@intentic/sandbox-contract 1.176.2 → 1.176.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentic/sandbox-contract",
3
- "version": "1.176.2",
3
+ "version": "1.176.3",
4
4
  "description": "oRPC wire contract for the intentic sandbox daemon — shared by the daemon and its browser client",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -67,8 +67,8 @@
67
67
  "@orpc/contract": "1.14.13",
68
68
  "tslib": "2.8.1",
69
69
  "zod": "4.4.3",
70
- "@intentic/extension-api": "1.176.2",
71
- "@intentic/registry": "0.0.0"
70
+ "@intentic/registry": "0.0.0",
71
+ "@intentic/extension-api": "1.176.3"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@types/node": "24.13.2",
@@ -36,6 +36,11 @@ export interface ProbeSpec {
36
36
  readonly timeoutMs: number;
37
37
  // Exit 0 ⇒ this repo can be measured. Runs in the repo's own directory, like the command.
38
38
  readonly available: string;
39
+ /* What is MISSING when `available` says no, named here rather than derived from the title. The obvious
40
+ * derivation — "this repository has no security advisories to measure" — states the one thing an unmeasured
41
+ * probe must never claim, that there are none, and it is the same conflation the block above exists to
42
+ * prevent. Phrased as a bare clause ("no lockfile"), because the panel groups these under its own lead-in. */
43
+ readonly unavailable: string;
39
44
  // `sh -c`, in the repo's directory. Stdout is the parser's input; a non-zero exit is NOT a failure by itself
40
45
  // (pnpm outdated and pnpm audit both exit non-zero precisely when they have something to report), so the
41
46
  // runner judges by whether the parser recognised the output.
@@ -216,6 +221,7 @@ export const PROBES: readonly ProbeSpec[] = [
216
221
  ttlMs: DAY_MS,
217
222
  timeoutMs: 5 * 60_000,
218
223
  available: `test -f package.json`,
224
+ unavailable: `no package.json`,
219
225
  // `-r` so a monorepo reports every workspace package, not just the root's own handful. `|| true` because
220
226
  // pnpm exits non-zero exactly when it HAS findings, and the runner judges by whether the parse succeeded.
221
227
  command: `pnpm outdated -r --json 2>/dev/null || true`,
@@ -231,6 +237,7 @@ export const PROBES: readonly ProbeSpec[] = [
231
237
  // A lockfile, not a package.json: auditing resolves the actual installed tree, and without one pnpm
232
238
  // reports against nothing.
233
239
  available: `test -f pnpm-lock.yaml || test -f package-lock.json`,
240
+ unavailable: `no lockfile`,
234
241
  command: `pnpm audit --json 2>/dev/null || true`,
235
242
  parse: parseAudit,
236
243
  },
@@ -244,6 +251,7 @@ export const PROBES: readonly ProbeSpec[] = [
244
251
  // The repo's OWN knip, never a floating one: `pnpm dlx knip` would download a version that disagrees with
245
252
  // the repo's knip.json about what counts as an entry point, and then report its whole public API as dead.
246
253
  available: `pnpm exec knip --version >/dev/null 2>&1`,
254
+ unavailable: `knip is not a devDependency`,
247
255
  command: `pnpm exec knip --reporter json --no-exit-code 2>/dev/null || true`,
248
256
  parse: parseKnip,
249
257
  },
@@ -255,6 +263,7 @@ export const PROBES: readonly ProbeSpec[] = [
255
263
  ttlMs: 7 * DAY_MS,
256
264
  timeoutMs: 20 * 60_000,
257
265
  available: `test -f package.json`,
266
+ unavailable: `no package.json`,
258
267
  // `--threshold 100` so jscpd never fails the command on its own opinion of what is too much duplication —
259
268
  // that judgement is the chore's, made from the percentage, not the tool's exit code.
260
269
  command:
@@ -73,7 +73,13 @@ const unmeasuredDetail = (needs: readonly ProbeId[], probes: ReadonlyMap<ProbeId
73
73
  if (probe.state === `ok`) {
74
74
  return [];
75
75
  }
76
- return [`${spec.title} · ${probe.state === `unavailable` ? `not available in this repository` : `failed`}${probe.reason === undefined ? `` : ` — ${probe.reason}`}`];
76
+ // An unavailable probe's reason already says what is missing ("no lockfile"), so prefixing it with "not
77
+ // available in this repository" only says the same thing twice. A failure has to keep its label: its
78
+ // reason is the tool's own output, which on its own reads as a fact rather than as a breakage.
79
+ if (probe.state === `unavailable`) {
80
+ return [`${spec.title} · ${probe.reason ?? `not available in this repository`}`];
81
+ }
82
+ return [`${spec.title} · failed${probe.reason === undefined ? `` : ` — ${probe.reason}`}`];
77
83
  });
78
84
 
79
85
  export const assessChore = (chore: Chore, context: ChoreContext, ledger: ChoreLedgerEntry | undefined): ChoreVerdict => {
@@ -0,0 +1,100 @@
1
+ import type { StateFile } from "./state-portability.js";
2
+
3
+ /* WHAT LIVES ON /history — the second half of the daemon's state, and the half nothing declared until an
4
+ * export had to reason about it.
5
+ *
6
+ * `WORKSPACE_STATE_FILES` covers `<workspace>/.intentic/`, which is where the manifests live. It is not where
7
+ * the machinery lives. Every repo's REAL git dir is here (a repo's in-tree `.git` is a pointer file — see
8
+ * git/repo-git-dirs.ts for the invariant that forces it), and so are the fleet registry, the turn journal, the
9
+ * ledgers, the checkpoint scopes and the isolated agents' checkouts. A "workspace export" that took `/work`
10
+ * alone would carry a tree of repos with dangling gitdir pointers — every git command in the restored sandbox
11
+ * answering `fatal: not a git repository` — and an empty agent board.
12
+ *
13
+ * The two tables stay separate rather than becoming one keyed by volume, because they answer different
14
+ * questions. A `.intentic` entry also declares which browser QUERY it makes stale, since the file watcher
15
+ * reports it; nothing here is watched at all (that is the point of the volume), so an `invalidates` field on
16
+ * these entries would be a column of empty arrays. What they share is the portability class, and that is
17
+ * imported rather than duplicated.
18
+ *
19
+ * `history-state.test.ts` fails when a daemon store builds a `/history` path this list doesn't carry, in both
20
+ * directions — the same shape-recognizing guard that covers the workspace table.
21
+ */
22
+
23
+ // Paths are historyRoot-relative, forward-slash, matched by PREFIX; a directory entry keeps its trailing slash
24
+ // so it cannot prefix-match a sibling file. See stateFileFor for how nesting resolves.
25
+ export const HISTORY_STATE_FILES: readonly StateFile[] = [
26
+ /* ---- the machinery a restored workspace is inert without ---- */
27
+
28
+ /* THE ONE THAT MAKES A BUNDLE A WORKSPACE. Every repo's real git dir, including the /work root's own
29
+ * ("root"), keyed by URI-encoded repo id. Carrying the working tree without this hands the target files
30
+ * whose `.git` points at a path that does not exist there — which is not a degraded repo but a broken one,
31
+ * and it takes the Changes review, the diff, land and every agent branch with it. The agent BRANCHES live
32
+ * in here too, which is what lets the checkouts below be left out. */
33
+ { path: "gits/", portability: "carry" },
34
+ // The checkpoint timeline (one bare repo per scope, snapshots on refs/snapshots/head). Restoring it is what
35
+ // makes "restore to before that turn" still reach back past the move.
36
+ { path: "scopes/", portability: "carry" },
37
+ // The fleet: every conversation card, its branch, its session ids, its standing.
38
+ { path: "agents.json", portability: "carry" },
39
+ { path: "turns/", portability: "carry" },
40
+ { path: "transcripts/", portability: "carry" },
41
+ { path: "activity.jsonl", portability: "carry" },
42
+ { path: "usage.jsonl", portability: "carry" },
43
+ { path: "account-usage.json", portability: "carry" },
44
+ { path: "provider-refusals.json", portability: "carry" },
45
+ // The deploy engine's own ledgers — a run's events and the check results the Pipelines view reads back.
46
+ { path: "apply-events.ndjson", portability: "carry" },
47
+ { path: "check-events/", portability: "carry" },
48
+
49
+ /* ---- regenerated by the target ---- */
50
+
51
+ /* THE DELIBERATE OMISSION, and the difference between a bundle of gigabytes and one of hundreds.
52
+ *
53
+ * A conversation's worktree is a full checkout of the monorepo per agent (plus its overlay upper dir), and
54
+ * there can be a hundred of them. None of it is unique: the branch it holds is in `gits/` above, and the
55
+ * registry entry naming it travels in agents.json — so an imported conversation arrives in exactly the
56
+ * shape the system already has a name for. `attached()` reports its checkout as absent, the board renders
57
+ * it, and the next turn's `ensure()` re-creates it from the recorded composition, which is the same path an
58
+ * archived agent takes when it runs again. The boot sweep's `git worktree prune` clears the stale admin
59
+ * entries the restored git dirs still carry. */
60
+ {
61
+ path: "worktrees/",
62
+ portability: "derived",
63
+ note: "Each conversation re-attaches its checkout from its branch on its next turn.",
64
+ },
65
+ { path: "overlays/", portability: "derived" },
66
+ { path: "logs/", portability: "derived" },
67
+ { path: "trash/", portability: "derived" },
68
+ { path: ".isolation-probe", portability: "derived" },
69
+
70
+ /* ---- credentials ---- */
71
+
72
+ // The ssh alias dir ~/.ssh/intentic-hosts symlinks to: per-host config, private keys and passphrases for
73
+ // every host capability and git remote the sandbox reaches.
74
+ {
75
+ path: "ssh-hosts/",
76
+ portability: "secret",
77
+ note: "Re-add each ssh host on the Capabilities view — its key does not travel.",
78
+ },
79
+ // The cli-proxy's config, which holds the routed subscriptions' provider tokens.
80
+ {
81
+ path: "translator/",
82
+ portability: "secret",
83
+ note: "Sign the routed AI subscriptions in again on the Agent tab.",
84
+ },
85
+
86
+ /* ---- identity: what binds this sandbox to its owner, its browsers and its host ---- */
87
+
88
+ /* Signs every browser session cookie. Carrying it would let a bundle's holder mint sessions against the
89
+ * target — an export becomes a credential — and the target minting its own costs exactly one sign-in. */
90
+ { path: "session-secret", portability: "identity", note: "Sign in again — the target signs its own sessions." },
91
+ {
92
+ path: "push.json",
93
+ portability: "identity",
94
+ note: "Re-enable notifications in the browsers you use — a push subscription is bound to the sandbox that minted it.",
95
+ },
96
+ { path: "sync-enrollments.json", portability: "identity", note: "Re-pair desktop sync from the Sync tab." },
97
+ { path: "sync-pair-consumed.json", portability: "identity" },
98
+ { path: "host-enrollments.json", portability: "identity" },
99
+ { path: "local-cert/", portability: "identity" },
100
+ ];
package/src/index.ts CHANGED
@@ -76,6 +76,8 @@ export * from "./events.js";
76
76
  export * from "./sse.js";
77
77
  export * from "./routes.js";
78
78
  export * from "./workspace-state.js";
79
+ export * from "./state-portability.js";
80
+ export * from "./history-state.js";
79
81
  export * from "./agent-catalog.js";
80
82
  export * from "./host-protocol.js";
81
83
  export * from "./hostnames.js";
package/src/schemas.ts CHANGED
@@ -3207,6 +3207,53 @@ export const EnvironmentSchema = z.object({
3207
3207
  export type Environment = z.infer<typeof EnvironmentSchema>;
3208
3208
  export const EnvironmentApproveSchema = z.object({ hash: z.string().min(1) });
3209
3209
 
3210
+ /* ---- portability: exporting a sandbox's environment and restoring it into a fresh one ----
3211
+ *
3212
+ * A sandbox is four stores, not one: `/work` (the workspace and the daemon's manifests), `/history` (every
3213
+ * repo's real git dir, the fleet registry, the ledgers), the CONTAINER (the built overlay image plus the env
3214
+ * the run contract replays) and the AI-provider credential root. A bundle carries the first two, declared entry
3215
+ * by entry in WORKSPACE_STATE_FILES / HISTORY_STATE_FILES. It cannot carry the other two, and the honest
3216
+ * consequence is that an import ends in a REPORT rather than a claim of equivalence — the container has no
3217
+ * docker socket, so only the host can rebuild the image the overlay describes.
3218
+ */
3219
+
3220
+ // What the bundle says about itself, written as its first tar entry so a reader learns the shape before the
3221
+ // bytes. `secrets` is the owner's export-time choice; the restorer re-derives every decision from the manifests
3222
+ // rather than trusting this, and uses it only to explain what is missing.
3223
+ export const BundleManifestSchema = z.object({
3224
+ // Bumped when the layout changes in a way an older daemon would misread. Refused rather than guessed at.
3225
+ version: z.literal(1),
3226
+ // Where it came from, for the report's first line. Never used to authorize anything.
3227
+ sandbox: z.object({ name: z.string() }).optional(),
3228
+ createdAt: z.number(),
3229
+ secrets: z.boolean(),
3230
+ /* The environment the target has to reproduce, carried as FACTS rather than as the composed file (which the
3231
+ * target recomposes against its OWN base image on first boot). `customDockerfile` is the owner-approved
3232
+ * source section; `capabilities` names what contributed the remaining fragments, so the report can list what
3233
+ * to re-add when the configs themselves did not travel. */
3234
+ environment: z.object({
3235
+ customDockerfile: z.string().optional(),
3236
+ baseImage: z.string().optional(),
3237
+ approvedHash: z.string().optional(),
3238
+ capabilities: z.array(z.object({ id: z.string(), kind: z.string() })),
3239
+ }),
3240
+ // Every path class the bundle deliberately left out, with the manifest's own note where it has one. This is
3241
+ // what turns "the export skipped things" from a silence into a list the owner can act on.
3242
+ excluded: z.array(z.object({ path: z.string(), portability: z.string(), note: z.string().optional() })),
3243
+ });
3244
+ export type BundleManifest = z.infer<typeof BundleManifestSchema>;
3245
+
3246
+ // What a restore actually did. `needsAction` is the part that matters: the environment rebuild command, the
3247
+ // credentials to re-enter, the logins to redo — each one a thing the target cannot do for itself.
3248
+ export const ImportReportSchema = z.object({
3249
+ restored: z.object({ workspaceFiles: z.number(), historyFiles: z.number(), repos: z.array(z.string()), bytes: z.number() }),
3250
+ // Entries the bundle carried that this daemon refused to write (an identity file, an escaping path) — empty
3251
+ // for any bundle a matching exporter produced, and a tamper signal when it is not.
3252
+ refused: z.array(z.string()),
3253
+ needsAction: z.array(z.object({ subject: z.string(), detail: z.string() })),
3254
+ });
3255
+ export type ImportReport = z.infer<typeof ImportReportSchema>;
3256
+
3210
3257
  // ---- secrets: user-supplied env-var secrets the daemon writes to desired-state/.env ----
3211
3258
  // The web posts a Cloudflare token / GitHub PAT / another-host SSH key straight to the sandbox daemon (never
3212
3259
  // through the platform); `apply` reloads .env each run so a new secret is picked up with NO restart. `list`
@@ -3693,7 +3740,10 @@ export const ProbeResultSchema = z.object({
3693
3740
  // deciding whether to force a refresh deserves to know what they are asking for.
3694
3741
  tookMs: z.number().int().nonnegative(),
3695
3742
  facts: ProbeFactsSchema.optional(),
3696
- // Why it is unavailable, or how it failed — a bounded quote of the tool's own output. Never invented here.
3743
+ // On `failed`, how it broke — a bounded quote of the tool's own output, never a summary of it. On
3744
+ // `unavailable`, what is missing, in the probe spec's own words ("no lockfile"): there is no tool output to
3745
+ // quote when the tool never ran, and the alternative — a sentence built from the probe's name — would have an
3746
+ // unmeasured probe claiming there is nothing to measure.
3697
3747
  reason: z.string().optional(),
3698
3748
  });
3699
3749
  export type ProbeResult = z.infer<typeof ProbeResultSchema>;
@@ -0,0 +1,56 @@
1
+ /* WHETHER A PIECE OF DAEMON STATE TRAVELS — the second thing every state manifest declares.
2
+ *
3
+ * A sandbox's environment is not one directory. `/work` holds the workspace and the daemon's manifests,
4
+ * `/history` holds the real git dirs, the fleet registry and the ledgers, and the container carries the built
5
+ * image and its env. "Export the environment" therefore means walking state that lives in two volumes and
6
+ * deciding, per entry, which of four things it is — and that decision has to be written down beside the entry
7
+ * itself, or the exporter becomes the hand-kept list that workspace-state.ts exists to argue against.
8
+ *
9
+ * So each manifest entry carries a class, and both the bundler and the restorer derive from it. Adding a store
10
+ * without saying whether it travels is a failing coverage test, not a file that silently goes missing from
11
+ * every bundle.
12
+ *
13
+ * The classes are exhaustive on purpose — there is no "unset" — because the failure mode of a default is
14
+ * exactly the one that matters: a credential store added later would inherit `carry` and ride out of the
15
+ * sandbox in a file the owner thought was safe to email.
16
+ */
17
+ export type Portability =
18
+ /* Ordinary state. Travels in every bundle. */
19
+ | "carry"
20
+ /* Holds a credential — a provider token, a webhook secret, an ssh key. Travels ONLY when the owner opts
21
+ * in at export, and the bundle records that it did. Never the default. */
22
+ | "secret"
23
+ /* Binds this sandbox to its owner, its browsers or its host: who may drive it, the session-signing secret,
24
+ * push subscriptions, enrollments, certificates. Never travels — the target mints its own, and carrying
25
+ * one would either hand the target's ownership to the source or leave two sandboxes claiming one identity. */
26
+ | "identity"
27
+ /* Regenerated by the target from state that does travel: composed files, caches, indexes, checkouts. Left
28
+ * out for size, not for safety — carrying it would only ship a stale copy of something the daemon rebuilds
29
+ * on its own. */
30
+ | "derived";
31
+
32
+ // One entry of a state manifest: the path it owns and what happens to it at export. `note` is user-facing —
33
+ // the import report prints it beside a skipped entry, so it is written as an instruction to the owner ("log in
34
+ // again"), not as a rationale for the next reader. Rationale belongs in a comment on the entry.
35
+ export interface StateFile {
36
+ readonly path: string;
37
+ readonly portability: Portability;
38
+ readonly note?: string;
39
+ }
40
+
41
+ /* The entry that owns a path, LONGEST PREFIX FIRST.
42
+ *
43
+ * Manifests deliberately nest: `.intentic/claude/` is a provider credential store that also contains
44
+ * `.intentic/claude/projects/`, the agent's memory notes and transcripts, which are the single most valuable
45
+ * thing in a bundle. First-match resolution would classify the notes by whichever entry happened to be listed
46
+ * first, so the specific entry has to win over the general one by construction rather than by list order —
47
+ * the same reason a .gitignore consults its deepest matcher first.
48
+ *
49
+ * Undefined for a path no entry claims. Callers decide what that means: the bundler treats an unclaimed
50
+ * `.intentic` path as a bug (the coverage test is what makes that safe to assume) and unclaimed ordinary
51
+ * workspace files as `carry`, which is what they are.
52
+ */
53
+ export const stateFileFor = <T extends StateFile>(path: string, files: readonly T[]): T | undefined =>
54
+ files
55
+ .filter((file) => path === file.path || path.startsWith(file.path) || `${path}/` === file.path)
56
+ .reduce<T | undefined>((best, file) => (best === undefined || file.path.length > best.path.length ? file : best), undefined);
@@ -117,13 +117,31 @@ describe(`WORKSPACE_STATE_FILES`, () => {
117
117
  }
118
118
  });
119
119
 
120
- it(`has no entry that prefix-matches another, so one write can't be billed twice`, () => {
121
- for (const file of WORKSPACE_STATE_FILES) {
122
- const overlapping = WORKSPACE_STATE_FILES.filter((other) => other !== file && other.path.startsWith(file.path));
120
+ it(`only nests under an entry that invalidates nothing, so one write can't be billed twice`, () => {
121
+ // Entries nest when one store answers PORTABILITY in two halves (`.intentic/claude/` is a credential
122
+ // root whose `projects/` subtree is the thing a bundle exists to carry) — stateFileFor's longest match
123
+ // is what keeps that unambiguous. Invalidation has no longest-match rule: staleQueryKeys unions every
124
+ // matching entry, so a nest under an entry that DOES invalidate would bill the outer view's queries for
125
+ // a write that belongs to the inner one. Nesting is therefore only legal beneath an empty `invalidates`.
126
+ for (const file of WORKSPACE_STATE_FILES.filter((entry) => entry.invalidates.length > 0)) {
127
+ const nested = WORKSPACE_STATE_FILES.filter((other) => other !== file && other.path.startsWith(file.path));
123
128
  expect(
124
- overlapping.map((other) => other.path),
125
- `${file.path} is a prefix of another entry`,
129
+ nested.map((other) => other.path),
130
+ `${file.path} invalidates ${file.invalidates.join(`, `)} and is a prefix of another entry`,
126
131
  ).toEqual([]);
127
132
  }
128
133
  });
134
+
135
+ it(`splits a nested entry from its parent for a reason the parent doesn't already carry`, () => {
136
+ // A nest that agrees with the entry it sits under is a duplicate: stateFileFor resolves to the same
137
+ // answer either way, so the split is dead weight the next reader has to diff to discover.
138
+ for (const file of WORKSPACE_STATE_FILES) {
139
+ for (const parent of WORKSPACE_STATE_FILES.filter((other) => other !== file && file.path.startsWith(other.path))) {
140
+ expect(
141
+ parent.portability === file.portability && parent.invalidates.join() === file.invalidates.join(),
142
+ `${file.path} says nothing its parent ${parent.path} doesn't already say`,
143
+ ).toBe(false);
144
+ }
145
+ }
146
+ });
129
147
  });
@@ -1,4 +1,5 @@
1
1
  import type { FileContribution } from "@intentic/extension-api";
2
+ import type { StateFile } from "./state-portability.js";
2
3
 
3
4
  /* WHICH WORKSPACE FILE BACKS WHICH CORE VIEW — one declaration, read by both sides of the wire.
4
5
  *
@@ -29,16 +30,18 @@ import type { FileContribution } from "@intentic/extension-api";
29
30
  * by the automations extension, because the extension had no way to say so itself. A key belongs to whoever
30
31
  * queries it. */
31
32
 
32
- // A core entry is an extension's `contributes.files` entry plus the one thing only the core list needs: the
33
- // right to declare NO invalidations, which for a daemon-owned file is the answer more often than not.
34
- export interface WorkspaceStateFile {
35
- /* Workspace-root-relative, forward-slash — the space `workspaceChanged` paths arrive in. Matching is by
36
- * PREFIX, which lets one entry cover three shapes without a second matching rule:
37
- * - an exact file `.intentic/settings.json`
38
- * - a directory `.intentic/drafts/` (one file per draft)
39
- * - a name family `.intentic/environment.` (…Dockerfile, .custom.Dockerfile, .approved.Dockerfile)
40
- * A directory entry keeps its trailing slash so it can never prefix-match a sibling file. */
41
- readonly path: string;
33
+ /* A core entry is an extension's `contributes.files` entry plus the two things only the core list needs: the
34
+ * right to declare NO invalidations (for a daemon-owned file, the answer more often than not), and a
35
+ * portability class, because the daemon's own state is what an environment export has to reason about.
36
+ *
37
+ * `path` is workspace-root-relative, forward-slash the space `workspaceChanged` paths arrive in. Matching is
38
+ * by PREFIX, which lets one entry cover three shapes without a second matching rule:
39
+ * - an exact file `.intentic/settings.json`
40
+ * - a directory `.intentic/drafts/` (one file per draft)
41
+ * - a name family `.intentic/environment.custom.` (…Dockerfile and anything later named beside it)
42
+ * A directory entry keeps its trailing slash so it can never prefix-match a sibling file. Entries may NEST —
43
+ * see stateFileFor, which resolves the longest match rather than the first. */
44
+ export interface WorkspaceStateFile extends StateFile {
42
45
  /* The browser query keys this file's contents feed. EMPTY is a real answer, not a gap — a file the browser
43
46
  * renders nothing from, or one deliberately kept off the push path — and `why` says which. Never a prefix
44
47
  * test over `.intentic/` as a whole: one stray write must not cost every view a refetch, which is the
@@ -49,13 +52,39 @@ export interface WorkspaceStateFile {
49
52
  }
50
53
 
51
54
  export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
52
- // A capability add/remove recomposes the environment overlay and can add or drop a repo's panel.
53
- { path: ".intentic/capabilities.json", invalidates: ["capabilities", "environment", "panels"] },
54
- { path: ".intentic/environment.", invalidates: ["environment"] },
55
- { path: ".intentic/settings.json", invalidates: ["settings"] },
55
+ /* A capability add/remove recomposes the environment overlay and can add or drop a repo's panel.
56
+ *
57
+ * Each entry's `config` carries that capability's credential (an mcp server's token, a Komodo key, an ssh
58
+ * key), so the manifest is a secret in full. It is also what composeEnvironment reads its Dockerfile
59
+ * fragments from, which makes this the entry where the owner's export choice has the most visible
60
+ * consequence: a bundle exported WITHOUT secrets rebuilds a stock overlay, and the import report has to
61
+ * name every capability the target needs re-added before its environment matches again. */
62
+ { path: ".intentic/capabilities.json", invalidates: ["capabilities", "environment", "panels"], portability: "secret" },
63
+
64
+ /* The overlay Dockerfile, four files that a single `.intentic/environment.` prefix used to cover. They are
65
+ * split here because they answer PORTABILITY differently while answering invalidation identically, and the
66
+ * split is the whole difference between an export that reproduces an environment and one that reproduces a
67
+ * stale copy of it:
68
+ * - custom is the owner-approved SOURCE OF TRUTH and the only one that must travel;
69
+ * - approved is COMPOSED from custom + the capability fragments + this container's base image, and is
70
+ * rewritten on the target's first boot — carrying it would ship a FROM naming an image the target may
71
+ * not be on (see composeEnvironment's baseImageOf);
72
+ * - the proposal and the per-tool drafts under environment.d/ are the agent's pending requests, which the
73
+ * owner has not answered yet; they travel so the question survives the move. */
74
+ { path: ".intentic/environment.custom.Dockerfile", invalidates: ["environment"], portability: "carry" },
75
+ { path: ".intentic/environment.Dockerfile", invalidates: ["environment"], portability: "carry" },
76
+ { path: ".intentic/environment.d/", invalidates: ["environment"], portability: "carry" },
77
+ {
78
+ path: ".intentic/environment.approved.Dockerfile",
79
+ invalidates: ["environment"],
80
+ portability: "derived",
81
+ note: "The target composes its own overlay on first boot; rebuild it there to install the tools it names.",
82
+ },
83
+
84
+ { path: ".intentic/settings.json", invalidates: ["settings"], portability: "carry" },
56
85
  // Written by the AGENT's file tools (the drafts skill), read by the owner's approval inbox — the one entry
57
86
  // here whose whole point is that a change arrives from outside the browser that renders it.
58
- { path: ".intentic/drafts/", invalidates: ["drafts"] },
87
+ { path: ".intentic/drafts/", invalidates: ["drafts"], portability: "carry" },
59
88
  // ---- declared by the extension that renders them (contributes.files), not here ----
60
89
  // The path is the DAEMON's (automations-store writes both), the query keys are the intentic.automations
61
90
  // extension's. It declares them in its own manifest and the browser unions the two lists, so uninstalling
@@ -64,11 +93,13 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
64
93
  path: ".intentic/automations.json",
65
94
  invalidates: [],
66
95
  why: "Declared by the intentic.automations extension's contributes.files — `automations` is its query key, not core's.",
96
+ portability: "carry",
67
97
  },
68
98
  {
69
99
  path: ".intentic/approvals/",
70
100
  invalidates: [],
71
101
  why: "Declared by the intentic.automations extension's contributes.files — `automation-approvals` is its query key, not core's.",
102
+ portability: "carry",
72
103
  },
73
104
 
74
105
  /* ---- reached by no query, for reasons that are not oversights ----
@@ -81,16 +112,24 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
81
112
  path: ".intentic/webchat-installs.json",
82
113
  invalidates: [],
83
114
  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.",
115
+ portability: "carry",
84
116
  },
85
117
  {
86
118
  path: ".intentic/thread-sessions.json",
87
119
  invalidates: [],
88
120
  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.",
121
+ portability: "carry",
89
122
  },
123
+ /* Values are a primitive union an extension chooses the meaning of, and "an API key for the service I talk
124
+ * to" is squarely within it — so this is classed by what it CAN hold, not by what any particular extension
125
+ * happens to put there. The alternative reads the wrong way round: a bundle that leaked one extension's
126
+ * token would have been correct about all the others. */
90
127
  {
91
128
  path: ".intentic/extension-settings.json",
92
129
  invalidates: [],
93
130
  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.",
131
+ portability: "secret",
132
+ note: "Re-enter each extension's settings on the Extensions tab.",
94
133
  },
95
134
  /* Unlike the settings file above it, the on/off switch IS observed by a query — the Extensions tab's list,
96
135
  * which carries each row's switch position — so a flip made elsewhere (another member, the agent writing the
@@ -99,11 +138,14 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
99
138
  {
100
139
  path: ".intentic/extension-enablement.json",
101
140
  invalidates: ["extensions"],
141
+ portability: "carry",
102
142
  },
103
143
  {
104
144
  path: ".intentic/members.json",
105
145
  invalidates: [],
106
146
  why: "Not this view's source at all: SandboxAccess renders the PLATFORM's invite records (apiClient.invite.list), and this file is the daemon's ENFORCED copy — written first so a grant the enforcer never got is never recorded, then never read back. A change here means the two disagreed, which the write order makes fail-closed rather than stale.",
147
+ portability: "identity",
148
+ note: "Re-invite collaborators from the Access tab — a grant is the platform's record, and the target enforces its own copy.",
107
149
  },
108
150
 
109
151
  // ---- daemon-owned, nothing derives from watching them ----
@@ -116,40 +158,76 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
116
158
  * is +119 watched directories against ~593 today (a fifth more), with 314 continuously-rewritten transcripts
117
159
  * inside the newly-watched set, to make ONE memory directory live. Notes change at agent-turn cadence, so the
118
160
  * poll costs a request a minute and the alternative costs a permanent 20% on the watcher. */
161
+ /* THE STORE THAT IS TWO THINGS, hence two entries — this is the case stateFileFor's longest-match rule
162
+ * exists for. `.intentic/claude/` is where linkClaudeState parks the Claude CLI's per-conversation state,
163
+ * and (when AGENT_AUTH_DIR is unset, which is the production layout) it is ALSO the provider credential
164
+ * root. The subtree under `projects/` is the half a bundle exists to carry — the agent's memory notes and
165
+ * every conversation's transcript — while its siblings hold the OAuth that must not leave the sandbox. */
119
166
  {
120
167
  path: ".intentic/claude/",
121
168
  invalidates: [],
122
169
  why: "Agent session transcripts — see the note above on why the memory notes under it stay polled.",
170
+ portability: "secret",
171
+ note: "Sign the agent's AI accounts in again on the Agent tab.",
172
+ },
173
+ {
174
+ path: ".intentic/claude/projects/",
175
+ invalidates: [],
176
+ why: "Same store as the entry above; split from it for portability, not for invalidation.",
177
+ portability: "carry",
123
178
  },
124
179
  {
125
180
  path: ".intentic/ci.json",
126
181
  invalidates: [],
127
182
  why: "Webhook secret + conclusion memory; the Pipelines view reads it through /ci/runs, not off disk.",
183
+ portability: "secret",
184
+ note: "Re-add the CI webhook on the Pipelines view — its secret is per-sandbox.",
128
185
  },
129
186
  {
130
187
  path: ".intentic/komodo.json",
131
188
  invalidates: [],
132
189
  why: "Per-connection 'when the owner last looked at Deployments'; the view reads it through /komodo/{capability}/overview, not off disk — and it is written BY that view being opened, so invalidating on it would refetch the board in answer to the browser's own click.",
190
+ portability: "carry",
191
+ },
192
+ {
193
+ path: ".intentic/bridge-tokens.json",
194
+ invalidates: [],
195
+ why: "Hashed ACP bridge tokens, listed on demand by the owner.",
196
+ portability: "identity",
197
+ note: "Mint fresh ACP bridge tokens — the old ones authenticate against the source sandbox.",
133
198
  },
134
- { path: ".intentic/bridge-tokens.json", invalidates: [], why: "Hashed ACP bridge tokens, listed on demand by the owner." },
135
199
  {
136
200
  path: ".intentic/owner.json",
137
201
  invalidates: [],
138
202
  why: "Bound once on first use; a change here means the sandbox was re-owned, which re-authenticates anyway.",
203
+ portability: "identity",
204
+ },
205
+ {
206
+ path: ".intentic/workspace.json",
207
+ invalidates: [],
208
+ why: "The workspace identity, read from the /events hello frame rather than as a file.",
209
+ portability: "identity",
139
210
  },
140
- { path: ".intentic/workspace.json", invalidates: [], why: "The workspace identity, read from the /events hello frame rather than as a file." },
141
- { path: ".intentic/templates.json", invalidates: [], why: "Scaffold templates, read when the scaffold dialog opens." },
211
+ { path: ".intentic/templates.json", invalidates: [], why: "Scaffold templates, read when the scaffold dialog opens.", portability: "carry" },
212
+ /* Classed `derived` for size rather than for safety, and it is the one entry where that costs the owner
213
+ * something real: the profiles ARE logged-in sessions. They are also gigabytes of a store Chromium rewrites
214
+ * constantly and versions against its own build, so carrying them ships bulk that the target's Chromium may
215
+ * refuse anyway. The note is what keeps the loss visible instead of silent. */
142
216
  {
143
217
  path: ".intentic/browser/",
144
218
  invalidates: [],
145
219
  why: "Browser-login profiles: Chromium rewrites these constantly. Descent-ignored by the watcher outright.",
220
+ portability: "derived",
221
+ note: "Log the agent's browser back into any site it needs — profiles do not travel.",
146
222
  },
147
223
  {
148
224
  path: ".intentic/extensions/",
149
225
  invalidates: [],
150
226
  why: "Extension checkouts — whole git clones. The `extensions` query is driven by the capability manifest above, not by their contents.",
227
+ portability: "derived",
228
+ note: "Extensions re-clone from the capability manifest on the target's next reconcile.",
151
229
  },
152
- { path: ".intentic/plugins/", invalidates: [], why: "Agent plugin dirs, read by the SDK's loader each turn." },
230
+ { path: ".intentic/plugins/", invalidates: [], why: "Agent plugin dirs, read by the SDK's loader each turn.", portability: "carry" },
153
231
  ];
154
232
 
155
233
  /* The query keys a batch of changed paths makes stale, deduped and stable. The browser's `/events` handler calls