@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
package/lib/github-tokens.ts
CHANGED
|
@@ -12,10 +12,26 @@ import { and, eq } from "drizzle-orm";
|
|
|
12
12
|
import { getDb, schema } from "./db";
|
|
13
13
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
14
14
|
import { dockerCli } from "./docker-exec";
|
|
15
|
+
import {
|
|
16
|
+
removeTaskAppContainer,
|
|
17
|
+
taskAppContainerName,
|
|
18
|
+
taskAppContainerState,
|
|
19
|
+
taskContainerCli,
|
|
20
|
+
} from "./task-container-cli";
|
|
21
|
+
import {
|
|
22
|
+
RUNTIME_AUTHORITY_ENV,
|
|
23
|
+
runtimeAuthorityDockerExecArgs,
|
|
24
|
+
} from "./runtime-authority";
|
|
25
|
+
import {
|
|
26
|
+
backgroundContainerWorkAllowed,
|
|
27
|
+
ContainerRuntimeUnavailableError,
|
|
28
|
+
requireContainerRuntimeOperational,
|
|
29
|
+
} from "./runtime-guard";
|
|
15
30
|
import {
|
|
16
31
|
configureTaskSshTransport,
|
|
17
32
|
ensureTaskSshIdentity,
|
|
18
33
|
} from "./git-identity";
|
|
34
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
19
35
|
import {
|
|
20
36
|
MAX_GITHUB_INSTALLATIONS,
|
|
21
37
|
MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
|
|
@@ -84,6 +100,30 @@ const CLEAN_CONTAINER_ENV = [
|
|
|
84
100
|
`PATH=${SAFE_CONTAINER_PATH}`,
|
|
85
101
|
] as const;
|
|
86
102
|
|
|
103
|
+
/** Provider-exec form of {@link SANITIZED_DOCKER_EXEC_ENV}: same overrides,
|
|
104
|
+
* carried as a bounded env record instead of docker `-e` flags. */
|
|
105
|
+
const SANITIZED_CONTAINER_ENV: Readonly<Record<string, string>> =
|
|
106
|
+
Object.freeze({
|
|
107
|
+
HOME: "/home/node",
|
|
108
|
+
GH_CONFIG_DIR: MANAGED_GH_CONFIG_DIR,
|
|
109
|
+
PATH: SAFE_CONTAINER_PATH,
|
|
110
|
+
XDG_CONFIG_HOME: "",
|
|
111
|
+
LD_PRELOAD: "",
|
|
112
|
+
LD_LIBRARY_PATH: "",
|
|
113
|
+
DYLD_INSERT_LIBRARIES: "",
|
|
114
|
+
DYLD_LIBRARY_PATH: "",
|
|
115
|
+
GIT_CONFIG: "",
|
|
116
|
+
GIT_CONFIG_GLOBAL: "",
|
|
117
|
+
GIT_CONFIG_SYSTEM: "",
|
|
118
|
+
GIT_CONFIG_NOSYSTEM: "",
|
|
119
|
+
GIT_CONFIG_COUNT: "0",
|
|
120
|
+
GIT_EXEC_PATH: "",
|
|
121
|
+
GIT_SSH: "",
|
|
122
|
+
GIT_SSH_COMMAND: "",
|
|
123
|
+
GIT_ASKPASS: "",
|
|
124
|
+
SSH_ASKPASS: "",
|
|
125
|
+
});
|
|
126
|
+
|
|
87
127
|
function managedContainerExecArgs(
|
|
88
128
|
container: string,
|
|
89
129
|
command: string[],
|
|
@@ -95,6 +135,7 @@ function managedContainerExecArgs(
|
|
|
95
135
|
"-u",
|
|
96
136
|
"node",
|
|
97
137
|
...SANITIZED_DOCKER_EXEC_ENV,
|
|
138
|
+
...runtimeAuthorityDockerExecArgs(),
|
|
98
139
|
container,
|
|
99
140
|
...CLEAN_CONTAINER_ENV,
|
|
100
141
|
...command,
|
|
@@ -129,6 +170,7 @@ function advanceGithubAuthGeneration(userId: string): number {
|
|
|
129
170
|
export function runGithubConnectionTransition<T>(
|
|
130
171
|
userId: string,
|
|
131
172
|
transition: () => Promise<T>,
|
|
173
|
+
admit?: () => void,
|
|
132
174
|
): Promise<T> {
|
|
133
175
|
const previous =
|
|
134
176
|
githubConnectionTransitionQueues.get(userId) ?? Promise.resolve();
|
|
@@ -136,7 +178,10 @@ export function runGithubConnectionTransition<T>(
|
|
|
136
178
|
let tail!: Promise<void>;
|
|
137
179
|
result = previous
|
|
138
180
|
.catch(() => {})
|
|
139
|
-
.then(
|
|
181
|
+
.then(() => {
|
|
182
|
+
admit?.();
|
|
183
|
+
return transition();
|
|
184
|
+
})
|
|
140
185
|
.finally(() => {
|
|
141
186
|
if (githubConnectionTransitionQueues.get(userId) === tail) {
|
|
142
187
|
githubConnectionTransitionQueues.delete(userId);
|
|
@@ -458,12 +503,12 @@ async function bestEffortRevoke(
|
|
|
458
503
|
}
|
|
459
504
|
|
|
460
505
|
/**
|
|
461
|
-
* Disconnect GitHub on this host (gh.connect.clear handler).
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
506
|
+
* Disconnect GitHub on this host (gh.connect.clear handler). The surrounding
|
|
507
|
+
* claimed `clear` generation makes the stored row inert immediately. Keep that
|
|
508
|
+
* row recoverable until every live container has dropped the credential and
|
|
509
|
+
* selected its disconnected transport; only then delete it and best-effort
|
|
510
|
+
* revoke remotely. A runtime loss can therefore reject without turning a
|
|
511
|
+
* retryable scrub into an irreversible half-transition.
|
|
467
512
|
*
|
|
468
513
|
* ADR-033: only a non-expiring ACCESS token can be revoked by token
|
|
469
514
|
* (`DELETE /applications/{client_id}/token` matches access tokens only — a
|
|
@@ -500,25 +545,6 @@ export async function onConnectClear(
|
|
|
500
545
|
);
|
|
501
546
|
}
|
|
502
547
|
|
|
503
|
-
// Local capability removal is synchronous and authoritative. It must happen
|
|
504
|
-
// even when token decryption or GitHub's revoke endpoint is unavailable.
|
|
505
|
-
(deps.deleteLocal ?? deleteToken)(userId);
|
|
506
|
-
|
|
507
|
-
// Await best-effort remote revocation while the caller still owns the
|
|
508
|
-
// per-user transition lock. revokeAtGitHub catches its own failures; the
|
|
509
|
-
// wrapper also contains failures from injected test/alternate implementations.
|
|
510
|
-
if (stored?.kind === "access") {
|
|
511
|
-
await bestEffortRevoke(
|
|
512
|
-
userId,
|
|
513
|
-
stored.token,
|
|
514
|
-
deps.revoke ?? revokeAtGitHub,
|
|
515
|
-
);
|
|
516
|
-
} else if (stored) {
|
|
517
|
-
console.warn(
|
|
518
|
-
`[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
|
|
519
|
-
);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
548
|
// Incrementing the host-Git generation prevents any not-yet-started use of
|
|
523
549
|
// an old prepared handle. Await already-started task-up operations before
|
|
524
550
|
// discovering and scrubbing containers: a task may create its app container
|
|
@@ -539,6 +565,27 @@ export async function onConnectClear(
|
|
|
539
565
|
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
540
566
|
);
|
|
541
567
|
if (failed) throw failed.reason;
|
|
568
|
+
|
|
569
|
+
// The claimed clear already removed this generation from every authority
|
|
570
|
+
// read. Delete the recoverable ciphertext only after all container scrubs
|
|
571
|
+
// crossed their durable boundary, so the same-generation operation can be
|
|
572
|
+
// retried if Docker disappeared mid-transition.
|
|
573
|
+
(deps.deleteLocal ?? deleteToken)(userId);
|
|
574
|
+
|
|
575
|
+
// Await best-effort remote revocation while the caller still owns the
|
|
576
|
+
// per-user transition lock. revokeAtGitHub catches its own failures; the
|
|
577
|
+
// wrapper also contains failures from injected test/alternate implementations.
|
|
578
|
+
if (stored?.kind === "access") {
|
|
579
|
+
await bestEffortRevoke(
|
|
580
|
+
userId,
|
|
581
|
+
stored.token,
|
|
582
|
+
deps.revoke ?? revokeAtGitHub,
|
|
583
|
+
);
|
|
584
|
+
} else if (stored) {
|
|
585
|
+
console.warn(
|
|
586
|
+
`[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
542
589
|
}
|
|
543
590
|
|
|
544
591
|
/** Fail-closed rollback for a generation that was claimed for set but could
|
|
@@ -1279,7 +1326,10 @@ export type DockerExec = (
|
|
|
1279
1326
|
) => Promise<{ status: number | null; stderr: string }>;
|
|
1280
1327
|
|
|
1281
1328
|
const defaultExec: DockerExec = async (args, input) => {
|
|
1282
|
-
const res = await
|
|
1329
|
+
const res = await taskContainerCli(args, {
|
|
1330
|
+
input,
|
|
1331
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
1332
|
+
});
|
|
1283
1333
|
return { status: res.status, stderr: res.stderr };
|
|
1284
1334
|
};
|
|
1285
1335
|
|
|
@@ -1292,10 +1342,38 @@ export async function injectIntoContainer(
|
|
|
1292
1342
|
taskId: string,
|
|
1293
1343
|
accessToken: string,
|
|
1294
1344
|
exec: DockerExec = defaultExec,
|
|
1345
|
+
admitContainerWork: () => void = requireContainerRuntimeOperational,
|
|
1346
|
+
environment?: Pick<TaskEnvironmentHandle, "exec">,
|
|
1295
1347
|
): Promise<void> {
|
|
1296
|
-
const container =
|
|
1297
|
-
|
|
1298
|
-
|
|
1348
|
+
const container = taskAppContainerName(taskId);
|
|
1349
|
+
// Provider-neutral path: same in-container `/usr/bin/env -i` command, same
|
|
1350
|
+
// sanitized-plus-authority environment, argv/stdin separation preserved.
|
|
1351
|
+
const run: (
|
|
1352
|
+
command: string[],
|
|
1353
|
+
input: string,
|
|
1354
|
+
) => Promise<{ status: number | null; stderr: string }> = environment
|
|
1355
|
+
? async (command, input) => {
|
|
1356
|
+
const [first, ...rest] = [...CLEAN_CONTAINER_ENV, ...command];
|
|
1357
|
+
const result = await environment.exec({
|
|
1358
|
+
argv: [first!, ...rest],
|
|
1359
|
+
user: "node",
|
|
1360
|
+
env: { ...SANITIZED_CONTAINER_ENV, ...RUNTIME_AUTHORITY_ENV },
|
|
1361
|
+
...(input ? { stdin: Buffer.from(input) } : {}),
|
|
1362
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
1363
|
+
maxOutputBytes: 256 * 1024,
|
|
1364
|
+
});
|
|
1365
|
+
return {
|
|
1366
|
+
status: result.exitCode,
|
|
1367
|
+
stderr: Buffer.from(result.stderr).toString("utf8"),
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
: (command, input) =>
|
|
1371
|
+
exec(
|
|
1372
|
+
managedContainerExecArgs(container, command, input.length > 0),
|
|
1373
|
+
input,
|
|
1374
|
+
);
|
|
1375
|
+
admitContainerWork();
|
|
1376
|
+
const res = await run(
|
|
1299
1377
|
[
|
|
1300
1378
|
"/usr/bin/gh",
|
|
1301
1379
|
"auth",
|
|
@@ -1306,40 +1384,38 @@ export async function injectIntoContainer(
|
|
|
1306
1384
|
"--git-protocol",
|
|
1307
1385
|
"https",
|
|
1308
1386
|
],
|
|
1309
|
-
|
|
1387
|
+
`${accessToken}\n`,
|
|
1310
1388
|
);
|
|
1311
|
-
|
|
1389
|
+
admitContainerWork();
|
|
1312
1390
|
if (res.status !== 0) {
|
|
1313
1391
|
throw new Error(`gh auth login failed in ${container}: ${res.stderr.trim()}`);
|
|
1314
1392
|
}
|
|
1315
1393
|
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
"auth",
|
|
1320
|
-
"setup-git",
|
|
1321
|
-
"--hostname",
|
|
1322
|
-
"github.com",
|
|
1323
|
-
]),
|
|
1394
|
+
admitContainerWork();
|
|
1395
|
+
const setup = await run(
|
|
1396
|
+
["/usr/bin/gh", "auth", "setup-git", "--hostname", "github.com"],
|
|
1324
1397
|
"",
|
|
1325
1398
|
);
|
|
1399
|
+
admitContainerWork();
|
|
1326
1400
|
if (setup.status !== 0) {
|
|
1327
1401
|
throw new Error(
|
|
1328
1402
|
`gh git credential setup failed in ${container}: ${setup.stderr.trim()}`,
|
|
1329
1403
|
);
|
|
1330
1404
|
}
|
|
1331
1405
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1406
|
+
admitContainerWork();
|
|
1407
|
+
const transport = await run(
|
|
1408
|
+
[
|
|
1334
1409
|
"/bin/sh",
|
|
1335
1410
|
"-c",
|
|
1336
1411
|
"/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true; " +
|
|
1337
1412
|
"/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true; " +
|
|
1338
1413
|
"/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'; " +
|
|
1339
1414
|
"/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
|
|
1340
|
-
]
|
|
1415
|
+
],
|
|
1341
1416
|
"",
|
|
1342
1417
|
);
|
|
1418
|
+
admitContainerWork();
|
|
1343
1419
|
if (transport.status !== 0) {
|
|
1344
1420
|
throw new Error(
|
|
1345
1421
|
`Git HTTPS transport setup failed in ${container}: ${transport.stderr.trim()}`,
|
|
@@ -1370,6 +1446,10 @@ export function scheduleRefresh(
|
|
|
1370
1446
|
const timer = setTimeout(() => {
|
|
1371
1447
|
timers.delete(taskId);
|
|
1372
1448
|
if (githubAuthGeneration(userId) !== generation) return;
|
|
1449
|
+
if (!backgroundContainerWorkAllowed()) {
|
|
1450
|
+
scheduleGithubRetry(taskId, userId, 0, {}, generation);
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1373
1453
|
void enqueueTaskGitAuth(taskId, userId, {}, {
|
|
1374
1454
|
generation,
|
|
1375
1455
|
getGeneration: () => githubAuthGeneration(userId),
|
|
@@ -1496,6 +1576,13 @@ function scheduleGithubRetry(
|
|
|
1496
1576
|
if (githubAuthGeneration(userId) !== generation) return;
|
|
1497
1577
|
const isActive = deps.taskIsActive ?? taskIsActive;
|
|
1498
1578
|
if (!isActive(taskId)) return; // task ended while we backed off
|
|
1579
|
+
if (!backgroundContainerWorkAllowed()) {
|
|
1580
|
+
// Runtime downtime is not a failed GitHub attempt. Preserve the same
|
|
1581
|
+
// retry budget and wait for another backoff window instead of spawning
|
|
1582
|
+
// Docker while the host is deliberately fail-closed.
|
|
1583
|
+
scheduleGithubRetry(taskId, userId, attempt, deps, generation);
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1499
1586
|
void enqueueTaskGitAuth(taskId, userId, {}, {
|
|
1500
1587
|
generation,
|
|
1501
1588
|
getGeneration: () => githubAuthGeneration(userId),
|
|
@@ -1534,6 +1621,9 @@ export interface SetupTaskDeps {
|
|
|
1534
1621
|
deleteToken?: (userId: string) => void;
|
|
1535
1622
|
taskIsActive?: (taskId: string) => boolean;
|
|
1536
1623
|
scrub?: (taskId: string) => Promise<boolean | void>;
|
|
1624
|
+
/** Late-bound provider handle for the default injection path. Absent (or
|
|
1625
|
+
* null at call time) keeps the legacy Docker fallback. */
|
|
1626
|
+
environment?: () => Pick<TaskEnvironmentHandle, "exec"> | null;
|
|
1537
1627
|
}
|
|
1538
1628
|
|
|
1539
1629
|
/**
|
|
@@ -1546,6 +1636,7 @@ export interface SetupTaskDeps {
|
|
|
1546
1636
|
interface SetupExecutionGuard {
|
|
1547
1637
|
generation: number;
|
|
1548
1638
|
isCurrent: () => boolean;
|
|
1639
|
+
admitContainerWork: () => void;
|
|
1549
1640
|
}
|
|
1550
1641
|
|
|
1551
1642
|
export async function setupTaskGithub(
|
|
@@ -1563,13 +1654,23 @@ export async function setupTaskGithub(
|
|
|
1563
1654
|
const _hasToken = deps.hasToken ?? hasToken;
|
|
1564
1655
|
const _request = deps.requestAccessToken ?? requestAccessToken;
|
|
1565
1656
|
const _inject =
|
|
1566
|
-
deps.inject ??
|
|
1657
|
+
deps.inject ??
|
|
1658
|
+
((t: string, tok: string) =>
|
|
1659
|
+
injectIntoContainer(
|
|
1660
|
+
t,
|
|
1661
|
+
tok,
|
|
1662
|
+
defaultExec,
|
|
1663
|
+
guard?.admitContainerWork ?? requireContainerRuntimeOperational,
|
|
1664
|
+
deps.environment?.() ?? undefined,
|
|
1665
|
+
));
|
|
1567
1666
|
if (!isCurrent()) return false;
|
|
1568
1667
|
try {
|
|
1569
1668
|
if (_hasToken(userId)) {
|
|
1570
1669
|
const tok = await _request(userId);
|
|
1571
1670
|
if (tok && isCurrent()) {
|
|
1671
|
+
guard?.admitContainerWork();
|
|
1572
1672
|
await _inject(taskId, tok.accessToken);
|
|
1673
|
+
guard?.admitContainerWork();
|
|
1573
1674
|
// The mutation itself cannot be interrupted, but a newer transition is
|
|
1574
1675
|
// already queued behind us. Do not let this stale run arm more work.
|
|
1575
1676
|
if (!isCurrent()) return false;
|
|
@@ -1618,7 +1719,9 @@ export async function setupTaskGithub(
|
|
|
1618
1719
|
`not task owner ${userId} — skipping.`,
|
|
1619
1720
|
);
|
|
1620
1721
|
} else {
|
|
1722
|
+
guard?.admitContainerWork();
|
|
1621
1723
|
await _inject(taskId, pat);
|
|
1724
|
+
guard?.admitContainerWork();
|
|
1622
1725
|
clearGithubRetry(taskId);
|
|
1623
1726
|
console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
|
|
1624
1727
|
return true;
|
|
@@ -1630,6 +1733,10 @@ export async function setupTaskGithub(
|
|
|
1630
1733
|
// A newer connect/disconnect owns the final state and is queued behind this
|
|
1631
1734
|
// operation. The superseded run must neither delete its grant nor retry.
|
|
1632
1735
|
if (!isCurrent()) return false;
|
|
1736
|
+
// Runtime unavailability is not an ordinary "GitHub not configured" or
|
|
1737
|
+
// retryable credential verdict. Reconciliation callers must reject so a
|
|
1738
|
+
// destructive set/clear transition cannot be acknowledged as complete.
|
|
1739
|
+
if (err instanceof ContainerRuntimeUnavailableError) throw err;
|
|
1633
1740
|
const reason = err instanceof Error ? err.message : String(err);
|
|
1634
1741
|
console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
|
|
1635
1742
|
// A revoked refresh token can't recover by retrying — drop it so the user
|
|
@@ -1687,8 +1794,11 @@ async function checkedDockerCommand(
|
|
|
1687
1794
|
args: string[],
|
|
1688
1795
|
label: string,
|
|
1689
1796
|
timeoutMs = EXEC_TIMEOUT_MS,
|
|
1797
|
+
admitContainerWork: () => void = requireContainerRuntimeOperational,
|
|
1690
1798
|
): Promise<Awaited<ReturnType<DockerCommand>>> {
|
|
1799
|
+
admitContainerWork();
|
|
1691
1800
|
const result = await run(args, { timeoutMs });
|
|
1801
|
+
admitContainerWork();
|
|
1692
1802
|
if (result.status !== 0) {
|
|
1693
1803
|
throw new Error(
|
|
1694
1804
|
`${label} failed: ${result.stderr.trim() || `exit ${result.status}`}`,
|
|
@@ -1699,52 +1809,27 @@ async function checkedDockerCommand(
|
|
|
1699
1809
|
|
|
1700
1810
|
export async function removeGithubFromContainer(
|
|
1701
1811
|
taskId: string,
|
|
1702
|
-
run: DockerCommand =
|
|
1812
|
+
run: DockerCommand = taskContainerCli,
|
|
1813
|
+
admitContainerWork: () => void = requireContainerRuntimeOperational,
|
|
1703
1814
|
): Promise<boolean> {
|
|
1704
|
-
const container =
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
"{{.State}}",
|
|
1714
|
-
],
|
|
1715
|
-
`inspect ${container}`,
|
|
1716
|
-
);
|
|
1717
|
-
const states = listed.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1718
|
-
if (states.length === 0) return false; // no container means no credential to retain
|
|
1719
|
-
if (states.length !== 1) {
|
|
1720
|
-
throw new Error(`inspect ${container} returned ${states.length} containers`);
|
|
1815
|
+
const container = taskAppContainerName(taskId);
|
|
1816
|
+
admitContainerWork();
|
|
1817
|
+
const observed = await taskAppContainerState(container, run as never);
|
|
1818
|
+
admitContainerWork();
|
|
1819
|
+
if (observed.kind === "unknown") {
|
|
1820
|
+
throw new Error(`inspect ${container} failed: ${observed.detail}`);
|
|
1821
|
+
}
|
|
1822
|
+
if (observed.kind === "absent") {
|
|
1823
|
+
return false; // no container means no credential to retain
|
|
1721
1824
|
}
|
|
1722
1825
|
|
|
1723
|
-
|
|
1724
|
-
if (originalState !== "running") {
|
|
1826
|
+
if (observed.kind !== "running") {
|
|
1725
1827
|
// Never start or unpause untrusted task code while its old credential is
|
|
1726
1828
|
// still on disk. The app container is disposable; workspace/repository
|
|
1727
|
-
// state lives outside it and
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
`remove credential-bearing ${container}`,
|
|
1732
|
-
);
|
|
1733
|
-
const remaining = await checkedDockerCommand(
|
|
1734
|
-
run,
|
|
1735
|
-
[
|
|
1736
|
-
"ps",
|
|
1737
|
-
"--all",
|
|
1738
|
-
"--filter",
|
|
1739
|
-
`name=^/${container}$`,
|
|
1740
|
-
"--format",
|
|
1741
|
-
"{{.State}}",
|
|
1742
|
-
],
|
|
1743
|
-
`verify removal of ${container}`,
|
|
1744
|
-
);
|
|
1745
|
-
if (remaining.stdout.trim()) {
|
|
1746
|
-
throw new Error(`remove ${container} was not verified`);
|
|
1747
|
-
}
|
|
1829
|
+
// state lives outside it and provisioning recreates it on Resume.
|
|
1830
|
+
admitContainerWork();
|
|
1831
|
+
await removeTaskAppContainer(container, run as never);
|
|
1832
|
+
admitContainerWork();
|
|
1748
1833
|
return false;
|
|
1749
1834
|
}
|
|
1750
1835
|
|
|
@@ -1756,6 +1841,8 @@ export async function removeGithubFromContainer(
|
|
|
1756
1841
|
REMOVE_MANAGED_GITHUB_AUTH,
|
|
1757
1842
|
]),
|
|
1758
1843
|
`remove managed GitHub auth from ${container}`,
|
|
1844
|
+
EXEC_TIMEOUT_MS,
|
|
1845
|
+
admitContainerWork,
|
|
1759
1846
|
);
|
|
1760
1847
|
return true;
|
|
1761
1848
|
}
|
|
@@ -1768,6 +1855,9 @@ export interface ReconcileTaskGitAuthDeps {
|
|
|
1768
1855
|
logoutContainer?: (taskId: string) => Promise<boolean | void>;
|
|
1769
1856
|
ensureSsh?: (taskId: string, userId: string) => Promise<boolean>;
|
|
1770
1857
|
selectSsh?: (taskId: string) => Promise<boolean>;
|
|
1858
|
+
/** Last-hop Docker admission. Recovery supplies an epoch-scoped predicate
|
|
1859
|
+
* while the public runtime deliberately remains `checking`. */
|
|
1860
|
+
admitContainerWork?: () => void;
|
|
1771
1861
|
}
|
|
1772
1862
|
|
|
1773
1863
|
interface ReconcileInvocation {
|
|
@@ -1810,7 +1900,10 @@ function enqueueTaskGitAuth(
|
|
|
1810
1900
|
.then(async () => {
|
|
1811
1901
|
const isCurrent = () =>
|
|
1812
1902
|
invocation.getGeneration() === invocation.generation;
|
|
1903
|
+
const admitContainerWork =
|
|
1904
|
+
deps.admitContainerWork ?? requireContainerRuntimeOperational;
|
|
1813
1905
|
if (!isCurrent()) return false;
|
|
1906
|
+
admitContainerWork();
|
|
1814
1907
|
|
|
1815
1908
|
const connected =
|
|
1816
1909
|
deps.hasToken ?? invocation.setupDeps?.hasToken ?? hasToken;
|
|
@@ -1821,12 +1914,14 @@ function enqueueTaskGitAuth(
|
|
|
1821
1914
|
// remove every managed account/helper first, in the same per-task queue,
|
|
1822
1915
|
// then install only the current user's credential. Non-running containers
|
|
1823
1916
|
// are removed rather than executed with the old credential on disk.
|
|
1824
|
-
const
|
|
1917
|
+
const logoutContainer =
|
|
1825
1918
|
deps.logoutContainer ??
|
|
1826
1919
|
invocation.setupDeps?.scrub ??
|
|
1827
|
-
|
|
1828
|
-
|
|
1920
|
+
((id: string) =>
|
|
1921
|
+
removeGithubFromContainer(id, taskContainerCli, admitContainerWork));
|
|
1922
|
+
const canConfigureLiveTransport = await logoutContainer(taskId);
|
|
1829
1923
|
if (!isCurrent()) return false;
|
|
1924
|
+
admitContainerWork();
|
|
1830
1925
|
if (canConfigureLiveTransport === false) {
|
|
1831
1926
|
// No credential-bearing container remains. A connected credential will
|
|
1832
1927
|
// be installed when Compose recreates it; disconnected state is already
|
|
@@ -1836,29 +1931,45 @@ function enqueueTaskGitAuth(
|
|
|
1836
1931
|
|
|
1837
1932
|
if (shouldConnect) {
|
|
1838
1933
|
if (deps.setupGithub) {
|
|
1934
|
+
admitContainerWork();
|
|
1839
1935
|
const result = await deps.setupGithub(taskId, userId);
|
|
1840
|
-
|
|
1936
|
+
if (!isCurrent()) return false;
|
|
1937
|
+
admitContainerWork();
|
|
1938
|
+
return result;
|
|
1841
1939
|
}
|
|
1842
|
-
|
|
1940
|
+
admitContainerWork();
|
|
1941
|
+
const result = await setupTaskGithub(
|
|
1843
1942
|
taskId,
|
|
1844
1943
|
userId,
|
|
1845
1944
|
invocation.setupDeps,
|
|
1846
1945
|
invocation.attempt,
|
|
1847
|
-
{
|
|
1946
|
+
{
|
|
1947
|
+
generation: invocation.generation,
|
|
1948
|
+
isCurrent,
|
|
1949
|
+
admitContainerWork,
|
|
1950
|
+
},
|
|
1848
1951
|
);
|
|
1952
|
+
if (!isCurrent()) return false;
|
|
1953
|
+
admitContainerWork();
|
|
1954
|
+
return result;
|
|
1849
1955
|
}
|
|
1850
1956
|
|
|
1851
1957
|
// SSH remains an opt-in fallback for users who did not connect GitHub;
|
|
1852
1958
|
// ensuring the identity also keeps commit/tag signing available. Never
|
|
1853
1959
|
// select SSH transport unless a usable identity was actually asserted.
|
|
1960
|
+
admitContainerWork();
|
|
1854
1961
|
const hasSshIdentity = await (
|
|
1855
1962
|
deps.ensureSsh ?? ensureTaskSshIdentity
|
|
1856
1963
|
)(taskId, userId);
|
|
1857
1964
|
if (!isCurrent()) return false;
|
|
1965
|
+
admitContainerWork();
|
|
1858
1966
|
if (hasSshIdentity) {
|
|
1967
|
+
admitContainerWork();
|
|
1859
1968
|
const selected = await (
|
|
1860
1969
|
deps.selectSsh ?? configureTaskSshTransport
|
|
1861
1970
|
)(taskId);
|
|
1971
|
+
if (!isCurrent()) return false;
|
|
1972
|
+
admitContainerWork();
|
|
1862
1973
|
if (!selected) {
|
|
1863
1974
|
throw new Error(
|
|
1864
1975
|
`task ${taskId}: SSH fallback transport could not be configured`,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical cloud endpoint parsing shared by first enrollment, later org
|
|
3
|
+
* attachment, and the crash-recovery journal.
|
|
4
|
+
*
|
|
5
|
+
* The HTTP enrollment call and the persistent WebSocket connection must name
|
|
6
|
+
* the same authority. Accepting an arbitrary path/query for one while sending
|
|
7
|
+
* the other to a fixed API origin can successfully spend a one-time token and
|
|
8
|
+
* then leave a host that can never connect.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ALLOWED_PROTOCOLS = new Set(["ws:", "wss:", "http:", "https:"]);
|
|
12
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
13
|
+
|
|
14
|
+
export interface HostCloudEndpoint {
|
|
15
|
+
/** Canonical value persisted as UAI_CLOUD_URL. */
|
|
16
|
+
readonly bridgeUrl: string;
|
|
17
|
+
/** Matching HTTPS/HTTP authority used by enrollment API calls. */
|
|
18
|
+
readonly apiOrigin: string;
|
|
19
|
+
readonly displayHost: string;
|
|
20
|
+
readonly secure: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseHostCloudEndpoint(raw: string): HostCloudEndpoint {
|
|
24
|
+
if (raw.length === 0 || raw.length > 2_048) {
|
|
25
|
+
throw new Error("cloud URL is invalid");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let parsed: URL;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error("cloud URL is invalid");
|
|
33
|
+
}
|
|
34
|
+
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
|
35
|
+
throw new Error(`unsupported cloud URL protocol: ${parsed.protocol}`);
|
|
36
|
+
}
|
|
37
|
+
if (parsed.username || parsed.password) {
|
|
38
|
+
throw new Error("cloud URL must not contain credentials");
|
|
39
|
+
}
|
|
40
|
+
if (parsed.pathname !== "/host") {
|
|
41
|
+
throw new Error("cloud URL path must be exactly /host");
|
|
42
|
+
}
|
|
43
|
+
if (parsed.search || parsed.hash) {
|
|
44
|
+
throw new Error("cloud URL must not contain a query or fragment");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const secure = parsed.protocol === "wss:" || parsed.protocol === "https:";
|
|
48
|
+
if (!secure && !LOOPBACK_HOSTS.has(parsed.hostname)) {
|
|
49
|
+
throw new Error("refusing to use a plaintext cloud URL outside loopback");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
parsed.protocol = secure ? "wss:" : "ws:";
|
|
53
|
+
const bridgeUrl = parsed.toString();
|
|
54
|
+
const apiOrigin = `${secure ? "https:" : "http:"}//${parsed.host}`;
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
bridgeUrl,
|
|
57
|
+
apiOrigin,
|
|
58
|
+
displayHost: parsed.host,
|
|
59
|
+
secure,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|