Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c8aa5b9
test: a client reads one lease through lease.get on the worker dispat…
V3RON Oct 23, 2026
dc86c70
feat(daemon): the worker dispatcher answers lease.get (#913)
V3RON Oct 23, 2026
520c1f3
test: a client reads one lease through lease.get on the gateway dispa…
V3RON Oct 23, 2026
159ef2e
feat(gateway): the gateway dispatcher answers lease.get (#913)
V3RON Oct 23, 2026
b0d2067
test: a client reads one lease through GET /v1/leases/{id} and its ev…
V3RON Oct 23, 2026
5889e43
feat(http): GET /v1/leases/{id} and its events find one lease with le…
V3RON Oct 23, 2026
e02e9fb
test: a client reads one lease over the daemon socket (#913)
V3RON Oct 23, 2026
3be1fa4
feat(daemon): the socket serves lease.get (#913)
V3RON Oct 23, 2026
960157a
test: a gateway's lease lists name the worker's own lease id as worke…
V3RON Oct 23, 2026
761717d
feat(gateway): a gateway's lease lists carry workerLeaseId (#913)
V3RON Oct 23, 2026
d0fb435
docs: lease.get and workerLeaseId in the module manuals and remote ag…
V3RON Oct 23, 2026
3e2ca14
refactor(gateway): split lease.get's lookup by where the lease lives …
V3RON Oct 23, 2026
3c73c58
Merge remote-tracking branch 'origin/task/919' into HEAD
V3RON Oct 23, 2026
b72adaf
docs: fix comments and a test title that still named lease.list as th…
V3RON Oct 23, 2026
694a4e8
test(gateway): lease.get answers UNKNOWN_LEASE for an index entry who…
V3RON Oct 23, 2026
1eab24b
docs: protocol range in the manuals is 58, drop a comment naming remo…
V3RON Oct 23, 2026
105b787
Merge remote-tracking branch 'origin/task/919' into HEAD
V3RON Oct 23, 2026
0009fff
Merge remote-tracking branch 'origin/task/919' into HEAD
V3RON Oct 23, 2026
cd30362
fix(ci): the daemon protocol test expects version 58, the gateway man…
V3RON Oct 23, 2026
6c61c82
Merge remote-tracking branch 'origin/task/919' into HEAD
V3RON Oct 23, 2026
32e838e
Merge remote-tracking branch 'origin/task/919' into HEAD
V3RON Oct 23, 2026
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
4 changes: 2 additions & 2 deletions src/contract/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ park on readiness (all but `status.get`) → handler → parse output.
every operation but `daemon.stop`, which `DaemonServer` answers.

- `agent`: `catalog.get`, `status.get`, `component.list`, `lease.request`, `lease.cancel`,
`lease.renew`, `lease.release`, `lease.list`, `driver.passthrough`, `device.exec`,
`lease.renew`, `lease.release`, `lease.list`, `lease.get`, `driver.passthrough`, `device.exec`,
`doctor.run` without `fix` or `purgeOrphans`.
- `admin`: every other operation (`list.get`, `config.get`, `daemon.stop`, `events.*`,
`token.*`, `worker.*`, `component.install`, `device.add`, `relay.request`, ...).
Expand All @@ -56,7 +56,7 @@ Three identities:

## Protocol version

- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 57, max: 57 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`.
- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 58, max: 58 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`.
- Every change to a contract shape bumps it by one in the same PR. Move `min` too, unless
a compatibility path stays side by side. `.agents/scripts/protocol-bump-check.mjs` checks
it on push and in CI; it counts every file here as a shape but tests, `index.ts` and the
Expand Down
2 changes: 2 additions & 0 deletions src/contract/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const ROLE_MATRIX: ReadonlyArray<{
{ name: "lease.renew", input: { leaseId: "lease_1" }, role: "agent" },
{ name: "lease.release", input: { leaseId: "lease_1" }, role: "agent" },
{ name: "lease.list", input: {}, role: "agent" },
{ name: "lease.get", input: { leaseId: "lse_1" }, role: "agent" },
{ name: "doctor.run", input: { fix: false }, role: "agent" },
{ name: "doctor.run", input: {}, role: "agent" },
{ name: "doctor.run", input: { fix: true }, role: "admin" },
Expand Down Expand Up @@ -116,6 +117,7 @@ const EFFECT_MATRIX: ReadonlyArray<{
{ name: "lease.renew", input: { leaseId: "lease_1" }, effect: "write" },
{ name: "lease.release", input: { leaseId: "lease_1" }, effect: "write" },
{ name: "lease.list", input: {}, effect: "read" },
{ name: "lease.get", input: { leaseId: "lse_1" }, effect: "read" },
{ name: "doctor.run", input: {}, effect: "read" },
{ name: "doctor.run", input: { fix: false }, effect: "read" },
{ name: "doctor.run", input: { fix: true }, effect: "write" },
Expand Down
11 changes: 11 additions & 0 deletions src/contract/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,16 @@ export const leaseList = defineOperation({
output: z.object({ leases: z.array(statusLeaseSchema) }),
});

// why: no `authorize` hook -- the handler answers `UNKNOWN_LEASE` for a lease the caller does not own, as `lease.list` hides it (ADR 0044 §3), so "not yours" and "not there" look the same.
// fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface.
export const leaseGet = defineOperation({
name: "lease.get",
role: "agent",
effect: "read",
input: z.object({ leaseId: z.string() }),
output: z.object({ lease: statusLeaseSchema }),
});

// ---- doctor.run -------------------------------------------------------------------------------

const doctorRunInputSchema = z.object({
Expand Down Expand Up @@ -1124,6 +1134,7 @@ export const OPERATIONS = {
"lease.renew": leaseRenew,
"lease.release": leaseRelease,
"lease.list": leaseList,
"lease.get": leaseGet,
"driver.passthrough": driverPassthrough,
"device.exec": deviceExec,
"doctor.run": doctorRun,
Expand Down
2 changes: 1 addition & 1 deletion src/contract/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface ProtocolRange {
const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() });

/** Widen only once a second version is kept alive side by side with the first; a bump that leaves no shim behind moves both ends. */
export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 57, max: 57 };
export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 58, max: 58 };

const LEGACY_DAEMON_PROTOCOL_VERSION = 2;

Expand Down
2 changes: 2 additions & 0 deletions src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1383,6 +1383,8 @@ export const workerViewSchema = z.object({
export const statusLeaseSchema = leaseRecordSchema.extend({
/** Which worker holds this lease; absent on a worker's own answer, set by a gateway. */
workerId: z.string().optional(),
// why: the part of a gateway lease id after `<workerId>.`, so a relayed event naming the worker's lease can be matched; set only by a gateway's `lease.list`, `lease.get` and `list.get kind=leases`.
workerLeaseId: z.string().optional(),
});

const USAGE_MAX_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
Expand Down
2 changes: 1 addition & 1 deletion src/daemon-protocol/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {

describe("daemon protocol", () => {
it("keeps the current protocol version and newline framing", () => {
expect(DAEMON_PROTOCOL_VERSION).toBe(57);
expect(DAEMON_PROTOCOL_VERSION).toBe(58);
expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n');
});

Expand Down
2 changes: 1 addition & 1 deletion src/daemon/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Its stdout lines move the state; a failed line, a non-zero exit or a failed laun
2. **Hello** — first frame must be `hello`, else `HANDSHAKE_REQUIRED` and close.
Role: the uplink connection is `admin` by acceptance; otherwise the resolver. Principal: `hello.principal`, else `SIMLOCK_AGENT_ID`, else the daemon's pid. Both are fixed for the connection's life.
3. **Version** — ranges overlap → highest common version. `PROTOCOL_VERSION_RANGE`
is `{ min: 57, max: 57 }` (`src/contract/protocol.ts`). No overlap → the
is `{ min: 58, max: 58 }` (`src/contract/protocol.ts`). No overlap → the
connection stays open, and every request but `daemon.stop` gets
`PROTOCOL_VERSION_UNSUPPORTED`.
4. **Request** — one frame → one `dispatch()` call. While stopping: `DAEMON_STOPPING`.
Expand Down
29 changes: 29 additions & 0 deletions src/daemon/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { openMemoryStateDatabase } from "../auth/testing.js";
import type { DispatcherOptions, DispatchSession } from "./dispatcher.js";
import { Dispatcher, DispatchError } from "./dispatcher.js";
import { classifyError } from "./error-code.js";
import { UnknownLeaseError } from "../core/index.js";
import { RequestCancelledError } from "../leasing/index.js";
import { createTestEngine, type TestEngine } from "../leasing/testing.js";

Expand Down Expand Up @@ -1166,6 +1167,7 @@ describe("Dispatcher: ownership", () => {
session({ principal: "tok_owner", role: "agent" }),
);
expect(own.leases).toHaveLength(1);
expect(own.leases[0]).not.toHaveProperty("workerLeaseId");

const otherAgent = await dispatcher.dispatch(
"lease.list",
Expand All @@ -1178,6 +1180,33 @@ describe("Dispatcher: ownership", () => {
expect(admin.leases).toHaveLength(1);
});

it("lease.get: an agent reads its own lease, another agent's or an unknown id is UNKNOWN_LEASE, admin reads any", async () => {
const { dispatcher } = await buildDispatcher();
const leaseId = await grantLease(dispatcher);
const owner = session({ principal: "tok_owner", role: "agent" });

const own = await dispatcher.dispatch("lease.get", { leaseId }, owner);
expect(own).toEqual({ lease: (await dispatcher.dispatch("lease.list", {}, owner)).leases[0] });
expect(own.lease).toMatchObject({ id: leaseId, ownerId: "tok_owner" });
await expect(
dispatcher.dispatch(
"lease.get",
{ leaseId },
session({ principal: "tok_other", role: "agent" }),
),
).rejects.toBeInstanceOf(UnknownLeaseError);
await expect(
dispatcher.dispatch("lease.get", { leaseId: "lse_nope" }, owner),
).rejects.toBeInstanceOf(UnknownLeaseError);
await expect(
dispatcher.dispatch(
"lease.get",
{ leaseId },
session({ principal: "tok_other", role: "admin" }),
),
).resolves.toMatchObject({ lease: { id: leaseId } });
});

it("lease.cancel: naming a requesterId with no pending request always passes (not-found), admin bypasses too", async () => {
const { dispatcher } = await buildDispatcher();

Expand Down
11 changes: 11 additions & 0 deletions src/daemon/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export class Dispatcher {
"lease.renew": this.#leaseRenew,
"lease.release": this.#leaseRelease,
"lease.list": this.#leaseList,
"lease.get": this.#leaseGet,
"driver.passthrough": this.#driverPassthrough,
"device.exec": this.#deviceExec,
"relay.request": this.#relayRequest,
Expand Down Expand Up @@ -624,6 +625,16 @@ export class Dispatcher {
return { leaseIds: [...leaseIds] };
};

#leaseGet: Handler<"lease.get"> = (input, session) => {
const lease = this.options.registry.snapshot.leases.find(
(candidate) => candidate.id === input.leaseId,
);
if (lease === undefined || (session.role !== "admin" && lease.ownerId !== session.principal)) {
throw new UnknownLeaseError(input.leaseId);
}
return { lease };
};

#leaseList: Handler<"lease.list"> = (_input, session) => {
const leases = this.options.registry.snapshot.leases.filter(
(lease) => session.role === "admin" || lease.ownerId === session.principal,
Expand Down
27 changes: 27 additions & 0 deletions src/daemon/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2935,6 +2935,33 @@ describe("DaemonServer roles and ownership (ADR 0003 §2-4)", () => {
await admin.close();
});

it("lease.get over the socket answers the caller's own lease and UNKNOWN_LEASE for another agent's", async () => {
const harness = await createHarness({ resolveRole: { resolve: () => "agent" } });
const alice = await createClient(harness.socketPath);
await helloAs(alice, "alice");
const grant = await alice.request("lease.request", {
model: "iPhone 16",
osVersion: "26.5",
platform: "ios",
});
const leaseId = leaseIdOf(grant);
const bob = await createClient(harness.socketPath);
await helloAs(bob, "bob");

const own = await alice.request("lease.get", { leaseId });
expect(own.ok).toBe(true);
expect((own.payload as { lease: { id: string } }).lease).toMatchObject({
id: leaseId,
ownerId: "alice",
});
const other = await bob.request("lease.get", { leaseId });
expect(other.ok).toBe(false);
expect(other.error).toMatchObject({ code: "UNKNOWN_LEASE" });

await alice.close();
await bob.close();
});

it("forwards ttlMs as the initial TTL of any lease, and stores it as the lease's width", async () => {
const harness = await createHarness({ resolveRole: { resolve: () => "agent" } });
const client = await createClient(harness.socketPath);
Expand Down
6 changes: 6 additions & 0 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,12 @@ export class DaemonServer {
frame.payload ?? {},
this.#session(connection),
);
case "lease.get":
return this.#dispatcher.dispatch(
"lease.get",
frame.payload ?? {},
this.#session(connection),
);
case "lease.list":
return this.#dispatcher.dispatch(
"lease.list",
Expand Down
6 changes: 3 additions & 3 deletions src/gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ 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) answer from
views. `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` (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`). `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
91 changes: 91 additions & 0 deletions src/gateway/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,97 @@ describe("GatewayDispatcher", () => {
);
});

it("lease.get reads one lease by its gateway id for its owner or an admin, a worker's own local lease for an admin only, and answers UNKNOWN_LEASE otherwise", async () => {
const { dispatcher, leaseIndex, workers } = harness();
workers.connected("wrk_1", "mac-mini-1", undefined);
const issued = {
...leaseFixture("lease_1", "dev_1"),
ownerId: "agent-1",
requesterId: `${GATEWAY_REQUESTER_PREFIX}agent-1`,
};
const local = { ...leaseFixture("local_1", "dev_2"), ownerId: "worker-agent" };
workers.refresh("wrk_1", { leases: [issued, local] });
const owner = session({ principal: "agent-1", role: "agent" });
const stranger = session({ principal: "agent-2", role: "agent" });
const get = (leaseId: string, who: ReturnType<typeof session>) =>
dispatcher.dispatch("lease.get", { leaseId }, who);

await expect(get("wrk_1.lease_1", owner)).resolves.toEqual({
lease: expect.objectContaining({ id: "wrk_1.lease_1", workerId: "wrk_1" }),
});
await expect(get("wrk_1.lease_1", session())).resolves.toMatchObject({
lease: { id: "wrk_1.lease_1" },
});
await expect(get("local_1", session())).resolves.toMatchObject({
lease: { id: "local_1", workerId: "wrk_1" },
});
const unknown = { code: "UNKNOWN_LEASE", details: { leaseId: expect.any(String) } };
await expect(get("wrk_1.lease_1", stranger)).rejects.toMatchObject(unknown);
await expect(get("local_1", stranger)).rejects.toMatchObject(unknown);
await expect(get("wrk_1.lease_nope", session())).rejects.toMatchObject(unknown);
await expect(get("lease_1", session())).rejects.toMatchObject(unknown);
// why: an index entry can outlive its worker view, or the view its lease.
for (const [workerId, workerLeaseId] of [
["wrk_gone", "lease_9"],
["wrk_1", "lease_gone"],
] as const) {
leaseIndex.add({
gatewayLeaseId: `${workerId}.${workerLeaseId}`,
grantedAt: 1,
ownerId: "agent-1",
requesterId: `${GATEWAY_REQUESTER_PREFIX}agent-1`,
workerId,
workerLeaseId,
});
await expect(get(`${workerId}.${workerLeaseId}`, session())).rejects.toMatchObject(unknown);
await expect(get(`${workerId}.${workerLeaseId}`, owner)).rejects.toMatchObject(unknown);
}
});

it("names the worker's own lease id as workerLeaseId on the leases of lease.list, lease.get and list.get kind=leases, never on status.get", async () => {
const { dispatcher, workers } = harness();
workers.connected("wrk_1", "mac-mini-1", undefined);
const issued = (id: string, chosen: boolean) => ({
...leaseFixture(id, `dev_${id}`),
idChosenByRequester: chosen,
ownerId: "agent-1",
requesterId: `${GATEWAY_REQUESTER_PREFIX}agent-1`,
});
workers.refresh("wrk_1", {
leases: [
issued("lse_42", false),
issued("my-lease", true),
leaseFixture("local_1", "dev_local"),
],
});
const expected = [
{ id: "wrk_1.lse_42", workerLeaseId: "lse_42" },
{ id: "my-lease", workerLeaseId: "my-lease" },
{ id: "local_1", workerLeaseId: "local_1" },
];
const pick = (leases: readonly { id: string; workerLeaseId?: string | undefined }[]) =>
leases.map(({ id, workerLeaseId }) => ({ id, workerLeaseId }));

const listed = await dispatcher.dispatch("lease.list", {}, session());
expect(pick(listed.leases)).toEqual(expected);
const fleet = await dispatcher.dispatch("list.get", { kind: "leases" }, session());
expect(pick(fleet as never)).toEqual(expected);
const owned = await dispatcher.dispatch(
"lease.list",
{},
session({ principal: "agent-1", role: "agent" }),
);
expect(pick(owned.leases)).toEqual(expected.slice(0, 2));
for (const { id, workerLeaseId } of expected) {
const { lease } = await dispatcher.dispatch("lease.get", { leaseId: id }, session());
expect(lease).toMatchObject({ id, workerLeaseId });
}
const status = await dispatcher.dispatch("status.get", {}, session());
expect((status.leases ?? []).map((lease) => Object.keys(lease))).toEqual(
expected.map(() => expect.not.arrayContaining(["workerLeaseId"])),
);
});

it("lists no device or lease for a worker that is starting, whether the session is an admin or the owner of a lease the gateway indexed on it, and answers worker.list with the view as it is", async () => {
const { dispatcher, workers } = harness();
workers.connected("wrk_1", "mac-mini-1", undefined);
Expand Down
Loading
Loading