@intentic/sandbox-contract 1.173.0 → 1.175.0

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.
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "vitest";
2
- import { CapabilitiesListSchema, SandboxSettingsSchema } from "./schemas.js";
2
+ import { CapabilitiesListSchema, DeployOverviewResponseSchema, SandboxSettingsSchema } from "./schemas.js";
3
3
 
4
4
  /* The settings shape spans a version seam that really moves: the browser ships with the platform, the daemon
5
5
  * ships inside the user's sandbox image, so a web build routinely parses a payload from an OLDER daemon. These
@@ -18,69 +18,23 @@ test("a payload from a build that predates a toggle parses, with the new toggle
18
18
  outputCleaners: "-cap",
19
19
  outputHoldout: 0.1,
20
20
  };
21
- expect(SandboxSettingsSchema.parse(older)).toEqual({
22
- ...older,
23
- terseHoldout: 0,
24
- iqContext: false,
25
- iqContextHoldout: 0,
26
- filterBackend: "native",
27
- systemPromptMode: "intentic",
28
- systemPrompt: "",
29
- quickModel: "",
30
- agentRetentionDays: 3,
31
- autoLand: true,
32
- resumeAfterOutage: true,
33
- autoResumeOnRestart: true,
34
- prepushCommand: "",
35
- prepushTimeoutMs: 900_000,
36
- prepushFixModel: "",
37
- prepushFixEffort: "",
38
- });
21
+ // The defaults come from the schema, not from a copy of it written here. Transcribing them made every
22
+ // setting the product gained land as a failure in this file — a diff that only ever said "the list moved",
23
+ // never "tolerance broke", and whose fix was always to paste the new default in. What this test is about
24
+ // is the seam: what the old build sent survives verbatim, and what it never heard of arrives at default.
25
+ expect(SandboxSettingsSchema.parse(older)).toEqual({ ...SandboxSettingsSchema.parse({}), ...older });
39
26
  });
40
27
 
41
- test("an empty object is the full default settings object", () => {
42
- expect(SandboxSettingsSchema.parse({})).toEqual({
43
- stableSystemPrompt: false,
44
- skills: [],
45
- hashlineEdits: false,
46
- terseOutput: false,
47
- // Off: the steer's turn-level control spends the tokens it measures, so measuring is opt-in.
48
- terseHoldout: 0,
49
- iqSearch: false,
50
- // Off, and its holdout with it: pre-injection spends input tokens on every eligible turn, and the
51
- // control that would tell you whether they paid for themselves costs the turns it measures.
52
- iqContext: false,
53
- iqContextHoldout: 0,
54
- outputCleaners: "off",
55
- outputHoldout: 0,
56
- filterBackend: "native",
57
- // The default base is Intentic's own prompt; the text field is only read under "custom".
58
- systemPromptMode: "intentic",
59
- systemPrompt: "",
60
- // Empty is not "no quick model" — it is Auto, resolved from the connected accounts on every read
61
- // (quick-model.ts). Storing a resolved id as the default would name a provider a fresh sandbox has no
62
- // credential for, and would go stale the moment one is connected.
63
- quickModel: "",
64
- // The one default that isn't "off": the fleet board's Finished lane has no exit of its own, and each
65
- // card it holds is a worktree checkout. Opting INTO cleanup would mean shipping a leak by default.
66
- agentRetentionDays: 3,
67
- // On because it is the historical behaviour — defaulting off would silently hold every existing
68
- // sandbox's finished work on branches nobody is watching.
69
- autoLand: true,
70
- // On, where a spent usage limit re-runs nothing: an outage resume spends nothing the dead turn hadn't
71
- // already committed, and the turns it saves are the unattended ones nobody is watching to restart by hand.
72
- resumeAfterOutage: true,
73
- // On: a daemon restart is usually intentic's own doing (an image update, an approved environment
74
- // change), not the user's decision, so the turn it interrupted resumes rather than staying stuck.
75
- autoResumeOnRestart: true,
76
- // Empty disables the pre-push check until the owner supplies this workspace's verification command.
77
- prepushCommand: "",
78
- prepushTimeoutMs: 900_000,
79
- // Empty ⇒ the suggested fix session opens on whatever the chat composer would have started with, which
80
- // is the model the user already chose to work with.
81
- prepushFixModel: "",
82
- prepushFixEffort: "",
83
- });
28
+ /* The invariant a fresh sandbox depends on: NO field is required. A settings object is written for the first
29
+ * time only when the user changes something, so until then the daemon parses `{}` — one field without a
30
+ * `.default()` turns that into a throw at boot, and the version tolerance above is built on the same property.
31
+ *
32
+ * Asserted by shape rather than by value: what each default IS belongs next to the field in schemas.ts, where
33
+ * the reason it holds is written down. A second copy here proved nothing the schema didn't already say and
34
+ * failed on every field the product added. */
35
+ test("no field is required — a workspace that has never written settings parses", () => {
36
+ const defaults = SandboxSettingsSchema.parse({});
37
+ expect(Object.keys(defaults).sort()).toEqual(Object.keys(SandboxSettingsSchema.shape).sort());
84
38
  });
85
39
 
86
40
  test("a key of the wrong type is still a parse failure — tolerance is for absence, not for garbage", () => {
@@ -98,3 +52,35 @@ test("a capability list from a daemon that predates recommendations parses, with
98
52
  const older = { capabilities: [{ id: "github", kind: "cli", status: { state: "active" }, config: { provider: "github" } }] };
99
53
  expect(CapabilitiesListSchema.parse(older).recommendations).toEqual([]);
100
54
  });
55
+
56
+ /* The deployments board crosses the same seam and learned it the hard way. `repos` (workspace repo → Komodo
57
+ * stack links) shipped REQUIRED, and the first sandbox whose daemon predated it rendered
58
+ * `Invalid input: expected array, received undefined at repos` instead of the board — a dead page, on the one
59
+ * surface whose job is to say whether production is up, to hide a band of suggestions.
60
+ *
61
+ * `viewer` is the deliberate contrast: also added later, also absent from an older daemon, but OPTIONAL rather
62
+ * than defaulted, because its absence is information. The empty state tells "the key can see nothing" apart
63
+ * from "we could not tell", and defaulting it would have collapsed the two. */
64
+
65
+ test("an overview from a daemon that predates repo links parses, with no links rather than no board", () => {
66
+ const older = { komodoUrl: "https://komodo.example.com", reachable: true, resources: [], servers: [], alerts: [] };
67
+ const parsed = DeployOverviewResponseSchema.parse(older);
68
+ expect(parsed.repos).toEqual([]);
69
+ // Absent, NOT defaulted: the empty state reads this to avoid claiming an empty Komodo it cannot vouch for.
70
+ expect(parsed.viewer).toBeUndefined();
71
+ });
72
+
73
+ test("a board that did carry links keeps them, and garbage in them is still a failure", () => {
74
+ const current = {
75
+ komodoUrl: "https://komodo.example.com",
76
+ reachable: true,
77
+ viewer: { username: "intentic", admin: false },
78
+ repos: [{ repo: "app", projectName: "app", composePath: "app/compose.yaml", suggestions: ["app-prod"] }],
79
+ resources: [],
80
+ servers: [],
81
+ alerts: [],
82
+ };
83
+ expect(DeployOverviewResponseSchema.parse(current).repos[0]?.suggestions).toEqual(["app-prod"]);
84
+ // Tolerance is for absence, not for the wrong shape — a `repos` that is present and wrong is real drift.
85
+ expect(DeployOverviewResponseSchema.safeParse({ ...current, repos: "none" }).success).toBe(false);
86
+ });
package/src/schemas.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ExtensionManifestSchema } from "@intentic/extension-api";
2
+ import { RegistryEntrySchema } from "@intentic/registry";
2
3
  import { z } from "zod";
3
4
 
4
5
  // All request/response wire schemas for the sandbox daemon. Inputs that carry a `{param}` in their route path
@@ -1316,6 +1317,11 @@ export const WorkspaceChildrenSchema = z.object({
1316
1317
  });
1317
1318
  export type WorkspaceChildren = z.infer<typeof WorkspaceChildrenSchema>;
1318
1319
  export const WorkspaceFileQuerySchema = z.object({ path: z.string().min(1) });
1320
+ /* The credential a <video>/<audio> element carries to GET /workspace/media, which is the one workspace route a
1321
+ * browser cannot put a header on. Minted here, over the ordinary bearer-authenticated contract, and scoped to
1322
+ * the single path it was asked for — see auth/media-tickets.ts for why scope rather than single-use is what
1323
+ * bounds it. `expiresAt` is epoch ms so a player can tell a dead ticket from a dead file. */
1324
+ export const WorkspaceMediaTicketSchema = z.object({ ticket: z.string(), expiresAt: z.number() });
1319
1325
  /* A text read is a read of a WINDOW: `offset` is the byte to start at (negative reads that many bytes from the
1320
1326
  * END, which is what following a growing log means — the tail's offset isn't knowable until the size is), and
1321
1327
  * `limit` how many bytes to serve. The daemon clamps `limit` to its own cap, so an omitted or oversized one is
@@ -2022,23 +2028,13 @@ export const ForticlientConnectionSchema = z.object({
2022
2028
  export type ForticlientConnection = z.infer<typeof ForticlientConnectionSchema>;
2023
2029
  export const ForticlientImportSchema = z.object({ connections: z.array(ForticlientConnectionSchema) });
2024
2030
 
2025
- // Browse a Claude Code plugin marketplace (a git repo with .claude-plugin/marketplace.json). POST so the
2026
- // optional token for a private marketplace never rides a URL or an access log.
2031
+ // Browse an extension/plugin registry (a git repo with .claude-plugin/marketplace.json see
2032
+ // @intentic/registry for the format). POST so the optional token for a private registry never rides a URL or
2033
+ // an access log.
2027
2034
  export const MarketplaceRequestSchema = z.object({ url: z.string().url(), token: z.string().min(1).optional() });
2028
- // One marketplace entry; `install` is the entry's source resolved onto PluginConfig shape (url/ref/path), so
2029
- // picking an entry just pre-fills the plugin form. Absent = a source the daemon can't clone (e.g. npm).
2030
- export const MarketplacePluginSchema = z.object({
2031
- name: z.string(),
2032
- description: z.string().optional(),
2033
- version: z.string().optional(),
2034
- // "extension" marks an intentic-extension entry (installs as the `extension` capability, sha-pinned);
2035
- // absent/"plugin" = a Claude Code plugin. Claude Code ignores unknown marketplace fields, so one
2036
- // marketplace repo serves both consumers.
2037
- kind: z.enum(["plugin", "extension"]).optional(),
2038
- install: z.object({ url: z.string(), ref: z.string().optional(), path: z.string().optional() }).optional(),
2039
- });
2040
- export type MarketplacePlugin = z.infer<typeof MarketplacePluginSchema>;
2041
- export const MarketplaceSchema = z.object({ name: z.string(), plugins: z.array(MarketplacePluginSchema) });
2035
+ // The rows are RegistryEntry the curated decision joined to the resolved pointer and the scanner's upstream
2036
+ // facts, exactly as the site's gallery renders them, so browsing in the app and browsing the web show one list.
2037
+ export const MarketplaceSchema = z.object({ name: z.string(), plugins: z.array(RegistryEntrySchema) });
2042
2038
  export type Marketplace = z.infer<typeof MarketplaceSchema>;
2043
2039
 
2044
2040
  // ---- extensions: installed extension-kind capabilities resolved to their manifests ----
@@ -2194,10 +2190,18 @@ export const WebchatConfigSchema = z.object({
2194
2190
  // The site's OWN Google OAuth web client id. It cannot be intentic's: Google Identity Services only issues
2195
2191
  // a token to an authorized JavaScript origin, and intentic's client can't list every customer domain.
2196
2192
  googleClientId: z.string().optional(),
2197
- // Widget chrome. `accent` is any CSS colour the host page can render; `position` picks the launcher corner.
2193
+ /* Widget chrome. `position` picks the launcher corner.
2194
+ *
2195
+ * `accent` is a HEX colour, not any CSS colour, because the widget derives values from its channels rather
2196
+ * than just painting it: a glyph step for the scheme, a 14% wash for the send button, a bubble edge, a focus
2197
+ * ring, and the label that goes on top (see webchat-widget's styles.ts). A colour we cannot read is a widget
2198
+ * with half its accent silently missing, so the unreadable case is rejected here instead. */
2198
2199
  title: z.string().max(80).optional(),
2199
2200
  greeting: z.string().max(500).optional(),
2200
- accent: z.string().max(40).optional(),
2201
+ accent: z
2202
+ .string()
2203
+ .regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, "accent must be a hex colour, e.g. #e47100")
2204
+ .optional(),
2201
2205
  position: z.enum(["top-right", "top-left", "bottom-right", "bottom-left"]).optional(),
2202
2206
  /* Two ceilings on top of the route's fixed per-minute window, because a public endpoint's real exposure is
2203
2207
  * cost, not request rate: `dailyMessageMax` caps the whole automation per UTC day, `conversationMessageMax`
@@ -2551,6 +2555,57 @@ export const DeployAlertSchema = z.object({
2551
2555
  });
2552
2556
  export type DeployAlert = z.infer<typeof DeployAlertSchema>;
2553
2557
 
2558
+ /* Who the API key acts as. Komodo filters EVERY list by the caller's permissions, so a key minted on a service
2559
+ * user with no grants returns 200 and an empty array — byte-identical to a Komodo with nothing deployed. That
2560
+ * ambiguity shipped once and cost a user an afternoon: their Komodo had four stacks and the board said "no
2561
+ * stacks or deployments yet". Carrying the viewer lets the empty state name the actual reason. */
2562
+ export const DeployViewerSchema = z.object({
2563
+ username: z.string(),
2564
+ // Either of Komodo's admin flags — an admin key sees everything, so its empty board really is empty.
2565
+ admin: z.boolean(),
2566
+ });
2567
+ export type DeployViewer = z.infer<typeof DeployViewerSchema>;
2568
+
2569
+ /* A workspace repo that ships a compose file, and the Komodo stack it belongs to.
2570
+ *
2571
+ * Komodo names a stack whatever its creator typed; a repo names its compose project in the file. The two
2572
+ * usually agree, or nearly — `intentic` in the repo against `intentic-platform` in Komodo — so the daemon
2573
+ * SUGGESTS and the owner decides. The link is explicit and persisted because a guess that silently becomes a
2574
+ * fact is worse than no guess: the owner is the one who knows that `atlas` is this repo's staging stack. */
2575
+ export const DeployRepoLinkSchema = z.object({
2576
+ // The workspace repo dir — the same `repo` key the rest of the app joins on.
2577
+ repo: z.string(),
2578
+ // The compose project name: the file's own `name:` when it has one, else the repo dir. This is what
2579
+ // `docker compose up` would call the project, so it is the best guess at the stack's name.
2580
+ projectName: z.string(),
2581
+ // Workspace-relative path of the compose file the name came from, so the UI can say where it looked.
2582
+ composePath: z.string(),
2583
+ // The stack the owner linked, once they have. Absent ⇒ unlinked, and `suggestions` is the offer.
2584
+ linkedStack: z.string().optional(),
2585
+ // Stack names that look like this repo, best first. Empty when nothing resembles it — in which case the
2586
+ // UI offers the full list rather than pretending it has an opinion.
2587
+ suggestions: z.array(z.string()),
2588
+ });
2589
+ export type DeployRepoLink = z.infer<typeof DeployRepoLinkSchema>;
2590
+
2591
+ // Link a repo to a stack, or clear the link with an empty `stack`. Explicit rather than a toggle: the owner
2592
+ // may be replacing one stack with another, and a toggle cannot express that in one call.
2593
+ export const DeployLinkParamSchema = z.object({
2594
+ capability: z.string(),
2595
+ repo: z.string(),
2596
+ stack: z.string(),
2597
+ });
2598
+ export type DeployLinkParam = z.infer<typeof DeployLinkParamSchema>;
2599
+
2600
+ /* EVERY FIELD ADDED AFTER THIS ROUTE FIRST SHIPPED IS OPTIONAL OR DEFAULTED, and that is a rule rather than a
2601
+ * style. The browser validates this response with `.parse()`, and the daemon it is talking to is not
2602
+ * necessarily built from the same commit — a sandbox image is rebuilt on the owner's schedule, the web bundle
2603
+ * on ours. `repos` shipped REQUIRED and the first daemon that predated it took the whole view down with
2604
+ * `Invalid input: expected array, received undefined at repos`: not a missing band, a dead page, on the
2605
+ * surface whose entire job is to tell you whether production is up.
2606
+ *
2607
+ * So: a new field is `.default(...)` when the view can render without it, and `.optional()` when its absence
2608
+ * is itself meaningful. Neither is ever `required`. A newer browser must degrade against an older daemon. */
2554
2609
  export const DeployOverviewResponseSchema = z.object({
2555
2610
  komodoUrl: z.string(),
2556
2611
  // FALSE means Komodo did not answer — the view says so loudly and the badge must not read `danger`.
@@ -2559,6 +2614,13 @@ export const DeployOverviewResponseSchema = z.object({
2559
2614
  reachable: z.boolean(),
2560
2615
  // Why it did not answer, in Komodo's own words — the view shows it instead of a bare "unavailable".
2561
2616
  unreachableReason: z.string().optional(),
2617
+ // Absent when Komodo did not answer — or when the daemon predates the field, which the empty state has to
2618
+ // tell apart from "the key sees nothing", since only one of those is the owner's to fix.
2619
+ viewer: DeployViewerSchema.optional(),
2620
+ // Every workspace repo with a compose file, with its suggested or linked stack. Independent of whether
2621
+ // Komodo returned anything: a repo the owner could link is worth showing even on an empty board, since
2622
+ // that is exactly the moment they need to know what this view is for.
2623
+ repos: z.array(DeployRepoLinkSchema).default([]),
2562
2624
  resources: z.array(DeployResourceSchema),
2563
2625
  servers: z.array(DeployServerSchema),
2564
2626
  // Newest first. Unresolved and resolved both: the view shows recent history, the badge reads only the
@@ -78,9 +78,14 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
78
78
  * query uses would put the drift this table exists to remove straight back into it. Each says which
79
79
  * constraint would have to move first, so the next reader doesn't re-derive it. */
80
80
  {
81
- path: ".intentic/webchat-sessions.json",
81
+ path: ".intentic/webchat-installs.json",
82
82
  invalidates: [],
83
- why: "Doorbell thread bookkeeping (visitor thread sandbox conversation + provider session), written on EVERY visitor message. Nothing in the browser reads it: what a visitor's chat produces is a conversation, and the fleet board already learns about that from the agent registry's own push. Naming a key here would bill every connected browser a refetch per inbound message the request storm this table's own note warns about — to refresh nothing it can see.",
83
+ why: "Which origins have loaded a Doorbell's widget, written on a 30s flush timer while a customer's site serves page views. The install panel that renders it fetches on open and polls itself while it is on screen, which is the whole window in which the answer changes for anyone. Pushing instead would bill every connected browser a refetch per flush, for a panel almost nobody has open.",
84
+ },
85
+ {
86
+ path: ".intentic/thread-sessions.json",
87
+ invalidates: [],
88
+ why: "Thread bookkeeping (an inbound thread — a Doorbell visitor, a Discord or Slack channel — → sandbox conversation + provider session), written on EVERY inbound message. Nothing in the browser reads it: what a thread produces is a conversation, and the fleet board already learns about that from the agent registry's own push. Naming a key here would bill every connected browser a refetch per inbound message — the request storm this table's own note warns about — to refresh nothing it can see.",
84
89
  },
85
90
  {
86
91
  path: ".intentic/extension-settings.json",