@intentic/sandbox-contract 1.244.0 → 1.246.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +17 -1
  2. package/dist/batch-runs.d.ts +32 -0
  3. package/dist/batch-runs.d.ts.map +1 -0
  4. package/dist/batch-runs.js +43 -0
  5. package/dist/batch-runs.js.map +1 -0
  6. package/dist/command-classes.d.ts +6 -3
  7. package/dist/command-classes.d.ts.map +1 -1
  8. package/dist/command-classes.js +43 -18
  9. package/dist/command-classes.js.map +1 -1
  10. package/dist/contracts/{cursor.contract.d.ts → accounts.contract.d.ts} +102 -3
  11. package/dist/contracts/accounts.contract.d.ts.map +1 -0
  12. package/dist/contracts/accounts.contract.js +61 -0
  13. package/dist/contracts/accounts.contract.js.map +1 -0
  14. package/dist/contracts/agents.contract.d.ts +121 -0
  15. package/dist/contracts/agents.contract.d.ts.map +1 -1
  16. package/dist/contracts/agents.contract.js +4 -4
  17. package/dist/contracts/agents.contract.js.map +1 -1
  18. package/dist/contracts/ci.contract.d.ts +2 -0
  19. package/dist/contracts/ci.contract.d.ts.map +1 -1
  20. package/dist/contracts/host.contract.d.ts +35 -0
  21. package/dist/contracts/host.contract.d.ts.map +1 -1
  22. package/dist/contracts/host.contract.js +3 -2
  23. package/dist/contracts/host.contract.js.map +1 -1
  24. package/dist/contracts/personas.contract.d.ts +4 -2
  25. package/dist/contracts/personas.contract.d.ts.map +1 -1
  26. package/dist/contracts/runner.contract.d.ts +2 -2
  27. package/dist/contracts/settings.contract.d.ts +2 -51
  28. package/dist/contracts/settings.contract.d.ts.map +1 -1
  29. package/dist/contracts/system.contract.d.ts +52 -2
  30. package/dist/contracts/system.contract.d.ts.map +1 -1
  31. package/dist/contracts/system.contract.js +26 -17
  32. package/dist/contracts/system.contract.js.map +1 -1
  33. package/dist/definition.d.ts +4 -8
  34. package/dist/definition.d.ts.map +1 -1
  35. package/dist/documents.d.ts +0 -1
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +1 -2
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed.d.ts +23 -0
  40. package/dist/embed.d.ts.map +1 -0
  41. package/dist/embed.js +84 -0
  42. package/dist/embed.js.map +1 -0
  43. package/dist/events.d.ts +21 -0
  44. package/dist/events.d.ts.map +1 -1
  45. package/dist/events.js +5 -2
  46. package/dist/events.js.map +1 -1
  47. package/dist/history-state.d.ts.map +1 -1
  48. package/dist/history-state.js +2 -0
  49. package/dist/history-state.js.map +1 -1
  50. package/dist/index.d.ts +354 -335
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +5 -13
  53. package/dist/index.js.map +1 -1
  54. package/dist/peer-dial.d.ts +33 -0
  55. package/dist/peer-dial.d.ts.map +1 -0
  56. package/dist/peer-dial.js +79 -0
  57. package/dist/peer-dial.js.map +1 -0
  58. package/dist/peer-mcp-server.d.ts +36 -0
  59. package/dist/peer-mcp-server.d.ts.map +1 -0
  60. package/dist/peer-mcp-server.js +71 -0
  61. package/dist/peer-mcp-server.js.map +1 -0
  62. package/dist/provider-specs.d.ts +38 -20
  63. package/dist/provider-specs.d.ts.map +1 -1
  64. package/dist/provider-specs.js +39 -13
  65. package/dist/provider-specs.js.map +1 -1
  66. package/dist/quick-model.d.ts.map +1 -1
  67. package/dist/quick-model.js +2 -1
  68. package/dist/quick-model.js.map +1 -1
  69. package/dist/runtime-state.d.ts +1 -1
  70. package/dist/runtime-state.js +1 -1
  71. package/dist/runtime-state.js.map +1 -1
  72. package/dist/safety-policy.d.ts +12 -3
  73. package/dist/safety-policy.d.ts.map +1 -1
  74. package/dist/safety-policy.js +30 -5
  75. package/dist/safety-policy.js.map +1 -1
  76. package/dist/schemas/agent.d.ts +6 -6
  77. package/dist/schemas/agent.d.ts.map +1 -1
  78. package/dist/schemas/agent.js +6 -2
  79. package/dist/schemas/agent.js.map +1 -1
  80. package/dist/schemas/agents.d.ts +42 -0
  81. package/dist/schemas/agents.d.ts.map +1 -1
  82. package/dist/schemas/agents.js +25 -4
  83. package/dist/schemas/agents.js.map +1 -1
  84. package/dist/schemas/automations.d.ts +11 -2
  85. package/dist/schemas/automations.d.ts.map +1 -1
  86. package/dist/schemas/automations.js +1 -1
  87. package/dist/schemas/automations.js.map +1 -1
  88. package/dist/schemas/ci.d.ts +6 -0
  89. package/dist/schemas/ci.d.ts.map +1 -1
  90. package/dist/schemas/ci.js +3 -2
  91. package/dist/schemas/ci.js.map +1 -1
  92. package/dist/schemas/context.d.ts +30 -0
  93. package/dist/schemas/context.d.ts.map +1 -0
  94. package/dist/schemas/context.js +34 -0
  95. package/dist/schemas/context.js.map +1 -0
  96. package/dist/schemas/{computers.d.ts → devices.d.ts} +155 -61
  97. package/dist/schemas/devices.d.ts.map +1 -0
  98. package/dist/schemas/devices.js +157 -0
  99. package/dist/schemas/devices.js.map +1 -0
  100. package/dist/schemas/hosts.d.ts +12 -0
  101. package/dist/schemas/hosts.d.ts.map +1 -1
  102. package/dist/schemas/hosts.js +1 -0
  103. package/dist/schemas/hosts.js.map +1 -1
  104. package/dist/schemas/issues.d.ts +0 -5
  105. package/dist/schemas/issues.d.ts.map +1 -1
  106. package/dist/schemas/issues.js +0 -1
  107. package/dist/schemas/issues.js.map +1 -1
  108. package/dist/schemas/personas.d.ts +5 -3
  109. package/dist/schemas/personas.d.ts.map +1 -1
  110. package/dist/schemas/personas.js +3 -2
  111. package/dist/schemas/personas.js.map +1 -1
  112. package/dist/schemas/provider-oauth.d.ts +48 -16
  113. package/dist/schemas/provider-oauth.d.ts.map +1 -1
  114. package/dist/schemas/provider-oauth.js +22 -20
  115. package/dist/schemas/provider-oauth.js.map +1 -1
  116. package/dist/schemas/settings.d.ts +1 -52
  117. package/dist/schemas/settings.d.ts.map +1 -1
  118. package/dist/schemas/settings.js +8 -11
  119. package/dist/schemas/settings.js.map +1 -1
  120. package/dist/schemas/terminal.js +9 -9
  121. package/dist/schemas/terminal.js.map +1 -1
  122. package/dist/schemas/usage.d.ts +0 -2
  123. package/dist/schemas/usage.d.ts.map +1 -1
  124. package/dist/schemas/usage.js +0 -2
  125. package/dist/schemas/usage.js.map +1 -1
  126. package/dist/shell-regions.d.ts +4 -0
  127. package/dist/shell-regions.d.ts.map +1 -0
  128. package/dist/shell-regions.js +156 -0
  129. package/dist/shell-regions.js.map +1 -0
  130. package/dist/workspace-state.d.ts +15 -0
  131. package/dist/workspace-state.d.ts.map +1 -1
  132. package/dist/workspace-state.js +21 -5
  133. package/dist/workspace-state.js.map +1 -1
  134. package/package.json +48 -4
  135. package/src/agent-catalog.ts +1 -1
  136. package/src/arrival.ts +3 -3
  137. package/src/batch-runs.test.ts +167 -0
  138. package/src/batch-runs.ts +181 -0
  139. package/src/command-classes.test.ts +195 -71
  140. package/src/command-classes.ts +148 -46
  141. package/src/contracts/accounts.contract.ts +94 -0
  142. package/src/contracts/agents.contract.ts +4 -3
  143. package/src/contracts/exit.contract.ts +2 -2
  144. package/src/contracts/host.contract.ts +17 -5
  145. package/src/contracts/settings.contract.ts +1 -1
  146. package/src/contracts/system.contract.ts +43 -24
  147. package/src/contracts/vpn.contract.ts +2 -2
  148. package/src/documents.test.ts +2 -1
  149. package/src/documents.ts +7 -11
  150. package/src/embed.test.ts +68 -0
  151. package/src/embed.ts +164 -0
  152. package/src/events.ts +30 -3
  153. package/src/history-state.ts +12 -3
  154. package/src/host-protocol.ts +2 -2
  155. package/src/index.ts +6 -14
  156. package/src/model-order.ts +1 -1
  157. package/src/peer-dial.test.ts +203 -0
  158. package/src/peer-dial.ts +163 -0
  159. package/src/peer-mcp-server.test.ts +104 -0
  160. package/src/peer-mcp-server.ts +144 -0
  161. package/src/provider-specs.test.ts +44 -17
  162. package/src/provider-specs.ts +147 -67
  163. package/src/quick-model.ts +10 -3
  164. package/src/routes.test.ts +6 -3
  165. package/src/runner-protocol.ts +1 -1
  166. package/src/runtime-state.ts +2 -2
  167. package/src/safety-policy.test.ts +88 -0
  168. package/src/safety-policy.ts +84 -14
  169. package/src/schemas/agent.ts +41 -13
  170. package/src/schemas/agents.ts +65 -4
  171. package/src/schemas/automations.ts +6 -4
  172. package/src/schemas/capabilities.ts +4 -4
  173. package/src/schemas/ci.ts +23 -6
  174. package/src/schemas/context.ts +87 -0
  175. package/src/schemas/{computers.ts → devices.ts} +201 -109
  176. package/src/schemas/hosts.ts +5 -1
  177. package/src/schemas/issues.ts +0 -4
  178. package/src/schemas/personas.ts +8 -3
  179. package/src/schemas/provider-oauth.ts +49 -52
  180. package/src/schemas/settings.ts +30 -54
  181. package/src/schemas/terminal.ts +12 -12
  182. package/src/schemas/usage.ts +3 -27
  183. package/src/schemas/version-seam.test.ts +0 -1
  184. package/src/shell-regions.ts +289 -0
  185. package/src/versions.ts +2 -2
  186. package/src/webext-links.ts +2 -2
  187. package/src/webext-protocol.ts +2 -2
  188. package/src/workspace-state.test.ts +55 -1
  189. package/src/workspace-state.ts +76 -11
  190. package/dist/contracts/claude.contract.d.ts +0 -91
  191. package/dist/contracts/claude.contract.d.ts.map +0 -1
  192. package/dist/contracts/claude.contract.js +0 -50
  193. package/dist/contracts/claude.contract.js.map +0 -1
  194. package/dist/contracts/cursor.contract.d.ts.map +0 -1
  195. package/dist/contracts/cursor.contract.js +0 -50
  196. package/dist/contracts/cursor.contract.js.map +0 -1
  197. package/dist/contracts/grok.contract.d.ts +0 -36
  198. package/dist/contracts/grok.contract.d.ts.map +0 -1
  199. package/dist/contracts/grok.contract.js +0 -31
  200. package/dist/contracts/grok.contract.js.map +0 -1
  201. package/dist/contracts/keys.contract.d.ts +0 -81
  202. package/dist/contracts/keys.contract.d.ts.map +0 -1
  203. package/dist/contracts/keys.contract.js +0 -51
  204. package/dist/contracts/keys.contract.js.map +0 -1
  205. package/dist/schemas/computers.d.ts.map +0 -1
  206. package/dist/schemas/computers.js +0 -134
  207. package/dist/schemas/computers.js.map +0 -1
  208. package/src/contracts/claude.contract.ts +0 -71
  209. package/src/contracts/cursor.contract.ts +0 -74
  210. package/src/contracts/grok.contract.ts +0 -41
  211. package/src/contracts/keys.contract.ts +0 -79
@@ -0,0 +1,144 @@
1
+ import { z } from "zod";
2
+ import { MCP_PROTOCOL_VERSION } from "./host-protocol.js";
3
+
4
+ /* THE MCP SERVER A PEER RUNS, on the peer, not in the sandbox.
5
+ *
6
+ * The sandbox's daemon forwards JSON-RPC verbatim and interprets none of it, so the tool table handed to this is
7
+ * the entire tool surface: what a device or a browser can do is decided by the build installed on it, and a
8
+ * peer that upgrades learns new tools without anything changing in the sandbox. That is the reason for the
9
+ * split; the alternative (schemas in the daemon, execution on the peer) makes every new tool a coordinated
10
+ * release of two products.
11
+ *
12
+ * The protocol implemented is the subset a Streamable HTTP client actually uses against a stateless server:
13
+ * initialize, tools/list, tools/call, ping, and notifications (which get no reply). Anything else answers
14
+ * "method not found", which is the correct JSON-RPC response and not an error worth logging.
15
+ *
16
+ * A FAILED TOOL IS NOT A FAILED CALL. Every error, a refused scope, a missing file, a command that exited 1, an
17
+ * argument that does not typecheck, comes back as a normal result with isError, because that is what a model can
18
+ * read and act on; a JSON-RPC error surfaces as a transport fault and invites a retry loop against a peer that
19
+ * will refuse it exactly the same way the second time.
20
+ *
21
+ * EACH TOOL'S ARGUMENTS ARE DESCRIBED ONCE. The zod schema a tool is built with is what the model is shown
22
+ * (`tools/list` publishes it as JSON Schema) AND what an arriving call is checked against, so the advertised
23
+ * shape and the accepted one cannot drift, the failure mode of writing both by hand, where a renamed field
24
+ * keeps validating and the model keeps being told about the old name. A handler receives its arguments typed. */
25
+
26
+ export interface McpTool<Ctx> {
27
+ readonly name: string;
28
+ readonly description: string;
29
+ // JSON Schema for `tools/list`, derived from the zod schema once at module load rather than per request.
30
+ readonly inputSchema: Record<string, unknown>;
31
+ readonly call: (args: unknown, ctx: Ctx) => Promise<Record<string, unknown>>;
32
+ }
33
+
34
+ export const textResult = (text: string, isError = false): Record<string, unknown> => ({ content: [{ type: "text", text }], isError });
35
+
36
+ /* One tool, from the only description of its arguments there is. The generic is what carries the schema's type
37
+ * through to the handler's parameter; `McpTool` erases it again, because the dispatch table holds them all and
38
+ * the parse is what re-establishes the type at the boundary. `Ctx` is what the peer hands every call beside
39
+ * its arguments: a device's live grant, nothing for a browser.
40
+ *
41
+ * `$schema` is dropped: the enclosing tool entry already says what this document is, and MCP clients read the
42
+ * keywords rather than the dialect declaration. */
43
+ export const tool = <Schema extends z.ZodType, Ctx>(spec: {
44
+ readonly name: string;
45
+ readonly description: string;
46
+ readonly input: Schema;
47
+ readonly run: (args: z.output<Schema>, ctx: Ctx) => Promise<Record<string, unknown>>;
48
+ }): McpTool<Ctx> => {
49
+ const { $schema: _dialect, ...inputSchema } = z.toJSONSchema(spec.input, { io: "input" });
50
+ return {
51
+ name: spec.name,
52
+ description: spec.description,
53
+ inputSchema,
54
+ call: async (args, ctx) => {
55
+ const parsed = spec.input.safeParse(args);
56
+ // Readable enough for a model to fix its own call: which field, and what was expected there.
57
+ return parsed.success ? await spec.run(parsed.data, ctx) : textResult(z.prettifyError(parsed.error), true);
58
+ },
59
+ };
60
+ };
61
+
62
+ // What the peer's audit log is told about one call: the arguments verbatim (redaction is the peer's, it knows
63
+ // which of its tools carry typed secrets), and how it ended. A tool that answered with isError is `ok: false`
64
+ // with no message; one that threw carries what it said, and whether it was the peer's own refusal.
65
+ export interface McpAuditEntry {
66
+ readonly tool: string;
67
+ readonly args: Record<string, unknown>;
68
+ readonly ok: boolean;
69
+ readonly failure?: { readonly refused: boolean; readonly message: string };
70
+ }
71
+
72
+ export interface McpServerSpec<Ctx> {
73
+ readonly serverInfo: () => { readonly name: string; readonly version: string };
74
+ readonly tools: readonly McpTool<Ctx>[];
75
+ // The sentence for a tool this peer does not have, in the peer's own noun.
76
+ readonly noSuchTool: (name: string) => string;
77
+ // Whether a thrown error is this peer's own refusal (a switch that is off, a site that is not granted) as
78
+ // opposed to a tool that failed: the audit line says which.
79
+ readonly refused: (error: unknown) => boolean;
80
+ readonly errorMessage: (error: unknown) => string;
81
+ // Every call, accepted or refused, once it has an outcome. Best-effort: a log that cannot be written must
82
+ // never fail the answer, so a rejection here is swallowed.
83
+ readonly audit: (entry: McpAuditEntry) => Promise<void> | void;
84
+ }
85
+
86
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
87
+
88
+ // Handle one JSON-RPC message. Returns the response, or undefined for a notification (nothing to answer). `ctx`
89
+ // is read per call by the tool that runs, so a grant pushed mid-session takes effect on the very next call.
90
+ export const createMcpServer = <Ctx>(spec: McpServerSpec<Ctx>): ((message: unknown, ctx: Ctx) => Promise<Record<string, unknown> | undefined>) => {
91
+ const byName = new Map(spec.tools.map((entry) => [entry.name, entry]));
92
+ const listing = spec.tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
93
+ const audited = async (entry: McpAuditEntry): Promise<void> => {
94
+ try {
95
+ await spec.audit(entry);
96
+ } catch {
97
+ // Deliberately silent: a record for a human, never a control.
98
+ }
99
+ };
100
+
101
+ const callTool = async (name: string, args: Record<string, unknown>, ctx: Ctx): Promise<Record<string, unknown>> => {
102
+ const found = byName.get(name);
103
+ if (found === undefined) {
104
+ return textResult(spec.noSuchTool(name), true);
105
+ }
106
+ try {
107
+ const result = await found.call(args, ctx);
108
+ await audited({ tool: name, args, ok: result["isError"] !== true });
109
+ return result;
110
+ } catch (error) {
111
+ const message = spec.errorMessage(error);
112
+ await audited({ tool: name, args, ok: false, failure: { refused: spec.refused(error), message } });
113
+ return textResult(message, true);
114
+ }
115
+ };
116
+
117
+ return async (message, ctx) => {
118
+ if (!isRecord(message)) {
119
+ return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } };
120
+ }
121
+ const id = message["id"];
122
+ if (id === undefined) {
123
+ return undefined;
124
+ }
125
+ const method = message["method"];
126
+ const reply = (result: Record<string, unknown>): Record<string, unknown> => ({ jsonrpc: "2.0", id, result });
127
+ if (method === "initialize") {
128
+ return reply({ protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: spec.serverInfo() });
129
+ }
130
+ if (method === "ping") {
131
+ return reply({});
132
+ }
133
+ if (method === "tools/list") {
134
+ return reply({ tools: listing });
135
+ }
136
+ if (method === "tools/call") {
137
+ const params = isRecord(message["params"]) ? message["params"] : {};
138
+ const name = typeof params["name"] === "string" ? params["name"] : "";
139
+ const args = isRecord(params["arguments"]) ? params["arguments"] : {};
140
+ return reply(await callTool(name, args, ctx));
141
+ }
142
+ return { jsonrpc: "2.0", id, error: { code: -32601, message: `method "${String(method)}" is not supported` } };
143
+ };
144
+ };
@@ -1,7 +1,16 @@
1
1
  import { PROVIDER_BRAND_PATHS } from "@intentic/constants";
2
2
  import { describe, expect, test } from "vitest";
3
3
  import { accessFor, capabilitiesOf, harnessChoosable, PROVIDER_ACCESS, PROVIDER_VENDOR, PROVIDERS, providerLabel } from "./agent-catalog.js";
4
- import { ACCESS_COST, cliProxyIdOf, KEY_PROVIDERS, keyEndpointOf, NATIVE_PROVIDERS, PROVIDER_SPECS, providerSpec } from "./provider-specs.js";
4
+ import {
5
+ ACCESS_COST,
6
+ cliProxyIdOf,
7
+ MINTED_PROVIDERS,
8
+ mintedVariant,
9
+ mintedVariants,
10
+ NATIVE_PROVIDERS,
11
+ PROVIDER_SPECS,
12
+ providerSpec,
13
+ } from "./provider-specs.js";
5
14
  import { NativeProviderParamSchema } from "./schemas/agent.js";
6
15
  import { TranslatorAccountsSchema } from "./schemas/plan-limits.js";
7
16
  import { KeyedProviderSchema } from "./schemas/provider-subscriptions.js";
@@ -44,22 +53,39 @@ describe("every provider in the table", () => {
44
53
  expect(Object.keys(TranslatorAccountsSchema.shape), `${id} has no slot in TranslatorAccounts`).toContain(id);
45
54
  return;
46
55
  }
47
- if (spec.auth.kind === "key") {
48
- const endpoint = keyEndpointOf(id);
49
- expect(endpoint, `${id} is keyed but names no endpoint`).toMatchObject({ kind: "key" });
50
- expect(KEY_PROVIDERS, `${id} is keyed but missing from KEY_PROVIDERS`).toContain(id);
51
- /* THE TURN URL AND THE CATALOG URL ARE DIFFERENT SURFACES OF ONE VENDOR, and both halves of that
52
- * sentence are asserted: same host (a pair that drifted apart would send the catalog to one vendor
53
- * and the turn to another), and the turn's base carries NO version segment, because the harness
54
- * appends `/v1/messages` itself and a doubled one is a 404 mid-conversation. */
55
- expect(new URL(endpoint!.anthropicBase).host, `${id}'s catalog and turn hosts differ`).toBe(new URL(endpoint!.catalogBase).host);
56
- expect(endpoint!.anthropicBase, `${id}'s turn base carries a version segment the harness would double`).not.toMatch(/\/v\d+$/);
57
- expect(new URL(endpoint!.console).protocol, `${id}'s key console is not a link`).toBe("https:");
56
+ if (spec.auth.kind === "minted") {
57
+ expect(MINTED_PROVIDERS, `${id} is minted but missing from MINTED_PROVIDERS`).toContain(id);
58
+ // At least one estate to sign in to, because the head of this list is what a `login/start` that names
59
+ // no variant gets: an empty one is a provider whose connect row has no button that can do anything.
60
+ expect(spec.auth.variants.length, `${id} is minted but offers no estate to sign in to`).toBeGreaterThan(0);
61
+ for (const variant of spec.auth.variants) {
62
+ /* THE TURN URL AND THE CATALOG URL ARE DIFFERENT SURFACES OF ONE ESTATE, and both halves of that
63
+ * sentence are asserted: same host (a pair that drifted apart would send the catalog to one
64
+ * vendor and the turn to another), and the turn's base carries NO version segment, because the
65
+ * harness appends `/v1/messages` itself and a doubled one is a 404 mid-conversation. */
66
+ expect(new URL(variant.anthropicBase).host, `${id}/${variant.id}'s catalog and turn hosts differ`).toBe(
67
+ new URL(variant.catalogBase).host,
68
+ );
69
+ expect(variant.anthropicBase, `${id}/${variant.id}'s turn base carries a version segment the harness would double`).not.toMatch(
70
+ /\/v\d+$/,
71
+ );
72
+ expect(variant.label.trim(), `${id}/${variant.id} has no estate label`).not.toBe("");
73
+ // The lookup every surface goes through, rather than the row behind it: an id that names a
74
+ // variant must resolve to that variant and nothing else.
75
+ expect(mintedVariant(id, variant.id), `${id}/${variant.id} does not resolve`).toEqual(variant);
76
+ }
77
+ // An absent variant takes the head of the list, which is what a single-estate provider's connect row
78
+ // always sends and what a `login/start` with no choice on it means.
79
+ expect(mintedVariant(id), `${id}'s default estate is not the head of its list`).toEqual(spec.auth.variants[0]);
80
+ // An estate id nobody declared is NOT the default: falling back would mint a key on one estate and
81
+ // dial it at another, and report the refusal as an authentication problem.
82
+ expect(mintedVariant(id, "no-such-estate"), `${id} defaults an unknown estate instead of refusing`).toBeUndefined();
58
83
  return;
59
84
  }
60
85
  // An oauth provider is served by a handshake this daemon runs; it is in neither routed list.
61
86
  expect(KeyedProviderSchema.options, `${id} is not routed but appears in KeyedProvider`).not.toContain(id);
62
- expect(KEY_PROVIDERS, `${id} is not keyed but appears in KEY_PROVIDERS`).not.toContain(id);
87
+ expect(MINTED_PROVIDERS, `${id} is not minted but appears in MINTED_PROVIDERS`).not.toContain(id);
88
+ expect(mintedVariants(id), `${id} is not minted but names estates`).toBeUndefined();
63
89
  });
64
90
 
65
91
  test.each(PROVIDER_SPECS.map((spec) => ({ id: spec.id })))("$id is in every derived list", ({ id }) => {
@@ -109,11 +135,11 @@ test("the harness chip is offered exactly where the two harnesses run different
109
135
  }
110
136
  });
111
137
 
112
- /* A KEYED PROVIDER HAS NO NATIVE RUNTIME, by construction rather than by coincidence: it is reached by pointing
138
+ /* A MINTED PROVIDER HAS NO NATIVE RUNTIME, by construction rather than by coincidence: it is reached by pointing
113
139
  * the Claude Code loop at the vendor's own Anthropic endpoint, and there is no second loop to point anywhere
114
140
  * else. A spec row that claimed one would produce a picker chip offering a runtime nothing serves. */
115
- test("a keyed provider runs the Claude Code loop on both harnesses", () => {
116
- for (const provider of KEY_PROVIDERS) {
141
+ test("a minted provider runs the Claude Code loop on both harnesses", () => {
142
+ for (const provider of MINTED_PROVIDERS) {
117
143
  expect(capabilitiesOf(provider, "native").runtime, provider).toBe("claude-code");
118
144
  expect(capabilitiesOf(provider, "claude-code").runtime, provider).toBe("claude-code");
119
145
  }
@@ -125,6 +151,7 @@ test("an id that is not a provider resolves to nothing", () => {
125
151
  for (const id of ["", "some-installed-agent", "endpoint/ollama", "META", "z.ai"]) {
126
152
  expect(providerSpec(id), id).toBeUndefined();
127
153
  expect(cliProxyIdOf(id), id).toBeUndefined();
128
- expect(keyEndpointOf(id), id).toBeUndefined();
154
+ expect(mintedVariants(id), id).toBeUndefined();
155
+ expect(mintedVariant(id), id).toBeUndefined();
129
156
  }
130
157
  });
@@ -14,15 +14,14 @@ import { type AgentCapabilities, CLAUDE_CODE, CODEX, CURSOR, OPENCODE, OPENCODE_
14
14
  *
15
15
  * WHAT A ROW IS, and the two axes it deliberately keeps apart:
16
16
  *
17
- * `access` is what a turn COSTS: free, an already-paid subscription with a quota, or a metered key. It is
18
- * what the picker badges, what orders the locked band, and what quick-model spends against
19
- * (ACCESS_COST).
17
+ * `access` is what a turn COSTS: free, or an already-paid subscription with a quota. It is what the picker
18
+ * badges, what orders the locked band, and what quick-model spends against (ACCESS_COST).
20
19
  * `auth` is what the user CONNECTS: an OAuth account this daemon stores, a subscription the bundled
21
- * translator holds, or an API key pasted into a field.
20
+ * translator holds, or a sign-in that mints the vendor's own API key.
22
21
  *
23
22
  * They are not the same question and conflating them is how Z.ai would have been described wrongly whichever
24
- * single word was picked: its cost is a prepaid coding plan, its credential is a key you paste. Keeping the
25
- * axes apart is what lets a surface ask the one it actually needs.
23
+ * single word was picked: its cost is a prepaid coding plan, and the credential that plan is spent through is an
24
+ * API key its sign-in mints. Keeping the axes apart is what lets a surface ask the one it actually needs.
26
25
  *
27
26
  * `brand` is typed against the marks in @intentic/constants, so a provider added without a logo does not
28
27
  * compile. That is deliberate: the fallback glyph is honest for an ACP agent nobody here has heard of, and
@@ -36,7 +35,12 @@ import { type AgentCapabilities, CLAUDE_CODE, CODEX, CURSOR, OPENCODE, OPENCODE_
36
35
  // "can this row actually run" is the first thing a model list has to answer. `free` is not a courtesy tier: the
37
36
  // Google channel serves its models on an ordinary Google sign-in, at no subscription, which is the single most
38
37
  // useful thing this catalog can tell a user who has connected nothing yet.
39
- export type AccessKind = "free" | "subscription" | "key";
38
+ //
39
+ // There is deliberately no `key` rung. Every provider here is unlocked by signing in to something the user
40
+ // already holds, so a per-call metered credential is not a shape this table can describe — a raw API key against
41
+ // somebody's own gateway is an `endpoint` capability (schemas/capabilities.ts), which is not a provider row and
42
+ // never appeared on this axis.
43
+ export type AccessKind = "free" | "subscription";
40
44
 
41
45
  export interface ProviderAccess {
42
46
  readonly kind: AccessKind;
@@ -46,11 +50,11 @@ export interface ProviderAccess {
46
50
  readonly runs: string;
47
51
  }
48
52
 
49
- // What a turn on this provider costs at the MARGIN, ordering the same three kinds by the only question a
50
- // helper spending the user's money on their behalf has to answer: free is free; a subscription is already paid
51
- // but has a quota the user watches; a key is metered, so every call is real money. Deliberately not folded into
52
- // AccessKind's declaration order, a union's order is not a runtime fact, and this one is relied on.
53
- export const ACCESS_COST: Record<AccessKind, number> = { free: 0, subscription: 1, key: 2 };
53
+ // What a turn on this provider costs at the MARGIN, ordering the two kinds by the only question a helper
54
+ // spending the user's allowance on their behalf has to answer: free is free; a subscription is already paid but
55
+ // has a quota the user watches. Deliberately not folded into AccessKind's declaration order, a union's order is
56
+ // not a runtime fact, and this one is relied on.
57
+ export const ACCESS_COST: Record<AccessKind, number> = { free: 0, subscription: 1 };
54
58
 
55
59
  /* HOW A CREDENTIAL FOR THIS PROVIDER IS OBTAINED AND HELD. Three mechanisms, and every surface that used to
56
60
  * branch on a provider's NAME (the web's readiness rules, the connect panel's shape, the daemon's credential
@@ -63,25 +67,53 @@ export const ACCESS_COST: Record<AccessKind, number> = { free: 0, subscription:
63
67
  * "translator" , the bundled CLIProxyAPI holds a SUBSCRIPTION OAuth and re-serves it behind an Anthropic
64
68
  * endpoint, so the Claude Code loop can run a non-Claude model on it. `cliProxy` is that
65
69
  * provider's id in the proxy's own vocabulary, which is not always ours.
66
- * "key" , the user pastes an API key and the harness is pointed straight at the provider's own
67
- * Anthropic Messages endpoint with it. No translator hop, because there is nothing to
68
- * translate the same reasoning an `anthropic`-protocol endpoint capability already rides.
70
+ * "minted" , the daemon runs a sign-in whose token is NOT an inference credential, so it goes on to mint
71
+ * the vendor's own API key from it and stores that. The harness is then pointed straight at the
72
+ * vendor's Anthropic Messages endpoint with the minted key no translator hop, because there
73
+ * is nothing to translate, the same road an `anthropic`-protocol endpoint capability drives.
74
+ *
75
+ * NOBODY PASTES A KEY, and the absence is the point rather than an omission. Both of these vendors sell a plan
76
+ * and issue keys under it, and the first cut of these two providers therefore shipped as a password field — which
77
+ * is a worse product than what the vendors' own CLIs do (their sign-in mints the key) and the only connect flow
78
+ * in this app that asked the user to go and find a credential. The minted mechanism is that sign-in. A raw key
79
+ * against somebody's own gateway is still supported and always was: it is an `endpoint` capability, not this.
69
80
  */
70
81
  export type ProviderAuth =
71
82
  | { readonly kind: "oauth" }
72
83
  | { readonly kind: "translator"; readonly cliProxy: string }
73
- | {
74
- readonly kind: "key";
75
- // What ANTHROPIC_BASE_URL is set to for a turn. WITHOUT a version segment: the harness appends
76
- // `/v1/messages` itself (see the daemon's endpoint-config.ts for why the two ecosystems disagree here).
77
- readonly anthropicBase: string;
78
- // Where the model catalog is read from, an OpenAI-compatible root WITH its version segment, because
79
- // that is the surface both of these vendors publish `GET …/models` on.
80
- readonly catalogBase: string;
81
- // Where a person goes to mint the key. Printed as a link in the connect panel, because "paste your API
82
- // key" is only actionable if you know which of a vendor's several consoles issues it.
83
- readonly console: string;
84
- };
84
+ | { readonly kind: "minted"; readonly variants: readonly MintedVariant[] };
85
+
86
+ /* ONE IDENTITY PROVIDER A MINTED PROVIDER CAN BE SIGNED INTO, and the reason this is a list rather than three
87
+ * fields on the auth row: Z.ai is one product sold through two entirely separate estates. An international plan
88
+ * signs in at chat.z.ai and its key works against api.z.ai; a mainland GLM Coding Plan signs in at bigmodel.cn
89
+ * and its key works against open.bigmodel.cn. Same models, same picker row, same store, and a credential minted
90
+ * on one estate is refused by the other's endpoint.
91
+ *
92
+ * Which is why the bases live HERE and not on the provider: a key knows which variant minted it, and the turn
93
+ * has to dial that variant's host. A provider-wide base URL would send a mainland plan's key to a host that has
94
+ * never heard of it, and the failure would arrive as an authentication error the user cannot act on. */
95
+ export interface MintedVariant {
96
+ // The stored account's record of where it came from, and what a `login/start` names. Never shown.
97
+ readonly id: string;
98
+ // What the connect row's estate control calls it, in the vendor's own words.
99
+ readonly label: string;
100
+ /* HOW THIS SIGN-IN ENDS, which decides the shape of the connect panel and nothing else.
101
+ *
102
+ * "device" , the daemon polls the vendor to completion and the account appears: nothing to paste back
103
+ * (Cursor's shape). Some of these also carry a one-time code to read off the card, which is
104
+ * a fact about the flow at RUNTIME, not about the provider, so it is not on this row.
105
+ * "redirect" , the vendor sends the browser to a loopback address only this container could bind, so the
106
+ * page dead-ends and the grant is in the address bar. The user brings that URL back
107
+ * (Google's shape, picture and all). */
108
+ readonly flow: "device" | "redirect";
109
+ // What ANTHROPIC_BASE_URL is set to for a turn on an account minted here. WITHOUT a version segment: the
110
+ // harness appends `/v1/messages` itself (see the daemon's endpoint-config.ts for why the two ecosystems
111
+ // disagree here).
112
+ readonly anthropicBase: string;
113
+ // Where this estate's model catalog is read from, an OpenAI-compatible root WITH its version segment,
114
+ // because that is the surface these vendors publish `GET …/models` on.
115
+ readonly catalogBase: string;
116
+ }
85
117
 
86
118
  export interface ProviderSpec {
87
119
  // The wire id, and the reserved capability id: an installed `agent` capability may not take one of these.
@@ -126,9 +158,9 @@ export interface ProviderSpec {
126
158
  * to: the api-call substitutes that token server-side like it does for the other two.
127
159
  *
128
160
  * Grok is one absence, because xAI's usable billing data needs a subject id CLIProxyAPI keeps out of its
129
- * auth-file listing, and the fallback probe spends a token to answer. The keyed providers are the other:
130
- * neither publishes a quota surface a stored key can read. Adding one is a reader and this flag, and
131
- * nothing else. */
161
+ * auth-file listing, and the fallback probe spends a token to answer. The minted providers are the other:
162
+ * neither publishes a quota surface their own minted key can read. Adding one is a reader and this flag,
163
+ * and nothing else. */
132
164
  readonly planLimits: boolean;
133
165
  /* THE TWO RUNTIMES THIS PROVIDER RUNS ON, one per value of the harness axis. Equal records mean the harness
134
166
  * is not a choice for this provider, and every surface reads that from here rather than keeping its own
@@ -242,12 +274,12 @@ export const PROVIDER_SPECS = [
242
274
  // Cursor ignores the harness for the mirror of Gemini's reason: there is no route to it but its own SDK.
243
275
  runtimes: { native: CURSOR, claudeCode: CURSOR },
244
276
  },
245
- /* THE TWO KEYED PROVIDERS, and the reason they cost no new runtime, no new adapter and no translator hop:
277
+ /* THE TWO MINTED PROVIDERS, and the reason they cost no new runtime, no new adapter and no translator hop:
246
278
  * both publish an ANTHROPIC MESSAGES endpoint of their own. The Claude Code loop is pointed straight at it
247
- * with the user's key, which is exactly the road an `anthropic`-protocol endpoint capability already
248
- * drives. So they are the Kimi shape — one record on both harnesses, no adapter, a catalog and a readiness
249
- * rung — and everything that makes them feel first-class (the brand, the badge, the section, the account
250
- * row) is these rows and nothing else. */
279
+ * with the key their sign-in minted, which is exactly the road an `anthropic`-protocol endpoint capability
280
+ * already drives. So they are the Kimi shape — one record on both harnesses, no adapter, a catalog and a
281
+ * readiness rung — and everything that makes them feel first-class (the brand, the badge, the section, the
282
+ * account row) is these rows and nothing else. */
251
283
  {
252
284
  id: "meta",
253
285
  label: "Meta",
@@ -255,18 +287,30 @@ export const PROVIDER_SPECS = [
255
287
  accountLabel: "Meta",
256
288
  destination: "Meta",
257
289
  brand: "meta",
258
- // `key`, because the Model API is METERED: every call is real money, which is what ACCESS_COST's third
259
- // rung means and what keeps an automatic helper from reaching for it to write a commit message.
260
- access: { kind: "key", requirement: "Meta Model API key", runs: "Muse Spark under Claude Code" },
290
+ // A `subscription` like the rest, because that is what the sign-in connects: Muse Code's device login
291
+ // mints a plan key, and the plan is what a turn spends. It shipped as `key` for one day, which put it in
292
+ // the metered band and told automatic helpers every call here was real money true of Meta's
293
+ // pay-per-token Model API, and not true of the thing this row now connects.
294
+ access: { kind: "subscription", requirement: "Muse Code subscription", runs: "Muse Spark under Claude Code" },
261
295
  auth: {
262
- kind: "key",
263
- // No version segment: the harness appends `/v1/messages` itself, and Meta serves the Anthropic
264
- // surface there.
265
- anthropicBase: "https://api.meta.ai",
266
- catalogBase: "https://api.meta.ai/v1",
267
- console: "https://dev.meta.ai/docs/getting-started/authentication",
296
+ kind: "minted",
297
+ // One estate, so no choice to offer: `login/start` takes no variant for Meta and the connect row
298
+ // shows no control. The id still exists because a stored account records which variant minted it.
299
+ variants: [
300
+ {
301
+ id: "meta",
302
+ label: "Meta",
303
+ // Meta's is the textbook device flow (RFC 8628): a user code to read off the card, and a
304
+ // poll that finishes without anything coming back here.
305
+ flow: "device",
306
+ // No version segment: the harness appends `/v1/messages` itself, and Meta serves the
307
+ // Anthropic surface there beside the OpenAI one the catalog is read from.
308
+ anthropicBase: "https://api.meta.ai",
309
+ catalogBase: "https://api.meta.ai/v1",
310
+ },
311
+ ],
268
312
  },
269
- // Nothing published that a stored key can read: no quota surface, so an account row shows no meter and
313
+ // Nothing published that a minted key can read: no quota surface, so an account row shows no meter and
270
314
  // says so, rather than showing an empty one that reads as "nothing left".
271
315
  planLimits: false,
272
316
  runtimes: { native: CLAUDE_CODE, claudeCode: CLAUDE_CODE },
@@ -278,21 +322,41 @@ export const PROVIDER_SPECS = [
278
322
  accountLabel: "Z.ai",
279
323
  destination: "Z.ai",
280
324
  brand: "zai",
281
- /* `subscription` while the credential is a KEY, which is precisely the pair the two axes exist to keep
282
- * apart. What is being spent is a GLM Coding Plan: prepaid, with a quota the user watches, which is
283
- * what `subscription` means to ACCESS_COST and to the picker's ordering. What is being CONNECTED is an
284
- * API key pasted into a field, which is what `auth` says. Collapsing the two into one word would have
285
- * been wrong whichever word won. */
325
+ // A GLM Coding Plan: prepaid, with a quota the user watches, which is what `subscription` means to
326
+ // ACCESS_COST and to the picker's ordering. The sign-in mints the plan's own key, so the requirement
327
+ // names the plan rather than the credential nobody has to go and find one.
286
328
  access: { kind: "subscription", requirement: "Z.ai GLM Coding Plan", runs: "GLM under Claude Code" },
287
329
  auth: {
288
- kind: "key",
289
- anthropicBase: "https://api.z.ai/api/anthropic",
290
- /* THE CODING-PLAN ROOT, not the general one, and they are not interchangeable: a Coding Plan key
291
- * reads its models from `/api/coding/paas/v4` and the general `/api/paas/v4` is a different
292
- * entitlement. Pointing the catalog at the general root would list models the plan's own Anthropic
293
- * endpoint then refuses, which is the worst shape a picker row can have. */
294
- catalogBase: "https://api.z.ai/api/coding/paas/v4",
295
- console: "https://z.ai/manage-apikey/apikey-list",
330
+ kind: "minted",
331
+ /* TWO ESTATES, and a key minted on one is refused by the other, which is why they are two variants
332
+ * rather than one base URL with a note. The catalog root is the CODING-PLAN one on both
333
+ * (`/api/coding/paas/v4`), not the general `/api/paas/v4`: that is a different entitlement, and
334
+ * pointing the catalog at it would list models the plan's own Anthropic endpoint then refuses,
335
+ * which is the worst shape a picker row can have. */
336
+ variants: [
337
+ {
338
+ id: "zai",
339
+ // Cased as the vendor cases it, which is also how the row this control sits under is
340
+ // titled: a pill reading "Z.AI" under a row reading "Z.ai" is two spellings of one product
341
+ // on one screen.
342
+ label: "Z.ai international",
343
+ // zcode.z.ai mediates the callback itself, so the daemon polls it and nothing dead-ends in
344
+ // the user's browser.
345
+ flow: "device",
346
+ anthropicBase: "https://api.z.ai/api/anthropic",
347
+ catalogBase: "https://api.z.ai/api/coding/paas/v4",
348
+ },
349
+ {
350
+ id: "bigmodel",
351
+ label: "BigModel (中国大陆)",
352
+ // BigModel refuses that mediated callback and takes a loopback redirect instead, which no
353
+ // browser outside this container can reach: the page dead-ends carrying the grant, and the
354
+ // user brings the address back. Google's flow exactly, down to the picture.
355
+ flow: "redirect",
356
+ anthropicBase: "https://open.bigmodel.cn/api/anthropic",
357
+ catalogBase: "https://open.bigmodel.cn/api/coding/paas/v4",
358
+ },
359
+ ],
296
360
  },
297
361
  planLimits: false,
298
362
  runtimes: { native: CLAUDE_CODE, claudeCode: CLAUDE_CODE },
@@ -330,10 +394,11 @@ export const TRANSLATOR_PROVIDERS: readonly TranslatorProvider[] = PROVIDER_SPEC
330
394
  (spec): spec is Extract<Spec, { auth: { kind: "translator" } }> => spec.auth.kind === "translator",
331
395
  ).map((spec) => spec.id);
332
396
 
333
- // The providers a pasted API key connects, served straight off the vendor's own Anthropic Messages endpoint.
334
- export type KeyProvider = Extract<Spec, { auth: { kind: "key" } }>["id"];
335
- export const KEY_PROVIDERS: readonly KeyProvider[] = PROVIDER_SPECS.filter(
336
- (spec): spec is Extract<Spec, { auth: { kind: "key" } }> => spec.auth.kind === "key",
397
+ // The providers whose sign-in mints the vendor's own API key, served straight off the vendor's own Anthropic
398
+ // Messages endpoint.
399
+ export type MintedProvider = Extract<Spec, { auth: { kind: "minted" } }>["id"];
400
+ export const MINTED_PROVIDERS: readonly MintedProvider[] = PROVIDER_SPECS.filter(
401
+ (spec): spec is Extract<Spec, { auth: { kind: "minted" } }> => spec.auth.kind === "minted",
337
402
  ).map((spec) => spec.id);
338
403
 
339
404
  // This provider's CLIProxyAPI id, where it has one. Not always ours: the app says "grok" where the proxy says
@@ -343,10 +408,25 @@ export const cliProxyIdOf = (provider: string): string | undefined => {
343
408
  return auth?.kind === "translator" ? auth.cliProxy : undefined;
344
409
  };
345
410
 
346
- // The endpoint facts a keyed provider's turn and catalog are built from, or nothing when the provider is not
347
- // one. Returned whole rather than field by field, because a base URL read without its sibling is how a catalog
348
- // and a turn end up pointed at two different hosts.
349
- export const keyEndpointOf = (provider: string): Extract<ProviderAuth, { kind: "key" }> | undefined => {
411
+ // Every estate a minted provider can be signed into, in the order the connect row offers them, or nothing when
412
+ // the provider is not one of them. The head is the default: what a `login/start` that names no variant gets.
413
+ export const mintedVariants = (provider: string): readonly MintedVariant[] | undefined => {
350
414
  const auth = providerSpec(provider)?.auth;
351
- return auth?.kind === "key" ? auth : undefined;
415
+ return auth?.kind === "minted" ? auth.variants : undefined;
416
+ };
417
+
418
+ /* THE ESTATE ONE ACCOUNT BELONGS TO: its bases and its flow, whole, because a base URL read without its sibling
419
+ * is how a catalog and a turn end up pointed at two different hosts.
420
+ *
421
+ * An ABSENT id takes the default (the head of the list), which is what a connect row that offers no choice
422
+ * sends and what a store written before a second variant existed reads back as. An id that names no variant is
423
+ * `undefined` and NOT the default: silently falling back would dial a mainland key against api.z.ai and report
424
+ * the refusal as an authentication problem, when what happened is that we lost track of where the key came
425
+ * from. */
426
+ export const mintedVariant = (provider: string, variant?: string): MintedVariant | undefined => {
427
+ const variants = mintedVariants(provider);
428
+ if (variants === undefined) {
429
+ return undefined;
430
+ }
431
+ return variant === undefined || variant === "" ? variants[0] : variants.find((entry) => entry.id === variant);
352
432
  };
@@ -92,12 +92,19 @@ const tierOf = (model: string): number => tierRankOf(familyOf(model));
92
92
  // list, so it reads -1 and leads the tiebreak; unreachable in practice, since it can never tie on cost.
93
93
  const providerOrder = (provider: AgentProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
94
94
 
95
+ /* WHAT AN ENDPOINT COSTS, one rung past every provider's, and the reason it is a number here rather than a
96
+ * member of AccessKind. That axis describes the providers this repo ships, and every one of them is unlocked by
97
+ * signing in to something the user already holds, so none of them is metered per call. An endpoint is the
98
+ * opposite: whatever gateway somebody pointed us at, whose bill this repo cannot see. Reading it as dearer than
99
+ * anything on the table is the conservative answer, and it is what keeps Auto from reaching for a paid gateway
100
+ * on its own initiative. */
101
+ const METERED_COST = Math.max(...Object.values(ACCESS_COST)) + 1;
102
+
95
103
  // How much a call on this provider costs at the margin. Every native provider declares an access kind; an
96
- // endpoint declares none, and takes the metered rung, the conservative reading of a model API whose bill this
97
- // repo cannot see, which keeps Auto from reaching for someone's paid gateway on its own initiative.
104
+ // endpoint declares none, and takes the metered rung above.
98
105
  const costOf = (provider: AgentProvider): number => {
99
106
  const access = accessFor(provider);
100
- return access === undefined ? ACCESS_COST.key : ACCESS_COST[access.kind];
107
+ return access === undefined ? METERED_COST : ACCESS_COST[access.kind];
101
108
  };
102
109
 
103
110
  /* AUTO, every connected provider's cheapest row, best-first, as a ladder rather than a winner.
@@ -137,7 +137,7 @@ describe(`routeShapes`, () => {
137
137
  describe(`the real sandbox contract`, () => {
138
138
  it(`fingerprints all but the streaming routes`, () => {
139
139
  const unshaped = SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES));
140
- // oRPC wraps an event iterator's output in an opaque type with no schema under it, so these ten
140
+ // oRPC wraps an event iterator's output in an opaque type with no schema under it, so these eleven
141
141
  // cannot be fingerprinted and are assumed compatible. Named rather than counted: a NEW entry here is
142
142
  // a route that quietly lost its shape check, which is worth failing a test over.
143
143
  expect(unshaped.toSorted()).toEqual([
@@ -152,14 +152,17 @@ describe(`the real sandbox contract`, () => {
152
152
  `intentic.applyEvents`,
153
153
  `intentic.run`,
154
154
  `system.events`,
155
- `system.manageMachineSandbox`,
155
+ `system.manageDeviceSandbox`,
156
+ // The device's own agent updating or restarting itself: minutes of download and swap, and the
157
+ // stream dies with the process it is reporting on, so the lines have to arrive as they happen.
158
+ `system.runDeviceAgentFlow`,
156
159
  `vpn.connect`,
157
160
  ]);
158
161
  });
159
162
 
160
163
  it(`fingerprints every other route exactly once`, () => {
161
164
  expect(Object.keys(SANDBOX_ROUTE_SHAPES).every((name) => SANDBOX_ROUTE_NAMES.includes(name))).toBe(true);
162
- expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 10);
165
+ expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 11);
163
166
  });
164
167
 
165
168
  it(`derives a route table with no duplicate names`, () => {
@@ -154,7 +154,7 @@ export type RunnerParity = z.infer<typeof RunnerParitySchema>;
154
154
  // HostSummary shape retold for a runner (no platform/scopes, parity instead).
155
155
  export const RunnerSummarySchema = z.object({
156
156
  id: z.string(),
157
- // The connected computer holding it, when this sandbox is what asked for it (the Computers view's create
157
+ // The connected device holding it, when this sandbox is what asked for it (the Devices view's create
158
158
  // flow). Absent for one started by hand on a machine, which this sandbox can dispatch to but not manage.
159
159
  host: z.string().optional(),
160
160
  online: z.boolean(),