@intentic/sandbox-contract 1.332.0 → 1.333.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/contracts/agent.contract.d.ts +1 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/automations.contract.d.ts +1 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/needs.contract.d.ts +29 -1
- package/dist/contracts/needs.contract.d.ts.map +1 -1
- package/dist/contracts/needs.contract.js +2 -2
- package/dist/contracts/needs.contract.js.map +1 -1
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.js +2 -2
- package/dist/contracts/runner.contract.js.map +1 -1
- package/dist/contracts/settings.contract.d.ts +34 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/ids/project-dir.d.ts +1 -0
- package/dist/ids/project-dir.d.ts.map +1 -1
- package/dist/ids/project-dir.js +7 -0
- package/dist/ids/project-dir.js.map +1 -1
- package/dist/index.d.ts +114 -43
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/models/model-roles.d.ts +8 -0
- package/dist/models/model-roles.d.ts.map +1 -1
- package/dist/models/model-roles.js +8 -0
- package/dist/models/model-roles.js.map +1 -1
- package/dist/policy/control-scopes.d.ts +47 -0
- package/dist/policy/control-scopes.d.ts.map +1 -1
- package/dist/policy/control-scopes.js +16 -0
- package/dist/policy/control-scopes.js.map +1 -1
- package/dist/policy/device-programs.d.ts +28 -0
- package/dist/policy/device-programs.d.ts.map +1 -0
- package/dist/policy/device-programs.js +79 -0
- package/dist/policy/device-programs.js.map +1 -0
- package/dist/policy/identity-assertion.d.ts +27 -0
- package/dist/policy/identity-assertion.d.ts.map +1 -0
- package/dist/policy/identity-assertion.js +77 -0
- package/dist/policy/identity-assertion.js.map +1 -0
- package/dist/policy/sandbox-power.d.ts +34 -0
- package/dist/policy/sandbox-power.d.ts.map +1 -0
- package/dist/policy/sandbox-power.js +146 -0
- package/dist/policy/sandbox-power.js.map +1 -0
- package/dist/protocol/doors/runner-protocol.d.ts +36 -0
- package/dist/protocol/doors/runner-protocol.d.ts.map +1 -1
- package/dist/protocol/doors/runner-protocol.js +2 -0
- package/dist/protocol/doors/runner-protocol.js.map +1 -1
- package/dist/protocol/raw/raw-json-routes.d.ts +839 -1
- package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-json-routes.js +60 -2
- package/dist/protocol/raw/raw-json-routes.js.map +1 -1
- package/dist/protocol/raw/raw-routes.d.ts +9 -0
- package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
- package/dist/protocol/raw/raw-routes.js +3 -0
- package/dist/protocol/raw/raw-routes.js.map +1 -1
- package/dist/schemas/agent.d.ts +3 -0
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/automations.d.ts +1 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/automations.js +8 -2
- package/dist/schemas/automations.js.map +1 -1
- package/dist/schemas/device-swaps.d.ts +19 -0
- package/dist/schemas/device-swaps.d.ts.map +1 -0
- package/dist/schemas/device-swaps.js +11 -0
- package/dist/schemas/device-swaps.js.map +1 -0
- package/dist/schemas/environment.d.ts +7 -0
- package/dist/schemas/environment.d.ts.map +1 -1
- package/dist/schemas/environment.js +2 -0
- package/dist/schemas/environment.js.map +1 -1
- package/dist/schemas/members.d.ts +4 -0
- package/dist/schemas/members.d.ts.map +1 -1
- package/dist/schemas/members.js +5 -0
- package/dist/schemas/members.js.map +1 -1
- package/dist/schemas/needs.d.ts +78 -1
- package/dist/schemas/needs.d.ts.map +1 -1
- package/dist/schemas/needs.js +36 -1
- package/dist/schemas/needs.js.map +1 -1
- package/dist/schemas/restart-intent.d.ts +47 -0
- package/dist/schemas/restart-intent.d.ts.map +1 -0
- package/dist/schemas/restart-intent.js +19 -0
- package/dist/schemas/restart-intent.js.map +1 -0
- package/dist/schemas/settings.d.ts +34 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +2 -0
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/short-codes.d.ts +16 -0
- package/dist/schemas/short-codes.d.ts.map +1 -1
- package/dist/schemas/short-codes.js +2 -0
- package/dist/schemas/short-codes.js.map +1 -1
- package/dist/schemas/speech.d.ts +4 -0
- package/dist/schemas/speech.d.ts.map +1 -1
- package/dist/schemas/speech.js +1 -0
- package/dist/schemas/speech.js.map +1 -1
- package/dist/schemas/system.d.ts +46 -0
- package/dist/schemas/system.d.ts.map +1 -1
- package/dist/schemas/system.js +18 -1
- package/dist/schemas/system.js.map +1 -1
- package/dist/schemas/voice.d.ts +171 -0
- package/dist/schemas/voice.d.ts.map +1 -0
- package/dist/schemas/voice.js +99 -0
- package/dist/schemas/voice.js.map +1 -0
- package/dist/schemas/workspace/workspace-upload.d.ts +14 -0
- package/dist/schemas/workspace/workspace-upload.d.ts.map +1 -0
- package/dist/schemas/workspace/workspace-upload.js +6 -0
- package/dist/schemas/workspace/workspace-upload.js.map +1 -0
- package/dist/state/arrival.d.ts +3 -0
- package/dist/state/arrival.d.ts.map +1 -1
- package/dist/state/arrival.js +1 -0
- package/dist/state/arrival.js.map +1 -1
- package/dist/state/definition.d.ts +52 -4
- package/dist/state/definition.d.ts.map +1 -1
- package/dist/state/history-state.d.ts.map +1 -1
- package/dist/state/history-state.js +2 -0
- package/dist/state/history-state.js.map +1 -1
- package/dist/state/runtime-state.d.ts +2 -2
- package/dist/state/runtime-state.d.ts.map +1 -1
- package/dist/state/runtime-state.js +2 -2
- package/dist/state/runtime-state.js.map +1 -1
- package/dist/state/update-probation.d.ts +50 -0
- package/dist/state/update-probation.d.ts.map +1 -0
- package/dist/state/update-probation.js +81 -0
- package/dist/state/update-probation.js.map +1 -0
- package/dist/state/workspace-state.d.ts +1 -1
- package/dist/state/workspace-state.d.ts.map +1 -1
- package/dist/state/workspace-state.js +1 -1
- package/dist/state/workspace-state.js.map +1 -1
- package/package.json +16 -5
- package/src/contracts/needs.contract.ts +2 -2
- package/src/contracts/runner.contract.ts +10 -2
- package/src/ids/project-dir.test.ts +19 -1
- package/src/ids/project-dir.ts +14 -0
- package/src/ids/tunnel-ids.test.ts +30 -0
- package/src/index.ts +7 -0
- package/src/models/model-roles.ts +10 -0
- package/src/policy/control-scopes.ts +27 -0
- package/src/policy/device-programs.ts +137 -0
- package/src/policy/identity-assertion.ts +130 -0
- package/src/policy/sandbox-power.ts +247 -0
- package/src/policy/tests/device-programs.test.ts +107 -0
- package/src/policy/tests/identity-assertion.test.ts +79 -0
- package/src/policy/tests/sandbox-power.test.ts +181 -0
- package/src/protocol/doors/runner-protocol.ts +7 -0
- package/src/protocol/raw/raw-json-routes.test.ts +87 -0
- package/src/protocol/raw/raw-json-routes.ts +125 -6
- package/src/protocol/raw/raw-routes.ts +6 -0
- package/src/schemas/automations.ts +10 -3
- package/src/schemas/device-swaps.test.ts +39 -0
- package/src/schemas/device-swaps.ts +26 -0
- package/src/schemas/environment.ts +4 -0
- package/src/schemas/members.ts +8 -0
- package/src/schemas/needs.ts +51 -1
- package/src/schemas/restart-intent.test.ts +45 -0
- package/src/schemas/restart-intent.ts +47 -0
- package/src/schemas/settings.ts +6 -0
- package/src/schemas/short-codes.ts +11 -0
- package/src/schemas/speech.ts +4 -0
- package/src/schemas/system.ts +43 -4
- package/src/schemas/voice.ts +161 -0
- package/src/schemas/workspace/workspace-upload.ts +11 -0
- package/src/state/arrival.ts +3 -0
- package/src/state/history-state.ts +5 -0
- package/src/state/runtime-state.test.ts +9 -0
- package/src/state/runtime-state.ts +7 -5
- package/src/state/update-probation.test.ts +45 -0
- package/src/state/update-probation.ts +169 -0
- package/src/state/workspace-state.ts +4 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { inertRegions, isLive } from "../text/shell-regions.js";
|
|
2
|
+
import type { CommandSpan } from "./command-classes.js";
|
|
3
|
+
|
|
4
|
+
// A SHELL COMMAND THAT STARTS, STOPS OR REPLACES A SANDBOX CONTAINER (2026-10-12). `ic sandbox restart work` and
|
|
5
|
+
// `docker stop intentic-sandbox-work` end the same turns the device tools manage_sandbox and swap_sandbox end, but they
|
|
6
|
+
// arrive as text in run_command, where no tool name says what they are. This reads that text once, for both ends that
|
|
7
|
+
// have to answer it: the daemon, which asks the owner before an agent cuts the sandbox it runs in while others are
|
|
8
|
+
// working (hosts/host-restart-guard.ts), and the device agent, whose "Manage sandboxes" switch decides whether anything
|
|
9
|
+
// may do this at all (the machine's tools/shell.ts). The same verbs and the same wording, so a command and the tool
|
|
10
|
+
// that does the same job cannot drift apart.
|
|
11
|
+
|
|
12
|
+
/** The sandbox operations a command can name, in the words `ic sandbox` spells them. */
|
|
13
|
+
export type SandboxPowerOp =
|
|
14
|
+
| "start"
|
|
15
|
+
| "stop"
|
|
16
|
+
| "restart"
|
|
17
|
+
| "sleep"
|
|
18
|
+
| "remove"
|
|
19
|
+
| "update"
|
|
20
|
+
| "prepare"
|
|
21
|
+
| "rebuild"
|
|
22
|
+
| "rollback"
|
|
23
|
+
| "dev"
|
|
24
|
+
| "resize"
|
|
25
|
+
| "restore"
|
|
26
|
+
| "connect";
|
|
27
|
+
|
|
28
|
+
/** The owner's own word for an operation, as a card or a refusal says it ("<doing> this sandbox now?"). */
|
|
29
|
+
export const SANDBOX_POWER_DOING = {
|
|
30
|
+
start: "Start",
|
|
31
|
+
stop: "Stop",
|
|
32
|
+
restart: "Restart",
|
|
33
|
+
sleep: "Stop",
|
|
34
|
+
remove: "Remove",
|
|
35
|
+
update: "Update",
|
|
36
|
+
prepare: "Prepare an update for",
|
|
37
|
+
rebuild: "Rebuild",
|
|
38
|
+
rollback: "Roll back",
|
|
39
|
+
dev: "Update",
|
|
40
|
+
resize: "Restart with new resources",
|
|
41
|
+
restore: "Restore a backup over",
|
|
42
|
+
connect: "Reinstall",
|
|
43
|
+
} as const satisfies Record<SandboxPowerOp, string>;
|
|
44
|
+
|
|
45
|
+
/** One command that manages a sandbox container, and what it aims at. */
|
|
46
|
+
export interface SandboxPowerCommand {
|
|
47
|
+
readonly via: "ic" | "docker";
|
|
48
|
+
readonly op: SandboxPowerOp;
|
|
49
|
+
/** Whether it ends the turns of a sandbox that is running; false for a start, a download, a resource ask saved for later. */
|
|
50
|
+
readonly stops: boolean;
|
|
51
|
+
/** The slugs (`ic`) or container names (`docker`) it names, as written; an expansion (`$x`, `$(…)`, `*`) stays as typed. */
|
|
52
|
+
readonly targets: readonly string[];
|
|
53
|
+
/** Names none: `ic sandbox restart` alone (the only sandbox the device runs), `remove --all`, `docker stop` fed from a pipe. */
|
|
54
|
+
readonly unnamed: boolean;
|
|
55
|
+
/** The fragment, so a card can point at it. */
|
|
56
|
+
readonly span: CommandSpan;
|
|
57
|
+
/** Whether a shell would run it, as against a quoted argument of echo or a comment. */
|
|
58
|
+
readonly live: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `ic sandbox <verb>`: the program may be a full path, `ic.exe`, or quoted (`& 'C:\…\ic.exe' sandbox restart work`).
|
|
62
|
+
const IC_INVOCATION = /(?<![\w.$-])ic(?:\.exe)?["']?\s+sandbox\s+([a-z][a-z-]*)(?![\w-])/gi;
|
|
63
|
+
// `docker stop`, `docker container rm`, podman alike. A global option between the program and the verb
|
|
64
|
+
// (`docker -H host stop x`) is not read: the verb then follows an option, and the command is left unrecognised.
|
|
65
|
+
const DOCKER_INVOCATION = /(?<![\w.$-])(?:docker|podman)(?:\.exe)?["']?\s+(?:container\s+)?(stop|restart|rm|remove|kill)(?![\w-])/gi;
|
|
66
|
+
|
|
67
|
+
const IC_VERBS = new Map<string, SandboxPowerOp>([
|
|
68
|
+
["start", "start"],
|
|
69
|
+
["stop", "stop"],
|
|
70
|
+
["restart", "restart"],
|
|
71
|
+
["sleep", "sleep"],
|
|
72
|
+
["remove", "remove"],
|
|
73
|
+
["rm", "remove"],
|
|
74
|
+
["update", "update"],
|
|
75
|
+
["prepare", "prepare"],
|
|
76
|
+
["rebuild", "rebuild"],
|
|
77
|
+
["rollback", "rollback"],
|
|
78
|
+
["dev", "dev"],
|
|
79
|
+
["reshape", "resize"],
|
|
80
|
+
["shape", "resize"],
|
|
81
|
+
["backup-restore", "restore"],
|
|
82
|
+
["connect", "connect"],
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const DOCKER_VERBS = new Map<string, SandboxPowerOp>([
|
|
86
|
+
["stop", "stop"],
|
|
87
|
+
["kill", "stop"],
|
|
88
|
+
["restart", "restart"],
|
|
89
|
+
["rm", "remove"],
|
|
90
|
+
["remove", "remove"],
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
// The flags of each program that take their value as the next word, so the value is not read as a slug.
|
|
94
|
+
const IC_VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
95
|
+
"--channel",
|
|
96
|
+
"--to",
|
|
97
|
+
"--set",
|
|
98
|
+
"--when",
|
|
99
|
+
"--memory",
|
|
100
|
+
"--cpus",
|
|
101
|
+
"--privileged",
|
|
102
|
+
"--gpus",
|
|
103
|
+
"--idle",
|
|
104
|
+
"--source",
|
|
105
|
+
"--code",
|
|
106
|
+
"--snapshot",
|
|
107
|
+
"--into",
|
|
108
|
+
"--confirm",
|
|
109
|
+
"--accept",
|
|
110
|
+
"--tail",
|
|
111
|
+
]);
|
|
112
|
+
const DOCKER_VALUE_FLAGS: ReadonlySet<string> = new Set(["-t", "--time", "-s", "--signal"]);
|
|
113
|
+
|
|
114
|
+
// The simple command that starts the text: up to the first `|`, `;`, `&` or newline outside quotes. A `&` right after
|
|
115
|
+
// `>` or `<` is a redirection (`2>&1`), which ends nothing.
|
|
116
|
+
const SIMPLE_COMMAND = /^(?:[^|;&\n\r"']|(?<=[<>])&|"[^"]*"?|'[^']*'?)*/;
|
|
117
|
+
// One word, quoted pieces included. Backslashes stay as written: they are Windows path separators as often as escapes,
|
|
118
|
+
// and a slug has neither.
|
|
119
|
+
const WORD = /(?:[^\s"']|"[^"]*"?|'[^']*'?)+/g;
|
|
120
|
+
const REDIRECTION = /^\d*(?:>>?|<)/;
|
|
121
|
+
const BARE_REDIRECTION = /^\d*(?:>>?|<)$/;
|
|
122
|
+
|
|
123
|
+
const wordsOf = (text: string): string[] => [...(SIMPLE_COMMAND.exec(text)?.[0] ?? "").matchAll(WORD)].map((word) => word[0].replace(/["']/g, ""));
|
|
124
|
+
|
|
125
|
+
// A redirection (`2>&1`, `> log`, `>>out`) is not an operand; a bare `>` takes the word after it with it.
|
|
126
|
+
const withoutRedirections = (words: readonly string[]): string[] => {
|
|
127
|
+
const kept: string[] = [];
|
|
128
|
+
let skipNext = false;
|
|
129
|
+
for (const word of words) {
|
|
130
|
+
if (skipNext) {
|
|
131
|
+
skipNext = false;
|
|
132
|
+
} else if (BARE_REDIRECTION.test(word)) {
|
|
133
|
+
skipNext = true;
|
|
134
|
+
} else if (!REDIRECTION.test(word)) {
|
|
135
|
+
kept.push(word);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return kept;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
interface Operands {
|
|
142
|
+
readonly positionals: readonly string[];
|
|
143
|
+
/** Flags as written, `--flag=value` split: name → value (undefined for a switch). */
|
|
144
|
+
readonly flags: ReadonlyMap<string, string | undefined>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const operandsOf = (words: readonly string[], valueFlags: ReadonlySet<string>): Operands => {
|
|
148
|
+
const positionals: string[] = [];
|
|
149
|
+
const flags = new Map<string, string | undefined>();
|
|
150
|
+
const list = withoutRedirections(words);
|
|
151
|
+
for (const [index, word] of list.entries()) {
|
|
152
|
+
const equals = word.indexOf("=");
|
|
153
|
+
if (!word.startsWith("-") || word === "-") {
|
|
154
|
+
// The word after a flag that takes a value is that value, not an operand.
|
|
155
|
+
if (!valueFlags.has(list[index - 1] ?? "")) {
|
|
156
|
+
positionals.push(word);
|
|
157
|
+
}
|
|
158
|
+
} else if (word.startsWith("--") && equals !== -1) {
|
|
159
|
+
flags.set(word.slice(0, equals), word.slice(equals + 1));
|
|
160
|
+
} else {
|
|
161
|
+
flags.set(word, valueFlags.has(word) ? list[index + 1] : undefined);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { positionals, flags };
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
interface Aim {
|
|
168
|
+
readonly stops: boolean;
|
|
169
|
+
readonly targets: readonly string[];
|
|
170
|
+
readonly all: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// What `ic sandbox <op>` with these operands does to a running sandbox, and which sandboxes it aims at. The verbs that
|
|
174
|
+
// restart nothing say so: a start, a download, a resource ask saved for the next restart, a backup restored to a folder.
|
|
175
|
+
const icAim = (op: SandboxPowerOp, { positionals, flags }: Operands): Aim => {
|
|
176
|
+
const all = flags.has("--all") || flags.has("-a");
|
|
177
|
+
if (op === "start" || op === "prepare") {
|
|
178
|
+
return { stops: false, targets: positionals, all };
|
|
179
|
+
}
|
|
180
|
+
if (op === "resize") {
|
|
181
|
+
// `reshape --later` and `--forget` save an ask and restart nothing; `shape` restarts only with `--when now`.
|
|
182
|
+
const when = flags.get("--when");
|
|
183
|
+
const saves = flags.has("--later") || flags.has("--forget") || (when !== undefined && when !== "now");
|
|
184
|
+
return { stops: !saves, targets: positionals, all };
|
|
185
|
+
}
|
|
186
|
+
if (op === "restore") {
|
|
187
|
+
// A backup restored into a folder touches no sandbox; `--into <slug>` stops that one to write its volumes.
|
|
188
|
+
const into = flags.get("--into");
|
|
189
|
+
return into === undefined ? { stops: false, targets: [], all: false } : { stops: true, targets: [into], all: false };
|
|
190
|
+
}
|
|
191
|
+
if (op === "connect") {
|
|
192
|
+
return { stops: flags.has("--replace"), targets: [], all: false };
|
|
193
|
+
}
|
|
194
|
+
return { stops: true, targets: positionals, all };
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Every command in `command` that starts, stops or replaces a sandbox container, through `ic sandbox` or `docker`.
|
|
199
|
+
* A scan for the programs by name, not a shell parse: it is meant to over-include, since a caller that mistakes
|
|
200
|
+
* a mention for a command asks the owner once, and one that misses a command cuts a fleet's turns unasked.
|
|
201
|
+
*/
|
|
202
|
+
export const sandboxPowerCommands = (command: string): SandboxPowerCommand[] => {
|
|
203
|
+
const regions = inertRegions(command);
|
|
204
|
+
const found: SandboxPowerCommand[] = [];
|
|
205
|
+
for (const invocation of command.matchAll(IC_INVOCATION)) {
|
|
206
|
+
const op = IC_VERBS.get((invocation[1] ?? "").toLowerCase());
|
|
207
|
+
if (op !== undefined) {
|
|
208
|
+
const span = { start: invocation.index, end: invocation.index + invocation[0].length };
|
|
209
|
+
const aim = icAim(op, operandsOf(wordsOf(command.slice(span.end)), IC_VALUE_FLAGS));
|
|
210
|
+
found.push({ via: "ic", op, stops: aim.stops, targets: aim.targets, unnamed: aim.targets.length === 0 || aim.all, span, live: isLive(span, regions) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const invocation of command.matchAll(DOCKER_INVOCATION)) {
|
|
214
|
+
const op = DOCKER_VERBS.get((invocation[1] ?? "").toLowerCase());
|
|
215
|
+
if (op !== undefined) {
|
|
216
|
+
const span = { start: invocation.index, end: invocation.index + invocation[0].length };
|
|
217
|
+
const { positionals } = operandsOf(wordsOf(command.slice(span.end)), DOCKER_VALUE_FLAGS);
|
|
218
|
+
found.push({ via: "docker", op, stops: true, targets: positionals, unnamed: positionals.length === 0, span, live: isLive(span, regions) });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return found.toSorted((left, right) => left.span.start - right.span.start);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// A word whose value exists only when the shell runs it: `$(docker ps -q)`, `$NAME`, `*`, PowerShell's `(…)` and `%X%`.
|
|
225
|
+
const isExpansion = (word: string): boolean => /[$`*?%]|^\(/.test(word);
|
|
226
|
+
|
|
227
|
+
/** The sandbox a caller means to protect: its slug, and the container name docker knows it by. */
|
|
228
|
+
export interface OwnSandbox {
|
|
229
|
+
readonly slug: string;
|
|
230
|
+
readonly container: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether `hit` may stop `own`. Deliberately generous: it names it, names none (the device's only sandbox, `--all`, a
|
|
235
|
+
* pipe), or names something that is only worked out when the command runs. A target spelled as the bare slug counts for
|
|
236
|
+
* `docker` too, which knows the container by a longer name.
|
|
237
|
+
*/
|
|
238
|
+
export const mayReachSandbox = (hit: SandboxPowerCommand, own: OwnSandbox): boolean =>
|
|
239
|
+
hit.unnamed || hit.targets.some((target) => target === own.slug || target === own.container || isExpansion(target));
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether `hit` manages a sandbox that a device's "Manage sandboxes" switch covers, whichever sandbox it is: any
|
|
243
|
+
* `ic sandbox` verb here, and a `docker` verb aimed at a container whose name starts with `containerPrefix` (a sandbox's
|
|
244
|
+
* own, or its tunnel's). A docker target worked out at run time is not read: the device cannot know what it will be.
|
|
245
|
+
*/
|
|
246
|
+
export const managesSandbox = (hit: SandboxPowerCommand, containerPrefix: string): boolean =>
|
|
247
|
+
hit.via === "ic" || hit.targets.some((target) => target.startsWith(containerPrefix));
|
|
@@ -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
|