Skip to content

A standalone worker writes a private agent-device profile for local agents #956

Closed

Description

@V3RON

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]
Loading

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 s1

Simlock 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 s1

The 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]
Loading
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
Loading

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 401 Unknown bearer token until 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 Mac is 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 --session names 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/modules and main.ts name no agent-device (F10, F8a).
  • Ports: file access goes through Filesystem only.
  • 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; daemonBaseUrl per the host table (127.0.0.1, 0.0.0.0, ::, ::1); the token is agent, labelled, and listed once; an operator's token made beforehand with the same label is revoked. A POST /v1/agent-device/rpc allocate with that token as Authorization: Bearer is 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.port rewrites daemonBaseUrl and 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.ts stops 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 warn line Could 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 for src/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), and using-devices/agent-device.mdx shows the profile connect line, --session on every command, and the connect --force remedy.
  • CI is green.

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  2. changed the title [-]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[/+] on Oct 23, 2026
  3. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  4. self-assigned this
    on Oct 23, 2026
  5. added a commit that references this issue on Oct 24, 2026
    f154a96
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions