Repository navigation
A standalone worker writes a private agent-device profile for local agents #956
Description
Part of #952.
Scope
A standalone worker with HTTP and agent-device on writes a private agent-device profile, <data dir>/agent-device-profile.json, holding its agent-device URL and one agent token it keeps for local agents. An agent on the same Mac, running as the same user, connects with agent-device connect --remote-config <that file> --session <name> and no token of its own. Until #957 makes them defaults, the operator still turns on http.enabled and capabilities.agent-device.enabled, but no longer mints or exports a token.
In short
At each start, a worker with HTTP and agent-device turned on makes sure the profile exists, is readable only by its user (mode 0600), and holds the one live agent token labelled agent-device on this Mac. It reuses that token across restarts, so an agent's saved connection keeps working. When HTTP or agent-device is off, it deletes the file and revokes the token; a joined worker always has HTTP off. "Turned on" means set, or on by default once #957 lands, whether or not the agent-device command is found.
flowchart LR
S[worker start: module host, after the socket claim] --> Q{HTTP on and agent-device turned on?}
Q -->|yes| R{file token verifies and has the label?}
R -->|yes| K[keep token, revoke other labelled tokens; rewrite file only if its bytes change]
R -->|no| N[mint agent token, revoke other labelled tokens, write 0600]
Q -->|no| D[delete file, revoke labelled tokens]
User session, HTTP and agent-device turned on, no token exported. Before:
$ agent-device connect proxy --daemon-base-url http://127.0.0.1:4701/v1/agent-device --session s1
$ agent-device open --platform ios --session s1Simlock answers the open with 401 UNAUTHENTICATED, Missing bearer token. After:
$ agent-device connect --remote-config ~/.simlock/agent-device-profile.json --session s1
$ agent-device open --platform ios --session s1The open leases and opens a simulator, owned by the profile token's id.
The file (keys in this order, two-space JSON, trailing newline). clientId is a constant, so the bytes stay stable; agent-device's proxy open refuses a profile without it (decided in delivery, pending the maintainer):
{
"clientId": "<a constant agent-device accepts>",
"daemonAuthToken": "<secret>",
"daemonBaseUrl": "http://<host>:<http.port>/v1/agent-device",
"daemonTransport": "http",
"leaseProvider": "proxy",
"runId": "simlock-local",
"sessionIsolation": "tenant",
"tenant": "local"
}<host> is http.host, except a wildcard (0.0.0.0, ::) → 127.0.0.1; any other IPv6 host is bracketed.
http.host, http.port |
daemonBaseUrl |
|---|---|
127.0.0.1, 4701 (default) |
http://127.0.0.1:4701/v1/agent-device |
0.0.0.0, 4701 |
http://127.0.0.1:4701/v1/agent-device |
::, 4801 |
http://127.0.0.1:4801/v1/agent-device |
::1, 4701 |
http://[::1]:4701/v1/agent-device |
10.0.0.5, 4701 |
http://10.0.0.5:4701/v1/agent-device |
If this goes wrong, the user notices one of three things: the connect line fails because the file is missing; open answers Unknown bearer token; or an agent has to connect --force after every restart because the file keeps changing.
Technical spec
Commit for every source: cac23e033f94ad87aee0b92e130190094df6d93a. agent-device is 0.21.28, the version package.json pins for @agent-device/proxy; its dist/src is minified, so it is cited by file and function.
Facts
| # | Fact | Source |
|---|---|---|
| F1 | agent-device's remote-config profile keys include daemonBaseUrl, daemonAuthToken, daemonTransport, tenant, sessionIsolation, runId, leaseProvider |
agent-device dist/src/sdk-remote-config.js, key list m |
| F2 | agent-device connect --remote-config <file> --session s then open --platform ios --session s, with no AGENT_DEVICE_DAEMON_AUTH_TOKEN, sends authorization: Bearer <daemonAuthToken> on GET …/health and on the agent_device.lease.allocate POST |
ran on 2026-10-23 against a local server logging headers: GET /v1/agent-device/health auth=Bearer slk_probe_secret, POST /v1/agent-device/rpc auth=Bearer slk_probe_secret {"method":"agent_device.lease.allocate",…} |
| F3 | connect proxy and --remote-config cannot be combined: "connect provider positional and --remote-config are mutually exclusive." |
same run |
| F4 | A saved connection stores the SHA-256 of the file's bytes; a changed file is refused with "Active remote connection config changed. Run agent-device connect --force to refresh it." | agent-device dist/src/cli.js, fns Xt, Kt |
| F5 | agent-device's own generated proxy profile has daemonBaseUrl, daemonTransport: "http", leaseProvider: "proxy", runId, sessionIsolation: "tenant", tenant: "proxy" (plus clientId) |
~/.agent-device/remote-connections/generated/proxy-*.json on this Mac |
| F6 | TokenStore has create(role, label), list, revoke, verify; a verify reads the tokens table, so a revoked token fails at once. Auth.tokens (TokenOperations) exposes create, list, revoke but not verify |
src/auth/token-store.ts:48, :65, :69, :76; src/auth/tokens.ts:11-15; src/auth/AGENTS.md Facts |
| F7 | The front accepts role agent and sets agent-device's tenant to the token's id |
src/agent-device/front/front.ts:386; src/agent-device/host/host.ts:123-127 |
| F8 | The module host skips a module whose enabled is not true: it records off and loads none of its code; its afterSocketClaimed runs hooks of started modules only, not awaited, called from onSocketClaimed before HTTP starts |
src/modules/host.ts:217-223, :257-262; src/daemon/main.ts:749-756; src/daemon/AGENTS.md Readiness |
| F8a | src/modules may import only modules, ports, contract, core and bus, so a token type it uses is a by-shape copy (Module…) with a type test in src/daemon/module-shapes.test.ts |
.dependency-cruiser.mjs:207-211; src/modules/AGENTS.md |
| F8b | TokenOperations is implemented by FakeTokens in src/gateway/dispatcher.test.ts |
src/gateway/dispatcher.test.ts:108 |
| F9 | The filesystem port writes atomically, with a mode set on the temp file's create | src/ports/filesystem.ts:38-44, :169 |
| F10 | src/daemon/main.ts imports nothing from agent-device; the edge is reached through the module registry only, pinned by a lint test |
src/agent-device/AGENTS.md "Imported by" |
| F11 | Auth is built once in main.ts, before the role branch |
src/daemon/main.ts:341-351 |
| F12 | A joined worker is one with gateway.url set |
src/core/config.ts:800-806 |
| F13 | No event is emitted for token create or revoke | docs/internal/EVENTS.md (no token. event) |
Modules touched
Searches: createModuleHost and ModuleHostOptions (its one construction in main.ts), OptionalModuleDefinition members, TokenOperations implementers and users (2), dataDirectory writers.
flowchart LR
M["daemon/main.ts (changed: passes auth.tokens, data dir)"] --> H["modules/host.ts + types.ts (changed: local profile step)"]
A["auth/tokens.ts, index.ts (changed: TokenOperations.verify)"] --> M
DEF["agent-device/definition.ts (changed: declares localProfile)"] --> H
H --> FS[ports Filesystem]
sequenceDiagram
participant W as module host, afterSocketClaimed
participant F as Filesystem
participant T as tokens (ModuleTokens)
W->>F: read <data dir>/agent-device-profile.json
W->>T: verify(secret)
alt record with the label
W->>T: revoke other labelled tokens
W->>F: write only if bytes differ
else
W->>T: create("agent", "agent-device on this Mac")
W->>T: revoke other labelled tokens
W->>F: write 0600, atomic
end
Note over W: any error → one warn line, the start goes on
Files: src/auth/tokens.ts, src/auth/index.ts (verify on Auth.tokens), src/gateway/dispatcher.test.ts (its FakeTokens), src/modules/types.ts (an optional localProfile on OptionalModuleDefinition: file name, token label, render(baseUrl, secret); a by-shape ModuleTokens; ModuleHostOptions gains the tokens), src/modules/host.ts (the step, for every definition that declares a profile, on or off; awaited by main.ts in onSocketClaimed before HTTP starts, unlike the unawaited module hooks), src/modules/testing.ts, src/daemon/module-shapes.test.ts, src/daemon/main.ts (passes auth.tokens; awaits the step before HTTP starts), src/agent-device/definition.ts (declares the profile; plain data and one pure function, no worker code), their tests. Nothing in src/modules names agent-device. Manuals: src/agent-device/AGENTS.md (Files, Disk, a "Local profile" paragraph), src/modules/AGENTS.md (the local profile step), src/auth/AGENTS.md (tokens row). Website: using-devices/agent-device.mdx gains a "On the same Mac" section with the connect line above, --session on every command, and the connect --force remedy.
Contract and event changes
None: no contract shape changes, and no event (F13). The token is an ordinary agent token that token.list shows with the label agent-device on this Mac.
Read from a file: the profile's daemonAuthToken, a string. Anything else means "make a new one": a missing file, not JSON, not an object, no string daemonAuthToken, a secret that does not verify, or a record without the label.
Other code on the same state
Searches: writers of the tokens table (the token.create and token.revoke handlers, TokenStore; 3 hits), agent-device-profile (0 hits today), files in the worker data directory.
- An operator revokes the profile token with
simlock token revoke <id>: local agents get 401Unknown bearer tokenuntil the next start, which mints a new token and rewrites the file. Tests line 2. - An operator's own token with the label
agent-device on this Macis revoked at the next start. Accepted, and the label is documented. - The step runs after the socket claim (F8), and two daemons cannot both hold one data directory's socket, so two writers never race.
- A standalone worker serves agent-device on localhost by default #957 changes when HTTP and agent-device are on; this task reads only the resolved values.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
| read profile | ok / missing / not JSON | missing or bad → make a new one | none |
tokens.verify |
record with the label | reuse | none |
| same | none, or no label | create, revoke the others, write | none |
tokens.create / revoke |
throws | warn Could not prepare the agent-device profile: <message>, the start goes on, the file stays as it was. No Tests line: the token store is the daemon's own SQLite, which the seam cannot fail on its own |
none |
| write file | throws | the same warn, the start goes on | none |
| delete file (off or joined) | missing | nothing | none |
| same | throws | the same warn, the start goes on | none |
A restart in the middle of a rotation leaves either the old file with a revoked token or the new file. The next start repairs both (Tests line 2). Nothing else writes the file.
Rules in play
- ADR 0046 §2–4: the file, its content, its stability and the shared token, exactly as written.
- ADR 0023 §4 "One simlock token is one tenant": local agents share one token, so one tenant; the docs tell them to keep their
--sessionnames apart. - ADR 0030 §2 as narrowed by ADR 0046: the host acts on an off module's declared profile and loads none of its code.
- Architecture: the core,
src/modulesandmain.tsname no agent-device (F10, F8a). - Ports: file access goes through
Filesystemonly. - Documentation rule 9: the agent-device page changes in this PR.
Tests
Seam: an in-process worker started with startDaemon and the fake agent-device, with HTTP on a free port (model src/agent-device/edge.test.ts, which already does this). The tests read the file through the test's filesystem and the token list through the daemon.
- A worker with HTTP and agent-device on writes the profile before HTTP listens: mode 0600; the keys and values above, in that order;
daemonBaseUrlper the host table (127.0.0.1,0.0.0.0,::,::1); the token isagent, labelled, and listed once; an operator's token made beforehand with the same label is revoked. APOST /v1/agent-device/rpcallocate with that token asAuthorization: Beareris granted, with the owner being the token's id. - A restart keeps the profile: with nothing changed, the same bytes and token; a changed
http.portrewritesdaemonBaseUrland keeps the token. A revoked token, a deleted file, a file that is not JSON, or a token without the label → a new token, every other labelled token revoked, the file rewritten. - The profile is on disk by the time the HTTP listener accepts its first request: a test that sees the order fails when
main.tsstops awaiting the profile step before starting HTTP (decided in delivery, pending the maintainer). - The profile is withdrawn where it does not apply: HTTP off, or agent-device off (the module not loaded) → no file (an existing one is deleted) and no labelled token left. A gateway never writes one.
- A failure does not stop the worker: the filesystem refusing the write → the worker starts, logs one
warnlineCould not prepare the agent-device profile: <message>, and agent-device still serves a hand-made token.
Done when
pnpm exec vitest run --project unit <file>passes forsrc/agent-device/edge.test.ts(or the new test file beside it) with the lines above.- The website build passes (
pnpm --dir website install && pnpm --dir website build && node website/scripts/check-openapi.mjs), andusing-devices/agent-device.mdxshows the profile connect line,--sessionon every command, and theconnect --forceremedy. - CI is green.
Out of scope
- New defaults for
http.enabledandcapabilities.agent-device.enabled, and the slow-lane case with the real CLI: A standalone worker serves agent-device on localhost by default #957. - A profile for joined workers or gateways.
- Writing the shipped agent instructions (Add
simlock instructions: the rules an agent must follow, printable and served over MCP #147).
Depends on
Written by an agent.
Activity
[-]agent-device on the same Mac needs no token: the local agent identity[/-][+]A standalone worker writes a private agent-device profile for local agents[/+]feat(modules): the profile host table and operator labelled tokens (#956
test: a restart keeps the profile, and a token that no longer verifie…
feat(modules): the profile token is kept across restarts (#956)
test: the profile is withdrawn where HTTP or agent-device is off (#956)
feat(modules): withdraw the profile where it does not apply (#956)
test: a failure preparing the profile does not stop the worker (#956)
docs: the local agent-device profile (#956)
refactor(modules): split the local profile step (#956)
refactor(modules): simplify the local profile step (#956)
test: the local profile test proves a restart does not rewrite an unc…
test: the local profile is on disk before HTTP accepts a connection (#…
feat(modules): a standalone worker writes a private agent-device prof…