@runuai/host 0.9.13 → 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 +69 -4
- 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 +3070 -223
- 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 +1463 -109
- 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 +871 -50
- 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
package/lib/tunnel-registry.ts
CHANGED
|
@@ -31,11 +31,26 @@ export interface TunnelUpstream {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
type TunnelOp = (upstream: TunnelUpstream) => void;
|
|
34
|
+
type TunnelRelease = () => Promise<void>;
|
|
35
|
+
|
|
36
|
+
interface OpenTunnel {
|
|
37
|
+
readonly upstream: TunnelUpstream;
|
|
38
|
+
readonly release: TunnelRelease;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface OpeningTunnel {
|
|
42
|
+
readonly ops: TunnelOp[];
|
|
43
|
+
readonly settled: Promise<void>;
|
|
44
|
+
finish(): void;
|
|
45
|
+
aborted: boolean;
|
|
46
|
+
}
|
|
34
47
|
|
|
35
48
|
export class TunnelRegistry {
|
|
36
|
-
private readonly open = new Map<string,
|
|
49
|
+
private readonly open = new Map<string, OpenTunnel>();
|
|
37
50
|
/** tunnelId -> ops that arrived while its `tunnel.open` was still resolving. */
|
|
38
|
-
private readonly opening = new Map<string,
|
|
51
|
+
private readonly opening = new Map<string, OpeningTunnel>();
|
|
52
|
+
/** Async provider teardown is joined by connection replacement/shutdown. */
|
|
53
|
+
private readonly pendingReleases = new Set<Promise<void>>();
|
|
39
54
|
|
|
40
55
|
/**
|
|
41
56
|
* Mark a tunnel as opening. MUST be called synchronously when `tunnel.open`
|
|
@@ -44,29 +59,61 @@ export class TunnelRegistry {
|
|
|
44
59
|
* whole bug this class exists for.
|
|
45
60
|
*/
|
|
46
61
|
markOpening(tunnelId: string): void {
|
|
47
|
-
this.opening.
|
|
62
|
+
const previous = this.opening.get(tunnelId);
|
|
63
|
+
if (previous) {
|
|
64
|
+
previous.aborted = true;
|
|
65
|
+
previous.finish();
|
|
66
|
+
}
|
|
67
|
+
let finish!: () => void;
|
|
68
|
+
const settled = new Promise<void>((resolve) => {
|
|
69
|
+
finish = resolve;
|
|
70
|
+
});
|
|
71
|
+
this.opening.set(tunnelId, {
|
|
72
|
+
ops: [],
|
|
73
|
+
settled,
|
|
74
|
+
finish,
|
|
75
|
+
aborted: false,
|
|
76
|
+
});
|
|
48
77
|
}
|
|
49
78
|
|
|
50
|
-
/** Target resolved: publish the upstream and replay what arrived meanwhile.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
79
|
+
/** Target resolved: publish the upstream and replay what arrived meanwhile.
|
|
80
|
+
* Returns whether this upstream now owns the tunnel — false means the open
|
|
81
|
+
* was canceled during resolution and the upstream was destroyed, so the
|
|
82
|
+
* caller must not write to it (a stale generation must never emit bytes). */
|
|
83
|
+
register(
|
|
84
|
+
tunnelId: string,
|
|
85
|
+
upstream: TunnelUpstream,
|
|
86
|
+
release: TunnelRelease = async () => {},
|
|
87
|
+
): boolean {
|
|
88
|
+
const pending = this.opening.get(tunnelId);
|
|
54
89
|
this.opening.delete(tunnelId);
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
pending?.finish();
|
|
91
|
+
const releaseOnce = onceAsync(release);
|
|
92
|
+
if (pending?.aborted) {
|
|
93
|
+
upstream.destroy();
|
|
94
|
+
this.trackRelease(releaseOnce());
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
this.open.set(tunnelId, { upstream, release: releaseOnce });
|
|
98
|
+
if (!pending) return true;
|
|
99
|
+
for (const op of pending.ops) op(upstream);
|
|
100
|
+
return true;
|
|
57
101
|
}
|
|
58
102
|
|
|
59
103
|
/** Open failed (no target, or it errored) — nothing will ever service these. */
|
|
60
104
|
abandon(tunnelId: string): void {
|
|
105
|
+
this.opening.get(tunnelId)?.finish();
|
|
61
106
|
this.opening.delete(tunnelId);
|
|
62
107
|
}
|
|
63
108
|
|
|
64
109
|
get(tunnelId: string): TunnelUpstream | undefined {
|
|
65
|
-
return this.open.get(tunnelId);
|
|
110
|
+
return this.open.get(tunnelId)?.upstream;
|
|
66
111
|
}
|
|
67
112
|
|
|
68
113
|
delete(tunnelId: string): void {
|
|
114
|
+
const current = this.open.get(tunnelId);
|
|
69
115
|
this.open.delete(tunnelId);
|
|
116
|
+
if (current) this.trackRelease(current.release());
|
|
70
117
|
}
|
|
71
118
|
|
|
72
119
|
/**
|
|
@@ -76,12 +123,13 @@ export class TunnelRegistry {
|
|
|
76
123
|
* race.
|
|
77
124
|
*/
|
|
78
125
|
apply(tunnelId: string, op: TunnelOp): void {
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
op(upstream);
|
|
126
|
+
const current = this.open.get(tunnelId);
|
|
127
|
+
if (current) {
|
|
128
|
+
op(current.upstream);
|
|
82
129
|
return;
|
|
83
130
|
}
|
|
84
|
-
this.opening.get(tunnelId)
|
|
131
|
+
const pending = this.opening.get(tunnelId);
|
|
132
|
+
if (pending && !pending.aborted) pending.ops.push(op);
|
|
85
133
|
}
|
|
86
134
|
|
|
87
135
|
/**
|
|
@@ -91,14 +139,82 @@ export class TunnelRegistry {
|
|
|
91
139
|
* at worst an unhandled error on a socket nobody is listening to any more.
|
|
92
140
|
*/
|
|
93
141
|
abort(tunnelId: string): void {
|
|
94
|
-
const
|
|
142
|
+
const current = this.open.get(tunnelId);
|
|
95
143
|
this.open.delete(tunnelId);
|
|
96
|
-
if (
|
|
97
|
-
upstream.destroy();
|
|
144
|
+
if (current) {
|
|
145
|
+
current.upstream.destroy();
|
|
146
|
+
this.trackRelease(current.release());
|
|
98
147
|
return;
|
|
99
148
|
}
|
|
100
|
-
|
|
101
|
-
|
|
149
|
+
const pending = this.opening.get(tunnelId);
|
|
150
|
+
if (pending) {
|
|
151
|
+
pending.ops.length = 0;
|
|
152
|
+
pending.aborted = true;
|
|
102
153
|
}
|
|
103
154
|
}
|
|
155
|
+
|
|
156
|
+
/** Destroy every upstream from one bridge generation and wait until opening
|
|
157
|
+
* targets have either failed or registered and their provider resources are
|
|
158
|
+
* released. No SSH forward may outlive the WebSocket generation that owns
|
|
159
|
+
* it — but reconnect must not be hostage to one wedged open, so the join is
|
|
160
|
+
* bounded: entries are already marked aborted, their eventual register()
|
|
161
|
+
* destroys the upstream and releases provider resources, and the releases
|
|
162
|
+
* stay tracked for the next generation's join. */
|
|
163
|
+
async abortAll(timeoutMs = 15_000): Promise<void> {
|
|
164
|
+
for (const pending of this.opening.values()) {
|
|
165
|
+
pending.ops.length = 0;
|
|
166
|
+
pending.aborted = true;
|
|
167
|
+
}
|
|
168
|
+
for (const [tunnelId, current] of this.open) {
|
|
169
|
+
this.open.delete(tunnelId);
|
|
170
|
+
current.upstream.destroy();
|
|
171
|
+
this.trackRelease(current.release());
|
|
172
|
+
}
|
|
173
|
+
let timer: NodeJS.Timeout | undefined;
|
|
174
|
+
const expired = new Promise<"timeout">((resolve) => {
|
|
175
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
176
|
+
timer.unref?.();
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
while (this.opening.size > 0 || this.pendingReleases.size > 0) {
|
|
180
|
+
const opening = [...this.opening.values()].map((entry) => entry.settled);
|
|
181
|
+
const outcome = await Promise.race([
|
|
182
|
+
Promise.all([...opening, ...this.pendingReleases]).then(
|
|
183
|
+
() => "settled" as const,
|
|
184
|
+
),
|
|
185
|
+
expired,
|
|
186
|
+
]);
|
|
187
|
+
if (outcome === "timeout") {
|
|
188
|
+
console.warn(
|
|
189
|
+
`[tunnel] abortAll: ${this.opening.size} opening and ` +
|
|
190
|
+
`${this.pendingReleases.size} releasing tunnels did not settle ` +
|
|
191
|
+
`within ${timeoutMs}ms; continuing while they drain`,
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private trackRelease(release: Promise<void>): void {
|
|
202
|
+
// A rejected provider release must neither crash the process (a bare
|
|
203
|
+
// `.finally()` chain rethrows with no handler attached) nor poison the
|
|
204
|
+
// abortAll() join above — cleanup failure is logged, never fatal.
|
|
205
|
+
const settled = release.catch((error: unknown) => {
|
|
206
|
+
console.warn(
|
|
207
|
+
`[tunnel] upstream release failed: ${
|
|
208
|
+
error instanceof Error ? error.message : String(error)
|
|
209
|
+
}`,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
this.pendingReleases.add(settled);
|
|
213
|
+
void settled.finally(() => this.pendingReleases.delete(settled));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function onceAsync(operation: TunnelRelease): TunnelRelease {
|
|
218
|
+
let result: Promise<void> | null = null;
|
|
219
|
+
return () => (result ??= operation());
|
|
104
220
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
|
|
3
|
+
import { dockerCli, type DockerResult } from "./docker-exec";
|
|
4
|
+
import { reprobeContainerRuntimeMachineIdentity } from "./container-runtime";
|
|
5
|
+
|
|
6
|
+
type DockerRunner = (
|
|
7
|
+
args: string[],
|
|
8
|
+
options?: { timeoutMs?: number },
|
|
9
|
+
) => Promise<DockerResult>;
|
|
10
|
+
|
|
11
|
+
export type TunnelContainerProof =
|
|
12
|
+
| {
|
|
13
|
+
kind: "running";
|
|
14
|
+
containerId: string;
|
|
15
|
+
published: { host: string; port: number } | null;
|
|
16
|
+
containerIp: string | null;
|
|
17
|
+
network: string | null;
|
|
18
|
+
labels: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
| { kind: "unavailable" }
|
|
21
|
+
| { kind: "daemon-unavailable"; detail: string };
|
|
22
|
+
|
|
23
|
+
interface DockerContainerInspection {
|
|
24
|
+
Id?: unknown;
|
|
25
|
+
Name?: unknown;
|
|
26
|
+
Config?: { Labels?: Record<string, unknown> | null };
|
|
27
|
+
State?: { Running?: unknown };
|
|
28
|
+
NetworkSettings?: {
|
|
29
|
+
Ports?: Record<
|
|
30
|
+
string,
|
|
31
|
+
Array<{ HostIp?: unknown; HostPort?: unknown }> | null
|
|
32
|
+
>;
|
|
33
|
+
Networks?: Record<string, { IPAddress?: unknown }>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
|
|
38
|
+
const COMPOSE_SERVICE_LABEL = "com.docker.compose.service";
|
|
39
|
+
const CONTAINER_ID = /^[a-f0-9]{64}$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Prove that an exact container is running and resolve its current routes.
|
|
43
|
+
*
|
|
44
|
+
* Persisted host ports and cached container IPs are unsafe after an unnoticed
|
|
45
|
+
* daemon restart: Docker may remap the app, and an unrelated local process (or
|
|
46
|
+
* another container) can inherit the old address. Every tunnel open crosses
|
|
47
|
+
* this proof before bytes are forwarded.
|
|
48
|
+
*/
|
|
49
|
+
export async function inspectTunnelContainer(
|
|
50
|
+
container: string,
|
|
51
|
+
containerPort: number,
|
|
52
|
+
deps: { docker?: DockerRunner } = {},
|
|
53
|
+
): Promise<TunnelContainerProof> {
|
|
54
|
+
const run = deps.docker ?? dockerCli;
|
|
55
|
+
const inspected = await run(
|
|
56
|
+
["inspect", "--format", "{{json .}}", container],
|
|
57
|
+
{ timeoutMs: 5_000 },
|
|
58
|
+
);
|
|
59
|
+
if (inspected.status !== 0) {
|
|
60
|
+
if (!deps.docker) {
|
|
61
|
+
return (await reprobeContainerRuntimeMachineIdentity()) === null
|
|
62
|
+
? { kind: "daemon-unavailable", detail: "docker did not answer" }
|
|
63
|
+
: { kind: "unavailable" };
|
|
64
|
+
}
|
|
65
|
+
const daemon = await run(
|
|
66
|
+
["info", "--format", "{{.ServerVersion}}"],
|
|
67
|
+
{ timeoutMs: 5_000 },
|
|
68
|
+
);
|
|
69
|
+
if (daemon.status !== 0 || !daemon.stdout.trim()) {
|
|
70
|
+
return {
|
|
71
|
+
kind: "daemon-unavailable",
|
|
72
|
+
// This reaches cloud-visible command errors. Docker's own diagnostic
|
|
73
|
+
// can contain the pinned unix socket (and therefore a host username),
|
|
74
|
+
// so keep endpoint details in Docker's local logs rather than the wire.
|
|
75
|
+
detail: "docker did not answer",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { kind: "unavailable" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let value: DockerContainerInspection;
|
|
82
|
+
try {
|
|
83
|
+
value = JSON.parse(inspected.stdout.trim()) as DockerContainerInspection;
|
|
84
|
+
} catch {
|
|
85
|
+
return { kind: "unavailable" };
|
|
86
|
+
}
|
|
87
|
+
if (value.State?.Running !== true) return { kind: "unavailable" };
|
|
88
|
+
|
|
89
|
+
const containerId = exactContainerId(value.Id);
|
|
90
|
+
if (!containerId) return { kind: "unavailable" };
|
|
91
|
+
const address = firstContainerAddress(value);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
kind: "running",
|
|
95
|
+
containerId,
|
|
96
|
+
published: publishedAddress(value, containerPort),
|
|
97
|
+
containerIp: address?.ip ?? null,
|
|
98
|
+
network: address?.network ?? null,
|
|
99
|
+
labels: stringLabels(value.Config?.Labels),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stronger proof for editor/preview routing into a task's app. Docker's exact
|
|
105
|
+
* name lookup is necessary but not sufficient: a recreated or foreign
|
|
106
|
+
* container can reuse it. Bind the route to Compose's project/service labels,
|
|
107
|
+
* the full container ID, and only the expected project default network.
|
|
108
|
+
*/
|
|
109
|
+
export async function inspectTaskAppTunnelContainer(
|
|
110
|
+
request: {
|
|
111
|
+
readonly container: string;
|
|
112
|
+
readonly composeProject: string;
|
|
113
|
+
readonly containerPort: number;
|
|
114
|
+
},
|
|
115
|
+
deps: { docker?: DockerRunner } = {},
|
|
116
|
+
): Promise<TunnelContainerProof> {
|
|
117
|
+
const run = deps.docker ?? dockerCli;
|
|
118
|
+
const inspected = await run(
|
|
119
|
+
["inspect", "--format", "{{json .}}", request.container],
|
|
120
|
+
{ timeoutMs: 5_000 },
|
|
121
|
+
);
|
|
122
|
+
if (inspected.status !== 0) {
|
|
123
|
+
if (!deps.docker) {
|
|
124
|
+
return (await reprobeContainerRuntimeMachineIdentity()) === null
|
|
125
|
+
? { kind: "daemon-unavailable", detail: "docker did not answer" }
|
|
126
|
+
: { kind: "unavailable" };
|
|
127
|
+
}
|
|
128
|
+
const daemon = await run(
|
|
129
|
+
["info", "--format", "{{.ServerVersion}}"],
|
|
130
|
+
{ timeoutMs: 5_000 },
|
|
131
|
+
);
|
|
132
|
+
if (daemon.status !== 0 || !daemon.stdout.trim()) {
|
|
133
|
+
return { kind: "daemon-unavailable", detail: "docker did not answer" };
|
|
134
|
+
}
|
|
135
|
+
return { kind: "unavailable" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let value: DockerContainerInspection;
|
|
139
|
+
try {
|
|
140
|
+
value = JSON.parse(inspected.stdout.trim()) as DockerContainerInspection;
|
|
141
|
+
} catch {
|
|
142
|
+
return { kind: "unavailable" };
|
|
143
|
+
}
|
|
144
|
+
const labels = stringLabels(value.Config?.Labels);
|
|
145
|
+
const containerId = exactContainerId(value.Id);
|
|
146
|
+
const network = `${request.composeProject}_default`;
|
|
147
|
+
const ip = exactNetworkAddress(value, network);
|
|
148
|
+
if (
|
|
149
|
+
value.State?.Running !== true ||
|
|
150
|
+
value.Name !== `/${request.container}` ||
|
|
151
|
+
!containerId ||
|
|
152
|
+
labels[COMPOSE_PROJECT_LABEL] !== request.composeProject ||
|
|
153
|
+
labels[COMPOSE_SERVICE_LABEL] !== "app" ||
|
|
154
|
+
!ip
|
|
155
|
+
) {
|
|
156
|
+
return { kind: "unavailable" };
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
kind: "running",
|
|
160
|
+
containerId,
|
|
161
|
+
published: publishedAddress(value, request.containerPort),
|
|
162
|
+
containerIp: ip,
|
|
163
|
+
network,
|
|
164
|
+
labels,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function exactContainerId(value: unknown): string | null {
|
|
169
|
+
return typeof value === "string" && CONTAINER_ID.test(value) ? value : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stringLabels(
|
|
173
|
+
labels: Record<string, unknown> | null | undefined,
|
|
174
|
+
): Record<string, string> {
|
|
175
|
+
if (!labels) return {};
|
|
176
|
+
return Object.fromEntries(
|
|
177
|
+
Object.entries(labels).filter(
|
|
178
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function publishedAddress(
|
|
184
|
+
value: DockerContainerInspection,
|
|
185
|
+
containerPort: number,
|
|
186
|
+
): { host: string; port: number } | null {
|
|
187
|
+
const bindings = value.NetworkSettings?.Ports?.[`${containerPort}/tcp`];
|
|
188
|
+
if (!Array.isArray(bindings)) return null;
|
|
189
|
+
const candidates = bindings.flatMap((binding) => {
|
|
190
|
+
const port = parsePort(binding.HostPort);
|
|
191
|
+
const host = loopbackForBinding(binding.HostIp);
|
|
192
|
+
return port && host ? [{ host, port }] : [];
|
|
193
|
+
});
|
|
194
|
+
return (
|
|
195
|
+
candidates.find((candidate) => candidate.host === "127.0.0.1") ??
|
|
196
|
+
candidates[0] ??
|
|
197
|
+
null
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function loopbackForBinding(value: unknown): string | null {
|
|
202
|
+
if (value === "127.0.0.1" || value === "0.0.0.0" || value === "") {
|
|
203
|
+
return "127.0.0.1";
|
|
204
|
+
}
|
|
205
|
+
if (value === "::1" || value === "::") return "::1";
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parsePort(value: unknown): number | null {
|
|
210
|
+
if (typeof value !== "string" || !/^\d{1,5}$/.test(value)) return null;
|
|
211
|
+
const port = Number(value);
|
|
212
|
+
return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function firstContainerAddress(
|
|
216
|
+
value: DockerContainerInspection,
|
|
217
|
+
): { network: string; ip: string } | null {
|
|
218
|
+
const networks = value.NetworkSettings?.Networks;
|
|
219
|
+
if (!networks || typeof networks !== "object") return null;
|
|
220
|
+
for (const [name, network] of Object.entries(networks)) {
|
|
221
|
+
const address = network?.IPAddress;
|
|
222
|
+
if (typeof address === "string" && isIP(address) !== 0) {
|
|
223
|
+
return { network: name, ip: address };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function exactNetworkAddress(
|
|
230
|
+
value: DockerContainerInspection,
|
|
231
|
+
network: string,
|
|
232
|
+
): string | null {
|
|
233
|
+
const address = value.NetworkSettings?.Networks?.[network]?.IPAddress;
|
|
234
|
+
return typeof address === "string" && isIP(address) !== 0 ? address : null;
|
|
235
|
+
}
|
package/package.json
CHANGED
package/scripts/agent/_common.sh
CHANGED
|
@@ -36,7 +36,14 @@ UAI_LOG_DIR="${UAI_LOG_DIR:-$UAI_DATA_DIR/logs}"
|
|
|
36
36
|
mkdir -p "$UAI_LOG_DIR"
|
|
37
37
|
|
|
38
38
|
# Optional dependencies — fail fast with a structured error if missing.
|
|
39
|
-
|
|
39
|
+
# docker is only a dependency of the docker runtime mode: the Apple runtime
|
|
40
|
+
# path must work on a Docker-free Mac (ADR-106), and its own CLI is validated
|
|
41
|
+
# by validate_task_runtime_selection instead.
|
|
42
|
+
agent_required_bins="sqlite3 jq git"
|
|
43
|
+
if [ "${UAI_TASK_RUNTIME:-docker}" != "apple-container" ]; then
|
|
44
|
+
agent_required_bins="$agent_required_bins docker"
|
|
45
|
+
fi
|
|
46
|
+
for bin in $agent_required_bins; do
|
|
40
47
|
if ! command -v "$bin" >/dev/null 2>&1; then
|
|
41
48
|
printf '{"ok":false,"error":{"code":"MISSING_DEPENDENCY","message":"%s not found on PATH"}}\n' "$bin" >&2
|
|
42
49
|
exit 127
|
|
@@ -72,7 +79,7 @@ emit_ok() {
|
|
|
72
79
|
# logs an `error` event, prints structured JSON to stderr, and exits 1.
|
|
73
80
|
emit_err() {
|
|
74
81
|
local code="$1" msg="$2" step="${3:-${UAI_CURRENT_STEP:-}}"
|
|
75
|
-
|
|
82
|
+
_uai_record_failure "$code" "$step"
|
|
76
83
|
local json
|
|
77
84
|
if [ -n "$step" ]; then
|
|
78
85
|
json=$(jq -nc --arg c "$code" --arg m "$msg" --arg s "$step" \
|
|
@@ -85,6 +92,42 @@ emit_err() {
|
|
|
85
92
|
exit 1
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
# Docker can disappear after the TypeScript admission check but before a
|
|
96
|
+
# minutes-long compose/init command finishes. These codes describe steps whose
|
|
97
|
+
# failure may be a daemon outage. Keep the command snapshot retryable here; the
|
|
98
|
+
# host process re-probes `docker info` and records a terminal error only when
|
|
99
|
+
# the daemon is positively reachable and the operation itself really failed.
|
|
100
|
+
_uai_is_runtime_sensitive_error() {
|
|
101
|
+
case "$1" in
|
|
102
|
+
STANDARD_IMAGE_MISSING|COMPOSE_UP_FAILED|COMPOSE_DOWN_FAILED|CONTAINER_INIT_FAILED|DOCKER_PS_FAILED)
|
|
103
|
+
return 0
|
|
104
|
+
;;
|
|
105
|
+
*)
|
|
106
|
+
return 1
|
|
107
|
+
;;
|
|
108
|
+
esac
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_uai_mark_retryable() {
|
|
112
|
+
if [ -n "${UAI_TASK_ID:-}" ] && [ -n "${UAI_DB_PATH:-}" ]; then
|
|
113
|
+
local restore=""
|
|
114
|
+
if [ -n "${UAI_RETRY_STATUS:-}" ]; then
|
|
115
|
+
restore=", status='$(sql_escape "$UAI_RETRY_STATUS")'"
|
|
116
|
+
fi
|
|
117
|
+
sqlite_exec "UPDATE uai_tasks SET locked_at=NULL${restore}, updated_at=(unixepoch()*1000) WHERE id='$(sql_escape "$UAI_TASK_ID")'" 2>/dev/null || true
|
|
118
|
+
fi
|
|
119
|
+
UAI_LOCK_HELD=""
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_uai_record_failure() {
|
|
123
|
+
local code="$1" step="$2"
|
|
124
|
+
if _uai_is_runtime_sensitive_error "$code"; then
|
|
125
|
+
_uai_mark_retryable
|
|
126
|
+
else
|
|
127
|
+
_uai_mark_error "$code" "$step"
|
|
128
|
+
fi
|
|
129
|
+
}
|
|
130
|
+
|
|
88
131
|
# _uai_mark_error <code> <step> — best-effort persist of status=error.
|
|
89
132
|
# Shared between emit_err (explicit) and the ERR trap (set -e).
|
|
90
133
|
_uai_mark_error() {
|
|
@@ -204,6 +247,7 @@ UAI_CURRENT_STEP=""
|
|
|
204
247
|
UAI_ERROR_CODE="UNKNOWN"
|
|
205
248
|
UAI_TASK_ID=""
|
|
206
249
|
UAI_LOCK_HELD=""
|
|
250
|
+
UAI_RETRY_STATUS=""
|
|
207
251
|
UAI_PROJECT_LOCK_PATH=""
|
|
208
252
|
UAI_PROJECT_LOCK_TOKEN=""
|
|
209
253
|
|
|
@@ -282,7 +326,7 @@ _uai_on_err() {
|
|
|
282
326
|
local code="$UAI_ERROR_CODE"
|
|
283
327
|
local stepname="$UAI_CURRENT_STEP"
|
|
284
328
|
|
|
285
|
-
|
|
329
|
+
_uai_record_failure "$code" "$stepname"
|
|
286
330
|
|
|
287
331
|
local out
|
|
288
332
|
out=$(jq -nc --arg c "$code" --arg m "agent step failed" --arg s "$stepname" --argjson rc "$rc" \
|
|
@@ -310,6 +354,82 @@ compose_project_for_task() { printf 'task-%s' "$(_uai_lower "$1")"; }
|
|
|
310
354
|
# ever do scale, swap to discovering the container name via the
|
|
311
355
|
# `com.docker.compose.project` + `com.docker.compose.service` labels.
|
|
312
356
|
app_container_for_task() { printf 'task-%s-app-1' "$(_uai_lower "$1")"; }
|
|
357
|
+
# ADR-106: the Apple runtime has no Compose, so its app container drops the
|
|
358
|
+
# replica suffix. Must match appleTaskContainerName in
|
|
359
|
+
# lib/task-environment/apple-container.ts.
|
|
360
|
+
apple_app_container_for_task() { printf 'task-%s-app' "$(_uai_lower "$1")"; }
|
|
361
|
+
|
|
362
|
+
# -----------------------------------------------------------------------------
|
|
363
|
+
# ADR-106 container runtime selection. The host publishes the selected backend
|
|
364
|
+
# in UAI_TASK_RUNTIME (+ UAI_CONTAINER_CLI for the bundled Apple toolchain);
|
|
365
|
+
# `docker` keeps every historical path byte-for-byte.
|
|
366
|
+
# -----------------------------------------------------------------------------
|
|
367
|
+
TASK_RUNTIME_MODE="${UAI_TASK_RUNTIME:-docker}"
|
|
368
|
+
CONTAINER_CLI="${UAI_CONTAINER_CLI:-}"
|
|
369
|
+
|
|
370
|
+
validate_task_runtime_selection() {
|
|
371
|
+
case "$TASK_RUNTIME_MODE" in
|
|
372
|
+
docker|apple-container) ;;
|
|
373
|
+
*)
|
|
374
|
+
emit_err "BAD_ARGS" \
|
|
375
|
+
"unsupported UAI_TASK_RUNTIME '$TASK_RUNTIME_MODE'" \
|
|
376
|
+
"select container runtime"
|
|
377
|
+
;;
|
|
378
|
+
esac
|
|
379
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
380
|
+
case "$CONTAINER_CLI" in
|
|
381
|
+
/*) ;;
|
|
382
|
+
*)
|
|
383
|
+
emit_err "BAD_ARGS" \
|
|
384
|
+
"UAI_CONTAINER_CLI must be an absolute path to the bundled container CLI" \
|
|
385
|
+
"select container runtime"
|
|
386
|
+
;;
|
|
387
|
+
esac
|
|
388
|
+
if [ -L "$CONTAINER_CLI" ] || [ ! -f "$CONTAINER_CLI" ] \
|
|
389
|
+
|| [ ! -x "$CONTAINER_CLI" ]; then
|
|
390
|
+
emit_err "BAD_ARGS" \
|
|
391
|
+
"the bundled container CLI at $CONTAINER_CLI is missing or not executable" \
|
|
392
|
+
"select container runtime"
|
|
393
|
+
fi
|
|
394
|
+
fi
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# Single dispatcher for the verbs whose flags are identical across Docker and
|
|
398
|
+
# the Apple CLI (exec/cp/stop/inspect/image inspect/volume create). Verbs that
|
|
399
|
+
# differ (compose, run, build, port) branch explicitly at their call sites.
|
|
400
|
+
crt() {
|
|
401
|
+
if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
402
|
+
"$CONTAINER_CLI" "$@"
|
|
403
|
+
else
|
|
404
|
+
docker "$@"
|
|
405
|
+
fi
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
# Absence proof for the Apple runtime. The CLI exits nonzero for BOTH a
|
|
409
|
+
# missing container and a broken apiserver; only the not-found error that
|
|
410
|
+
# names this exact container proves absence. Everything else proves nothing
|
|
411
|
+
# and must never authorize destructive work.
|
|
412
|
+
apple_container_proven_absent() {
|
|
413
|
+
apple_absent_name="$1"
|
|
414
|
+
# One CLI response supplies the whole proof: nonzero exit, empty stdout,
|
|
415
|
+
# and the complete not-found error line naming this exact container (never
|
|
416
|
+
# a substring — a not-found answer for "$name-shadow" must not prove the
|
|
417
|
+
# absence of "$name"). Two separate invocations could each satisfy half
|
|
418
|
+
# while no single response satisfies both.
|
|
419
|
+
apple_absent_dir=$(mktemp -d "${TMPDIR:-/tmp}/uai-absent.XXXXXX") \
|
|
420
|
+
|| return 1
|
|
421
|
+
apple_absent_rc=0
|
|
422
|
+
"$CONTAINER_CLI" inspect "$apple_absent_name" \
|
|
423
|
+
>"$apple_absent_dir/out" 2>"$apple_absent_dir/err" \
|
|
424
|
+
|| apple_absent_rc=$?
|
|
425
|
+
apple_absent_proven=1
|
|
426
|
+
[ "$apple_absent_rc" -ne 0 ] || apple_absent_proven=0
|
|
427
|
+
[ ! -s "$apple_absent_dir/out" ] || apple_absent_proven=0
|
|
428
|
+
grep -Fqx "Error: container not found: $apple_absent_name" \
|
|
429
|
+
"$apple_absent_dir/err" 2>/dev/null || apple_absent_proven=0
|
|
430
|
+
rm -rf "$apple_absent_dir"
|
|
431
|
+
[ "$apple_absent_proven" -eq 1 ]
|
|
432
|
+
}
|
|
313
433
|
|
|
314
434
|
# Canonicalise every GitHub URL shape to credential-free HTTPS. task-up selects
|
|
315
435
|
# the credential per task: the creator's connected GitHub token is primary;
|