@runuai/host 0.9.150 → 0.9.151
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1830 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
package/lib/github-tokens.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { and, eq } from "drizzle-orm";
|
|
|
11
11
|
|
|
12
12
|
import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
|
|
13
13
|
import { getDb, schema } from "./db";
|
|
14
|
+
import { githubApiOrigin } from "./github-origin";
|
|
14
15
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
15
16
|
import { dockerCli } from "./docker-exec";
|
|
16
17
|
import {
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
ensureTaskSshIdentity,
|
|
34
35
|
} from "./git-identity";
|
|
35
36
|
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
37
|
+
import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
36
38
|
import { sidecarMode } from "./sidecar";
|
|
37
39
|
import { signInBoxMode } from "./signin-box";
|
|
38
40
|
import {
|
|
@@ -95,7 +97,7 @@ const SANITIZED_DOCKER_EXEC_ENV = [
|
|
|
95
97
|
"SSH_ASKPASS=",
|
|
96
98
|
] as const;
|
|
97
99
|
|
|
98
|
-
const CLEAN_CONTAINER_ENV = [
|
|
100
|
+
export const CLEAN_CONTAINER_ENV = [
|
|
99
101
|
"/usr/bin/env",
|
|
100
102
|
"-i",
|
|
101
103
|
"HOME=/home/node",
|
|
@@ -105,7 +107,7 @@ const CLEAN_CONTAINER_ENV = [
|
|
|
105
107
|
|
|
106
108
|
/** Provider-exec form of {@link SANITIZED_DOCKER_EXEC_ENV}: same overrides,
|
|
107
109
|
* carried as a bounded env record instead of docker `-e` flags. */
|
|
108
|
-
const SANITIZED_CONTAINER_ENV: Readonly<Record<string, string>> =
|
|
110
|
+
export const SANITIZED_CONTAINER_ENV: Readonly<Record<string, string>> =
|
|
109
111
|
Object.freeze({
|
|
110
112
|
HOME: "/home/node",
|
|
111
113
|
GH_CONFIG_DIR: MANAGED_GH_CONFIG_DIR,
|
|
@@ -1043,29 +1045,23 @@ interface GitHubListDeps {
|
|
|
1043
1045
|
requestAccessToken?: typeof requestAccessToken;
|
|
1044
1046
|
fetch?: typeof fetch;
|
|
1045
1047
|
credentialGenerationMatches?: typeof githubCredentialGenerationMatches;
|
|
1048
|
+
/** When the whole operation must end (the token's read included). Absent,
|
|
1049
|
+
* the listing alone gets its own bound once a token is in hand. */
|
|
1050
|
+
deadlineAt?: number;
|
|
1046
1051
|
}
|
|
1047
1052
|
|
|
1048
1053
|
/**
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
* entirely, so a staged URL can never be pointed at a real user's token.
|
|
1054
|
+
* The token could not be read where it is kept (the sign-in box's store):
|
|
1055
|
+
* a fixed message, and a transient code, never the store's own words.
|
|
1052
1056
|
*/
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
if (!configured) return fallback;
|
|
1058
|
-
const url = new URL(configured);
|
|
1059
|
-
if (
|
|
1060
|
-
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
1061
|
-
url.username ||
|
|
1062
|
-
url.password
|
|
1063
|
-
) {
|
|
1064
|
-
throw new Error("UAI_GITHUB_API_URL must be an HTTP(S) origin");
|
|
1057
|
+
export class GitHubTokenUnreadableError extends Error {
|
|
1058
|
+
constructor() {
|
|
1059
|
+
super("the GitHub sign-in could not be read");
|
|
1060
|
+
this.name = "GitHubTokenUnreadableError";
|
|
1065
1061
|
}
|
|
1066
|
-
return url.origin;
|
|
1067
1062
|
}
|
|
1068
1063
|
|
|
1064
|
+
|
|
1069
1065
|
/**
|
|
1070
1066
|
* Every installation GitHub currently lists for this user, read with the token
|
|
1071
1067
|
* this host already holds — the host-side half of `gh.installations.list`.
|
|
@@ -1099,6 +1095,9 @@ export async function listUserInstallationIds(
|
|
|
1099
1095
|
try {
|
|
1100
1096
|
tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
|
|
1101
1097
|
} catch (err) {
|
|
1098
|
+
if (err instanceof GitHubTokenUnreadableError) {
|
|
1099
|
+
throw new GitHubRepositoryAccessError("github_unavailable", err.message);
|
|
1100
|
+
}
|
|
1102
1101
|
const reason = err instanceof Error ? err.message : String(err);
|
|
1103
1102
|
throw new GitHubRepositoryAccessError(
|
|
1104
1103
|
isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
|
|
@@ -1112,7 +1111,7 @@ export async function listUserInstallationIds(
|
|
|
1112
1111
|
);
|
|
1113
1112
|
}
|
|
1114
1113
|
const ids = new Set<number>();
|
|
1115
|
-
const deadline = Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
|
|
1114
|
+
const deadline = deps.deadlineAt ?? Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
|
|
1116
1115
|
let res: Response;
|
|
1117
1116
|
try {
|
|
1118
1117
|
res = await (deps.fetch ?? fetch)(
|
|
@@ -1226,6 +1225,9 @@ export async function listUserInstallationRepositoryIds(
|
|
|
1226
1225
|
try {
|
|
1227
1226
|
tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
|
|
1228
1227
|
} catch (err) {
|
|
1228
|
+
if (err instanceof GitHubTokenUnreadableError) {
|
|
1229
|
+
throw new GitHubRepositoryAccessError("github_unavailable", err.message);
|
|
1230
|
+
}
|
|
1229
1231
|
const reason = err instanceof Error ? err.message : String(err);
|
|
1230
1232
|
throw new GitHubRepositoryAccessError(
|
|
1231
1233
|
isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
|
|
@@ -1240,7 +1242,7 @@ export async function listUserInstallationRepositoryIds(
|
|
|
1240
1242
|
}
|
|
1241
1243
|
const repositoryIds = new Set<number>();
|
|
1242
1244
|
let truncated = false;
|
|
1243
|
-
const deadline = Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
|
|
1245
|
+
const deadline = deps.deadlineAt ?? Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
|
|
1244
1246
|
for (let page = 1; page <= REPOSITORY_PAGE_CAP; page++) {
|
|
1245
1247
|
let res: Response;
|
|
1246
1248
|
try {
|
|
@@ -1337,6 +1339,21 @@ export function clearAllAccessCache(): void {
|
|
|
1337
1339
|
githubAuthGenerations.clear();
|
|
1338
1340
|
}
|
|
1339
1341
|
|
|
1342
|
+
/**
|
|
1343
|
+
* ADR-154 §13, the cutover purge, once the token rows are gone: no GitHub
|
|
1344
|
+
* token stays in this process. Each user with a cached access token or an
|
|
1345
|
+
* exchange under way moves to a new generation, as at a sign-out, which
|
|
1346
|
+
* drops both; an exchange that ends later then caches and stores nothing.
|
|
1347
|
+
* The sign-in box's cache goes too. Unlike the test hook above, no
|
|
1348
|
+
* generation goes back, so nothing fenced off before is let in again.
|
|
1349
|
+
*/
|
|
1350
|
+
export function forgetGithubTokensInMemory(): void {
|
|
1351
|
+
for (const userId of new Set([...accessCache.keys(), ...inflightExchanges.keys()])) {
|
|
1352
|
+
advanceGithubAuthGeneration(userId);
|
|
1353
|
+
}
|
|
1354
|
+
boxTokens.clear();
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1340
1357
|
export async function requestAccessToken(
|
|
1341
1358
|
userId: string,
|
|
1342
1359
|
): Promise<{ accessToken: string; expiresAt: number | null } | null> {
|
|
@@ -2288,11 +2305,29 @@ export function reconcileTaskGitAuth(
|
|
|
2288
2305
|
});
|
|
2289
2306
|
}
|
|
2290
2307
|
|
|
2308
|
+
/**
|
|
2309
|
+
* ADR-154: the runner's own GitHub sign-in (or its SSH key) reaches a task
|
|
2310
|
+
* only while the runner is on its own credentials. Leaving them waits for a
|
|
2311
|
+
* reconciliation already under way, and each of its steps, the last write
|
|
2312
|
+
* included, is fenced on them: none starts once leaving has begun.
|
|
2313
|
+
*/
|
|
2291
2314
|
function enqueueTaskGitAuth(
|
|
2292
2315
|
taskId: string,
|
|
2293
2316
|
userId: string,
|
|
2294
2317
|
deps: ReconcileTaskGitAuthDeps,
|
|
2295
2318
|
invocation: ReconcileInvocation,
|
|
2319
|
+
): Promise<boolean> {
|
|
2320
|
+
return (
|
|
2321
|
+
whileOnOwnCredentials(() => enqueueOwnTaskGitAuth(taskId, userId, deps, invocation)) ??
|
|
2322
|
+
Promise.resolve(false)
|
|
2323
|
+
);
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function enqueueOwnTaskGitAuth(
|
|
2327
|
+
taskId: string,
|
|
2328
|
+
userId: string,
|
|
2329
|
+
deps: ReconcileTaskGitAuthDeps,
|
|
2330
|
+
invocation: ReconcileInvocation,
|
|
2296
2331
|
): Promise<boolean> {
|
|
2297
2332
|
const previous = gitAuthReconcileQueues.get(taskId) ?? Promise.resolve(false);
|
|
2298
2333
|
let queued: Promise<boolean>;
|
|
@@ -2300,7 +2335,7 @@ function enqueueTaskGitAuth(
|
|
|
2300
2335
|
.catch(() => false)
|
|
2301
2336
|
.then(async () => {
|
|
2302
2337
|
const isCurrent = () =>
|
|
2303
|
-
invocation.getGeneration() === invocation.generation;
|
|
2338
|
+
invocation.getGeneration() === invocation.generation && onOwnCredentials();
|
|
2304
2339
|
const admitContainerWork =
|
|
2305
2340
|
deps.admitContainerWork ?? requireContainerRuntimeOperational;
|
|
2306
2341
|
if (!isCurrent()) return false;
|
package/lib/host-env.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
queueStoreWrite,
|
|
24
24
|
type CredentialStore,
|
|
25
25
|
} from "./credential-store";
|
|
26
|
+
import { ENGINE_ENV_CREDENTIAL_KEYS } from "../src/cloud-credentials";
|
|
26
27
|
import { getDb, schema } from "./db";
|
|
27
28
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
28
29
|
import { signInBoxMode } from "./signin-box";
|
|
@@ -83,6 +84,14 @@ export function noteProjectEnvAuthorityChange(): void {
|
|
|
83
84
|
emitProjectEnvChange();
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/** A task's project env changed outside this runner's store (ADR-154: the
|
|
88
|
+
* cloud's task replica, or the composer starting or ending serving it):
|
|
89
|
+
* whoever follows changes (the proxy's trust delivery) looks again. The
|
|
90
|
+
* store's own revisions do not move; nothing caches a view of the cloud's. */
|
|
91
|
+
export function noteProjectEnvViewChange(): void {
|
|
92
|
+
emitProjectEnvChange();
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
/** Current process-local revision of the store's records and gate. */
|
|
87
96
|
export function projectEnvAuthorityRevision(): number {
|
|
88
97
|
return projectEnvAuthorityRevisionCount;
|
|
@@ -402,15 +411,19 @@ const RESERVED_TASK_ENV_EXACT = new Set([
|
|
|
402
411
|
"NODE_PATH", "SHELL", "SHELLOPTS", "BASHOPTS", "PS4", "BASH_ENV", "ENV",
|
|
403
412
|
"NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE",
|
|
404
413
|
"__CURSOR_SANDBOX_ENV_RESTORE", "GROK_LOGIN_ENV",
|
|
405
|
-
|
|
406
|
-
|
|
414
|
+
// Every name an env-token engine's account may set (ADR-154's contract):
|
|
415
|
+
// a project value never stands in for an engine's credential.
|
|
416
|
+
...Object.values(ENGINE_ENV_CREDENTIAL_KEYS).flat(),
|
|
417
|
+
"PLAYWRIGHT_BROWSERS_PATH",
|
|
407
418
|
]);
|
|
408
419
|
const RESERVED_TASK_ENV_NPM = /^npm_config_(prefix|cache|logs[_-]dir|umask)$/i;
|
|
420
|
+
/** Toolchain prefixes, in any case, as task-up.sh matches them. */
|
|
421
|
+
const RESERVED_TASK_ENV_TOOLCHAIN = /^(asdf|corepack)_/i;
|
|
409
422
|
|
|
410
423
|
export function isReservedTaskEnvName(name: string): boolean {
|
|
411
424
|
if (RESERVED_TASK_ENV_EXACT.has(name)) return true;
|
|
412
|
-
if (name.startsWith("GIT_CONFIG") ||
|
|
413
|
-
if (name.startsWith("
|
|
425
|
+
if (name.startsWith("GIT_CONFIG") || RESERVED_TASK_ENV_TOOLCHAIN.test(name)) return true;
|
|
426
|
+
if (name.startsWith("UAI_")) return true;
|
|
414
427
|
if (name.startsWith("__UAI_ENV_")) return true;
|
|
415
428
|
return RESERVED_TASK_ENV_NPM.test(name);
|
|
416
429
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this host was launched for development or tests, or is an
|
|
3
|
+
* installed product. An installed service leaves NODE_ENV unset, so "not
|
|
4
|
+
* production" is the wrong test; these markers are the positive signals of a
|
|
5
|
+
* development launch. Telemetry's default DSN (lib/obs) and any development
|
|
6
|
+
* override of where credentials are sent (lib/github-origin) both read this.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type LaunchEnv = {
|
|
10
|
+
NODE_ENV?: string;
|
|
11
|
+
UAI_AGENT_FROM_REPO?: string;
|
|
12
|
+
UAI_LAUNCHED_FROM_REPO?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A development or test launch: an explicit dev/test NODE_ENV, or a host
|
|
16
|
+
* started from a repository checkout (src/load-env.ts stamps that). */
|
|
17
|
+
export function isDevelopmentLaunch(env: LaunchEnv = process.env): boolean {
|
|
18
|
+
return (
|
|
19
|
+
env.NODE_ENV === "development" ||
|
|
20
|
+
env.NODE_ENV === "test" ||
|
|
21
|
+
env.UAI_AGENT_FROM_REPO === "1" ||
|
|
22
|
+
env.UAI_AGENT_FROM_REPO === "true" ||
|
|
23
|
+
env.UAI_LAUNCHED_FROM_REPO === "1"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §13: pre-v1 task runtimes are retired as the runner moves to
|
|
3
|
+
* cloud credentials. Every runtime it has was created on its own
|
|
4
|
+
* credentials (the mode was `legacy` for each one's whole life), and holds
|
|
5
|
+
* them: Compose interpolated the default slot's engine tokens into the
|
|
6
|
+
* container env, and legacy copies of Codex, Grok, OpenCode, `gh` and SSH
|
|
7
|
+
* credentials sit in its home, recorded nowhere.
|
|
8
|
+
*
|
|
9
|
+
* Two halves, because removing a runtime cannot be undone:
|
|
10
|
+
*
|
|
11
|
+
* - `planLegacyTaskRuntimeRetirement` changes nothing. From one snapshot
|
|
12
|
+
* (the task rows, the identity fence on those same rows, the pinned
|
|
13
|
+
* engine) it finds every blocker known in advance, and rejects with all of
|
|
14
|
+
* them, so a runner that cannot finish stays on v0 exactly as it was: the
|
|
15
|
+
* container runtime not operational; a task row of another backend or
|
|
16
|
+
* engine than the one pinned (the persisted identity fence); a machine
|
|
17
|
+
* (ADR-121) or self runtime, which cannot be removed with its worktree
|
|
18
|
+
* kept; a row naming a Compose project that is not its own; a container at
|
|
19
|
+
* a task's name that cannot be proven that task's; a `task-*` Compose
|
|
20
|
+
* project no row with a runtime names, which nothing proves Uai's
|
|
21
|
+
* (`task-*` alone is no ownership, see orchestrator.ts's runtime
|
|
22
|
+
* isolation), and which the operator removes or renames. A row with no
|
|
23
|
+
* runtime (a task that went down keeps its row, its runtime fields
|
|
24
|
+
* cleared) neither blocks nor vouches for anything. The plan records the
|
|
25
|
+
* engine it was made against.
|
|
26
|
+
* - `retireLegacyTaskRuntimes` carries a plan out, only after the runner has
|
|
27
|
+
* committed to the switch. Each task's check, Stop and removal share its
|
|
28
|
+
* lifecycle slot: before its assignment closes and again before anything
|
|
29
|
+
* is removed, the live engine is re-probed and the task's row checked
|
|
30
|
+
* against the plan's engine, and so again before each command of the
|
|
31
|
+
* removal after its first and once more before the task counts as
|
|
32
|
+
* retired, so an absence answered by another engine never counts. A daemon
|
|
33
|
+
* replaced since (even at the same socket), or a row gone or cleared since
|
|
34
|
+
* (its task went down), is a problem, never a removal: the next plan
|
|
35
|
+
* decides. Each task stops, and its
|
|
36
|
+
* container goes with its worktree kept, exactly as task-up's step 2
|
|
37
|
+
* removes a previous container before a Resume (Docker by the Compose
|
|
38
|
+
* project its task id names, proven by an empty label query; Apple by
|
|
39
|
+
* name, its exact identity proven before it is deleted, and its absence
|
|
40
|
+
* by the CLI's not-found answer). An Apple task container no row names
|
|
41
|
+
* goes too when its label names it exactly (apple-uninstall-teardown.ts's
|
|
42
|
+
* proof). It is idempotent: a runtime already gone counts as removed, so
|
|
43
|
+
* a later attempt finishes what one left. Every removal is attempted; what
|
|
44
|
+
* could not be proven is in `problems`, so the caller can still report the
|
|
45
|
+
* tasks that did go.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import type { DockerResult } from "./docker-exec";
|
|
49
|
+
import { SAFE_HOST_TASK_ID, isSafeHostTaskId } from "./task-identity";
|
|
50
|
+
|
|
51
|
+
/** A task row, as far as its retirement goes. */
|
|
52
|
+
export interface RetirementTask {
|
|
53
|
+
taskId: string;
|
|
54
|
+
/** `uai_host_tasks.environment_provider`; null on rows that predate it
|
|
55
|
+
* (Docker Compose). */
|
|
56
|
+
provider: string | null;
|
|
57
|
+
composeProject: string | null;
|
|
58
|
+
/** The task directory (task-up's `$task_dir`). */
|
|
59
|
+
worktreePath: string | null;
|
|
60
|
+
/** It names a runtime (a locator, or a legacy Compose project). */
|
|
61
|
+
hasRuntime: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The container engine the runner has pinned. */
|
|
65
|
+
export interface RetirementMachine {
|
|
66
|
+
backend: string;
|
|
67
|
+
endpoint: string;
|
|
68
|
+
engineId: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What one plan reads, all at once. */
|
|
72
|
+
export interface RetirementSnapshot {
|
|
73
|
+
tasks: RetirementTask[];
|
|
74
|
+
/** Why the container runtime cannot be used now; null when it can. */
|
|
75
|
+
runtimeProblem: string | null;
|
|
76
|
+
/** The persisted identity fence over these same rows: every row's runtime
|
|
77
|
+
* is on `machine`. */
|
|
78
|
+
fence: { ok: boolean; detail?: string };
|
|
79
|
+
machine: RetirementMachine | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type Cli = (args: string[], opts?: { timeoutMs?: number; maxOutputBytes?: number }) => Promise<DockerResult>;
|
|
83
|
+
|
|
84
|
+
export interface RetirementPlanDeps {
|
|
85
|
+
snapshot: () => RetirementSnapshot;
|
|
86
|
+
/** The pinned container backend. */
|
|
87
|
+
apple: boolean;
|
|
88
|
+
docker: Cli;
|
|
89
|
+
/** The Apple container CLI (only called when `apple`). */
|
|
90
|
+
appleCli: Cli;
|
|
91
|
+
fileExists: (path: string) => boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type PlannedRemoval =
|
|
95
|
+
| { taskId: string; kind: "docker"; project: string; composeFile: string | null }
|
|
96
|
+
| { taskId: string; kind: "apple"; container: string };
|
|
97
|
+
|
|
98
|
+
export interface RetirementPlan {
|
|
99
|
+
/** The engine the plan was made against. */
|
|
100
|
+
machine: RetirementMachine;
|
|
101
|
+
/** Every runtime a task row names, whether or not anything is left of it. */
|
|
102
|
+
removals: PlannedRemoval[];
|
|
103
|
+
/** Apple task containers no row names, each labelled with the task its
|
|
104
|
+
* name names. */
|
|
105
|
+
appleOrphans: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface RetirementDeps extends RetirementPlanDeps {
|
|
109
|
+
/** Why the live engine, or this task's row (null: the engine alone), no
|
|
110
|
+
* longer matches the plan; null while it does. Called inside the task's
|
|
111
|
+
* lifecycle slot, right before its Stop and right before its removal. */
|
|
112
|
+
current: (taskId: string | null) => Promise<string | null>;
|
|
113
|
+
/** The task's Stop, for a caller holding its lifecycle slot. */
|
|
114
|
+
stop: (taskId: string) => Promise<{ ok: true } | { ok: false; error: string }>;
|
|
115
|
+
/** `work` in the task's lifecycle slot. */
|
|
116
|
+
inLifecycle: <T>(taskId: string, work: () => Promise<T>) => Promise<T>;
|
|
117
|
+
log: (line: string) => void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface RetirementReport {
|
|
121
|
+
/** Tasks whose runtime is gone (or was already). */
|
|
122
|
+
retired: string[];
|
|
123
|
+
/** Apple task containers no row named, removed. */
|
|
124
|
+
orphans: string[];
|
|
125
|
+
/** What could not be proven removed: the retirement is not done. */
|
|
126
|
+
problems: string[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const DOCKER_PROVIDER = "docker-compose";
|
|
130
|
+
const APPLE_PROVIDER = "apple-container";
|
|
131
|
+
const TASK_ID = SAFE_HOST_TASK_ID.source.slice(1, -1);
|
|
132
|
+
const TASK_PROJECT = new RegExp(`^task-${TASK_ID}$`);
|
|
133
|
+
const APPLE_TASK_CONTAINER = new RegExp(`^task-(${TASK_ID})-app$`);
|
|
134
|
+
|
|
135
|
+
export async function planLegacyTaskRuntimeRetirement(deps: RetirementPlanDeps): Promise<RetirementPlan> {
|
|
136
|
+
const blockers: string[] = [];
|
|
137
|
+
const snapshot = deps.snapshot();
|
|
138
|
+
if (snapshot.runtimeProblem !== null || snapshot.machine === null) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`task runtimes cannot be retired now: the container runtime is not ready (${snapshot.runtimeProblem ?? "no engine is pinned"})`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!snapshot.fence.ok) blockers.push(snapshot.fence.detail ?? "the task rows do not match the pinned container runtime");
|
|
144
|
+
|
|
145
|
+
const removals: PlannedRemoval[] = [];
|
|
146
|
+
// A row with no runtime takes no part: it blocks nothing, and vouches for
|
|
147
|
+
// no Compose project or container of its task's name.
|
|
148
|
+
const rows = snapshot.tasks.filter((task) => task.hasRuntime);
|
|
149
|
+
for (const task of rows) {
|
|
150
|
+
if (!isSafeHostTaskId(task.taskId)) {
|
|
151
|
+
blockers.push(`a task row has an unsafe id ${JSON.stringify(task.taskId)}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const provider = task.provider ?? DOCKER_PROVIDER;
|
|
155
|
+
if (provider !== DOCKER_PROVIDER && provider !== APPLE_PROVIDER) {
|
|
156
|
+
blockers.push(`task ${task.taskId}: its ${provider} runtime cannot be removed with its worktree kept; close the task first`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if ((provider === APPLE_PROVIDER) !== deps.apple) {
|
|
160
|
+
blockers.push(`task ${task.taskId}: its runtime belongs to another container backend`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (provider === DOCKER_PROVIDER) {
|
|
164
|
+
const project = `task-${task.taskId}`;
|
|
165
|
+
if (task.composeProject !== null && task.composeProject !== project) {
|
|
166
|
+
blockers.push(`task ${task.taskId}: its row names Compose project ${JSON.stringify(task.composeProject)}, not its own`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
removals.push({ taskId: task.taskId, kind: "docker", project, composeFile: composeFile(deps, task) });
|
|
170
|
+
} else {
|
|
171
|
+
const container = `task-${task.taskId}-app`;
|
|
172
|
+
try {
|
|
173
|
+
if ((await appleIdentity(deps.appleCli, container)) === "foreign") {
|
|
174
|
+
blockers.push(`task ${task.taskId}: a container named ${container} exists but cannot be proven this task's; remove or rename it`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
blockers.push(`task ${task.taskId}: ${message(error)}`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
removals.push({ taskId: task.taskId, kind: "apple", container });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const appleOrphans: string[] = [];
|
|
186
|
+
try {
|
|
187
|
+
if (deps.apple) {
|
|
188
|
+
const named = new Set(rows.map((task) => `task-${task.taskId}-app`));
|
|
189
|
+
for (const container of await listOwnedAppleTaskContainers(deps.appleCli)) {
|
|
190
|
+
if (!named.has(container)) appleOrphans.push(container);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
const named = new Set(rows.map((task) => `task-${task.taskId}`));
|
|
194
|
+
for (const project of await listTaskProjects(deps.docker)) {
|
|
195
|
+
if (named.has(project)) continue;
|
|
196
|
+
blockers.push(
|
|
197
|
+
`Compose project ${project} has containers but no task on this runner: nothing proves it Uai's, so it is not removed. ` +
|
|
198
|
+
`If it was a task's, remove it (docker compose -p ${project} down); otherwise rename it`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
blockers.push(`the task runtime inventory failed: ${message(error)}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (blockers.length > 0) {
|
|
207
|
+
throw new Error(`task runtimes cannot all be retired: ${blockers.join("; ").slice(0, 1500)}`);
|
|
208
|
+
}
|
|
209
|
+
return { machine: snapshot.machine, removals, appleOrphans };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function retireLegacyTaskRuntimes(plan: RetirementPlan, deps: RetirementDeps): Promise<RetirementReport> {
|
|
213
|
+
const problems: string[] = [];
|
|
214
|
+
const retired: string[] = [];
|
|
215
|
+
for (const removal of plan.removals) {
|
|
216
|
+
try {
|
|
217
|
+
await deps.inLifecycle(removal.taskId, async () => {
|
|
218
|
+
const staleBeforeStop = await deps.current(removal.taskId);
|
|
219
|
+
if (staleBeforeStop !== null) throw new Error(`not stopped: ${staleBeforeStop}`);
|
|
220
|
+
const stopped = await deps.stop(removal.taskId);
|
|
221
|
+
if (!stopped.ok && stopped.error !== "task has no running stack") {
|
|
222
|
+
// Its removal below proves what matters: the runtime gone.
|
|
223
|
+
deps.log(`[credentials] task ${removal.taskId}: stop before retirement failed (${stopped.error}); removing its runtime`);
|
|
224
|
+
}
|
|
225
|
+
const fence = async (): Promise<void> => {
|
|
226
|
+
const stale = await deps.current(removal.taskId);
|
|
227
|
+
if (stale !== null) throw new Error(`not removed: ${stale}`);
|
|
228
|
+
};
|
|
229
|
+
await (removal.kind === "apple"
|
|
230
|
+
? removeAppleContainer(deps.appleCli, removal.container, fence)
|
|
231
|
+
: removeComposeProject(deps.docker, removal.project, removal.composeFile, fence));
|
|
232
|
+
});
|
|
233
|
+
retired.push(removal.taskId);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
problems.push(`task ${removal.taskId}: ${message(error)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const orphans: string[] = [];
|
|
239
|
+
for (const container of plan.appleOrphans) {
|
|
240
|
+
try {
|
|
241
|
+
const fence = async (): Promise<void> => {
|
|
242
|
+
const stale = await deps.current(null);
|
|
243
|
+
if (stale !== null) throw new Error(`${container} not removed: ${stale}`);
|
|
244
|
+
};
|
|
245
|
+
await removeAppleContainer(deps.appleCli, container, fence);
|
|
246
|
+
orphans.push(container);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
problems.push(message(error));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { retired, orphans, problems };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function composeFile(deps: RetirementPlanDeps, task: RetirementTask): string | null {
|
|
255
|
+
if (task.worktreePath === null) return null;
|
|
256
|
+
const file = `${task.worktreePath}/.uai/docker-compose.yml`;
|
|
257
|
+
return deps.fileExists(file) ? file : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** task-up's step 2 for Docker, and then whatever Compose left, by label.
|
|
261
|
+
* `fence` re-proves the engine before every command and once more after
|
|
262
|
+
* the last, so each command and the absence proof are the planned
|
|
263
|
+
* engine's. */
|
|
264
|
+
async function removeComposeProject(
|
|
265
|
+
docker: Cli,
|
|
266
|
+
project: string,
|
|
267
|
+
file: string | null,
|
|
268
|
+
fence: () => Promise<void>,
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
if (!TASK_PROJECT.test(project)) throw new Error(`refusing to remove Compose project ${JSON.stringify(project)}`);
|
|
271
|
+
await fence();
|
|
272
|
+
await docker(["compose", "-p", project, ...(file === null ? [] : ["-f", file]), "down", "--remove-orphans"], {
|
|
273
|
+
timeoutMs: 180_000,
|
|
274
|
+
maxOutputBytes: 1024 * 1024,
|
|
275
|
+
});
|
|
276
|
+
await fence();
|
|
277
|
+
let left = await projectContainers(docker, project);
|
|
278
|
+
if (left.length > 0) {
|
|
279
|
+
await fence();
|
|
280
|
+
await docker(["rm", "--force", ...left], { timeoutMs: 120_000, maxOutputBytes: 256 * 1024 });
|
|
281
|
+
await fence();
|
|
282
|
+
left = await projectContainers(docker, project);
|
|
283
|
+
}
|
|
284
|
+
await fence();
|
|
285
|
+
if (left.length > 0) throw new Error(`${left.length} container(s) of ${project} are still present`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function projectContainers(docker: Cli, project: string): Promise<string[]> {
|
|
289
|
+
const listed = await docker(["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`], {
|
|
290
|
+
timeoutMs: 30_000,
|
|
291
|
+
maxOutputBytes: 256 * 1024,
|
|
292
|
+
});
|
|
293
|
+
if (listed.status !== 0) {
|
|
294
|
+
throw new Error(`could not list ${project}'s containers: ${listed.stderr.trim().slice(0, 200) || "docker ps failed"}`);
|
|
295
|
+
}
|
|
296
|
+
return lines(listed.stdout);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Every Compose project of a task-shaped name, whatever its containers' state. */
|
|
300
|
+
async function listTaskProjects(docker: Cli): Promise<string[]> {
|
|
301
|
+
const listed = await docker(
|
|
302
|
+
["ps", "-a", "--filter", "label=com.docker.compose.project", "--format", '{{.Label "com.docker.compose.project"}}'],
|
|
303
|
+
{ timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 },
|
|
304
|
+
);
|
|
305
|
+
if (listed.status !== 0) throw new Error(listed.stderr.trim().slice(0, 200) || "docker ps failed");
|
|
306
|
+
return [...new Set(lines(listed.stdout).filter((project) => TASK_PROJECT.test(project)))];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Whether the container at a task's name is that task's: absent, or exactly
|
|
311
|
+
* one record whose id is the name, labelled `com.uai.task` with the task id
|
|
312
|
+
* the name derives from (task-down.sh's proof). Anything else is foreign.
|
|
313
|
+
* Throws when the CLI gives no answer that settles it.
|
|
314
|
+
*/
|
|
315
|
+
async function appleIdentity(cli: Cli, name: string): Promise<"absent" | "owned" | "foreign"> {
|
|
316
|
+
const taskId = APPLE_TASK_CONTAINER.exec(name)?.[1];
|
|
317
|
+
if (taskId === undefined) return "foreign";
|
|
318
|
+
const inspected = await cli(["inspect", name], { timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 });
|
|
319
|
+
if (inspected.status !== 0) {
|
|
320
|
+
if (
|
|
321
|
+
inspected.stdout.trim() === "" &&
|
|
322
|
+
inspected.stderr.split("\n").some((line) => line.trim() === `Error: container not found: ${name}`)
|
|
323
|
+
) {
|
|
324
|
+
return "absent";
|
|
325
|
+
}
|
|
326
|
+
throw new Error(`could not inspect ${name}: ${inspected.stderr.trim().slice(0, 200) || "inspect failed"}`);
|
|
327
|
+
}
|
|
328
|
+
let records: unknown;
|
|
329
|
+
try {
|
|
330
|
+
records = JSON.parse(inspected.stdout);
|
|
331
|
+
} catch {
|
|
332
|
+
return "foreign";
|
|
333
|
+
}
|
|
334
|
+
if (!Array.isArray(records) || records.length !== 1) return "foreign";
|
|
335
|
+
const record = records[0] as { id?: unknown; configuration?: { labels?: Record<string, unknown> } } | null;
|
|
336
|
+
return record?.id === name && record.configuration?.labels?.["com.uai.task"] === taskId ? "owned" : "foreign";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** task-up's step 2 for Apple: its identity proven, then stop, delete, and
|
|
340
|
+
* the not-found answer, `fence` re-proving the engine before every command
|
|
341
|
+
* and once more after the last. */
|
|
342
|
+
async function removeAppleContainer(cli: Cli, name: string, fence: () => Promise<void>): Promise<void> {
|
|
343
|
+
await fence();
|
|
344
|
+
const identity = await appleIdentity(cli, name);
|
|
345
|
+
await fence();
|
|
346
|
+
if (identity === "absent") return;
|
|
347
|
+
if (identity === "foreign") throw new Error(`container ${name} cannot be proven Uai's; it was not removed`);
|
|
348
|
+
await cli(["stop", "--time", "10", name], { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 });
|
|
349
|
+
await fence();
|
|
350
|
+
await cli(["delete", "--force", name], { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 });
|
|
351
|
+
await fence();
|
|
352
|
+
const after = await appleIdentity(cli, name).catch(() => "unknown");
|
|
353
|
+
await fence();
|
|
354
|
+
if (after !== "absent") throw new Error(`container ${name} could not be proven removed`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Uai's task containers no matter their state: labelled `com.uai.task`
|
|
358
|
+
* with exactly the task their name names. */
|
|
359
|
+
async function listOwnedAppleTaskContainers(cli: Cli): Promise<string[]> {
|
|
360
|
+
const listed = await cli(["list", "--all", "--format", "json"], { timeoutMs: 30_000, maxOutputBytes: 8 * 1024 * 1024 });
|
|
361
|
+
if (listed.status !== 0) throw new Error(listed.stderr.trim().slice(0, 200) || "container list failed");
|
|
362
|
+
let rows: unknown;
|
|
363
|
+
try {
|
|
364
|
+
rows = JSON.parse(listed.stdout);
|
|
365
|
+
} catch {
|
|
366
|
+
throw new Error("the container list is not JSON");
|
|
367
|
+
}
|
|
368
|
+
if (!Array.isArray(rows)) throw new Error("the container list is not an array");
|
|
369
|
+
const names: string[] = [];
|
|
370
|
+
for (const row of rows as Array<{ configuration?: { id?: unknown; labels?: Record<string, unknown> } }>) {
|
|
371
|
+
const id = row?.configuration?.id;
|
|
372
|
+
if (typeof id !== "string") continue;
|
|
373
|
+
const taskId = APPLE_TASK_CONTAINER.exec(id)?.[1];
|
|
374
|
+
if (taskId !== undefined && row.configuration?.labels?.["com.uai.task"] === taskId) names.push(id);
|
|
375
|
+
}
|
|
376
|
+
return names;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function lines(text: string): string[] {
|
|
380
|
+
return text
|
|
381
|
+
.split("\n")
|
|
382
|
+
.map((line) => line.trim())
|
|
383
|
+
.filter(Boolean);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function message(error: unknown): string {
|
|
387
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
388
|
+
}
|
package/lib/machine-exec.ts
CHANGED
|
@@ -56,7 +56,12 @@ export function shellQuote(value: string): string {
|
|
|
56
56
|
* instead (`-l`). Env values therefore appear in the machine-side process
|
|
57
57
|
* table; the machine is a single-task trust domain, and hardening to sshd
|
|
58
58
|
* SetEnv acceptlists is recorded ADR-121 follow-up work, not silently
|
|
59
|
-
* skipped.
|
|
59
|
+
* skipped. They also sit on the runner's own `ssh` argv for as long as the
|
|
60
|
+
* connection lives, so a leased engine's credentials never ride here: they
|
|
61
|
+
* travel in its launch's single-use admission (ADR-154 §6,
|
|
62
|
+
* credential-consumers/engine-anchor.ts). With `clean`, the command starts
|
|
63
|
+
* from an empty environment, not the session's (whatever sshd, PAM and the
|
|
64
|
+
* login gave it): only what the request names reaches it.
|
|
60
65
|
*/
|
|
61
66
|
export function buildRemoteCommand(request: {
|
|
62
67
|
argv: readonly string[];
|
|
@@ -64,6 +69,7 @@ export function buildRemoteCommand(request: {
|
|
|
64
69
|
inheritEnv?: readonly string[];
|
|
65
70
|
env?: Readonly<Record<string, string>>;
|
|
66
71
|
detached?: boolean;
|
|
72
|
+
clean?: boolean;
|
|
67
73
|
}): string {
|
|
68
74
|
const parts: string[] = [];
|
|
69
75
|
if (request.cwd) {
|
|
@@ -79,8 +85,12 @@ export function buildRemoteCommand(request: {
|
|
|
79
85
|
)) {
|
|
80
86
|
env.push(`${name}=${value}`);
|
|
81
87
|
}
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
// A clean start names env absolutely: the session's PATH never picks it.
|
|
89
|
+
const envPrefix = request.clean
|
|
90
|
+
? `/usr/bin/env -i ${env.map((entry) => `${shellQuote(entry)} `).join("")}`
|
|
91
|
+
: env.length > 0
|
|
92
|
+
? `env ${env.map(shellQuote).join(" ")} `
|
|
93
|
+
: "";
|
|
84
94
|
const command = request.argv.map(shellQuote).join(" ");
|
|
85
95
|
if (request.detached) {
|
|
86
96
|
// Detached durable sessions manage their own transcript IO (runner.mjs);
|
|
@@ -146,6 +156,7 @@ export function machineEnvironmentSshArgs(
|
|
|
146
156
|
user?: string;
|
|
147
157
|
inheritEnv?: readonly string[];
|
|
148
158
|
env?: Readonly<Record<string, string>>;
|
|
159
|
+
clean?: boolean;
|
|
149
160
|
},
|
|
150
161
|
mode: "interactive" | "detached",
|
|
151
162
|
): string[] {
|