Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/daemon/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
53 changes: 52 additions & 1 deletion src/daemon/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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",
Expand Down
25 changes: 19 additions & 6 deletions src/daemon/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions src/gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +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` (admin: all; else own) 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.
**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.

Expand Down
97 changes: 97 additions & 0 deletions src/gateway/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof dispatcher.dispatch<"status.get">>>) =>
(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(
Expand Down
17 changes: 16 additions & 1 deletion src/gateway/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
1 change: 1 addition & 0 deletions src/http/route-declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/docs/gateway/access-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ A command runs on the worker machine, as the user who runs the worker daemon. `s
| `GET /v1/leases/<id>` | Read the lease again, for example after your agent restarts. `expiresAt` is the current deadline. |
| `GET /v1/leases/<id>/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. |

Expand Down
Loading