From 5b078018c8f9545762ed9fb616f47820919ec519 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Fri, 2 Oct 2026 03:21:12 +0000 Subject: [PATCH 1/5] feat(agentlet): persist stable device identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentlet/packages/local/src/agentlet.ts | 16 +++-- .../packages/local/src/device-identity.ts | 66 +++++++++++++++++++ .../agentlet/packages/local/src/ws-client.ts | 2 +- .../packages/local/tests/agentlet.test.ts | 50 ++++++++++++-- .../local/tests/daemon-integration.test.ts | 8 ++- .../packages/protocol/src/messages.ts | 2 + 6 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 external/agentlet/packages/local/src/device-identity.ts diff --git a/external/agentlet/packages/local/src/agentlet.ts b/external/agentlet/packages/local/src/agentlet.ts index 8b2ce5865..813db98a3 100644 --- a/external/agentlet/packages/local/src/agentlet.ts +++ b/external/agentlet/packages/local/src/agentlet.ts @@ -1,4 +1,4 @@ -import { hostname, platform } from 'node:os' +import { arch, hostname, platform } from 'node:os' import { join, resolve } from 'node:path' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import WebSocket from 'ws' @@ -31,6 +31,7 @@ import { type SessionProfile, } from './session-bootstrap.js' import type { AgentletOptions } from './cli.js' +import { resolveDeviceIdentity } from './device-identity.js' interface ManagedAgent { sessionId: string @@ -57,9 +58,9 @@ const EARLY_MESSAGE_BUFFER_CAP = 1000 export function resolveAgentletId( configuredId: string | undefined, - machineHostname = hostname(), + identityPath?: string, ): string { - return configuredId?.trim() || machineHostname + return configuredId?.trim() || resolveDeviceIdentity(identityPath) } /** @@ -74,6 +75,11 @@ export class Agentlet { private shutdownInProgress = false private readonly daemonId: string + private readonly machine = { + hostname: hostname(), + platform: platform(), + arch: arch(), + } private controlWs: WebSocket | null = null private readonly agents = new Map() private pendingSpawns = 0 @@ -201,7 +207,7 @@ export class Agentlet { private sendDaemonHello(): void { const agentletProfile: AgentletProfile = { bridge: { name: 'agentlet', version: PROTOCOL_VERSION }, - machine: { hostname: this.daemonId, platform: platform() }, + machine: this.machine, capabilities: { autoRestart: true, bufferLimit: this.options.bufferLimit, @@ -597,7 +603,7 @@ export class Agentlet { capabilities: { autoRestart, bufferLimit: this.options.bufferLimit }, heartbeatInterval: this.options.heartbeat, allowInsecure: this.options.allowInsecure, - machine: { hostname: this.daemonId, platform: platform() }, + machine: this.machine, }) managed.ws = agentWs diff --git a/external/agentlet/packages/local/src/device-identity.ts b/external/agentlet/packages/local/src/device-identity.ts new file mode 100644 index 000000000..2d2bf1659 --- /dev/null +++ b/external/agentlet/packages/local/src/device-identity.ts @@ -0,0 +1,66 @@ +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { randomUUID } from 'node:crypto' + +interface DeviceIdentityFile { + version: 1 + deviceId: string +} + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +export function defaultDeviceIdentityPath(): string { + return join(homedir(), '.agentlet', 'device.json') +} + +function parseDeviceIdentity(path: string): string { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `Agentlet device identity is unreadable at ${path}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + if ( + !parsed || + typeof parsed !== 'object' || + (parsed as Partial).version !== 1 || + typeof (parsed as Partial).deviceId !== 'string' || + !UUID_PATTERN.test((parsed as DeviceIdentityFile).deviceId) + ) { + throw new Error(`Agentlet device identity is invalid at ${path}`) + } + return (parsed as DeviceIdentityFile).deviceId +} + +export function resolveDeviceIdentity( + path = defaultDeviceIdentityPath(), +): string { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + const deviceId = randomUUID() + let descriptor: number | undefined + try { + descriptor = openSync(path, 'wx', 0o600) + writeFileSync( + descriptor, + `${JSON.stringify({ version: 1, deviceId } satisfies DeviceIdentityFile)}\n`, + 'utf8', + ) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } finally { + if (descriptor !== undefined) closeSync(descriptor) + } + return parseDeviceIdentity(path) +} diff --git a/external/agentlet/packages/local/src/ws-client.ts b/external/agentlet/packages/local/src/ws-client.ts index 9a2dc2bfc..98c201453 100644 --- a/external/agentlet/packages/local/src/ws-client.ts +++ b/external/agentlet/packages/local/src/ws-client.ts @@ -33,7 +33,7 @@ export interface WsClientOptions { capabilities: { autoRestart: boolean; bufferLimit: number; maxAgents?: number } heartbeatInterval?: number allowInsecure?: boolean - machine?: { hostname: string; platform: string } + machine?: { hostname: string; platform: string; arch: string } } export interface WsClientEvents { diff --git a/external/agentlet/packages/local/tests/agentlet.test.ts b/external/agentlet/packages/local/tests/agentlet.test.ts index 77a354913..74a4d278a 100644 --- a/external/agentlet/packages/local/tests/agentlet.test.ts +++ b/external/agentlet/packages/local/tests/agentlet.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' import { buildAgentProcessEnv, @@ -7,9 +10,33 @@ import { import { parseCli } from '../src/cli.js' describe('agentlet daemon identity', () => { - it('uses the current machine hostname when no identity is injected', () => { - expect(resolveAgentletId(undefined, 'machine-a')).toBe('machine-a') - expect(resolveAgentletId(undefined, 'machine-b')).toBe('machine-b') + const directories: string[] = [] + + afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + function identityPath(): string { + const directory = mkdtempSync(join(tmpdir(), 'agentlet-device-')) + directories.push(directory) + return join(directory, 'device.json') + } + + it('creates and reuses one persisted UUID by default', () => { + const path = identityPath() + const first = resolveAgentletId(undefined, path) + const second = resolveAgentletId(undefined, path) + + expect(first).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ) + expect(second).toBe(first) + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ + version: 1, + deviceId: first, + }) }) it('accepts an explicit identity from the supervising host', () => { @@ -31,6 +58,21 @@ describe('agentlet daemon identity', () => { }) }) + it('does not read or rewrite the persisted default for an explicit override', () => { + const path = identityPath() + expect(resolveAgentletId('custom-id', path)).toBe('custom-id') + expect(() => readFileSync(path)).toThrow() + }) + + it('fails instead of rotating a damaged persisted identity', () => { + const path = identityPath() + writeFileSync(path, '{"version":1,"deviceId":"broken"}') + + expect(() => resolveAgentletId(undefined, path)).toThrow( + 'Agentlet device identity is invalid', + ) + }) + it.each(['0', '-1', '1.5', 'Infinity', '9007199254740992', '10agents'])( 'rejects invalid max-agents value %s', (maxAgents) => { diff --git a/external/agentlet/packages/local/tests/daemon-integration.test.ts b/external/agentlet/packages/local/tests/daemon-integration.test.ts index b19197e07..8a80c99bc 100644 --- a/external/agentlet/packages/local/tests/daemon-integration.test.ts +++ b/external/agentlet/packages/local/tests/daemon-integration.test.ts @@ -1,4 +1,5 @@ import { createServer, type Server } from 'node:http' +import { arch, hostname, platform } from 'node:os' import { mkdirSync, rmSync, writeFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -137,7 +138,7 @@ describe('agentlet daemon integration', () => { expect(controlHello).toMatchObject({ agentletId: 'machine-a', agentletProfile: { - machine: { hostname: 'machine-a' }, + machine: { hostname: hostname(), platform: platform(), arch: arch() }, capabilities: { harnessDiscovery: { version: 1 } }, }, }) @@ -163,7 +164,10 @@ describe('agentlet daemon integration', () => { await waitUntil(() => sessionHello !== undefined) expect(sessionHello).toMatchObject({ sessionId: 'native-bootstrap', - sessionProfile: { agentletId: 'machine-a', machine: { hostname: 'machine-a' } }, + sessionProfile: { + agentletId: 'machine-a', + machine: { hostname: hostname(), platform: platform(), arch: arch() }, + }, }) await waitUntil(() => sessionMessages.some( (message) => 'method' in message && message.method === 'session/update', diff --git a/external/agentlet/packages/protocol/src/messages.ts b/external/agentlet/packages/protocol/src/messages.ts index 28b5c7dab..4029320e1 100644 --- a/external/agentlet/packages/protocol/src/messages.ts +++ b/external/agentlet/packages/protocol/src/messages.ts @@ -15,6 +15,7 @@ export interface AgentletProfile { machine?: { hostname: string platform: string + arch: string } /** Agentlet capabilities */ @@ -72,6 +73,7 @@ export interface SessionProfile { machine?: { hostname: string platform: string + arch: string } /** Agentlet capabilities */ From b6eff770f617d2c9c7aea185d0c0363dd85bc59b Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Fri, 2 Oct 2026 03:22:12 +0000 Subject: [PATCH 2/5] refactor(agenetes): remove supervised placement identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/acp-driver/src/driver.test.ts | 13 ++++++--- .../packages/acp-driver/src/handle.ts | 11 ++++++-- .../packages/acp-driver/src/placement.test.ts | 5 ---- .../acp-driver/src/spawn-orchestrator.ts | 28 ++++--------------- .../packages/agentlet-host/src/daemon-auth.ts | 5 ---- .../agentlet-host/src/daemon-supervisor.ts | 7 ----- .../agentlet-host/src/gateway-mount.test.ts | 4 +-- .../packages/agentlet-host/src/index.ts | 13 +-------- 8 files changed, 26 insertions(+), 60 deletions(-) diff --git a/external/agenetes/packages/acp-driver/src/driver.test.ts b/external/agenetes/packages/acp-driver/src/driver.test.ts index 337761c08..a31a7d1f0 100644 --- a/external/agenetes/packages/acp-driver/src/driver.test.ts +++ b/external/agenetes/packages/acp-driver/src/driver.test.ts @@ -5,7 +5,6 @@ // keyed by `workload.threadId` (I9.3). The driver payload is nested under // `workload.spec` and validated by the mounted driver. -import { getSupervisedAgentletId } from '@agenetes/agentlet-host'; import { describe, expect, it } from 'vitest'; import { acpDriverFactory } from './driver.js'; @@ -104,7 +103,7 @@ describe('acpDriverFactory (M5 FACTORY)', () => { ).rejects.toThrow(); }); - it('preserves explicit placement and resolves legacy specs without mutation', () => { + it('preserves explicit placement and rejects legacy specs without placement', () => { const explicit: AcpCreateSpec = { kind: 'acp', workloadType: 'Deployment', @@ -124,7 +123,9 @@ describe('acpDriverFactory (M5 FACTORY)', () => { }; expect(resolveAcpAgentletId(explicit)).toBe('machine-b'); - expect(resolveAcpAgentletId(legacy)).toBe(getSupervisedAgentletId()); + expect(() => resolveAcpAgentletId(legacy)).toThrow( + 'The workload has no Agentlet placement.', + ); expect('agentletId' in legacy.spec).toBe(false); }); @@ -136,7 +137,10 @@ describe('acpDriverFactory (M5 FACTORY)', () => { workloadType: 'Deployment', threadId: 'thr_1', namespace: { name: 'canvas_1', storage: { root: '/data/c1' } }, - spec: { binding: { alias: 'copilot', profileId: 'prof_1' } }, + spec: { + agentletId: 'machine-a', + binding: { alias: 'copilot', profileId: 'prof_1' }, + }, }, freshContext, ); @@ -188,6 +192,7 @@ describe('acpDriverFactory (M5 FACTORY)', () => { threadId: 'thr_2', namespace: { name: 'canvas_1', storage: { root: '/data/c1' } }, spec: { + agentletId: 'machine-a', binding: { alias: 'claude', profileId: 'prof_2' }, cwd: '/work', recipe: null, diff --git a/external/agenetes/packages/acp-driver/src/handle.ts b/external/agenetes/packages/acp-driver/src/handle.ts index 07b21a611..986fd35fd 100644 --- a/external/agenetes/packages/acp-driver/src/handle.ts +++ b/external/agenetes/packages/acp-driver/src/handle.ts @@ -33,7 +33,6 @@ import { randomUUID } from 'node:crypto'; -import { getSupervisedAgentletId } from '@agenetes/agentlet-host'; import { resolveAgentInputs } from '@agenetes/protocol'; import { HistoryLoadDeniedError, @@ -236,9 +235,15 @@ export async function resolveAcpRuntimeLaunch( }; } -/** Resolve explicit placement or the read-only legacy local fallback. */ +/** Resolve the immutable execution-node placement stored in the workload. */ export function resolveAcpAgentletId(spec: AcpCreateSpec): string { - return spec.spec.agentletId ?? getSupervisedAgentletId(); + if (!spec.spec.agentletId) { + throw new AcpServiceError( + 'placement_unavailable', + 'The workload has no Agentlet placement.', + ); + } + return spec.spec.agentletId; } /** The per-turn context an {@link AcpAgentHandle.run} accepts. */ diff --git a/external/agenetes/packages/acp-driver/src/placement.test.ts b/external/agenetes/packages/acp-driver/src/placement.test.ts index 00e5d6cc1..9b08f2aca 100644 --- a/external/agenetes/packages/acp-driver/src/placement.test.ts +++ b/external/agenetes/packages/acp-driver/src/placement.test.ts @@ -10,11 +10,6 @@ vi.mock('@agenetes/agentlet-host', async (importOriginal) => { return { ...actual, getAgentletGateway: () => host.gateway, - getSupervisedAgentletId: () => 'machine-a', - getDaemonSupervisor: () => ({ - getStatus: () => ({ online: false }), - hasGivenUp: () => false, - }), }; }); diff --git a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts index a3b7a3dee..6571149ba 100644 --- a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts +++ b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts @@ -39,9 +39,7 @@ import { AgentletRequestError, - getDaemonSupervisor, getAgentletGateway, - getSupervisedAgentletId, } from '@agenetes/agentlet-host'; import { harnessLaunchPlanSchema, @@ -71,10 +69,8 @@ export function isSessionResumeUnavailableError(error: unknown): boolean { * Gatekeeper systems. Subsequent launches are usually subsecond * because the OS has the files cached. * - * We still short-circuit the wait as soon as the supervisor reports - * `hasGivenUp()` (agentlet entry missing, repeated crashes, …) so a - * truly broken install does not make every UI affordance hang for - * the full window. + * Placement is independent from process supervision, so every target + * receives the same connection grace period. */ const AGENTLET_READY_TIMEOUT_MS = 20_000; @@ -109,20 +105,15 @@ function readTargetAgentlet(agentletId: string): { agentletId: string } | null { /** * Poll {@link readTargetAgentlet} until the target agentlet is online or * `timeoutMs` elapses. Returns the resolved descriptor or `null` on - * timeout (or as soon as the supervisor has stopped trying). + * timeout. */ async function waitForTargetAgentlet( agentletId: string, timeoutMs: number, ): Promise<{ agentletId: string } | null> { const deadline = Date.now() + timeoutMs; - const supervisor = getDaemonSupervisor(); - const supervisedAgentletId = getSupervisedAgentletId(); let agentlet = readTargetAgentlet(agentletId); while (!agentlet && Date.now() < deadline) { - if (agentletId === supervisedAgentletId && supervisor.hasGivenUp()) { - return null; - } await new Promise((r) => setTimeout(r, 100)); agentlet = readTargetAgentlet(agentletId); } @@ -178,10 +169,10 @@ async function waitForAgentConnection( * Cold-start tolerance: we wait up to {@link AGENTLET_READY_TIMEOUT_MS} * for the agentlet to come online and up to 3 s for the freshly-spawned * agent to finish its handshake. Only after both windows expire (or the - * supervisor reports it has given up) do we surface a user-facing error. + * target stays unavailable) do we surface a user-facing error. * * Throws when: - * • the supervisor never brings the agentlet online (truly offline), + * • the target agentlet does not come online, * • the agentlet RPC for spawn fails. * * Idempotent within a single agentlet's lifetime — repeat calls for @@ -208,16 +199,9 @@ export async function ensureAgentForThread( AGENTLET_READY_TIMEOUT_MS, ); if (!agentlet) { - const supervisorStatus = - agentletId === getSupervisedAgentletId() - ? getDaemonSupervisor().getStatus() - : null; - const hint = supervisorStatus?.lastError - ? ` (${supervisorStatus.lastError})` - : ''; throw new AcpServiceError( 'placement_unavailable', - `Target agentlet '${agentletId}' is not connected${hint}.`, + `Target agentlet '${agentletId}' is not connected.`, ); } diff --git a/external/agenetes/packages/agentlet-host/src/daemon-auth.ts b/external/agenetes/packages/agentlet-host/src/daemon-auth.ts index a6dd2619a..5d91122f6 100644 --- a/external/agenetes/packages/agentlet-host/src/daemon-auth.ts +++ b/external/agenetes/packages/agentlet-host/src/daemon-auth.ts @@ -55,11 +55,6 @@ class AcpDaemonAuth { this.token = token; } - /** Configure the identity and token accepted for the supervised daemon. */ - configure(_agentletId: string, token: string): void { - this.token = token; - } - /** * Mint and store a fresh 256-bit hex token. Retained for tests / * fallback; the production path injects a stable token via diff --git a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts index 324b4c1d7..823042a92 100644 --- a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts +++ b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts @@ -48,7 +48,6 @@ import { fork } from 'node:child_process'; import { existsSync, unlinkSync } from 'node:fs'; -import { hostname } from 'node:os'; import { join } from 'node:path'; import { getDaemonAuth } from './daemon-auth.js'; @@ -230,8 +229,6 @@ export interface AttachOptions { daemonEntryPath: string; /** Absolute directory for host-owned persistent state. */ dataDir: string; - /** Machine identity shared by the daemon and Gateway authenticator. */ - agentletId?: string; /** Resolve the host-owned process limit each time the daemon starts. */ getMaxAgents?: () => number; /** @@ -300,7 +297,6 @@ class DaemonSupervisor { * {@link attach} time. Used only for legacy-ticket cleanup here. */ private dataDir = ''; - private agentletId = ''; private getMaxAgents: (() => number) | undefined; private hostEnvPrefix: string | undefined; private hostEnvAllowlist: readonly string[] | undefined; @@ -314,7 +310,6 @@ class DaemonSupervisor { this.app = app; this.daemonEntryPath = opts.daemonEntryPath; this.dataDir = opts.dataDir; - this.agentletId = opts.agentletId ?? hostname(); this.getMaxAgents = opts.getMaxAgents; this.hostEnvPrefix = opts.hostEnvPrefix; this.hostEnvAllowlist = opts.hostEnvAllowlist; @@ -467,8 +462,6 @@ class DaemonSupervisor { serverUrl, '--token', token, - '--agentlet-id', - this.agentletId, ...(maxAgents === undefined ? [] : ['--max-agents', String(maxAgents)]), '--allow-insecure', ]; diff --git a/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts b/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts index 47b577363..083e15cc4 100644 --- a/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts +++ b/external/agenetes/packages/agentlet-host/src/gateway-mount.test.ts @@ -23,7 +23,7 @@ afterEach(async () => { describe('Agentlet Gateway mount', () => { it('accepts the shared token from another machine identity', () => { const auth = getDaemonAuth(); - auth.configure('machine-a', 'test-token'); + auth.setDaemonToken('test-token'); expect(() => auth.validateAgentlet('machine-b', 'test-token'), @@ -31,7 +31,7 @@ describe('Agentlet Gateway mount', () => { }); it('authenticates the supervised identity and closes upgraded sockets', async () => { - getDaemonAuth().configure('machine-a', 'test-token'); + getDaemonAuth().setDaemonToken('test-token'); app = Fastify({ logger: false }); const gateway = mountAgentletGateway(app, {}); await app.listen({ host: '127.0.0.1', port: 0 }); diff --git a/external/agenetes/packages/agentlet-host/src/index.ts b/external/agenetes/packages/agentlet-host/src/index.ts index 52188ba93..f0a3c4d88 100644 --- a/external/agenetes/packages/agentlet-host/src/index.ts +++ b/external/agenetes/packages/agentlet-host/src/index.ts @@ -15,8 +15,6 @@ * */ -import { hostname } from 'node:os'; - import { mountAgentProfileRegistry } from './agent-profile-mount.js'; import { getDaemonAuth } from './daemon-auth.js'; import { getDaemonSupervisor } from './daemon-supervisor.js'; @@ -30,13 +28,6 @@ import type { } from '@agenetes/agentlet-gateway'; import type { FastifyInstance } from 'fastify'; -const supervisedAgentletId = hostname(); - -/** Machine identity used by Sediment's supervised local daemon. */ -export function getSupervisedAgentletId(): string { - return supervisedAgentletId; -} - export { getAgentProfileRegistry } from './agent-profile-mount.js'; export { ACP_UPGRADE_PATH, @@ -135,8 +126,7 @@ export function mountAgenetes( app: FastifyInstance, opts: MountAgenetesOptions, ): AgentletGateway { - const agentletId = getSupervisedAgentletId(); - getDaemonAuth().configure(agentletId, opts.connectionToken); + getDaemonAuth().setDaemonToken(opts.connectionToken); const gateway = mountAgentletGateway(app, { authenticate: opts.authenticate, @@ -149,7 +139,6 @@ export function mountAgenetes( getDaemonSupervisor().attach(app, { daemonEntryPath: opts.daemonEntryPath, dataDir: opts.dataDir, - agentletId, getMaxAgents: opts.getMaxAgents, hostEnvPrefix: opts.hostEnvPrefix, hostEnvAllowlist: opts.hostEnvAllowlist, From edefe4c7028e57bdc2c7c6ce3b5d81e174f19a44 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Fri, 2 Oct 2026 03:31:55 +0000 Subject: [PATCH 3/5] fix(agentlet): publish device identity atomically Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/agentlet/README.md | 2 +- external/agentlet/packages/local/src/cli.ts | 5 +++- .../packages/local/src/device-identity.ts | 23 +++++++++++++++++-- external/agentlet/spec/protocol.md | 2 +- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/external/agentlet/README.md b/external/agentlet/README.md index 6a77e8420..8d28f8fb5 100644 --- a/external/agentlet/README.md +++ b/external/agentlet/README.md @@ -61,7 +61,7 @@ Important options: | Option | Meaning | | --- | --- | -| `--agentlet-id ` | Stable execution-node identity; defaults to the machine hostname. | +| `--agentlet-id ` | Exact execution-node identity override; the default is the persistent UUID in `~/.agentlet/device.json`. | | `--max-agents ` | Maximum number of concurrently managed agent processes. | | `--buffer-limit ` | Buffer capacity advertised in daemon and session profiles. | | `--reconnect-max ` | Maximum exponential reconnect delay. | diff --git a/external/agentlet/packages/local/src/cli.ts b/external/agentlet/packages/local/src/cli.ts index 7965d0bac..eebfcf2d6 100644 --- a/external/agentlet/packages/local/src/cli.ts +++ b/external/agentlet/packages/local/src/cli.ts @@ -43,7 +43,10 @@ export function parseCli(argv: string[]): CliResult { .option('--reconnect-max ', 'Maximum reconnection backoff in seconds', '300') .option('--buffer-limit ', 'Max messages buffered during disconnection', '1000') .option('--max-agents ', 'Maximum concurrent agents', '10') - .option('--agentlet-id ', 'Machine identity reported to the host (defaults to hostname)') + .option( + '--agentlet-id ', + 'Device identity reported to the host (defaults to the persisted device UUID)', + ) .option('--log-level ', 'Logging verbosity: debug, info, warn, error', 'info') .option('--log-file ', 'Path to write structured log output (JSON lines)') .option('--heartbeat ', 'WebSocket ping interval in seconds (0 to disable)', '30') diff --git a/external/agentlet/packages/local/src/device-identity.ts b/external/agentlet/packages/local/src/device-identity.ts index 2d2bf1659..2a37c097d 100644 --- a/external/agentlet/packages/local/src/device-identity.ts +++ b/external/agentlet/packages/local/src/device-identity.ts @@ -1,12 +1,16 @@ import { closeSync, + existsSync, + fsyncSync, + linkSync, mkdirSync, openSync, readFileSync, + unlinkSync, writeFileSync, } from 'node:fs' import { homedir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' interface DeviceIdentityFile { @@ -48,19 +52,34 @@ export function resolveDeviceIdentity( path = defaultDeviceIdentityPath(), ): string { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + if (existsSync(path)) return parseDeviceIdentity(path) + const deviceId = randomUUID() + const temporaryPath = join( + dirname(path), + `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`, + ) let descriptor: number | undefined try { - descriptor = openSync(path, 'wx', 0o600) + descriptor = openSync(temporaryPath, 'wx', 0o600) writeFileSync( descriptor, `${JSON.stringify({ version: 1, deviceId } satisfies DeviceIdentityFile)}\n`, 'utf8', ) + fsyncSync(descriptor) + closeSync(descriptor) + descriptor = undefined + linkSync(temporaryPath, path) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error } finally { if (descriptor !== undefined) closeSync(descriptor) + try { + unlinkSync(temporaryPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } } return parseDeviceIdentity(path) } diff --git a/external/agentlet/spec/protocol.md b/external/agentlet/spec/protocol.md index feb0d053b..77960208e 100644 --- a/external/agentlet/spec/protocol.md +++ b/external/agentlet/spec/protocol.md @@ -155,7 +155,7 @@ The daemon uses bounded FIFO buffers for ACP notifications emitted during bootst ## 7. Identity and placement -The daemon's `agentletId` defaults to the operating-system hostname and can be supplied explicitly with `--agentlet-id`. The same identity appears in the control query, `agentlet/hello`, session query context, and `sessionProfile.agentletId`. +The daemon's `agentletId` defaults to the persistent UUID in `~/.agentlet/device.json` and can be supplied explicitly with `--agentlet-id`. The identity file is created atomically on first use; an invalid existing file is an error rather than a reason to rotate identity. An explicit override does not rewrite the default identity. The same identity appears in the control query, `agentlet/hello`, session query context, and `sessionProfile.agentletId`; hostname, platform, and architecture are separate informational metadata. The native ACP `sessionId` is established by session bootstrap and is the routing identity for one session connection. The embedding control plane selects the target `agentletId`; the daemon does not choose workload placement. From 12acb3d64de5e64b9603648c5d6e777f0f9d3821 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Fri, 2 Oct 2026 03:31:55 +0000 Subject: [PATCH 4/5] fix(agenetes): isolate supervisor health status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentlet-host/src/daemon-supervisor.ts | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts index 823042a92..6dc6e200e 100644 --- a/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts +++ b/external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts @@ -31,10 +31,10 @@ * * ### Status reporting * - * `getDaemonStatus()` combines the supervisor's view (last error, - * backoff schedule) with the Gateway's view (is a daemon - * actually registered right now?). The UI uses the merged snapshot - * to decide whether to show the amber troubleshooting block. + * `getDaemonStatus()` reports only the supervised child lifecycle. + * Connected Agentlet devices are projected separately by the host, + * because the child owns its device identity and the supervisor must + * not infer it from an arbitrary Gateway connection. * * ### Entry resolution * @@ -51,7 +51,6 @@ import { existsSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { getDaemonAuth } from './daemon-auth.js'; -import { getAgentletGateway } from './gateway-mount.js'; import type { AgentletStatus } from '@agenetes/protocol'; import type { FastifyInstance } from 'fastify'; @@ -404,24 +403,9 @@ class DaemonSupervisor { getDaemonAuth().close(); } - /** - * Merge the supervisor's view with the Gateway's daemon - * registry to produce the wire snapshot consumed by the UI. - */ + /** Project the supervised child lifecycle for the UI health surface. */ getStatus(): AgentletStatus { - const gateway = getAgentletGateway(); - const live = gateway?.getAgentlets({ status: 'connected' }) ?? []; - const agentlet = live[0]; - - if (agentlet) { - return { - online: true, - agentletId: agentlet.agentletId, - hostname: agentlet.agentletProfile?.machine?.hostname, - platform: agentlet.agentletProfile?.machine?.platform, - connectedAt: agentlet.connectedAt.toISOString(), - }; - } + if (this.state.child && !this.state.child.killed) return { online: true }; const status: AgentletStatus = { online: false }; if (this.state.lastError) status.lastError = this.state.lastError; From 371021a95ca0a9924b05f396ecb1f1f1fec82a73 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Fri, 2 Oct 2026 03:33:03 +0000 Subject: [PATCH 5/5] feat(agent): manage connected Agentlet devices Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/app.ts | 15 +-- .../modules/agent/acp/agent-cli.route.test.ts | 16 ++-- .../src/modules/agent/acp/agent-cli.route.ts | 29 +++--- .../src/modules/agent/acp/daemon-auth.test.ts | 2 +- apps/server/src/modules/agent/acp/index.ts | 1 - .../acp/legacy-profile-migration.test.ts | 75 --------------- .../agent/acp/legacy-profile-migration.ts | 34 ------- .../modules/agent/acp/profile-store.test.ts | 74 --------------- .../src/modules/agent/acp/profile-store.ts | 64 ------------- .../modules/agent/acp/profiles.route.test.ts | 41 +++++++- .../src/modules/agent/acp/profiles.route.ts | 75 +++++++++++++-- apps/server/src/modules/agent/acp/service.ts | 28 +++--- .../agent/acp/service.workload-spec.test.ts | 11 ++- .../acp/threads.route.cached-meta.test.ts | 12 +-- .../modules/agent/acp/threads.route.test.ts | 4 - .../src/modules/agent/acp/threads.route.ts | 9 +- .../agent/selectable-agent-profile.test.ts | 36 ++++++- .../modules/agent/selectable-agent-profile.ts | 32 ++++++- apps/web/src/api/_routes.ts | 8 +- apps/web/src/api/acp.test.ts | 8 +- apps/web/src/api/acp.ts | 11 ++- .../AgentProfileEditor.test.tsx | 37 +++++++- .../agent-profiles/AgentProfileEditor.tsx | 12 ++- .../agent-profiles/CommandProfileForm.tsx | 53 +++++++++-- .../ExternalAgentsSettings.test.tsx | 16 +++- .../agent-profiles/ExternalAgentsSettings.tsx | 95 ++++++++++++++++--- .../agent-profiles/useDetectedClis.test.tsx | 23 +++-- .../agent-profiles/useDetectedClis.ts | 21 ++-- apps/web/src/i18n/resources/en/common.json | 6 ++ apps/web/src/i18n/resources/zh-CN/common.json | 6 ++ apps/web/src/store/acpProfilesStore.test.ts | 2 + apps/web/src/store/acpProfilesStore.ts | 10 +- docs/architecture/agent-profiles.md | 17 ++-- docs/architecture/api-design.md | 2 + ...agenetes-agentlet-gateway-consolidation.md | 70 +++++++------- packages/shared/src/types/api/acp.ts | 28 +++++- .../src/types/api/agent-profile.test.ts | 13 +++ .../shared/src/types/api/agent-profile.ts | 11 ++- 38 files changed, 569 insertions(+), 438 deletions(-) delete mode 100644 apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts delete mode 100644 apps/server/src/modules/agent/acp/legacy-profile-migration.ts delete mode 100644 apps/server/src/modules/agent/acp/profile-store.test.ts delete mode 100644 apps/server/src/modules/agent/acp/profile-store.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 96758aa79..231aaeffe 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { existsSync, unlinkSync } from 'node:fs'; +import { unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -25,13 +25,10 @@ import { externalAgentRuntimeConfigRoutes, getExternalAgentRuntimeConfig, getAgentProfileRegistry, - getSupervisedAgentletId, installAcpProfileCachePort, mountAgenetes, resolveDaemonEntry, } from './modules/agent/acp/index.js'; -import { buildLegacyCommandProfiles } from './modules/agent/acp/legacy-profile-migration.js'; -import { listProfiles as listLegacyAcpProfiles } from './modules/agent/acp/profile-store.js'; import { initializeAgentDefaults } from './modules/agent/agent-defaults.js'; import agentDefaultsRoutes from './modules/agent/agent-defaults.route.js'; import agentRoutes from './modules/agent/agent.route.js'; @@ -317,15 +314,7 @@ const agentletGateway = mountAgenetes(app, { profiles: { storageDir: join(getDataDir(), 'agent-profiles'), legacyStorageDir: join(getDataDir(), 'agent-team'), - legacyCommandProfiles: existsSync( - join(getDataDir(), 'agent-profiles', 'registry.json'), - ) - ? [] - : buildLegacyCommandProfiles( - listLegacyAcpProfiles(), - getSupervisedAgentletId(), - process.cwd(), - ), + legacyCommandProfiles: [], }, }); let unregisterHarnessDiscovery: (() => void) | undefined; diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts index a02f85b31..a8b3aae5c 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts @@ -12,8 +12,10 @@ import type { FastifyInstance } from 'fastify'; const mocks = vi.hoisted(() => ({ getProfile: vi.fn(), discover: vi.fn() })); vi.mock('@agenetes/agentlet-host', () => ({ getAgentProfileRegistry: () => ({ getProfile: mocks.getProfile }), - getSupervisedAgentletId: () => 'supervised', - getAgentletGateway: () => ({ discoverHarnesses: mocks.discover }), + getAgentletGateway: () => ({ + getAgentlet: () => ({ status: 'connected' }), + discoverHarnesses: mocks.discover, + }), })); let app: FastifyInstance | undefined; @@ -52,7 +54,7 @@ describe('ACP agent CLI route', () => { const response = await app.inject({ method: 'GET', - url: '/api/acp/agent-cli', + url: '/api/acp/agent-cli?agentletId=machine-a', }); expect(response.statusCode).toBe(200); @@ -72,7 +74,7 @@ describe('ACP agent CLI route', () => { const response = await app.inject({ method: 'GET', - url: '/api/acp/agent-cli', + url: '/api/acp/agent-cli?agentletId=machine-a', remoteAddress: '192.0.2.10', }); @@ -92,7 +94,7 @@ describe('ACP agent CLI route', () => { const response = await app.inject({ method: 'GET', - url: '/api/acp/agent-cli', + url: '/api/acp/agent-cli?agentletId=machine-a', remoteAddress: '192.0.2.10', }); @@ -108,7 +110,9 @@ describe('ACP agent CLI route', () => { ), { prefix: '/api/acp' }, ); - const response = await app.inject('/api/acp/agent-cli'); + const response = await app.inject( + '/api/acp/agent-cli?agentletId=machine-a', + ); expect(response.statusCode).toBe(503); expect(response.json().code).toBe('harness_discovery_unavailable'); expect(response.json()).not.toHaveProperty('agents'); diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.ts b/apps/server/src/modules/agent/acp/agent-cli.route.ts index 116e1915a..24d053434 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.ts @@ -17,7 +17,6 @@ import { getAgentletGateway, - getSupervisedAgentletId, getAgentProfileRegistry, } from '@agenetes/agentlet-host'; import { @@ -36,19 +35,25 @@ import type { } from '@huabu/shared'; import type { FastifyPluginAsync } from 'fastify'; -async function detectAgentClis(profileId?: string): Promise { +async function detectAgentClis(target: { + profileId?: string; + agentletId?: string; +}): Promise { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - const profile = profileId - ? getAgentProfileRegistry()?.getProfile(profileId) + const profile = target.profileId + ? getAgentProfileRegistry()?.getProfile(target.profileId) : undefined; - if (profileId && !profile) throw new Error('Agent Profile is unavailable'); - const result = await gateway.discoverHarnesses( - profile?.agentletId ?? getSupervisedAgentletId(), - { - prepareWorkspaces: false, - }, - ); + if (target.profileId && !profile) + throw new Error('Agent Profile is unavailable'); + const agentletId = profile?.agentletId ?? target.agentletId; + if (!agentletId) throw new Error('Agentlet target is required'); + const connection = gateway.getAgentlet(agentletId); + if (connection?.status !== 'connected') + throw new Error('Agentlet is not connected'); + const result = await gateway.discoverHarnesses(agentletId, { + prepareWorkspaces: false, + }); if (result.harnesses.some((entry) => entry.id === CUSTOM_COMMAND_WRAPPER_ID)) return result.harnesses; return [ @@ -96,7 +101,7 @@ export function createAcpAgentCliRoutes( message: 'Agent Profile is unavailable', }); } - return { agents: await detect(parsed.data.profileId) }; + return { agents: await detect(parsed.data) }; } catch (error) { request.log.warn( { err: error }, diff --git a/apps/server/src/modules/agent/acp/daemon-auth.test.ts b/apps/server/src/modules/agent/acp/daemon-auth.test.ts index 7fad62a5c..975015cea 100644 --- a/apps/server/src/modules/agent/acp/daemon-auth.test.ts +++ b/apps/server/src/modules/agent/acp/daemon-auth.test.ts @@ -32,7 +32,7 @@ function makeAgentletHello(): AgentletHelloParams { agentletId: 'test:agentlet', agentletProfile: { bridge: { name: 'agentlet', version: '1.0.0' }, - machine: { hostname: 'test', platform: 'linux' }, + machine: { hostname: 'test', platform: 'linux', arch: 'x64' }, capabilities: { autoRestart: true, bufferLimit: 1000 }, }, }; diff --git a/apps/server/src/modules/agent/acp/index.ts b/apps/server/src/modules/agent/acp/index.ts index 45b29ac2e..e2d0430e9 100644 --- a/apps/server/src/modules/agent/acp/index.ts +++ b/apps/server/src/modules/agent/acp/index.ts @@ -4,7 +4,6 @@ export { mountAgenetes, getAgentProfileRegistry, - getSupervisedAgentletId, ACP_UPGRADE_PATH, } from '@agenetes/agentlet-host'; export type { diff --git a/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts b/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts deleted file mode 100644 index 2b8afabb5..000000000 --- a/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { describe, expect, it } from 'vitest'; - -import { buildLegacyCommandProfiles } from './legacy-profile-migration.js'; - -import type { AcpAgentProfile } from '@huabu/shared'; - -function makeProfile( - overrides: Partial = {}, -): AcpAgentProfile { - return { - id: 'legacy-profile', - displayName: 'Legacy Profile', - cliId: 'copilot', - command: 'copilot --acp', - cwd: '/workspace', - autoRestart: true, - createdAt: 1, - updatedAt: 1, - ...overrides, - }; -} - -describe('buildLegacyCommandProfiles', () => { - it('preserves ordinary profile identity and launch fields', () => { - expect( - buildLegacyCommandProfiles( - [makeProfile()], - 'local-agentlet', - '/server-cwd', - ), - ).toEqual([ - { - id: 'legacy-profile', - alias: 'Legacy Profile', - agentletId: 'local-agentlet', - command: 'copilot --acp', - workingDirPath: '/workspace', - metadata: { cliId: 'copilot' }, - }, - ]); - }); - - it('uses the inherited host directory when an old profile omitted cwd', () => { - const [profile] = buildLegacyCommandProfiles( - [makeProfile({ cwd: undefined })], - 'local-agentlet', - '/server-cwd', - ); - - expect(profile?.workingDirPath).toBe('/server-cwd'); - }); - - it('leaves legacy Agent Team records unmigrated', () => { - expect( - buildLegacyCommandProfiles( - [makeProfile({ id: 'team', cliId: 'agent-team' })], - 'local-agentlet', - '/server-cwd', - ), - ).toEqual([]); - }); - - it('fails explicitly for an ordinary record without a command', () => { - expect(() => - buildLegacyCommandProfiles( - [makeProfile({ command: undefined })], - 'local-agentlet', - '/server-cwd', - ), - ).toThrow("Legacy command Profile 'legacy-profile' has no command"); - }); -}); diff --git a/apps/server/src/modules/agent/acp/legacy-profile-migration.ts b/apps/server/src/modules/agent/acp/legacy-profile-migration.ts deleted file mode 100644 index 474278013..000000000 --- a/apps/server/src/modules/agent/acp/legacy-profile-migration.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import type { CreateAcpCommandProfileInput } from '@agenetes/agent-profile'; -import type { AcpAgentProfile } from '@huabu/shared'; - -/** - * Convert spawnable legacy ACP profiles into unified command Profiles. - * - * Older records may omit `cwd`; the old launcher then inherited the host - * process directory, so migration makes that implicit behavior explicit. - */ -export function buildLegacyCommandProfiles( - profiles: AcpAgentProfile[], - agentletId: string, - defaultWorkingDir: string, -): CreateAcpCommandProfileInput[] { - return profiles.flatMap((profile) => { - if (profile.cliId === 'agent-team') return []; - if (!profile.command?.trim()) { - throw new Error(`Legacy command Profile '${profile.id}' has no command`); - } - return [ - { - id: profile.id, - alias: profile.displayName, - agentletId, - command: profile.command, - workingDirPath: profile.cwd ?? defaultWorkingDir, - metadata: { cliId: profile.cliId }, - }, - ]; - }); -} diff --git a/apps/server/src/modules/agent/acp/profile-store.test.ts b/apps/server/src/modules/agent/acp/profile-store.test.ts deleted file mode 100644 index cc70c2b81..000000000 --- a/apps/server/src/modules/agent/acp/profile-store.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { listProfiles } from './profile-store.js'; - -let directory: string; -let previousDataDir: string | undefined; - -const commandProfile = { - id: 'command', - displayName: 'Copilot', - cliId: 'copilot', - command: 'copilot --acp', - cwd: '/workspace', - autoRestart: true, - createdAt: 1, - updatedAt: 1, -}; - -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'huabu-legacy-profiles-')); - previousDataDir = process.env.HUABU_DATA_DIR; - process.env.HUABU_DATA_DIR = directory; -}); - -afterEach(() => { - if (previousDataDir === undefined) delete process.env.HUABU_DATA_DIR; - else process.env.HUABU_DATA_DIR = previousDataDir; - rmSync(directory, { recursive: true, force: true }); -}); - -describe('read-only legacy Profile import source', () => { - it('returns no records for an absent file', () => { - expect(listProfiles()).toEqual([]); - }); - - it('reads ordinary Profiles without rewriting retired Team data', () => { - const file = join(directory, 'agent-profiles.json'); - const original = JSON.stringify({ - schemaVersion: 1, - profiles: [ - commandProfile, - { - id: 'retired-team', - cliId: 'agent-team', - agentTeam: { agentDir: '/team' }, - }, - { ...commandProfile, id: 'team-disguised-as-command', agentTeam: {} }, - ], - }); - writeFileSync(file, original); - expect(listProfiles()).toEqual([commandProfile]); - expect(readFileSync(file, 'utf8')).toBe(original); - }); - - it.each([ - '{"profiles": []}', - '{"schemaVersion": 999, "profiles": []}', - '{"schemaVersion": 1, "profiles": [{}]}', - '{invalid', - ])( - 'rejects corrupt or unsupported data rather than silently losing it', - (text) => { - writeFileSync(join(directory, 'agent-profiles.json'), text); - expect(() => listProfiles()).toThrow(); - }, - ); -}); diff --git a/apps/server/src/modules/agent/acp/profile-store.ts b/apps/server/src/modules/agent/acp/profile-store.ts deleted file mode 100644 index 21437bc56..000000000 --- a/apps/server/src/modules/agent/acp/profile-store.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { acpAgentProfileSchema } from '@huabu/shared'; - -import { getDataDir } from '../../../data-dir.js'; -import { logger } from '../../../utils/logger.js'; - -import type { AcpAgentProfile } from '@huabu/shared'; - -/** Read the pre-registry command data once during migration; never rewrite it. */ -export function listProfiles(): AcpAgentProfile[] { - let text: string; - try { - text = readFileSync(join(getDataDir(), 'agent-profiles.json'), 'utf8'); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return []; - } - throw error; - } - const file: unknown = JSON.parse(text); - if ( - !file || - typeof file !== 'object' || - !('schemaVersion' in file) || - file.schemaVersion !== 1 || - !('profiles' in file) || - !Array.isArray(file.profiles) - ) { - throw new Error('Unsupported legacy command Profile file'); - } - - const profiles: AcpAgentProfile[] = []; - let retired = 0; - for (const raw of file.profiles) { - if ( - raw && - typeof raw === 'object' && - (raw.cliId === 'agent-team' || - Object.prototype.hasOwnProperty.call(raw, 'agentTeam')) - ) { - retired += 1; - continue; - } - const parsed = acpAgentProfileSchema.safeParse(raw); - if (!parsed.success) { - throw new Error( - `Invalid legacy command Profile: ${parsed.error.message}`, - ); - } - profiles.push(parsed.data); - } - if (retired) { - logger.warn( - { retired }, - '[acp] legacy Agent Team records retained as data only', - ); - } - return profiles.sort((left, right) => left.createdAt - right.createdAt); -} diff --git a/apps/server/src/modules/agent/acp/profiles.route.test.ts b/apps/server/src/modules/agent/acp/profiles.route.test.ts index bd206adb8..dab86c012 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.test.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ initializeDefaults: vi.fn(), discoverHarnesses: vi.fn(), buildHarnessLaunch: vi.fn(), + connectedIds: new Set(['machine-a', 'remote-machine']), })); vi.mock('../agent-defaults.js', () => ({ @@ -32,10 +33,23 @@ vi.mock('@agenetes/agentlet-host', () => ({ getDaemonSupervisor: () => ({ getStatus: () => ({ online: true, restartAttempt: 0 }), }), - getSupervisedAgentletId: () => 'machine-a', getAgentletGateway: () => ({ discoverHarnesses: mocks.discoverHarnesses, buildHarnessLaunch: mocks.buildHarnessLaunch, + getAgentlets: () => + [...mocks.connectedIds].map((agentletId) => ({ + agentletId, + status: 'connected', + connectedAt: new Date('2026-01-01T00:00:00.000Z'), + agentletProfile: { + bridge: { name: 'agentlet', version: '1.0.0' }, + machine: { + hostname: `${agentletId}-host`, + platform: 'linux', + arch: 'x64', + }, + }, + })), }), })); @@ -92,7 +106,12 @@ describe('ordinary Profile catalog routes', () => { const response = await server.inject({ method: 'POST', url: '/api/acp/profiles', - payload: { alias: 'Typed', workingDirPath: '/work', launch }, + payload: { + alias: 'Typed', + agentletId: 'machine-a', + workingDirPath: '/work', + launch, + }, }); expect(response.statusCode).toBe(200); expect(mocks.discoverHarnesses).toHaveBeenCalledWith('machine-a', { @@ -122,6 +141,7 @@ describe('ordinary Profile catalog routes', () => { url: '/api/acp/profiles', payload: { alias: 'Typed', + agentletId: 'machine-a', workingDirPath: '/work', launch: { kind: 'acp-harness', harnessId: 'copilot' }, }, @@ -139,6 +159,7 @@ describe('ordinary Profile catalog routes', () => { url: '/api/acp/profiles', payload: { alias: 'Copilot', + agentletId: 'machine-a', workingDirPath: '/work/project', launch: commandProfile.launch, metadata: { cliId: 'copilot' }, @@ -166,6 +187,16 @@ describe('ordinary Profile catalog routes', () => { expect(response.json()).toMatchObject({ profiles: [commandProfile], selectableProfileIds: ['command-1'], + connectedDevices: [ + expect.objectContaining({ + agentletId: 'machine-a', + profileCount: 1, + }), + expect.objectContaining({ + agentletId: 'remote-machine', + profileCount: 0, + }), + ], }); expect(mocks.registry.createProfile).not.toHaveBeenCalled(); expect(mocks.initializeDefaults).not.toHaveBeenCalled(); @@ -179,6 +210,7 @@ describe('ordinary Profile catalog routes', () => { url: '/api/acp/profiles', payload: { alias: 'Forged', + agentletId: 'machine-a', workingDirPath: '/work', launch: commandProfile.launch, customData: { discoveredAgent: source }, @@ -419,7 +451,10 @@ describe('ordinary Profile catalog routes', () => { const response = await server.inject({ method: 'POST', url: '/api/acp/profile-launch-preview', - payload: { launch: commandProfile.launch }, + payload: { + agentletId: 'machine-a', + launch: commandProfile.launch, + }, }); expect(response.statusCode).toBe(503); expect(response.json().code).toBe('harness_preview_unavailable'); diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts index cf06c373d..9e4fe6d3d 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.ts @@ -29,7 +29,6 @@ import { getAgentProfileRegistry, getAgentletGateway, getDaemonSupervisor, - getSupervisedAgentletId, } from '@agenetes/agentlet-host'; import { @@ -50,6 +49,7 @@ import { initializeAgentDefaults, } from '../agent-defaults.js'; +import type { AgentletConnection } from '@agenetes/agentlet-host'; import type { AcpProfileMutationResponse, AcpProfilesListResponse, @@ -68,6 +68,16 @@ function denyRemote(request: FastifyRequest, reply: FastifyReply): boolean { return true; } +function getConnectedAgentlets(): AgentletConnection[] { + return getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? []; +} + +function isAgentletConnected(agentletId: string): boolean { + return getConnectedAgentlets().some( + (connection) => connection.agentletId === agentletId, + ); +} + async function validateHarnessLaunch( launch: AgentProfileView['launch'], agentletId: string, @@ -146,10 +156,16 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { try { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - return await gateway.buildHarnessLaunch( - profile?.agentletId ?? getSupervisedAgentletId(), - { launch: parsed.data.launch }, - ); + const agentletId = profile?.agentletId ?? parsed.data.agentletId; + if (!agentletId || !isAgentletConnected(agentletId)) { + return reply.status(409).send({ + code: 'agentlet_unavailable', + message: 'The selected Agentlet is not connected.', + }); + } + return await gateway.buildHarnessLaunch(agentletId, { + launch: parsed.data.launch, + }); } catch (error) { request.log.warn({ err: error }, 'Profile launch preview failed'); return reply.status(503).send({ @@ -172,9 +188,46 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { code: 'profile_registry_unavailable', }); } + const profiles = registry.listProfiles(); + const connected = getConnectedAgentlets(); + const connectedIds = new Set( + connected.map((connection) => connection.agentletId), + ); + const profileCounts = new Map(); + for (const profile of profiles) { + profileCounts.set( + profile.agentletId, + (profileCounts.get(profile.agentletId) ?? 0) + 1, + ); + } + const connectedDevices = connected + .map((connection) => ({ + agentletId: connection.agentletId, + ...(connection.agentletProfile?.machine?.hostname + ? { hostname: connection.agentletProfile.machine.hostname } + : {}), + ...(connection.agentletProfile?.machine?.platform + ? { platform: connection.agentletProfile.machine.platform } + : {}), + ...(connection.agentletProfile?.machine?.arch + ? { arch: connection.agentletProfile.machine.arch } + : {}), + version: connection.agentletProfile?.bridge.version ?? 'unknown', + connectedAt: connection.connectedAt.toISOString(), + profileCount: profileCounts.get(connection.agentletId) ?? 0, + })) + .sort( + (left, right) => + (left.hostname ?? left.agentletId).localeCompare( + right.hostname ?? right.agentletId, + ) || left.agentletId.localeCompare(right.agentletId), + ); return { - profiles: registry.listProfiles(), - selectableProfileIds: registry.listSelectableProfileIds(), + profiles, + selectableProfileIds: profiles + .filter((profile) => connectedIds.has(profile.agentletId)) + .map((profile) => profile.id), + connectedDevices, agentlet: getDaemonSupervisor().getStatus(), agentDefaults: getAgentDefaults(), }; @@ -206,7 +259,13 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { code: 'profile_registry_unavailable', }); } - const agentletId = getSupervisedAgentletId(); + const agentletId = parsed.data.agentletId; + if (!isAgentletConnected(agentletId)) { + return reply.status(409).send({ + code: 'agentlet_unavailable', + message: 'The selected Agentlet is not connected.', + }); + } const launch = parsed.data.launch; if (!(await validateHarnessLaunch(launch, agentletId, request, reply))) return; diff --git a/apps/server/src/modules/agent/acp/service.ts b/apps/server/src/modules/agent/acp/service.ts index 24d5fae3f..62a6d0194 100644 --- a/apps/server/src/modules/agent/acp/service.ts +++ b/apps/server/src/modules/agent/acp/service.ts @@ -22,10 +22,8 @@ import { randomUUID } from 'node:crypto'; -import { - getAgentProfileRegistry, - getSupervisedAgentletId, -} from '@agenetes/agentlet-host'; +import { AcpServiceError } from '@agenetes/acp-driver'; +import { getAgentProfileRegistry } from '@agenetes/agentlet-host'; import { renderExternalAgentInputs } from './preprocessor.js'; import { getProfileSessionPreferences } from './profile-session-preferences.js'; @@ -183,18 +181,18 @@ export function buildAcpWorkloadSpec( const { binding, threadId } = opts; const canvasId = opts.canvasId ?? ''; const profile = resolveProfileSnapshot(binding.profileId); - let agentletId: string; - let cwd: string | undefined; - let recipe: AcpBindingRecipe | null; - if (profile) { - agentletId = profile.agentletId; - cwd = profile.workingDirPath; - recipe = recipeFromProfileSnapshot(profile, binding.alias); - } else { - agentletId = getSupervisedAgentletId(); - cwd = opts.cwd; - recipe = resolveBindingRecipe(binding.profileId); + if (!profile) { + throw new AcpServiceError( + 'profile_missing', + `Agent Profile '${binding.profileId}' is unavailable.`, + ); } + const agentletId = profile.agentletId; + let cwd: string | undefined = profile.workingDirPath; + let recipe: AcpBindingRecipe | null = recipeFromProfileSnapshot( + profile, + binding.alias, + ); const workingDirPath = opts.launchOverrides?.workingDirPath; cwd = workingDirPath ?? cwd; diff --git a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts index 732e764eb..0434f5611 100644 --- a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts +++ b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts @@ -20,7 +20,6 @@ vi.mock('@agenetes/agentlet-host', () => ({ getAgentProfileRegistry: () => ({ getProfile: () => mocks.profile, }), - getSupervisedAgentletId: () => 'supervised-agentlet', })); vi.mock('../agenetes/drivers.js', () => ({ @@ -137,4 +136,14 @@ describe('buildAcpWorkloadSpec', () => { 'Node constraints', ]); }); + + it('rejects a missing Profile instead of inventing a placement', () => { + expect(() => + buildAcpWorkloadSpec({ + binding: { profileId: 'missing', alias: 'Missing' }, + threadId: 'thread-a', + canvasId: 'canvas-a', + }), + ).toThrow("Agent Profile 'missing' is unavailable."); + }); }); diff --git a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts index c8fdf0d8a..66dfbc803 100644 --- a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts +++ b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts @@ -29,10 +29,6 @@ vi.mock('@agenetes/acp-driver', () => ({ }, })); -vi.mock('@agenetes/agentlet-host', () => ({ - getSupervisedAgentletId: () => 'agentlet-supervised', -})); - vi.mock('./external-agent-realization.js', () => ({ externalAgentRealization: { realize: vi.fn(), ensureSession: vi.fn() }, realizationHttpError: () => ({ @@ -139,7 +135,7 @@ describe('ACP cached-meta across awaited persistence', () => { }); }); - it('falls back to the supervised agentlet for a thread with no record', async () => { + it('does not invent a placement for a thread with no record', async () => { mocks.live.set('agentlet-supervised\u0000thread-1', { availableCommands: [], commandsUpdatedAt: 3, @@ -160,11 +156,7 @@ describe('ACP cached-meta across awaited persistence', () => { url: CACHED_META_URL, }); - expect(response.json()).toMatchObject({ - source: 'thread', - commandsUpdatedAt: 3, - sessionMeta: { updatedAt: 4 }, - }); + expect(response.json()).toMatchObject({ source: 'none' }); }); it('answers a dormant thread from the metadata its record kept', async () => { diff --git a/apps/server/src/modules/agent/acp/threads.route.test.ts b/apps/server/src/modules/agent/acp/threads.route.test.ts index 0a1f91c1b..4f9549346 100644 --- a/apps/server/src/modules/agent/acp/threads.route.test.ts +++ b/apps/server/src/modules/agent/acp/threads.route.test.ts @@ -19,10 +19,6 @@ vi.mock('@agenetes/acp-driver', () => ({ acpSessionRegistry: { get: () => mocks.live }, })); -vi.mock('@agenetes/agentlet-host', () => ({ - getSupervisedAgentletId: () => 'agentlet-1', -})); - vi.mock('./external-agent-realization.js', () => ({ externalAgentRealization: { realize: mocks.realize, diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts index 58947fc60..df778ecb0 100644 --- a/apps/server/src/modules/agent/acp/threads.route.ts +++ b/apps/server/src/modules/agent/acp/threads.route.ts @@ -2,7 +2,6 @@ // Licensed under the MIT license. import { acpSessionRegistry } from '@agenetes/acp-driver'; -import { getSupervisedAgentletId } from '@agenetes/agentlet-host'; import { acpPermissionDecisionSchema, @@ -140,7 +139,7 @@ export async function awaitSchemaQuiescence( async function resolveThreadAgentletId( threadId: string, canvasId?: string, -): Promise { +): Promise { if (canvasId) { const record = await agenetes.record( canvasAcpNamespace(canvasId), @@ -155,7 +154,7 @@ async function resolveThreadAgentletId( return (driverSpec as { agentletId: string }).agentletId; } } - return getSupervisedAgentletId(); + return undefined; } /** @@ -285,7 +284,9 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { } const { canvasId, profileId } = parsed.data; const agentletId = await resolveThreadAgentletId(threadId, canvasId); - const live = acpSessionRegistry.get(agentletId, threadId); + const live = agentletId + ? acpSessionRegistry.get(agentletId, threadId) + : undefined; if (live) { return { source: 'thread', diff --git a/apps/server/src/modules/agent/selectable-agent-profile.test.ts b/apps/server/src/modules/agent/selectable-agent-profile.test.ts index d2976c00e..0f649404f 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.test.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.test.ts @@ -1,13 +1,34 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const host = vi.hoisted(() => ({ + profiles: [] as Array<{ id: string; alias: string; agentletId: string }>, + connectedIds: [] as string[], +})); + +vi.mock('@agenetes/agentlet-host', () => ({ + getAgentProfileRegistry: () => ({ + getProfile: (profileId: string) => + host.profiles.find((profile) => profile.id === profileId), + listProfiles: () => host.profiles, + }), + getAgentletGateway: () => ({ + getAgentlets: () => host.connectedIds.map((agentletId) => ({ agentletId })), + }), +})); import { listAvailableAgentProfiles, requireAvailableAgentProfile, } from './selectable-agent-profile.js'; +afterEach(() => { + host.profiles = []; + host.connectedIds = []; +}); + describe('listAvailableAgentProfiles', () => { it('prepends Huabu and projects available Profile identities', () => { const profiles = new Map([ @@ -41,6 +62,19 @@ describe('listAvailableAgentProfiles', () => { ]); }); + it('projects only Profiles on currently connected Agentlets by default', () => { + host.profiles = [ + { id: 'online', alias: 'Online', agentletId: 'device-a' }, + { id: 'offline', alias: 'Offline', agentletId: 'device-b' }, + ]; + host.connectedIds = ['device-a']; + + expect(listAvailableAgentProfiles()).toEqual([ + { id: 'huabu', alias: 'Built-In Pi' }, + { id: 'online', alias: 'Online', default: true }, + ]); + }); + it('accepts the Huabu Profile without an external registry', () => { expect(() => requireAvailableAgentProfile('huabu', null)).not.toThrow(); }); diff --git a/apps/server/src/modules/agent/selectable-agent-profile.ts b/apps/server/src/modules/agent/selectable-agent-profile.ts index aabc27e89..8b69df24e 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.ts @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { getAgentProfileRegistry } from '@agenetes/agentlet-host'; +import { + getAgentProfileRegistry, + getAgentletGateway, +} from '@agenetes/agentlet-host'; import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared'; @@ -18,6 +21,25 @@ interface AgentProfileRegistryPort { listSelectableProfileIds(): string[]; } +function getConnectedProfileRegistry(): AgentProfileRegistryPort | null { + const registry = getAgentProfileRegistry(); + if (!registry) return null; + return { + getProfile: (profileId) => registry.getProfile(profileId), + listSelectableProfileIds: () => { + const connectedIds = new Set( + (getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? []).map( + (connection) => connection.agentletId, + ), + ); + return registry + .listProfiles() + .filter((profile) => connectedIds.has(profile.agentletId)) + .map((profile) => profile.id); + }, + }; +} + export interface AvailableAgentProfileSummary { id: string; alias: string; @@ -36,7 +58,7 @@ export class SelectableAgentProfileError extends Error { export function requireSelectableAgentProfile( profileId: string, - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): SelectableAgentProfile { if (!registry) { throw new SelectableAgentProfileError( @@ -60,20 +82,20 @@ export function requireSelectableAgentProfile( export function requireAvailableAgentProfile( profileId: string, - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): void { if (profileId === HUABU_AGENT_PROFILE_ID) return; requireSelectableAgentProfile(profileId, registry); } export function getFirstSelectableAgentProfileId( - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): string | null { return registry?.listSelectableProfileIds()[0] ?? null; } export function listAvailableAgentProfiles( - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): AvailableAgentProfileSummary[] { const defaultProfileId = getFirstSelectableAgentProfileId(registry); const huabu = { diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index b14e52c9b..2cabc6501 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -149,8 +149,12 @@ export const routes = { agentChangeReviewConfig: '/agent-change-review/config', // ── ACP (external agent bridge) ─────────────────────────────────── - acpAgentCli: (profileId?: string) => - `/acp/agent-cli${profileId ? `?profileId=${enc(profileId)}` : ''}`, + acpAgentCli: (target: { profileId?: string; agentletId?: string }) => { + const params = target.profileId + ? `profileId=${enc(target.profileId)}` + : `agentletId=${enc(target.agentletId ?? '')}`; + return `/acp/agent-cli?${params}`; + }, // Profiles (loopback-only) — user-managed spawn recipes. acpProfiles: '/acp/profiles', acpProfileLaunchPreview: '/acp/profile-launch-preview', diff --git a/apps/web/src/api/acp.test.ts b/apps/web/src/api/acp.test.ts index 26697c41d..a4841ea6d 100644 --- a/apps/web/src/api/acp.test.ts +++ b/apps/web/src/api/acp.test.ts @@ -18,19 +18,19 @@ describe('ACP Profile editing API', () => { it('routes discovery to the saved Profile and encodes its identity', async () => { const fetch = vi.fn().mockResolvedValue(new Response('{"agents":[]}')); vi.stubGlobal('fetch', fetch); - await listAcpAgentClis('remote/#1'); + await listAcpAgentClis({ profileId: 'remote/#1' }); expect(fetch).toHaveBeenCalledWith( expect.stringMatching(/\/api\/acp\/agent-cli\?profileId=remote%2F%231$/), expect.any(Object), ); }); - it('uses supervised-daemon discovery for creation', async () => { + it('routes creation discovery to the selected Agentlet', async () => { const fetch = vi.fn().mockResolvedValue(new Response('{"agents":[]}')); vi.stubGlobal('fetch', fetch); - await listAcpAgentClis(); + await listAcpAgentClis({ agentletId: 'device/#1' }); expect(fetch).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/acp\/agent-cli$/), + expect.stringMatching(/\/api\/acp\/agent-cli\?agentletId=device%2F%231$/), expect.any(Object), ); }); diff --git a/apps/web/src/api/acp.ts b/apps/web/src/api/acp.ts index 4b56cb1c9..f8965f44f 100644 --- a/apps/web/src/api/acp.ts +++ b/apps/web/src/api/acp.ts @@ -29,6 +29,7 @@ import { routes } from './_routes'; import type { AcpAgentCliListResponse, + AcpAgentCliQuery, AcpAgentletStatus, AcpAgentletStatusResponse, AcpPermissionDecisionRequest, @@ -55,6 +56,7 @@ import type { } from '@huabu/shared'; export type { + AcpAgentCliQuery, AcpAgentCliInfo, AcpAgentCliListResponse, AcpAgentProfile, @@ -84,6 +86,7 @@ export type { ConnectionTokenConfig, ConnectionTokenUpdate, AgentletConnectionCommandResponse, + ConnectedAgentletDevice, WarmAcpSessionRequest, WarmAcpSessionResponse, } from '@huabu/shared'; @@ -91,12 +94,12 @@ export type { // ── Agent CLI detection ────────────────────────────────────────────── /** - * Read the supervised daemon's catalogue, or the saved Profile's target daemon. + * Read the explicitly selected Agentlet's catalogue. */ export async function listAcpAgentClis( - profileId?: string, + target: AcpAgentCliQuery, ): Promise { - return apiFetch(routes.acpAgentCli(profileId), { + return apiFetch(routes.acpAgentCli(target), { fallbackMessage: 'Failed to detect installed agent CLIs', }); } @@ -111,7 +114,7 @@ export async function listAcpProfiles(): Promise { } /** - * Create a command Profile on the local agentlet. The server allocates its id. + * Create a command Profile on its explicitly selected Agentlet. */ export async function createAcpProfile( payload: CreateAcpProfileBody, diff --git a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx index 0140a3aaf..fc388e428 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx @@ -62,12 +62,18 @@ vi.mock('@/components/Common/Select', () => ({ value, options, onChange, + ariaLabel, }: { value: string; options: { value: string; label: string; disabled?: boolean }[]; onChange: (value: string) => void; + ariaLabel?: string; }) => ( - onChange(event.target.value)} + > {options.map((option) => (