@capybara-hq/kernel 0.1.0-rc.22 → 0.1.0-rc.23

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 (42) hide show
  1. package/dist/admin/admin.controller.d.ts +3 -3
  2. package/dist/admin/admin.controller.js +6 -2
  3. package/dist/admin/admin.schemas.d.ts +13 -0
  4. package/dist/background/launch.d.ts +43 -5
  5. package/dist/background/launch.js +88 -8
  6. package/dist/background/session.d.ts +14 -2
  7. package/dist/background/session.js +26 -4
  8. package/dist/background/status.d.ts +1 -1
  9. package/dist/background/status.js +3 -3
  10. package/dist/background/stop.d.ts +0 -2
  11. package/dist/background/stop.js +1 -21
  12. package/dist/catalog/catalog-content.d.ts +5 -0
  13. package/dist/catalog/catalog-content.js +1 -0
  14. package/dist/catalog/marketplaces.service.d.ts +1 -1
  15. package/dist/catalog/requests.service.js +2 -2
  16. package/dist/config/schema.d.ts +3 -3
  17. package/dist/config/schema.js +9 -7
  18. package/dist/devices/devices.service.js +12 -6
  19. package/dist/errors.d.ts +4 -0
  20. package/dist/errors.js +4 -0
  21. package/dist/identity/settings.d.ts +53 -3
  22. package/dist/identity/settings.js +92 -9
  23. package/dist/infra/config/kernel-config.service.d.ts +7 -0
  24. package/dist/infra/config/kernel-config.service.js +20 -0
  25. package/dist/local-devices/local-device-gateway.d.ts +4 -2
  26. package/dist/local-devices/local-device-gateway.js +39 -14
  27. package/dist/local-devices/machines.d.ts +16 -0
  28. package/dist/local-devices/machines.js +51 -13
  29. package/dist/orgs/settings.controller.js +2 -2
  30. package/dist/orgs/settings.service.d.ts +11 -5
  31. package/dist/orgs/settings.service.js +28 -14
  32. package/dist/platform/ops/admin.ops.js +32 -7
  33. package/dist/platform/ops/org-settings.ops.js +3 -3
  34. package/dist/platform/ops/orgs.ops.js +1 -0
  35. package/dist/platform/ops/platform.ops.js +1 -1
  36. package/dist/sandbox/platform-tiers.d.ts +86 -0
  37. package/dist/sandbox/platform-tiers.js +97 -0
  38. package/dist/sandbox/sandbox.service.d.ts +1 -1
  39. package/dist/sandbox/sandbox.service.js +20 -6
  40. package/dist/sandbox/tiers.d.ts +14 -2
  41. package/dist/sandbox/tiers.js +8 -0
  42. package/package.json +2 -2
@@ -5,7 +5,7 @@ import { PluginIcons } from "../extensions/plugin-icons.js";
5
5
  import { type Account, AccountsService } from "../identity/accounts.js";
6
6
  import { type Principal } from "../identity/principal.js";
7
7
  import { SessionService } from "../identity/sessions.js";
8
- import { type DeploymentSettings, SettingsService } from "../identity/settings.js";
8
+ import { type DeploymentSettingsView, SettingsService } from "../identity/settings.js";
9
9
  import { KernelConfigService } from "../infra/config/kernel-config.service.js";
10
10
  import { type ModelDisabledPatch, type ModelProviderParams } from "../models/enablement.controller.js";
11
11
  import { type ModelDisableResult, ModelEnablement } from "../models/enablement.js";
@@ -67,8 +67,8 @@ export declare class AdminController {
67
67
  orgMembers({ orgId }: z.infer<typeof AdminOrgParams>): Promise<{
68
68
  members: Member[];
69
69
  }>;
70
- getSettings(): Promise<DeploymentSettings>;
71
- putSettings(principal: Principal, body: z.infer<typeof DeploymentSettingsBody>): Promise<DeploymentSettings>;
70
+ getSettings(): Promise<DeploymentSettingsView>;
71
+ putSettings(principal: Principal, body: z.infer<typeof DeploymentSettingsBody>): Promise<DeploymentSettingsView>;
72
72
  /**
73
73
  * 模型供应商与模型(后台「模型」页):每个供应商带状态,列模型失败的标 `error`,不影响其他供应商;
74
74
  * 一并返回价格表与币种(改价仍走 `PUT /admin/model-prices`)。Each provider carries its extension's
@@ -142,11 +142,15 @@ let AdminController = class AdminController {
142
142
  return { members: await this.members.list(orgId) };
143
143
  }
144
144
  async getSettings() {
145
- return this.settings.get();
145
+ return this.settings.view();
146
146
  }
147
147
  async putSettings(principal, body) {
148
148
  const actor = requireUser(principal);
149
- return this.settings.update(body, actor.account.id, patTokenId(actor));
149
+ const tokenId = patTokenId(actor);
150
+ // an API token may lower the platform's sandbox tiers, never raise them (dev tiers spec §2)
151
+ return this.settings.update(body, actor.account.id, tokenId, undefined, {
152
+ onlyNarrows: tokenId !== undefined,
153
+ });
150
154
  }
151
155
  /**
152
156
  * 模型供应商与模型(后台「模型」页):每个供应商带状态,列模型失败的标 `error`,不影响其他供应商;
@@ -58,6 +58,19 @@ export declare const DeploymentSettingsBody: z.ZodObject<{
58
58
  enabled: z.ZodOptional<z.ZodBoolean>;
59
59
  disabled: z.ZodOptional<z.ZodArray<z.ZodString>>;
60
60
  }, z.core.$strict>>;
61
+ sandboxTiers: z.ZodOptional<z.ZodObject<{
62
+ max: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
63
+ dev: "dev";
64
+ open: "open";
65
+ strict: "strict";
66
+ }>>>;
67
+ default: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
68
+ dev: "dev";
69
+ open: "open";
70
+ strict: "strict";
71
+ }>>>;
72
+ allowUnsandboxed: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
73
+ }, z.core.$strict>>;
61
74
  }, z.core.$strict>;
62
75
  export declare const PurposeParams: z.ZodObject<{
63
76
  purpose: z.ZodString;
@@ -28,14 +28,52 @@ export declare const taskSidPath: (id: string) => string;
28
28
  export declare function newTaskId(): string;
29
29
  /** A task id ({@link newTaskId}). */
30
30
  export declare const TASK_ID_RE: RegExp;
31
+ /**
32
+ * A shell fragment that sets how a task's processes are found and signalled by its sid, defining
33
+ * `tk_alive <sid>` (status 0 while the task has a process) and `tk_kill <SIGNAL> <sid>` (0: sent,
34
+ * 1: no process left, anything else: a failure), and `pg` to the mode:
35
+ * - `-s`, the session, where `pgrep` has `-s` (Linux procps and busybox, the sandbox-fake shims:
36
+ * `pgrep -s 0`, the own session, exits 0 or 1): `pgrep -s` and `pkill -s`;
37
+ * - `-g`, the process group, elsewhere (macOS). Its `pgrep` has no `-s`, and inside the OS sandbox
38
+ * (Seatbelt) a command can neither list processes nor signal those of another command. So the
39
+ * task's process group (its id is the sid) is watched by its launcher ({@link GROUP_LAUNCHER}),
40
+ * which signals it on request: `tk_kill` appends the signal to `.capybara/tasks/g<sid>.ctl`, and
41
+ * `tk_alive` asks `kill(0, -sid)`, where "not permitted" (another command's group) still means
42
+ * the group exists. A group signal is one system call, so it misses no process forked meanwhile.
43
+ * Relative to the workspace root. Every script that asks for or signals a task's processes starts
44
+ * with it; the launch picks its launcher with the same test.
45
+ */
46
+ export declare const SESSION_MODE_SCRIPT: string;
47
+ /**
48
+ * The process group launcher (macOS: {@link SESSION_MODE_SCRIPT}'s `-g`), a perl program run as
49
+ * `perl -e <this> <seconds> <task path>` from the workspace root. It makes a session of its own
50
+ * (leaving the launch exec's process group), starts `bash <task>.sh` as the leader of a new process
51
+ * group (the task; its id, the sid, goes to `<task>.sid` through a rename) and watches it every
52
+ * 0.1 s, outside the group:
53
+ * - the command exits: its status as the shell gives it (`128 + signal` when killed) goes to
54
+ * `<task>.exit`, and what it left running in the group is SIGKILLed (the task ends when its
55
+ * command exits, as the poll does on Linux);
56
+ * - the time limit passes: 137 goes to `<task>.exit`, then the group is SIGKILLed (as
57
+ * `timeout -s KILL`);
58
+ * - a signal is requested in `g<sid>.ctl` ({@link SESSION_MODE_SCRIPT}'s `tk_kill`, read through
59
+ * a rename): the group gets it, and no exit file is written any more (a stop, as on Linux).
60
+ * It exits once the group is empty. Contains no single quote (it goes between single quotes).
61
+ */
62
+ export declare const GROUP_LAUNCHER: string;
31
63
  /**
32
64
  * The shell of the launch exec: writes `command` (base64 in the script, so nothing is quoted by
33
65
  * hand) to `.capybara/tasks/<id>.sh` and starts it with bash (the shell a foreground `Bash` call
34
- * runs its command in) in a session of its own under `timeout -s KILL <seconds>`, with its exit
35
- * status written to `<id>.exit` and its output to `<id>.out`; writes the session id (`$!`, the pid
36
- * `setsid` started) to `<id>.sid` and prints it. An environment without one of the needed programs fails with a readable error on
37
- * stderr and exit code 127, before anything starts. `id` must be a task id and `seconds` a
38
- * positive integer (both go into the script unquoted).
66
+ * runs its command in), detached from the exec under a hard time limit of `seconds` that SIGKILLs
67
+ * every process of the task, with its exit status written to `<id>.exit` and its output to
68
+ * `<id>.out`; the task's sid goes to `<id>.sid` and is printed. Where `pgrep` has `-s`
69
+ * ({@link SESSION_MODE_SCRIPT}) the command runs in a session of its own under
70
+ * `setsid nohup sh -c 'timeout -s KILL <seconds> …'` and the sid is `$!`, the pid `setsid` started;
71
+ * elsewhere (macOS: no `setsid`, no `timeout`, no `pgrep -s`) {@link GROUP_LAUNCHER} runs it in a
72
+ * process group of its own, watched by the launcher, and writes the sid (the group id) itself,
73
+ * which the launch waits for (up to 10 s). An
74
+ * environment without one of the needed programs fails with a readable error on stderr and exit
75
+ * code 127, before anything starts. `id` must be a task id and `seconds` a positive integer (both
76
+ * go into the script unquoted).
39
77
  */
40
78
  export declare function launchScript(id: string, command: string, seconds: number): string;
41
79
  /** The model-visible result of a launch (spec §2.1); `workspace`: the instance's workspace root. */
@@ -34,18 +34,88 @@ export function newTaskId() {
34
34
  out += BASE32[b & 31];
35
35
  return out;
36
36
  }
37
- /** The programs the launch and the later polls and stops need in the environment. */
38
- const REQUIRED = ["bash", "setsid", "nohup", "timeout", "pgrep", "pkill", "base64"];
37
+ /** The programs a session launch and the later polls and stops need (Linux, sandbox-fake). */
38
+ const REQUIRED_SESSION = ["bash", "setsid", "nohup", "timeout", "pgrep", "pkill", "base64"];
39
+ /** The programs a process group launch and the later polls and stops need (macOS). */
40
+ const REQUIRED_GROUP = ["bash", "perl", "nohup", "base64"];
39
41
  /** A task id ({@link newTaskId}). */
40
42
  export const TASK_ID_RE = /^bt_[a-z2-7]{6}$/;
43
+ /**
44
+ * A shell fragment that sets how a task's processes are found and signalled by its sid, defining
45
+ * `tk_alive <sid>` (status 0 while the task has a process) and `tk_kill <SIGNAL> <sid>` (0: sent,
46
+ * 1: no process left, anything else: a failure), and `pg` to the mode:
47
+ * - `-s`, the session, where `pgrep` has `-s` (Linux procps and busybox, the sandbox-fake shims:
48
+ * `pgrep -s 0`, the own session, exits 0 or 1): `pgrep -s` and `pkill -s`;
49
+ * - `-g`, the process group, elsewhere (macOS). Its `pgrep` has no `-s`, and inside the OS sandbox
50
+ * (Seatbelt) a command can neither list processes nor signal those of another command. So the
51
+ * task's process group (its id is the sid) is watched by its launcher ({@link GROUP_LAUNCHER}),
52
+ * which signals it on request: `tk_kill` appends the signal to `.capybara/tasks/g<sid>.ctl`, and
53
+ * `tk_alive` asks `kill(0, -sid)`, where "not permitted" (another command's group) still means
54
+ * the group exists. A group signal is one system call, so it misses no process forked meanwhile.
55
+ * Relative to the workspace root. Every script that asks for or signals a task's processes starts
56
+ * with it; the launch picks its launcher with the same test.
57
+ */
58
+ export const SESSION_MODE_SCRIPT = [
59
+ 'if pgrep -s 0 >/dev/null 2>&1 || [ "$?" -eq 1 ]; then pg=-s',
60
+ 'tk_alive() { pgrep -s "$1" >/dev/null 2>&1; }',
61
+ 'tk_kill() { pkill -"$1" -s "$2"; }',
62
+ "else pg=-g",
63
+ `tk_alive() { perl -e 'exit((kill(0, -$ARGV[0]) || $!{EPERM}) ? 0 : 1)' "$1"; }`,
64
+ `tk_kill() { tk_alive "$2" || return 1; echo "$1" >> ${TASKS_DIR}/g"$2".ctl || return 2; }`,
65
+ "fi",
66
+ ].join("\n");
67
+ /**
68
+ * The process group launcher (macOS: {@link SESSION_MODE_SCRIPT}'s `-g`), a perl program run as
69
+ * `perl -e <this> <seconds> <task path>` from the workspace root. It makes a session of its own
70
+ * (leaving the launch exec's process group), starts `bash <task>.sh` as the leader of a new process
71
+ * group (the task; its id, the sid, goes to `<task>.sid` through a rename) and watches it every
72
+ * 0.1 s, outside the group:
73
+ * - the command exits: its status as the shell gives it (`128 + signal` when killed) goes to
74
+ * `<task>.exit`, and what it left running in the group is SIGKILLed (the task ends when its
75
+ * command exits, as the poll does on Linux);
76
+ * - the time limit passes: 137 goes to `<task>.exit`, then the group is SIGKILLed (as
77
+ * `timeout -s KILL`);
78
+ * - a signal is requested in `g<sid>.ctl` ({@link SESSION_MODE_SCRIPT}'s `tk_kill`, read through
79
+ * a rename): the group gets it, and no exit file is written any more (a stop, as on Linux).
80
+ * It exits once the group is empty. Contains no single quote (it goes between single quotes).
81
+ */
82
+ export const GROUP_LAUNCHER = [
83
+ "use POSIX;",
84
+ "my ($n, $t) = @ARGV;",
85
+ "my ($d) = $t =~ m{^(.*)/};",
86
+ 'sub put { my ($f, $v) = @_; open(my $h, ">", "$f.tmp") or return; print $h "$v\\n"; close($h); rename("$f.tmp", $f) }',
87
+ 'POSIX::setsid() or die "setsid: $!\\n";',
88
+ "my $c = fork();",
89
+ 'defined $c or die "fork: $!\\n";',
90
+ 'if ($c == 0) { setpgid(0, 0); exec("bash", "$t.sh") or POSIX::_exit(127) }',
91
+ "setpgid($c, $c);",
92
+ 'put("$t.sid", $c);',
93
+ 'my $ctl = "$d/g$c.ctl";',
94
+ "my $end = time() + $n;",
95
+ "my ($code, $done);",
96
+ 'sub fin { put("$t.exit", $_[0]) unless $done; $done = 1 }',
97
+ "while (1) {",
98
+ ' if (rename($ctl, "$ctl.$$")) { open(my $h, "<", "$ctl.$$"); my @s = grep { /^[A-Z0-9]+$/ } map { s/\\s+//gr } <$h>; close($h); unlink("$ctl.$$"); $done = 1; kill($_, -$c) for @s }',
99
+ ' if (!defined $code && waitpid($c, WNOHANG) == $c) { $code = ($? & 127) ? 128 + ($? & 127) : $? >> 8; fin($code); kill("KILL", -$c) }',
100
+ ' if (!defined $code && time() >= $end) { fin(137); kill("KILL", -$c) }',
101
+ " last if defined $code && !kill(0, -$c);",
102
+ " select(undef, undef, undef, 0.1);",
103
+ "}",
104
+ ].join(" ");
41
105
  /**
42
106
  * The shell of the launch exec: writes `command` (base64 in the script, so nothing is quoted by
43
107
  * hand) to `.capybara/tasks/<id>.sh` and starts it with bash (the shell a foreground `Bash` call
44
- * runs its command in) in a session of its own under `timeout -s KILL <seconds>`, with its exit
45
- * status written to `<id>.exit` and its output to `<id>.out`; writes the session id (`$!`, the pid
46
- * `setsid` started) to `<id>.sid` and prints it. An environment without one of the needed programs fails with a readable error on
47
- * stderr and exit code 127, before anything starts. `id` must be a task id and `seconds` a
48
- * positive integer (both go into the script unquoted).
108
+ * runs its command in), detached from the exec under a hard time limit of `seconds` that SIGKILLs
109
+ * every process of the task, with its exit status written to `<id>.exit` and its output to
110
+ * `<id>.out`; the task's sid goes to `<id>.sid` and is printed. Where `pgrep` has `-s`
111
+ * ({@link SESSION_MODE_SCRIPT}) the command runs in a session of its own under
112
+ * `setsid nohup sh -c 'timeout -s KILL <seconds> …'` and the sid is `$!`, the pid `setsid` started;
113
+ * elsewhere (macOS: no `setsid`, no `timeout`, no `pgrep -s`) {@link GROUP_LAUNCHER} runs it in a
114
+ * process group of its own, watched by the launcher, and writes the sid (the group id) itself,
115
+ * which the launch waits for (up to 10 s). An
116
+ * environment without one of the needed programs fails with a readable error on stderr and exit
117
+ * code 127, before anything starts. `id` must be a task id and `seconds` a positive integer (both
118
+ * go into the script unquoted).
49
119
  */
50
120
  export function launchScript(id, command, seconds) {
51
121
  if (!TASK_ID_RE.test(id))
@@ -55,15 +125,25 @@ export function launchScript(id, command, seconds) {
55
125
  }
56
126
  const b64 = Buffer.from(command, "utf8").toString("base64");
57
127
  const t = `${TASKS_DIR}/${id}`;
128
+ const need = (list) => `for c in ${list.join(" ")}; do command -v "$c" >/dev/null 2>&1 || { echo "Background tasks need ${list.join(", ")} in this environment; $c is missing." >&2; exit 127; }; done`;
58
129
  return [
59
130
  "set -e",
60
- `for c in ${REQUIRED.join(" ")}; do command -v "$c" >/dev/null 2>&1 || { echo "Background tasks need ${REQUIRED.join(", ")} in this environment; $c is missing." >&2; exit 127; }; done`,
131
+ SESSION_MODE_SCRIPT,
132
+ `if [ "$pg" = -s ]; then ${need(REQUIRED_SESSION)}; else ${need(REQUIRED_GROUP)}; fi`,
61
133
  `mkdir -p ${TASKS_DIR}`,
62
134
  `printf '%s' '${b64}' | base64 -d > ${t}.sh`,
135
+ `if [ "$pg" = -s ]; then`,
63
136
  `setsid nohup sh -c 'timeout -s KILL ${seconds} bash ${t}.sh; echo $? > ${t}.exit' > ${t}.out 2>&1 < /dev/null &`,
64
137
  // the session id goes to a file first: a launch that ends before its output comes back
65
138
  // (aborted, timed out) still leaves the id of what it started
66
139
  `echo $! > ${t}.sid`,
140
+ "else",
141
+ `rm -f ${t}.sid`,
142
+ `nohup perl -e '${GROUP_LAUNCHER}' ${seconds} ${t} > ${t}.out 2>&1 < /dev/null &`,
143
+ // the launcher writes the group id once the group exists
144
+ `i=0; while [ ! -s ${t}.sid ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i+1)); done`,
145
+ `[ -s ${t}.sid ] || { echo "The background task did not start: no process group id after 10 s." >&2; exit 1; }`,
146
+ "fi",
67
147
  `cat ${t}.sid`,
68
148
  ].join("\n");
69
149
  }
@@ -4,7 +4,8 @@ import type { HeldProvider, TaggedProvider } from "../sandbox/sandbox.service.js
4
4
  * A background task's session inside the sandbox (spec 2026-11-05-background-bash-design §4.2):
5
5
  * every process of a task is in the session `setsid` started, so liveness is `pgrep -s <sid>` and
6
6
  * stopping is `pkill -s <sid>` (never `kill -0` or `/proc`, which disagree between sandbox-fake and
7
- * Linux).
7
+ * Linux). macOS has no `setsid` and its `pgrep` / `pkill` no `-s`: there the task is the process
8
+ * group the launch made (`launchScript`), signalled with `kill -- -<sid>` ({@link SESSION_MODE_SCRIPT}).
8
9
  */
9
10
  /**
10
11
  * `p` with every `exec` carrying `actor` (`SandboxService.ifRunning` tags execs but leaves the
@@ -27,7 +28,8 @@ export declare function assertTaskRef(id: string, sid: string): void;
27
28
  * session is empty (pkill exits 1: no process left): a process may fork between pkill's listing
28
29
  * and its signals, and the child would live on. Sets `ks` to 0 when the session was confirmed
29
30
  * empty, else to pkill's failing status (3 when it was still not empty after 10 tries). Uses only
30
- * the variables `ks`, `kc` and `ki`. `sid` must be a session id.
31
+ * the variables and functions of {@link SESSION_MODE_SCRIPT} (it starts with it), `ks`, `kc` and `ki`. `sid` must be
32
+ * a session id.
31
33
  */
32
34
  export declare function killSessionScript(sid: string): string;
33
35
  /**
@@ -37,6 +39,16 @@ export declare function killSessionScript(sid: string): string;
37
39
  * empty; false when it could not be (an error is never thrown).
38
40
  */
39
41
  export declare function killSession(p: Pick<HeldProvider, "exec">, h: SandboxHandle, sid: string): Promise<boolean>;
42
+ /** How long a stop waits after SIGTERM before SIGKILL (spec §2.3). */
43
+ export declare const STOP_GRACE_MS = 5000;
44
+ /** The time limit of the SIGTERM exec: the grace period plus some room. */
45
+ export declare const TERM_EXEC_TIMEOUT_MS: number;
46
+ /**
47
+ * Exit code 0 with `exited <code>` when the task had already finished (its exit file exists),
48
+ * else sends SIGTERM to the session and waits up to {@link STOP_GRACE_MS} for it to empty, then
49
+ * prints `term`. Relative to the workspace root.
50
+ */
51
+ export declare function termScript(id: string, sid: string): string;
40
52
  /** The longest `lastLines` of a finished task (spec §2.4). */
41
53
  export declare const LAST_LINES_MAX_CHARS = 4000;
42
54
  /**
@@ -1,10 +1,11 @@
1
1
  import { rootsOf } from "@capybara-hq/plugin-sdk";
2
- import { TASK_ID_RE, TASKS_DIR } from "./launch.js";
2
+ import { SESSION_MODE_SCRIPT, TASK_ID_RE, TASKS_DIR } from "./launch.js";
3
3
  /**
4
4
  * A background task's session inside the sandbox (spec 2026-11-05-background-bash-design §4.2):
5
5
  * every process of a task is in the session `setsid` started, so liveness is `pgrep -s <sid>` and
6
6
  * stopping is `pkill -s <sid>` (never `kill -0` or `/proc`, which disagree between sandbox-fake and
7
- * Linux).
7
+ * Linux). macOS has no `setsid` and its `pgrep` / `pkill` no `-s`: there the task is the process
8
+ * group the launch made (`launchScript`), signalled with `kill -- -<sid>` ({@link SESSION_MODE_SCRIPT}).
8
9
  */
9
10
  /**
10
11
  * `p` with every `exec` carrying `actor` (`SandboxService.ifRunning` tags execs but leaves the
@@ -35,12 +36,13 @@ export function assertTaskRef(id, sid) {
35
36
  * session is empty (pkill exits 1: no process left): a process may fork between pkill's listing
36
37
  * and its signals, and the child would live on. Sets `ks` to 0 when the session was confirmed
37
38
  * empty, else to pkill's failing status (3 when it was still not empty after 10 tries). Uses only
38
- * the variables `ks`, `kc` and `ki`. `sid` must be a session id.
39
+ * the variables and functions of {@link SESSION_MODE_SCRIPT} (it starts with it), `ks`, `kc` and `ki`. `sid` must be
40
+ * a session id.
39
41
  */
40
42
  export function killSessionScript(sid) {
41
43
  if (!SID_RE.test(sid))
42
44
  throw new Error(`invalid background task session: ${JSON.stringify(sid)}`);
43
- return `ks=3; for ki in 1 2 3 4 5 6 7 8 9 10; do pkill -KILL -s ${sid}; kc=$?; if [ "$kc" -eq 1 ]; then ks=0; break; fi; if [ "$kc" -ne 0 ]; then ks=$kc; break; fi; sleep 0.2; done`;
45
+ return `${SESSION_MODE_SCRIPT}\nks=3; for ki in 1 2 3 4 5 6 7 8 9 10; do tk_kill KILL ${sid}; kc=$?; if [ "$kc" -eq 1 ]; then ks=0; break; fi; if [ "$kc" -ne 0 ]; then ks=$kc; break; fi; sleep 0.2; done`;
44
46
  }
45
47
  /**
46
48
  * Kills every process of the session `sid` ({@link killSessionScript}) in an exec of its own. Any
@@ -62,6 +64,26 @@ export async function killSession(p, h, sid) {
62
64
  .catch(() => undefined);
63
65
  return r?.exitCode === 0;
64
66
  }
67
+ /** How long a stop waits after SIGTERM before SIGKILL (spec §2.3). */
68
+ export const STOP_GRACE_MS = 5000;
69
+ /** The time limit of the SIGTERM exec: the grace period plus some room. */
70
+ export const TERM_EXEC_TIMEOUT_MS = STOP_GRACE_MS + 5000;
71
+ /**
72
+ * Exit code 0 with `exited <code>` when the task had already finished (its exit file exists),
73
+ * else sends SIGTERM to the session and waits up to {@link STOP_GRACE_MS} for it to empty, then
74
+ * prints `term`. Relative to the workspace root.
75
+ */
76
+ export function termScript(id, sid) {
77
+ const f = `${TASKS_DIR}/${id}`;
78
+ const tries = Math.ceil(STOP_GRACE_MS / 200);
79
+ return [
80
+ `if [ -s ${f}.exit ]; then echo "exited $(cat ${f}.exit)"; exit 0; fi`,
81
+ SESSION_MODE_SCRIPT,
82
+ `tk_kill TERM ${sid} >/dev/null 2>&1`,
83
+ `i=0; while [ "$i" -lt ${tries} ] && tk_alive ${sid}; do sleep 0.2; i=$((i+1)); done`,
84
+ "echo term",
85
+ ].join("\n");
86
+ }
65
87
  /** The most bytes of a task's output end a tail reads (the event keeps at most 4 000 characters). */
66
88
  const TAIL_BYTES = 12_000;
67
89
  /** The longest `lastLines` of a finished task (spec §2.4). */
@@ -5,7 +5,7 @@ export interface TaskReport {
5
5
  /** The exit file's code; null while there is none. */
6
6
  exitCode: number | null;
7
7
  /**
8
- * Whether the session still has a process: `pgrep -s <sid>`, or, for a task whose command
8
+ * Whether the session still has a process: `pgrep -s <sid>` (`-g` on macOS), or, for a task whose command
9
9
  * exited, whether its leftover processes could not be confirmed killed.
10
10
  */
11
11
  alive: boolean;
@@ -1,4 +1,4 @@
1
- import { TASK_ID_RE, TASKS_DIR } from "./launch.js";
1
+ import { SESSION_MODE_SCRIPT, TASK_ID_RE, TASKS_DIR } from "./launch.js";
2
2
  import { decodeTail, killSessionScript, SID_RE, tailCommand } from "./session.js";
3
3
  import { exitedAs } from "./stop.js";
4
4
  /**
@@ -17,7 +17,7 @@ export function pollScript(tasks) {
17
17
  }
18
18
  const f = `${TASKS_DIR}/${id}`;
19
19
  return [
20
- `if pgrep -s ${sid} >/dev/null 2>&1; then a=1; else a=0; fi`,
20
+ `if tk_alive ${sid}; then a=1; else a=0; fi`,
21
21
  `if [ -s ${f}.exit ]; then c=$(tr -dc '0-9-' < ${f}.exit); else c=-; fi`,
22
22
  `[ -n "$c" ] || c=-`,
23
23
  `if [ "$c" != - ] && [ "$a" = 1 ]; then ${killSessionScript(sid)}; [ "$ks" = 0 ] && a=0; fi`,
@@ -25,7 +25,7 @@ export function pollScript(tasks) {
25
25
  `echo "${id} $c $a \${t:--}"`,
26
26
  ].join("\n");
27
27
  });
28
- return lines.join("\n");
28
+ return [SESSION_MODE_SCRIPT, ...lines].join("\n");
29
29
  }
30
30
  /** Parses {@link pollScript}'s output; a task missing from it is absent from the map. */
31
31
  export function parsePollOutput(stdout) {
@@ -5,8 +5,6 @@ import { type BackgroundFinish, BackgroundTaskFinisher } from "./finish.js";
5
5
  import { type BackgroundTaskRow, BackgroundTaskStore } from "./store.js";
6
6
  /** Why a task is stopped. */
7
7
  export type StopReason = "tool" | "person" | "subagent_end" | "conversation_deleted";
8
- /** How long a stop waits after SIGTERM before SIGKILL (spec §2.3). */
9
- export declare const STOP_GRACE_MS = 5000;
10
8
  /** How a task that exited by itself ends (spec §4.3): exit 137 past its deadline timed out. */
11
9
  export declare function exitedAs(task: BackgroundTaskRow, code: number, now: Date): BackgroundFinish;
12
10
  /** How a task whose sandbox is not running ends (`ifRunning`'s `not_running` status). */
@@ -13,30 +13,10 @@ import { KernelDb } from "../infra/database/kernel-db.js";
13
13
  import { KERNEL_LOGGER } from "../infra/tokens.js";
14
14
  import { SandboxService } from "../sandbox/sandbox.service.js";
15
15
  import { BackgroundTaskFinisher, } from "./finish.js";
16
- import { TASKS_DIR } from "./launch.js";
17
- import { actingAs, assertTaskRef, decodeTail, killSession, tailCommand, taskActor, } from "./session.js";
16
+ import { actingAs, assertTaskRef, decodeTail, killSession, TERM_EXEC_TIMEOUT_MS, tailCommand, taskActor, termScript, } from "./session.js";
18
17
  import { BackgroundTaskStore } from "./store.js";
19
- /** How long a stop waits after SIGTERM before SIGKILL (spec §2.3). */
20
- export const STOP_GRACE_MS = 5000;
21
- /** The time limit of the SIGTERM exec: the grace period plus some room. */
22
- const TERM_EXEC_TIMEOUT_MS = STOP_GRACE_MS + 5000;
23
18
  /** The time limit of reading the last lines after a stop. */
24
19
  const TAIL_EXEC_TIMEOUT_MS = 10_000;
25
- /**
26
- * Exit code 0 with `exited <code>` when the task had already finished (its exit file exists),
27
- * else sends SIGTERM to the session and waits up to {@link STOP_GRACE_MS} for it to empty, then
28
- * prints `term`. Relative to the workspace root.
29
- */
30
- function termScript(id, sid) {
31
- const f = `${TASKS_DIR}/${id}`;
32
- const tries = Math.ceil(STOP_GRACE_MS / 200);
33
- return [
34
- `if [ -s ${f}.exit ]; then echo "exited $(cat ${f}.exit)"; exit 0; fi`,
35
- `pkill -TERM -s ${sid} >/dev/null 2>&1`,
36
- `i=0; while [ "$i" -lt ${tries} ] && pgrep -s ${sid} >/dev/null 2>&1; do sleep 0.2; i=$((i+1)); done`,
37
- "echo term",
38
- ].join("\n");
39
- }
40
20
  /** How a task that exited by itself ends (spec §4.3): exit 137 past its deadline timed out. */
41
21
  export function exitedAs(task, code, now) {
42
22
  if (code === 0)
@@ -72,6 +72,11 @@ export interface OrgCatalogTemplate {
72
72
  editableEndpoint: string[];
73
73
  auth: Record<string, unknown>;
74
74
  ownership: "org" | "personal" | null;
75
+ /**
76
+ * The template file says `defaultEnabled: false`: it starts switched off in every org, so the
77
+ * install wizard does not ask for it (an org admin sets it up when switching it on).
78
+ */
79
+ defaultOff: boolean;
75
80
  /**
76
81
  * The template's setup guide in the item's latest version (`GET …/catalog/:itemId/guides/
77
82
  * :guideId`), null without one (spec 2026-11-09-setup-guides-v2-design.md §9.3).
@@ -29,6 +29,7 @@ export function orgTemplateView(id, file, guide = null) {
29
29
  auth: t.auth,
30
30
  ownership: t.ownership ??
31
31
  (typeof t.auth.ownership === "string" ? t.auth.ownership : null),
32
+ defaultOff: file.defaultEnabled === false,
32
33
  guide,
33
34
  };
34
35
  }
@@ -188,7 +188,7 @@ export declare function headsToResolve(entries: readonly MarketplaceEntry[], imp
188
188
  /** The host versions (an entry's `minKernel` is judged against them). */
189
189
  export declare const HOST_VERSIONS: {
190
190
  readonly kernel: string;
191
- readonly sdk: "0.1.0-rc.16";
191
+ readonly sdk: "0.1.0-rc.17";
192
192
  };
193
193
  /** Whether this deployment satisfies the entry's `minKernel`. */
194
194
  export declare const entryCompatible: (e: Pick<MarketplaceEntry, "minKernel">) => boolean;
@@ -201,7 +201,7 @@ let CatalogRequestsService = class CatalogRequestsService {
201
201
  const t = this.db;
202
202
  await this.orgLock.lockAndAuthorize(orgId, actor, "catalog.request");
203
203
  const hosts = [...new Set(input.hosts.map((h) => h.toLowerCase()))].sort();
204
- const deployment = this.orgSettings.deployment().sandboxEgress;
204
+ const deployment = (await this.orgSettings.deployment()).sandboxEgress;
205
205
  if (deployment.mode === "none")
206
206
  throw deploymentEgressNone();
207
207
  const settings = await readOrgSettings(t, orgId);
@@ -621,7 +621,7 @@ let CatalogRequestsService = class CatalogRequestsService {
621
621
  */
622
622
  async approveNetwork(orgId, actor, requestId, detail, opts) {
623
623
  const hosts = detail?.hosts ?? [];
624
- const deployment = this.orgSettings.deployment().sandboxEgress;
624
+ const deployment = (await this.orgSettings.deployment()).sandboxEgress;
625
625
  if (deployment.mode === "none")
626
626
  throw deploymentEgressNone();
627
627
  const current = (await readOrgSettings(this.db, orgId)).sandboxEgress;
@@ -228,17 +228,17 @@ export declare const KernelConfigSchema: z.ZodObject<{
228
228
  devHosts: z.ZodDefault<z.ZodArray<z.ZodString>>;
229
229
  }, z.core.$strip>>;
230
230
  tiers: z.ZodPrefault<z.ZodObject<{
231
- max: z.ZodDefault<z.ZodEnum<{
231
+ max: z.ZodOptional<z.ZodEnum<{
232
232
  dev: "dev";
233
233
  open: "open";
234
234
  strict: "strict";
235
235
  }>>;
236
- default: z.ZodDefault<z.ZodEnum<{
236
+ default: z.ZodOptional<z.ZodEnum<{
237
237
  dev: "dev";
238
238
  open: "open";
239
239
  strict: "strict";
240
240
  }>>;
241
- allowUnsandboxed: z.ZodDefault<z.ZodBoolean>;
241
+ allowUnsandboxed: z.ZodOptional<z.ZodBoolean>;
242
242
  }, z.core.$strip>>;
243
243
  }, z.core.$strip>>;
244
244
  devices: z.ZodPrefault<z.ZodObject<{
@@ -1246,27 +1246,29 @@ export const KernelConfigSchema = z.object({
1246
1246
  .object({
1247
1247
  max: z
1248
1248
  .enum(["strict", "dev", "open"])
1249
- .default("dev")
1249
+ .optional()
1250
1250
  .meta(meta({
1251
1251
  hot: true,
1252
- description: "Highest sandbox tier any organization may reach: strict (the allowlist only), dev (adds devHosts and local development affordances) or open (any public host); organizations and devices choose at most this",
1252
+ description: "Highest sandbox tier any organization may reach: strict (the allowlist only), dev (adds devHosts and local development affordances) or open (any public host); organizations and devices choose at most this. Unset: the super admin's choice in the admin console, else dev; set here, it is read-only there",
1253
1253
  })),
1254
1254
  default: z
1255
1255
  .enum(["strict", "dev", "open"])
1256
- .default("dev")
1256
+ .optional()
1257
1257
  .meta(meta({
1258
1258
  hot: true,
1259
- description: "Sandbox tier of a device whose owner chose none, unless its organization sets its own default; at most sandbox.tiers.max",
1259
+ description: "Sandbox tier of a device whose owner chose none, unless its organization sets its own default; at most sandbox.tiers.max. Unset: the super admin's choice in the admin console, else dev; set here, it is read-only there",
1260
1260
  })),
1261
1261
  allowUnsandboxed: z
1262
1262
  .boolean()
1263
- .default(true)
1263
+ .optional()
1264
1264
  .meta(meta({
1265
1265
  hot: true,
1266
- description: "Whether a local device may run commands without a sandbox (its owner's local choice); it only applies where the organization's ceiling is open, and an organization may forbid it",
1266
+ description: "Whether a local device may run commands without a sandbox (its owner's local choice); it only applies where the organization's ceiling is open, and an organization may forbid it. Unset: the super admin's choice in the admin console, else true; set here, it is read-only there",
1267
1267
  })),
1268
1268
  })
1269
- .refine((t) => tierAtMost(t.default, t.max), {
1269
+ // both set here: the default may not be above the max (a max from the admin console
1270
+ // caps a default set here instead, see sandbox/platform-tiers.ts)
1271
+ .refine((t) => t.default === undefined || t.max === undefined || tierAtMost(t.default, t.max), {
1270
1272
  message: "sandbox.tiers.default must not be above sandbox.tiers.max",
1271
1273
  path: ["default"],
1272
1274
  })
@@ -29,7 +29,7 @@ import { readOrgSettings } from "../orgs/settings.service.js";
29
29
  import { SandboxJobs } from "../sandbox/jobs.js";
30
30
  import { isInternalProvider, LOCAL_PROVIDER, SandboxProviders } from "../sandbox/providers.js";
31
31
  import { deviceNotFound, SandboxService } from "../sandbox/sandbox.service.js";
32
- import { effectiveSandboxPolicy } from "../sandbox/tiers.js";
32
+ import { effectiveSandboxPolicy, readDeploymentSandboxPolicy, } from "../sandbox/tiers.js";
33
33
  import { unattendedTierOf } from "../schedules/unattended.js";
34
34
  import { DeviceAccess, demotedProvider, deviceProvider, deviceUnavailable, inUseForDefault, } from "./access.js";
35
35
  /** How many recent sandbox changes a device view lists. */
@@ -293,7 +293,11 @@ let DevicesService = class DevicesService {
293
293
  }
294
294
  /** The org's sandbox policy for a device choosing `tier` (null: the org's default). */
295
295
  async policyFor(orgId, tier) {
296
- return effectiveSandboxPolicy(this.config.get().sandbox, await readOrgSettings(this.db.db, orgId), tier);
296
+ const [deployment, settings] = await Promise.all([
297
+ readDeploymentSandboxPolicy(this.db.db, this.config),
298
+ readOrgSettings(this.db.db, orgId),
299
+ ]);
300
+ return effectiveSandboxPolicy(deployment, settings, tier);
297
301
  }
298
302
  /**
299
303
  * The sandbox level a sandbox call in the device runs at now, for the model (`device_paths`,
@@ -318,11 +322,12 @@ let DevicesService = class DevicesService {
318
322
  .executeTakeFirst();
319
323
  if (row === undefined)
320
324
  return null;
321
- const [settings, unattended] = await Promise.all([
325
+ const [deployment, settings, unattended] = await Promise.all([
326
+ readDeploymentSandboxPolicy(this.db.db, this.config),
322
327
  readOrgSettings(this.db.db, orgId),
323
328
  runId === undefined ? null : unattendedTierOf(this.db.db, runId, deviceId),
324
329
  ]);
325
- const policyOf = (tier) => effectiveSandboxPolicy(this.config.get().sandbox, settings, tier);
330
+ const policyOf = (tier) => effectiveSandboxPolicy(deployment, settings, tier);
326
331
  const summary = localPolicyOf(row.local_policy);
327
332
  const s = sandboxStateOf(policyOf, {
328
333
  source: row.source,
@@ -807,12 +812,13 @@ let DevicesService = class DevicesService {
807
812
  const demotedProviders = [
808
813
  ...new Set(rows.flatMap((r) => (demoted.get(r.id) ? [demoted.get(r.id)] : []))),
809
814
  ];
810
- const [names, recent, settings] = await Promise.all([
815
+ const [names, recent, settings, deployment] = await Promise.all([
811
816
  this.displayNames.many(demotedProviders),
812
817
  this.recentSandboxChanges(orgId, rows.map((r) => r.id)),
813
818
  readOrgSettings(this.db.db, orgId),
819
+ readDeploymentSandboxPolicy(this.db.db, this.config),
814
820
  ]);
815
- const policyOf = (tier) => effectiveSandboxPolicy(this.config.get().sandbox, settings, tier);
821
+ const policyOf = (tier) => effectiveSandboxPolicy(deployment, settings, tier);
816
822
  // each device's limits (a cloud device's own tier) and its instance's pending change
817
823
  const perDevice = new Map(await Promise.all(rows.map(async (r) => {
818
824
  const policy = policyOf(r.source === "cloud" ? r.tier : null);
package/dist/errors.d.ts CHANGED
@@ -36,6 +36,10 @@ export declare const ERROR_CODES: {
36
36
  readonly "org.owner_required": 403;
37
37
  readonly "org.sandbox_egress_not_allowed": 400;
38
38
  readonly "org.sandbox_tier_not_allowed": 400;
39
+ /** A deployment setting set in the startup config (file, Nacos or env): read-only in the admin console. */
40
+ readonly "deployment.setting_readonly": 409;
41
+ /** Raising the platform's sandbox tiers through an API token or the Platform tool (only narrowing). */
42
+ readonly "deployment.sandbox_tier_not_allowed": 400;
39
43
  readonly "member.not_found": 404;
40
44
  readonly "member.already_exists": 409;
41
45
  readonly "member.anonymous_guest": 409;
package/dist/errors.js CHANGED
@@ -36,6 +36,10 @@ export const ERROR_CODES = {
36
36
  "org.owner_required": 403,
37
37
  "org.sandbox_egress_not_allowed": 400,
38
38
  "org.sandbox_tier_not_allowed": 400,
39
+ /** A deployment setting set in the startup config (file, Nacos or env): read-only in the admin console. */
40
+ "deployment.setting_readonly": 409,
41
+ /** Raising the platform's sandbox tiers through an API token or the Platform tool (only narrowing). */
42
+ "deployment.sandbox_tier_not_allowed": 400,
39
43
  "member.not_found": 404,
40
44
  "member.already_exists": 409,
41
45
  "member.anonymous_guest": 409,