@intentic/sandbox-contract 1.174.0 → 1.175.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.
@@ -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
@@ -962,17 +963,21 @@ export const IntenticRunSchema = z.object({ args: z.array(z.string()) });
962
963
 
963
964
  // ---- git ----
964
965
 
965
- // What a commit records — two shapes, each a real git spelling:
966
- // all: true ⇒ stage every change in the repo, then commit (`commit -a`; VSCode's "stage all and commit")
966
+ // What a commit records — three shapes, each a real git spelling. The last two are for the case where nothing
967
+ // is staged yet and the caller has said what to stage; they are alternatives, and a caller sends at most one:
967
968
  // absent ⇒ commit whatever is staged (plain `git commit`)
969
+ // all: true ⇒ stage every change in the repo, then commit (`commit -a`; VSCode's "stage all and commit")
970
+ // paths ⇒ `git add` those repo-relative paths, then commit the index
968
971
  //
969
- // There is deliberately no `paths`. The index IS git's mechanism for choosing what a commit contains, so a
970
- // second path-selection channel alongside it can only disagree with it: a `commit --only` over a partially
971
- // staged file records the WORKTREE content while the row the user picked showed the INDEX content. Staging is
972
- // the selection; this endpoint only ever records it.
972
+ // `paths` is emphatically NOT `commit --only`. The index IS git's mechanism for choosing what a commit
973
+ // contains, so a second path-selection channel alongside it could only disagree with it: a partial commit over
974
+ // a half-staged file records the WORKTREE content while the row the user picked showed the INDEX content. This
975
+ // stages and then records the whole index, which is why it is safe — and why it also survives a merge, where
976
+ // git refuses a partial commit outright (and refuses it only AFTER moving the index).
973
977
  export const CommitSchema = RepoParamSchema.extend({
974
978
  message: z.string().min(1),
975
979
  all: z.boolean().optional(),
980
+ paths: z.array(z.string().min(1)).max(500).optional(),
976
981
  });
977
982
  export const DiscardSchema = RepoParamSchema.extend({
978
983
  // Repo-relative paths to discard; absent ⇒ discard every uncommitted change in the repo.
@@ -1001,13 +1006,21 @@ export const GitFilesSchema = z.object({ files: z.array(z.string()) });
1001
1006
  export const GitFileSchema = z.object({ path: z.string(), content: z.string() });
1002
1007
  export const CommitResultSchema = z.object({ committed: z.boolean() });
1003
1008
 
1009
+ // One repo's slice of a workspace-wide git action: the whole repo, or only the repo-relative paths named. The
1010
+ // same pair the per-repo routes take as {repo} + `paths`, in the one shape a caller that spans repos can send.
1011
+ export const RepoPathsSchema = z.object({ repo: z.string().min(1), paths: z.array(z.string().min(1)).max(500).optional() });
1012
+ export type RepoPaths = z.infer<typeof RepoPathsSchema>;
1013
+
1004
1014
  /* AI-drafted commit message. Workspace-wide, not per repo, because the commit box's target IS a set of repos
1005
1015
  * sharing one message — so the draft has to see every one of their diffs to describe what the commit actually
1006
- * records. `repos` and `all` mirror the panel's own commit target exactly: `all` reads the WORKTREE (what
1007
- * "Commit all" would sweep), absent reads the INDEX (what a bare commit records). Getting that wrong would
1008
- * describe changes the commit isn't going to contain. */
1016
+ * records. The input mirrors CommitSchema field for field, which is the whole point: whatever the commit is
1017
+ * about to do is what gets described, and the two cannot drift.
1018
+ * repos[].paths the subset that commit will stage — read the WORKTREE, narrowed to those paths
1019
+ * all: true ⇒ the whole worktree, untracked included (what "Commit all" sweeps)
1020
+ * neither ⇒ the INDEX (what a bare commit records)
1021
+ * Getting that wrong would describe changes the commit isn't going to contain. */
1009
1022
  export const CommitMessageDraftSchema = z.object({
1010
- repos: z.array(z.string().min(1)).min(1).max(50),
1023
+ repos: z.array(RepoPathsSchema).min(1).max(50),
1011
1024
  all: z.boolean().optional(),
1012
1025
  });
1013
1026
  // The draft plus WHICH model wrote it, so the surface can name it rather than claiming an anonymous "AI" —
@@ -1316,6 +1329,11 @@ export const WorkspaceChildrenSchema = z.object({
1316
1329
  });
1317
1330
  export type WorkspaceChildren = z.infer<typeof WorkspaceChildrenSchema>;
1318
1331
  export const WorkspaceFileQuerySchema = z.object({ path: z.string().min(1) });
1332
+ /* The credential a <video>/<audio> element carries to GET /workspace/media, which is the one workspace route a
1333
+ * browser cannot put a header on. Minted here, over the ordinary bearer-authenticated contract, and scoped to
1334
+ * the single path it was asked for — see auth/media-tickets.ts for why scope rather than single-use is what
1335
+ * bounds it. `expiresAt` is epoch ms so a player can tell a dead ticket from a dead file. */
1336
+ export const WorkspaceMediaTicketSchema = z.object({ ticket: z.string(), expiresAt: z.number() });
1319
1337
  /* A text read is a read of a WINDOW: `offset` is the byte to start at (negative reads that many bytes from the
1320
1338
  * END, which is what following a growing log means — the tail's offset isn't knowable until the size is), and
1321
1339
  * `limit` how many bytes to serve. The daemon clamps `limit` to its own cap, so an omitted or oversized one is
@@ -2022,23 +2040,13 @@ export const ForticlientConnectionSchema = z.object({
2022
2040
  export type ForticlientConnection = z.infer<typeof ForticlientConnectionSchema>;
2023
2041
  export const ForticlientImportSchema = z.object({ connections: z.array(ForticlientConnectionSchema) });
2024
2042
 
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.
2043
+ // Browse an extension/plugin registry (a git repo with .claude-plugin/marketplace.json see
2044
+ // @intentic/registry for the format). POST so the optional token for a private registry never rides a URL or
2045
+ // an access log.
2027
2046
  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) });
2047
+ // The rows are RegistryEntry the curated decision joined to the resolved pointer and the scanner's upstream
2048
+ // facts, exactly as the site's gallery renders them, so browsing in the app and browsing the web show one list.
2049
+ export const MarketplaceSchema = z.object({ name: z.string(), plugins: z.array(RegistryEntrySchema) });
2042
2050
  export type Marketplace = z.infer<typeof MarketplaceSchema>;
2043
2051
 
2044
2052
  // ---- extensions: installed extension-kind capabilities resolved to their manifests ----
@@ -2194,10 +2202,18 @@ export const WebchatConfigSchema = z.object({
2194
2202
  // The site's OWN Google OAuth web client id. It cannot be intentic's: Google Identity Services only issues
2195
2203
  // a token to an authorized JavaScript origin, and intentic's client can't list every customer domain.
2196
2204
  googleClientId: z.string().optional(),
2197
- // Widget chrome. `accent` is any CSS colour the host page can render; `position` picks the launcher corner.
2205
+ /* Widget chrome. `position` picks the launcher corner.
2206
+ *
2207
+ * `accent` is a HEX colour, not any CSS colour, because the widget derives values from its channels rather
2208
+ * than just painting it: a glyph step for the scheme, a 14% wash for the send button, a bubble edge, a focus
2209
+ * ring, and the label that goes on top (see webchat-widget's styles.ts). A colour we cannot read is a widget
2210
+ * with half its accent silently missing, so the unreadable case is rejected here instead. */
2198
2211
  title: z.string().max(80).optional(),
2199
2212
  greeting: z.string().max(500).optional(),
2200
- accent: z.string().max(40).optional(),
2213
+ accent: z
2214
+ .string()
2215
+ .regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, "accent must be a hex colour, e.g. #e47100")
2216
+ .optional(),
2201
2217
  position: z.enum(["top-right", "top-left", "bottom-right", "bottom-left"]).optional(),
2202
2218
  /* Two ceilings on top of the route's fixed per-minute window, because a public endpoint's real exposure is
2203
2219
  * cost, not request rate: `dailyMessageMax` caps the whole automation per UTC day, `conversationMessageMax`
@@ -2551,6 +2567,57 @@ export const DeployAlertSchema = z.object({
2551
2567
  });
2552
2568
  export type DeployAlert = z.infer<typeof DeployAlertSchema>;
2553
2569
 
2570
+ /* Who the API key acts as. Komodo filters EVERY list by the caller's permissions, so a key minted on a service
2571
+ * user with no grants returns 200 and an empty array — byte-identical to a Komodo with nothing deployed. That
2572
+ * ambiguity shipped once and cost a user an afternoon: their Komodo had four stacks and the board said "no
2573
+ * stacks or deployments yet". Carrying the viewer lets the empty state name the actual reason. */
2574
+ export const DeployViewerSchema = z.object({
2575
+ username: z.string(),
2576
+ // Either of Komodo's admin flags — an admin key sees everything, so its empty board really is empty.
2577
+ admin: z.boolean(),
2578
+ });
2579
+ export type DeployViewer = z.infer<typeof DeployViewerSchema>;
2580
+
2581
+ /* A workspace repo that ships a compose file, and the Komodo stack it belongs to.
2582
+ *
2583
+ * Komodo names a stack whatever its creator typed; a repo names its compose project in the file. The two
2584
+ * usually agree, or nearly — `intentic` in the repo against `intentic-platform` in Komodo — so the daemon
2585
+ * SUGGESTS and the owner decides. The link is explicit and persisted because a guess that silently becomes a
2586
+ * fact is worse than no guess: the owner is the one who knows that `atlas` is this repo's staging stack. */
2587
+ export const DeployRepoLinkSchema = z.object({
2588
+ // The workspace repo dir — the same `repo` key the rest of the app joins on.
2589
+ repo: z.string(),
2590
+ // The compose project name: the file's own `name:` when it has one, else the repo dir. This is what
2591
+ // `docker compose up` would call the project, so it is the best guess at the stack's name.
2592
+ projectName: z.string(),
2593
+ // Workspace-relative path of the compose file the name came from, so the UI can say where it looked.
2594
+ composePath: z.string(),
2595
+ // The stack the owner linked, once they have. Absent ⇒ unlinked, and `suggestions` is the offer.
2596
+ linkedStack: z.string().optional(),
2597
+ // Stack names that look like this repo, best first. Empty when nothing resembles it — in which case the
2598
+ // UI offers the full list rather than pretending it has an opinion.
2599
+ suggestions: z.array(z.string()),
2600
+ });
2601
+ export type DeployRepoLink = z.infer<typeof DeployRepoLinkSchema>;
2602
+
2603
+ // Link a repo to a stack, or clear the link with an empty `stack`. Explicit rather than a toggle: the owner
2604
+ // may be replacing one stack with another, and a toggle cannot express that in one call.
2605
+ export const DeployLinkParamSchema = z.object({
2606
+ capability: z.string(),
2607
+ repo: z.string(),
2608
+ stack: z.string(),
2609
+ });
2610
+ export type DeployLinkParam = z.infer<typeof DeployLinkParamSchema>;
2611
+
2612
+ /* EVERY FIELD ADDED AFTER THIS ROUTE FIRST SHIPPED IS OPTIONAL OR DEFAULTED, and that is a rule rather than a
2613
+ * style. The browser validates this response with `.parse()`, and the daemon it is talking to is not
2614
+ * necessarily built from the same commit — a sandbox image is rebuilt on the owner's schedule, the web bundle
2615
+ * on ours. `repos` shipped REQUIRED and the first daemon that predated it took the whole view down with
2616
+ * `Invalid input: expected array, received undefined at repos`: not a missing band, a dead page, on the
2617
+ * surface whose entire job is to tell you whether production is up.
2618
+ *
2619
+ * So: a new field is `.default(...)` when the view can render without it, and `.optional()` when its absence
2620
+ * is itself meaningful. Neither is ever `required`. A newer browser must degrade against an older daemon. */
2554
2621
  export const DeployOverviewResponseSchema = z.object({
2555
2622
  komodoUrl: z.string(),
2556
2623
  // FALSE means Komodo did not answer — the view says so loudly and the badge must not read `danger`.
@@ -2559,6 +2626,13 @@ export const DeployOverviewResponseSchema = z.object({
2559
2626
  reachable: z.boolean(),
2560
2627
  // Why it did not answer, in Komodo's own words — the view shows it instead of a bare "unavailable".
2561
2628
  unreachableReason: z.string().optional(),
2629
+ // Absent when Komodo did not answer — or when the daemon predates the field, which the empty state has to
2630
+ // tell apart from "the key sees nothing", since only one of those is the owner's to fix.
2631
+ viewer: DeployViewerSchema.optional(),
2632
+ // Every workspace repo with a compose file, with its suggested or linked stack. Independent of whether
2633
+ // Komodo returned anything: a repo the owner could link is worth showing even on an empty board, since
2634
+ // that is exactly the moment they need to know what this view is for.
2635
+ repos: z.array(DeployRepoLinkSchema).default([]),
2562
2636
  resources: z.array(DeployResourceSchema),
2563
2637
  servers: z.array(DeployServerSchema),
2564
2638
  // Newest first. Unresolved and resolved both: the view shows recent history, the badge reads only the
@@ -83,9 +83,9 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
83
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
84
  },
85
85
  {
86
- path: ".intentic/webchat-sessions.json",
86
+ path: ".intentic/thread-sessions.json",
87
87
  invalidates: [],
88
- 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.",
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.",
89
89
  },
90
90
  {
91
91
  path: ".intentic/extension-settings.json",