@intentic/sandbox-contract 1.343.0 → 1.345.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 (325) hide show
  1. package/README.md +10 -0
  2. package/dist/contracts/accounts.contract.d.ts +24 -0
  3. package/dist/contracts/accounts.contract.d.ts.map +1 -1
  4. package/dist/contracts/agent.contract.d.ts +214 -6
  5. package/dist/contracts/agent.contract.d.ts.map +1 -1
  6. package/dist/contracts/agent.contract.js +13 -2
  7. package/dist/contracts/agent.contract.js.map +1 -1
  8. package/dist/contracts/agents.contract.d.ts +656 -0
  9. package/dist/contracts/agents.contract.d.ts.map +1 -1
  10. package/dist/contracts/agents.contract.js +5 -3
  11. package/dist/contracts/agents.contract.js.map +1 -1
  12. package/dist/contracts/device.contract.d.ts +259 -0
  13. package/dist/contracts/device.contract.d.ts.map +1 -1
  14. package/dist/contracts/device.contract.js +5 -1
  15. package/dist/contracts/device.contract.js.map +1 -1
  16. package/dist/contracts/endpoints.contract.d.ts +2 -0
  17. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  18. package/dist/contracts/endpoints.contract.js +1 -0
  19. package/dist/contracts/endpoints.contract.js.map +1 -1
  20. package/dist/contracts/push.contract.d.ts +11 -0
  21. package/dist/contracts/push.contract.d.ts.map +1 -1
  22. package/dist/contracts/push.contract.js +10 -1
  23. package/dist/contracts/push.contract.js.map +1 -1
  24. package/dist/contracts/runner.contract.d.ts +18 -2
  25. package/dist/contracts/runner.contract.d.ts.map +1 -1
  26. package/dist/contracts/secrets.contract.d.ts +169 -0
  27. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  28. package/dist/contracts/secrets.contract.js +35 -0
  29. package/dist/contracts/secrets.contract.js.map +1 -1
  30. package/dist/contracts/sessions.contract.d.ts +2 -0
  31. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  32. package/dist/contracts/settings.contract.d.ts +37 -2
  33. package/dist/contracts/settings.contract.d.ts.map +1 -1
  34. package/dist/contracts/system.contract.d.ts +185 -35
  35. package/dist/contracts/system.contract.d.ts.map +1 -1
  36. package/dist/contracts/translator.contract.d.ts +24 -0
  37. package/dist/contracts/translator.contract.d.ts.map +1 -1
  38. package/dist/contracts/workspace.contract.d.ts +11 -0
  39. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  40. package/dist/contracts/workspace.contract.js +11 -1
  41. package/dist/contracts/workspace.contract.js.map +1 -1
  42. package/dist/events/agent-events.d.ts +18 -0
  43. package/dist/events/agent-events.d.ts.map +1 -1
  44. package/dist/events/agent-events.js +3 -1
  45. package/dist/events/agent-events.js.map +1 -1
  46. package/dist/events/agent-transcript.d.ts +727 -0
  47. package/dist/events/agent-transcript.d.ts.map +1 -0
  48. package/dist/events/agent-transcript.js +34 -0
  49. package/dist/events/agent-transcript.js.map +1 -0
  50. package/dist/events/agent-words.d.ts +5 -0
  51. package/dist/events/agent-words.d.ts.map +1 -1
  52. package/dist/events/agent-words.js +9 -0
  53. package/dist/events/agent-words.js.map +1 -1
  54. package/dist/events/requests.d.ts +143 -0
  55. package/dist/events/requests.d.ts.map +1 -1
  56. package/dist/events/requests.js +35 -1
  57. package/dist/events/requests.js.map +1 -1
  58. package/dist/events/sandbox-notice.d.ts +7 -0
  59. package/dist/events/sandbox-notice.d.ts.map +1 -1
  60. package/dist/events/sandbox-notice.js +1 -0
  61. package/dist/events/sandbox-notice.js.map +1 -1
  62. package/dist/events/system-events.d.ts +88 -0
  63. package/dist/events/system-events.d.ts.map +1 -1
  64. package/dist/events/transcript.d.ts +20 -720
  65. package/dist/events/transcript.d.ts.map +1 -1
  66. package/dist/events/transcript.js +17 -19
  67. package/dist/events/transcript.js.map +1 -1
  68. package/dist/events/undelivered.d.ts +4 -0
  69. package/dist/events/undelivered.d.ts.map +1 -1
  70. package/dist/events/undelivered.js +13 -0
  71. package/dist/events/undelivered.js.map +1 -1
  72. package/dist/ids/conversation-ids.d.ts +2 -0
  73. package/dist/ids/conversation-ids.d.ts.map +1 -1
  74. package/dist/ids/conversation-ids.js +2 -0
  75. package/dist/ids/conversation-ids.js.map +1 -1
  76. package/dist/ids/row-ids.d.ts +3 -0
  77. package/dist/ids/row-ids.d.ts.map +1 -0
  78. package/dist/ids/row-ids.js +11 -0
  79. package/dist/ids/row-ids.js.map +1 -0
  80. package/dist/index.d.ts +1339 -48
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/models/agent-lanes.d.ts.map +1 -1
  83. package/dist/models/agent-lanes.js +3 -2
  84. package/dist/models/agent-lanes.js.map +1 -1
  85. package/dist/models/agent-standing.d.ts +4 -0
  86. package/dist/models/agent-standing.d.ts.map +1 -1
  87. package/dist/models/agent-standing.js +2 -1
  88. package/dist/models/agent-standing.js.map +1 -1
  89. package/dist/models/plan-pools.d.ts +5 -1
  90. package/dist/models/plan-pools.d.ts.map +1 -1
  91. package/dist/models/plan-pools.js +57 -21
  92. package/dist/models/plan-pools.js.map +1 -1
  93. package/dist/netd/generated/wire.d.ts +5 -0
  94. package/dist/netd/generated/wire.d.ts.map +1 -1
  95. package/dist/policy/command-classes.d.ts +1 -1
  96. package/dist/policy/command-classes.d.ts.map +1 -1
  97. package/dist/policy/command-classes.js +2 -2
  98. package/dist/policy/command-classes.js.map +1 -1
  99. package/dist/policy/keyring-origins.d.ts +27 -0
  100. package/dist/policy/keyring-origins.d.ts.map +1 -0
  101. package/dist/policy/keyring-origins.js +213 -0
  102. package/dist/policy/keyring-origins.js.map +1 -0
  103. package/dist/policy/keyring-seal.d.ts +9 -0
  104. package/dist/policy/keyring-seal.d.ts.map +1 -1
  105. package/dist/policy/keyring-seal.js +18 -6
  106. package/dist/policy/keyring-seal.js.map +1 -1
  107. package/dist/policy/safety-policy.d.ts +1 -1
  108. package/dist/policy/safety-policy.d.ts.map +1 -1
  109. package/dist/policy/safety-policy.js +1 -1
  110. package/dist/policy/safety-policy.js.map +1 -1
  111. package/dist/policy/turned-away.d.ts +1 -0
  112. package/dist/policy/turned-away.d.ts.map +1 -1
  113. package/dist/policy/turned-away.js +11 -0
  114. package/dist/policy/turned-away.js.map +1 -1
  115. package/dist/protocol/doors/host-protocol.d.ts +1 -0
  116. package/dist/protocol/doors/host-protocol.d.ts.map +1 -1
  117. package/dist/protocol/doors/host-protocol.js +1 -0
  118. package/dist/protocol/doors/host-protocol.js.map +1 -1
  119. package/dist/protocol/doors/webext-links.d.ts +1 -0
  120. package/dist/protocol/doors/webext-links.d.ts.map +1 -1
  121. package/dist/protocol/doors/webext-links.js +1 -0
  122. package/dist/protocol/doors/webext-links.js.map +1 -1
  123. package/dist/protocol/peer-dial.d.ts +2 -0
  124. package/dist/protocol/peer-dial.d.ts.map +1 -1
  125. package/dist/protocol/peer-dial.js +62 -29
  126. package/dist/protocol/peer-dial.js.map +1 -1
  127. package/dist/protocol/raw/raw-json-routes.d.ts +193 -0
  128. package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
  129. package/dist/protocol/raw/raw-json-routes.js +5 -0
  130. package/dist/protocol/raw/raw-json-routes.js.map +1 -1
  131. package/dist/protocol/raw/raw-routes.d.ts +31 -0
  132. package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
  133. package/dist/protocol/raw/raw-routes.js +8 -0
  134. package/dist/protocol/raw/raw-routes.js.map +1 -1
  135. package/dist/schemas/agent-place.d.ts +10 -0
  136. package/dist/schemas/agent-place.d.ts.map +1 -0
  137. package/dist/schemas/agent-place.js +15 -0
  138. package/dist/schemas/agent-place.js.map +1 -0
  139. package/dist/schemas/agent.d.ts +116 -83
  140. package/dist/schemas/agent.d.ts.map +1 -1
  141. package/dist/schemas/agent.js +26 -39
  142. package/dist/schemas/agent.js.map +1 -1
  143. package/dist/schemas/agents-list.d.ts +38 -0
  144. package/dist/schemas/agents-list.d.ts.map +1 -1
  145. package/dist/schemas/agents.d.ts +117 -16
  146. package/dist/schemas/agents.d.ts.map +1 -1
  147. package/dist/schemas/agents.js +19 -21
  148. package/dist/schemas/agents.js.map +1 -1
  149. package/dist/schemas/attach.d.ts +8 -0
  150. package/dist/schemas/attach.d.ts.map +1 -0
  151. package/dist/schemas/attach.js +14 -0
  152. package/dist/schemas/attach.js.map +1 -0
  153. package/dist/schemas/command-admission.d.ts +71 -0
  154. package/dist/schemas/command-admission.d.ts.map +1 -0
  155. package/dist/schemas/command-admission.js +27 -0
  156. package/dist/schemas/command-admission.js.map +1 -0
  157. package/dist/schemas/conversation-links.d.ts +44 -0
  158. package/dist/schemas/conversation-links.d.ts.map +1 -0
  159. package/dist/schemas/conversation-links.js +35 -0
  160. package/dist/schemas/conversation-links.js.map +1 -0
  161. package/dist/schemas/data-protection.d.ts +5 -2
  162. package/dist/schemas/data-protection.d.ts.map +1 -1
  163. package/dist/schemas/data-protection.js +17 -1
  164. package/dist/schemas/data-protection.js.map +1 -1
  165. package/dist/schemas/device-folder.d.ts +61 -0
  166. package/dist/schemas/device-folder.d.ts.map +1 -0
  167. package/dist/schemas/device-folder.js +24 -0
  168. package/dist/schemas/device-folder.js.map +1 -0
  169. package/dist/schemas/devices.d.ts +112 -39
  170. package/dist/schemas/devices.d.ts.map +1 -1
  171. package/dist/schemas/devices.js +8 -10
  172. package/dist/schemas/devices.js.map +1 -1
  173. package/dist/schemas/hosts.d.ts +2 -0
  174. package/dist/schemas/hosts.d.ts.map +1 -1
  175. package/dist/schemas/hosts.js +2 -0
  176. package/dist/schemas/hosts.js.map +1 -1
  177. package/dist/schemas/keyring-native.d.ts +137 -0
  178. package/dist/schemas/keyring-native.d.ts.map +1 -0
  179. package/dist/schemas/keyring-native.js +69 -0
  180. package/dist/schemas/keyring-native.js.map +1 -0
  181. package/dist/schemas/keyring.d.ts +413 -0
  182. package/dist/schemas/keyring.d.ts.map +1 -1
  183. package/dist/schemas/keyring.js +139 -4
  184. package/dist/schemas/keyring.js.map +1 -1
  185. package/dist/schemas/metrics.d.ts +11 -0
  186. package/dist/schemas/metrics.d.ts.map +1 -1
  187. package/dist/schemas/metrics.js +17 -0
  188. package/dist/schemas/metrics.js.map +1 -1
  189. package/dist/schemas/phone-passwords.d.ts +186 -0
  190. package/dist/schemas/phone-passwords.d.ts.map +1 -0
  191. package/dist/schemas/phone-passwords.js +25 -0
  192. package/dist/schemas/phone-passwords.js.map +1 -0
  193. package/dist/schemas/providers/plan-limits.d.ts +74 -0
  194. package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
  195. package/dist/schemas/providers/plan-limits.js +31 -0
  196. package/dist/schemas/providers/plan-limits.js.map +1 -1
  197. package/dist/schemas/providers/provider-oauth.d.ts +24 -0
  198. package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
  199. package/dist/schemas/providers/usage.d.ts +1 -0
  200. package/dist/schemas/providers/usage.d.ts.map +1 -1
  201. package/dist/schemas/providers/usage.js +1 -0
  202. package/dist/schemas/providers/usage.js.map +1 -1
  203. package/dist/schemas/push.d.ts +13 -0
  204. package/dist/schemas/push.d.ts.map +1 -1
  205. package/dist/schemas/push.js +11 -1
  206. package/dist/schemas/push.js.map +1 -1
  207. package/dist/schemas/sandbox-passwords.d.ts +288 -0
  208. package/dist/schemas/sandbox-passwords.d.ts.map +1 -0
  209. package/dist/schemas/sandbox-passwords.js +66 -0
  210. package/dist/schemas/sandbox-passwords.js.map +1 -0
  211. package/dist/schemas/secrets.d.ts +2 -0
  212. package/dist/schemas/secrets.d.ts.map +1 -1
  213. package/dist/schemas/secrets.js +4 -0
  214. package/dist/schemas/secrets.js.map +1 -1
  215. package/dist/schemas/settings-history.d.ts +1 -1
  216. package/dist/schemas/settings-history.d.ts.map +1 -1
  217. package/dist/schemas/settings-history.js +15 -0
  218. package/dist/schemas/settings-history.js.map +1 -1
  219. package/dist/schemas/settings-savings.d.ts +709 -0
  220. package/dist/schemas/settings-savings.d.ts.map +1 -0
  221. package/dist/schemas/settings-savings.js +53 -0
  222. package/dist/schemas/settings-savings.js.map +1 -0
  223. package/dist/schemas/settings.d.ts +7 -673
  224. package/dist/schemas/settings.d.ts.map +1 -1
  225. package/dist/schemas/settings.js +7 -58
  226. package/dist/schemas/settings.js.map +1 -1
  227. package/dist/schemas/terminal.d.ts +5 -0
  228. package/dist/schemas/terminal.d.ts.map +1 -1
  229. package/dist/schemas/terminal.js +4 -0
  230. package/dist/schemas/terminal.js.map +1 -1
  231. package/dist/schemas/work-assurance.d.ts +24 -0
  232. package/dist/schemas/work-assurance.d.ts.map +1 -0
  233. package/dist/schemas/work-assurance.js +23 -0
  234. package/dist/schemas/work-assurance.js.map +1 -0
  235. package/dist/schemas/workspace/workspace-tree.d.ts +23 -0
  236. package/dist/schemas/workspace/workspace-tree.d.ts.map +1 -1
  237. package/dist/schemas/workspace/workspace-tree.js +20 -0
  238. package/dist/schemas/workspace/workspace-tree.js.map +1 -1
  239. package/dist/state/auth-state.d.ts +4 -0
  240. package/dist/state/auth-state.d.ts.map +1 -0
  241. package/dist/state/auth-state.js +37 -0
  242. package/dist/state/auth-state.js.map +1 -0
  243. package/dist/state/definition.d.ts +12 -4
  244. package/dist/state/definition.d.ts.map +1 -1
  245. package/dist/state/history-state.d.ts.map +1 -1
  246. package/dist/state/history-state.js +17 -1
  247. package/dist/state/history-state.js.map +1 -1
  248. package/dist/state/runtime-state.d.ts.map +1 -1
  249. package/dist/state/runtime-state.js.map +1 -1
  250. package/dist/text/transcript-fold.js +5 -5
  251. package/dist/text/transcript-fold.js.map +1 -1
  252. package/dist/webext/index.d.ts +1 -0
  253. package/dist/webext/index.d.ts.map +1 -1
  254. package/dist/webext/index.js +1 -0
  255. package/dist/webext/index.js.map +1 -1
  256. package/package.json +6 -5
  257. package/src/contracts/agent.contract.ts +13 -1
  258. package/src/contracts/agents.contract.ts +4 -3
  259. package/src/contracts/device.contract.ts +16 -0
  260. package/src/contracts/endpoints.contract.ts +3 -0
  261. package/src/contracts/push.contract.ts +23 -1
  262. package/src/contracts/secrets.contract.ts +39 -0
  263. package/src/contracts/workspace.contract.ts +16 -0
  264. package/src/events/agent-events.ts +6 -1
  265. package/src/events/agent-transcript.ts +48 -0
  266. package/src/events/agent-words.ts +13 -0
  267. package/src/events/requests.ts +41 -3
  268. package/src/events/sandbox-notice.ts +3 -0
  269. package/src/events/transcript.ts +30 -28
  270. package/src/events/undelivered.ts +18 -0
  271. package/src/ids/conversation-ids.ts +7 -0
  272. package/src/ids/row-ids.ts +20 -0
  273. package/src/models/agent-lanes.ts +9 -3
  274. package/src/models/agent-standing.ts +9 -1
  275. package/src/models/plan-pools.test.ts +128 -20
  276. package/src/models/plan-pools.ts +114 -31
  277. package/src/netd/generated/netd-wire.json +25 -0
  278. package/src/netd/generated/wire.ts +1 -1
  279. package/src/policy/command-classes.ts +2 -2
  280. package/src/policy/keyring-origins.test.ts +132 -0
  281. package/src/policy/keyring-origins.ts +287 -0
  282. package/src/policy/keyring-seal.test.ts +37 -2
  283. package/src/policy/keyring-seal.ts +33 -7
  284. package/src/policy/safety-policy.ts +1 -1
  285. package/src/policy/tests/safety-policy.test.ts +1 -1
  286. package/src/policy/turned-away.ts +16 -0
  287. package/src/protocol/doors/host-protocol.ts +3 -0
  288. package/src/protocol/doors/webext-links.ts +3 -0
  289. package/src/protocol/peer-dial.test.ts +90 -4
  290. package/src/protocol/peer-dial.ts +83 -39
  291. package/src/protocol/raw/raw-json-routes.ts +13 -0
  292. package/src/protocol/raw/raw-routes.ts +12 -1
  293. package/src/schemas/agent-place.ts +22 -0
  294. package/src/schemas/agent.ts +42 -78
  295. package/src/schemas/agents.ts +41 -46
  296. package/src/schemas/attach.ts +23 -0
  297. package/src/schemas/command-admission.ts +57 -0
  298. package/src/schemas/conversation-links.ts +62 -0
  299. package/src/schemas/data-protection.ts +36 -10
  300. package/src/schemas/device-folder.ts +59 -0
  301. package/src/schemas/devices.ts +17 -42
  302. package/src/schemas/hosts.ts +4 -0
  303. package/src/schemas/keyring-native.test.ts +13 -0
  304. package/src/schemas/keyring-native.ts +101 -0
  305. package/src/schemas/keyring.ts +202 -4
  306. package/src/schemas/metrics.ts +28 -3
  307. package/src/schemas/phone-passwords.ts +51 -0
  308. package/src/schemas/providers/plan-limits.ts +49 -2
  309. package/src/schemas/providers/usage.ts +2 -0
  310. package/src/schemas/push.ts +19 -3
  311. package/src/schemas/sandbox-passwords.ts +88 -0
  312. package/src/schemas/secrets.ts +4 -0
  313. package/src/schemas/settings-history.ts +20 -0
  314. package/src/schemas/settings-savings.ts +125 -0
  315. package/src/schemas/settings.test.ts +38 -0
  316. package/src/schemas/settings.ts +20 -128
  317. package/src/schemas/terminal.ts +8 -0
  318. package/src/schemas/work-assurance.ts +36 -0
  319. package/src/schemas/workspace/workspace-tree.ts +32 -0
  320. package/src/state/auth-state.ts +89 -0
  321. package/src/state/history-state.ts +44 -1
  322. package/src/state/runtime-state.ts +5 -4
  323. package/src/text/transcript-fold.test.ts +67 -0
  324. package/src/text/transcript-fold.ts +6 -6
  325. package/src/webext/index.ts +2 -0
@@ -0,0 +1,287 @@
1
+ import { KEYRING_NAME_RE, type KeyringCaptureSource, type KeyringOrigin, type KeyringRemember } from "../schemas/keyring.js";
2
+
3
+ // WHERE A CREDENTIAL BELONGS, in the two shapes the product keeps it in: an ORIGIN (schemas/keyring.ts), which is how a
4
+ // capture, an agent's need and the Passwords app speak of it ("github.com", "ssh radar@build-01"), and an entry's RULES
5
+ // (programs, packages, hosts, askpass matchers), which is how the keyring decides where a value may go. One file maps
6
+ // each onto the other, names and titles a new entry, and finds the entry a capture updates, so the keyring, the
7
+ // sandbox and the app agree on all four. Pure: no crypto, no I/O, safe in a browser bundle.
8
+
9
+ /** The parts of an entry's rules an origin speaks to. */
10
+ export interface OriginRules {
11
+ readonly programs: readonly string[];
12
+ readonly packages: readonly string[];
13
+ readonly hosts: readonly string[];
14
+ readonly askpass: readonly string[];
15
+ }
16
+
17
+ const EMPTY: OriginRules = { programs: [], packages: [], hosts: [], askpass: [] };
18
+
19
+ /** A host as two captures of the same site agree on it: lower case, no trailing dot, no leading `www.`. */
20
+ export const normalizeHost = (host: string): string =>
21
+ host
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/\.$/u, "")
25
+ .replace(/^www\./u, "");
26
+
27
+ // A program by its executable's file name, as Windows compares them: without case, without its folder.
28
+ const programName = (program: string): string => (program.split(/[\\/]/u).pop() ?? program).toLowerCase();
29
+
30
+ const hostOfUrl = (url: string): string | undefined => {
31
+ try {
32
+ return normalizeHost(new URL(url).host);
33
+ } catch {
34
+ // allow(silent-catch): a git remote named without a scheme is not an address to take a host from
35
+ return undefined;
36
+ }
37
+ };
38
+
39
+ /** `https://github.com/org/repo` → `https://github.com`: git asks per host, and an entry answers for all its repos. */
40
+ export const gitOrigin = (url: string): string => {
41
+ try {
42
+ const parsed = new URL(url);
43
+ return `${parsed.protocol}//${normalizeHost(parsed.host)}`;
44
+ } catch {
45
+ // allow(silent-catch): kept as written; it still matches itself
46
+ return url;
47
+ }
48
+ };
49
+
50
+ /** One string per place, so two captures of the same place compare equal. */
51
+ export const originKey = (origin: KeyringOrigin): string => {
52
+ switch (origin.kind) {
53
+ case "site":
54
+ return `site:${normalizeHost(origin.host)}`;
55
+ case "program":
56
+ return `program:${programName(origin.program)}`;
57
+ case "package":
58
+ return `package:${origin.package}`;
59
+ case "ssh":
60
+ return `ssh:${origin.target.toLowerCase()}`;
61
+ case "ssh-key":
62
+ return `ssh-key:${origin.path}`;
63
+ case "sudo":
64
+ return `sudo:${origin.user ?? ""}`;
65
+ case "git":
66
+ return `git:${gitOrigin(origin.url)}`;
67
+ case "prompt":
68
+ return `prompt:${origin.sandbox ?? ""}:${origin.command ?? ""}:${origin.text.trim()}`;
69
+ }
70
+ };
71
+
72
+ /** The rules an entry needs to be used where `origin` is: what a captured credential's entry gets without anyone typing it. */
73
+ export const rulesForOrigin = (origin: KeyringOrigin): OriginRules => {
74
+ switch (origin.kind) {
75
+ case "site":
76
+ return { ...EMPTY, hosts: [normalizeHost(origin.host)] };
77
+ case "program":
78
+ return { ...EMPTY, programs: [origin.program] };
79
+ case "package":
80
+ return { ...EMPTY, packages: [origin.package] };
81
+ case "ssh":
82
+ return { ...EMPTY, askpass: [`ssh:${origin.target}`] };
83
+ case "ssh-key":
84
+ return { ...EMPTY, askpass: [`ssh-key:${origin.path}`] };
85
+ case "sudo":
86
+ return { ...EMPTY, askpass: [origin.user === undefined ? "sudo" : `sudo:${origin.user}`] };
87
+ case "git": {
88
+ const host = hostOfUrl(origin.url);
89
+ return { ...EMPTY, askpass: [`git:${gitOrigin(origin.url)}`], hosts: host === undefined ? [] : [host] };
90
+ }
91
+ case "prompt":
92
+ // A sandbox terminal's prompt is no rule on this computer: the entry is found by its title, and shared on a need.
93
+ return origin.sandbox === undefined ? { ...EMPTY, askpass: [`prompt:${origin.text.trim()}`] } : EMPTY;
94
+ }
95
+ };
96
+
97
+ /** Two sets of rules as one, each list without repeats: how a second place is added to an entry. */
98
+ export const mergeRules = (a: OriginRules, b: OriginRules): OriginRules => {
99
+ const union = (x: readonly string[], y: readonly string[]): string[] => [...new Set([...x, ...y])];
100
+ return { programs: union(a.programs, b.programs), packages: union(a.packages, b.packages), hosts: union(a.hosts, b.hosts), askpass: union(a.askpass, b.askpass) };
101
+ };
102
+
103
+ // An askpass matcher back into the origin it was made from; undefined for one this file did not make.
104
+ const originOfMatcher = (matcher: string): KeyringOrigin | undefined => {
105
+ const colon = matcher.indexOf(":");
106
+ const head = colon === -1 ? matcher : matcher.slice(0, colon);
107
+ const rest = colon === -1 ? "" : matcher.slice(colon + 1);
108
+ switch (head) {
109
+ case "ssh":
110
+ return rest === "" ? undefined : { kind: "ssh", target: rest };
111
+ case "ssh-key":
112
+ return rest === "" ? undefined : { kind: "ssh-key", path: rest };
113
+ case "sudo":
114
+ return rest === "" ? { kind: "sudo" } : { kind: "sudo", user: rest };
115
+ case "git":
116
+ return rest === "" ? undefined : { kind: "git", url: rest };
117
+ case "prompt":
118
+ return rest === "" ? undefined : { kind: "prompt", text: rest };
119
+ default:
120
+ return undefined;
121
+ }
122
+ };
123
+
124
+ /** Every place an entry's rules say it is used, for the app's "Works on" chips. A `*.domain` host reads as the domain. */
125
+ export const originsOfRules = (rules: OriginRules): KeyringOrigin[] => {
126
+ const fromAskpass = rules.askpass.map(originOfMatcher).filter((origin): origin is KeyringOrigin => origin !== undefined);
127
+ // A git entry's host is said by its `git:` matcher; listing the site again would say it twice.
128
+ const gitHosts = new Set(fromAskpass.flatMap((origin) => (origin.kind === "git" ? [hostOfUrl(origin.url)] : [])));
129
+ return [
130
+ ...rules.hosts.map((host) => host.replace(/^\*\./u, "")).filter((host) => !gitHosts.has(normalizeHost(host))).map((host): KeyringOrigin => ({ kind: "site", host })),
131
+ ...rules.programs.map((program): KeyringOrigin => ({ kind: "program", program })),
132
+ ...rules.packages.map((pkg): KeyringOrigin => ({ kind: "package", package: pkg })),
133
+ ...fromAskpass,
134
+ ];
135
+ };
136
+
137
+ const hostServes = (host: string, pattern: string): boolean => {
138
+ const want = normalizeHost(host);
139
+ const wildcard = pattern.startsWith("*.");
140
+ const base = normalizeHost(wildcard ? pattern.slice(2) : pattern);
141
+ // A saved `github.com` also fills `gist.github.com`, as every password manager's site matching does.
142
+ return want === base || want.endsWith(`.${base}`);
143
+ };
144
+
145
+ /** Whether an entry with these rules is the one for `origin`: the test a need, a fill and a capture all use. */
146
+ export const rulesServe = (rules: OriginRules, origin: KeyringOrigin): boolean => {
147
+ switch (origin.kind) {
148
+ case "site":
149
+ return rules.hosts.some((pattern) => hostServes(origin.host, pattern));
150
+ case "program": {
151
+ const want = programName(origin.program);
152
+ return rules.programs.some((program) => programName(program) === want);
153
+ }
154
+ case "package":
155
+ return rules.packages.includes(origin.package);
156
+ case "git": {
157
+ const want = `git:${gitOrigin(origin.url)}`;
158
+ const host = hostOfUrl(origin.url);
159
+ return rules.askpass.some((matcher) => matcher.startsWith("git:") && `git:${gitOrigin(matcher.slice(4))}` === want) || (host !== undefined && rules.hosts.some((pattern) => hostServes(host, pattern)));
160
+ }
161
+ case "ssh":
162
+ case "ssh-key":
163
+ case "sudo":
164
+ case "prompt": {
165
+ const wanted = rulesForOrigin(origin).askpass;
166
+ return wanted.length > 0 && rules.askpass.some((matcher) => wanted.includes(matcher));
167
+ }
168
+ }
169
+ };
170
+
171
+ const sameUser = (a: string | undefined, b: string | undefined): boolean => (a ?? "").trim().toLowerCase() === (b ?? "").trim().toLowerCase();
172
+
173
+ /** What a capture is compared against: each entry's name, the rules that place it, and the account it signs in as. */
174
+ export interface CaptureCandidate {
175
+ readonly name: string;
176
+ readonly rules: OriginRules;
177
+ readonly username?: string | undefined;
178
+ readonly title?: string | undefined;
179
+ }
180
+
181
+ /**
182
+ * The entry a capture updates: one used at the same place for the same account. Undefined: the capture is a new entry,
183
+ * even where the place has entries for other accounts (a second GitHub login is a second entry, not an overwrite).
184
+ */
185
+ export const findCaptureTarget = <T extends CaptureCandidate>(entries: readonly T[], origin: KeyringOrigin, username: string | undefined): T | undefined =>
186
+ entries.find((entry) => rulesServe(entry.rules, origin) && sameUser(entry.username, username)) ??
187
+ // A sandbox terminal's prompt places nothing on this computer, so its entry is found by the title it was given.
188
+ (origin.kind === "prompt" && origin.sandbox !== undefined ? entries.find((entry) => entry.title === titleForOrigin(origin) && sameUser(entry.username, username)) : undefined);
189
+
190
+ /** Entries that serve `origin`, the given account first: what a need, a browser's or a phone's fill offers. */
191
+ export const entriesFor = <T extends CaptureCandidate>(entries: readonly T[], origin: KeyringOrigin, username?: string): T[] => {
192
+ const serving = entries.filter((entry) => rulesServe(entry.rules, origin));
193
+ return username === undefined ? serving : [...serving.filter((entry) => sameUser(entry.username, username)), ...serving.filter((entry) => !sameUser(entry.username, username))];
194
+ };
195
+
196
+ const baseName = (path: string): string => path.split(/[\\/]/u).pop() ?? path;
197
+
198
+ /** What a person calls the place: `github.com`, `FortiClient VPN`, `ssh radar@build-01`, `sudo`, `git github.com`. */
199
+ export const titleForOrigin = (origin: KeyringOrigin, given?: string): string => {
200
+ const named = given?.trim();
201
+ if (named !== undefined && named !== "") {
202
+ return named.slice(0, 200);
203
+ }
204
+ switch (origin.kind) {
205
+ case "site":
206
+ return normalizeHost(origin.host);
207
+ case "program":
208
+ return origin.label ?? baseName(origin.program).replace(/\.exe$/iu, "");
209
+ case "package":
210
+ return origin.label ?? origin.package.split(".").pop() ?? origin.package;
211
+ case "ssh":
212
+ return `ssh ${origin.target}`;
213
+ case "ssh-key":
214
+ return `ssh key ${baseName(origin.path)}`;
215
+ case "sudo":
216
+ return origin.user === undefined ? "sudo" : `sudo as ${origin.user}`;
217
+ case "git":
218
+ return `git ${hostOfUrl(origin.url) ?? origin.url}`;
219
+ case "prompt":
220
+ return origin.command ?? origin.text.replace(/[:\s]+$/u, "");
221
+ }
222
+ };
223
+
224
+ const NAME_MAX = 128;
225
+
226
+ // Upper case, every run of anything else one underscore, never starting with a digit: the shape a reference takes.
227
+ const referenceName = (text: string): string => {
228
+ const upper = text
229
+ .normalize("NFKD")
230
+ .replace(/[\u0300-\u036f]/gu, "")
231
+ .toUpperCase()
232
+ .replace(/[^A-Z0-9]+/gu, "_")
233
+ .replace(/^_+|_+$/gu, "");
234
+ const named = upper === "" ? "PASSWORD" : /^[0-9]/u.test(upper) ? `_${upper}` : upper;
235
+ return named.slice(0, NAME_MAX).replace(/_+$/u, "");
236
+ };
237
+
238
+ /**
239
+ * A new entry's name, the `{{secret:NAME}}` an agent writes: from its title (`GITHUB_COM`), then with the account
240
+ * (`GITHUB_COM_RADARSU`) when that is taken, then numbered. Always matches KEYRING_NAME_RE.
241
+ */
242
+ export const entryNameFor = (title: string, username: string | undefined, taken: (name: string) => boolean): string => {
243
+ const base = referenceName(title);
244
+ const tries = [base];
245
+ if (username !== undefined && username.trim() !== "") {
246
+ tries.push(referenceName(`${base}_${username.split("@")[0] ?? username}`));
247
+ }
248
+ for (const name of tries) {
249
+ if (!taken(name) && KEYRING_NAME_RE.test(name)) {
250
+ return name;
251
+ }
252
+ }
253
+ const stem = (tries.at(-1) ?? base).slice(0, NAME_MAX - 6);
254
+ for (let n = 2; ; n++) {
255
+ const name = `${stem}_${String(n)}`;
256
+ if (!taken(name)) {
257
+ return name;
258
+ }
259
+ }
260
+ };
261
+
262
+ /**
263
+ * The remember switch a capture source needs on, or undefined for a deliberate act (an import, an agent's `secrets ask`,
264
+ * adding by hand) that needs none.
265
+ */
266
+ export const rememberSwitchFor = (source: KeyringCaptureSource): keyof KeyringRemember | undefined => {
267
+ switch (source) {
268
+ case "browser":
269
+ return "browser";
270
+ case "askpass":
271
+ case "git":
272
+ return "terminal";
273
+ case "app":
274
+ return "apps";
275
+ case "phone":
276
+ return "phone";
277
+ case "terminal":
278
+ return "sandbox";
279
+ case "import":
280
+ case "agent":
281
+ case "manual":
282
+ return undefined;
283
+ }
284
+ };
285
+
286
+ /** Remembering off everywhere: a vault's settings before the person's one yes. */
287
+ export const REMEMBER_NOTHING: KeyringRemember = { browser: false, terminal: false, apps: false, phone: false, sandbox: false };
@@ -1,5 +1,24 @@
1
- import type { KeyringGrant } from "../schemas/keyring.js";
2
- import { grantNonce, keyFingerprint, newSealKeyPair, newSignKeyPair, seal, signGrant, unseal, verifyGrant } from "./keyring-seal.js";
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { packageRoot } from "@intentic/constants/node";
4
+ import type { KeyringGrant, SealedBox } from "../schemas/keyring.js";
5
+ import { SealedBoxSchema } from "../schemas/keyring.js";
6
+ import { grantNonce, keyFingerprint, newSealKeyPair, newSignKeyPair, seal, sealWithEphemeral, signGrant, unseal, verifyGrant, type SealPurpose } from "./keyring-seal.js";
7
+
8
+ const goldenPath = join(packageRoot(import.meta.url), "golden", "keyring-seal.json");
9
+ const golden = JSON.parse(readFileSync(goldenPath, "utf8")) as {
10
+ recipient: { publicKey: string; privateKey: string };
11
+ phone: { publicKey: string; privateKey: string };
12
+ vectors: Array<{
13
+ name: string;
14
+ plaintext: string;
15
+ purpose: SealPurpose;
16
+ ephemeral: { publicKey: string; privateKey: string };
17
+ iv: string;
18
+ box: SealedBox;
19
+ }>;
20
+ unseal: { phonePlaintext: string; phonePurpose: SealPurpose };
21
+ };
3
22
 
4
23
  const SANDBOX = "https://a1b2c3d4e5f6.intentic.dev";
5
24
 
@@ -91,3 +110,19 @@ describe("grants", () => {
91
110
  test("fingerprints read in groups of four", () => {
92
111
  expect(keyFingerprint(newSealKeyPair().publicKey)).toMatch(/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/u);
93
112
  });
113
+
114
+ describe("golden vectors (shared with the Android app)", () => {
115
+ test.each(golden.vectors)("$name seals to the golden box", ({ name, plaintext, purpose, ephemeral, iv, box }) => {
116
+ const recipient = name === "phone" ? golden.phone.publicKey : golden.recipient.publicKey;
117
+ const rebuilt = sealWithEphemeral(recipient, plaintext, purpose, ephemeral, Buffer.from(iv, "base64url"));
118
+ expect(rebuilt).toEqual(SealedBoxSchema.parse(box));
119
+ });
120
+
121
+ test("the phone opens the golden phone box", () => {
122
+ const phoneVector = golden.vectors.find((vector) => vector.name === "phone");
123
+ if (phoneVector === undefined) {
124
+ throw new Error("the golden file has no phone vector");
125
+ }
126
+ expect(unseal(golden.phone, phoneVector.box, phoneVector.purpose)).toBe(golden.unseal.phonePlaintext);
127
+ });
128
+ });
@@ -51,7 +51,12 @@ export type SealPurpose =
51
51
  | { readonly kind: "type"; readonly sandbox: string; readonly secret: string; readonly nonce: string }
52
52
  | { readonly kind: "inbox"; readonly sandbox: string; readonly secret: string }
53
53
  | { readonly kind: "copy"; readonly sandbox: string; readonly secret: string; readonly updatedAt: number }
54
- | { readonly kind: "sync"; readonly account: string; readonly device: string; readonly revision: number };
54
+ | { readonly kind: "sync"; readonly account: string; readonly device: string; readonly revision: number }
55
+ // A credential seen in use and sent to be kept (schemas/keyring.ts KeyringCaptureItem): bound to who saw it and when,
56
+ // so a box lifted from one capture cannot be replayed as another's.
57
+ | { readonly kind: "capture"; readonly via: string; readonly at: number }
58
+ // A value released to a paired phone for one autofill (KeyringPhoneFill), bound to that request's nonce.
59
+ | { readonly kind: "phone"; readonly device: string; readonly nonce: string };
55
60
 
56
61
  export const sealAad = (purpose: SealPurpose): Buffer => {
57
62
  switch (purpose.kind) {
@@ -64,21 +69,42 @@ export const sealAad = (purpose: SealPurpose): Buffer => {
64
69
  return Buffer.from(`copy\0${purpose.sandbox}\0${purpose.secret}\0${String(purpose.updatedAt)}`, "utf8");
65
70
  case "sync":
66
71
  return Buffer.from(`sync\0${purpose.account}\0${purpose.device}\0${String(purpose.revision)}`, "utf8");
72
+ case "capture":
73
+ return Buffer.from(`capture\0${purpose.via}\0${String(purpose.at)}`, "utf8");
74
+ case "phone":
75
+ return Buffer.from(`phone\0${purpose.device}\0${purpose.nonce}`, "utf8");
67
76
  }
68
77
  };
69
78
 
70
79
  const boxKey = (shared: Buffer, epk: string, recipient: string): Buffer =>
71
80
  Buffer.from(hkdfSync("sha256", shared, Buffer.concat([Buffer.from(epk, "base64url"), Buffer.from(recipient, "base64url")]), SEAL_INFO, 32));
72
81
 
82
+ // Seals `plaintext` to the holder of `recipient`'s private half, for `purpose` alone, with a caller-chosen ephemeral
83
+ // key and IV. The phone app's unit tests and golden/keyring-seal.json call this with fixed inputs so Kotlin can match.
84
+ export const sealWithEphemeral = (
85
+ recipient: string,
86
+ plaintext: string,
87
+ purpose: SealPurpose,
88
+ ephemeralPair: KeyringKeyPair,
89
+ iv: Buffer,
90
+ ): SealedBox => {
91
+ const shared = diffieHellman({ privateKey: privateKeyOf("X25519", ephemeralPair), publicKey: publicKeyOf("X25519", recipient) });
92
+ const cipher = createCipheriv("aes-256-gcm", boxKey(shared, ephemeralPair.publicKey, recipient), iv);
93
+ cipher.setAAD(sealAad(purpose));
94
+ const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
95
+ return {
96
+ v: 1,
97
+ epk: ephemeralPair.publicKey,
98
+ iv: iv.toString("base64url"),
99
+ tag: cipher.getAuthTag().toString("base64url"),
100
+ data: data.toString("base64url"),
101
+ };
102
+ };
103
+
73
104
  // Seals `plaintext` to the holder of `recipient`'s private half, for `purpose` alone.
74
105
  export const seal = (recipient: string, plaintext: string, purpose: SealPurpose): SealedBox => {
75
106
  const ephemeral = newSealKeyPair();
76
- const shared = diffieHellman({ privateKey: privateKeyOf("X25519", ephemeral), publicKey: publicKeyOf("X25519", recipient) });
77
- const iv = randomBytes(12);
78
- const cipher = createCipheriv("aes-256-gcm", boxKey(shared, ephemeral.publicKey, recipient), iv);
79
- cipher.setAAD(sealAad(purpose));
80
- const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
81
- return { v: 1, epk: ephemeral.publicKey, iv: iv.toString("base64url"), tag: cipher.getAuthTag().toString("base64url"), data: data.toString("base64url") };
107
+ return sealWithEphemeral(recipient, plaintext, purpose, ephemeral, randomBytes(12));
82
108
  };
83
109
 
84
110
  // Opens a box sealed to `pair` for `purpose`; throws on any way of not being one (malformed, another key, another purpose).
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { COMMAND_CLASS_LABELS, COMMAND_CLASS_PATTERNS, type CommandPattern } from "./command-classes.js";
3
- import { type CommandClass, CommandClassSchema, type CommandLocus, CommandLocusSchema } from "../schemas/agent.js";
3
+ import { type CommandClass, CommandClassSchema, type CommandLocus, CommandLocusSchema } from "../schemas/command-admission.js";
4
4
 
5
5
  // The owner's safety policy as prose, judged by a model rather than a table of verdicts, since deciding whether a
6
6
  // command is malicious takes understanding a regex can't reach. Patterns (command-classes.ts) are now just triage,
@@ -1,6 +1,6 @@
1
1
  import { COMMAND_CLASS_LABELS, COMMAND_CLASS_PATTERNS } from "../command-classes.js";
2
2
  import { COMMAND_RULE_CATALOG, DEFAULT_SAFETY_POLICY, hardRuleClasses } from "../safety-policy.js";
3
- import { CommandClassSchema, CommandLocusSchema } from "../../schemas/agent.js";
3
+ import { CommandClassSchema, CommandLocusSchema } from "../../schemas/command-admission.js";
4
4
 
5
5
  // The hard rule is the one thing on this page nobody can argue with, so it's pinned here rather than trusted to review:
6
6
  // a class added silently is one an owner can never decide about for themselves.
@@ -22,6 +22,7 @@ const TURNED_AWAY: ReadonlySet<string> = new Set([
22
22
  "sandbox-memory-low",
23
23
  // The conversation's own view of the workspace could not be set up in time: nothing was started in it.
24
24
  "workspace-setup",
25
+ "sandbox-disk-low",
25
26
  "trial-unavailable",
26
27
  "trial-model-unavailable",
27
28
  "trial-exhausted",
@@ -29,5 +30,20 @@ const TURNED_AWAY: ReadonlySet<string> = new Set([
29
30
  "engine-version-floor",
30
31
  ]);
31
32
 
33
+ // The refusals about the model itself (its allowance, its account, its catalogue, its window), which another model
34
+ // gets past: a turn one of these stops partway offers to carry on elsewhere rather than to wait.
35
+ const ANOTHER_MODEL_CLEARS: ReadonlySet<string> = new Set([
36
+ "claude-not-entitled",
37
+ "context-window-too-small",
38
+ "model-helper-only",
39
+ "trial-unavailable",
40
+ "trial-model-unavailable",
41
+ "trial-exhausted",
42
+ "model-unavailable",
43
+ ]);
44
+
45
+ /** Whether another model would get past a refusal: the trial's, the model's, or the account's on it. */
46
+ export const anotherModelClears = (code: string | undefined): boolean => code !== undefined && ANOTHER_MODEL_CLEARS.has(code);
47
+
32
48
  /** Whether a failure code refuses a turn before the model sees any of it. */
33
49
  export const turnedAwayCode = (code: string | undefined): boolean => code !== undefined && TURNED_AWAY.has(code);
@@ -19,3 +19,6 @@ export type HostHello = z.infer<typeof HostHelloSchema>;
19
19
  // The URL the machine's agent dials, given the sandbox's public URL. Carries no credential (the token rides the hello
20
20
  // frame); one place builds it, so the agent and the daemon route can't disagree.
21
21
  export const hostConnectUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/^http/, "ws").replace(/\/$/, "")}/system/hosts/connect`;
22
+
23
+ // Where a connected machine tells its sandbox that Passwords shares changed (2026-10-20), bearer-authenticated.
24
+ export const hostKeyringChangedUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/\/$/, "")}/system/hosts/keyring-changed`;
@@ -22,6 +22,9 @@ export const webextLendUrl = (sandboxUrl: string): string => `${sandboxUrl.repla
22
22
  // are: a tool answer over the socket lands in the model's context, and this value must never reach one.
23
23
  export const webextSecretUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/\/$/, "")}/system/webext/secret`;
24
24
 
25
+ // Where the extension tells its sandbox that site access changed outside the popup (2026-10-20), bearer-authenticated.
26
+ export const webextGrantsChangedUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/\/$/, "")}/system/webext/grants-changed`;
27
+
25
28
  // The tool the daemon calls on a browser to type a secret into its focused field, taking only a ticket the daemon
26
29
  // minted for that one call. The agent's own mount of the browser never lists it (webext-peer.ts): a model holding the
27
30
  // tool would hold nothing it could use, so it is not offered.
@@ -47,16 +47,22 @@ class FakeSocket implements SocketLike {
47
47
  // This door's silence deadline, named so the assertions can read it off the same number the loop is given.
48
48
  const SILENCE_MS = 30_000;
49
49
 
50
- // A ladder that answers with a fixed delay and records what it was told, so the test can read the held time.
50
+ // A ladder that answers with a fixed delay and records what it was told, so the test can read the held time, and how
51
+ // often it was put back on its floor.
51
52
  const ladder = (delay = 1_000) => {
52
53
  const held: number[] = [];
53
- return {
54
+ const rungs = {
54
55
  held,
56
+ resets: 0,
55
57
  next: (heldMs: number) => {
56
58
  held.push(heldMs);
57
59
  return delay;
58
60
  },
61
+ reset: () => {
62
+ rungs.resets += 1;
63
+ },
59
64
  };
65
+ return rungs;
60
66
  };
61
67
 
62
68
  const dialling = (delay?: number) => {
@@ -64,6 +70,7 @@ const dialling = (delay?: number) => {
64
70
  const attached: FakeSocket[] = [];
65
71
  const said: string[] = [];
66
72
  const revoked = jest.fn();
73
+ const backoff = ladder(delay);
67
74
  const link = dialPeer<FakeSocket>({
68
75
  open: async () => {
69
76
  const socket = new FakeSocket();
@@ -72,12 +79,12 @@ const dialling = (delay?: number) => {
72
79
  },
73
80
  hello: () => ({ type: "hello", token: "iht_test", version: "1.0.0" }),
74
81
  attach: (socket) => void attached.push(socket),
75
- backoff: ladder(delay),
82
+ backoff,
76
83
  silenceMs: SILENCE_MS,
77
84
  log: (message) => void said.push(message),
78
85
  revoked,
79
86
  });
80
- return { link, sockets, attached, said, revoked };
87
+ return { link, sockets, attached, said, revoked, backoff };
81
88
  };
82
89
 
83
90
  test("the handler is attached before the hello goes out, and the hello is the first frame", async () => {
@@ -305,6 +312,85 @@ test("a link nobody has answered in a long time rests between tries, and is neve
305
312
  }
306
313
  });
307
314
 
315
+ /* A FAR END SOMEBODY KNOWS IS BACK (a sandbox the machine agent just woke) is dialled at once, whatever the ladder had
316
+ left to wait, and a dial that still finds it booting is back on the ladder's first rung, not on the long rest. */
317
+ test("retryNow ends the long rest and dials at once, and the outage behind it no longer counts", async () => {
318
+ jest.useFakeTimers();
319
+ try {
320
+ const RUNG_MS = 30_000;
321
+ const { link, sockets, said, backoff } = dialling(RUNG_MS);
322
+ for (let attempt = 1; attempt <= LONG_OUTAGE_ATTEMPTS; attempt += 1) {
323
+ // one drop per attempt is serial by definition
324
+ await waitFor(() => expect(sockets).toHaveLength(attempt));
325
+ sockets.at(-1)?.drops(1006);
326
+ if (attempt < LONG_OUTAGE_ATTEMPTS) {
327
+ // ditto
328
+ await advanceTimersByTimeAsync(RUNG_MS);
329
+ }
330
+ }
331
+ // Resting now: the long outage's quarter hour on top of the rung.
332
+ expect(link.outage()).toEqual({ failures: LONG_OUTAGE_ATTEMPTS, since: expect.any(Number) });
333
+ await advanceTimersByTimeAsync(RUNG_MS);
334
+ expect(sockets).toHaveLength(LONG_OUTAGE_ATTEMPTS);
335
+
336
+ link.retryNow();
337
+ await waitFor(() => expect(sockets).toHaveLength(LONG_OUTAGE_ATTEMPTS + 1));
338
+ expect(backoff.resets).toBe(1);
339
+ expect(link.outage()).toBeUndefined();
340
+ expect(link.state()).toBe("connecting");
341
+
342
+ // Still booting: the first failure of a new outage, said out loud, waits one rung and no rest.
343
+ sockets.at(-1)?.drops(PEER_TRY_AGAIN);
344
+ expect(said.at(-1)).toBe("the sandbox is not ready to admit this connection yet; reconnecting in 30s");
345
+ expect(link.outage()).toEqual({ failures: 1, since: expect.any(Number) });
346
+ await advanceTimersByTimeAsync(RUNG_MS);
347
+ await waitFor(() => expect(sockets).toHaveLength(LONG_OUTAGE_ATTEMPTS + 2));
348
+
349
+ // Nothing of the cut wait is left to fire (the one dial it owed has been made): the open socket's watchdog is
350
+ // the one timer armed.
351
+ sockets.at(-1)?.opens();
352
+ expect(link.state()).toBe("open");
353
+ expect(jest.getTimerCount()).toBe(1);
354
+
355
+ link.stop();
356
+ await link.done;
357
+ } finally {
358
+ jest.useRealTimers();
359
+ }
360
+ });
361
+
362
+ test("retryNow dials nothing beside an open link or a dial in flight, and nothing once the link is stopped", async () => {
363
+ jest.useFakeTimers();
364
+ try {
365
+ const { link, sockets, backoff } = dialling(60_000);
366
+ await waitFor(() => expect(sockets).toHaveLength(1));
367
+ // In flight: no second dial beside it, though its ladder is reset so a failure of it retries on the floor.
368
+ link.retryNow();
369
+ await advanceTimersByTimeAsync(0);
370
+ expect(sockets).toHaveLength(1);
371
+ expect(backoff.resets).toBe(1);
372
+
373
+ sockets[0]?.opens();
374
+ link.retryNow();
375
+ await advanceTimersByTimeAsync(0);
376
+ expect(sockets).toHaveLength(1);
377
+ expect(backoff.resets).toBe(1);
378
+ expect(link.state()).toBe("open");
379
+
380
+ sockets[0]?.drops(1006);
381
+ link.stop();
382
+ await link.done;
383
+ link.retryNow();
384
+ await advanceTimersByTimeAsync(120_000);
385
+ expect(sockets).toHaveLength(1);
386
+ expect(backoff.resets).toBe(1);
387
+ expect(jest.getTimerCount()).toBe(0);
388
+ expect(link.state()).toBe("closed");
389
+ } finally {
390
+ jest.useRealTimers();
391
+ }
392
+ });
393
+
308
394
  // A stop that lands while the address is still being decided has nothing to close and must open nothing after
309
395
  // the fact: a socket dialled by a loop that has already reported itself done would be a connection nobody stops.
310
396
  test("a stop during resolution opens nothing, and a socket a slow resolver still hands over is closed", async () => {