@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
@@ -0,0 +1,107 @@
1
+ import { commandLineOf, DEVICE_PROGRAM_TOOLS, ProgramArgumentsSchema, programInCall } from "../device-programs.js";
2
+
3
+ // As the bridge hands it over: the arguments of a tools/call, parsed by the table's own schema.
4
+ const read = (tool: string, args: Record<string, unknown>) => programInCall(tool, ProgramArgumentsSchema.parse(args));
5
+
6
+ describe("the shell tools", () => {
7
+ test("run_command carries its command as it was sent", () => {
8
+ expect(read("run_command", { command: "rm -rf ~/old-builds" })).toEqual({ tool: "run_command", kind: "command", commandLine: "rm -rf ~/old-builds" });
9
+ expect(read("run_command", { command: " " })).toBeUndefined();
10
+ expect(read("run_command", {})).toBeUndefined();
11
+ expect(read("run_command", { command: 7 })).toBeUndefined();
12
+ });
13
+
14
+ test("android_shell carries the adb program the machine runs for it, and a serial that could hide the rest is left out", () => {
15
+ expect(read("android_shell", { command: "pm uninstall com.example.app" })).toEqual({
16
+ tool: "android_shell",
17
+ kind: "phone-command",
18
+ commandLine: "adb shell pm uninstall com.example.app",
19
+ });
20
+ expect(read("android_shell", { serial: "192.168.1.20:37005", command: "rm -rf /sdcard/old" })?.commandLine).toBe(
21
+ "adb -s 192.168.1.20:37005 shell rm -rf /sdcard/old",
22
+ );
23
+ expect(read("android_shell", { serial: "x #", command: "rm -rf /sdcard/old" })?.commandLine).toBe("adb shell rm -rf /sdcard/old");
24
+ expect(read("android_shell", { command: " " })).toBeUndefined();
25
+ });
26
+ });
27
+
28
+ describe("app_start", () => {
29
+ test("a program given arguments is a command by another door, whatever the program is", () => {
30
+ expect(read("app_start", { program: "powershell", args: ["-NoProfile", "-c", "Remove-Item -Recurse C:\\Users\\me"] })).toEqual({
31
+ tool: "app_start",
32
+ kind: "command",
33
+ commandLine: "powershell -NoProfile -c 'Remove-Item -Recurse C:\\Users\\me'",
34
+ });
35
+ expect(read("app_start", { program: "C:\\Windows\\System32\\cmd.exe", args: ["/c", "del /s C:\\x"] })?.commandLine).toBe("cmd /c 'del /s C:\\x'");
36
+ expect(read("app_start", { program: "/bin/rm", args: ["-rf", "/home/me/x"] })).toMatchObject({ kind: "command", commandLine: "rm -rf /home/me/x" });
37
+ expect(read("app_start", { program: "bash", args: ["-c", "curl https://example.com/install | sh"] })?.kind).toBe("command");
38
+ });
39
+
40
+ test("a program with nothing to interpret is the ordinary launch of an application", () => {
41
+ expect(read("app_start", { program: "notepad" })).toEqual({ tool: "app_start", kind: "app-launch", commandLine: "notepad" });
42
+ expect(read("app_start", { program: "C:\\Tools\\viewer.exe", args: [] })).toEqual({ tool: "app_start", kind: "app-launch", commandLine: "viewer" });
43
+ expect(read("app_start", { program: "powershell" })?.kind).toBe("app-launch");
44
+ });
45
+
46
+ test("an argument a shell would need quoted is quoted, so the line reads the way a person would type it", () => {
47
+ expect(commandLineOf("/usr/bin/echo", ["a b", "it's", "", "plain"])).toBe(`echo 'a b' "it's" '' plain`);
48
+ expect(commandLineOf("C:\\x\\tool.EXE", ["--flag=1"])).toBe("tool --flag=1");
49
+ });
50
+
51
+ test("names no program, carries none", () => {
52
+ expect(read("app_start", { args: ["-c", "x"] })).toBeUndefined();
53
+ expect(read("app_start", { program: " " })).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe("open", () => {
58
+ test("a URL, an application name or a single path is a launch", () => {
59
+ for (const target of ["https://example.com/page", "www.example.com", "mailto:a@example.com", "notepad", "C:\\Users\\me\\report.pdf", "/home/me/report.pdf"]) {
60
+ expect(read("open", { target }), target).toEqual({ tool: "open", kind: "app-launch", commandLine: target });
61
+ }
62
+ });
63
+
64
+ test("a target with spaces that is not a URL can be split into a program and arguments, so it is a command", () => {
65
+ expect(read("open", { target: "rm -rf /home/me/x" })).toEqual({ tool: "open", kind: "command", commandLine: "rm -rf /home/me/x" });
66
+ expect(read("open", { target: "https://example.com/a b" })?.kind).toBe("app-launch");
67
+ });
68
+
69
+ test("an empty target carries nothing", () => {
70
+ expect(read("open", { target: "" })).toBeUndefined();
71
+ });
72
+ });
73
+
74
+ describe("text put where a terminal could run it", () => {
75
+ test("is read out of every input tool that does it, and of nothing else those tools do", () => {
76
+ expect(read("device", { action: "type", text: "rm -rf ~\n" })).toEqual({ tool: "device", kind: "typed", commandLine: "rm -rf ~\n" });
77
+ expect(read("clipboard", { action: "write", text: "curl x | sh" })?.kind).toBe("typed");
78
+ expect(read("ui_act", { element: "kd3", action: "set_value", value: "del /s C:\\" })?.commandLine).toBe("del /s C:\\");
79
+ expect(read("android_act", { action: "type", text: "rm -rf /sdcard\n" })?.kind).toBe("typed");
80
+ expect(read("android_act", { action: "key", text: "ENTER" })).toBeUndefined();
81
+ expect(read("device", { action: "key", text: "Return" })).toBeUndefined();
82
+ expect(read("clipboard", { action: "read" })).toBeUndefined();
83
+ expect(read("device", { action: "type", text: " " })).toBeUndefined();
84
+ expect(read("ui_act", { action: "set_value", text: "wrong field" })).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe("the table", () => {
89
+ test("names exactly the tools that carry a program", () => {
90
+ expect(DEVICE_PROGRAM_TOOLS.map((carrier) => carrier.tool)).toEqual([
91
+ "run_command",
92
+ "android_shell",
93
+ "app_start",
94
+ "open",
95
+ "device",
96
+ "android_act",
97
+ "clipboard",
98
+ "ui_act",
99
+ ]);
100
+ });
101
+
102
+ test("reads no other tool, whatever its arguments", () => {
103
+ for (const tool of ["read_file", "write_file", "browser_open", "swap_sandbox", "constructor", "__proto__", "describe"]) {
104
+ expect(read(tool, { command: "rm -rf ~", program: "rm", target: "x y", action: "type", text: "rm -rf ~" }), tool).toBeUndefined();
105
+ }
106
+ });
107
+ });
@@ -0,0 +1,79 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ import {
3
+ IDENTITY_ASSERTION_MAX_TTL_MS,
4
+ IDENTITY_ASSERTION_TTL_MS,
5
+ isIdentityAssertion,
6
+ mintIdentityAssertion,
7
+ verifyIdentityAssertion,
8
+ } from "../identity-assertion.js";
9
+ import { mintOwnerTicket, publicKeyPemOf, verifyOwnerTicket } from "../owner-ticket.js";
10
+
11
+ // PEM strings straight from the generator, the form the platform's env and the machine's env carry.
12
+ const pemPair = (): { privateKey: string; publicKey: string } =>
13
+ generateKeyPairSync("ed25519", { publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" } });
14
+ const { privateKey: privatePem, publicKey: publicPem } = pemPair();
15
+ const NOW = 1_800_000_000_000;
16
+ const SANDBOX = "0123456789ab";
17
+
18
+ const mint = (overrides: Partial<Parameters<typeof mintIdentityAssertion>[1]> = {}): string =>
19
+ mintIdentityAssertion(privatePem, { sandboxId: SANDBOX, email: "Ada@Example.org", method: "email", issuedAtMs: NOW, ...overrides });
20
+
21
+ describe("identity assertion", () => {
22
+ it("round-trips the claim, lowercased, and expires when it says", () => {
23
+ const assertion = mint();
24
+ expect(isIdentityAssertion(assertion)).toBe(true);
25
+ expect(verifyIdentityAssertion(publicPem, assertion, NOW + 1_000)).toEqual({
26
+ sandboxId: SANDBOX,
27
+ email: "ada@example.org",
28
+ method: "email",
29
+ issuedAt: NOW / 1000,
30
+ expiresAt: (NOW + IDENTITY_ASSERTION_TTL_MS) / 1000,
31
+ nonce: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/),
32
+ });
33
+ expect(verifyIdentityAssertion(publicPem, assertion, NOW + IDENTITY_ASSERTION_TTL_MS)).toBeUndefined();
34
+ });
35
+
36
+ it("gives every mint its own nonce, so a daemon can spend each once", () => {
37
+ const first = verifyIdentityAssertion(publicPem, mint(), NOW);
38
+ const second = verifyIdentityAssertion(publicPem, mint(), NOW);
39
+ expect(first?.nonce).not.toBe(second?.nonce);
40
+ });
41
+
42
+ it("verifies against the public half derived from the platform's private key", () => {
43
+ expect(verifyIdentityAssertion(publicKeyPemOf(privatePem), mint({ method: "google" }), NOW)).toMatchObject({ method: "google" });
44
+ });
45
+
46
+ it("refuses another key's signature, a tampered claim, and garbage", () => {
47
+ const other = pemPair().publicKey;
48
+ const assertion = mint();
49
+ expect(verifyIdentityAssertion(other, assertion, NOW)).toBeUndefined();
50
+ const [prefix, , signature] = assertion.split(".");
51
+ const forged = Buffer.from(
52
+ JSON.stringify({ v: 1, sub: SANDBOX, email: "eve@example.org", amr: "email", iat: NOW / 1000, exp: NOW / 1000 + 60, jti: "x".repeat(22) }),
53
+ ).toString("base64url");
54
+ expect(verifyIdentityAssertion(publicPem, `${prefix}.${forged}.${signature}`, NOW)).toBeUndefined();
55
+ expect(verifyIdentityAssertion(publicPem, "pa1.not.base64", NOW)).toBeUndefined();
56
+ expect(verifyIdentityAssertion(publicPem, `${assertion}.extra`, NOW)).toBeUndefined();
57
+ expect(isIdentityAssertion("ot1.x.y")).toBe(false);
58
+ });
59
+
60
+ it("refuses a claim issued more than a minute ahead of the daemon's clock", () => {
61
+ expect(verifyIdentityAssertion(publicPem, mint({ issuedAtMs: NOW + 61_000 }), NOW)).toBeUndefined();
62
+ expect(verifyIdentityAssertion(publicPem, mint({ issuedAtMs: NOW + 59_000 }), NOW)).toMatchObject({ email: "ada@example.org" });
63
+ });
64
+
65
+ /* One key could sign tickets and assertions if an operator reused it; neither may pass as the other. */
66
+ it("is never an owner ticket, and an owner ticket is never an assertion", () => {
67
+ const ticket = mintOwnerTicket(privatePem, { sandboxId: SANDBOX, email: "ada@example.org", issuedAtMs: NOW });
68
+ expect(verifyIdentityAssertion(publicPem, ticket, NOW)).toBeUndefined();
69
+ expect(verifyIdentityAssertion(publicPem, `pa1.${ticket.slice("ot1.".length)}`, NOW)).toBeUndefined();
70
+ expect(verifyOwnerTicket(publicPem, mint(), NOW)).toBeUndefined();
71
+ });
72
+
73
+ it("refuses to mint for anything but a real sandbox id, an address, and a short life", () => {
74
+ expect(() => mint({ sandboxId: "nope" })).toThrow("12-hex sandbox id");
75
+ expect(() => mint({ email: " " })).toThrow("names an address");
76
+ expect(() => mint({ ttlMs: IDENTITY_ASSERTION_MAX_TTL_MS + 1 })).toThrow("lives at most");
77
+ expect(() => mint({ ttlMs: 0 })).toThrow("lives at most");
78
+ });
79
+ });
@@ -0,0 +1,181 @@
1
+ import { managesSandbox, mayReachSandbox, SANDBOX_POWER_DOING, type SandboxPowerCommand, sandboxPowerCommands } from "../sandbox-power.js";
2
+
3
+ const OWN = { slug: "work", container: "intentic-sandbox-work" };
4
+ const PREFIX = "intentic-sandbox-";
5
+
6
+ // What a command aims at, without the spans: the verb, whether it ends a running sandbox's turns, and who it names.
7
+ const aimOf = (hit: SandboxPowerCommand | undefined) => hit && { via: hit.via, op: hit.op, stops: hit.stops, targets: hit.targets, unnamed: hit.unnamed };
8
+ const first = (command: string) => aimOf(sandboxPowerCommands(command)[0]);
9
+
10
+ describe("ic sandbox", () => {
11
+ test("reads every verb that ends a running sandbox's turns, with the slug it names", () => {
12
+ expect(first("ic sandbox restart work")).toEqual({ via: "ic", op: "restart", stops: true, targets: ["work"], unnamed: false });
13
+ expect(first("ic sandbox stop work")).toEqual({ via: "ic", op: "stop", stops: true, targets: ["work"], unnamed: false });
14
+ expect(first("ic sandbox rm work")).toEqual({ via: "ic", op: "remove", stops: true, targets: ["work"], unnamed: false });
15
+ expect(first("ic sandbox remove work -y")).toEqual({ via: "ic", op: "remove", stops: true, targets: ["work"], unnamed: false });
16
+ expect(first("ic sandbox rebuild work 3f2a")).toEqual({ via: "ic", op: "rebuild", stops: true, targets: ["work", "3f2a"], unnamed: false });
17
+ expect(first("ic sandbox update work --no-resume")).toEqual({ via: "ic", op: "update", stops: true, targets: ["work"], unnamed: false });
18
+ expect(first("ic sandbox rollback work --to 1.315.0")).toEqual({ via: "ic", op: "rollback", stops: true, targets: ["work"], unnamed: false });
19
+ expect(first("ic sandbox sleep work")).toEqual({ via: "ic", op: "sleep", stops: true, targets: ["work"], unnamed: false });
20
+ expect(first("ic sandbox dev work")).toEqual({ via: "ic", op: "dev", stops: true, targets: ["work"], unnamed: false });
21
+ });
22
+
23
+ test("a start, a download and a resource ask saved for later are managing the sandbox but ending nothing", () => {
24
+ expect(first("ic sandbox start work")).toEqual({ via: "ic", op: "start", stops: false, targets: ["work"], unnamed: false });
25
+ expect(first("ic sandbox prepare work --auto")).toEqual({ via: "ic", op: "prepare", stops: false, targets: ["work"], unnamed: false });
26
+ expect(first("ic sandbox reshape work --memory 12g --later")?.stops).toBe(false);
27
+ expect(first("ic sandbox reshape work --forget")?.stops).toBe(false);
28
+ expect(first("ic sandbox shape work --set cpus=4 --when next-restart")?.stops).toBe(false);
29
+ expect(first("ic sandbox shape work --set cpus=4 --when nextRestart")?.stops).toBe(false);
30
+ });
31
+
32
+ test("a resource change applied now restarts, and so does a bare reshape, which applies what was saved", () => {
33
+ expect(first("ic sandbox reshape work --memory 12g")).toEqual({ via: "ic", op: "resize", stops: true, targets: ["work"], unnamed: false });
34
+ expect(first("ic sandbox reshape work")?.stops).toBe(true);
35
+ expect(first("ic sandbox shape work --set cpus=4 --when now")).toEqual({ via: "ic", op: "resize", stops: true, targets: ["work"], unnamed: false });
36
+ expect(first("ic sandbox shape work --when=now --set cpus=4")?.stops).toBe(true);
37
+ });
38
+
39
+ test("a flag's value is not a slug, so a command naming no sandbox reads as naming none", () => {
40
+ expect(first("ic sandbox update --channel stable")).toEqual({ via: "ic", op: "update", stops: true, targets: [], unnamed: true });
41
+ expect(first("ic sandbox rollback --to 1.315.0")).toEqual({ via: "ic", op: "rollback", stops: true, targets: [], unnamed: true });
42
+ expect(first("ic sandbox restart")).toEqual({ via: "ic", op: "restart", stops: true, targets: [], unnamed: true });
43
+ expect(first("ic sandbox restart 2>&1")?.unnamed).toBe(true);
44
+ expect(first("ic sandbox remove --all -y")?.unnamed).toBe(true);
45
+ });
46
+
47
+ test("a backup restored into a sandbox stops it; restored to a folder it touches none", () => {
48
+ expect(first("ic sandbox backup-restore work --snapshot latest --into work -y")).toEqual({
49
+ via: "ic",
50
+ op: "restore",
51
+ stops: true,
52
+ targets: ["work"],
53
+ unnamed: false,
54
+ });
55
+ expect(first("ic sandbox backup-restore work --snapshot latest --into other")?.targets).toEqual(["other"]);
56
+ expect(first("ic sandbox backup-restore work --snapshot latest --to /tmp/look")?.stops).toBe(false);
57
+ });
58
+
59
+ test("reinstalling over a sandbox ends it, and names no slug the command line shows", () => {
60
+ expect(first("ic sandbox connect --replace -y abc123")).toEqual({ via: "ic", op: "connect", stops: true, targets: [], unnamed: true });
61
+ expect(first("ic sandbox connect -y abc123")?.stops).toBe(false);
62
+ });
63
+
64
+ test("reads the program however a shell is told to find it", () => {
65
+ for (const command of [
66
+ "ic.exe sandbox restart work",
67
+ "& 'C:\\Users\\radar\\.intentic\\bin\\ic.exe' sandbox restart work",
68
+ '& "C:\\Users\\radar\\.intentic\\bin\\ic.exe" sandbox restart work',
69
+ "~/.intentic/bin/ic sandbox restart work",
70
+ "sudo ic sandbox restart work",
71
+ "cd /tmp && ic sandbox restart work",
72
+ "wsl -d archlinux -- ic sandbox restart work",
73
+ "IC SANDBOX RESTART work",
74
+ ]) {
75
+ expect(first(command), command).toMatchObject({ via: "ic", op: "restart", targets: ["work"] });
76
+ }
77
+ });
78
+
79
+ test("leaves alone what only reads, and words that merely end in ic", () => {
80
+ for (const command of [
81
+ "ic sandbox list --json",
82
+ "ic sandbox logs work --tail 50",
83
+ "ic sandbox doctor work",
84
+ "ic sandbox versions work",
85
+ "intentic sandbox restart work",
86
+ "magic sandbox restart work",
87
+ "ic runner remove build",
88
+ "git commit -m 'document how ic works'",
89
+ ]) {
90
+ expect(sandboxPowerCommands(command), command).toEqual([]);
91
+ }
92
+ });
93
+
94
+ test("stops reading at the end of the simple command, so a later command's words are not this one's operands", () => {
95
+ expect(first("ic sandbox restart work; echo done")?.targets).toEqual(["work"]);
96
+ expect(first("ic sandbox restart | tee log")?.unnamed).toBe(true);
97
+ expect(first("ic sandbox restart work && ic sandbox start other")?.targets).toEqual(["work"]);
98
+ expect(sandboxPowerCommands("ic sandbox restart work && ic sandbox start other").map((hit) => hit.targets)).toEqual([["work"], ["other"]]);
99
+ });
100
+
101
+ test("marks a command that only appears as text as not live", () => {
102
+ expect(sandboxPowerCommands("echo 'ic sandbox restart work'")[0]?.live).toBe(false);
103
+ expect(sandboxPowerCommands("# ic sandbox restart work")[0]?.live).toBe(false);
104
+ expect(sandboxPowerCommands("ic sandbox restart work")[0]?.live).toBe(true);
105
+ expect(sandboxPowerCommands("ic sandbox restart work")[0]?.span).toEqual({ start: 0, end: "ic sandbox restart".length });
106
+ });
107
+ });
108
+
109
+ describe("docker", () => {
110
+ test("reads a stop, kill, restart or remove of a container, with the names it gives", () => {
111
+ expect(first("docker stop intentic-sandbox-work")).toEqual({ via: "docker", op: "stop", stops: true, targets: ["intentic-sandbox-work"], unnamed: false });
112
+ expect(first("docker kill intentic-sandbox-work")?.op).toBe("stop");
113
+ expect(first("docker restart intentic-sandbox-work")?.op).toBe("restart");
114
+ expect(first("docker rm -f intentic-sandbox-work")).toEqual({ via: "docker", op: "remove", stops: true, targets: ["intentic-sandbox-work"], unnamed: false });
115
+ expect(first("docker container stop -t 60 a b")).toEqual({ via: "docker", op: "stop", stops: true, targets: ["a", "b"], unnamed: false });
116
+ expect(first("docker.exe stop a")?.targets).toEqual(["a"]);
117
+ expect(first("podman rm a")?.via).toBe("docker");
118
+ });
119
+
120
+ test("leaves alone docker that stops nothing", () => {
121
+ for (const command of ["docker ps", "docker logs intentic-sandbox-work", "docker start intentic-sandbox-work", "docker exec c kill -9 1", "docker compose stop"]) {
122
+ expect(sandboxPowerCommands(command), command).toEqual([]);
123
+ }
124
+ });
125
+
126
+ test("a stop fed from a pipe names nothing", () => {
127
+ expect(first("docker ps -q | xargs docker stop")).toEqual({ via: "docker", op: "stop", stops: true, targets: [], unnamed: true });
128
+ });
129
+ });
130
+
131
+ describe("whether a command may reach this sandbox", () => {
132
+ const reaches = (command: string): boolean => sandboxPowerCommands(command).some((hit) => mayReachSandbox(hit, OWN));
133
+
134
+ test("names it by slug or by container", () => {
135
+ expect(reaches("ic sandbox restart work")).toBe(true);
136
+ expect(reaches("docker stop intentic-sandbox-work")).toBe(true);
137
+ // docker knows no such container, but a card for a mistaken match costs less than a fleet cut unasked
138
+ expect(reaches("docker stop work")).toBe(true);
139
+ });
140
+
141
+ test("names none, or something that is only worked out when it runs", () => {
142
+ expect(reaches("ic sandbox update")).toBe(true);
143
+ expect(reaches("ic sandbox remove --all")).toBe(true);
144
+ expect(reaches("docker stop $(docker ps -q)")).toBe(true);
145
+ expect(reaches('docker stop "$SANDBOX"')).toBe(true);
146
+ expect(reaches("docker stop (docker ps -q)")).toBe(true);
147
+ expect(reaches("docker rm intentic-sandbox-*")).toBe(true);
148
+ expect(reaches("ic sandbox restart %NAME%")).toBe(true);
149
+ });
150
+
151
+ test("names another sandbox, or another container", () => {
152
+ expect(reaches("ic sandbox restart other")).toBe(false);
153
+ expect(reaches("ic sandbox rebuild other 3f2a")).toBe(false);
154
+ expect(reaches("docker stop intentic-sandbox-other")).toBe(false);
155
+ expect(reaches("docker restart postgres")).toBe(false);
156
+ expect(reaches("ic sandbox update --channel stable other")).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe("whether a command manages a sandbox the switch covers", () => {
161
+ const manages = (command: string): boolean => sandboxPowerCommands(command).some((hit) => managesSandbox(hit, PREFIX));
162
+
163
+ test("any ic sandbox verb, whichever sandbox it names", () => {
164
+ expect(manages("ic sandbox restart other")).toBe(true);
165
+ expect(manages("ic sandbox start other")).toBe(true);
166
+ expect(manages("ic sandbox update")).toBe(true);
167
+ });
168
+
169
+ test("docker only for a container that is a sandbox's, since the device cannot know a name worked out at run time", () => {
170
+ expect(manages("docker stop intentic-sandbox-other")).toBe(true);
171
+ expect(manages("docker rm -f intentic-sandbox-tunnel-abc")).toBe(true);
172
+ expect(manages("docker restart postgres")).toBe(false);
173
+ expect(manages("docker stop $(docker ps -q)")).toBe(false);
174
+ });
175
+ });
176
+
177
+ test("every operation has the owner's word for it", () => {
178
+ expect(SANDBOX_POWER_DOING.restart).toBe("Restart");
179
+ expect(SANDBOX_POWER_DOING.resize).toBe("Restart with new resources");
180
+ expect(SANDBOX_POWER_DOING.rollback).toBe("Roll back");
181
+ });
@@ -154,6 +154,13 @@ export const RunnerSummarySchema = z.object({
154
154
  drift: z.array(NeedsActionSchema).optional(),
155
155
  });
156
156
  export type RunnerSummary = z.infer<typeof RunnerSummarySchema>;
157
+ // GET /system/runners: every enrolled runner, the one list the Devices view, the placement picker and the Access tab read.
158
+ export const RunnersListSchema = z.object({ runners: z.array(RunnerSummarySchema) });
159
+ export type RunnersList = z.infer<typeof RunnersListSchema>;
160
+ // What a runner answers when it takes a definition (`applyDefinition`), and POST /system/runners/{id}/definition/sync
161
+ // relays: the settings it now runs.
162
+ export const RunnerDefinitionAppliedSchema = z.object({ settings: z.array(z.string()) });
163
+ export type RunnerDefinitionApplied = z.infer<typeof RunnerDefinitionAppliedSchema>;
157
164
 
158
165
  // A workspace sync, narrated line by line as it happens (first contact clones whole repos). `op`: `pull` brings the
159
166
  // runner's checkout current before a turn, `push` returns the branch after; `repos` is the parent's own record, since
@@ -0,0 +1,87 @@
1
+ import { RAW_JSON_ROUTES, type RawJsonRoute } from "./raw-json-routes.js";
2
+ import { RAW_ROUTES, rawRouteUrl } from "./raw-routes.js";
3
+
4
+ // The table the editor calls the daemon's plain-JSON routes by: each entry is a route the daemon registers, says what it
5
+ // takes and answers, and parses what the editor reads off the wire.
6
+
7
+ const entries = Object.entries<RawJsonRoute>(RAW_JSON_ROUTES);
8
+ const keys = entries.map(([key]) => key);
9
+ const bodies = (route: RawJsonRoute): number => [route.input !== undefined, route.text === true, route.bytes === true].filter(Boolean).length;
10
+
11
+ describe(`RAW_JSON_ROUTES`, () => {
12
+ it(`names only routes the daemon registers, so a typed call can never aim at a path nothing serves`, () => {
13
+ expect(keys.filter((key) => !(key in RAW_ROUTES))).toEqual([]);
14
+ });
15
+
16
+ it(`gives a route one kind of body: JSON, a document, or bytes`, () => {
17
+ expect(entries.filter(([, route]) => bodies(route) > 1).map(([key]) => key)).toEqual([]);
18
+ });
19
+
20
+ it(`takes a body only on a route that writes: a read has none to send`, () => {
21
+ expect(entries.filter(([key, route]) => key.startsWith(`GET `) && bodies(route) > 0).map(([key]) => key)).toEqual([]);
22
+ });
23
+
24
+ it(`holds the routes the editor's rosters, access tab, uploads and dictation reach by key`, () => {
25
+ expect(keys).toEqual(
26
+ expect.arrayContaining([
27
+ `GET /system/hosts`,
28
+ `GET /system/webext`,
29
+ `GET /system/phones`,
30
+ `GET /system/runners`,
31
+ `GET /system/sync`,
32
+ `POST /system/sync/pair`,
33
+ `GET /system/passkeys`,
34
+ `POST /system/passkeys/register`,
35
+ `GET /system/control/tokens`,
36
+ `GET /bundles`,
37
+ `POST /arrivals/plan`,
38
+ `GET /speech/status`,
39
+ `POST /workspace/upload`,
40
+ `POST /workspace/upload-diff`,
41
+ ]),
42
+ );
43
+ });
44
+
45
+ it(`has a path every {param} of which is filled and encoded as one segment`, () => {
46
+ expect(rawRouteUrl(`DELETE /system/hosts/{id}`, { id: `my laptop/1` })).toBe(`/system/hosts/my%20laptop%2F1`);
47
+ expect(rawRouteUrl(`POST /system/runners/{id}/definition/sync`, { id: `r 1` })).toBe(`/system/runners/r%201/definition/sync`);
48
+ });
49
+ });
50
+
51
+ describe(`what the rosters and pairings answer`, () => {
52
+ it(`reads a peer's pairing from an older daemon, which sent a token and no code`, () => {
53
+ expect(RAW_JSON_ROUTES[`POST /system/webext/pair`].output.parse({ token: `iht_x` })).toEqual({ token: `iht_x` });
54
+ expect(RAW_JSON_ROUTES[`POST /system/hosts/pair`].output.parse({ token: `iht_x`, expiresIn: 600, code: `ABCD1234` })).toEqual({
55
+ token: `iht_x`,
56
+ expiresIn: 600,
57
+ code: `ABCD1234`,
58
+ });
59
+ });
60
+
61
+ it(`reads the sync pairing with the mode the daemon granted`, () => {
62
+ expect(RAW_JSON_ROUTES[`POST /system/sync/pair`].output.parse({ token: `t`, expiresIn: 600, code: `ABCD1234`, mode: `mirror` })).toMatchObject({ mode: `mirror` });
63
+ expect(() => RAW_JSON_ROUTES[`POST /system/sync/pair`].output.parse({ token: `t`, mode: `both` })).toThrow();
64
+ });
65
+
66
+ it(`reads a runner list, which two screens used to each describe themselves`, () => {
67
+ expect(RAW_JSON_ROUTES[`GET /system/runners`].output.parse({ runners: [{ id: `r1`, online: false, parity: `unknown` }] })).toEqual({
68
+ runners: [{ id: `r1`, online: false, parity: `unknown` }],
69
+ });
70
+ expect(() => RAW_JSON_ROUTES[`GET /system/runners`].output.parse({ runners: [{ id: `r1` }] })).toThrow();
71
+ });
72
+
73
+ it(`accepts the WebAuthn options only with the challenge the browser's own parser needs`, () => {
74
+ expect(RAW_JSON_ROUTES[`POST /system/passkeys/register/options`].output.safeParse({ challenge: `AwQ`, rp: { id: `app.test`, name: `work` } }).success).toBe(true);
75
+ expect(RAW_JSON_ROUTES[`POST /system/passkeys/register/options`].output.safeParse({ rp: { id: `app.test` } }).success).toBe(false);
76
+ expect(RAW_JSON_ROUTES[`POST /system/passkeys/assert/options`].output.safeParse({ challenge: `AQID`, available: true }).success).toBe(true);
77
+ });
78
+
79
+ it(`reads a session recovery as the session and the codes still unspent`, () => {
80
+ expect(RAW_JSON_ROUTES[`POST /system/session/recover`].output.parse({ token: `s`, expiresAt: 5, email: `a@example.com`, remaining: 3 })).toEqual({
81
+ token: `s`,
82
+ expiresAt: 5,
83
+ email: `a@example.com`,
84
+ remaining: 3,
85
+ });
86
+ });
87
+ });