@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.
- package/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +33 -2
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3060 -218
- package/lib/preview-sidecar.ts +57 -13
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +945 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +443 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1405 -107
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +766 -42
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- 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
|
+
}
|
package/lib/runtime-state.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 =
|
|
181
|
-
const res = await
|
|
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 =
|
|
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
|
+
}
|