@intentic/sandbox-contract 1.309.0 → 1.311.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.
Files changed (200) hide show
  1. package/README.md +38 -159
  2. package/dist/contracts/accounts.contract.d.ts +3 -0
  3. package/dist/contracts/accounts.contract.d.ts.map +1 -1
  4. package/dist/contracts/agent.contract.d.ts +15 -0
  5. package/dist/contracts/agent.contract.d.ts.map +1 -1
  6. package/dist/contracts/agents.contract.d.ts +596 -1
  7. package/dist/contracts/agents.contract.d.ts.map +1 -1
  8. package/dist/contracts/agents.contract.js +13 -2
  9. package/dist/contracts/agents.contract.js.map +1 -1
  10. package/dist/contracts/capabilities.contract.d.ts +2 -0
  11. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  12. package/dist/contracts/device.contract.d.ts +1 -0
  13. package/dist/contracts/device.contract.d.ts.map +1 -1
  14. package/dist/contracts/runner.contract.d.ts +45 -0
  15. package/dist/contracts/runner.contract.d.ts.map +1 -1
  16. package/dist/contracts/settings.contract.d.ts +11 -15
  17. package/dist/contracts/settings.contract.d.ts.map +1 -1
  18. package/dist/contracts/system.contract.d.ts +59 -0
  19. package/dist/contracts/system.contract.d.ts.map +1 -1
  20. package/dist/events/agent-events.d.ts +20 -0
  21. package/dist/events/agent-events.d.ts.map +1 -1
  22. package/dist/events/agent-events.js +6 -1
  23. package/dist/events/agent-events.js.map +1 -1
  24. package/dist/events/system-events.d.ts +48 -0
  25. package/dist/events/system-events.d.ts.map +1 -1
  26. package/dist/events/transcript.d.ts +5 -0
  27. package/dist/events/transcript.d.ts.map +1 -1
  28. package/dist/events/transcript.js +9 -1
  29. package/dist/events/transcript.js.map +1 -1
  30. package/dist/front/generated/wire.d.ts +74 -0
  31. package/dist/front/generated/wire.d.ts.map +1 -0
  32. package/dist/front/generated/wire.js +2 -0
  33. package/dist/front/generated/wire.js.map +1 -0
  34. package/dist/index.d.ts +717 -74
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/policy/command-run.d.ts +1 -1
  39. package/dist/policy/command-run.d.ts.map +1 -1
  40. package/dist/policy/command-run.js +7 -4
  41. package/dist/policy/command-run.js.map +1 -1
  42. package/dist/protocol/ingress-protocol.d.ts.map +1 -1
  43. package/dist/protocol/ingress-protocol.js +15 -0
  44. package/dist/protocol/ingress-protocol.js.map +1 -1
  45. package/dist/protocol/upgrade-relay.d.ts +4 -0
  46. package/dist/protocol/upgrade-relay.d.ts.map +1 -0
  47. package/dist/protocol/upgrade-relay.js +25 -0
  48. package/dist/protocol/upgrade-relay.js.map +1 -0
  49. package/dist/schemas/agents.d.ts +72 -3
  50. package/dist/schemas/agents.d.ts.map +1 -1
  51. package/dist/schemas/agents.js +10 -7
  52. package/dist/schemas/agents.js.map +1 -1
  53. package/dist/schemas/automations.d.ts +24 -0
  54. package/dist/schemas/automations.d.ts.map +1 -1
  55. package/dist/schemas/capabilities.d.ts +3 -0
  56. package/dist/schemas/capabilities.d.ts.map +1 -1
  57. package/dist/schemas/capabilities.js +4 -0
  58. package/dist/schemas/capabilities.js.map +1 -1
  59. package/dist/schemas/ci.d.ts.map +1 -1
  60. package/dist/schemas/ci.js.map +1 -1
  61. package/dist/schemas/devices.d.ts +32 -6
  62. package/dist/schemas/devices.d.ts.map +1 -1
  63. package/dist/schemas/devices.js +8 -6
  64. package/dist/schemas/devices.js.map +1 -1
  65. package/dist/schemas/keep-warm.d.ts +68 -0
  66. package/dist/schemas/keep-warm.d.ts.map +1 -0
  67. package/dist/schemas/keep-warm.js +74 -0
  68. package/dist/schemas/keep-warm.js.map +1 -0
  69. package/dist/schemas/metrics.d.ts +24 -25
  70. package/dist/schemas/metrics.d.ts.map +1 -1
  71. package/dist/schemas/metrics.js +6 -31
  72. package/dist/schemas/metrics.js.map +1 -1
  73. package/dist/schemas/providers/provider-oauth.d.ts +3 -0
  74. package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
  75. package/dist/schemas/providers/provider-oauth.js +4 -0
  76. package/dist/schemas/providers/provider-oauth.js.map +1 -1
  77. package/dist/schemas/providers/usage.d.ts +6 -0
  78. package/dist/schemas/providers/usage.d.ts.map +1 -1
  79. package/dist/schemas/providers/usage.js +4 -0
  80. package/dist/schemas/providers/usage.js.map +1 -1
  81. package/dist/schemas/settings.d.ts +13 -29
  82. package/dist/schemas/settings.d.ts.map +1 -1
  83. package/dist/schemas/settings.js +48 -11
  84. package/dist/schemas/settings.js.map +1 -1
  85. package/dist/state/definition.d.ts +24 -20
  86. package/dist/state/definition.d.ts.map +1 -1
  87. package/dist/state/fix-stance.d.ts.map +1 -1
  88. package/dist/state/fix-stance.js +1 -0
  89. package/dist/state/fix-stance.js.map +1 -1
  90. package/dist/state/runtime-state.d.ts +3 -0
  91. package/dist/state/runtime-state.d.ts.map +1 -1
  92. package/dist/state/runtime-state.js +1 -0
  93. package/dist/state/runtime-state.js.map +1 -1
  94. package/dist/state/workspace-state.d.ts +0 -7
  95. package/dist/state/workspace-state.d.ts.map +1 -1
  96. package/dist/state/workspace-state.js +0 -8
  97. package/dist/state/workspace-state.js.map +1 -1
  98. package/dist/text/transcript-fold.d.ts +2 -0
  99. package/dist/text/transcript-fold.d.ts.map +1 -1
  100. package/dist/text/transcript-fold.js +20 -7
  101. package/dist/text/transcript-fold.js.map +1 -1
  102. package/dist/time/zone.d.ts +1 -0
  103. package/dist/time/zone.d.ts.map +1 -1
  104. package/dist/time/zone.js +5 -0
  105. package/dist/time/zone.js.map +1 -1
  106. package/package.json +27 -5
  107. package/src/chores/chores.test.ts +0 -1
  108. package/src/chores/digest.test.ts +0 -1
  109. package/src/chores/probes.test.ts +0 -1
  110. package/src/chores/stack.test.ts +0 -1
  111. package/src/chores/verdict.test.ts +0 -1
  112. package/src/contracts/agents.contract.ts +14 -2
  113. package/src/events/agent-events.ts +9 -1
  114. package/src/events/agent-words.test.ts +0 -1
  115. package/src/events/resume.test.ts +0 -1
  116. package/src/events/transcript.ts +14 -3
  117. package/src/events/watch-wake.test.ts +0 -1
  118. package/src/front/generated/wire.ts +73 -0
  119. package/src/ids/conversation-ids.test.ts +1 -2
  120. package/src/ids/hostnames.fixture.json +20 -0
  121. package/src/ids/hostnames.test.ts +20 -1
  122. package/src/ids/share-paths.test.ts +0 -1
  123. package/src/ids/tunnel-ids.test.ts +0 -1
  124. package/src/index.ts +1 -3
  125. package/src/models/agent-catalog.test.ts +0 -1
  126. package/src/models/capability-ledger.test.ts +0 -1
  127. package/src/models/model-label.test.ts +0 -1
  128. package/src/models/model-offer.test.ts +0 -1
  129. package/src/models/model-order.test.ts +0 -1
  130. package/src/models/model-pins.test.ts +0 -1
  131. package/src/models/model-roles.test.ts +0 -1
  132. package/src/models/plan-pools.test.ts +0 -1
  133. package/src/models/provider-specs.test.ts +0 -1
  134. package/src/policy/batch-runs.test.ts +0 -1
  135. package/src/policy/command-classes.test.ts +0 -1
  136. package/src/policy/command-run.test.ts +35 -0
  137. package/src/policy/command-run.ts +10 -5
  138. package/src/policy/credential-material.test.ts +0 -1
  139. package/src/policy/fence-paths.test.ts +0 -1
  140. package/src/policy/overlay-lint.test.ts +0 -1
  141. package/src/policy/owner-ticket.test.ts +0 -1
  142. package/src/policy/persona-home.test.ts +0 -1
  143. package/src/policy/reserved-servers.test.ts +0 -1
  144. package/src/policy/safety-policy.test.ts +0 -1
  145. package/src/policy/search-globs.test.ts +0 -1
  146. package/src/protocol/container-requirements.test.ts +0 -1
  147. package/src/protocol/ingress-contract.test.ts +0 -1
  148. package/src/protocol/ingress-protocol.test.ts +45 -2
  149. package/src/protocol/ingress-protocol.ts +25 -4
  150. package/src/protocol/peer-dial.test.ts +1 -2
  151. package/src/protocol/peer-mcp-server.test.ts +0 -1
  152. package/src/protocol/routes.test.ts +6 -3
  153. package/src/protocol/tunnel-heartbeat.test.ts +2 -3
  154. package/src/protocol/upgrade-relay.ts +28 -0
  155. package/src/schemas/agent.test.ts +0 -1
  156. package/src/schemas/agents.ts +14 -7
  157. package/src/schemas/areas.test.ts +1 -2
  158. package/src/schemas/capabilities.ts +6 -0
  159. package/src/schemas/ci.ts +3 -9
  160. package/src/schemas/context-trim.test.ts +0 -1
  161. package/src/schemas/dev-rebuild-log.test.ts +0 -1
  162. package/src/schemas/devices.ts +16 -9
  163. package/src/schemas/git/git.test.ts +0 -1
  164. package/src/schemas/keep-warm.test.ts +68 -0
  165. package/src/schemas/keep-warm.ts +124 -0
  166. package/src/schemas/metrics.ts +33 -64
  167. package/src/schemas/providers/provider-oauth.ts +6 -0
  168. package/src/schemas/providers/usage.ts +7 -0
  169. package/src/schemas/settings.test.ts +41 -7
  170. package/src/schemas/settings.ts +78 -44
  171. package/src/schemas/version-seam.test.ts +0 -1
  172. package/src/state/contract-lock.test.ts +0 -1
  173. package/src/state/fix-attempt-plan.test.ts +0 -1
  174. package/src/state/fix-stance.test.ts +14 -1
  175. package/src/state/fix-stance.ts +2 -0
  176. package/src/state/landed-commit.test.ts +0 -1
  177. package/src/state/runtime-state.test.ts +0 -1
  178. package/src/state/runtime-state.ts +4 -0
  179. package/src/state/versions.test.ts +0 -1
  180. package/src/state/workspace-state.test.ts +0 -3
  181. package/src/state/workspace-state.ts +0 -14
  182. package/src/text/archives.test.ts +0 -1
  183. package/src/text/documents.test.ts +0 -1
  184. package/src/text/embed.test.ts +2 -3
  185. package/src/text/emoji.test.ts +0 -1
  186. package/src/text/mentions.test.ts +0 -1
  187. package/src/text/path-refs.test.ts +0 -1
  188. package/src/text/title.test.ts +0 -1
  189. package/src/text/transcript-fold.test.ts +32 -4
  190. package/src/text/transcript-fold.ts +39 -9
  191. package/src/text/whisper.test.ts +0 -1
  192. package/src/text/workflow-faults.test.ts +0 -1
  193. package/src/time/zone.test.ts +0 -1
  194. package/src/time/zone.ts +7 -0
  195. package/src/webext/index.ts +1 -1
  196. package/dist/contracts/prepush.contract.d.ts +0 -32
  197. package/dist/contracts/prepush.contract.d.ts.map +0 -1
  198. package/dist/contracts/prepush.contract.js +0 -41
  199. package/dist/contracts/prepush.contract.js.map +0 -1
  200. package/src/contracts/prepush.contract.ts +0 -58
@@ -1,4 +1,3 @@
1
- import { describe, it, expect } from "bun:test";
2
1
  import { SHARE_ID, shareId, shareStem } from "./share-paths.js";
3
2
 
4
3
  /* The id a shared conversation is filed under is two things at once: the readable half of a link somebody pastes. */
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { hostSshIdFromToken, PORT_SLOT_COUNT, portSlotsFromToken, sandboxIdFromToken } from "./tunnel-ids.js";
3
2
 
4
3
  const TOKEN = "connect-token-one";
package/src/index.ts CHANGED
@@ -28,7 +28,6 @@ import { loopsContract } from "./contracts/loops.contract.js";
28
28
  import { panelsContract } from "./contracts/panels.contract.js";
29
29
  import { portsContract } from "./contracts/ports.contract.js";
30
30
  import { publicContract } from "./contracts/public.contract.js";
31
- import { prepushContract } from "./contracts/prepush.contract.js";
32
31
  import { netdiskContract } from "./contracts/netdisk.contract.js";
33
32
  import { providersContract } from "./contracts/providers.contract.js";
34
33
  import { pushContract } from "./contracts/push.contract.js";
@@ -89,7 +88,6 @@ export { loopsContract } from "./contracts/loops.contract.js";
89
88
  export { panelsContract } from "./contracts/panels.contract.js";
90
89
  export { portsContract } from "./contracts/ports.contract.js";
91
90
  export { publicContract } from "./contracts/public.contract.js";
92
- export { prepushContract } from "./contracts/prepush.contract.js";
93
91
  export { providersContract, type RunnableProviders, RunnableProvidersSchema } from "./contracts/providers.contract.js";
94
92
  export { pushContract } from "./contracts/push.contract.js";
95
93
  export { safetyContract } from "./contracts/safety.contract.js";
@@ -218,6 +216,7 @@ export * from "./schemas/system-prompt.js";
218
216
  export * from "./schemas/system.js";
219
217
  export * from "./schemas/terminal.js";
220
218
  export * from "./schemas/turn-break.js";
219
+ export * from "./schemas/keep-warm.js";
221
220
  export * from "./schemas/providers/usage.js";
222
221
  export * from "./schemas/vpn.js";
223
222
  export * from "./schemas/webext.js";
@@ -272,7 +271,6 @@ export const sandboxContract = {
272
271
  panels: panelsContract,
273
272
  ports: portsContract,
274
273
  public: publicContract,
275
- prepush: prepushContract,
276
274
  providers: providersContract,
277
275
  push: pushContract,
278
276
  secrets: secretsContract,
@@ -1,4 +1,3 @@
1
- import { describe, it, test, expect } from "bun:test";
2
1
  import {
3
2
  accessFor,
4
3
  capabilitiesOf,
@@ -1,4 +1,3 @@
1
- import { describe, it, expect } from "bun:test";
2
1
  import { capabilitiesOf, limitationsOf } from "./agent-catalog.js";
3
2
  import type { AgentCapabilities } from "./agent-runtimes.js";
4
3
 
@@ -1,4 +1,3 @@
1
- import { expect, test } from "bun:test";
2
1
  import { humanizeModelId } from "./model-label.js";
3
2
 
4
3
  // One humanizer for every id no catalog names, on both sides of the wire.
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { type ModelOffer, offerLines, parseModelPick, renewsInWords } from "./model-offer.js";
3
2
 
4
3
  /* THE OFFER the Auto judge chooses from, and the reading of what it replies. Pure both ways: no catalog, no account. */
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { compareModelIds, compareUnrankedModelIds, familyOf, namesThinking, releaseOf, tierRankOf } from "./model-order.js";
3
2
 
4
3
  // The order every provider's catalog is served and browsed in; only Anthropic's catalog arrives ranked, the rest hand
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { type ModelSource, modelPinKey, parsePinned, readyChain } from "./model-pins.js";
3
2
  import type { ModelPin } from "../schemas/agent.js";
4
3
 
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { MODEL_ROLE_BLOCKS, MODEL_ROLES, type ModelRoleSpec } from "./model-roles.js";
3
2
 
4
3
  // The catalog draws the settings page directly, so its properties (every role in one block) must hold of the table
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { bindingWindow, gatesModel, gatingWindows, scopedWindow, windowLive, windowPeriod } from "./plan-pools.js";
3
2
  import type { AccountUsage, UsageWindow } from "../schemas/providers/plan-limits.js";
4
3
 
@@ -1,5 +1,4 @@
1
1
  import { PROVIDER_BRAND_PATHS } from "@intentic/constants";
2
- import { describe, test, expect } from "bun:test";
3
2
  import { accessFor, capabilitiesOf, harnessChoosable, PROVIDER_ACCESS, PROVIDER_VENDOR, PROVIDERS, providerLabel } from "./agent-catalog.js";
4
3
  import {
5
4
  ACCESS_COST,
@@ -1,5 +1,4 @@
1
1
  import { STATE_DIR } from "@intentic/constants";
2
- import { describe, it, expect } from "bun:test";
3
2
 
4
3
  // Stands in for any role: what's tested is that the role reaches the turn, not which one it is.
5
4
  const ROLE = `maintenance-chore` as const;
@@ -1,4 +1,3 @@
1
- import { describe, test, expect } from "bun:test";
2
1
  import { classifyCommand, type CommandContext, type CommandSpan, matchCommand } from "./command-classes.js";
3
2
 
4
3
  // Every call states a locus (now required); these default to `device`, the wider reading, so an assertion made here
@@ -0,0 +1,35 @@
1
+ import type { CommandRun } from "../schemas/ci.js";
2
+ import { followCommandRun } from "./command-run.js";
3
+
4
+ const RUNNING: CommandRun = { status: "running", command: "pnpm test", output: "" };
5
+ const PASSED: CommandRun = { status: "passed", command: "pnpm test", output: "ok" };
6
+
7
+ test("a poll that fails is reported and retried, and the run is still followed to its verdict", async () => {
8
+ const answers: Array<CommandRun | Error> = [new Error("daemon restarting"), RUNNING, PASSED];
9
+ const errors: unknown[] = [];
10
+ const settled = await followCommandRun(
11
+ async () => {
12
+ const next = answers.shift();
13
+ if (next instanceof Error) {
14
+ throw next;
15
+ }
16
+ return next as CommandRun;
17
+ },
18
+ { intervalMs: 1, onError: (cause) => errors.push(cause) },
19
+ );
20
+ expect(settled).toEqual(PASSED);
21
+ expect(errors).toEqual([new Error("daemon restarting")]);
22
+ });
23
+
24
+ test("a throw from onState is the caller's bug and reaches the caller, not a poll failure retried forever", async () => {
25
+ const errors: unknown[] = [];
26
+ const following = followCommandRun(async () => RUNNING, {
27
+ intervalMs: 1,
28
+ onState: () => {
29
+ throw new TypeError("state.session is undefined");
30
+ },
31
+ onError: (cause) => errors.push(cause),
32
+ });
33
+ await expect(following).rejects.toThrow("state.session is undefined");
34
+ expect(errors).toEqual([]);
35
+ });
@@ -10,8 +10,9 @@ export interface FollowRunOptions<R extends CommandRun> {
10
10
  readonly signal?: AbortSignal | undefined;
11
11
  // Every state read, settled or not, for a surface that draws progress.
12
12
  readonly onState?: ((run: R) => void) | undefined;
13
- // A poll that failed, not a run that failed: the run is still going, and following continues.
14
- readonly onError?: ((cause: unknown) => void) | undefined;
13
+ // A poll that failed, not a run that failed: the run is still going, and following continues. Required, since a
14
+ // loop that retries a failing read forever with nobody told looks exactly like a run that is still going.
15
+ readonly onError: (cause: unknown) => void;
15
16
  }
16
17
 
17
18
  // Follows a run to its verdict: reads state until it is no longer running. The one shared loop, so a dropped poll isn't
@@ -22,14 +23,18 @@ export const followCommandRun = async <R extends CommandRun>(read: () => Promise
22
23
  if (signal?.aborted === true) {
23
24
  return undefined;
24
25
  }
26
+ // Only the read is a poll that may fail and be retried; a throw from `onState` is the caller's own bug.
27
+ let run: R | undefined;
25
28
  try {
26
- const run = await read();
29
+ run = await read();
30
+ } catch (cause) {
31
+ onError(cause);
32
+ }
33
+ if (run !== undefined) {
27
34
  onState?.(run);
28
35
  if (run.status !== "running") {
29
36
  return run;
30
37
  }
31
- } catch (cause) {
32
- onError?.(cause);
33
38
  }
34
39
  await sleep(intervalMs, { signal });
35
40
  }
@@ -1,4 +1,3 @@
1
- import { describe, test, expect } from "bun:test";
2
1
  import { holdsCredentialMaterial, maskCredentialMaterial } from "./credential-material.js";
3
2
 
4
3
  // The two directions aren't symmetric: a miss just raises an extra card, a wrong clear un-gates a real credential read.
@@ -1,4 +1,3 @@
1
- import { describe, test, expect } from "bun:test";
2
1
  import { fenceAllows, fenceCovers, fenceIntersection, fenceReaches, foldPath, pathInsideFolder } from "./fence-paths.js";
3
2
 
4
3
  describe("foldPath", () => {
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { hasOfficialBase, invalidExtensionFragment, isOfficialSandboxImage, lintOverlay, overlayBase, rewriteOverlayBase } from "./overlay-lint.js";
3
2
 
4
3
  test("accepts a RUN/ENV-only fragment, comments and blank lines included", () => {
@@ -1,5 +1,4 @@
1
1
  import { generateKeyPairSync } from "node:crypto";
2
- import { describe, it, expect } from "bun:test";
3
2
  import { mintReachabilityGrant, verifyReachabilityGrant } from "../protocol/ingress-contract.js";
4
3
  import { isOwnerTicket, mintOwnerTicket, OWNER_TICKET_TTL_MS, publicKeyPemOf, verifyOwnerTicket } from "./owner-ticket.js";
5
4
 
@@ -1,4 +1,3 @@
1
- import { describe, test, expect } from "bun:test";
2
1
  import { fenceHoldsPersona, personaHome } from "./persona-home.js";
3
2
 
4
3
  // Where a card lives, and therefore who may wear it. This is the whole of the rule the Access tab draws and the
@@ -1,4 +1,3 @@
1
- import { describe, test, expect } from "bun:test";
2
1
  import {
3
2
  collidesWithReservedServer,
4
3
  CONTROL_MCP_SERVERS,
@@ -1,4 +1,3 @@
1
- import { describe, test, expect } from "bun:test";
2
1
  import { COMMAND_CLASS_LABELS, COMMAND_CLASS_PATTERNS } from "./command-classes.js";
3
2
  import { COMMAND_RULE_CATALOG, DEFAULT_SAFETY_POLICY, hardRuleClasses } from "./safety-policy.js";
4
3
  import { CommandClassSchema, CommandLocusSchema } from "../schemas/agent.js";
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { includeGlobs } from "./search-globs.js";
3
2
 
4
3
  test("an empty field scopes nothing", () => {
@@ -1,4 +1,3 @@
1
- import { describe, it, expect } from "bun:test";
2
1
  import { CONTAINER_REQUIREMENTS, containerDrift } from "./container-requirements.js";
3
2
 
4
3
  // Healthy baseline: every case below removes one piece of it to produce a gap.
@@ -1,5 +1,4 @@
1
1
  import { generateKeyPairSync } from "node:crypto";
2
- import { describe, it, expect } from "bun:test";
3
2
  import { hostOwnerId, mintReachabilityGrant, verifyReachabilityGrant } from "./ingress-contract.js";
4
3
 
5
4
  const pemPair = (): { privateKey: string; publicKey: string } => {
@@ -2,7 +2,6 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { createServer, type IncomingMessage, request as h1Request, type Server, type ServerResponse } from "node:http";
3
3
  import type { AddressInfo } from "node:net";
4
4
  import { type Duplex, duplexPair } from "node:stream";
5
- import { test, expect, beforeAll, afterAll } from "bun:test";
6
5
  import { waitFor } from "@intentic/testing/bun";
7
6
  import { INITIAL_WINDOW_SIZE, openIngressSession, serveIngressSession } from "./ingress-protocol.js";
8
7
 
@@ -149,9 +148,25 @@ const target = createServer((request: IncomingMessage, response: ServerResponse)
149
148
 
150
149
  const cancelled = gate<string>();
151
150
 
151
+ // `/late` holds its handshake until released, so the session can die while the loopback upgrade is still pending.
152
+ const lateArrived = gate();
153
+ const lateReleased = gate();
154
+ let lateTornDown = false;
155
+
152
156
  // Hand-written HTTP/1.1 upgrade carrying the 101 head plus the raw bytes after it. `/refuse` answers instead of
153
157
  // upgrading.
154
158
  target.on("upgrade", (request: IncomingMessage, socket: Duplex, head: Buffer) => {
159
+ if ((request.url ?? "") === "/late") {
160
+ socket.on("error", () => {});
161
+ socket.on("close", () => (lateTornDown = true));
162
+ lateArrived.open();
163
+ void lateReleased.opened.then(() => {
164
+ if (!socket.destroyed) {
165
+ socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n");
166
+ }
167
+ });
168
+ return;
169
+ }
155
170
  if ((request.url ?? "") === "/refuse") {
156
171
  socket.end("HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 7\r\n\r\nno dice");
157
172
  return;
@@ -180,7 +195,7 @@ target.on("upgrade", (request: IncomingMessage, socket: Duplex, head: Buffer) =>
180
195
  // One session; every request is routed through it.
181
196
  const front = async (
182
197
  targetPort: number,
183
- ): Promise<{ readonly server: Server; readonly poison: (bytes: Buffer) => void; readonly close: () => void }> => {
198
+ ): Promise<{ readonly server: Server; readonly poison: (bytes: Buffer) => void; readonly sever: () => void; readonly close: () => void }> => {
184
199
  const [edgeSide, daemonSide] = duplexPair();
185
200
  const daemon = await serveIngressSession(daemonSide, { targetPort });
186
201
  const session = await openIngressSession(edgeSide);
@@ -203,6 +218,8 @@ const front = async (
203
218
  server,
204
219
  // Writes garbage onto the wire the edge reads, simulating a wedged or corrupted peer.
205
220
  poison: (bytes: Buffer) => void daemonSide.write(bytes),
221
+ // Cuts the tunnel under the daemon, which takes its h2 session and every stream on it down.
222
+ sever: () => void daemonSide.destroy(),
206
223
  close: () => {
207
224
  session.close();
208
225
  daemon.close();
@@ -440,6 +457,32 @@ test("a browser that gives up cancels the stream all the way to the target", asy
440
457
  await expect(cancelled.opened).resolves.toBe("aborted");
441
458
  });
442
459
 
460
+ // The handshake lands after its stream is gone: answering it throws ERR_HTTP2_INVALID_STREAM from an event callback.
461
+ test("a session that dies mid-handshake tears the loopback upgrade down instead of answering into a dead stream", async () => {
462
+ const own = await front((target.address() as AddressInfo).port);
463
+ const ownPort = await listen(own.server);
464
+ const request = h1Request({
465
+ host: "127.0.0.1",
466
+ port: ownPort,
467
+ path: "/late",
468
+ headers: { host: HOST, connection: "Upgrade", upgrade: "websocket", "sec-websocket-key": "x" },
469
+ agent: false,
470
+ });
471
+ request.on("error", () => {});
472
+ request.end();
473
+ await lateArrived.opened;
474
+
475
+ own.sever();
476
+ await new Promise((resolve) => setTimeout(resolve, 250));
477
+ lateReleased.open();
478
+
479
+ await waitFor(() => expect(lateTornDown).toBe(true));
480
+ await new Promise((resolve) => setTimeout(resolve, 50));
481
+ expect(uncaught).toStrictEqual([]);
482
+ request.destroy();
483
+ own.close();
484
+ });
485
+
443
486
  test("a tunnel whose target is not listening fails the exchange rather than answering for it", async () => {
444
487
  const dead = createServer();
445
488
  const deadPort = await listen(dead);
@@ -10,9 +10,9 @@ import {
10
10
  import { type AddressInfo, createServer as createNetServer, connect as netConnect, type Socket } from "node:net";
11
11
  import { Duplex } from "node:stream";
12
12
 
13
- // Both ends of the ingress tunnel's h2 session, over any node Duplex, in one file: a wire format with two owners
14
- // drifts. h2 gives multiplexing and backpressure for free; routing is per h2 stream, since one edge connection can
15
- // interleave several sandboxes' hosts.
13
+ // Both halves of the ingress tunnel's h2 session over any node Duplex, in one file so the wire has one owner: the edge's,
14
+ // and a daemon half its suites dial (a sandbox runs the Rust one, _sandbox/front tunnel.rs, held to this by its test).
15
+ // Routing is per h2 stream, since one edge connection can interleave several sandboxes' hosts.
16
16
 
17
17
  // Header hygiene
18
18
 
@@ -308,7 +308,7 @@ export const openIngressSession = async (duplex: Duplex): Promise<IngressSession
308
308
  };
309
309
  };
310
310
 
311
- // The daemon half: an h2 server over the tunnel, onto the loopback listener
311
+ // The daemon half the edge's suites dial: an h2 server over the tunnel, onto a local listener
312
312
 
313
313
  export interface ServeIngressSessionOptions {
314
314
  // The daemon's own listener; every stream lands there as a plain HTTP/1.1 request or upgrade.
@@ -340,6 +340,10 @@ export const serveIngressSession = async (duplex: Duplex, options: ServeIngressS
340
340
  server.on("error", () => duplex.destroy());
341
341
  server.on("session", (session) => session.setLocalWindowSize(CONNECTION_WINDOW_SIZE));
342
342
 
343
+ // The loopback's answer can outlive its stream when the session dies, and respond/write on a dead stream throws
344
+ // ERR_HTTP2_INVALID_STREAM from an event callback, where nothing catches it.
345
+ const gone = (stream: ServerHttp2Stream): boolean => stream.destroyed || stream.closed;
346
+
343
347
  const forwardToLoopback = (stream: ServerHttp2Stream, headers: IncomingHttpHeaders): void => {
344
348
  const local = h1Request({
345
349
  host: LOOPBACK,
@@ -353,6 +357,10 @@ export const serveIngressSession = async (duplex: Duplex, options: ServeIngressS
353
357
  });
354
358
  stream.pipe(local);
355
359
  local.on("response", (response) => {
360
+ if (gone(stream)) {
361
+ response.destroy();
362
+ return;
363
+ }
356
364
  stream.respond({ [constants.HTTP2_HEADER_STATUS]: response.statusCode ?? 502, ...endToEnd(response.headers) });
357
365
  response.pipe(stream);
358
366
  });
@@ -364,6 +372,9 @@ export const serveIngressSession = async (duplex: Duplex, options: ServeIngressS
364
372
  // means this — on a reset, node fires `aborted` before `finish` and `close`, so a
365
373
  // `writableEnded`/`writableFinished` guard there would miss it.
366
374
  stream.on("aborted", () => local.destroy());
375
+ // `aborted` fires only while the stream's writable side is open; `close` covers every other way it dies, and a
376
+ // request whose answer already completed ignores the destroy.
377
+ stream.on("close", () => local.destroy());
367
378
  };
368
379
 
369
380
  const spliceUpgrade = (stream: ServerHttp2Stream, headers: IncomingHttpHeaders): void => {
@@ -372,6 +383,10 @@ export const serveIngressSession = async (duplex: Duplex, options: ServeIngressS
372
383
  // pool the request path above uses.
373
384
  const local = h1Request({ host: LOOPBACK, port: options.targetPort, method: head.method, path: head.path, headers: head.headers, agent: false });
374
385
  local.on("upgrade", (response, socket, first) => {
386
+ if (gone(stream)) {
387
+ socket.destroy();
388
+ return;
389
+ }
375
390
  stream.respond({ [constants.HTTP2_HEADER_STATUS]: constants.HTTP_STATUS_OK });
376
391
  // Response head written verbatim (see serializeHead), then any bytes already sent past it, then the pipe.
377
392
  stream.write(serializeHead(response, NOTHING_DROPPED, []));
@@ -383,11 +398,17 @@ export const serveIngressSession = async (duplex: Duplex, options: ServeIngressS
383
398
  // The local server declined to upgrade; its answer goes to the browser with framing that matches the already
384
399
  // de-chunked body.
385
400
  local.on("response", (response) => {
401
+ if (gone(stream)) {
402
+ response.destroy();
403
+ return;
404
+ }
386
405
  stream.respond({ [constants.HTTP2_HEADER_STATUS]: constants.HTTP_STATUS_OK });
387
406
  stream.write(serializeHead(response, DECLINED_UPGRADE_DROP, DECLINED_UPGRADE_ADD));
388
407
  response.pipe(stream);
389
408
  });
390
409
  local.on("error", () => stream.close(constants.NGHTTP2_CONNECT_ERROR));
410
+ // Before the handshake answers, nothing else ties the loopback request to the stream; once spliced, a no-op.
411
+ stream.on("close", () => local.destroy());
391
412
  // An upgrade request carries no body; the client is waiting on the handshake before it says anything else.
392
413
  local.end();
393
414
  };
@@ -1,4 +1,3 @@
1
- import { test, expect, mock, jest } from "bun:test";
2
1
  import { waitFor, advanceTimersByTimeAsync } from "@intentic/testing/bun";
3
2
  import { dialPeer, LONG_OUTAGE_ATTEMPTS, LONG_OUTAGE_MS, PEER_TRY_AGAIN, type SocketLike } from "./peer-dial.js";
4
3
 
@@ -64,7 +63,7 @@ const dialling = (delay?: number) => {
64
63
  const sockets: FakeSocket[] = [];
65
64
  const attached: FakeSocket[] = [];
66
65
  const said: string[] = [];
67
- const revoked = mock();
66
+ const revoked = jest.fn();
68
67
  const link = dialPeer<FakeSocket>({
69
68
  open: async () => {
70
69
  const socket = new FakeSocket();
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { z } from "zod";
3
2
  import { createMcpServer, type McpAuditEntry, textResult, tool } from "./peer-mcp-server.js";
4
3
 
@@ -1,5 +1,4 @@
1
1
  import { eventIterator, oc } from "@orpc/contract";
2
- import { describe, it, expect } from "bun:test";
3
2
  import { z } from "zod";
4
3
  import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTE_SHAPES, SANDBOX_ROUTES, sandboxRouteName } from "../index.js";
5
4
  import { procedure } from "./route-meta.js";
@@ -34,8 +33,12 @@ describe(`contractRoutes`, () => {
34
33
 
35
34
  it(`carries the policy a procedure declared, merged over its builder's`, () => {
36
35
  const operated = procedure.meta({ agent: true, control: `never` });
37
- const routes = contractRoutes({ vpn: { connect: operated.route({ method: "POST", path: "/vpn/{id}/connect" }).meta({ floor: `collaborator` }) } });
38
- expect(routes).toEqual([{ name: `vpn.connect`, method: `POST`, path: `/vpn/{id}/connect`, meta: { agent: true, control: `never`, floor: `collaborator` } }]);
36
+ const routes = contractRoutes({
37
+ vpn: { connect: operated.route({ method: "POST", path: "/vpn/{id}/connect" }).meta({ floor: `collaborator` }) },
38
+ });
39
+ expect(routes).toEqual([
40
+ { name: `vpn.connect`, method: `POST`, path: `/vpn/{id}/connect`, meta: { agent: true, control: `never`, floor: `collaborator` } },
41
+ ]);
39
42
  });
40
43
 
41
44
  it(`ignores non-procedure members rather than inventing routes for them`, () => {
@@ -1,12 +1,11 @@
1
- import { describe, test, expect, mock } from "bun:test";
2
1
  import { createHeartbeat, DEAD_AFTER_MS } from "./tunnel-heartbeat.js";
3
2
 
4
3
  // The clock is injected, so these read as "what happens after N seconds of silence" rather than as timer
5
4
  // plumbing — which is the only question the heartbeat answers.
6
5
  const world = (deadAfterMs = DEAD_AFTER_MS) => {
7
6
  let clock = 0;
8
- const ping = mock();
9
- const onDead = mock();
7
+ const ping = jest.fn();
8
+ const onDead = jest.fn();
10
9
  const heartbeat = createHeartbeat({ ping, onDead, deadAfterMs, now: () => clock });
11
10
  return { heartbeat, ping, onDead, advance: (ms: number) => (clock += ms) };
12
11
  };
@@ -0,0 +1,28 @@
1
+ import type { ClientRequest } from "node:http";
2
+ import type { Duplex } from "node:stream";
3
+
4
+ // `upstream` is the client's handshake replayed; a 101 is echoed and bytes flow raw, any other answer ends the socket.
5
+ export const relayUpgrade = (upstream: ClientRequest, socket: Duplex, head: Buffer): void => {
6
+ upstream.on("error", () => socket.destroy());
7
+ upstream.on("response", (answer) => {
8
+ socket.end(`HTTP/1.1 ${answer.statusCode ?? 502} ${answer.statusMessage ?? ""}\r\n\r\n`);
9
+ });
10
+ upstream.on("upgrade", (answer, upstreamSocket, upstreamHead) => {
11
+ const lines: string[] = [];
12
+ for (let index = 0; index < answer.rawHeaders.length; index += 2) {
13
+ lines.push(`${answer.rawHeaders[index]}: ${answer.rawHeaders[index + 1]}`);
14
+ }
15
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\n${lines.join("\r\n")}\r\n\r\n`);
16
+ if (upstreamHead.length > 0) {
17
+ socket.write(upstreamHead);
18
+ }
19
+ if (head.length > 0) {
20
+ upstreamSocket.write(head);
21
+ }
22
+ upstreamSocket.on("error", () => socket.destroy());
23
+ socket.on("error", () => upstreamSocket.destroy());
24
+ upstreamSocket.pipe(socket);
25
+ socket.pipe(upstreamSocket);
26
+ });
27
+ upstream.end();
28
+ };
@@ -1,4 +1,3 @@
1
- import { describe, expect, test } from "bun:test";
2
1
  import { AgentTurnSchema, profileOf, TurnProfileSchema } from "./agent.js";
3
2
 
4
3
  // The profile is what every continuation carries whole, so its field set and its normalisation are the contract: a key
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { AgentHarnessSchema, AgentOriginSchema, AgentProviderSchema, ConversationQueueSchema, ForkedFromSchema } from "./agent.js";
4
4
  import { LoopStateSchema } from "./loops.js";
5
5
  import { LimitPolicySchema, RetryPolicySchema, TurnBreakPolicySchema, TurnBreakSchema } from "./turn-break.js";
6
+ import { KeepWarmSchema } from "./keep-warm.js";
6
7
  import { EMOJI_MAX_LENGTH, isSingleEmoji } from "../text/emoji.js";
7
8
  // A fleet agent is any conversation with a registry entry, keyed by conversationId. Isolated ones own a git worktree
8
9
  // (branch agent/<id>); workspace conversations have none, but both share one status/activity/cost lifecycle.
@@ -329,11 +330,24 @@ export const AgentSummarySchema = z.object({
329
330
  .object({
330
331
  at: z.number().describe("When its last request touched the provider's prompt cache, in milliseconds."),
331
332
  ttlMs: z.number().describe("How long that entry lives from `at`, in milliseconds."),
333
+ rollsAt: z
334
+ .number()
335
+ .optional()
336
+ .describe(
337
+ "When the date written into the agent's prompt next changes, in milliseconds (midnight where the agent runs). Past it the next turn sends a different prompt, so nothing kept before it is read again.",
338
+ ),
339
+ keepable: z
340
+ .boolean()
341
+ .optional()
342
+ .describe("Whether this sandbox can keep this cache warm: it holds the last turn's exact request to replay, on a Claude subscription account."),
332
343
  })
333
344
  .optional()
334
345
  .describe(
335
346
  "When this conversation's prompt cache was last kept alive and how long it lasts, which together say when picking the conversation up stops being cheap. Absent when the provider publishes nothing to ground it on.",
336
347
  ),
348
+ keepWarm: KeepWarmSchema.optional().describe(
349
+ "Whether the sandbox is keeping this conversation's prompt cache warm while it sits idle, how that is going, or why it stopped. Absent when nobody asked for it.",
350
+ ),
337
351
  activity: AgentActivitySchema.optional().describe("What it is doing at this moment."),
338
352
  // Beside `activity` because both answer "where is it", one in a sentence and one as a fraction. Live while a turn
339
353
  // runs, and left standing after it, so a settled card still says how far the work got.
@@ -517,13 +531,6 @@ export const AgentToolChildrenQuerySchema = AgentIdSchema.extend({
517
531
  toolId: z.string().min(1).describe("Which tool call, by the id its card carries."),
518
532
  });
519
533
  // Absent `ids` archives every archivable finished agent (the lane's "Clear"); unarchive always names its own ids.
520
- export const AgentArchiveSchema = z.object({
521
- ids: z
522
- .array(z.string().min(1))
523
- .max(500)
524
- .optional()
525
- .describe("Which conversations to put away. Leave it out for every finished one that can be archived right now."),
526
- });
527
534
  export const AgentIdsSchema = z.object({ ids: z.array(z.string().min(1)).min(1).max(500).describe("Which conversations.") });
528
535
  // What actually moved, not the roster afterward: two archives finishing at once would otherwise let a slower snapshot
529
536
  // resurrect what a faster one just filed away. Whole summaries, since the receiving surfaces (archive list, detail
@@ -1,5 +1,4 @@
1
1
  import { PUBLIC_DIR, STATE_DIR } from "@intentic/constants";
2
- import { describe, test, expect } from "bun:test";
3
2
  import { AreaFolderSchema } from "./areas.js";
4
3
 
5
4
  // What an area may name. These refusals are load-bearing rather than cosmetic: an area's folders become the fence
@@ -22,7 +21,7 @@ describe("AreaFolderSchema", () => {
22
21
  });
23
22
 
24
23
  test("the sandbox's own configuration and its outbox can never be named", () => {
25
- for (const folder of [STATE_DIR, `${STATE_DIR}/config`, `${STATE_DIR}/config/hooks`, PUBLIC_DIR, `${PUBLIC_DIR}/site`]) {
24
+ for (const folder of [STATE_DIR, `${STATE_DIR}/config`, `${STATE_DIR}/config/skills`, PUBLIC_DIR, `${PUBLIC_DIR}/site`]) {
26
25
  expect(AreaFolderSchema.safeParse(folder).success, folder).toBe(false);
27
26
  }
28
27
  });
@@ -354,6 +354,12 @@ export const CapabilityStatusSchema = z.object({
354
354
  state: CapabilityStateSchema.describe("Whether it is live, still coming up, broken, or switched off."),
355
355
  detail: z.string().optional().describe("What is wrong, in words a person can act on."),
356
356
  code: z.string().optional().describe("A short marker for that reason, for anything deciding what to do about it."),
357
+ settling: z
358
+ .boolean()
359
+ .optional()
360
+ .describe(
361
+ "True while something under way will move this on its own: a start, a download, a pairing code waiting to be typed. Absent when only a person can move it, or a pushed change will say when it moved.",
362
+ ),
357
363
  });
358
364
  export type CapabilityStatus = z.infer<typeof CapabilityStatusSchema>;
359
365
  // The list row: manifest entry plus live status, secrets never returned. `secrets` names which config keys hold a
package/src/schemas/ci.ts CHANGED
@@ -151,15 +151,9 @@ export const CiFixResponseSchema = z.object({
151
151
  conversationId: z.string().describe("The conversation that was opened, already holding the failure. Open it to watch, or attach to its turn."),
152
152
  });
153
153
  export type CiFixResponse = z.infer<typeof CiFixResponseSchema>;
154
- // The workspace's own answer to "would this push go red": front-runs CI at the push itself, the last moment before work
155
- // leaves the machine and the first at which what's pushed is finally settled. Runs at the push rather than the land,
156
- // where the tree keeps moving and a verdict would need staleness tracking to answer a question the push gets for free.
157
- // No stored verdict, nothing polled at rest: a run exists while it runs and is gone after, since the next push asks
158
- // again.
159
-
160
- // One shape for any command the daemon runs on a click with a verdict and a quotable tail (the pre-push check, and the
161
- // push itself via PushRunSchema in schemas/git/git.ts), so the fields the browser reads (terminal, tail, kill vs timeout)
162
- // can't drift between them.
154
+ // One shape for any command the daemon runs on a click with a verdict and a quotable tail (the push via PushRunSchema in
155
+ // schemas/git/git.ts, which runs the repository's own pre-push hook), so the fields the browser reads (terminal, tail,
156
+ // kill vs timeout) have one definition.
163
157
  // idle: nothing has run yet, or the last run was cleared
164
158
  // running: live; its output is the terminal's, not this object's
165
159
  // passed: exited 0
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { contextTrimLine } from "./context-trim.js";
3
2
 
4
3
  // The one thing a reader ever sees of this mechanism. It has to answer three questions in one line: why the turn ran
@@ -1,4 +1,3 @@
1
- import { test, expect } from "bun:test";
2
1
  import { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, devRebuildLogPath, readDevRebuildLog } from "./devices.js";
3
2
 
4
3
  // The one account a detached rebuild gives of itself. The daemon builds the shell that writes this text and the browser