@intentic/sandbox-contract 1.174.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.
- package/dist/contracts/capabilities.contract.d.ts +12 -2
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +1 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/komodo.contract.d.ts +18 -0
- package/dist/contracts/komodo.contract.d.ts.map +1 -1
- package/dist/contracts/komodo.contract.js +2 -1
- package/dist/contracts/komodo.contract.js.map +1 -1
- package/dist/contracts/workspace.contract.d.ts +6 -0
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +2 -1
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/index.d.ts +37 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/schemas.d.ts +48 -17
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +25 -9
- package/dist/schemas.js.map +1 -1
- package/dist/workspace-state.js +2 -2
- package/dist/workspace-state.js.map +1 -1
- package/package.json +3 -2
- package/src/contracts/komodo.contract.ts +4 -0
- package/src/contracts/workspace.contract.ts +6 -0
- package/src/schemas.test.ts +48 -62
- package/src/schemas.ts +80 -18
- package/src/workspace-state.ts +2 -2
package/src/schemas.test.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
2026
|
-
// optional token for a private
|
|
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
|
-
//
|
|
2029
|
-
//
|
|
2030
|
-
export const
|
|
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
|
-
|
|
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
|
|
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
|
package/src/workspace-state.ts
CHANGED
|
@@ -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/
|
|
86
|
+
path: ".intentic/thread-sessions.json",
|
|
87
87
|
invalidates: [],
|
|
88
|
-
why: "
|
|
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",
|