@runuai/host 0.9.14 → 0.9.42

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 (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +33 -2
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3060 -218
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1405 -107
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +766 -42
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Host-owned runtime/cache authority for every command launched in a task
3
+ * container. Admission and recovery separately prove the container's fixed
4
+ * Config.Env; these values are reasserted for each managed exec so a caller
5
+ * cannot accidentally omit the policy or inherit project-controlled values.
6
+ */
7
+ export const RUNTIME_AUTHORITY_ENV: Readonly<Record<string, string>> =
8
+ Object.freeze({
9
+ // Shared, host-maintained shims stay ahead of task-installed commands so
10
+ // `npm install -g claude/codex/node` cannot replace Uai's proved tools.
11
+ // Packages with new command names remain available from the private
12
+ // container-layer prefix at the final component, after every trusted
13
+ // control directory.
14
+ PATH: "/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/node/.local/share/uai-npm/bin",
15
+ HOME: "/home/node",
16
+ XDG_CONFIG_HOME: "/home/node/.config",
17
+ XDG_DATA_HOME: "/home/node/.local/share",
18
+ XDG_CACHE_HOME: "/home/node/.cache",
19
+ ASDF_DIR: "/opt/asdf",
20
+ ASDF_DATA_DIR: "/opt/asdf-data",
21
+ ASDF_CONFIG_FILE: "/dev/null",
22
+ ASDF_DEFAULT_TOOL_VERSIONS_FILENAME: ".tool-versions",
23
+ ASDF_NODEJS_VERSION: "",
24
+ ASDF_PYTHON_VERSION: "",
25
+ ASDF_GOLANG_VERSION: "",
26
+ ASDF_RUBY_VERSION: "",
27
+ ASDF_RUST_VERSION: "",
28
+ // asdf-nodejs wraps global npm installs with an automatic reshim. Task
29
+ // mounts are intentionally read-only and private bins are already on PATH;
30
+ // only host maintenance may mutate shared shims.
31
+ ASDF_SKIP_RESHIM: "1",
32
+ // Corepack selections are completely prepared by host maintenance. Task
33
+ // contexts see this path only through the read-only shared volume.
34
+ COREPACK_HOME: "/opt/asdf-data/corepack",
35
+ COREPACK_DEFAULT_TO_LATEST: "0",
36
+ COREPACK_ENV_FILE: "0",
37
+ COREPACK_NPM_REGISTRY: "https://registry.npmjs.org",
38
+ COREPACK_ENABLE_NETWORK: "0",
39
+ COREPACK_ENABLE_AUTO_PIN: "0",
40
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
41
+ COREPACK_ENABLE_PROJECT_SPEC: "1",
42
+ COREPACK_ENABLE_STRICT: "1",
43
+ COREPACK_ENABLE_UNSAFE_CUSTOM_URLS: "0",
44
+ COREPACK_INTEGRITY_CHECK: "1",
45
+ // npm treats npm_config_* names case-insensitively and lifecycle scripts
46
+ // often receive the lowercase spelling. Pin both to task-private storage
47
+ // so image/project environment cannot turn an ordinary global install
48
+ // into a shared-cache writer.
49
+ NPM_CONFIG_PREFIX: "/home/node/.local/share/uai-npm",
50
+ npm_config_prefix: "/home/node/.local/share/uai-npm",
51
+ NPM_CONFIG_CACHE: "/home/node/.cache/uai-npm",
52
+ npm_config_cache: "/home/node/.cache/uai-npm",
53
+ NPM_CONFIG_LOGS_DIR: "/home/node/.cache/uai-npm/_logs",
54
+ npm_config_logs_dir: "/home/node/.cache/uai-npm/_logs",
55
+ NPM_CONFIG_UMASK: "0002",
56
+ npm_config_umask: "0002",
57
+ LD_PRELOAD: "",
58
+ LD_AUDIT: "",
59
+ LD_LIBRARY_PATH: "",
60
+ NODE_OPTIONS: "",
61
+ NODE_PATH: "",
62
+ // SHELLOPTS/BASHOPTS deliberately absent: present-in-env (even empty)
63
+ // makes bash export its live options into children — the Editor-terminal
64
+ // nounset poisoning of 2026-08-16.
65
+ PS4: "",
66
+ BASH_ENV: "",
67
+ ENV: "",
68
+ });
69
+
70
+ /** Docker `exec` options for the fixed authority environment. Callers append
71
+ * this after every less-trusted `-e` option and immediately before the
72
+ * container name. Container admission additionally rejects noncanonical
73
+ * case/underscore/hyphen aliases for npm's host-owned path settings. */
74
+ export function runtimeAuthorityDockerExecArgs(): string[] {
75
+ return Object.entries(RUNTIME_AUTHORITY_ENV).flatMap(([key, value]) => [
76
+ "-e",
77
+ `${key}=${value}`,
78
+ ]);
79
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Process-wide, dependency-light admission seam for background work that can
3
+ * reach Docker without entering through HostCommands. It defaults open so
4
+ * isolated library tests and non-daemon embeddings preserve their historical
5
+ * behavior; main.ts installs the authoritative ADR-101 readiness predicate.
6
+ */
7
+ let containersOperational = (): boolean => true;
8
+
9
+ /**
10
+ * A typed failure for work that reached its last Docker boundary after the
11
+ * process-wide runtime verdict was invalidated. Callers that otherwise use a
12
+ * boolean for an ordinary domain result (notably GitHub's disconnected state)
13
+ * must be able to keep infrastructure unavailability distinct.
14
+ */
15
+ export class ContainerRuntimeUnavailableError extends Error {
16
+ constructor(message = "container runtime is not operational") {
17
+ super(message);
18
+ this.name = "ContainerRuntimeUnavailableError";
19
+ }
20
+ }
21
+
22
+ export function setContainerRuntimeOperationalCheck(
23
+ check: () => boolean,
24
+ ): void {
25
+ containersOperational = check;
26
+ }
27
+
28
+ export function backgroundContainerWorkAllowed(): boolean {
29
+ return containersOperational();
30
+ }
31
+
32
+ export function requireContainerRuntimeOperational(): void {
33
+ if (!backgroundContainerWorkAllowed()) {
34
+ throw new ContainerRuntimeUnavailableError();
35
+ }
36
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Crash-safe identity for a container backend that may hold task state.
3
+ *
4
+ * The marker is deliberately monotonic. Once a bundled-runtime task can create
5
+ * a container, image, or volume, `auto` must not silently move the host back
6
+ * to an ambient Docker Desktop/OrbStack daemon on the next service restart. An
7
+ * explicit `UAI_RUNTIME=docker` remains the operator's switch.
8
+ */
9
+
10
+ import { randomBytes } from "node:crypto";
11
+ import {
12
+ chmodSync,
13
+ closeSync,
14
+ existsSync,
15
+ fsyncSync,
16
+ linkSync,
17
+ lstatSync,
18
+ mkdirSync,
19
+ openSync,
20
+ readFileSync,
21
+ unlinkSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { basename, dirname, join } from "node:path";
25
+
26
+ export type StatefulRuntimeProvider = "apple-container";
27
+
28
+ export interface RuntimeProviderState {
29
+ readonly schemaVersion: 1;
30
+ readonly statefulProvider: StatefulRuntimeProvider;
31
+ readonly claimedAt: number;
32
+ }
33
+
34
+ export interface RuntimeProviderStateStore {
35
+ read(): RuntimeProviderState | null | Promise<RuntimeProviderState | null>;
36
+ claim(
37
+ provider: StatefulRuntimeProvider,
38
+ ): RuntimeProviderState | Promise<RuntimeProviderState>;
39
+ }
40
+
41
+ export function runtimeProviderStatePath(dataDir: string): string {
42
+ return join(dataDir, "container-runtime-provider.json");
43
+ }
44
+
45
+ export function fileRuntimeProviderStateStore(
46
+ file: string,
47
+ ): RuntimeProviderStateStore {
48
+ return {
49
+ read: () => readRuntimeProviderState(file),
50
+ claim: (provider) => claimRuntimeProviderState(file, provider),
51
+ };
52
+ }
53
+
54
+ export function readRuntimeProviderState(
55
+ file: string,
56
+ ): RuntimeProviderState | null {
57
+ if (!existsSync(file)) return null;
58
+ const stat = lstatSync(file);
59
+ if (!stat.isFile() || stat.isSymbolicLink()) {
60
+ throw new Error("container runtime provider state must be a regular file");
61
+ }
62
+ let value: unknown;
63
+ try {
64
+ value = JSON.parse(readFileSync(file, "utf8"));
65
+ } catch {
66
+ throw new Error("container runtime provider state is malformed");
67
+ }
68
+ return validateRuntimeProviderState(value);
69
+ }
70
+
71
+ /**
72
+ * Durably claim the first stateful fallback provider without an overwrite
73
+ * race. The marker is written before task provisioning can mutate that
74
+ * provider. A false-positive after an interrupted task-up is safe (and only
75
+ * makes `auto` conservative); a missing marker after VM state was created is
76
+ * not.
77
+ */
78
+ export function claimRuntimeProviderState(
79
+ file: string,
80
+ provider: StatefulRuntimeProvider,
81
+ ): RuntimeProviderState {
82
+ const existing = readRuntimeProviderState(file);
83
+ if (existing) {
84
+ if (existing.statefulProvider !== provider) {
85
+ throw new Error("container runtime provider is already claimed");
86
+ }
87
+ return existing;
88
+ }
89
+
90
+ const state: RuntimeProviderState = {
91
+ schemaVersion: 1,
92
+ statefulProvider: provider,
93
+ claimedAt: Date.now(),
94
+ };
95
+ const parent = dirname(file);
96
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
97
+ const temporary = join(
98
+ parent,
99
+ `.${basename(file)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
100
+ );
101
+ try {
102
+ writeDurableFile(temporary, `${JSON.stringify(state, null, 2)}\n`);
103
+ try {
104
+ linkSync(temporary, file);
105
+ fsyncDirectory(parent);
106
+ return state;
107
+ } catch (error) {
108
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
109
+ const winner = readRuntimeProviderState(file);
110
+ if (!winner) throw new Error("container runtime provider claim disappeared");
111
+ if (winner.statefulProvider !== provider) {
112
+ throw new Error("container runtime provider was claimed concurrently");
113
+ }
114
+ return winner;
115
+ }
116
+ } finally {
117
+ try {
118
+ unlinkSync(temporary);
119
+ } catch (error) {
120
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
121
+ }
122
+ }
123
+ }
124
+
125
+ function validateRuntimeProviderState(value: unknown): RuntimeProviderState {
126
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
127
+ throw new Error("container runtime provider state is malformed");
128
+ }
129
+ const record = value as Record<string, unknown>;
130
+ if (
131
+ Object.keys(record).length !== 3 ||
132
+ record.schemaVersion !== 1 ||
133
+ record.statefulProvider !== "apple-container" ||
134
+ typeof record.claimedAt !== "number" ||
135
+ !Number.isSafeInteger(record.claimedAt) ||
136
+ record.claimedAt <= 0
137
+ ) {
138
+ throw new Error("container runtime provider state is malformed");
139
+ }
140
+ return {
141
+ schemaVersion: 1,
142
+ statefulProvider: "apple-container",
143
+ claimedAt: record.claimedAt,
144
+ };
145
+ }
146
+
147
+ function writeDurableFile(file: string, body: string): void {
148
+ const descriptor = openSync(file, "wx", 0o600);
149
+ try {
150
+ writeFileSync(descriptor, body, "utf8");
151
+ fsyncSync(descriptor);
152
+ } finally {
153
+ closeSync(descriptor);
154
+ }
155
+ chmodSync(file, 0o600);
156
+ }
157
+
158
+ function fsyncDirectory(directory: string): void {
159
+ let descriptor: number | null = null;
160
+ try {
161
+ descriptor = openSync(directory, "r");
162
+ fsyncSync(descriptor);
163
+ } catch {
164
+ // Some filesystems do not expose directory fsync. The marker itself was
165
+ // still fsync'd before the atomic hard-link publication.
166
+ } finally {
167
+ if (descriptor !== null) closeSync(descriptor);
168
+ }
169
+ }
@@ -1,9 +1,16 @@
1
- import { eq } from "drizzle-orm";
1
+ import { eq, inArray, isNull, notInArray, or } from "drizzle-orm";
2
2
 
3
- import { getDb, schema } from "./db";
3
+ import { getDb, schema, type Db } from "./db";
4
+ import { isTerminal, TERMINAL_STATUSES } from "./task-status";
4
5
  import { newId } from "./ulid";
5
6
  import type { HostTask } from "../db/schema";
6
7
  import type { TaskUpResult } from "../src/protocol";
8
+ import {
9
+ serializeTaskEnvironmentLocator,
10
+ type TaskEnvironmentLocator,
11
+ } from "./task-environment/types";
12
+
13
+ type DbExecutor = Pick<Db, "select" | "insert" | "update" | "delete">;
7
14
 
8
15
  /** Lifecycle event kinds recorded for the local UI's `/api/events` (ADR-028). */
9
16
  export type HostEventKind =
@@ -22,12 +29,104 @@ export function getHostTask(taskId: string): HostTask | null {
22
29
  );
23
30
  }
24
31
 
32
+ /** Remove a provisional first-seen row when admission loses a lifecycle gate. */
33
+ export function deleteHostTask(taskId: string): void {
34
+ getDb()
35
+ .delete(schema.hostTasks)
36
+ .where(eq(schema.hostTasks.taskId, taskId))
37
+ .run();
38
+ }
39
+
40
+ /** Final orphan-GC metadata purge. Runtime/filesystem and credential proofs
41
+ * happen before this transaction; a SQLite failure leaves every task-owned
42
+ * row intact rather than acknowledging a partial metadata deletion. */
43
+ export function purgeHostTaskState(taskId: string): void {
44
+ getDb().transaction((tx) => {
45
+ tx.delete(schema.hostAgentSessions)
46
+ .where(eq(schema.hostAgentSessions.taskId, taskId))
47
+ .run();
48
+ tx.delete(schema.hostEvents)
49
+ .where(eq(schema.hostEvents.taskId, taskId))
50
+ .run();
51
+ tx.delete(schema.hostTasks)
52
+ .where(eq(schema.hostTasks.taskId, taskId))
53
+ .run();
54
+ });
55
+ }
56
+
57
+ /** A managed runtime may restart only when no task lifecycle is active. */
58
+ export function hasActiveHostTasks(): boolean {
59
+ return Boolean(
60
+ getDb()
61
+ .select({ taskId: schema.hostTasks.taskId })
62
+ .from(schema.hostTasks)
63
+ .where(inArray(schema.hostTasks.statusMirror, ["starting", "running"]))
64
+ .limit(1)
65
+ .get(),
66
+ );
67
+ }
68
+
69
+ /**
70
+ * A task lifecycle mutation is mid-flight: a taskUp between admission and its
71
+ * recorded result. Steady-state RUNNING tasks are deliberately not counted —
72
+ * durable sessions survive a managed service restart, so a version update
73
+ * needs only the mutations drained. The strict all-tasks predicate above
74
+ * remains for legacy conversion (which takes over a different service's live
75
+ * workload) and rollback. Live 2026-08-17: using the strict predicate for
76
+ * plain updates made every update fail while any task merely existed.
77
+ */
78
+ export function hasInFlightHostTaskLifecycle(): boolean {
79
+ return Boolean(
80
+ getDb()
81
+ .select({ taskId: schema.hostTasks.taskId })
82
+ .from(schema.hostTasks)
83
+ .where(eq(schema.hostTasks.statusMirror, "starting"))
84
+ .limit(1)
85
+ .get(),
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Whether a persisted workload makes destructive host maintenance unsafe.
91
+ * Only the closed set of cloud terminal states is removable. Null and future /
92
+ * malformed values fail closed so a newer or partially-written lifecycle row
93
+ * cannot be destroyed by an older CLI that does not understand it.
94
+ */
95
+ export function hostTaskBlocksMaintenance(status: string | null): boolean {
96
+ return status === null || !isTerminal(status);
97
+ }
98
+
99
+ /** Uninstall must preserve every local workload that is not provably terminal. */
100
+ export function hasNonTerminalHostTasks(): boolean {
101
+ return Boolean(
102
+ getDb()
103
+ .select({ taskId: schema.hostTasks.taskId })
104
+ .from(schema.hostTasks)
105
+ .where(
106
+ or(
107
+ isNull(schema.hostTasks.statusMirror),
108
+ notInArray(schema.hostTasks.statusMirror, [...TERMINAL_STATUSES]),
109
+ ),
110
+ )
111
+ .limit(1)
112
+ .get(),
113
+ );
114
+ }
115
+
25
116
  export function upsertHostTask(
26
117
  taskId: string,
27
118
  values: Partial<Omit<HostTask, "taskId" | "updatedAt">>,
119
+ ): void {
120
+ upsertHostTaskIn(getDb(), taskId, values);
121
+ }
122
+
123
+ function upsertHostTaskIn(
124
+ db: DbExecutor,
125
+ taskId: string,
126
+ values: Partial<Omit<HostTask, "taskId" | "updatedAt">>,
28
127
  ): void {
29
128
  const now = Date.now();
30
- getDb()
129
+ db
31
130
  .insert(schema.hostTasks)
32
131
  .values({
33
132
  taskId,
@@ -45,12 +144,21 @@ export function upsertHostTask(
45
144
  .run();
46
145
  }
47
146
 
48
- export function recordTaskUpResult(taskId: string, result: TaskUpResult): void {
147
+ export function recordTaskUpResult(
148
+ taskId: string,
149
+ result: TaskUpResult,
150
+ environment?: TaskEnvironmentLocator,
151
+ ): void {
49
152
  upsertHostTask(taskId, {
50
153
  codeServerPort: result.codeServerPort ?? null,
51
154
  previewPorts: JSON.stringify(result.previewPorts ?? []),
52
155
  composeProject: result.composeProject,
53
156
  worktreePath: result.worktreePath,
157
+ environmentProvider: environment?.provider ?? null,
158
+ environmentLocator:
159
+ environment === undefined
160
+ ? null
161
+ : serializeTaskEnvironmentLocator(environment),
54
162
  statusMirror: "running",
55
163
  lockedAt: null,
56
164
  startedAt: Date.now(),
@@ -67,15 +175,34 @@ export function recordTaskStarting(taskId: string): void {
67
175
  });
68
176
  }
69
177
 
70
- export function recordTaskDown(taskId: string, status: string | undefined): void {
178
+ /** Persist the provider-owned crash-recovery identity before provisioning may
179
+ * create worktrees, containers, networks, or volumes. Status remains
180
+ * `starting`; the enriched locator is written by `recordTaskUpResult`. */
181
+ export function recordTaskEnvironmentPrepared(
182
+ taskId: string,
183
+ environment: TaskEnvironmentLocator,
184
+ ): void {
71
185
  upsertHostTask(taskId, {
72
- codeServerPort: null,
73
- previewPorts: "[]",
74
- composeProject: null,
75
- worktreePath: null,
76
- statusMirror: status ?? "killed",
77
- lockedAt: null,
78
- endedAt: Date.now(),
186
+ environmentProvider: environment.provider,
187
+ environmentLocator: serializeTaskEnvironmentLocator(environment),
188
+ });
189
+ }
190
+
191
+ export function recordTaskDown(taskId: string, status: string | undefined): void {
192
+ const db = getDb();
193
+ db.transaction((tx) => {
194
+ upsertHostTaskIn(tx, taskId, {
195
+ codeServerPort: null,
196
+ previewPorts: "[]",
197
+ composeProject: null,
198
+ worktreePath: null,
199
+ environmentProvider: null,
200
+ environmentLocator: null,
201
+ statusMirror: status ?? "killed",
202
+ lockedAt: null,
203
+ endedAt: Date.now(),
204
+ });
205
+ applyTaskTerminalHistoryRetentionInTransaction(tx, taskId);
79
206
  });
80
207
  }
81
208
 
@@ -113,8 +240,101 @@ export function recordHostEvent(
113
240
  kind: HostEventKind,
114
241
  detail?: string | null,
115
242
  ): void {
243
+ // `recordTaskDown` prunes the earlier lifecycle history before this terminal
244
+ // event is appended. Do not immediately recreate that owner-facing history
245
+ // when retention is disabled; the correctness tombstone remains in
246
+ // `uai_host_tasks` either way.
247
+ if (kind === "task.ended" && !retainsTerminalHistory()) return;
116
248
  getDb()
117
249
  .insert(schema.hostEvents)
118
250
  .values({ id: newId(), taskId, kind, detail: detail ?? null })
119
251
  .run();
120
252
  }
253
+
254
+ function retainsTerminalHistory(db: DbExecutor = getDb()): boolean {
255
+ return (
256
+ db
257
+ .select({
258
+ retainTerminalHistory: schema.hostSettings.retainTerminalHistory,
259
+ })
260
+ .from(schema.hostSettings)
261
+ .where(eq(schema.hostSettings.id, "host"))
262
+ .get()?.retainTerminalHistory ?? true
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Apply the owner-facing terminal-history policy without deleting the durable
268
+ * lifecycle tombstone used by idempotence, maintenance admission, and orphan
269
+ * GC. Active/stopped/error rows and their history are deliberately untouched.
270
+ *
271
+ * Returns whether terminal history remains enabled. The optional DB keeps the
272
+ * operation testable and lets the config manager apply a newly-disabled policy
273
+ * to the same host database immediately.
274
+ */
275
+ export function applyTerminalHistoryRetention(db: Db = getDb()): boolean {
276
+ let retained = true;
277
+ db.transaction((tx) => {
278
+ retained = applyTerminalHistoryRetentionInTransaction(tx);
279
+ });
280
+ return retained;
281
+ }
282
+
283
+ /** Same policy, for callers that already own the crash-consistency boundary. */
284
+ export function applyTerminalHistoryRetentionInTransaction(
285
+ db: DbExecutor,
286
+ ): boolean {
287
+ if (retainsTerminalHistory(db)) return true;
288
+
289
+ const terminalTasks = db
290
+ .select({ taskId: schema.hostTasks.taskId })
291
+ .from(schema.hostTasks)
292
+ .where(inArray(schema.hostTasks.statusMirror, [...TERMINAL_STATUSES]))
293
+ .all();
294
+
295
+ for (const { taskId } of terminalTasks) {
296
+ scrubTerminalTaskHistory(db, taskId);
297
+ }
298
+
299
+ return false;
300
+ }
301
+
302
+ /**
303
+ * Apply disabled retention to the task that is transitioning terminal.
304
+ *
305
+ * The full sweep above is reserved for boot reconciliation and the one-time
306
+ * config transition. Re-scanning every permanent tombstone on each task-down
307
+ * would make teardown cost grow with all historical tasks.
308
+ */
309
+ export function applyTaskTerminalHistoryRetentionInTransaction(
310
+ db: DbExecutor,
311
+ taskId: string,
312
+ ): boolean {
313
+ if (retainsTerminalHistory(db)) return true;
314
+ const status = db
315
+ .select({ statusMirror: schema.hostTasks.statusMirror })
316
+ .from(schema.hostTasks)
317
+ .where(eq(schema.hostTasks.taskId, taskId))
318
+ .get()?.statusMirror;
319
+ if (status && isTerminal(status)) scrubTerminalTaskHistory(db, taskId);
320
+ return false;
321
+ }
322
+
323
+ function scrubTerminalTaskHistory(db: DbExecutor, taskId: string): void {
324
+ db.delete(schema.hostAgentSessions)
325
+ .where(eq(schema.hostAgentSessions.taskId, taskId))
326
+ .run();
327
+ db.delete(schema.hostEvents)
328
+ .where(eq(schema.hostEvents.taskId, taskId))
329
+ .run();
330
+ db.update(schema.hostTasks)
331
+ .set({
332
+ ownerUserId: null,
333
+ ownerEmail: null,
334
+ ownerName: null,
335
+ projectSlugs: "[]",
336
+ startedAt: null,
337
+ })
338
+ .where(eq(schema.hostTasks.taskId, taskId))
339
+ .run();
340
+ }
package/lib/skills.ts CHANGED
@@ -23,6 +23,11 @@ import { resolve } from "node:path";
23
23
 
24
24
  import { taskWorkspaceDir } from "./env";
25
25
  import { dockerCli } from "./docker-exec";
26
+ import {
27
+ taskAppContainerName,
28
+ taskContainerCli,
29
+ } from "./task-container-cli";
30
+ import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
26
31
  import type { RosterAgent, RosterSkill } from "./agents/types";
27
32
 
28
33
  /** Skills that go into the flat per-agent SKILL.md — link/document only. The
@@ -176,9 +181,25 @@ export type SkillExec = (
176
181
  cwd?: string,
177
182
  ) => Promise<{ status: number | null; stderr: string }>;
178
183
 
184
+ export function packageSkillDockerExecArgs(
185
+ container: string,
186
+ cmd: string[],
187
+ cwd?: string,
188
+ ): string[] {
189
+ return [
190
+ "exec",
191
+ "-u",
192
+ "node",
193
+ ...(cwd ? ["-w", cwd] : []),
194
+ ...runtimeAuthorityDockerExecArgs(),
195
+ container,
196
+ ...cmd,
197
+ ];
198
+ }
199
+
179
200
  const dockerExec: SkillExec = async (container, cmd, cwd) => {
180
- const full = ["exec", "-u", "node", ...(cwd ? ["-w", cwd] : []), container, ...cmd];
181
- const res = await dockerCli(full, { timeoutMs: SKILL_EXEC_TIMEOUT_MS });
201
+ const full = packageSkillDockerExecArgs(container, cmd, cwd);
202
+ const res = await taskContainerCli(full, { timeoutMs: SKILL_EXEC_TIMEOUT_MS });
182
203
  return { status: res.status, stderr: res.stderr };
183
204
  };
184
205
 
@@ -197,7 +218,7 @@ export async function installPackageSkills(
197
218
  ): Promise<string[]> {
198
219
  const pkgs = collectPackageSkills(agents);
199
220
  if (pkgs.length === 0) return [];
200
- const container = `task-${taskId}-app-1`;
221
+ const container = taskAppContainerName(taskId);
201
222
  const done: string[] = [];
202
223
 
203
224
  for (const s of pkgs) {
package/lib/ssh.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { spawnSync } from "node:child_process";
13
13
  import {
14
+ lstatSync,
14
15
  mkdirSync,
15
16
  mkdtempSync,
16
17
  readFileSync,
@@ -25,6 +26,7 @@ import { eq } from "drizzle-orm";
25
26
  import { getDb, schema } from "./db";
26
27
  import { env } from "./env";
27
28
  import { sealAesGcm, openAesGcm } from "./secrets";
29
+ import { assertSafeHostTaskId } from "./task-identity";
28
30
 
29
31
  const withNewline = (s: string): string => (s.endsWith("\n") ? s : `${s}\n`);
30
32
 
@@ -187,3 +189,19 @@ export function writeTaskIdentity(
187
189
  export function removeTaskIdentity(taskId: string): void {
188
190
  rmSync(taskIdentityDir(taskId), { recursive: true, force: true });
189
191
  }
192
+
193
+ /** Orphan GC may acknowledge only after the materialized private key is
194
+ * positively absent. The caller first retires every task-scoped ensure, so no
195
+ * trusted host continuation can recreate this directory after the check. */
196
+ export function removeTaskIdentityStrict(taskId: string): void {
197
+ assertSafeHostTaskId(taskId);
198
+ const dir = taskIdentityDir(taskId);
199
+ rmSync(dir, { recursive: true, force: true });
200
+ try {
201
+ lstatSync(dir);
202
+ } catch (error) {
203
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
204
+ throw error;
205
+ }
206
+ throw new Error("task SSH identity remains after removal");
207
+ }