@intentic/sandbox-contract 1.332.0 → 1.333.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 (159) hide show
  1. package/README.md +8 -1
  2. package/dist/contracts/agent.contract.d.ts +1 -0
  3. package/dist/contracts/agent.contract.d.ts.map +1 -1
  4. package/dist/contracts/automations.contract.d.ts +1 -0
  5. package/dist/contracts/automations.contract.d.ts.map +1 -1
  6. package/dist/contracts/needs.contract.d.ts +29 -1
  7. package/dist/contracts/needs.contract.d.ts.map +1 -1
  8. package/dist/contracts/needs.contract.js +2 -2
  9. package/dist/contracts/needs.contract.js.map +1 -1
  10. package/dist/contracts/runner.contract.d.ts.map +1 -1
  11. package/dist/contracts/runner.contract.js +2 -2
  12. package/dist/contracts/runner.contract.js.map +1 -1
  13. package/dist/contracts/settings.contract.d.ts +34 -0
  14. package/dist/contracts/settings.contract.d.ts.map +1 -1
  15. package/dist/index.d.ts +114 -43
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +7 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/models/model-roles.d.ts +8 -0
  20. package/dist/models/model-roles.d.ts.map +1 -1
  21. package/dist/models/model-roles.js +8 -0
  22. package/dist/models/model-roles.js.map +1 -1
  23. package/dist/policy/control-scopes.d.ts +47 -0
  24. package/dist/policy/control-scopes.d.ts.map +1 -1
  25. package/dist/policy/control-scopes.js +16 -0
  26. package/dist/policy/control-scopes.js.map +1 -1
  27. package/dist/policy/device-programs.d.ts +28 -0
  28. package/dist/policy/device-programs.d.ts.map +1 -0
  29. package/dist/policy/device-programs.js +79 -0
  30. package/dist/policy/device-programs.js.map +1 -0
  31. package/dist/policy/identity-assertion.d.ts +27 -0
  32. package/dist/policy/identity-assertion.d.ts.map +1 -0
  33. package/dist/policy/identity-assertion.js +77 -0
  34. package/dist/policy/identity-assertion.js.map +1 -0
  35. package/dist/policy/sandbox-power.d.ts +34 -0
  36. package/dist/policy/sandbox-power.d.ts.map +1 -0
  37. package/dist/policy/sandbox-power.js +146 -0
  38. package/dist/policy/sandbox-power.js.map +1 -0
  39. package/dist/protocol/doors/runner-protocol.d.ts +36 -0
  40. package/dist/protocol/doors/runner-protocol.d.ts.map +1 -1
  41. package/dist/protocol/doors/runner-protocol.js +2 -0
  42. package/dist/protocol/doors/runner-protocol.js.map +1 -1
  43. package/dist/protocol/raw/raw-json-routes.d.ts +839 -1
  44. package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
  45. package/dist/protocol/raw/raw-json-routes.js +60 -2
  46. package/dist/protocol/raw/raw-json-routes.js.map +1 -1
  47. package/dist/protocol/raw/raw-routes.d.ts +9 -0
  48. package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
  49. package/dist/protocol/raw/raw-routes.js +3 -0
  50. package/dist/protocol/raw/raw-routes.js.map +1 -1
  51. package/dist/schemas/agent.d.ts +3 -0
  52. package/dist/schemas/agent.d.ts.map +1 -1
  53. package/dist/schemas/automations.d.ts +1 -0
  54. package/dist/schemas/automations.d.ts.map +1 -1
  55. package/dist/schemas/automations.js +8 -2
  56. package/dist/schemas/automations.js.map +1 -1
  57. package/dist/schemas/device-swaps.d.ts +19 -0
  58. package/dist/schemas/device-swaps.d.ts.map +1 -0
  59. package/dist/schemas/device-swaps.js +11 -0
  60. package/dist/schemas/device-swaps.js.map +1 -0
  61. package/dist/schemas/environment.d.ts +7 -0
  62. package/dist/schemas/environment.d.ts.map +1 -1
  63. package/dist/schemas/environment.js +2 -0
  64. package/dist/schemas/environment.js.map +1 -1
  65. package/dist/schemas/members.d.ts +4 -0
  66. package/dist/schemas/members.d.ts.map +1 -1
  67. package/dist/schemas/members.js +5 -0
  68. package/dist/schemas/members.js.map +1 -1
  69. package/dist/schemas/needs.d.ts +78 -1
  70. package/dist/schemas/needs.d.ts.map +1 -1
  71. package/dist/schemas/needs.js +36 -1
  72. package/dist/schemas/needs.js.map +1 -1
  73. package/dist/schemas/restart-intent.d.ts +47 -0
  74. package/dist/schemas/restart-intent.d.ts.map +1 -0
  75. package/dist/schemas/restart-intent.js +19 -0
  76. package/dist/schemas/restart-intent.js.map +1 -0
  77. package/dist/schemas/settings.d.ts +34 -0
  78. package/dist/schemas/settings.d.ts.map +1 -1
  79. package/dist/schemas/settings.js +2 -0
  80. package/dist/schemas/settings.js.map +1 -1
  81. package/dist/schemas/short-codes.d.ts +16 -0
  82. package/dist/schemas/short-codes.d.ts.map +1 -1
  83. package/dist/schemas/short-codes.js +2 -0
  84. package/dist/schemas/short-codes.js.map +1 -1
  85. package/dist/schemas/speech.d.ts +4 -0
  86. package/dist/schemas/speech.d.ts.map +1 -1
  87. package/dist/schemas/speech.js +1 -0
  88. package/dist/schemas/speech.js.map +1 -1
  89. package/dist/schemas/system.d.ts +46 -0
  90. package/dist/schemas/system.d.ts.map +1 -1
  91. package/dist/schemas/system.js +18 -1
  92. package/dist/schemas/system.js.map +1 -1
  93. package/dist/schemas/voice.d.ts +171 -0
  94. package/dist/schemas/voice.d.ts.map +1 -0
  95. package/dist/schemas/voice.js +99 -0
  96. package/dist/schemas/voice.js.map +1 -0
  97. package/dist/schemas/workspace/workspace-upload.d.ts +14 -0
  98. package/dist/schemas/workspace/workspace-upload.d.ts.map +1 -0
  99. package/dist/schemas/workspace/workspace-upload.js +6 -0
  100. package/dist/schemas/workspace/workspace-upload.js.map +1 -0
  101. package/dist/state/arrival.d.ts +3 -0
  102. package/dist/state/arrival.d.ts.map +1 -1
  103. package/dist/state/arrival.js +1 -0
  104. package/dist/state/arrival.js.map +1 -1
  105. package/dist/state/definition.d.ts +52 -4
  106. package/dist/state/definition.d.ts.map +1 -1
  107. package/dist/state/history-state.d.ts.map +1 -1
  108. package/dist/state/history-state.js +2 -0
  109. package/dist/state/history-state.js.map +1 -1
  110. package/dist/state/runtime-state.d.ts +2 -2
  111. package/dist/state/runtime-state.d.ts.map +1 -1
  112. package/dist/state/runtime-state.js +2 -2
  113. package/dist/state/runtime-state.js.map +1 -1
  114. package/dist/state/update-probation.d.ts +50 -0
  115. package/dist/state/update-probation.d.ts.map +1 -0
  116. package/dist/state/update-probation.js +81 -0
  117. package/dist/state/update-probation.js.map +1 -0
  118. package/dist/state/workspace-state.d.ts +1 -1
  119. package/dist/state/workspace-state.d.ts.map +1 -1
  120. package/dist/state/workspace-state.js +1 -1
  121. package/dist/state/workspace-state.js.map +1 -1
  122. package/package.json +16 -5
  123. package/src/contracts/needs.contract.ts +2 -2
  124. package/src/contracts/runner.contract.ts +10 -2
  125. package/src/ids/tunnel-ids.test.ts +30 -0
  126. package/src/index.ts +7 -0
  127. package/src/models/model-roles.ts +10 -0
  128. package/src/policy/control-scopes.ts +27 -0
  129. package/src/policy/device-programs.ts +137 -0
  130. package/src/policy/identity-assertion.ts +130 -0
  131. package/src/policy/sandbox-power.ts +247 -0
  132. package/src/policy/tests/device-programs.test.ts +107 -0
  133. package/src/policy/tests/identity-assertion.test.ts +79 -0
  134. package/src/policy/tests/sandbox-power.test.ts +181 -0
  135. package/src/protocol/doors/runner-protocol.ts +7 -0
  136. package/src/protocol/raw/raw-json-routes.test.ts +87 -0
  137. package/src/protocol/raw/raw-json-routes.ts +125 -6
  138. package/src/protocol/raw/raw-routes.ts +6 -0
  139. package/src/schemas/automations.ts +10 -3
  140. package/src/schemas/device-swaps.test.ts +39 -0
  141. package/src/schemas/device-swaps.ts +26 -0
  142. package/src/schemas/environment.ts +4 -0
  143. package/src/schemas/members.ts +8 -0
  144. package/src/schemas/needs.ts +51 -1
  145. package/src/schemas/restart-intent.test.ts +45 -0
  146. package/src/schemas/restart-intent.ts +47 -0
  147. package/src/schemas/settings.ts +6 -0
  148. package/src/schemas/short-codes.ts +11 -0
  149. package/src/schemas/speech.ts +4 -0
  150. package/src/schemas/system.ts +43 -4
  151. package/src/schemas/voice.ts +161 -0
  152. package/src/schemas/workspace/workspace-upload.ts +11 -0
  153. package/src/state/arrival.ts +3 -0
  154. package/src/state/history-state.ts +5 -0
  155. package/src/state/runtime-state.test.ts +9 -0
  156. package/src/state/runtime-state.ts +7 -5
  157. package/src/state/update-probation.test.ts +45 -0
  158. package/src/state/update-probation.ts +169 -0
  159. package/src/state/workspace-state.ts +4 -1
@@ -193,8 +193,8 @@ export const ManifestRepairSchema = z.object({
193
193
  ),
194
194
  });
195
195
  export type ManifestRepair = z.infer<typeof ManifestRepairSchema>;
196
- // Steady-state browser credential (system.session), exchanged for a verified Google ID token so Google UI appears only
197
- // at sign-in. expiresAt is epoch ms; renew ahead of it without parsing the token.
196
+ // Steady-state browser credential (system.session), exchanged for a verified Google ID token or the platform's identity
197
+ // assertion, so sign-in UI appears only once. expiresAt is epoch ms; renew ahead of it without parsing the token.
198
198
  export const DaemonSessionSchema = z.object({
199
199
  token: z.string().describe("The credential every other call carries. Present it as a bearer token."),
200
200
  expiresAt: z.number().describe("When it stops working, in milliseconds, so a caller can renew ahead of it without reading the token."),
@@ -206,10 +206,18 @@ export type DaemonSession = z.infer<typeof DaemonSessionSchema>;
206
206
  // daemon is the WebAuthn relying party; these are the shapes its /system/passkeys routes and the 428 step-up carry.
207
207
 
208
208
  // How a session's identity was proven. A session carries the set it was minted from; the require-passkey policy reads
209
- // it on every request, so a session minted before the switch stops working on its next call.
210
- export const ProofMethodSchema = z.enum(["google", "ticket", "passkey", "recovery"]);
209
+ // it on every request, so a session minted before the switch stops working on its next call. `email` is the platform's
210
+ // signed word that the person controls the address (policy/identity-assertion.ts), accepted only where the owner opted
211
+ // that person in, and on hosted machines.
212
+ export const ProofMethodSchema = z.enum(["google", "ticket", "passkey", "recovery", "email"]);
211
213
  export type ProofMethod = z.infer<typeof ProofMethodSchema>;
212
214
 
215
+ // The `reason` a daemon's refusal of an identity assertion carries beside its `error`, so the editor can say what to do:
216
+ // a 403 for a person this sandbox has not opted in to email sign-in (the owner turns it on for them), a 401 when the
217
+ // daemon has not yet learned the platform's identity key (it has not reached the platform; worth a retry).
218
+ export const EMAIL_SIGN_IN_OFF = "email-sign-in-off";
219
+ export const PLATFORM_KEY_UNKNOWN = "platform-key-unknown";
220
+
213
221
  export const PasskeySummarySchema = z.object({
214
222
  id: z.string().describe("The credential id the authenticator chose, base64url."),
215
223
  email: z.string().describe("Whose passkey this is; the owner's list carries every member's, a member's only their own."),
@@ -233,6 +241,9 @@ export type PasskeysList = z.infer<typeof PasskeysListSchema>;
233
241
 
234
242
  export const PasskeyPolicySchema = z.object({ required: z.boolean() });
235
243
  export type PasskeyPolicy = z.infer<typeof PasskeyPolicySchema>;
244
+ // POST /system/passkeys/policy: switching the rule on answers with the recovery codes, off with none.
245
+ export const PasskeyPolicyAnswerSchema = PasskeyPolicySchema.extend({ codes: z.array(z.string()).optional() });
246
+ export type PasskeyPolicyAnswer = z.infer<typeof PasskeyPolicyAnswerSchema>;
236
247
 
237
248
  // Shown exactly once: the daemon keeps only their hashes.
238
249
  export const RecoveryCodesSchema = z.object({ codes: z.array(z.string()) });
@@ -319,6 +330,26 @@ export interface AuthenticationOptionsJSON {
319
330
  readonly available: boolean;
320
331
  }
321
332
 
333
+ // The two interfaces above read off the wire with the one check a body needs before the browser's own parser takes it
334
+ // (`parseCreationOptionsFromJSON` rejects the rest): an object with a challenge. Typed, not parsed field by field, since
335
+ // only the daemon writes them.
336
+ const WithChallengeSchema = z.object({ challenge: z.string() });
337
+ const optionsWithChallenge = <Options extends { readonly challenge: string }>() => z.custom<Options>((value) => WithChallengeSchema.safeParse(value).success);
338
+ export const RegistrationOptionsSchema = optionsWithChallenge<RegistrationOptionsJSON>();
339
+ export const AuthenticationOptionsSchema = optionsWithChallenge<AuthenticationOptionsJSON>();
340
+
341
+ // POST /system/passkeys/register: the passkey as listed, and the session upgraded on the spot when the bearer that
342
+ // registered held no passkey yet (the same proof, now carrying one).
343
+ export const PasskeyRegisteredSchema = z.object({
344
+ passkey: PasskeySummarySchema,
345
+ session: DaemonSessionSchema.optional(),
346
+ });
347
+ export type PasskeyRegistered = z.infer<typeof PasskeyRegisteredSchema>;
348
+
349
+ // POST /system/session/recover: the session the recovery code bought, and how many codes are left unspent.
350
+ export const SessionRecoveredSchema = DaemonSessionSchema.extend({ remaining: z.number() });
351
+ export type SessionRecovered = z.infer<typeof SessionRecoveredSchema>;
352
+
322
353
  // Whether this sandbox's public address answers, probed by the box itself; the same shape as `announce` beside it, so
323
354
  // `ic sandbox doctor` and the setup wizard can name whichever link broke.
324
355
  export const ReachStateSchema = z.object({
@@ -359,6 +390,9 @@ export const DaemonHealthSchema = z.object({
359
390
  // How the world reaches here: a dialed tunnel, or loopback only.
360
391
  reachedBy: z.enum(["tunnel", "loopback"]),
361
392
  build: BuildStampSchema,
393
+ // Which proofs this daemon takes to open a session, read before any sign-in since the route list arrives only after
394
+ // one. Absent on a daemon from before email sign-in: Google, and the owner ticket on a hosted machine.
395
+ signIn: z.object({ proofs: z.array(ProofMethodSchema) }).optional(),
362
396
  });
363
397
  export type DaemonHealth = z.infer<typeof DaemonHealthSchema>;
364
398
 
@@ -376,5 +410,10 @@ export const DaemonHealthReadingSchema = z.object({
376
410
  reach: ReachStateSchema.optional().catch(undefined),
377
411
  reachedBy: z.string().optional().catch(undefined),
378
412
  build: BuildStampSchema.optional().catch(undefined),
413
+ // Proof names this build may not know are kept as text, so a newer daemon's list still reads.
414
+ signIn: z
415
+ .object({ proofs: z.array(z.string()).catch([]) })
416
+ .optional()
417
+ .catch(undefined),
379
418
  });
380
419
  export type DaemonHealthReading = z.infer<typeof DaemonHealthReadingSchema>;
@@ -0,0 +1,161 @@
1
+ import { z } from "zod";
2
+
3
+ // Speaking a reply aloud: which voices a sandbox can speak with, which one speaks a language, and the live wire the
4
+ // editor plays speech over. The daemon's engine is src/speech/voice; the editor's half is
5
+ // _editor/web/src/features/chat/voice. Dictation, the other direction, is schemas/speech.ts.
6
+
7
+ // Where a voice is made. `kokoro` and `piper` run on this sandbox's own CPU (sherpa-onnx, the runtime dictation already
8
+ // loads), so nothing leaves it; the rest are providers the owner connected with an API key of their own.
9
+ export const VoiceEngineSchema = z.enum(["kokoro", "piper", "elevenlabs", "cartesia", "deepgram"]);
10
+ export type VoiceEngine = z.infer<typeof VoiceEngineSchema>;
11
+
12
+ export const CloudVoiceProviderSchema = z.enum(["elevenlabs", "cartesia", "deepgram"]);
13
+ export type CloudVoiceProvider = z.infer<typeof CloudVoiceProviderSchema>;
14
+
15
+ export const isCloudVoiceEngine = (engine: VoiceEngine): engine is CloudVoiceProvider => CloudVoiceProviderSchema.safeParse(engine).success;
16
+
17
+ // A voice, as `<engine>/<name>`: `kokoro/af_heart`, `piper/pl_PL-gosia-medium`, `elevenlabs/<voice id>`. One string so a
18
+ // setting, a preview and a log line all name a voice the same way.
19
+ export const VOICE_ID = /^(kokoro|piper|elevenlabs|cartesia|deepgram)\/[\w.-]+$/u;
20
+ export const VoiceIdSchema = z.string().regex(VOICE_ID);
21
+ export type VoiceId = z.infer<typeof VoiceIdSchema>;
22
+
23
+ export const voiceEngineOf = (id: VoiceId): VoiceEngine => VoiceEngineSchema.parse(id.slice(0, id.indexOf("/")));
24
+ export const voiceNameOf = (id: VoiceId): string => id.slice(id.indexOf("/") + 1);
25
+
26
+ // A language as an ISO 639-1 primary subtag, lowercased: `en`, `pl`. Regional voices (British English, Brazilian
27
+ // Portuguese) are voices of the one language, chosen like any other.
28
+ export const VoiceLanguageSchema = z.string().regex(/^[a-z]{2,3}$/u);
29
+
30
+ // A provider the owner connected for speaking: its key stays in the sandbox's secrets, named here.
31
+ export const CloudVoiceSchema = z.object({
32
+ provider: CloudVoiceProviderSchema.describe("Which provider speaks."),
33
+ secret: z.string().min(1).describe("The stored secret holding its API key, by name, as the Secrets page lists it."),
34
+ model: z.string().default("").describe("Which of its models speaks. Empty takes its fast default, the one made for conversation."),
35
+ voice: z.string().default("").describe("Which of its voices, by the provider's own voice id. Empty takes a default that speaks the language."),
36
+ });
37
+ export type CloudVoice = z.infer<typeof CloudVoiceSchema>;
38
+
39
+ // The sandbox's speaking choices (SandboxSettings.voice).
40
+ export const VoiceSettingsSchema = z.object({
41
+ replies: z
42
+ .boolean()
43
+ .default(true)
44
+ .describe(
45
+ "When you talk to the agent hands-free, have it answer out loud: a few spoken sentences saying what it did and what it needs from you, while the full reply stays on screen.",
46
+ ),
47
+ interrupt: z
48
+ .boolean()
49
+ .default(false)
50
+ .describe(
51
+ "Stop speaking the moment you start talking over it. Off, the microphone rests while a reply is spoken, which is the safe choice on speakers: a voice the browser fails to cancel would otherwise be heard as yours.",
52
+ ),
53
+ speed: z.number().min(0.5).max(2).default(1).describe("How fast it speaks, 1 being the voice's own pace."),
54
+ voices: z
55
+ .record(VoiceLanguageSchema, VoiceIdSchema)
56
+ .default({})
57
+ .describe(
58
+ "Which voice speaks each language, where you picked one. A language left out is spoken by the connected provider when there is one, and otherwise by its default on this sandbox: Kokoro for the eight languages it speaks, Piper for the rest.",
59
+ ),
60
+ cloud: CloudVoiceSchema.optional().describe(
61
+ "A speech provider of your own, used for every language it speaks unless you picked a voice on this sandbox for it. Its key is a stored secret; text it speaks leaves the sandbox for that provider.",
62
+ ),
63
+ });
64
+ export type VoiceSettings = z.infer<typeof VoiceSettingsSchema>;
65
+
66
+ // One voice the sandbox can speak with, as the settings page lists it.
67
+ export const VoiceInfoSchema = z.object({
68
+ id: VoiceIdSchema,
69
+ label: z.string().describe("Its name as a person would say it."),
70
+ languages: z.array(VoiceLanguageSchema).describe("What it speaks; a regional voice lists its language."),
71
+ accent: z.string().optional().describe("The region it sounds from, where the language has several (US, GB, BR)."),
72
+ gender: z.enum(["female", "male"]).optional(),
73
+ // The download a local voice's model costs, the first time anything asks it to speak; shared by every voice of
74
+ // one model (Kokoro's 54 voices are one download).
75
+ model: z.string().optional().describe("The model a local voice lives in."),
76
+ bytes: z.number().optional().describe("How much that model downloads, the first time it speaks."),
77
+ installed: z.boolean().optional().describe("Its model is on this sandbox already."),
78
+ });
79
+ export type VoiceInfo = z.infer<typeof VoiceInfoSchema>;
80
+
81
+ // GET /speech/voices: everything the settings page needs to choose.
82
+ export const VoicesViewSchema = z.object({
83
+ voices: z.array(VoiceInfoSchema),
84
+ // The local default for every language a local voice speaks; what speaks a language nobody chose for.
85
+ defaults: z.record(VoiceLanguageSchema, VoiceIdSchema),
86
+ cloud: z
87
+ .object({
88
+ provider: CloudVoiceProviderSchema,
89
+ ready: z.boolean().describe("Its secret resolves, so it can be asked."),
90
+ languages: z.array(VoiceLanguageSchema).describe("What it speaks; any other language falls back to this sandbox's voices."),
91
+ })
92
+ .optional(),
93
+ provisioned: z.boolean().describe("This sandbox can run speech synthesis at all."),
94
+ });
95
+ export type VoicesView = z.infer<typeof VoicesViewSchema>;
96
+
97
+ // Where the voice that speaks a phrase stands: its model on disk, still on its way, or a provider's.
98
+ export const VoiceStatusSchema = z.object({
99
+ voice: VoiceIdSchema,
100
+ model: z.enum(["ready", "downloading", "absent", "failed", "cloud"]),
101
+ received: z.number().optional().describe("Bytes of the model fetched so far, while it downloads."),
102
+ total: z.number().optional().describe("The model's whole download, while it downloads."),
103
+ error: z.string().optional(),
104
+ });
105
+ export type VoiceStatus = z.infer<typeof VoiceStatusSchema>;
106
+
107
+ // What a text is, so the daemon knows how to say it:
108
+ // - `plain`: words to say as they are (a preview, a question card read out, Discord).
109
+ // - `read`: a written reply read aloud whole: code is announced rather than spelled, tables and links shortened.
110
+ // - `reply`: a written reply turned into a few spoken sentences first (the `spoken-reply` job, or its first lines when
111
+ // no model is set for it); the words chosen come back as `spoken` before they are heard.
112
+ export const SpeakAsSchema = z.enum(["plain", "read", "reply"]);
113
+ export type SpeakAs = z.infer<typeof SpeakAsSchema>;
114
+
115
+ // The longest text one utterance takes: a long reply read whole, with headroom.
116
+ export const MAX_SPOKEN_TEXT = 40_000;
117
+
118
+ // GET /speech/say, a WebSocket (one per player, many utterances): `say` opens utterance `id`, `stop` ends it (and the
119
+ // audio still queued for it is dropped). The daemon answers, per utterance, `status` while its voice's model is on
120
+ // its way, `spoken` with the words chosen (always for `reply`, so they can be shown), then pairs of `audio` (a JSON
121
+ // header) and one binary frame of that many mono s16le samples, then `done`. Utterances are spoken one at a time, in
122
+ // the order they were asked.
123
+ export const VoiceClientMessageSchema = z.discriminatedUnion("type", [
124
+ z.object({
125
+ type: z.literal("say"),
126
+ id: z.number(),
127
+ text: z.string().min(1).max(MAX_SPOKEN_TEXT),
128
+ as: SpeakAsSchema.default("plain"),
129
+ lang: z.string().optional().describe("The reader's language, a hint: the text's own language decides when it can be told."),
130
+ voice: VoiceIdSchema.optional().describe("Speak with this voice whatever the settings say: a preview."),
131
+ asked: z.string().max(4000).optional().describe("For `reply`: what the person asked, so the spoken answer can be in their language."),
132
+ conversationId: z.string().optional().describe("For `reply`: the conversation it answers, for the providers allowed to read it."),
133
+ }),
134
+ z.object({ type: z.literal("stop"), id: z.number() }),
135
+ ]);
136
+ export type VoiceClientMessage = z.infer<typeof VoiceClientMessageSchema>;
137
+
138
+ export const VoiceServerMessageSchema = z.discriminatedUnion("type", [
139
+ z.object({ type: z.literal("status"), id: z.number(), status: VoiceStatusSchema }),
140
+ z.object({
141
+ type: z.literal("spoken"),
142
+ id: z.number(),
143
+ text: z.string().describe("The words about to be said."),
144
+ lang: VoiceLanguageSchema,
145
+ voice: VoiceIdSchema,
146
+ }),
147
+ z.object({
148
+ type: z.literal("audio"),
149
+ id: z.number(),
150
+ sampleRate: z.number(),
151
+ samples: z.number().describe("How many mono s16le samples the binary frame right after this one holds."),
152
+ }),
153
+ z.object({ type: z.literal("done"), id: z.number() }),
154
+ z.object({
155
+ type: z.literal("notice"),
156
+ id: z.number(),
157
+ message: z.string().describe("Something worth saying that did not stop it: a provider that failed, so this sandbox's voice spoke instead."),
158
+ }),
159
+ z.object({ type: z.literal("error"), id: z.number().optional(), message: z.string() }),
160
+ ]);
161
+ export type VoiceServerMessage = z.infer<typeof VoiceServerMessageSchema>;
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+
3
+ // What the editor sends ahead of a drop so a re-drop only moves what changed: POST /workspace/upload-diff takes each
4
+ // dropped file's destination path with its source size and mtime (ms, from File.lastModified), and answers the paths
5
+ // already identical on disk, whose bytes then never upload.
6
+ export const UploadManifestSchema = z.object({
7
+ files: z.array(z.object({ path: z.string(), size: z.number(), mtime: z.number() })),
8
+ });
9
+ export type UploadManifest = z.infer<typeof UploadManifestSchema>;
10
+ export const UploadSkipSchema = z.object({ skip: z.array(z.string()) });
11
+ export type UploadSkip = z.infer<typeof UploadSkipSchema>;
@@ -108,6 +108,9 @@ export const ArrivalHostSchema = z.object({
108
108
  export type ArrivalHost = z.infer<typeof ArrivalHostSchema>;
109
109
  export const ArrivalHostsSchema = z.object({ hosts: z.array(ArrivalHostSchema) });
110
110
 
111
+ // DELETE /arrivals: whether there was a held plan to put away.
112
+ export const ArrivalAbandonedSchema = z.object({ ok: z.boolean() });
113
+
111
114
  // Reads a setup off a connected device instead of an upload; answers with a plan exactly like the upload door does.
112
115
  export const ArrivalScanSchema = z.object({ host: z.string().min(1) });
113
116
  export type ArrivalScan = z.infer<typeof ArrivalScanSchema>;
@@ -227,6 +227,11 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
227
227
  portability: "identity",
228
228
  note: "Approve again from Approvals anything that was approved and had not run yet.",
229
229
  },
230
+ // The wakes automations hold for a person, one file each (the daemon's automations/held-wakes-store.ts): daemon-minted, so
231
+ // kept where no turn writes. They travel with the workspace as they always did; a countdown hold arrives waiting for a
232
+ // person, since the key that signed it stays behind.
233
+ { path: "automation-holds/", portability: "carry" },
234
+ { path: "automation-holds.key", portability: "identity", note: "A held wake that was counting down arrives waiting for your yes instead." },
230
235
  { path: "local-cert/", portability: "identity" },
231
236
 
232
237
  /* ---- the state engine's own bookkeeping (the daemon's store/evolution/state-journal.ts) ---- */
@@ -40,6 +40,15 @@ describe(`staleRuntimeQueryKeys`, () => {
40
40
  it(`re-reads the Allowed list when a card's yes is written or taken back, which no file push carries`, () => {
41
41
  expect(staleRuntimeQueryKeys([`grants`])).toEqual([[`grants`]]);
42
42
  });
43
+
44
+ it(`re-reads each paired-machine roster on its own door's push, and the sync state on a device's`, () => {
45
+ // The dialogs and rows that name a machine read one roster each (the editor's PAIRED_* and RUNNERS families), so a
46
+ // socket opening or closing, or a sync key enrolled, moves exactly the keys that show it.
47
+ expect(staleRuntimeQueryKeys([`hosts`])).toEqual([[`capabilities`], [`devices`], [`hosts`], [`sync-health`]]);
48
+ expect(staleRuntimeQueryKeys([`webext`])).toEqual([[`capabilities`], [`webext`]]);
49
+ expect(staleRuntimeQueryKeys([`phones`])).toEqual([[`capabilities`], [`phones`]]);
50
+ expect(staleRuntimeQueryKeys([`runners`])).toEqual([[`runners`]]);
51
+ });
43
52
  });
44
53
 
45
54
  describe(`RUNTIME_DOMAIN_BINDINGS`, () => {
@@ -37,11 +37,13 @@ const RUNTIME_DOMAINS = [
37
37
  // Daemon-held; changes continuously (tokens, tool uses), so it's rate-limited rather than pushed per mutation.
38
38
  { domain: "subagents", invalidates: [["subagents"]] },
39
39
 
40
- // Machines on the other end of a socket (devices, browsers, runners); "online" is the socket itself, never sampled.
41
- // hosts/webext land on capabilities since a pairing card's state is literally the hub's online answer. `hosts` also
42
- // carries a device reading that landed too late for the reader waiting on it (hosts/device-reports.ts).
43
- { domain: "hosts", invalidates: [["capabilities"], ["devices"]] },
44
- { domain: "webext", invalidates: [["capabilities"]] },
40
+ // Machines on the other end of a socket (devices, browsers, phones, runners); "online" is the socket itself, never
41
+ // sampled. hosts/webext/phones land on capabilities since a pairing card's state is literally the hub's online answer,
42
+ // and each on the roster its dialogs and rows read (`hosts`, `webext`, `phones`; the editor's PAIRED_* families).
43
+ // `hosts` also carries a device reading that landed too late for the reader waiting on it (hosts/device-reports.ts),
44
+ // and a desktop-sync enrollment made or revoked, which is what moves `sync-health` (GET /system/sync).
45
+ { domain: "hosts", invalidates: [["capabilities"], ["devices"], ["hosts"], ["sync-health"]] },
46
+ { domain: "webext", invalidates: [["capabilities"], ["webext"]] },
45
47
  { domain: "phones", invalidates: [["capabilities"], ["phones"]] },
46
48
  { domain: "runners", invalidates: [["runners"]] },
47
49
 
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { packageRoot } from "@intentic/constants/node";
4
+ import { CRASH_LOOP_RESTARTS, judgeProbation, type ProbationLook, probationStep, type ProbationStep, type ProbationVerdict, UPDATE_PROBATION } from "./update-probation.js";
5
+
6
+ // The update probation policy and its rule, held to golden/update-probation.json: the cases hand-written from ic's Rust
7
+ // (probation.rs), which builds every one of them too. A number or a reason that moves in one language and not the other
8
+ // fails here or there, naming the case.
9
+
10
+ interface JudgeCase {
11
+ readonly name: string;
12
+ readonly look: Partial<ProbationLook>;
13
+ readonly swap: { readonly at: number; readonly reach: string | null };
14
+ readonly now: number;
15
+ readonly graceMs?: number;
16
+ readonly verdict: ProbationVerdict;
17
+ }
18
+ interface StepCase {
19
+ readonly name: string;
20
+ readonly verdict: ProbationVerdict;
21
+ readonly strikes: number;
22
+ readonly over: boolean;
23
+ readonly step: ProbationStep;
24
+ }
25
+
26
+ // SAFETY: the golden is this package's own file; the assertions below fail on any field it lacks.
27
+ const GOLDEN = JSON.parse(readFileSync(join(packageRoot(import.meta.url), "golden", "update-probation.json"), "utf8")) as {
28
+ readonly policy: Record<string, number>;
29
+ readonly base: ProbationLook;
30
+ readonly judge: JudgeCase[];
31
+ readonly steps: StepCase[];
32
+ };
33
+
34
+ test("the policy numbers are the ones the golden records for ic, to the unit", () => {
35
+ expect(Object.fromEntries(Object.entries(UPDATE_PROBATION))).toEqual(GOLDEN.policy);
36
+ expect(GOLDEN.policy).toHaveProperty("crashLoopRestarts", CRASH_LOOP_RESTARTS);
37
+ });
38
+
39
+ test.each(GOLDEN.judge)("judges: $name", ({ look, swap, now, graceMs, verdict }) => {
40
+ expect(judgeProbation({ ...GOLDEN.base, ...look }, swap, now, graceMs)).toEqual(verdict);
41
+ });
42
+
43
+ test.each(GOLDEN.steps)("steps: $name", ({ verdict, strikes, over, step }) => {
44
+ expect(probationStep(verdict, strikes, over)).toEqual(step);
45
+ });
@@ -0,0 +1,169 @@
1
+ // THE POLICY AN UPDATE IS HELD TO AFTER IT TOOK, one policy on two substrates.
2
+ //
3
+ // A new version of a sandbox that came up is not yet a version that works. On a machine `ic` runs, the old container stays
4
+ // parked and ready while a watch looks at the new one (_sandbox/ic/src/sandbox/probation.rs), and puts the old one back
5
+ // by itself if it keeps failing. On Fly the platform's api does the same job over the image the machine's row keeps as
6
+ // its way back (_platform/api/src/sandbox/hosted/gate/hosted-probation.ts). The two cannot share code (one looks through
7
+ // Docker, the other through Fly's exec), so they share what decides: the numbers below, and `judgeProbation` /
8
+ // `probationStep`, the rule that turns one look at the new version into a verdict and a verdict into a step.
9
+ //
10
+ // `ic` owns the rule; this is its TS twin, and golden/update-probation.json holds each number and each case, hand-written
11
+ // from the Rust: ic's test (probation.rs) and this package's test (update-probation.test.ts) both build every one, so
12
+ // neither can drift without the other failing. A number changes here, in the golden and in ic's constant together.
13
+
14
+ // Restarts that make a crash loop, whoever counts them: netd restarting a crashed daemon inside a container that stays up,
15
+ // Docker restarting the container, Fly restarting the machine, or the editor reading a sandbox's vitals (diagnose.ts).
16
+ // ic's health.rs CRASH_LOOP_RESTARTS and the platform's gate (daemon-health.ts) are this number too.
17
+ export const CRASH_LOOP_RESTARTS = 3;
18
+
19
+ export const UPDATE_PROBATION = {
20
+ // How long the version before an update is kept ready to go back to (`IC_PROBATION_SECONDS` shortens ic's, for its
21
+ // nightly drill), and how long the platform watches a hosted machine's new image.
22
+ watchMs: 24 * 60 * 60_000,
23
+ // How long a new version may stay not-ready, unreachable or silent before that counts against it: longer than any
24
+ // healthy first boot (state conversions, a cold cache), which the switch's own health wait already sat through.
25
+ graceMs: 10 * 60_000,
26
+ // An open state journal is a boot still converting; past this it is a boot that will not finish.
27
+ journalMs: 15 * 60_000,
28
+ // Consecutive failed looks that end a probation in a rollback: never one bad answer.
29
+ strikes: 3,
30
+ // Rollback targets kept BEYOND `previous`, newest first: older builds `ic sandbox rollback --to` reaches without a
31
+ // download (record.rs MAX_KEPT). The hosted row keeps only `previous`; see the platform's gate-row.ts.
32
+ keptBuilds: 2,
33
+ // Restarts that are a crash loop on their own, of the container and of the daemon inside it.
34
+ crashLoopRestarts: CRASH_LOOP_RESTARTS,
35
+ } as const;
36
+
37
+ // A tunnel that has been `checking` this much longer than the grace has not come up.
38
+ const TUNNEL_EXTRA_MS = 5 * 60_000;
39
+
40
+ // What one look at the new version found: the same facts ic gathers through Docker and the platform through Fly.
41
+ // `null` is "not known" (a daemon too old to say), which is never held against a version.
42
+ export interface ProbationLook {
43
+ // The container (the Fly machine) is running, and whether it is on its way down or up.
44
+ readonly running: boolean;
45
+ readonly restarting: boolean;
46
+ // How many times the container has restarted: past `crashLoopRestarts` it is a crash loop on its own.
47
+ readonly restarts: number;
48
+ // `/health` answered with a document.
49
+ readonly answered: boolean;
50
+ // `boot.ready` (or an older daemon's top-level `ready`).
51
+ readonly ready: boolean | null;
52
+ // `state.journal`: open, none, failed.
53
+ readonly journal: string | null;
54
+ // `reach.state`: off, checking, reachable, unreachable.
55
+ readonly reach: string | null;
56
+ // What the daemon wrote to /history/boot-failure.json after the swap began.
57
+ readonly bootFailure: string | null;
58
+ // How many times the watch has seen the daemon start again inside a running container.
59
+ readonly daemonRestarts: number;
60
+ // netd's own word that the daemon keeps crashing, already in words.
61
+ readonly crashLoop: string | null;
62
+ // The owner stopped it on purpose: there is nothing to judge until it runs.
63
+ readonly held: boolean;
64
+ }
65
+
66
+ // The facts of the swap a look is judged against: when the new version began, and how the old one's tunnel read.
67
+ export interface ProbationSwap {
68
+ readonly at: number;
69
+ // Held only to a tunnel the old version had.
70
+ readonly reach: string | null;
71
+ }
72
+
73
+ export type ProbationVerdict =
74
+ // The strikes start over.
75
+ | { readonly kind: "healthy" }
76
+ // Too early to hold anything against it.
77
+ | { readonly kind: "pending" }
78
+ // One failed look.
79
+ | { readonly kind: "strike"; readonly reason: string }
80
+ // Nothing to wait for: go back now.
81
+ | { readonly kind: "fail"; readonly reason: string };
82
+
83
+ const strike = (reason: string): ProbationVerdict => ({ kind: "strike", reason });
84
+ const fail = (reason: string): ProbationVerdict => ({ kind: "fail", reason });
85
+ const PENDING: ProbationVerdict = { kind: "pending" };
86
+
87
+ // The condition a stopped-on-purpose, failed-boot, failed-conversion or crash-looping version is judged by, before the
88
+ // slower ones. Split out so `judgeProbation` reads as the list of what it is judged on, in order.
89
+ const failure = (look: ProbationLook): ProbationVerdict | undefined => {
90
+ if (look.bootFailure !== null) {
91
+ return fail(`the new version could not start: ${look.bootFailure}`);
92
+ }
93
+ if (look.journal === "failed") {
94
+ return fail("the new version could not convert this sandbox's stored files; it put them back as they were");
95
+ }
96
+ if (look.daemonRestarts >= UPDATE_PROBATION.crashLoopRestarts) {
97
+ return fail(`the new version's daemon kept crashing (${look.daemonRestarts} restarts)`);
98
+ }
99
+ if (look.crashLoop !== null) {
100
+ return fail(`the new version's daemon kept crashing: ${look.crashLoop}`);
101
+ }
102
+ return undefined;
103
+ };
104
+
105
+ // Held only to a tunnel the old version had: a sandbox reached on loopback alone has none to lose.
106
+ const tunnel = (look: ProbationLook, swap: ProbationSwap, since: number, graceMs: number): ProbationVerdict | undefined => {
107
+ if (swap.reach !== "reachable") {
108
+ return undefined;
109
+ }
110
+ if (look.reach === "unreachable" && since >= graceMs) {
111
+ return strike("the new version cannot be reached through its tunnel");
112
+ }
113
+ if (look.reach === "checking" && since >= graceMs + TUNNEL_EXTRA_MS) {
114
+ return strike("the new version's tunnel never came up");
115
+ }
116
+ return look.reach === "unreachable" || look.reach === "checking" ? PENDING : undefined;
117
+ };
118
+
119
+ // The judgement, pure over one look, the swap and the clock: ic's `judge` (probation.rs), reason for reason.
120
+ export const judgeProbation = (look: ProbationLook, swap: ProbationSwap, now: number, graceMs: number = UPDATE_PROBATION.graceMs): ProbationVerdict => {
121
+ const since = Math.max(0, now - swap.at);
122
+ const late = since >= graceMs;
123
+ // Stopped on purpose: there is nothing to judge, and going back would start a sandbox its owner stopped.
124
+ if (look.held && !look.running && !look.restarting) {
125
+ return PENDING;
126
+ }
127
+ const failed = failure(look);
128
+ if (failed !== undefined) {
129
+ return failed;
130
+ }
131
+ if (!look.running || look.restarting) {
132
+ return look.restarts >= UPDATE_PROBATION.crashLoopRestarts
133
+ ? fail(`the new version kept crashing (${look.restarts} restarts)`)
134
+ : strike("the new version's container is not running");
135
+ }
136
+ if (!look.answered) {
137
+ return late ? strike("the new version's daemon is not answering") : PENDING;
138
+ }
139
+ if (look.journal === "open") {
140
+ return since >= UPDATE_PROBATION.journalMs ? strike("the new version is still converting stored files after 15 minutes") : PENDING;
141
+ }
142
+ if (look.ready === false) {
143
+ return late ? strike("the new version never finished starting up") : PENDING;
144
+ }
145
+ return tunnel(look, swap, since, graceMs) ?? { kind: "healthy" };
146
+ };
147
+
148
+ // What the watch does with a verdict: go back, keep watching with the strikes as they now stand, or let the old version
149
+ // go because the probation is over.
150
+ export type ProbationStep =
151
+ | { readonly action: "roll-back"; readonly reason: string }
152
+ | { readonly action: "watch"; readonly strikes: number; readonly reason?: string }
153
+ | { readonly action: "keep" };
154
+
155
+ // ic's judge_probation, as a decision over the verdict, the strikes so far and whether the probation is over. Pure.
156
+ export const probationStep = (verdict: ProbationVerdict, strikes: number, over: boolean): ProbationStep => {
157
+ switch (verdict.kind) {
158
+ case "fail":
159
+ return { action: "roll-back", reason: verdict.reason };
160
+ case "strike":
161
+ return strikes + 1 >= UPDATE_PROBATION.strikes
162
+ ? { action: "roll-back", reason: verdict.reason }
163
+ : { action: "watch", strikes: strikes + 1, reason: verdict.reason };
164
+ case "healthy":
165
+ return over ? { action: "keep" } : { action: "watch", strikes: 0 };
166
+ case "pending":
167
+ return over ? { action: "keep" } : { action: "watch", strikes };
168
+ }
169
+ };
@@ -209,10 +209,13 @@ const STATE_FILES = [
209
209
  why: "A guard's own scratch file; the daemon never reads it and nothing in the browser renders it.",
210
210
  portability: "carry",
211
211
  },
212
+ // Where releases before 2026-10-12 kept the wakes automations held for a person. They now sit on the history volume
213
+ // (`automation-holds/`, history-state.ts), off the workspace every turn writes, and nothing reads this directory; a
214
+ // build that read it is only ever rolled back to. Declared still, since the extension that listed them binds it.
212
215
  {
213
216
  path: ".intentic/records/approvals/",
214
217
  invalidates: [],
215
- why: "Declared by the intentic.approvals extension's contributes.files (the page that lists held wakes), `automation-approvals` is its query key, not core's.",
218
+ why: "Where releases before 2026-10-12 kept the wakes automations held for a person; they now sit on the history volume, and nothing reads this copy. The intentic.approvals extension's contributes.files still binds it, with `automation-approvals` as its query key, not core's.",
216
219
  portability: "carry",
217
220
  },
218
221
  // Bug-report inbox, one file per fingerprint (daemon's issues-store), rendered by intentic.issues. `carry`: a fact