From f8b5f146a3009303858717982749cd3d9924f1db Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 11:00:00 +0100 Subject: [PATCH 1/2] fix(daemon): status.get shows an agent only its own leases and queue entries (#893) --- src/daemon/AGENTS.md | 2 +- src/daemon/dispatcher.test.ts | 53 +++++++++- src/daemon/dispatcher.ts | 25 +++-- src/gateway/AGENTS.md | 5 +- src/gateway/dispatcher.test.ts | 97 +++++++++++++++++++ src/gateway/dispatcher.ts | 17 +++- src/http/route-declarations.ts | 1 + .../docs/docs/gateway/access-control.mdx | 2 +- .../src/content/docs/docs/reference/cli.mdx | 2 +- .../docs/using-devices/lease-lifecycle.mdx | 2 +- .../docs/docs/using-devices/remote-agents.mdx | 1 + 11 files changed, 193 insertions(+), 14 deletions(-) diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index 70eb59cfe..f7693da00 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -75,7 +75,7 @@ One pipeline for `Dispatcher`, `GatewayDispatcher` and HTTP: 1. No handler → `UNKNOWN_REQUEST`. 2. Parse input → `BAD_REQUEST`. 3. Role: `admin` passes all; `agent` only `agent` operations → `FORBIDDEN`. -4. `authorize` hook (lease owner lookups) → `FORBIDDEN`. An unknown id passes, so the handler answers `UNKNOWN_LEASE`. +4. `authorize` hook (lease owner lookups) → `FORBIDDEN`. An unknown id passes, so the handler answers `UNKNOWN_LEASE`. Lists filter in the handler: an `agent`'s `lease.list`, and `status.get`'s `leases` and `waiting`, hold only what its principal owns. 5. Park on readiness — every operation but `status.get` and `daemon.update {query}`. 6. Handler → parse output; a mismatch is logged and answers `INTERNAL`. diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 5252ad895..31a5d61ef 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -1178,6 +1178,57 @@ describe("Dispatcher: ownership", () => { ).resolves.toMatchObject({ lease: { id: leaseId } }); }); + it("status.get: an agent sees only the leases and waiting requests it owns, one that owns none sees empty lists, admin sees all", async () => { + const { dispatcher, engine } = await buildDispatcher(); + const IOS = { model: "iPhone 17 Pro", osVersion: "26.5", platform: "ios" } as const; + const ask = (principal: string, requesterId: string) => + dispatcher.dispatch( + "lease.request", + { ...IOS, requesterId }, + session({ principal, role: "agent" }), + ); + // why: two leases fill iOS (maxRunning: 2, see testConfig), so the next two requests wait. + await ask("tok_owner1", "owner1-lease"); + await ask("tok_owner2", "owner2-lease"); + void ask("tok_owner1", "owner1-waiting").catch(() => undefined); + void ask("host", "agent-7").catch(() => undefined); + await expect.poll(() => engine.queueDepth).toBe(2); + + const cases = [ + [ + "admin", + { principal: "tok_admin", role: "admin" }, + ["owner1-lease", "owner2-lease"], + ["owner1-waiting", "agent-7"], + ], + [ + "an agent with a lease and a waiting request", + { principal: "tok_owner1", role: "agent" }, + ["owner1-lease"], + ["owner1-waiting"], + ], + [ + "an agent whose request waits under another requesterId", + { principal: "host", role: "agent" }, + [], + ["agent-7"], + ], + ["an agent that owns nothing", { principal: "tok_nobody", role: "agent" }, [], []], + ] as const; + const seen = await Promise.all( + cases.map(async ([name, who]) => { + const status = await dispatcher.dispatch("status.get", {}, session(who)); + return { + leases: status.leases?.map((lease) => lease.requesterId), + name, + waiting: status.waiting?.map((request) => request.requesterId), + }; + }), + ); + + expect(seen).toEqual(cases.map(([name, , leases, waiting]) => ({ leases, name, waiting }))); + }); + it("lease.cancel: naming a requesterId with no pending request always passes (not-found), admin bypasses too", async () => { const { dispatcher } = await buildDispatcher(); @@ -2432,7 +2483,7 @@ describe("Dispatcher: list.get requests and status.get waiting", () => { await expect.poll(() => engine.queueDepth).toBe(1); const listed = await requests(dispatcher); - const { waiting } = await dispatcher.dispatch("status.get", {}, session()); + const { waiting } = await dispatcher.dispatch("status.get", {}, session({ role: "admin" })); expect(listed.map((entry) => ("stage" in entry ? entry.stage : undefined))).toEqual([ "starting", diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index 22fe2dec2..2187d61ce 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -420,10 +420,10 @@ export class Dispatcher { host, ...loadField, installs: [...this.options.components.inProgress()], - leases: [...snapshot.leases], + leases: this.#leasesFor(session), physicalQueueDepth: this.options.queue.physicalQueueDepth, queueDepth: this.options.queue.queueDepth, - waiting: [...this.options.queue.waitingRequests()], + waiting: this.#waitingFor(session), warmPool: this.options.warmPool.figures(), }; return session.role === "agent" ? withoutQuarantineMessages(answer) : answer; @@ -639,12 +639,25 @@ export class Dispatcher { return { lease }; }; - #leaseList: Handler<"lease.list"> = (_input, session) => { - const leases = this.options.registry.snapshot.leases.filter( + #leaseList: Handler<"lease.list"> = (_input, session) => ({ leases: this.#leasesFor(session) }); + + // why: an agent token must not learn other principals' leases or requester ids; admin sees all. + #leasesFor(session: DispatchSession) { + return this.options.registry.snapshot.leases.filter( (lease) => session.role === "admin" || lease.ownerId === session.principal, ); - return { leases }; - }; + } + + // why: a waiting entry carries no owner, so the queue's recorded owner of its requester decides. + #waitingFor(session: DispatchSession) { + return this.options.queue + .waitingRequests() + .filter( + (request) => + session.role === "admin" || + this.options.queue.pendingRequestOwner(request.requesterId) === session.principal, + ); + } /** * Resolution only: the daemon builds the command and never runs it. Spawning it here would diff --git a/src/gateway/AGENTS.md b/src/gateway/AGENTS.md index a1b9e5f15..67bbf1735 100644 --- a/src/gateway/AGENTS.md +++ b/src/gateway/AGENTS.md @@ -99,9 +99,10 @@ missing from 2 snapshots in a row → dropped; kept across a disconnect, forgott 4. Worker: `relay.*` only on the uplink session (`FORBIDDEN` elsewhere; a gateway refuses them from everyone). Target from `relayTargets`; unknown → `RELAY_TARGET_MISSING`. -**Reads.** `status.get`, `catalog.get`, `list.get`, `lease.list` (admin: all; else own) and `lease.get` +**Reads.** `status.get`, `catalog.get`, `list.get`, `lease.list` and `lease.get` (one lease, as `lease.list` would hold it for the caller) answer from views; a gateway lease record -also carries `workerLeaseId`, the worker's own id (not on `status.get`). `inbox.get` runs `inboxItems` (contract) over the worker views, the fleet's leases and waiting requests (what `list.get` returns for `leases` and `requests`) and the token list (an unavailable store leaves holders named by id). `worker.install-component` fans out. `nuke.run`, `cleanup.run`, `doctor.run`, `driver.passthrough`, `component.*`, `device.add`, `device.remove` → `UNSUPPORTED_IN_GATEWAY_MODE`. `usage.get` reads this gateway's `history.db` rows through `core/usage`: fleet totals, one entry per registry or named worker; `dispose` stops its read thread. +also carries `workerLeaseId`, the worker's own id (not on `status.get`). `lease.list` and `status.get`'s `leases`: admin all; else +the index's entries it owns. `status.get`'s `waiting`: admin all; else the fleet requests it owns. For a non-admin, `workers[]` drops each worker's own `leases` and `waiting`. `inbox.get` runs `inboxItems` (contract) over the worker views, the fleet's leases and waiting requests (what `list.get` returns for `leases` and `requests`) and the token list (an unavailable store leaves holders named by id). `worker.install-component` fans out. `nuke.run`, `cleanup.run`, `doctor.run`, `driver.passthrough`, `component.*`, `device.add`, `device.remove` → `UNSUPPORTED_IN_GATEWAY_MODE`. `usage.get` reads this gateway's `history.db` rows through `core/usage`: fleet totals, one entry per registry or named worker; `dispose` stops its read thread. **Uplink drop.** View → `disconnected`, kept; `worker.disconnected`. In-flight calls → `WORKER_UNREACHABLE`, an in-flight lease request → `worker-failed`. `token.revoke` closes its uplinks. diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index 3fd3d4b91..cdf07cbfe 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -1113,6 +1113,103 @@ describe("GatewayDispatcher", () => { }); }); + it("status.get: an agent sees only the fleet leases and fleet requests it owns, one that owns none sees empty lists, admin sees all", async () => { + const { coordinator, directory, dispatcher, workers } = harness(); + directory.add("wrk_1", new ScriptedWorkerClient()); + const capacity = statusFixture().capacity; + const issued = (id: string, deviceId: string, ownerId: string) => ({ + ...leaseFixture(id, deviceId), + ownerId, + requesterId: `${GATEWAY_REQUESTER_PREFIX}${ownerId}`, + }); + workers.connected("wrk_1", "mac-mini-1", "0.3.0"); + workers.refresh("wrk_1", { + capacity: { ...capacity, ios: { ...capacity.ios, maxRunning: 0 } }, + catalog: catalogFixture([{ models: ["iPhone 17"], platform: "ios", runtimes: ["26.0"] }]) + .platforms, + health: "running", + // why: lease_3 is the worker's own local lease, owned by a local "agent-1" this gateway never issued to. + leases: [ + issued("lease_1", "dev_1", "agent-1"), + issued("lease_2", "dev_2", "agent-2"), + leaseFixture("lease_3", "dev_3"), + ], + queueDepth: 0, + }); + const wait = (ownerId: string, requesterId: string) => + void coordinator + .request( + { model: "iPhone 17", platform: "ios" }, + { allowDownload: false, noWait: false, ownerId, requesterId }, + ) + .catch(() => undefined); + wait("agent-1", "fleet-a"); + wait("agent-3", "fleet-b"); + await expect.poll(() => coordinator.queueDepth).toBe(2); + + const cases = [ + [ + "admin", + { principal: "operator-1", role: "admin" }, + ["wrk_1.lease_1", "wrk_1.lease_2", "lease_3"], + ["fleet-a", "fleet-b"], + ], + [ + "an agent with a lease and a waiting request", + { principal: "agent-1", role: "agent" }, + ["wrk_1.lease_1"], + ["fleet-a"], + ], + [ + "an agent with a lease only", + { principal: "agent-2", role: "agent" }, + ["wrk_1.lease_2"], + [], + ], + [ + "an agent with a waiting request only", + { principal: "agent-3", role: "agent" }, + [], + ["fleet-b"], + ], + ["an agent that owns nothing", { principal: "nobody", role: "agent" }, [], []], + ] as const; + const seen = await Promise.all( + cases.map(async ([name, who]) => { + const status = await dispatcher.dispatch("status.get", {}, session(who)); + return { + leases: status.leases?.map((lease) => lease.id), + name, + waiting: status.waiting?.map((request) => request.requesterId), + }; + }), + ); + + expect(seen).toEqual(cases.map(([name, , leases, waiting]) => ({ leases, name, waiting }))); + + // why: `workers[]` repeats each worker's own leases and waiting requests, so an agent must not read them there. + const held = (status: Awaited>>) => + (status.workers ?? []).flatMap((worker) => [ + ...(worker.leases ?? []).map((lease) => lease.id), + ...(worker.waiting ?? []).map((request) => request.requesterId), + ]); + expect( + held( + await dispatcher.dispatch( + "status.get", + {}, + session({ principal: "operator-1", role: "admin" }), + ), + ), + ).not.toEqual([]); + for (const principal of ["agent-1", "agent-2", "nobody"]) { + expect( + held(await dispatcher.dispatch("status.get", {}, session({ principal, role: "agent" }))), + principal, + ).toEqual([]); + } + }); + it("replays and subscribes to its own bus, which carries the fleet's events", async () => { const { dispatcher, eventBus } = harness(); eventBus.emit( diff --git a/src/gateway/dispatcher.ts b/src/gateway/dispatcher.ts index 153059d4c..ba105aa2a 100644 --- a/src/gateway/dispatcher.ts +++ b/src/gateway/dispatcher.ts @@ -363,7 +363,22 @@ export class GatewayDispatcher { leaseIndex: this.options.leaseIndex, }); const answer = { ...status, daemon: { ...status.daemon, ...consoleUrl }, history }; - return session.role === "agent" ? withoutQuarantineMessages(answer) : answer; + if (session.role === "admin") return answer; + const { principal } = session; + return withoutQuarantineMessages({ + ...answer, + // why: a worker's view repeats its admin-read leases and waiting requests, other principals' included. + workers: answer.workers?.map(({ leases: _leases, ...worker }) => ({ + ...worker, + waiting: [], + })), + leases: this.#ownedFleetLeases(principal), + // why: a waiting entry carries no owner, so the fleet queue's recorded owner of its requester decides. + waiting: (answer.waiting ?? []).filter( + (request) => + this.options.coordinator.pendingRequestOwner(request.requesterId) === principal, + ), + }); }; #catalogGet: Handler<"catalog.get"> = (input) => diff --git a/src/http/route-declarations.ts b/src/http/route-declarations.ts index d9886ace5..15e340b66 100644 --- a/src/http/route-declarations.ts +++ b/src/http/route-declarations.ts @@ -20,6 +20,7 @@ export const statusRoute = declareRoute({ tag: "System", summary: "The daemon's devices, leases and capacity", role: "agent", + notes: "With an agent token, leases and waiting hold only that token's own.", reply: OPERATIONS["status.get"].output, }); diff --git a/website/src/content/docs/docs/gateway/access-control.mdx b/website/src/content/docs/docs/gateway/access-control.mdx index fa25f5573..bafe5f7a1 100644 --- a/website/src/content/docs/docs/gateway/access-control.mdx +++ b/website/src/content/docs/docs/gateway/access-control.mdx @@ -13,7 +13,7 @@ This page uses the gateway in its examples. Tokens work the same way on a worker | Role | Made for | May | | --- | --- | --- | -| `agent` | A coding agent that leases devices over HTTP. | Read the catalog and status, list installed runtimes, request, renew and release its own leases, cancel its own waiting requests, and run commands on the devices it leases. | +| `agent` | A coding agent that leases devices over HTTP. | Read the catalog and status (only its own leases and waiting requests), list installed runtimes, request, renew and release its own leases, cancel its own waiting requests, and run commands on the devices it leases. | | `operator` | A person, or a script that manages the fleet. | Everything an agent may, for every requester's leases, plus the console, events, usage figures, tokens, worker commands and component installs. | | `worker` | A worker machine joining a gateway. | Open the worker's connection to the gateway, and nothing else. Any other request with it is refused with `403`. | diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index 56c52415b..08607177a 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -261,7 +261,7 @@ Skill statuses are `wrote`, `replaced` and `skipped`; MCP statuses are `connecte ## simlock status -Show the daemon's health and mode, the Simlock version it runs, its console address, capacity, devices, leases and queue depth. Works for any caller. See [Monitoring](/simlock/docs/worker/monitoring). +Show the daemon's health and mode, the Simlock version it runs, its console address, capacity, devices, leases and queue depth. Works for any caller. With an agent token, the leases and waiting requests are only your own. See [Monitoring](/simlock/docs/worker/monitoring). When the daemon has seen a newer Simlock version on npm, the `Simlock:` line says so: `Simlock: 0.3.0 (0.4.0 available)`. A daemon learns the newest version at start and every 24 hours after, unless `update.check` is `false`. diff --git a/website/src/content/docs/docs/using-devices/lease-lifecycle.mdx b/website/src/content/docs/docs/using-devices/lease-lifecycle.mdx index dc2f1c2f4..2e0331e6d 100644 --- a/website/src/content/docs/docs/using-devices/lease-lifecycle.mdx +++ b/website/src/content/docs/docs/using-devices/lease-lifecycle.mdx @@ -141,7 +141,7 @@ Over HTTP the requester is the token, not anything the request says. See [Remote | `simlock list --leases` | Every lease, as one JSON line. | | `simlock list --requests` | Every request waiting for a device. | -On the machine that runs the daemon, the CLI connects as admin, so these show everyone's leases, and `lease renew` and `release` work on any lease id from any shell. +On the machine that runs the daemon, the CLI connects as admin, so these show everyone's leases, and `lease renew` and `release` work on any lease id from any shell. With an agent token, `simlock status` shows only your own leases and waiting requests. ## When a lease is lost diff --git a/website/src/content/docs/docs/using-devices/remote-agents.mdx b/website/src/content/docs/docs/using-devices/remote-agents.mdx index e7a0914b3..463d2bb44 100644 --- a/website/src/content/docs/docs/using-devices/remote-agents.mdx +++ b/website/src/content/docs/docs/using-devices/remote-agents.mdx @@ -158,6 +158,7 @@ A command runs on the worker machine, as the user who runs the worker daemon. `s | `GET /v1/leases/` | Read the lease again, for example after your agent restarts. `expiresAt` is the current deadline. | | `GET /v1/leases//events` | Server-Sent Events for `device_unhealthy`, `device_recovered` and `lease_lost`. The stream ends after `lease_lost`. | | `GET /v1/leases` | Your leases. On a gateway each one also carries `workerLeaseId`, the id its worker knows it by. | +| `GET /v1/status` | The devices, capacity and queue depth. With an agent token, `leases` and `waiting` show only your own. | | `GET /v1/catalog?platform=ios` | The models and runtimes you can ask for, as `simlock catalog --json` prints them. | | `GET /v1/identity` | Who the token is. | From bd03b8c3861a5b4890895056cd556d87a87b91b7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 11:36:47 +0100 Subject: [PATCH 2/2] docs(gateway): keep the gateway manual inside its line budget --- src/gateway/AGENTS.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/gateway/AGENTS.md b/src/gateway/AGENTS.md index 67bbf1735..151c8f9b0 100644 --- a/src/gateway/AGENTS.md +++ b/src/gateway/AGENTS.md @@ -99,10 +99,8 @@ missing from 2 snapshots in a row → dropped; kept across a disconnect, forgott 4. Worker: `relay.*` only on the uplink session (`FORBIDDEN` elsewhere; a gateway refuses them from everyone). Target from `relayTargets`; unknown → `RELAY_TARGET_MISSING`. -**Reads.** `status.get`, `catalog.get`, `list.get`, `lease.list` and `lease.get` -(one lease, as `lease.list` would hold it for the caller) answer from views; a gateway lease record -also carries `workerLeaseId`, the worker's own id (not on `status.get`). `lease.list` and `status.get`'s `leases`: admin all; else -the index's entries it owns. `status.get`'s `waiting`: admin all; else the fleet requests it owns. For a non-admin, `workers[]` drops each worker's own `leases` and `waiting`. `inbox.get` runs `inboxItems` (contract) over the worker views, the fleet's leases and waiting requests (what `list.get` returns for `leases` and `requests`) and the token list (an unavailable store leaves holders named by id). `worker.install-component` fans out. `nuke.run`, `cleanup.run`, `doctor.run`, `driver.passthrough`, `component.*`, `device.add`, `device.remove` → `UNSUPPORTED_IN_GATEWAY_MODE`. `usage.get` reads this gateway's `history.db` rows through `core/usage`: fleet totals, one entry per registry or named worker; `dispose` stops its read thread. +**Reads.** `status.get`, `catalog.get`, `list.get`, `lease.list` and `lease.get` (one lease, as `lease.list` would hold it for the caller) answer from views; a gateway lease record also carries `workerLeaseId`, the worker's own id (not on `status.get`). +`lease.list` and `status.get`'s `leases`: admin all; else the index's entries it owns. `status.get`'s `waiting`: admin all; else the fleet requests it owns. For a non-admin, `workers[]` drops each worker's own `leases` and `waiting`. `inbox.get` runs `inboxItems` (contract) over the worker views, the fleet's leases and waiting requests (what `list.get` returns for `leases` and `requests`) and the token list (an unavailable store leaves holders named by id). `worker.install-component` fans out. `nuke.run`, `cleanup.run`, `doctor.run`, `driver.passthrough`, `component.*`, `device.add`, `device.remove` → `UNSUPPORTED_IN_GATEWAY_MODE`. `usage.get` reads this gateway's `history.db` rows through `core/usage`: fleet totals, one entry per registry or named worker; `dispose` stops its read thread. **Uplink drop.** View → `disconnected`, kept; `worker.disconnected`. In-flight calls → `WORKER_UNREACHABLE`, an in-flight lease request → `worker-failed`. `token.revoke` closes its uplinks.