diff --git a/src/contract/AGENTS.md b/src/contract/AGENTS.md index ccdf3da0..86783d79 100644 --- a/src/contract/AGENTS.md +++ b/src/contract/AGENTS.md @@ -105,6 +105,9 @@ Three identities: - OS constraint: a bare version is exact (string equality). A range is one or more of `>=`, `>`, `<=`, `<` + a dotted version, joined by single spaces, or `A - B`. `^`, `~`, `*`, `||` and `x` read as a range and are refused. +- `lease.request` holds an exact `osVersion` to `componentVersionSchema`'s bound (1 to 64 + characters, no whitespace, control character or `;`, no leading `-`): a downloadable missing + runtime installs it as a component. - A partial version covers its prefix: `<=26` takes every 26.x, `>26` excludes it. A version that is not dotted integers (`Baklava`) matches only exactly. - Duration: `500` (ms), `2s`, `3m`, `1h`, `7d`. `undefined` when not one or not a safe integer. diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index 8af039f6..17267d3f 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -248,6 +248,7 @@ describe("component.install input", () => { ["a newline", "35\n"], ["a control character", "35\u0000"], ["starting with '-', like an option", "-35"], + ["carrying ';', sdkmanager's package path separator", "35;extras"], ])("refuses a version that is %s", (_label, version) => { expect(parse(version)).toBe(false); }); @@ -261,12 +262,20 @@ describe("lease.request osVersion", () => { platform: "ios", }); - it.each([["18.4"], ["Baklava"], ["34-ext12"], [">=18"], [">=18 <26"], ["18 - 26"]])( - "accepts %s", - (osVersion) => { - expect(parse(osVersion).success).toBe(true); - }, - ); + it.each([ + ["18.4"], + ["17.4"], + ["34"], + ["18.5.1"], + ["Baklava"], + ["34-ext12"], + ["x".repeat(64)], + [">=18"], + [">=18 <26"], + ["18 - 26"], + ])("accepts %s", (osVersion) => { + expect(parse(osVersion).success).toBe(true); + }); it.each([["^18"], ["~18"], ["18.x"], ["18.X"], ["*"], [">=18 || <17"], [""]])( "refuses %j with a message naming the accepted forms", @@ -279,6 +288,28 @@ describe("lease.request osVersion", () => { } }, ); + + it.each([ + ["trailing text after a space", "17.0 -foo"], + ["a leading '-', like an option", "-1"], + ["a ';', sdkmanager's package path separator", "34;extras"], + ["more than 64 characters", "x".repeat(65)], + ["a control character", "17.4\u0007"], + ["a leading space", " 17.4"], + ])( + "refuses an exact version with %s, naming the bound a component version has", + (_label, osVersion) => { + const result = parse(osVersion); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0]?.message).toBe( + "an exact OS version is 1 to 64 characters, none of them whitespace, a control character or `;`, and does not start with `-`", + ); + expect(result.error.issues[0]?.path).toEqual(["osVersion"]); + } + }, + ); }); describe("lease.request leaseId", () => { diff --git a/src/contract/operations.ts b/src/contract/operations.ts index da41b603..4f0c381a 100644 --- a/src/contract/operations.ts +++ b/src/contract/operations.ts @@ -151,6 +151,22 @@ export const LEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; export const leaseIdSchema = z.string().regex(LEASE_ID_PATTERN); +/** + * why: a component's version (`26.4`, `35`) reaches the driver unread and becomes an installer + * argument, so its shape is bounded here (safety rule 10): no whitespace or control character, no + * `;` (sdkmanager's package path separator), no leading `-` (an option). An exact `osVersion` in + * `lease.request` is held to it too, because a downloadable missing runtime installs that string. + */ +const componentVersionSchema = z + .string() + .min(1) + .max(64) + .regex(/^[^\s\p{Cc};]+$/u, "must contain no whitespace, control character or ';'") + .refine((version) => !version.startsWith("-"), "must not start with '-'"); + +const EXACT_OS_VERSION_BOUND = + "an exact OS version is 1 to 64 characters, none of them whitespace, a control character or `;`, and does not start with `-`"; + const leaseRequestBaseSchema = z .object({ model: z.string().min(1).optional(), @@ -281,6 +297,17 @@ function refuseBadOsVersion(): !version.startsWith("-"), "must not start with '-'"); - /** * Installs one iOS simulator runtime or Android system image without leasing a device (ADR 0010 * §6). Admin-only: the command is an operator's consent to a multi-GB download, which diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index f7693da0..0b300c07 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -59,7 +59,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: 59, max: 59 }` (`src/contract/protocol.ts`). No overlap → the + is `{ min: 62, max: 62 }` (`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`. diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index f11232f5..928f19fa 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -2410,7 +2410,16 @@ describe("DaemonServer download policy", () => { it("still answers a lease request whose download names a component past the progress push's bound, sending no downloading push for it and logging that", async () => { const clock = new FakeClock(1_000); - const driver = new FakeDriver({ availableOsVersions: [], clock, platform: "ios" }); + const driver = new FakeDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }); + // why: the driver, not the request, names the component: a request's exact osVersion is held + // within the push's bound. Every resolve before the install answers as a missing runtime does. + for (const callNumber of [1, 2]) { + driver.failOn( + "resolveSpec", + callNumber, + new RuntimeMissingError("ios", "26.5", { component: "9".repeat(65) }), + ); + } const sink = new MemoryLogSink(); const harness = await createHarness({ clock, @@ -2419,13 +2428,12 @@ describe("DaemonServer download policy", () => { }); const client = await createClient(harness.socketPath); await hello(client); - const osVersion = "9".repeat(65); const grant = await client.request("lease.request", { allowDownload: true, requesterId: "agent-1", model: "iPhone 16", - osVersion, + osVersion: "26.5", platform: "ios", }); diff --git a/website/src/content/docs/docs/using-devices/choosing-a-device.mdx b/website/src/content/docs/docs/using-devices/choosing-a-device.mdx index 24f76e5d..571539ec 100644 --- a/website/src/content/docs/docs/using-devices/choosing-a-device.mdx +++ b/website/src/content/docs/docs/using-devices/choosing-a-device.mdx @@ -73,7 +73,7 @@ Without `--os`, the lease gets the newest runtime installed for that platform. U A range is one or more of `>=`, `>`, `<=`, `<` followed by a version, separated by single spaces, or a hyphen range with spaces around the hyphen. A short version covers everything under it, so `<=26` includes 26.1. Quote ranges so the shell does not read `>` or `<`. -Forms like `^18`, `~18`, `18.x`, `*` and `||` are refused with `BAD_REQUEST` (exit 2). Any other text is an exact version, which is how Android names such as `Baklava` or `34-ext12` work. +Forms like `^18`, `~18`, `18.x`, `*` and `||` are refused with `BAD_REQUEST` (exit 2). Any other text is an exact version, which is how Android names such as `Baklava` or `34-ext12` work. An exact version is 1 to 64 characters with no spaces, control characters or `;`, and does not start with `-`; anything else is refused with `BAD_REQUEST`. A range takes an idle device whose OS is inside it first, and otherwise creates a device on the newest installed runtime inside it. A range never downloads anything: if no installed runtime is inside it, the lease fails at once with `RUNTIME_MISSING` (exit 12).