@intentic/sandbox-contract 1.213.0 → 1.215.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/capability-secrets.d.ts +3 -0
- package/dist/capability-secrets.d.ts.map +1 -0
- package/dist/capability-secrets.js +3 -0
- package/dist/capability-secrets.js.map +1 -0
- package/dist/contracts/agent.contract.d.ts +12 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +150 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +5 -1
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +1 -0
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.js.map +1 -1
- package/dist/contracts/git.contract.d.ts +21 -0
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.js +3 -1
- package/dist/contracts/git.contract.js.map +1 -1
- package/dist/contracts/host.contract.d.ts +1 -0
- package/dist/contracts/host.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +2 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +8 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/events.d.ts +15 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +2 -0
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +195 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +78 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +31 -1
- package/dist/schemas.js.map +1 -1
- package/dist/workspace-state.d.ts +7 -5
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +14 -5
- package/dist/workspace-state.js.map +1 -1
- package/package.json +4 -4
- package/src/capability-secrets.ts +20 -0
- package/src/contracts/agents.contract.ts +12 -0
- package/src/contracts/capabilities.contract.ts +12 -5
- package/src/contracts/git.contract.ts +10 -0
- package/src/events.ts +34 -3
- package/src/index.ts +1 -0
- package/src/schemas.ts +172 -13
- package/src/workspace-state.test.ts +50 -0
- package/src/workspace-state.ts +104 -17
package/src/schemas.ts
CHANGED
|
@@ -832,6 +832,19 @@ export const AgentSummarySchema = z.object({
|
|
|
832
832
|
// the global toggle meaningful: an agent that never expressed an opinion follows the sandbox wherever it
|
|
833
833
|
// is pointed next. Written by `agents.autoLand`; the UI shows the EFFECTIVE value (this ?? the setting).
|
|
834
834
|
autoLand: z.boolean().optional(),
|
|
835
|
+
/* This agent's own answer to "re-run my turn when the model provider was what failed?" — the same
|
|
836
|
+
* two-level shape as `autoLand` above, and here for a sharper reason than symmetry.
|
|
837
|
+
*
|
|
838
|
+
* The press that writes this is offered INSIDE one conversation, at the moment that conversation's turn
|
|
839
|
+
* died, and what a person means by it is "finish THIS piece of work". It used to write the sandbox-wide
|
|
840
|
+
* setting, so one impatient click at 2 a.m. quietly armed every agent on the board — a blast radius
|
|
841
|
+
* nothing on screen had asked about. So the chat's offer writes this, the settings toggle writes the
|
|
842
|
+
* default, and the two stay honestly different things.
|
|
843
|
+
*
|
|
844
|
+
* ABSENT ⇒ inherit the sandbox setting, which is what keeps that default meaningful: a conversation that
|
|
845
|
+
* never expressed an opinion follows the sandbox wherever it is pointed next. Written by
|
|
846
|
+
* `agents.resumeAfterOutage`; every surface shows the EFFECTIVE value (this ?? the setting). */
|
|
847
|
+
resumeAfterOutage: z.boolean().optional(),
|
|
835
848
|
// A collaborator asked for this agent's work to be landed (agents.requestLand) — collaborators may drive
|
|
836
849
|
// agents but not merge into the main tree, so the ask rides the summary where every maintainer's board
|
|
837
850
|
// sees it. Cleared by the land or discard that answers it. Absent ⇒ nobody is waiting.
|
|
@@ -1048,6 +1061,11 @@ export const AgentPlaceSchema = z.object({ id: z.string().min(1), text: z.string
|
|
|
1048
1061
|
// the sandbox setting" — the browser sends it whenever the user toggles back to what the global already says,
|
|
1049
1062
|
// so agents don't accumulate frozen overrides that quietly stop following the global toggle.
|
|
1050
1063
|
export const AgentAutoLandSchema = z.object({ id: z.string().min(1), autoLand: z.boolean().nullable() });
|
|
1064
|
+
// resumeAfterOutage's input: this ONE conversation's answer to a provider outage. `null` clears the override
|
|
1065
|
+
// back to "inherit the sandbox setting" — sent whenever the user toggles back to what the global already says,
|
|
1066
|
+
// on the same reasoning as autoLand's null: an agent holding a frozen copy of a default has quietly stopped
|
|
1067
|
+
// following it, and nothing on screen would say so.
|
|
1068
|
+
export const AgentResumeAfterOutageSchema = z.object({ id: z.string().min(1), resumeAfterOutage: z.boolean().nullable() });
|
|
1051
1069
|
export const AgentFileDiffQuerySchema = z.object({ id: z.string().min(1), repo: z.string().min(1), path: z.string().min(1) });
|
|
1052
1070
|
/* WHY a path would not land. The distinction is the whole difference between an actionable report and a dead
|
|
1053
1071
|
* end, because the three have nothing in common but their symptom:
|
|
@@ -1712,6 +1730,9 @@ export const SkillRemoveSchema = z.object({ name: SkillNameSchema });
|
|
|
1712
1730
|
// find them. Independent of iqSearch: that one teaches the agent to search, this one
|
|
1713
1731
|
// answers ahead of it.
|
|
1714
1732
|
// iqContextHoldout — measurement control for iqContext, same shape as terseHoldout (UsageTurn.iqContext).
|
|
1733
|
+
// workspaceMap — computes an AREA index of the project a run starts in and prepends it to the
|
|
1734
|
+
// conversation's opening message, so the turn does not have to buy its own orientation
|
|
1735
|
+
// with a directory listing. Generated from the filesystem every time, never stored.
|
|
1715
1736
|
// outputCleaners — the Bash output-cleaner spec (agent-output-filter): "off" = filter disabled (default),
|
|
1716
1737
|
// "" = all cleaners on, else an iq-style allow-list / default-minus
|
|
1717
1738
|
// spec ("git,pnpm" = only those; "-cap" = all except). Threaded to the filter via env.
|
|
@@ -1793,6 +1814,25 @@ export const SandboxSettingsSchema = z.object({
|
|
|
1793
1814
|
// otherwise-eligible turns run WITHOUT the retrieved context and stamp their arm onto the ledger
|
|
1794
1815
|
// (UsageTurn.iqContext), so the report compares two real populations of turns instead of asserting a saving.
|
|
1795
1816
|
iqContextHoldout: z.number().min(0).max(1).default(0),
|
|
1817
|
+
/* THE MAP THE TURN OPENS WITH — which areas the project a run starts in has, one derived line on what each
|
|
1818
|
+
* is for, and where the run is standing among them (agent/workspace-map.ts).
|
|
1819
|
+
*
|
|
1820
|
+
* A different answer to the same question `iqContext` answers. That one retrieves for the WORDS of the
|
|
1821
|
+
* message and is only as good as the question; this one answers the question every first turn has whatever
|
|
1822
|
+
* it was asked — "what is this and where am I in it" — which across a hundred sessions of this workspace was
|
|
1823
|
+
* being bought with a directory listing in two turns out of five, and with ~5.3k tokens of tool results
|
|
1824
|
+
* before the job was touched. The two compose: this says which area, retrieval says which file.
|
|
1825
|
+
*
|
|
1826
|
+
* ROOTED AT THE RUN'S STARTING FOLDER rather than at the workspace: a persona's start folder, an isolated
|
|
1827
|
+
* conversation's worktree, or wherever the turn's cwd is. It maps the project containing that folder and
|
|
1828
|
+
* names the rest of the workspace on one line, because a run three levels inside one project is not asking
|
|
1829
|
+
* about the others.
|
|
1830
|
+
*
|
|
1831
|
+
* REGENERATED, NEVER STORED, which is the whole reason it is a mechanism rather than a paragraph in the
|
|
1832
|
+
* system prompt or a hand-written CLAUDE.md: in the ten days that motivated it this repo's two busiest
|
|
1833
|
+
* top-level directories stopped existing, and every written-down copy of the layout was wrong by the end of
|
|
1834
|
+
* the window. Off by default — it spends its tokens on the opening message of every conversation. */
|
|
1835
|
+
workspaceMap: z.boolean().default(false),
|
|
1796
1836
|
outputCleaners: z.string().default("off"),
|
|
1797
1837
|
outputHoldout: z.number().min(0).max(1).default(0),
|
|
1798
1838
|
/* The models behind the small automatic jobs that are not a conversation — today the commit message
|
|
@@ -1852,15 +1892,23 @@ export const SandboxSettingsSchema = z.object({
|
|
|
1852
1892
|
// terminal state: without a sweep the Finished lane grows for the life of the sandbox, and each card it
|
|
1853
1893
|
// holds is a live worktree checkout, not just a row.
|
|
1854
1894
|
agentRetentionDays: z.number().min(0).max(365).default(3),
|
|
1855
|
-
/*
|
|
1856
|
-
* socket), re-run it on an escalating backoff until it goes through or the
|
|
1895
|
+
/* THE SANDBOX-WIDE DEFAULT for "when a turn dies because the MODEL PROVIDER was failing (500/502/503, a
|
|
1896
|
+
* 529 at capacity, a dropped socket), re-run it on an escalating backoff until it goes through or the
|
|
1897
|
+
* attempts are spent".
|
|
1898
|
+
*
|
|
1899
|
+
* A DEFAULT, not the whole answer: any one conversation may override it (AgentSummarySchema
|
|
1900
|
+
* .resumeAfterOutage), and the chat's own offer at the moment of failure writes THAT rather than this.
|
|
1901
|
+
* This toggle is the standing policy for every agent that has not said otherwise, which is why it lives in
|
|
1902
|
+
* settings and is not reachable by a single press from inside one chat — flipping how the whole board
|
|
1903
|
+
* behaves should be a thing somebody went to do.
|
|
1857
1904
|
*
|
|
1858
1905
|
* OFF by default, on the same reasoning that keeps a spent usage limit out of this pair entirely: a resume
|
|
1859
1906
|
* re-runs a turn the user sent once, on their own allowance, and only they can say whether the turn was
|
|
1860
1907
|
* worth paying for twice. Starting off costs nothing, because the failed turn is remembered whatever the
|
|
1861
1908
|
* toggle says (recordOutageFailure) — the failure frame reports an "available" resume and the chat's offer
|
|
1862
|
-
* arms that very turn the moment it is
|
|
1863
|
-
* nobody in the room (automation wakes, Discord, webhooks), which is the case no browser
|
|
1909
|
+
* arms that very turn the moment it is armed for that conversation. Worth turning ON for a sandbox whose
|
|
1910
|
+
* turns mostly have nobody in the room (automation wakes, Discord, webhooks), which is the case no browser
|
|
1911
|
+
* could rescue and the case a per-conversation press cannot reach. */
|
|
1864
1912
|
resumeAfterOutage: z.boolean().default(false),
|
|
1865
1913
|
/* When the daemon dies under a running turn, re-run that turn once it is back (agent/turn-journal.ts records
|
|
1866
1914
|
* every in-flight turn; the boot pass in agent/turn-resume.ts re-runs what survived). OFF by default, like
|
|
@@ -2481,6 +2529,52 @@ export const GitLogQuerySchema = RepoParamSchema.extend({
|
|
|
2481
2529
|
// Every real git repo under /work as root-relative dir ids ("root" is implicit — the /work repo itself).
|
|
2482
2530
|
export const GitReposSchema = z.object({ repos: z.array(z.string()) });
|
|
2483
2531
|
export type GitRepos = z.infer<typeof GitReposSchema>;
|
|
2532
|
+
|
|
2533
|
+
/* WHERE EACH WORKSPACE REPO LIVES ONLINE — one entry per repo that has a parseable remote, as the host and the
|
|
2534
|
+
* `owner/name` project it names. Separate from `repos` above rather than folded into it because that route is
|
|
2535
|
+
* on the file tree's hot path and this costs a `git remote -v` per repo; a caller that wants to recognise a
|
|
2536
|
+
* workspace repo in somebody else's list (the publisher claim does exactly that) asks for it deliberately.
|
|
2537
|
+
*
|
|
2538
|
+
* A repo with no remote, or one naming a local path, is absent rather than present-and-empty: "this repo is
|
|
2539
|
+
* nowhere online" and "this repo is at X" are different answers and only one of them can be matched against. */
|
|
2540
|
+
export const GitRemoteRepoSchema = z.object({ repo: z.string(), host: z.string(), project: z.string() });
|
|
2541
|
+
export type GitRemoteRepo = z.infer<typeof GitRemoteRepoSchema>;
|
|
2542
|
+
export const GitRemoteReposSchema = z.object({ repos: z.array(GitRemoteRepoSchema) });
|
|
2543
|
+
export type GitRemoteRepos = z.infer<typeof GitRemoteReposSchema>;
|
|
2544
|
+
|
|
2545
|
+
/* PUT ONE FILE ON THE DEFAULT BRANCH AND PUBLISH IT — write, commit that path alone, push, in one call.
|
|
2546
|
+
*
|
|
2547
|
+
* One route rather than three because the interesting states are the ones BETWEEN the steps: a file written but
|
|
2548
|
+
* not committed, or committed but not pushed, is a repo the user now has to clean up by hand, and a browser
|
|
2549
|
+
* making three requests owns that mess without being able to describe it. Here the caller gets one answer that
|
|
2550
|
+
* says how far it got.
|
|
2551
|
+
*
|
|
2552
|
+
* `message` is the caller's because the commit shows up in the user's own history and a generic subject there
|
|
2553
|
+
* is litter. */
|
|
2554
|
+
export const GitPublishFileSchema = RepoParamSchema.extend({ path: z.string().min(1), content: z.string(), message: z.string().min(1) });
|
|
2555
|
+
|
|
2556
|
+
/* HOW FAR THE PUBLISH GOT, in the terms the screen has to explain it in. `ok` is "the file is on the default
|
|
2557
|
+
* branch of the remote" and nothing less — the only state that makes a public read of it succeed.
|
|
2558
|
+
*
|
|
2559
|
+
* The three steps are reported SEPARATELY because every boundary between them is a state a user can be left
|
|
2560
|
+
* in and would otherwise have to discover: a file written but not committed, a commit that exists locally but
|
|
2561
|
+
* was refused by the remote for credentials. Each of those needs a different sentence and a different next
|
|
2562
|
+
* move, and one `ok: false` cannot carry either. It is also what tells the daemon whether the worktree moved
|
|
2563
|
+
* at all, which decides whether this counts as a user write on the timeline.
|
|
2564
|
+
*
|
|
2565
|
+
* `branch` and `defaultBranch` ride along so a refusal can name both sides of the mismatch rather than saying
|
|
2566
|
+
* "wrong branch" at someone who cannot see which one they are on. */
|
|
2567
|
+
export const GitPublishFileResultSchema = z.object({
|
|
2568
|
+
ok: z.boolean(),
|
|
2569
|
+
wrote: z.boolean(),
|
|
2570
|
+
committed: z.boolean(),
|
|
2571
|
+
pushed: z.boolean(),
|
|
2572
|
+
branch: z.string().optional(),
|
|
2573
|
+
defaultBranch: z.string().optional(),
|
|
2574
|
+
reason: z.string().optional(),
|
|
2575
|
+
});
|
|
2576
|
+
export type GitPublishFileResult = z.infer<typeof GitPublishFileResultSchema>;
|
|
2577
|
+
|
|
2484
2578
|
export const GitCommitDiffQuerySchema = RepoParamSchema.extend({ sha: ShaSchema });
|
|
2485
2579
|
// A commit's changed files (vs its first parent; a root commit vs the empty tree) — the graph's detail tree
|
|
2486
2580
|
// renders these (line stats included) and reuses the diff UI on click. Just GitChanges: the line stats live on
|
|
@@ -3845,12 +3939,25 @@ export const CapabilityStatusSchema = z.object({
|
|
|
3845
3939
|
code: z.string().optional(),
|
|
3846
3940
|
});
|
|
3847
3941
|
export type CapabilityStatus = z.infer<typeof CapabilityStatusSchema>;
|
|
3848
|
-
|
|
3942
|
+
/* The list row: manifest entry + live status. Secrets are never returned (an mcp token becomes hasToken).
|
|
3943
|
+
*
|
|
3944
|
+
* `secrets` NAMES them without carrying them — the config keys this connection is actually holding a credential
|
|
3945
|
+
* under. It is what makes an edit form possible at all: `config` is everything the browser may see, so a form
|
|
3946
|
+
* seeded from it alone cannot tell "this tunnel has a pre-shared key I'm not allowed to show you" from "this
|
|
3947
|
+
* tunnel has no pre-shared key", and both render as an empty required box. Saving one then wipes the
|
|
3948
|
+
* credential, which is why changing a routed network used to mean re-typing a key.
|
|
3949
|
+
*
|
|
3950
|
+
* Keys, never values, and never a boolean per known field: the set is derived from what the entry stores, so a
|
|
3951
|
+
* field the user left blank is absent and a card that gained a credential since is present. The form reads it as
|
|
3952
|
+
* "show dots, and let blank mean keep" (VAULTED — capability-secrets.ts). */
|
|
3849
3953
|
export const CapabilitySummarySchema = z.object({
|
|
3850
3954
|
id: z.string(),
|
|
3851
3955
|
kind: CapabilityKindSchema,
|
|
3852
3956
|
status: CapabilityStatusSchema,
|
|
3853
3957
|
config: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
|
|
3958
|
+
// Defaulted for the daemon-older-than-browser seam, like `recommendations` below: a required field would
|
|
3959
|
+
// fail the whole list parse against a sandbox predating this, taking the page down to hide some dots.
|
|
3960
|
+
secrets: z.array(z.string()).default([]),
|
|
3854
3961
|
});
|
|
3855
3962
|
/* A capability the WORKSPACE asks for but the manifest doesn't carry — derived from what is checked out under
|
|
3856
3963
|
* /work, not from anything the user configured. It exists because the failures it prevents are illegible: a
|
|
@@ -5395,19 +5502,23 @@ export type MachineSandbox = z.infer<typeof MachineSandboxSchema>;
|
|
|
5395
5502
|
/* ONE OPERATION ON ONE SANDBOX ON ONE MACHINE — the Computers view's buttons, and the only thing that changes a
|
|
5396
5503
|
* machine's fleet from a browser.
|
|
5397
5504
|
*
|
|
5398
|
-
* All
|
|
5399
|
-
* behave underneath: three are a docker call that returns in a second,
|
|
5505
|
+
* All nine ops travel one route because they are one decision to the person clicking, however differently they
|
|
5506
|
+
* behave underneath: three are a docker call that returns in a second, four run the `ic` flow for minutes, one
|
|
5400
5507
|
* deletes, and one only reads. Splitting them by duration would put the same button on two doors and give the
|
|
5401
5508
|
* view two shapes to render. So every op answers as a STREAM of lines ending in a result — the fast ones simply
|
|
5402
5509
|
* have little to say, and `logs` is the case where the lines ARE the answer.
|
|
5403
5510
|
*
|
|
5404
|
-
* `
|
|
5405
|
-
*
|
|
5406
|
-
*
|
|
5511
|
+
* `prepare` is the one that changes nothing on purpose: it downloads and builds the next update and stops
|
|
5512
|
+
* there, leaving the container running the image it was already running. It is what turns `update` from a wait
|
|
5513
|
+
* of minutes into a restart of seconds, and it is safe to offer at any moment for exactly that reason.
|
|
5514
|
+
*
|
|
5515
|
+
* `logs` is here rather than on a route of its own for the same reason the rest share it: it is a button in the
|
|
5516
|
+
* same row as the others, on a container that may be too broken to answer any other way, and the stream shape
|
|
5517
|
+
* already carries "many lines, then an outcome" exactly as a log tail wants to arrive.
|
|
5407
5518
|
*
|
|
5408
|
-
* The machine enforces which of them it will do: `sandboxes` covers
|
|
5409
|
-
*
|
|
5410
|
-
export const MachineSandboxOpSchema = z.enum(["start", "stop", "restart", "update", "rebuild", "rollback", "remove", "logs"]);
|
|
5519
|
+
* The machine enforces which of them it will do: `sandboxes` covers everything but removal, which takes its own
|
|
5520
|
+
* switch, and a refusal comes back as the machine's own sentence naming the control to flip. */
|
|
5521
|
+
export const MachineSandboxOpSchema = z.enum(["start", "stop", "restart", "prepare", "update", "rebuild", "rollback", "remove", "logs"]);
|
|
5411
5522
|
export type MachineSandboxOp = z.infer<typeof MachineSandboxOpSchema>;
|
|
5412
5523
|
|
|
5413
5524
|
export const MachineSandboxFlowSchema = z.object({
|
|
@@ -6049,6 +6160,26 @@ export const MigrationPlanSchema = z.object({
|
|
|
6049
6160
|
});
|
|
6050
6161
|
export type MigrationPlan = z.infer<typeof MigrationPlanSchema>;
|
|
6051
6162
|
|
|
6163
|
+
/* One of the owner's own computers, as an import SOURCE — the answer to "where is my setup" that needs no
|
|
6164
|
+
* packing at all. Read on the card's first render for every enrolled machine, so the offer appears before the
|
|
6165
|
+
* owner has read a single instruction.
|
|
6166
|
+
*
|
|
6167
|
+
* `found` absent means "connected, and nothing to import here" — which is a real answer worth rendering
|
|
6168
|
+
* quietly, not an error: the machine may simply be a different one from the machine the assistant runs on. */
|
|
6169
|
+
export const MigrationHostSchema = z.object({
|
|
6170
|
+
id: z.string(),
|
|
6171
|
+
online: z.boolean(),
|
|
6172
|
+
found: MigrationSourceSchema.optional(),
|
|
6173
|
+
// Why this machine cannot be read right now, when it cannot — offline, or its own refusal, in its words.
|
|
6174
|
+
detail: z.string().optional(),
|
|
6175
|
+
});
|
|
6176
|
+
export const MigrationHostsSchema = z.object({ hosts: z.array(MigrationHostSchema) });
|
|
6177
|
+
export type MigrationHost = z.infer<typeof MigrationHostSchema>;
|
|
6178
|
+
|
|
6179
|
+
// Read the setup off a connected computer instead of an upload. Answers with a plan, exactly as the upload
|
|
6180
|
+
// route does — everything after this point is identical whichever door the setup came through.
|
|
6181
|
+
export const MigrationScanSchema = z.object({ host: z.string().min(1) });
|
|
6182
|
+
|
|
6052
6183
|
export const MigrationApplySchema = z.object({
|
|
6053
6184
|
token: z.string(),
|
|
6054
6185
|
// The ticked item ids. Ids the re-derived plan does not contain are ignored rather than erroring — the
|
|
@@ -6152,6 +6283,31 @@ export const AdapterHealthSchema = z.object({
|
|
|
6152
6283
|
});
|
|
6153
6284
|
export type AdapterHealthReport = z.infer<typeof AdapterHealthSchema>;
|
|
6154
6285
|
|
|
6286
|
+
/* AN UPDATE ALREADY DOWNLOADED AND BUILT, waiting for the restart that applies it.
|
|
6287
|
+
*
|
|
6288
|
+
* An update is one blocking operation but it was never one kind of work: pulling the new image and re-applying
|
|
6289
|
+
* the environment recipe take the overwhelming majority of the wall clock, and the sandbox is up and serving
|
|
6290
|
+
* through both of them. Only the cutover is downtime, and it is seconds.
|
|
6291
|
+
*
|
|
6292
|
+
* The daemon cannot know any of this by itself — it holds no host Docker socket — so `ic sandbox prepare`
|
|
6293
|
+
* tells it, on the machine that runs the container. That is the whole reason this exists: without it, the
|
|
6294
|
+
* update card had to quote the download as if it were an outage, and "a few minutes, this page loses the
|
|
6295
|
+
* sandbox" is a completely different decision from "about half a minute".
|
|
6296
|
+
*
|
|
6297
|
+
* Advisory only, in the strict sense: it decides what a card SAYS and never what gets installed. The swap
|
|
6298
|
+
* re-derives every one of these facts from the host-side record and refuses the fast path if any has drifted. */
|
|
6299
|
+
export const StagedUpdateSchema = z.object({
|
|
6300
|
+
// The version the staged image reports about itself. Absent when the image would not say (an older build,
|
|
6301
|
+
// a probe that failed), which reads as "ready, version unknown" — never as nothing being ready.
|
|
6302
|
+
version: z.string().optional(),
|
|
6303
|
+
// The release channel it was staged FROM, which is not necessarily the one this sandbox follows: preparing
|
|
6304
|
+
// a beta build is not moving onto beta.
|
|
6305
|
+
channel: z.string(),
|
|
6306
|
+
// When it finished downloading, epoch ms — what answers "is this still the update I am being offered?"
|
|
6307
|
+
at: z.number(),
|
|
6308
|
+
});
|
|
6309
|
+
export type StagedUpdate = z.infer<typeof StagedUpdateSchema>;
|
|
6310
|
+
|
|
6155
6311
|
export const InfoSchema = z.object({
|
|
6156
6312
|
name: z.string().optional(),
|
|
6157
6313
|
image: z.string().optional(),
|
|
@@ -6187,6 +6343,9 @@ export const InfoSchema = z.object({
|
|
|
6187
6343
|
* turns the update card from an offer into a warning that asks to be read before it hands over the
|
|
6188
6344
|
* command. Absent for the overwhelming majority of updates, which break nothing. */
|
|
6189
6345
|
breakingNotes: z.array(z.string()).optional(),
|
|
6346
|
+
/* AN UPDATE THAT HAS ALREADY BEEN DOWNLOADED AND BUILT on the machine that runs this container, and is
|
|
6347
|
+
* waiting for the restart that applies it. Absent for the ordinary case where nothing is staged. */
|
|
6348
|
+
staged: StagedUpdateSchema.optional(),
|
|
6190
6349
|
});
|
|
6191
6350
|
export type Info = z.infer<typeof InfoSchema>;
|
|
6192
6351
|
|
|
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
|
|
4
4
|
import {
|
|
5
5
|
isLockedWorkspacePath,
|
|
6
6
|
isReportedManifest,
|
|
7
|
+
isReviewableLockedPath,
|
|
7
8
|
REPORTED_MANIFEST_PATHS,
|
|
8
9
|
staleQueryKeys,
|
|
9
10
|
VERSIONED_STATE_PATHS,
|
|
@@ -229,6 +230,45 @@ describe(`isLockedWorkspacePath`, () => {
|
|
|
229
230
|
});
|
|
230
231
|
});
|
|
231
232
|
|
|
233
|
+
/* The carve-out the diff routes ask for, and the reason it is derived: a locked entry the root repo TRACKS has
|
|
234
|
+
* a diff by construction, and refusing to serve it made the Changes panel list a row it could not open. */
|
|
235
|
+
describe(`isReviewableLockedPath`, () => {
|
|
236
|
+
it(`admits the locked entry the root repo tracks, and nothing else locked`, () => {
|
|
237
|
+
expect(isReviewableLockedPath(`.intentic/capabilities.json`)).toBe(true);
|
|
238
|
+
// Every other locked entry is a credential, an identity binding or private runtime state. None is
|
|
239
|
+
// versioned, so none is reachable through a diff — the carve-out cannot widen without the flag.
|
|
240
|
+
expect(isReviewableLockedPath(`.intentic/owner.json`)).toBe(false);
|
|
241
|
+
expect(isReviewableLockedPath(`.intentic/members.json`)).toBe(false);
|
|
242
|
+
expect(isReviewableLockedPath(`.intentic/ci.json`)).toBe(false);
|
|
243
|
+
expect(isReviewableLockedPath(`.intentic/auth/codex/auth.json`)).toBe(false);
|
|
244
|
+
expect(isReviewableLockedPath(`.intentic/sessions/claude/x.jsonl`)).toBe(false);
|
|
245
|
+
expect(isReviewableLockedPath(`.intentic/browser/Default/Cookies`)).toBe(false);
|
|
246
|
+
expect(isReviewableLockedPath(`.git/config`)).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it(`answers only for the locked set — an ordinary path was never refused to begin with`, () => {
|
|
250
|
+
// Tracked, but not locked: the guards never ask this of them, and a `true` here would read as "this
|
|
251
|
+
// path needed a carve-out", which is a different and wrong statement.
|
|
252
|
+
expect(isReviewableLockedPath(`.intentic/settings.json`)).toBe(false);
|
|
253
|
+
expect(isReviewableLockedPath(`src/app.ts`)).toBe(false);
|
|
254
|
+
// A repo's own nested state dir is its project's content, exactly as the lock reads it.
|
|
255
|
+
expect(isReviewableLockedPath(`myrepo/.intentic/capabilities.json`)).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it(`reads a platform path and a dot-relative one the same as a posix one`, () => {
|
|
259
|
+
expect(isReviewableLockedPath(`.intentic\\capabilities.json`)).toBe(true);
|
|
260
|
+
expect(isReviewableLockedPath(`./.intentic/capabilities.json`)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it(`stays a strict subset of the lock`, () => {
|
|
264
|
+
// The carve-out is about which locked paths a DIFF may serve. A path it admits that the lock never
|
|
265
|
+
// held would mean the guards had stopped agreeing on what the control plane is.
|
|
266
|
+
for (const path of VERSIONED_STATE_PATHS) {
|
|
267
|
+
expect([path, isReviewableLockedPath(path) && !isLockedWorkspacePath(path)]).toEqual([path, false]);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
232
272
|
describe(`VERSIONED_STATE_PATHS`, () => {
|
|
233
273
|
/* THE ONE ASSERTION THAT MUST NEVER GO GREEN BY ACCIDENT.
|
|
234
274
|
*
|
|
@@ -268,6 +308,12 @@ describe(`VERSIONED_STATE_PATHS`, () => {
|
|
|
268
308
|
it(`tracks exactly the configuration slice plus the agent's own authored output`, () => {
|
|
269
309
|
expect(VERSIONED_STATE_PATHS.toSorted()).toEqual([
|
|
270
310
|
`.intentic/automations.json`,
|
|
311
|
+
/* The connections themselves, and the entry that took the longest to earn its place — it was classed
|
|
312
|
+
* `secret` on the strength of holding each capability's credential, which stopped being true when the
|
|
313
|
+
* vault took the values out and left the shape behind. Connecting a deployment orchestrator, or
|
|
314
|
+
* granting a connected computer shell and screen control, is the largest change made to what this
|
|
315
|
+
* sandbox can DO, and it used to leave no diff. */
|
|
316
|
+
`.intentic/capabilities.json`,
|
|
271
317
|
`.intentic/capability-dismissals.json`,
|
|
272
318
|
/* The two entries the AGENT authors on its own initiative, and the reason `versioned` is not read as
|
|
273
319
|
* config-only. Both are the sandbox acting outward — a draft publishes words under the owner's name,
|
|
@@ -279,6 +325,10 @@ describe(`VERSIONED_STATE_PATHS`, () => {
|
|
|
279
325
|
`.intentic/environment.custom.Dockerfile`,
|
|
280
326
|
`.intentic/environment.d/`,
|
|
281
327
|
`.intentic/extension-enablement.json`,
|
|
328
|
+
/* Its twin, and the pair is the argument: the SWITCH was already tracked while the configuration
|
|
329
|
+
* behind it was not, so a commit could record turning an extension on and say nothing about what it
|
|
330
|
+
* was told to do. Tracked once its declared-secret values moved to the vault. */
|
|
331
|
+
`.intentic/extension-settings.json`,
|
|
282
332
|
// The owner's per-extension update posture (notify / agent / auto) — a standing decision about
|
|
283
333
|
// what may run unattended, which is exactly the kind of edit worth a line in `git log`.
|
|
284
334
|
`.intentic/extension-update-policy.json`,
|
package/src/workspace-state.ts
CHANGED
|
@@ -109,12 +109,36 @@ export interface WorkspaceStateFile extends StateFile {
|
|
|
109
109
|
const STATE_FILES = [
|
|
110
110
|
/* A capability add/remove recomposes the environment overlay and can add or drop a repo's panel.
|
|
111
111
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
112
|
+
* SPLIT ALREADY — and this entry's classification had not caught up, which is the whole of what changed here.
|
|
113
|
+
* It read `secret` on a claim that had stopped being true: that each entry's `config` carries that
|
|
114
|
+
* capability's credential, so the manifest is a secret in full. It does not. capabilities-store.ts's
|
|
115
|
+
* withSecretVault keeps credential VALUES off /work entirely — the manifest holds `__intentic_vaulted__`
|
|
116
|
+
* where one used to be, reads rehydrate so no caller noticed, and main.ts sweeps a hand-written value out at
|
|
117
|
+
* boot. What is left is the SHAPE of a connection: a kind, a URL, a username, a purpose, which permissions a
|
|
118
|
+
* connected computer was granted.
|
|
119
|
+
*
|
|
120
|
+
* WHICH KEYS THOSE ARE IS DERIVED, not listed a second time: `echo` already answers "what of this config may
|
|
121
|
+
* a browser see", and the credential keys are exactly its complement (capabilities/secret-fields.ts). A kind
|
|
122
|
+
* that starts withholding a new field starts vaulting it on the same commit. That is what makes this
|
|
123
|
+
* classification a property of the code rather than a promise to re-audit it — the reason the entry can be
|
|
124
|
+
* reclassified at all, and the reason a hand-kept "these fields are safe" list could not have earned it.
|
|
125
|
+
*
|
|
126
|
+
* `carry`, and this is the entry where that earns the most. composeEnvironment reads its Dockerfile fragments
|
|
127
|
+
* from here, so a bundle that dropped it arrived on a stock overlay with an import report listing every
|
|
128
|
+
* connection to re-add by hand. It now arrives listing them itself, each visibly unconnected and waiting for
|
|
129
|
+
* one credential apiece — the shape personas.json has had all along, for the same reason.
|
|
130
|
+
*
|
|
131
|
+
* `versioned`, which is the point. Connecting this sandbox to a deployment orchestrator, or granting a
|
|
132
|
+
* connected computer shell and screen control, is the largest change anyone makes to what it can DO, and it
|
|
133
|
+
* left a diff nowhere. One consequence worth stating rather than discovering: an identifier that pairs with a
|
|
134
|
+
* credential — Komodo's api key beside its api secret, which its own connector card calls "like a database
|
|
135
|
+
* user" — is echoed, and therefore lands in the diff exactly as a database username would. */
|
|
136
|
+
{
|
|
137
|
+
path: ".intentic/capabilities.json",
|
|
138
|
+
invalidates: ["capabilities", "environment", "panels", "manifests"],
|
|
139
|
+
portability: "carry",
|
|
140
|
+
versioned: true,
|
|
141
|
+
},
|
|
118
142
|
|
|
119
143
|
/* Which workspace-derived recommendations the owner has said "not needed" to, and the evidence each was
|
|
120
144
|
* declined against. It rides the `capabilities` key because the catalog is what changes when one lands, and
|
|
@@ -290,16 +314,33 @@ const STATE_FILES = [
|
|
|
290
314
|
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.",
|
|
291
315
|
portability: "carry",
|
|
292
316
|
},
|
|
293
|
-
/*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
317
|
+
/* SPLIT, so that "what an extension is configured to do" and "the token it does it with" stop being one file.
|
|
318
|
+
*
|
|
319
|
+
* This entry used to be `secret` and untracked, classed by what a value COULD hold: values are a primitive
|
|
320
|
+
* union an extension chooses the meaning of, and "an API key for the service I talk to" is squarely within
|
|
321
|
+
* it. That classification was honest about the risk and wrong about the file — it meant an extension's whole
|
|
322
|
+
* configuration was unreviewable because one of its keys might be a credential, AND the credential was in
|
|
323
|
+
* there anyway, in a file the workspace API does not lock. A turn could simply read it.
|
|
324
|
+
*
|
|
325
|
+
* A descriptor already says which keys those are (`contributes.settings[].secret`), so the values it names
|
|
326
|
+
* now live in the vault off /work and this file keeps the rest — the capability manifest's split, applied to
|
|
327
|
+
* the same problem one table over (extensions/extension-settings.ts holds it, and the reasoning). Reads
|
|
328
|
+
* rehydrate, so no caller changed.
|
|
329
|
+
*
|
|
330
|
+
* What the split earns: `carry`, because what is left is an extension's configuration and a bundle should
|
|
331
|
+
* arrive with it; and `versioned`, because turning an extension's behaviour on is a decision, and the file
|
|
332
|
+
* that records it can now be read without reading anybody's token. The boot sweep is what keeps that true of
|
|
333
|
+
* a file the agent can also edit — see vaultExtensionSettingSecrets.
|
|
334
|
+
*
|
|
335
|
+
* NO `note`, and the split is why: a note is printed by the import report beside a SKIPPED entry, so an entry
|
|
336
|
+
* that carries can never show one. "Re-enter the credentials" is now the vault's instruction to give, and the
|
|
337
|
+
* vault is under `.intentic/auth/` — which is skipped, and says so there. */
|
|
297
338
|
{
|
|
298
339
|
path: ".intentic/extension-settings.json",
|
|
299
340
|
invalidates: [],
|
|
300
341
|
why: "Held in a module-level shallowRef store per extension (web's extensionSettingsStore) with no query observer, and deliberately so: api.settings.get must answer SYNCHRONOUSLY from an extension's first activate() line, and the store outlives every component scope. A module-level QueryObserver is the one shape that would make invalidation refetch, and this app already ruled it out — it detaches on the queryClient.clear() at logout (see useSandbox's sandbox-list mirror). So a remote member's setting edit reaches this browser on its next load, not live.",
|
|
301
|
-
portability: "
|
|
302
|
-
|
|
342
|
+
portability: "carry",
|
|
343
|
+
versioned: true,
|
|
303
344
|
},
|
|
304
345
|
/* Unlike the settings file above it, the on/off switch IS observed by a query — the Extensions tab's list,
|
|
305
346
|
* which carries each row's switch position — so a flip made elsewhere (another member, the agent writing the
|
|
@@ -346,6 +387,17 @@ const STATE_FILES = [
|
|
|
346
387
|
why: "Which of the routes each extension DECLARED it has actually called — the evidence behind the permissions list on its row. The one entry here whose empty set is a RATE decision rather than an architectural one: every browser with the app open reports its batch on a timer, so wiring this to the `extensions` query would refetch the whole list every few seconds for a figure nobody is watching change. The tab reads it when it loads, which is when anyone is reading it.",
|
|
347
388
|
portability: "carry",
|
|
348
389
|
},
|
|
390
|
+
/* THE ONE ENTRY WHERE "HOLDS NO CREDENTIAL" IS TRUE AND `versioned` IS STILL WRONG, which is worth stating
|
|
391
|
+
* because it looks like the two above it: an email and a role per row, nothing to vault, and "who may drive
|
|
392
|
+
* this sandbox" is as consequential a fact as any this table tracks.
|
|
393
|
+
*
|
|
394
|
+
* It stays out for two reasons that are not about secrecy. It is a MIRROR — the platform's invite records are
|
|
395
|
+
* the grant, this is the copy the enforcer keeps so a grant it never received is never honoured, and a change
|
|
396
|
+
* here is the two disagreeing rather than anyone deciding something. Review of the decision already exists,
|
|
397
|
+
* on the Access tab, against the record that is authoritative. And tracking it would mean reclassifying it
|
|
398
|
+
* `carry` to satisfy the guard, which is the one thing it must never be: an access list that travelled would
|
|
399
|
+
* let a source sandbox hand itself the target's ownership. Widening the guard for this single entry is the
|
|
400
|
+
* worse trade — it protects every `identity` entry, and most of those ARE credentials. */
|
|
349
401
|
{
|
|
350
402
|
path: ".intentic/members.json",
|
|
351
403
|
invalidates: [],
|
|
@@ -358,13 +410,20 @@ const STATE_FILES = [
|
|
|
358
410
|
/* Keep credentials and conversation state in disjoint top-level trees. Provider homes are intentionally
|
|
359
411
|
* classified as a single secret unit: several CLIs mix OAuth, config, and provider-native thread metadata,
|
|
360
412
|
* and no generic export can safely distinguish those files. The broad root also makes a newly-added provider
|
|
361
|
-
* secret by construction instead of relying on another hand-maintained provider-name list.
|
|
413
|
+
* secret by construction instead of relying on another hand-maintained provider-name list.
|
|
414
|
+
*
|
|
415
|
+
* IT IS NO LONGER ONLY THE AI LOGINS. Both credential splits put their vault here — `capability-secrets.json`
|
|
416
|
+
* and `extension-secrets.json`, sited beside the provider homes precisely because this tree is already
|
|
417
|
+
* outside the file routes, the workspace walk and the search index (composition.ts sites them, and the two
|
|
418
|
+
* stores argue why). So this is now the ONE entry a secret-less bundle leaves behind, and its note is
|
|
419
|
+
* therefore the only place the owner is told what to re-enter: the manifests that name those connections
|
|
420
|
+
* travel, and would otherwise arrive looking complete. */
|
|
362
421
|
{
|
|
363
422
|
path: ".intentic/auth/",
|
|
364
423
|
invalidates: [],
|
|
365
|
-
why: "AI-provider credentials and runtime homes; each account is rendered through owner-gated provider routes.",
|
|
424
|
+
why: "AI-provider credentials and runtime homes, plus the capability and extension-settings secret vaults; each account is rendered through owner-gated provider routes.",
|
|
366
425
|
portability: "secret",
|
|
367
|
-
note: "Sign the agent's AI accounts in again on the Agent tab.",
|
|
426
|
+
note: "Sign the agent's AI accounts in again on the Agent tab, then re-enter each connection's credential on Capabilities and each extension's secret settings on Extensions — both arrived listed but unauthenticated.",
|
|
368
427
|
},
|
|
369
428
|
/* Agent session transcripts, rewritten on every streamed token.
|
|
370
429
|
*
|
|
@@ -538,8 +597,13 @@ export const VERSIONED_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.fi
|
|
|
538
597
|
* ledger is forgotten from, and the forgetting is silent — it ranked loop iteration history and cloned
|
|
539
598
|
* third-party extension source against the user's own code for months before this derivation existed.
|
|
540
599
|
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
600
|
+
* WHAT THE CREDENTIAL SPLITS MOVED ACROSS THIS LINE, since the note that used to sit here said the opposite and
|
|
601
|
+
* was worth replacing rather than deleting. `capabilities.json` was `secret` and unversioned, and the sentence
|
|
602
|
+
* celebrated that the index therefore stopped copying capability tokens into search text. It is `versioned` now
|
|
603
|
+
* and searchable — and the guarantee is unchanged, because the tokens are not in the file any more. The floor
|
|
604
|
+
* moved from "keep the index away from the file that holds credentials" to "the file holds none", which is the
|
|
605
|
+
* stronger of the two: it also holds for the shell, which never consulted this list at all. `auth/` — where those
|
|
606
|
+
* values went, both vaults included — is still denied, and is the entry that was doing the real work all along. */
|
|
543
607
|
export const SEARCHABLE_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter((file) => file.versioned || file.authored).map(
|
|
544
608
|
(file) => file.path,
|
|
545
609
|
);
|
|
@@ -633,6 +697,29 @@ export const isLockedWorkspacePath = (relPath: string): boolean => {
|
|
|
633
697
|
return segments.length >= 2 && segments[0] === STATE_DIR && LOCKED_STATE_ENTRIES.has(segments[1] ?? "");
|
|
634
698
|
};
|
|
635
699
|
|
|
700
|
+
/* THE LOCKED ENTRIES THE ROOT REPO TRACKS — refused by the file API, and diffable anyway.
|
|
701
|
+
*
|
|
702
|
+
* `capabilities.json` is the only one today and the whole reason this exists. Both of its rules are right on
|
|
703
|
+
* their own: it is `versioned`, because connecting this sandbox to a deployment orchestrator is the largest
|
|
704
|
+
* change anyone makes to what it can DO and that belongs in review; and it is locked, because a member who
|
|
705
|
+
* could PUT one through the generic file API would be granting themselves a capability the owner never
|
|
706
|
+
* approved. The lock was always about that WRITE — its credentials live in the vault, not in the file.
|
|
707
|
+
*
|
|
708
|
+
* Together, though, the second silently cancelled the first. The Changes panel listed the file (git tracks it,
|
|
709
|
+
* so `git status` reports it), and clicking the row asked a diff route that refuses every control-plane path —
|
|
710
|
+
* a 404 on the one surface `versioned` exists to produce. The bytes were already in `git log`, in every clone
|
|
711
|
+
* of the root repo and in the workspace search; only the review was missing.
|
|
712
|
+
*
|
|
713
|
+
* So the review surfaces ask THIS instead of the flat lock, and it derives from the same flag rather than
|
|
714
|
+
* naming the file, so marking another locked entry `versioned` cannot reproduce the contradiction. Every other
|
|
715
|
+
* surface — read, write, move, delete, publish — still asks `isLockedWorkspacePath` and still refuses.
|
|
716
|
+
*
|
|
717
|
+
* Accepts either slash, like the rule above it. */
|
|
718
|
+
export const isReviewableLockedPath = (relPath: string): boolean => {
|
|
719
|
+
const rel = relPath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
720
|
+
return isLockedWorkspacePath(rel) && VERSIONED_STATE_PATHS.some((path) => (path.endsWith("/") ? rel.startsWith(path) : rel === path));
|
|
721
|
+
};
|
|
722
|
+
|
|
636
723
|
/* Every path this table declares, as a type. `as const` above is what makes it one, and it is what finally makes
|
|
637
724
|
* the first sentence of this file's header TRUE rather than aspirational.
|
|
638
725
|
*
|