@moondeck/station 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- 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/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
|
@@ -0,0 +1,2933 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generation-scoped remote engine login (ADR-100).
|
|
3
|
+
*
|
|
4
|
+
* OAuth happens in a short-lived standard-image container, never in a task
|
|
5
|
+
* container. The cloud sees only a validated HTTPS authorization URL and
|
|
6
|
+
* secret-blind progress. One-time codes/callback queries are accepted once,
|
|
7
|
+
* consumed locally, and are never copied into an event, error, or log line.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { constants, existsSync } from "node:fs";
|
|
13
|
+
import {
|
|
14
|
+
chmod,
|
|
15
|
+
lstat,
|
|
16
|
+
mkdir,
|
|
17
|
+
mkdtemp,
|
|
18
|
+
open,
|
|
19
|
+
readdir,
|
|
20
|
+
readFile,
|
|
21
|
+
realpath,
|
|
22
|
+
rename,
|
|
23
|
+
rm,
|
|
24
|
+
unlink,
|
|
25
|
+
} from "node:fs/promises";
|
|
26
|
+
import { homedir, tmpdir } from "node:os";
|
|
27
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
ENGINE_LOGIN_KINDS,
|
|
31
|
+
HOST_ACCOUNT_SCOPE,
|
|
32
|
+
isEngineAccountLabel,
|
|
33
|
+
isEngineAccountScope,
|
|
34
|
+
isHostOpId,
|
|
35
|
+
MAX_ENGINE_LOGIN_QUERY_CHARS,
|
|
36
|
+
MAX_ENGINE_LOGIN_TEXT_CHARS,
|
|
37
|
+
MAX_ENGINE_LOGIN_URL_CHARS,
|
|
38
|
+
type EngineLoginCapability,
|
|
39
|
+
type EngineLoginErrorCode,
|
|
40
|
+
type EngineLoginEventFrame,
|
|
41
|
+
type EngineLoginKind,
|
|
42
|
+
} from "../src/protocol";
|
|
43
|
+
import type { CaptureFlowWire } from "../src/cloud-credentials";
|
|
44
|
+
import type { CaptureOutcome, EngineCaptureMaterial } from "./capture-flow";
|
|
45
|
+
import {
|
|
46
|
+
addCodexAccountFromAuthJson,
|
|
47
|
+
addEngineAccount,
|
|
48
|
+
addGrokAccountFromAuthJson,
|
|
49
|
+
} from "./engine-accounts";
|
|
50
|
+
import { reinjectCodexRunningTasks } from "./codex-auth";
|
|
51
|
+
import {
|
|
52
|
+
appleContainerRuntimeBinding,
|
|
53
|
+
pinnedContainerRuntimeProvider,
|
|
54
|
+
} from "./container-runtime";
|
|
55
|
+
import { dockerCli } from "./docker-exec";
|
|
56
|
+
import { env } from "./env";
|
|
57
|
+
import {
|
|
58
|
+
serializeEnvFileMutation,
|
|
59
|
+
writeEnvValuesAtomic,
|
|
60
|
+
} from "./env-file";
|
|
61
|
+
import { ASDF_DATA_VOLUME, STANDARD_IMAGE_TAG } from "./standard-image";
|
|
62
|
+
|
|
63
|
+
export const ENGINE_LOGIN_TIMEOUT_MS = 10 * 60_000;
|
|
64
|
+
/** Delay before the separate submit keystroke — long enough for the TUI to
|
|
65
|
+
* finish ingesting the pasted code chunk, short enough to feel instant. */
|
|
66
|
+
export const SUBMIT_KEYSTROKE_DELAY_MS = 300;
|
|
67
|
+
// Global serialization keeps a failed per-operation stale-resource sweep from
|
|
68
|
+
// ever deleting another live login's labeled container or marked temp dir.
|
|
69
|
+
/**
|
|
70
|
+
* Concurrent login containers per runner. Workspace sign-ins run side by
|
|
71
|
+
* side (ADR-136 sign-in box: a user is never told to wait for someone else's
|
|
72
|
+
* login); only the host's DEFAULT slot is exclusive, per engine. Bounded so
|
|
73
|
+
* a burst cannot fork containers without limit.
|
|
74
|
+
*/
|
|
75
|
+
export const MAX_CONCURRENT_ENGINE_LOGINS = 16;
|
|
76
|
+
|
|
77
|
+
const LOGIN_MOUNT = "/run/uai-engine-login";
|
|
78
|
+
const LOGIN_HOME = `${LOGIN_MOUNT}/home`;
|
|
79
|
+
const CODEX_HOME = `${LOGIN_MOUNT}/codex`;
|
|
80
|
+
/** grok writes its auth.json under GROK_HOME (host 0.9.105 remote sign-in). */
|
|
81
|
+
const GROK_HOME = `${LOGIN_MOUNT}/grok`;
|
|
82
|
+
/** The login image only carries grok when the host had it configured at its
|
|
83
|
+
* last image build; a shared host never did. Install into the op's private
|
|
84
|
+
* HOME on the fly (x.ai's installer, ~seconds) when the image lacks it. */
|
|
85
|
+
const GROK_LOGIN_SHELL =
|
|
86
|
+
'g="$(command -v grok 2>/dev/null || true)"; ' +
|
|
87
|
+
'[ -n "$g" ] || { curl -fsSL https://x.ai/cli/install.sh | bash >/dev/null 2>&1; g="$HOME/.grok/bin/grok"; }; ' +
|
|
88
|
+
'[ -x "$g" ] || { echo "grok CLI is not available in the login image" >&2; exit 127; }; ' +
|
|
89
|
+
'exec "$g" login --device-auth';
|
|
90
|
+
const MAX_OUTPUT_TAIL_BYTES = 64 * 1_024;
|
|
91
|
+
/** script(1) transcripts of a login run a few KB; 1MB is a hostile-file cap. */
|
|
92
|
+
const MAX_TRANSCRIPT_BYTES = 1_024 * 1_024;
|
|
93
|
+
const MAX_CODEX_AUTH_BYTES = 1024 * 1024;
|
|
94
|
+
const DOCKER_CONTROL_TIMEOUT_MS = 15_000;
|
|
95
|
+
const DOCKER_CLEANUP_TIMEOUT_MS = 5_000;
|
|
96
|
+
const DOCKER_CLEANUP_ATTEMPTS = 3;
|
|
97
|
+
const DOCKER_RUN_STOP_GRACE_MS = 1_000;
|
|
98
|
+
const MAX_STALE_LOGIN_CONTAINERS = 64;
|
|
99
|
+
const MAX_STALE_LOGIN_TEMP_DIRS = 64;
|
|
100
|
+
const ENGINE_LOGIN_TEMP_DIR_PATTERN = /^uai-engine-login-[A-Za-z0-9]{6}$/;
|
|
101
|
+
const ENGINE_LOGIN_TEMP_MARKER = ".uai-engine-login-owner";
|
|
102
|
+
const ENGINE_LOGIN_TEMP_MARKER_VERSION = "v1";
|
|
103
|
+
export const ENGINE_LOGIN_CONTAINER_LABEL = "io.moondeck.engine-login";
|
|
104
|
+
export const ENGINE_LOGIN_CONTAINER_LABEL_VALUE = "v1";
|
|
105
|
+
|
|
106
|
+
type EmitEngineLoginEvent = (event: EngineLoginEventFrame) => void;
|
|
107
|
+
|
|
108
|
+
export interface EngineLoginProcessResult {
|
|
109
|
+
readonly code: number | null;
|
|
110
|
+
readonly spawnFailed?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A running `docker run --rm` login container. */
|
|
114
|
+
export interface EngineLoginProcess {
|
|
115
|
+
readonly containerName: string;
|
|
116
|
+
readonly completion: Promise<EngineLoginProcessResult>;
|
|
117
|
+
writeInput(value: string): void;
|
|
118
|
+
stop(): Promise<void>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface EngineLoginContainerRequest {
|
|
122
|
+
readonly engine: EngineLoginKind;
|
|
123
|
+
readonly containerName: string;
|
|
124
|
+
readonly tempDir: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface EngineLoginDockerResult {
|
|
128
|
+
readonly status: number | null;
|
|
129
|
+
readonly stdout: string;
|
|
130
|
+
readonly stderr: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type EngineLoginDockerExec = (
|
|
134
|
+
args: string[],
|
|
135
|
+
options: {
|
|
136
|
+
input?: string;
|
|
137
|
+
timeoutMs: number;
|
|
138
|
+
maxOutputBytes: number;
|
|
139
|
+
},
|
|
140
|
+
) => Promise<EngineLoginDockerResult>;
|
|
141
|
+
|
|
142
|
+
export interface EngineLoginTimers {
|
|
143
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
144
|
+
clearTimeout(timer: unknown): void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface AccountAddOutcome {
|
|
148
|
+
ok: boolean;
|
|
149
|
+
message: string;
|
|
150
|
+
id?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface EngineLoginSeams {
|
|
154
|
+
createTempDir(): Promise<string>;
|
|
155
|
+
removeTempDir(path: string): Promise<void>;
|
|
156
|
+
startContainer(
|
|
157
|
+
request: EngineLoginContainerRequest,
|
|
158
|
+
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
159
|
+
): Promise<EngineLoginProcess>;
|
|
160
|
+
replayCodexCallback(
|
|
161
|
+
containerName: string,
|
|
162
|
+
callbackUrl: string,
|
|
163
|
+
): Promise<void>;
|
|
164
|
+
reconcileStaleContainers(): Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* Read the script(1) PTY transcript from the login leaf (null when absent
|
|
167
|
+
* or unreadable). The transcript is written INSIDE the container, so it is
|
|
168
|
+
* the pure PTY byte stream — structurally free of the container runtime's
|
|
169
|
+
* own stdio chatter, which on the Apple CLI painted XPC noise over the
|
|
170
|
+
* very cells the TUI's diff renderer skipped (live 2026-08-21).
|
|
171
|
+
*/
|
|
172
|
+
readTranscript(tempDir: string): Promise<string | null>;
|
|
173
|
+
persistClaudeToken(token: string): Promise<void>;
|
|
174
|
+
persistCodexAuth(sourceFile: string): Promise<void>;
|
|
175
|
+
/** ADR-116: labeled extra-account captures (never the default slots).
|
|
176
|
+
* Sync on a host that seals the capture locally; a promise on the ADR-136
|
|
177
|
+
* sign-in box, whose sink awaits the credential store's write. */
|
|
178
|
+
addClaudeAccount(
|
|
179
|
+
label: string,
|
|
180
|
+
token: string,
|
|
181
|
+
scope: string,
|
|
182
|
+
): AccountAddOutcome | Promise<AccountAddOutcome>;
|
|
183
|
+
addCodexAccount(
|
|
184
|
+
label: string,
|
|
185
|
+
authJson: string,
|
|
186
|
+
scope: string,
|
|
187
|
+
): AccountAddOutcome | Promise<AccountAddOutcome>;
|
|
188
|
+
addGrokAccount(
|
|
189
|
+
label: string,
|
|
190
|
+
authJson: string,
|
|
191
|
+
scope: string,
|
|
192
|
+
): AccountAddOutcome | Promise<AccountAddOutcome>;
|
|
193
|
+
persistGrokAuth(sourceFile: string): Promise<void>;
|
|
194
|
+
/** ADR-154: the sign-in box's sink for a capture flow; absent elsewhere. */
|
|
195
|
+
captureEngineAccount?(
|
|
196
|
+
flow: CaptureFlowWire,
|
|
197
|
+
engine: EngineLoginKind,
|
|
198
|
+
material: EngineCaptureMaterial,
|
|
199
|
+
): Promise<CaptureOutcome>;
|
|
200
|
+
/** Optional engines ride the standard image only when configured; a fresh
|
|
201
|
+
* account may be the first configuration → rebuild (queue-deduped). */
|
|
202
|
+
ensureStandardImage(): Promise<void>;
|
|
203
|
+
refreshEngineAccountAgents(engine: "claude"): Promise<void>;
|
|
204
|
+
reinjectCodexRunningTasks(): Promise<void>;
|
|
205
|
+
ownerHome(): string;
|
|
206
|
+
procEnv: NodeJS.ProcessEnv;
|
|
207
|
+
fileExists(path: string): boolean;
|
|
208
|
+
randomHex(bytes: number): string;
|
|
209
|
+
timers: EngineLoginTimers;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface EngineLoginManagerOptions {
|
|
213
|
+
/** The running orchestrator owns this operation; main.ts supplies it. */
|
|
214
|
+
refreshEngineAccountAgents(engine: "claude"): Promise<void>;
|
|
215
|
+
/**
|
|
216
|
+
* Exact host directory used for short-lived bind inputs. main.ts supplies
|
|
217
|
+
* a private leaf under UAI_HOME; ambient Docker mounts it directly.
|
|
218
|
+
*/
|
|
219
|
+
tempRoot?: string;
|
|
220
|
+
/** Defer Docker inventory until the first start when no runtime is selected. */
|
|
221
|
+
reconcileOnCreate?: boolean;
|
|
222
|
+
/** ADR-136 sign-in box: only labeled, workspace-scoped sign-ins are
|
|
223
|
+
* admitted — the default slots are the host owner's and the box has none. */
|
|
224
|
+
workspaceOnly?: boolean;
|
|
225
|
+
seams?: Partial<EngineLoginSeams>;
|
|
226
|
+
/** Rebuild the standard image after a successful capture (optional engines). */
|
|
227
|
+
ensureStandardImage?: () => Promise<unknown>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
type OperationStage =
|
|
231
|
+
| "starting"
|
|
232
|
+
| "awaiting_input"
|
|
233
|
+
| "awaiting_callback"
|
|
234
|
+
| "awaiting_confirmation";
|
|
235
|
+
|
|
236
|
+
interface CodexCallbackTarget {
|
|
237
|
+
readonly origin: string;
|
|
238
|
+
readonly pathname: string;
|
|
239
|
+
readonly port: string;
|
|
240
|
+
readonly hostname: string;
|
|
241
|
+
readonly state: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface LoginOperation {
|
|
245
|
+
readonly opId: string;
|
|
246
|
+
readonly engine: EngineLoginKind;
|
|
247
|
+
readonly emit: EmitEngineLoginEvent;
|
|
248
|
+
active: boolean;
|
|
249
|
+
finishing: boolean;
|
|
250
|
+
stage: OperationStage;
|
|
251
|
+
timer: unknown | null;
|
|
252
|
+
process: EngineLoginProcess | null;
|
|
253
|
+
tempDir: string | null;
|
|
254
|
+
launchPromise: Promise<void> | null;
|
|
255
|
+
callbackPromise: Promise<boolean> | null;
|
|
256
|
+
finishPromise: Promise<void> | null;
|
|
257
|
+
cleanupPromise: Promise<void> | null;
|
|
258
|
+
settlementPromise: Promise<void> | null;
|
|
259
|
+
suppressTerminalEvent: boolean;
|
|
260
|
+
outputTail: string;
|
|
261
|
+
/** stdout-only tail — the credential boundary (stderr never joins it). */
|
|
262
|
+
stdoutTail: string;
|
|
263
|
+
inputUsed: boolean;
|
|
264
|
+
callbackUsed: boolean;
|
|
265
|
+
pendingInput: string | null;
|
|
266
|
+
callbackTarget: CodexCallbackTarget | null;
|
|
267
|
+
/** ADR-154: when set, the capture is written as this revision of a cloud
|
|
268
|
+
* credential and nothing else happens to it. */
|
|
269
|
+
captureFlow: CaptureFlowWire | null;
|
|
270
|
+
/** ADR-116: when set, the captured credential lands in a labeled extra
|
|
271
|
+
* account instead of the default slot. */
|
|
272
|
+
accountLabel: string | null;
|
|
273
|
+
/** ADR-131 D3: the scope a labeled capture is stored under. */
|
|
274
|
+
accountScope: string;
|
|
275
|
+
/** Diagnosis breadcrumb: has ANY output arrived after the one-time code
|
|
276
|
+
* was written? Distinguishes "stdin never delivered" from "CLI answered
|
|
277
|
+
* and we ignored it" without reading the PTY transcript. */
|
|
278
|
+
postInputOutputSeen: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const defaultTimers: EngineLoginTimers = {
|
|
282
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
283
|
+
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
function unrefTimer(timer: unknown): void {
|
|
287
|
+
if (
|
|
288
|
+
typeof timer === "object" &&
|
|
289
|
+
timer !== null &&
|
|
290
|
+
"unref" in timer &&
|
|
291
|
+
typeof (timer as { unref?: unknown }).unref === "function"
|
|
292
|
+
) {
|
|
293
|
+
(timer as { unref(): void }).unref();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function ownerHome(): string {
|
|
298
|
+
return process.env.UAI_OWNER_HOME?.trim() || homedir();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function envLocalPath(): string {
|
|
302
|
+
return join(env.uaiHome, ".env.local");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function persistClaudeTokenAtomic(
|
|
306
|
+
token: string,
|
|
307
|
+
options: {
|
|
308
|
+
envFile?: string;
|
|
309
|
+
procEnv?: NodeJS.ProcessEnv;
|
|
310
|
+
} = {},
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
if (extractClaudeOAuthToken(token) !== token) {
|
|
313
|
+
throw new Error("invalid Claude OAuth token");
|
|
314
|
+
}
|
|
315
|
+
const file = options.envFile ?? envLocalPath();
|
|
316
|
+
await serializeEnvFileMutation(file, () => {
|
|
317
|
+
writeEnvValuesAtomic(file, { CLAUDE_CODE_OAUTH_TOKEN: token });
|
|
318
|
+
});
|
|
319
|
+
// Agent availability and task env injection read the live process env.
|
|
320
|
+
(options.procEnv ?? process.env).CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function persistCodexAuthAtomic(
|
|
324
|
+
sourceFile: string,
|
|
325
|
+
options: { ownerHome?: string } = {},
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const body = await readBoundedRegularFile(sourceFile, MAX_CODEX_AUTH_BYTES);
|
|
328
|
+
let parsed: unknown;
|
|
329
|
+
try {
|
|
330
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
331
|
+
} catch {
|
|
332
|
+
throw new Error("Codex produced invalid auth data");
|
|
333
|
+
}
|
|
334
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
335
|
+
throw new Error("Codex produced invalid auth data");
|
|
336
|
+
}
|
|
337
|
+
await writeSensitiveFileAtomic(
|
|
338
|
+
join(options.ownerHome ?? ownerHome(), ".codex", "auth.json"),
|
|
339
|
+
body,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function persistGrokAuthAtomic(
|
|
344
|
+
sourceFile: string,
|
|
345
|
+
options: { ownerHome?: string } = {},
|
|
346
|
+
): Promise<void> {
|
|
347
|
+
const body = await readBoundedRegularFile(sourceFile, MAX_CODEX_AUTH_BYTES);
|
|
348
|
+
let parsed: unknown;
|
|
349
|
+
try {
|
|
350
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
351
|
+
} catch {
|
|
352
|
+
throw new Error("Grok produced invalid auth data");
|
|
353
|
+
}
|
|
354
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
355
|
+
throw new Error("Grok produced invalid auth data");
|
|
356
|
+
}
|
|
357
|
+
await writeSensitiveFileAtomic(
|
|
358
|
+
join(options.ownerHome ?? ownerHome(), ".grok", "auth.json"),
|
|
359
|
+
body,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function defaultSeams(options: EngineLoginManagerOptions): EngineLoginSeams {
|
|
364
|
+
const tempRoot = options.tempRoot;
|
|
365
|
+
return {
|
|
366
|
+
createTempDir: () => createEngineLoginTempDir(tempRoot),
|
|
367
|
+
removeTempDir: (path) => rm(path, { recursive: true, force: true }),
|
|
368
|
+
startContainer: startLoginContainer,
|
|
369
|
+
replayCodexCallback,
|
|
370
|
+
reconcileStaleContainers: () =>
|
|
371
|
+
reconcileStaleEngineLoginResources({ tempRoot }),
|
|
372
|
+
readTranscript: async (tempDir) => {
|
|
373
|
+
try {
|
|
374
|
+
const body = await readBoundedRegularFile(
|
|
375
|
+
join(tempDir, "typescript"),
|
|
376
|
+
MAX_TRANSCRIPT_BYTES,
|
|
377
|
+
);
|
|
378
|
+
return body.toString("utf8");
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
persistClaudeToken: (token) => persistClaudeTokenAtomic(token),
|
|
384
|
+
persistCodexAuth: (sourceFile) => persistCodexAuthAtomic(sourceFile),
|
|
385
|
+
addClaudeAccount: (label, token, scope) =>
|
|
386
|
+
addEngineAccount("claude", label, { token }, {}, scope),
|
|
387
|
+
addCodexAccount: (label, authJson, scope) =>
|
|
388
|
+
addCodexAccountFromAuthJson(label, authJson, {}, scope),
|
|
389
|
+
addGrokAccount: (label, authJson, scope) =>
|
|
390
|
+
addGrokAccountFromAuthJson(label, authJson, {}, scope),
|
|
391
|
+
persistGrokAuth: (sourceFile) => persistGrokAuthAtomic(sourceFile),
|
|
392
|
+
ensureStandardImage: async () => {
|
|
393
|
+
await options.ensureStandardImage?.();
|
|
394
|
+
},
|
|
395
|
+
refreshEngineAccountAgents: options.refreshEngineAccountAgents,
|
|
396
|
+
reinjectCodexRunningTasks: () => reinjectCodexRunningTasks(),
|
|
397
|
+
ownerHome,
|
|
398
|
+
procEnv: process.env,
|
|
399
|
+
fileExists: existsSync,
|
|
400
|
+
randomHex: (bytes) => randomBytes(bytes).toString("hex"),
|
|
401
|
+
timers: defaultTimers,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function withDefaults(options: EngineLoginManagerOptions): EngineLoginSeams {
|
|
406
|
+
const defaults = defaultSeams(options);
|
|
407
|
+
return {
|
|
408
|
+
...defaults,
|
|
409
|
+
...options.seams,
|
|
410
|
+
timers: { ...defaults.timers, ...options.seams?.timers },
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* One manager belongs to one authenticated socket generation. Disconnect must
|
|
416
|
+
* call `stopAll()` before dropping the socket emitter.
|
|
417
|
+
*/
|
|
418
|
+
export class EngineLoginManager {
|
|
419
|
+
private readonly seams: EngineLoginSeams;
|
|
420
|
+
private readonly workspaceOnly: boolean;
|
|
421
|
+
private readonly operations = new Map<string, LoginOperation>();
|
|
422
|
+
/**
|
|
423
|
+
* The DEFAULT-SLOT lock: one unlabeled capture per engine at a time, since
|
|
424
|
+
* two of them would race on the host's own `~/.codex`-style slot. Labeled
|
|
425
|
+
* (workspace) captures land in isolated accounts and never take it — on
|
|
426
|
+
* the ADR-136 sign-in box every workspace's sign-in runs alongside every
|
|
427
|
+
* other's; a user is never told to wait for someone else's login.
|
|
428
|
+
*/
|
|
429
|
+
private readonly defaultSlotOperations = new Map<EngineLoginKind, LoginOperation>();
|
|
430
|
+
private reconciliation: Promise<boolean>;
|
|
431
|
+
|
|
432
|
+
constructor(options: EngineLoginManagerOptions) {
|
|
433
|
+
this.seams = withDefaults(options);
|
|
434
|
+
this.workspaceOnly = options.workspaceOnly === true;
|
|
435
|
+
this.reconciliation =
|
|
436
|
+
options.reconcileOnCreate === false
|
|
437
|
+
? Promise.resolve(false)
|
|
438
|
+
: this.runReconciliation();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Secret-free current capability state. v1 deliberately claims no expiry. */
|
|
442
|
+
capabilities(): EngineLoginCapability[] {
|
|
443
|
+
const claudeConfigured = Boolean(
|
|
444
|
+
this.seams.procEnv.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
445
|
+
this.seams.procEnv.ANTHROPIC_API_KEY ||
|
|
446
|
+
this.seams.procEnv.ANTHROPIC_AUTH_TOKEN,
|
|
447
|
+
);
|
|
448
|
+
const codexConfigured = this.seams.fileExists(
|
|
449
|
+
join(this.seams.ownerHome(), ".codex", "auth.json"),
|
|
450
|
+
);
|
|
451
|
+
const grokConfigured =
|
|
452
|
+
this.seams.fileExists(join(this.seams.ownerHome(), ".grok", "auth.json")) ||
|
|
453
|
+
Boolean(this.seams.procEnv.XAI_API_KEY);
|
|
454
|
+
return [
|
|
455
|
+
{
|
|
456
|
+
engine: "claude",
|
|
457
|
+
status: claudeConfigured ? "configured" : "missing",
|
|
458
|
+
loginSupported: true,
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
engine: "codex",
|
|
462
|
+
status: codexConfigured ? "configured" : "missing",
|
|
463
|
+
loginSupported: true,
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
engine: "grok",
|
|
467
|
+
status: grokConfigured ? "configured" : "missing",
|
|
468
|
+
loginSupported: true,
|
|
469
|
+
},
|
|
470
|
+
];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Reserve and launch without waiting for a human-paced OAuth flow. */
|
|
474
|
+
start(
|
|
475
|
+
opId: string,
|
|
476
|
+
engine: EngineLoginKind,
|
|
477
|
+
emit: EmitEngineLoginEvent,
|
|
478
|
+
label?: string,
|
|
479
|
+
scope?: string,
|
|
480
|
+
flow?: CaptureFlowWire,
|
|
481
|
+
): boolean {
|
|
482
|
+
if (!isHostOpId(opId)) return false;
|
|
483
|
+
// ADR-154: a capture flow names its own destination; it runs only on the
|
|
484
|
+
// sign-in box, which alone writes cloud credentials.
|
|
485
|
+
if (
|
|
486
|
+
flow !== undefined &&
|
|
487
|
+
(!this.workspaceOnly || this.seams.captureEngineAccount === undefined)
|
|
488
|
+
) {
|
|
489
|
+
safeEmit(
|
|
490
|
+
emit,
|
|
491
|
+
failedEvent(opId, engine, "start_failed", "Only the Moondeck Cloud sign-in box writes cloud credentials."),
|
|
492
|
+
);
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
if (scope !== undefined && !isEngineAccountScope(scope)) {
|
|
496
|
+
safeEmit(
|
|
497
|
+
emit,
|
|
498
|
+
failedEvent(opId, engine, "start_failed", "The account scope is invalid."),
|
|
499
|
+
);
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
// ADR-136 sign-in box: nothing here may write a default slot, and a
|
|
503
|
+
// host-scope account would belong to a runner that owns no credential.
|
|
504
|
+
if (
|
|
505
|
+
this.workspaceOnly &&
|
|
506
|
+
flow === undefined &&
|
|
507
|
+
(scope === undefined || scope === HOST_ACCOUNT_SCOPE || label === undefined)
|
|
508
|
+
) {
|
|
509
|
+
safeEmit(
|
|
510
|
+
emit,
|
|
511
|
+
failedEvent(
|
|
512
|
+
opId,
|
|
513
|
+
engine,
|
|
514
|
+
"start_failed",
|
|
515
|
+
"The Moondeck Cloud sign-in box signs in workspace accounts only.",
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
// A workspace's sign-in is always a labeled account: the default slot is
|
|
521
|
+
// the host owner's and must never be written on a tenant's behalf.
|
|
522
|
+
if (scope !== undefined && scope !== HOST_ACCOUNT_SCOPE && label === undefined) {
|
|
523
|
+
safeEmit(
|
|
524
|
+
emit,
|
|
525
|
+
failedEvent(opId, engine, "start_failed", "A workspace account needs a label."),
|
|
526
|
+
);
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
if (label !== undefined && !isEngineAccountLabel(label)) {
|
|
530
|
+
safeEmit(
|
|
531
|
+
emit,
|
|
532
|
+
failedEvent(opId, engine, "start_failed", "The account label is invalid."),
|
|
533
|
+
);
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
if (this.operations.has(opId)) {
|
|
537
|
+
safeEmit(emit, failedEvent(opId, engine, "unavailable", "Login operation already exists."));
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
if (label === undefined && this.defaultSlotOperations.has(engine)) {
|
|
541
|
+
safeEmit(
|
|
542
|
+
emit,
|
|
543
|
+
failedEvent(
|
|
544
|
+
opId,
|
|
545
|
+
engine,
|
|
546
|
+
"unavailable",
|
|
547
|
+
`A ${engineLabel(engine)} login is already in progress.`,
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
// Every login is a container; bound the fan-out without serialising it.
|
|
553
|
+
if (this.operations.size >= MAX_CONCURRENT_ENGINE_LOGINS) {
|
|
554
|
+
safeEmit(
|
|
555
|
+
emit,
|
|
556
|
+
failedEvent(
|
|
557
|
+
opId,
|
|
558
|
+
engine,
|
|
559
|
+
"unavailable",
|
|
560
|
+
"Too many sign-ins are running on this station right now; try again in a minute.",
|
|
561
|
+
),
|
|
562
|
+
);
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const operation: LoginOperation = {
|
|
567
|
+
opId,
|
|
568
|
+
engine,
|
|
569
|
+
emit,
|
|
570
|
+
active: true,
|
|
571
|
+
finishing: false,
|
|
572
|
+
stage: "starting",
|
|
573
|
+
timer: null,
|
|
574
|
+
process: null,
|
|
575
|
+
tempDir: null,
|
|
576
|
+
launchPromise: null,
|
|
577
|
+
callbackPromise: null,
|
|
578
|
+
finishPromise: null,
|
|
579
|
+
cleanupPromise: null,
|
|
580
|
+
settlementPromise: null,
|
|
581
|
+
suppressTerminalEvent: false,
|
|
582
|
+
outputTail: "",
|
|
583
|
+
stdoutTail: "",
|
|
584
|
+
inputUsed: false,
|
|
585
|
+
callbackUsed: false,
|
|
586
|
+
pendingInput: null,
|
|
587
|
+
callbackTarget: null,
|
|
588
|
+
captureFlow: flow ?? null,
|
|
589
|
+
accountLabel: label ?? null,
|
|
590
|
+
accountScope: scope ?? HOST_ACCOUNT_SCOPE,
|
|
591
|
+
postInputOutputSeen: false,
|
|
592
|
+
};
|
|
593
|
+
this.operations.set(opId, operation);
|
|
594
|
+
if (label === undefined && flow === undefined) {
|
|
595
|
+
this.defaultSlotOperations.set(engine, operation);
|
|
596
|
+
}
|
|
597
|
+
operation.timer = this.seams.timers.setTimeout(() => {
|
|
598
|
+
void this.timeout(operation);
|
|
599
|
+
}, ENGINE_LOGIN_TIMEOUT_MS);
|
|
600
|
+
unrefTimer(operation.timer);
|
|
601
|
+
|
|
602
|
+
safeEmit(emit, {
|
|
603
|
+
kind: "engine.login.event",
|
|
604
|
+
opId,
|
|
605
|
+
engine,
|
|
606
|
+
phase: "starting",
|
|
607
|
+
message: `Starting ${engineLabel(engine)} login on this host.`,
|
|
608
|
+
});
|
|
609
|
+
operation.launchPromise = this.launch(operation);
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Consume Claude's one-time code exactly once. */
|
|
614
|
+
input(opId: string, text: string): boolean {
|
|
615
|
+
const operation = this.operations.get(opId);
|
|
616
|
+
// Every rejection names itself in the host log: a silently dropped code
|
|
617
|
+
// reads as "Waiting for the host…" forever on the cloud pane (live
|
|
618
|
+
// 2026-08-20, the guard chain gave the operator nothing to act on).
|
|
619
|
+
// Never log `text` — it is one-time OAuth material.
|
|
620
|
+
if (!operation) {
|
|
621
|
+
console.warn(`[engine-login] input dropped: unknown opId ${opId}`);
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
const rejection = !this.isCurrent(operation)
|
|
625
|
+
? "operation belongs to a previous bridge generation"
|
|
626
|
+
: operation.engine !== "claude"
|
|
627
|
+
? `engine is ${operation.engine}, not claude`
|
|
628
|
+
: operation.stage !== "awaiting_input"
|
|
629
|
+
? `stage is ${operation.stage}, not awaiting_input`
|
|
630
|
+
: operation.inputUsed
|
|
631
|
+
? "a code was already submitted for this operation"
|
|
632
|
+
: operation.finishing
|
|
633
|
+
? "the operation is already finishing"
|
|
634
|
+
: null;
|
|
635
|
+
if (rejection !== null) {
|
|
636
|
+
console.warn(
|
|
637
|
+
`[engine-login] input dropped for ${opId}: ${rejection}`,
|
|
638
|
+
);
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
if (!isValidOneTimeInput(text)) {
|
|
642
|
+
console.warn(
|
|
643
|
+
`[engine-login] input rejected for ${opId}: failed one-time-input validation (length ${text.length})`,
|
|
644
|
+
);
|
|
645
|
+
void this.fail(
|
|
646
|
+
operation,
|
|
647
|
+
"invalid_input",
|
|
648
|
+
"The one-time authorization code was rejected.",
|
|
649
|
+
);
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
console.log(
|
|
653
|
+
`[engine-login] input accepted for ${opId} (${text.length} chars)`,
|
|
654
|
+
);
|
|
655
|
+
operation.inputUsed = true;
|
|
656
|
+
operation.outputTail = "";
|
|
657
|
+
operation.stdoutTail = "";
|
|
658
|
+
if (!operation.process) {
|
|
659
|
+
console.log(
|
|
660
|
+
`[engine-login] input buffered for ${opId} (login process still starting)`,
|
|
661
|
+
);
|
|
662
|
+
operation.pendingInput = text;
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
return this.deliverOneTimeCode(operation, text);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The submit is a SEPARATE, delayed carriage return — both halves of that
|
|
670
|
+
* are load-bearing, reproduced live 2026-08-20/21:
|
|
671
|
+
* - `\r`, not `\n`: the Ink raw-mode prompt only submits on carriage
|
|
672
|
+
* return (a `\n` types the code and sits forever).
|
|
673
|
+
* - a separate delayed write, not one chunk: a real 92-char code + `\r`
|
|
674
|
+
* in one write is treated as PASTE data and the embedded `\r` is
|
|
675
|
+
* swallowed — stars appear, nothing submits (short test codes slipped
|
|
676
|
+
* under the paste threshold, which is why every isolated repro passed
|
|
677
|
+
* while production hung).
|
|
678
|
+
*/
|
|
679
|
+
private deliverOneTimeCode(operation: LoginOperation, text: string): boolean {
|
|
680
|
+
const process = operation.process;
|
|
681
|
+
if (!process) return false;
|
|
682
|
+
try {
|
|
683
|
+
process.writeInput(text);
|
|
684
|
+
console.log(
|
|
685
|
+
`[engine-login] input written to the login process for ${operation.opId}`,
|
|
686
|
+
);
|
|
687
|
+
} catch {
|
|
688
|
+
console.warn(
|
|
689
|
+
`[engine-login] input write FAILED for ${operation.opId}: the login process rejected stdin`,
|
|
690
|
+
);
|
|
691
|
+
void this.fail(
|
|
692
|
+
operation,
|
|
693
|
+
"invalid_input",
|
|
694
|
+
"The login process could not accept the one-time code.",
|
|
695
|
+
);
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
const submitTimer = setTimeout(() => {
|
|
699
|
+
if (
|
|
700
|
+
!this.isCurrent(operation) ||
|
|
701
|
+
operation.finishing ||
|
|
702
|
+
operation.process !== process
|
|
703
|
+
) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
process.writeInput("\r");
|
|
708
|
+
console.log(
|
|
709
|
+
`[engine-login] submit keystroke dispatched for ${operation.opId}`,
|
|
710
|
+
);
|
|
711
|
+
} catch {
|
|
712
|
+
void this.fail(
|
|
713
|
+
operation,
|
|
714
|
+
"invalid_input",
|
|
715
|
+
"The login process could not accept the submit keystroke.",
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
}, SUBMIT_KEYSTROKE_DELAY_MS);
|
|
719
|
+
unrefTimer(submitTimer);
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Validate and replay a Codex callback only to this operation's listener. */
|
|
724
|
+
async callback(opId: string, value: string): Promise<boolean> {
|
|
725
|
+
const operation = this.operations.get(opId);
|
|
726
|
+
// Same named-rejection contract as input() (0.9.51): every silent exit
|
|
727
|
+
// here left an operator staring at "Waiting for the host…" with zero
|
|
728
|
+
// evidence on either side (live 2026-08-21 — the callback path was the
|
|
729
|
+
// last breadcrumb-free room in the login pipeline).
|
|
730
|
+
const refuse = (reason: string): false => {
|
|
731
|
+
console.log(
|
|
732
|
+
`[engine-login] callback refused for ${opId}: ${reason}`,
|
|
733
|
+
);
|
|
734
|
+
return false;
|
|
735
|
+
};
|
|
736
|
+
if (!operation) return refuse("unknown opId");
|
|
737
|
+
if (!this.isCurrent(operation)) return refuse("superseded operation");
|
|
738
|
+
if (operation.engine !== "codex") return refuse("wrong engine");
|
|
739
|
+
if (operation.stage !== "awaiting_callback") {
|
|
740
|
+
return refuse(`wrong stage (${operation.stage})`);
|
|
741
|
+
}
|
|
742
|
+
if (operation.callbackUsed) return refuse("callback already used");
|
|
743
|
+
if (operation.finishing) return refuse("operation finishing");
|
|
744
|
+
if (!operation.process) return refuse("no login process");
|
|
745
|
+
if (!operation.callbackTarget) return refuse("no callback target");
|
|
746
|
+
console.log(
|
|
747
|
+
`[engine-login] callback accepted for ${opId} (${value.length} chars); validating`,
|
|
748
|
+
);
|
|
749
|
+
const query = validateCodexCallback(value, operation.callbackTarget);
|
|
750
|
+
if (!query) {
|
|
751
|
+
console.warn(
|
|
752
|
+
`[engine-login] callback failed validation for ${opId} (state/shape mismatch with this attempt's authorize URL)`,
|
|
753
|
+
);
|
|
754
|
+
await this.fail(
|
|
755
|
+
operation,
|
|
756
|
+
"invalid_callback",
|
|
757
|
+
"The localhost callback was rejected.",
|
|
758
|
+
);
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
console.log(
|
|
762
|
+
`[engine-login] callback validated for ${opId}; replaying into the login container`,
|
|
763
|
+
);
|
|
764
|
+
operation.callbackUsed = true;
|
|
765
|
+
const callbackUrl = callbackReplayUrl(operation.callbackTarget, query);
|
|
766
|
+
const replay = (async () => {
|
|
767
|
+
try {
|
|
768
|
+
await this.seams.replayCodexCallback(
|
|
769
|
+
operation.process!.containerName,
|
|
770
|
+
callbackUrl,
|
|
771
|
+
);
|
|
772
|
+
console.log(
|
|
773
|
+
`[engine-login] callback replay delivered for ${operation.opId}; waiting for the CLI to finish`,
|
|
774
|
+
);
|
|
775
|
+
} catch {
|
|
776
|
+
if (this.isCurrent(operation)) {
|
|
777
|
+
await this.fail(
|
|
778
|
+
operation,
|
|
779
|
+
"invalid_callback",
|
|
780
|
+
"The localhost callback could not be delivered.",
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
return this.isCurrent(operation) || operation.finishing;
|
|
786
|
+
})();
|
|
787
|
+
operation.callbackPromise = replay;
|
|
788
|
+
try {
|
|
789
|
+
return await replay;
|
|
790
|
+
} finally {
|
|
791
|
+
if (operation.callbackPromise === replay) {
|
|
792
|
+
operation.callbackPromise = null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Explicit operator cancellation emits one terminal event. */
|
|
798
|
+
async stop(opId: string): Promise<boolean> {
|
|
799
|
+
const operation = this.operations.get(opId);
|
|
800
|
+
if (!operation || !this.isCurrent(operation)) return false;
|
|
801
|
+
if (operation.finishPromise) {
|
|
802
|
+
// Credential commit is the irreversible boundary. Once it starts, join
|
|
803
|
+
// the truthful success/failure outcome instead of claiming cancellation.
|
|
804
|
+
await this.ensureSettlement(operation);
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
this.detach(operation);
|
|
808
|
+
safeEmit(operation.emit, {
|
|
809
|
+
kind: "engine.login.event",
|
|
810
|
+
opId: operation.opId,
|
|
811
|
+
engine: operation.engine,
|
|
812
|
+
phase: "cancelled",
|
|
813
|
+
message: "Login cancelled.",
|
|
814
|
+
});
|
|
815
|
+
await this.ensureSettlement(operation);
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Generation teardown. Detach every operation and secret before awaiting
|
|
821
|
+
* Docker/fs cleanup, so no late child callback can touch a dead socket.
|
|
822
|
+
*/
|
|
823
|
+
async stopAll(): Promise<void> {
|
|
824
|
+
const operations = [...this.operations.values()];
|
|
825
|
+
for (const operation of operations) {
|
|
826
|
+
operation.suppressTerminalEvent = true;
|
|
827
|
+
if (!operation.finishPromise) this.detach(operation);
|
|
828
|
+
else this.ensureSettlement(operation);
|
|
829
|
+
}
|
|
830
|
+
await Promise.all(
|
|
831
|
+
operations.map((operation) => this.ensureSettlement(operation)),
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
activeCount(): number {
|
|
836
|
+
let active = 0;
|
|
837
|
+
for (const operation of this.operations.values()) {
|
|
838
|
+
if (operation.active) active += 1;
|
|
839
|
+
}
|
|
840
|
+
return active;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private async launch(operation: LoginOperation): Promise<void> {
|
|
844
|
+
let tempDir: string | null = null;
|
|
845
|
+
let processHandle: EngineLoginProcess | null = null;
|
|
846
|
+
try {
|
|
847
|
+
if (!(await this.reconciliation)) {
|
|
848
|
+
// A daemon blip during the eager startup sweep gets one new bounded
|
|
849
|
+
// reconciliation on the next attempted login; never add an orphan on
|
|
850
|
+
// top of state whose ownership could not be established.
|
|
851
|
+
this.reconciliation = this.runReconciliation();
|
|
852
|
+
if (!(await this.reconciliation)) {
|
|
853
|
+
throw new Error("stale login containers could not be reconciled");
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (!this.isCurrent(operation)) return;
|
|
857
|
+
tempDir = await this.seams.createTempDir();
|
|
858
|
+
if (!this.isCurrent(operation)) {
|
|
859
|
+
await this.removeDetachedTemp(tempDir);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
operation.tempDir = tempDir;
|
|
863
|
+
const containerName = `uai-login-${operation.engine}-${this.seams.randomHex(8)}`;
|
|
864
|
+
processHandle = await this.seams.startContainer(
|
|
865
|
+
{ engine: operation.engine, containerName, tempDir },
|
|
866
|
+
(chunk, stream) => this.onOutput(operation, chunk, stream ?? "stdout"),
|
|
867
|
+
);
|
|
868
|
+
if (!this.isCurrent(operation)) {
|
|
869
|
+
await this.stopDetachedProcess(processHandle);
|
|
870
|
+
if (operation.tempDir === tempDir) operation.tempDir = null;
|
|
871
|
+
await this.removeDetachedTemp(tempDir);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
operation.process = processHandle;
|
|
875
|
+
if (operation.pendingInput !== null) {
|
|
876
|
+
const pending = operation.pendingInput;
|
|
877
|
+
operation.pendingInput = null;
|
|
878
|
+
if (!this.deliverOneTimeCode(operation, pending)) return;
|
|
879
|
+
}
|
|
880
|
+
void processHandle.completion.then(
|
|
881
|
+
(result) => this.onProcessComplete(operation, result),
|
|
882
|
+
() =>
|
|
883
|
+
this.fail(
|
|
884
|
+
operation,
|
|
885
|
+
"start_failed",
|
|
886
|
+
"The login container stopped unexpectedly.",
|
|
887
|
+
),
|
|
888
|
+
);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
// The operator sees a one-line failure; the journal must say WHY (the
|
|
891
|
+
// 2026-09-09 sweep refusal was silent for six hours).
|
|
892
|
+
console.warn(
|
|
893
|
+
`[engine-login] ${operation.engine} login could not start: ${
|
|
894
|
+
error instanceof Error ? error.message : String(error)
|
|
895
|
+
}`,
|
|
896
|
+
);
|
|
897
|
+
if (processHandle && !operation.process) {
|
|
898
|
+
await this.stopDetachedProcess(processHandle);
|
|
899
|
+
}
|
|
900
|
+
if (tempDir && !operation.tempDir) await this.removeDetachedTemp(tempDir);
|
|
901
|
+
if (this.isCurrent(operation)) {
|
|
902
|
+
await this.fail(
|
|
903
|
+
operation,
|
|
904
|
+
"start_failed",
|
|
905
|
+
"The login container could not be started.",
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private onOutput(
|
|
912
|
+
operation: LoginOperation,
|
|
913
|
+
chunk: string,
|
|
914
|
+
stream: "stdout" | "stderr" = "stdout",
|
|
915
|
+
): void {
|
|
916
|
+
if (!this.isCurrent(operation) || operation.finishing) return;
|
|
917
|
+
operation.outputTail = appendUtf8Tail(
|
|
918
|
+
operation.outputTail,
|
|
919
|
+
chunk,
|
|
920
|
+
MAX_OUTPUT_TAIL_BYTES,
|
|
921
|
+
);
|
|
922
|
+
if (stream === "stdout") {
|
|
923
|
+
// The CREDENTIAL boundary reads stdout only: stderr chunks (the Apple
|
|
924
|
+
// runtime's XPC chatter) interleaving into a merged tail once produced
|
|
925
|
+
// a corrupted token-shaped capture (live 2026-08-21). The merged tail
|
|
926
|
+
// above stays for URL detection and human-facing evidence.
|
|
927
|
+
operation.stdoutTail = appendUtf8Tail(
|
|
928
|
+
operation.stdoutTail,
|
|
929
|
+
chunk,
|
|
930
|
+
MAX_OUTPUT_TAIL_BYTES,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (operation.engine === "claude") {
|
|
935
|
+
if (operation.inputUsed && !operation.postInputOutputSeen) {
|
|
936
|
+
operation.postInputOutputSeen = true;
|
|
937
|
+
console.log(
|
|
938
|
+
`[engine-login] output resumed after input for ${operation.opId} — the code reached the login TUI`,
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
// The TUI answers a bad/expired/mismatched code with an error AND an
|
|
942
|
+
// open prompt — no exit, no token. Detect it and fail visibly instead
|
|
943
|
+
// of the silent wait-for-timeout that hid the 2026-08-20 hangs.
|
|
944
|
+
if (operation.inputUsed && !operation.finishing) {
|
|
945
|
+
const cliError = detectClaudeCliError(operation.outputTail);
|
|
946
|
+
if (cliError) {
|
|
947
|
+
console.warn(
|
|
948
|
+
`[engine-login] login CLI rejected the code for ${operation.opId}: ${cliError}`,
|
|
949
|
+
);
|
|
950
|
+
void this.fail(
|
|
951
|
+
operation,
|
|
952
|
+
"invalid_input",
|
|
953
|
+
`The sign-in CLI rejected the code: ${cliError} Make sure the code comes from THIS attempt's authorization page, then retry.`,
|
|
954
|
+
);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
// Before the one-time input is consumed, token-shaped output could only
|
|
959
|
+
// be an authorization URL/detail or hostile terminal content. This also
|
|
960
|
+
// prevents an input echo from becoming the credential boundary.
|
|
961
|
+
// The scan reads the RECONSTRUCTED screen, not the raw stream: the
|
|
962
|
+
// TUI's diff renderer skips already-correct cells, so the raw bytes
|
|
963
|
+
// never carry the contiguous token (see renderTerminalScreenText).
|
|
964
|
+
const token = operation.inputUsed
|
|
965
|
+
? extractClaudeOAuthToken(renderTerminalScreenText(operation.stdoutTail))
|
|
966
|
+
: null;
|
|
967
|
+
if (token) {
|
|
968
|
+
console.log(
|
|
969
|
+
`[engine-login] token detected in CLI output for ${operation.opId}; persisting`,
|
|
970
|
+
);
|
|
971
|
+
operation.outputTail = "";
|
|
972
|
+
operation.stdoutTail = "";
|
|
973
|
+
this.beginClaudeFinish(operation, token);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (operation.stage === "starting") {
|
|
977
|
+
const authorizeUrl = findSafeHttpsUrl(operation.outputTail);
|
|
978
|
+
if (authorizeUrl) {
|
|
979
|
+
console.log(
|
|
980
|
+
`[engine-login] authorize URL found for ${operation.opId}; awaiting the one-time code`,
|
|
981
|
+
);
|
|
982
|
+
operation.stage = "awaiting_input";
|
|
983
|
+
safeEmit(operation.emit, authorizeEvent(operation, authorizeUrl));
|
|
984
|
+
safeEmit(operation.emit, {
|
|
985
|
+
kind: "engine.login.event",
|
|
986
|
+
opId: operation.opId,
|
|
987
|
+
engine: operation.engine,
|
|
988
|
+
phase: "awaiting_input",
|
|
989
|
+
message: "Paste the one-time authorization code.",
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (operation.stage === "starting") {
|
|
997
|
+
const device =
|
|
998
|
+
operation.engine === "grok"
|
|
999
|
+
? findGrokDeviceAuthorization(operation.outputTail)
|
|
1000
|
+
: findCodexDeviceAuthorization(operation.outputTail);
|
|
1001
|
+
if (device) {
|
|
1002
|
+
console.log(
|
|
1003
|
+
`[engine-login] ${operation.engine} device authorization found for ${operation.opId}; awaiting operator confirmation`,
|
|
1004
|
+
);
|
|
1005
|
+
operation.stage = "awaiting_confirmation";
|
|
1006
|
+
safeEmit(
|
|
1007
|
+
operation.emit,
|
|
1008
|
+
authorizeEvent(operation, device.verificationUrl),
|
|
1009
|
+
);
|
|
1010
|
+
safeEmit(operation.emit, {
|
|
1011
|
+
kind: "engine.login.event",
|
|
1012
|
+
opId: operation.opId,
|
|
1013
|
+
engine: operation.engine,
|
|
1014
|
+
phase: "awaiting_confirmation",
|
|
1015
|
+
code: device.userCode,
|
|
1016
|
+
message: `Enter code ${device.userCode} on the verification page. Nothing to paste here — this completes on its own.`,
|
|
1017
|
+
});
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (operation.engine !== "codex") return;
|
|
1021
|
+
const authorization = findCodexAuthorization(operation.outputTail);
|
|
1022
|
+
if (authorization) {
|
|
1023
|
+
// Symmetric with the claude branch: this breadcrumb's ABSENCE was
|
|
1024
|
+
// unreadable during the 2026-08-21 hunt — silence meant either "no
|
|
1025
|
+
// output arrived" or "detection fired, codex just never says so".
|
|
1026
|
+
console.log(
|
|
1027
|
+
`[engine-login] codex authorize URL found for ${operation.opId}; awaiting the callback URL`,
|
|
1028
|
+
);
|
|
1029
|
+
operation.callbackTarget = authorization.target;
|
|
1030
|
+
operation.stage = "awaiting_callback";
|
|
1031
|
+
safeEmit(
|
|
1032
|
+
operation.emit,
|
|
1033
|
+
authorizeEvent(operation, authorization.authorizeUrl),
|
|
1034
|
+
);
|
|
1035
|
+
safeEmit(operation.emit, {
|
|
1036
|
+
kind: "engine.login.event",
|
|
1037
|
+
opId: operation.opId,
|
|
1038
|
+
engine: operation.engine,
|
|
1039
|
+
phase: "awaiting_callback",
|
|
1040
|
+
message: "Paste the failed localhost callback URL.",
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
private async onProcessComplete(
|
|
1047
|
+
operation: LoginOperation,
|
|
1048
|
+
result: EngineLoginProcessResult,
|
|
1049
|
+
): Promise<void> {
|
|
1050
|
+
if (!this.isCurrent(operation) || operation.finishing) return;
|
|
1051
|
+
if (
|
|
1052
|
+
(operation.engine === "codex" || operation.engine === "grok") &&
|
|
1053
|
+
result.code === 0
|
|
1054
|
+
) {
|
|
1055
|
+
this.beginCodexFinish(operation);
|
|
1056
|
+
if (operation.finishPromise) await operation.finishPromise;
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (
|
|
1060
|
+
operation.engine === "claude" &&
|
|
1061
|
+
result.code === 0 &&
|
|
1062
|
+
operation.inputUsed &&
|
|
1063
|
+
operation.tempDir
|
|
1064
|
+
) {
|
|
1065
|
+
// A clean exit after the code went in means the CLI almost certainly
|
|
1066
|
+
// minted and printed the token — a stream capture that saw nothing is
|
|
1067
|
+
// the capture's failure, not the login's. The relayed stream can be
|
|
1068
|
+
// polluted by the container runtime's own chatter (the Apple CLI's XPC
|
|
1069
|
+
// noise painted characters over the cells the TUI's diff renderer
|
|
1070
|
+
// skipped, corrupting the reconstructed screen — live 2026-08-21 on
|
|
1071
|
+
// the Air, straight after the Linux fix). The PTY transcript is
|
|
1072
|
+
// written inside the container and cannot contain that noise, so it
|
|
1073
|
+
// gets the authoritative re-read before this operation is failed.
|
|
1074
|
+
const transcript = await this.seams.readTranscript(operation.tempDir);
|
|
1075
|
+
if (!this.isCurrent(operation) || operation.finishing) return;
|
|
1076
|
+
const token = transcript
|
|
1077
|
+
? extractClaudeOAuthToken(renderTerminalScreenText(transcript))
|
|
1078
|
+
: null;
|
|
1079
|
+
if (token) {
|
|
1080
|
+
console.log(
|
|
1081
|
+
`[engine-login] token recovered from the PTY transcript for ${operation.opId}; persisting`,
|
|
1082
|
+
);
|
|
1083
|
+
operation.outputTail = "";
|
|
1084
|
+
operation.stdoutTail = "";
|
|
1085
|
+
this.beginClaudeFinish(operation, token);
|
|
1086
|
+
if (operation.finishPromise) await operation.finishPromise;
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const evidence = lastSignificantOutputLine(operation.outputTail);
|
|
1091
|
+
console.warn(
|
|
1092
|
+
`[engine-login] login process for ${operation.opId} ended (exit ${result.code ?? "spawn-failed"}) before completion` +
|
|
1093
|
+
(evidence ? `; last output: ${evidence}` : ""),
|
|
1094
|
+
);
|
|
1095
|
+
await this.fail(
|
|
1096
|
+
operation,
|
|
1097
|
+
result.spawnFailed ? "unavailable" : "start_failed",
|
|
1098
|
+
result.spawnFailed
|
|
1099
|
+
? "The login runtime is unavailable."
|
|
1100
|
+
: `${engineLabel(operation.engine)} login did not complete.${evidence ? ` Last output: ${evidence}` : ""}`,
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
private beginClaudeFinish(
|
|
1105
|
+
operation: LoginOperation,
|
|
1106
|
+
token: string,
|
|
1107
|
+
): void {
|
|
1108
|
+
if (!this.isCurrent(operation) || operation.finishing) return;
|
|
1109
|
+
operation.finishing = true;
|
|
1110
|
+
this.clearOperationTimer(operation);
|
|
1111
|
+
operation.finishPromise = this.commitClaude(operation, token);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private async commitClaude(
|
|
1115
|
+
operation: LoginOperation,
|
|
1116
|
+
token: string,
|
|
1117
|
+
): Promise<void> {
|
|
1118
|
+
let terminal: EngineLoginEventFrame;
|
|
1119
|
+
if (operation.captureFlow !== null) {
|
|
1120
|
+
await this.finishCapture(operation, {
|
|
1121
|
+
authKind: "env",
|
|
1122
|
+
env: { CLAUDE_CODE_OAUTH_TOKEN: token },
|
|
1123
|
+
});
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (operation.accountLabel !== null) {
|
|
1127
|
+
// ADR-116: labeled capture — an extra account, not the default slot.
|
|
1128
|
+
// Extras are picked up at the next agent spawn / rotation, so no
|
|
1129
|
+
// running-agent refresh is needed (mirrors the local accounts API).
|
|
1130
|
+
const added = await this.seams.addClaudeAccount(
|
|
1131
|
+
operation.accountLabel,
|
|
1132
|
+
token,
|
|
1133
|
+
operation.accountScope,
|
|
1134
|
+
);
|
|
1135
|
+
terminal = added.ok
|
|
1136
|
+
? {
|
|
1137
|
+
kind: "engine.login.event",
|
|
1138
|
+
opId: operation.opId,
|
|
1139
|
+
engine: operation.engine,
|
|
1140
|
+
phase: "succeeded",
|
|
1141
|
+
message: added.message,
|
|
1142
|
+
...(added.id === undefined ? {} : { accountId: added.id }),
|
|
1143
|
+
}
|
|
1144
|
+
: failedEvent(
|
|
1145
|
+
operation.opId,
|
|
1146
|
+
operation.engine,
|
|
1147
|
+
"persist_failed",
|
|
1148
|
+
added.message,
|
|
1149
|
+
);
|
|
1150
|
+
await this.finishCommit(operation, terminal);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
try {
|
|
1154
|
+
await this.seams.persistClaudeToken(token);
|
|
1155
|
+
} catch {
|
|
1156
|
+
terminal = failedEvent(
|
|
1157
|
+
operation.opId,
|
|
1158
|
+
operation.engine,
|
|
1159
|
+
"persist_failed",
|
|
1160
|
+
"Claude authorized, but its credential could not be saved.",
|
|
1161
|
+
);
|
|
1162
|
+
await this.finishCommit(operation, terminal);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
// The credential is already durable. Refresh existing agents in the
|
|
1166
|
+
// background: it may traverse many task containers, and must not retain
|
|
1167
|
+
// this ephemeral login container/temp dir or block generation teardown.
|
|
1168
|
+
runBestEffort(() => this.seams.refreshEngineAccountAgents("claude"));
|
|
1169
|
+
terminal = {
|
|
1170
|
+
kind: "engine.login.event",
|
|
1171
|
+
opId: operation.opId,
|
|
1172
|
+
engine: operation.engine,
|
|
1173
|
+
phase: "succeeded",
|
|
1174
|
+
message: "Claude connected.",
|
|
1175
|
+
};
|
|
1176
|
+
await this.finishCommit(operation, terminal);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** ADR-154: hand a capture to the box's sink and report its revision. */
|
|
1180
|
+
private async finishCapture(
|
|
1181
|
+
operation: LoginOperation,
|
|
1182
|
+
material: EngineCaptureMaterial,
|
|
1183
|
+
): Promise<void> {
|
|
1184
|
+
const sink = this.seams.captureEngineAccount;
|
|
1185
|
+
const outcome =
|
|
1186
|
+
sink && operation.captureFlow
|
|
1187
|
+
? await sink(operation.captureFlow, operation.engine, material)
|
|
1188
|
+
: { ok: false, message: "Only the Moondeck Cloud sign-in box writes cloud credentials." };
|
|
1189
|
+
await this.finishCommit(
|
|
1190
|
+
operation,
|
|
1191
|
+
outcome.ok && outcome.capture
|
|
1192
|
+
? {
|
|
1193
|
+
kind: "engine.login.event",
|
|
1194
|
+
opId: operation.opId,
|
|
1195
|
+
engine: operation.engine,
|
|
1196
|
+
phase: "succeeded",
|
|
1197
|
+
message: outcome.message,
|
|
1198
|
+
capture: outcome.capture,
|
|
1199
|
+
}
|
|
1200
|
+
: failedEvent(operation.opId, operation.engine, "persist_failed", outcome.message),
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
private beginCodexFinish(operation: LoginOperation): void {
|
|
1205
|
+
if (
|
|
1206
|
+
!this.isCurrent(operation) ||
|
|
1207
|
+
operation.finishing ||
|
|
1208
|
+
!operation.tempDir
|
|
1209
|
+
) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
operation.finishing = true;
|
|
1213
|
+
this.clearOperationTimer(operation);
|
|
1214
|
+
operation.finishPromise = this.commitCodex(operation);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
private async commitCodex(operation: LoginOperation): Promise<void> {
|
|
1218
|
+
const tempDir = operation.tempDir;
|
|
1219
|
+
if (!tempDir) return;
|
|
1220
|
+
// codex and grok share the shape: a device-code CLI that exits 0 after
|
|
1221
|
+
// writing auth.json under its HOME var (CODEX_HOME / GROK_HOME), which
|
|
1222
|
+
// the container mounts from the op's private leaf.
|
|
1223
|
+
const grok = operation.engine === "grok";
|
|
1224
|
+
const authFile = join(tempDir, grok ? "grok" : "codex", "auth.json");
|
|
1225
|
+
const label = grok ? "Grok" : "Codex";
|
|
1226
|
+
let terminal: EngineLoginEventFrame;
|
|
1227
|
+
if (operation.captureFlow !== null) {
|
|
1228
|
+
let contents: string;
|
|
1229
|
+
try {
|
|
1230
|
+
contents = await readFile(authFile, "utf8");
|
|
1231
|
+
} catch {
|
|
1232
|
+
await this.finishCommit(
|
|
1233
|
+
operation,
|
|
1234
|
+
failedEvent(
|
|
1235
|
+
operation.opId,
|
|
1236
|
+
operation.engine,
|
|
1237
|
+
"persist_failed",
|
|
1238
|
+
`${label} authorized, but its credential could not be read.`,
|
|
1239
|
+
),
|
|
1240
|
+
);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
await this.finishCapture(operation, {
|
|
1244
|
+
authKind: "config-dir",
|
|
1245
|
+
files: { "auth.json": contents.endsWith("\n") ? contents : `${contents}\n` },
|
|
1246
|
+
});
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (operation.accountLabel !== null) {
|
|
1250
|
+
// ADR-116: labeled capture — the auth.json this login produced becomes
|
|
1251
|
+
// an isolated extra account instead of overwriting ~/.codex.
|
|
1252
|
+
let added: AccountAddOutcome;
|
|
1253
|
+
try {
|
|
1254
|
+
const contents = await readFile(authFile, "utf8");
|
|
1255
|
+
added = await (grok
|
|
1256
|
+
? this.seams.addGrokAccount(
|
|
1257
|
+
operation.accountLabel,
|
|
1258
|
+
contents,
|
|
1259
|
+
operation.accountScope,
|
|
1260
|
+
)
|
|
1261
|
+
: this.seams.addCodexAccount(
|
|
1262
|
+
operation.accountLabel,
|
|
1263
|
+
contents,
|
|
1264
|
+
operation.accountScope,
|
|
1265
|
+
));
|
|
1266
|
+
} catch {
|
|
1267
|
+
added = {
|
|
1268
|
+
ok: false,
|
|
1269
|
+
message: `${label} authorized, but its credential could not be read.`,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
terminal = added.ok
|
|
1273
|
+
? {
|
|
1274
|
+
kind: "engine.login.event",
|
|
1275
|
+
opId: operation.opId,
|
|
1276
|
+
engine: operation.engine,
|
|
1277
|
+
phase: "succeeded",
|
|
1278
|
+
message: added.message,
|
|
1279
|
+
...(added.id === undefined ? {} : { accountId: added.id }),
|
|
1280
|
+
}
|
|
1281
|
+
: failedEvent(
|
|
1282
|
+
operation.opId,
|
|
1283
|
+
operation.engine,
|
|
1284
|
+
"persist_failed",
|
|
1285
|
+
added.message,
|
|
1286
|
+
);
|
|
1287
|
+
await this.finishCommit(operation, terminal);
|
|
1288
|
+
if (added.ok) runBestEffort(() => this.seams.ensureStandardImage());
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
if (grok) await this.seams.persistGrokAuth(authFile);
|
|
1293
|
+
else await this.seams.persistCodexAuth(authFile);
|
|
1294
|
+
} catch {
|
|
1295
|
+
terminal = failedEvent(
|
|
1296
|
+
operation.opId,
|
|
1297
|
+
operation.engine,
|
|
1298
|
+
"persist_failed",
|
|
1299
|
+
`${label} authorized, but its credential could not be saved.`,
|
|
1300
|
+
);
|
|
1301
|
+
await this.finishCommit(operation, terminal);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (!grok) runBestEffort(() => this.seams.reinjectCodexRunningTasks());
|
|
1305
|
+
terminal = {
|
|
1306
|
+
kind: "engine.login.event",
|
|
1307
|
+
opId: operation.opId,
|
|
1308
|
+
engine: operation.engine,
|
|
1309
|
+
phase: "succeeded",
|
|
1310
|
+
message: `${label} connected.`,
|
|
1311
|
+
};
|
|
1312
|
+
await this.finishCommit(operation, terminal);
|
|
1313
|
+
runBestEffort(() => this.seams.ensureStandardImage());
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
private async finishCommit(
|
|
1317
|
+
operation: LoginOperation,
|
|
1318
|
+
terminal: EngineLoginEventFrame,
|
|
1319
|
+
): Promise<void> {
|
|
1320
|
+
if (this.isCurrent(operation)) this.detach(operation);
|
|
1321
|
+
if (!operation.suppressTerminalEvent) safeEmit(operation.emit, terminal);
|
|
1322
|
+
await this.cleanup(operation);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
private async timeout(operation: LoginOperation): Promise<void> {
|
|
1326
|
+
if (!this.isCurrent(operation)) return;
|
|
1327
|
+
if (operation.finishPromise) {
|
|
1328
|
+
await operation.finishPromise;
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
// ADR-116: attach the CLI's last visible line — a remote operator staring
|
|
1332
|
+
// at "Waiting for the host…" (live 2026-08-20, apple-container) otherwise
|
|
1333
|
+
// has zero evidence of where the flow died.
|
|
1334
|
+
const evidence = lastSignificantOutputLine(operation.outputTail);
|
|
1335
|
+
this.detach(operation);
|
|
1336
|
+
safeEmit(operation.emit, {
|
|
1337
|
+
kind: "engine.login.event",
|
|
1338
|
+
opId: operation.opId,
|
|
1339
|
+
engine: operation.engine,
|
|
1340
|
+
phase: "timed_out",
|
|
1341
|
+
errorCode: "timeout",
|
|
1342
|
+
message: evidence
|
|
1343
|
+
? `Login timed out. Last output: ${evidence}`
|
|
1344
|
+
: "Login timed out.",
|
|
1345
|
+
});
|
|
1346
|
+
await this.ensureSettlement(operation);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
private async fail(
|
|
1350
|
+
operation: LoginOperation,
|
|
1351
|
+
errorCode: EngineLoginErrorCode,
|
|
1352
|
+
message: string,
|
|
1353
|
+
): Promise<void> {
|
|
1354
|
+
if (!this.isCurrent(operation)) return;
|
|
1355
|
+
if (operation.finishPromise) {
|
|
1356
|
+
await operation.finishPromise;
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
this.detach(operation);
|
|
1360
|
+
safeEmit(
|
|
1361
|
+
operation.emit,
|
|
1362
|
+
failedEvent(operation.opId, operation.engine, errorCode, message),
|
|
1363
|
+
);
|
|
1364
|
+
await this.cleanup(operation);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
private detach(operation: LoginOperation): void {
|
|
1368
|
+
operation.active = false;
|
|
1369
|
+
operation.outputTail = "";
|
|
1370
|
+
operation.stdoutTail = "";
|
|
1371
|
+
operation.pendingInput = null;
|
|
1372
|
+
operation.callbackTarget = null;
|
|
1373
|
+
this.clearOperationTimer(operation);
|
|
1374
|
+
// Keep the exact operation reserved in both maps until cleanup settles.
|
|
1375
|
+
// stopAll() must be able to discover a terminal operation whose Docker/fs
|
|
1376
|
+
// cleanup is still in flight, and a replacement login must not overlap it.
|
|
1377
|
+
this.ensureSettlement(operation);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
private cleanup(operation: LoginOperation): Promise<void> {
|
|
1381
|
+
if (operation.cleanupPromise) return operation.cleanupPromise;
|
|
1382
|
+
const processHandle = operation.process;
|
|
1383
|
+
const tempDir = operation.tempDir;
|
|
1384
|
+
operation.process = null;
|
|
1385
|
+
operation.tempDir = null;
|
|
1386
|
+
operation.cleanupPromise = (async () => {
|
|
1387
|
+
if (processHandle) await this.stopDetachedProcess(processHandle);
|
|
1388
|
+
if (tempDir) await this.removeDetachedTemp(tempDir);
|
|
1389
|
+
})();
|
|
1390
|
+
return operation.cleanupPromise;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
private ensureSettlement(operation: LoginOperation): Promise<void> {
|
|
1394
|
+
if (operation.settlementPromise) return operation.settlementPromise;
|
|
1395
|
+
const launch = operation.launchPromise;
|
|
1396
|
+
const finish = operation.finishPromise;
|
|
1397
|
+
const callback = operation.callbackPromise;
|
|
1398
|
+
operation.settlementPromise = (async () => {
|
|
1399
|
+
// A detached launch owns any resource that appears after cancellation.
|
|
1400
|
+
// Join it before snapshotting cleanup, then stop the container promptly
|
|
1401
|
+
// while callback/credential completion settles independently.
|
|
1402
|
+
if (launch) await launch;
|
|
1403
|
+
const cleanup = this.cleanup(operation);
|
|
1404
|
+
await Promise.all([
|
|
1405
|
+
cleanup,
|
|
1406
|
+
...(finish ? [finish] : []),
|
|
1407
|
+
...(callback ? [callback] : []),
|
|
1408
|
+
]);
|
|
1409
|
+
})().finally(() => {
|
|
1410
|
+
if (this.operations.get(operation.opId) === operation) {
|
|
1411
|
+
this.operations.delete(operation.opId);
|
|
1412
|
+
}
|
|
1413
|
+
if (this.defaultSlotOperations.get(operation.engine) === operation) {
|
|
1414
|
+
this.defaultSlotOperations.delete(operation.engine);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
return operation.settlementPromise;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
private async stopDetachedProcess(
|
|
1421
|
+
processHandle: EngineLoginProcess,
|
|
1422
|
+
): Promise<void> {
|
|
1423
|
+
try {
|
|
1424
|
+
await processHandle.stop();
|
|
1425
|
+
} catch {
|
|
1426
|
+
// A failed exact removal is not absence proof. Serialize a bounded
|
|
1427
|
+
// labeled sweep behind any sweep already in flight and retain its false
|
|
1428
|
+
// result so the next launch must retry before creating more resources.
|
|
1429
|
+
await this.queueReconciliation();
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
private async removeDetachedTemp(path: string): Promise<void> {
|
|
1434
|
+
try {
|
|
1435
|
+
await this.seams.removeTempDir(path);
|
|
1436
|
+
} catch {
|
|
1437
|
+
// Codex may have written auth.json here. Queue the same container-first
|
|
1438
|
+
// reconciliation used at boot and keep its false result as a launch
|
|
1439
|
+
// gate rather than declaring a secret-bearing temp directory settled.
|
|
1440
|
+
await this.queueReconciliation();
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
private isCurrent(operation: LoginOperation): boolean {
|
|
1445
|
+
return operation.active && this.operations.get(operation.opId) === operation;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
private clearOperationTimer(operation: LoginOperation): void {
|
|
1449
|
+
if (operation.timer === null) return;
|
|
1450
|
+
this.seams.timers.clearTimeout(operation.timer);
|
|
1451
|
+
operation.timer = null;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
private runReconciliation(): Promise<boolean> {
|
|
1455
|
+
return this.seams.reconcileStaleContainers().then(
|
|
1456
|
+
() => true,
|
|
1457
|
+
() => false,
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
private queueReconciliation(): Promise<boolean> {
|
|
1462
|
+
const queued = this.reconciliation.then(() => this.runReconciliation());
|
|
1463
|
+
this.reconciliation = queued;
|
|
1464
|
+
return queued;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function runBestEffort(work: () => Promise<void>): void {
|
|
1469
|
+
try {
|
|
1470
|
+
void work().catch(() => {
|
|
1471
|
+
// Recovery/reconnect will inject the now-durable credential.
|
|
1472
|
+
});
|
|
1473
|
+
} catch {
|
|
1474
|
+
// Treat a synchronous adapter failure the same as a rejected promise.
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
export function createEngineLoginManager(
|
|
1479
|
+
options: EngineLoginManagerOptions,
|
|
1480
|
+
): EngineLoginManager {
|
|
1481
|
+
return new EngineLoginManager(options);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/** Detect the TUI's post-input rejection ("OAuth error: Invalid code…",
|
|
1485
|
+
* expired codes, …) in the sanitized output. ANSI stripping can eat spaces
|
|
1486
|
+
* ("Invalidcode"), so the patterns tolerate missing whitespace. Exported for
|
|
1487
|
+
* tests. */
|
|
1488
|
+
export function detectClaudeCliError(tail: string): string | null {
|
|
1489
|
+
const lines = tail
|
|
1490
|
+
.replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
|
|
1491
|
+
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
|
|
1492
|
+
.split("\n")
|
|
1493
|
+
.map((line) => line.trim())
|
|
1494
|
+
.filter(Boolean);
|
|
1495
|
+
const match = lines.find((line) =>
|
|
1496
|
+
/oauth\s*error|invalid\s*cod|code\s*.{0,12}expired|authentication\s*failed/i.test(
|
|
1497
|
+
line,
|
|
1498
|
+
),
|
|
1499
|
+
);
|
|
1500
|
+
if (!match) return null;
|
|
1501
|
+
if (/sk-ant-|Bearer |[A-Za-z0-9_-]{40,}/.test(match)) return null;
|
|
1502
|
+
return match.slice(0, 200);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** The last visible, sanitized line of the login CLI's output — bounded and
|
|
1506
|
+
* secret-checked so it can ride a terminal event message (ADR-116). Anything
|
|
1507
|
+
* token-shaped disqualifies the whole line rather than risking a partial. */
|
|
1508
|
+
function lastSignificantOutputLine(tail: string): string | null {
|
|
1509
|
+
// stripTerminalControl handles the shapes the old inline regex missed —
|
|
1510
|
+
// private-parameter CSI (kitty's ESC[<u, ESC[>4m) and charset selects
|
|
1511
|
+
// (ESC(B) — whose husks once surfaced verbatim as "(B[>4m[<u" evidence.
|
|
1512
|
+
const lines = stripTerminalControl(tail)
|
|
1513
|
+
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
|
|
1514
|
+
.split("\n")
|
|
1515
|
+
.map((line) => line.trim())
|
|
1516
|
+
.filter((line) => line.length > 3);
|
|
1517
|
+
const last = lines.at(-1);
|
|
1518
|
+
if (!last) return null;
|
|
1519
|
+
if (/sk-ant-|Bearer |[A-Za-z0-9_-]{40,}/.test(last)) return null;
|
|
1520
|
+
return last.slice(0, 200);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function safeEmit(
|
|
1524
|
+
emit: EmitEngineLoginEvent,
|
|
1525
|
+
event: EngineLoginEventFrame,
|
|
1526
|
+
): void {
|
|
1527
|
+
try {
|
|
1528
|
+
emit(event);
|
|
1529
|
+
} catch {
|
|
1530
|
+
// Socket teardown calls stopAll; never turn a closed emitter into a crash.
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function engineLabel(engine: EngineLoginKind): "Claude" | "Codex" | "Grok" {
|
|
1535
|
+
return engine === "claude" ? "Claude" : engine === "codex" ? "Codex" : "Grok";
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function failedEvent(
|
|
1539
|
+
opId: string,
|
|
1540
|
+
engine: EngineLoginKind,
|
|
1541
|
+
errorCode: EngineLoginErrorCode,
|
|
1542
|
+
message: string,
|
|
1543
|
+
): EngineLoginEventFrame {
|
|
1544
|
+
return {
|
|
1545
|
+
kind: "engine.login.event",
|
|
1546
|
+
opId,
|
|
1547
|
+
engine,
|
|
1548
|
+
phase: "failed",
|
|
1549
|
+
errorCode,
|
|
1550
|
+
message,
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function authorizeEvent(
|
|
1555
|
+
operation: LoginOperation,
|
|
1556
|
+
url: string,
|
|
1557
|
+
): EngineLoginEventFrame {
|
|
1558
|
+
return {
|
|
1559
|
+
kind: "engine.login.event",
|
|
1560
|
+
opId: operation.opId,
|
|
1561
|
+
engine: operation.engine,
|
|
1562
|
+
phase: "authorize",
|
|
1563
|
+
url,
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function isValidOneTimeInput(value: string): boolean {
|
|
1568
|
+
return (
|
|
1569
|
+
value.length >= 1 &&
|
|
1570
|
+
value.length <= MAX_ENGINE_LOGIN_TEXT_CHARS &&
|
|
1571
|
+
value.trim() === value &&
|
|
1572
|
+
!/[\0-\x1f\x7f]/.test(value) &&
|
|
1573
|
+
// The PTY echoes input. Never let a caller supply a token-shaped value
|
|
1574
|
+
// that the output parser could mistake for the CLI's newly minted token.
|
|
1575
|
+
extractClaudeOAuthToken(value) === null
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function appendUtf8Tail(current: string, chunk: string, maxBytes: number): string {
|
|
1580
|
+
const bytes = Buffer.from(current + chunk, "utf8");
|
|
1581
|
+
if (bytes.byteLength <= maxBytes) return bytes.toString("utf8");
|
|
1582
|
+
return bytes.subarray(bytes.byteLength - maxBytes).toString("utf8");
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function stripTerminalControl(value: string): string {
|
|
1586
|
+
return value
|
|
1587
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
|
1588
|
+
.replace(/\x1b\[[0-9;:?<=>]*[!-/]*[@-~]/g, "")
|
|
1589
|
+
.replace(/\x1b[()][0-9A-B]/g, "")
|
|
1590
|
+
.replace(/\x1b[78=>DEHM]/g, "")
|
|
1591
|
+
.replace(/\r/g, "\n");
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
const SCREEN_MAX_ROWS = 200;
|
|
1595
|
+
const SCREEN_MAX_COLS = 600;
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Rebuild the visible terminal screen from raw TUI output. The login CLI's
|
|
1599
|
+
* renderer diffs frames: a repainted line SKIPS cells that already show the
|
|
1600
|
+
* right character and jumps the cursor over them. The minted token paints
|
|
1601
|
+
* over the "Paste code here if prompted >" prompt, whose `code` leaves an
|
|
1602
|
+
* 'o' exactly where `sk-ant-oat01-`'s 'o' lands — so the byte stream carries
|
|
1603
|
+
* `sk-ant-` ESC[10G `at01-…` and NEVER the contiguous token (live
|
|
1604
|
+
* 2026-08-21, first Linux host; the Air's earlier "corrupted" capture was
|
|
1605
|
+
* the same hole, not stderr interleave). Only a spatial reconstruction sees
|
|
1606
|
+
* what the operator sees, so the credential scan reads this rendering, never
|
|
1607
|
+
* the raw stream. Cells no frame painted stay spaces — a hole can only be
|
|
1608
|
+
* filled by content some frame actually painted there, never closed up.
|
|
1609
|
+
*/
|
|
1610
|
+
export function renderTerminalScreenText(value: string): string {
|
|
1611
|
+
const rows: (string[] | undefined)[] = [];
|
|
1612
|
+
let row = 0;
|
|
1613
|
+
let col = 0;
|
|
1614
|
+
let savedRow = 0;
|
|
1615
|
+
let savedCol = 0;
|
|
1616
|
+
const clamp = () => {
|
|
1617
|
+
row = Math.min(Math.max(row, 0), SCREEN_MAX_ROWS - 1);
|
|
1618
|
+
col = Math.min(Math.max(col, 0), SCREEN_MAX_COLS);
|
|
1619
|
+
};
|
|
1620
|
+
const line = (): string[] => (rows[row] ??= []);
|
|
1621
|
+
const pattern =
|
|
1622
|
+
// OSC | CSI(params, final) | charset | bare ESC final | CR | LF | text
|
|
1623
|
+
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[([0-9;:?<=>]*)[!-/]*([@-~])|\x1b[()][0-9A-B]|\x1b([78=>DEHM])|(\r)|(\n)|([^\x1b\r\n\x00-\x1f\x7f]+)|[\s\S]/g;
|
|
1624
|
+
for (const part of value.matchAll(pattern)) {
|
|
1625
|
+
const [, csiParams, csiFinal, escFinal, cr, lf, text] = part;
|
|
1626
|
+
if (text !== undefined) {
|
|
1627
|
+
const cells = line();
|
|
1628
|
+
for (const ch of text) {
|
|
1629
|
+
if (col < SCREEN_MAX_COLS) cells[col] = ch;
|
|
1630
|
+
col += 1;
|
|
1631
|
+
}
|
|
1632
|
+
clamp();
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
if (cr !== undefined) {
|
|
1636
|
+
col = 0;
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
if (lf !== undefined) {
|
|
1640
|
+
row += 1;
|
|
1641
|
+
clamp();
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
if (escFinal !== undefined) {
|
|
1645
|
+
if (escFinal === "7") [savedRow, savedCol] = [row, col];
|
|
1646
|
+
else if (escFinal === "8") [row, col] = [savedRow, savedCol];
|
|
1647
|
+
else if (escFinal === "D" || escFinal === "E") row += 1;
|
|
1648
|
+
else if (escFinal === "M") row -= 1;
|
|
1649
|
+
if (escFinal === "E") col = 0;
|
|
1650
|
+
clamp();
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
if (csiFinal === undefined) continue;
|
|
1654
|
+
const params = csiParams ?? "";
|
|
1655
|
+
// Private-parameter sequences (kitty keyboard pops, DEC modes) are not
|
|
1656
|
+
// cursor movement — never let e.g. `ESC[<u` restore a stale cursor.
|
|
1657
|
+
if (/[?<=>]/.test(params)) continue;
|
|
1658
|
+
const numbers = params.split(";").map((entry) => Number.parseInt(entry, 10));
|
|
1659
|
+
const n = (index: number, fallback: number): number => {
|
|
1660
|
+
const parsed = numbers[index];
|
|
1661
|
+
return Number.isFinite(parsed) && parsed! > 0 ? parsed! : fallback;
|
|
1662
|
+
};
|
|
1663
|
+
switch (csiFinal) {
|
|
1664
|
+
case "G":
|
|
1665
|
+
col = n(0, 1) - 1;
|
|
1666
|
+
break;
|
|
1667
|
+
case "A":
|
|
1668
|
+
row -= n(0, 1);
|
|
1669
|
+
break;
|
|
1670
|
+
case "B":
|
|
1671
|
+
row += n(0, 1);
|
|
1672
|
+
break;
|
|
1673
|
+
case "C":
|
|
1674
|
+
col += n(0, 1);
|
|
1675
|
+
break;
|
|
1676
|
+
case "D":
|
|
1677
|
+
col -= n(0, 1);
|
|
1678
|
+
break;
|
|
1679
|
+
case "E":
|
|
1680
|
+
row += n(0, 1);
|
|
1681
|
+
col = 0;
|
|
1682
|
+
break;
|
|
1683
|
+
case "F":
|
|
1684
|
+
row -= n(0, 1);
|
|
1685
|
+
col = 0;
|
|
1686
|
+
break;
|
|
1687
|
+
case "d":
|
|
1688
|
+
row = n(0, 1) - 1;
|
|
1689
|
+
break;
|
|
1690
|
+
case "H":
|
|
1691
|
+
case "f":
|
|
1692
|
+
row = n(0, 1) - 1;
|
|
1693
|
+
col = n(1, 1) - 1;
|
|
1694
|
+
break;
|
|
1695
|
+
case "s":
|
|
1696
|
+
[savedRow, savedCol] = [row, col];
|
|
1697
|
+
break;
|
|
1698
|
+
case "u":
|
|
1699
|
+
[row, col] = [savedRow, savedCol];
|
|
1700
|
+
break;
|
|
1701
|
+
case "K": {
|
|
1702
|
+
const cells = line();
|
|
1703
|
+
const mode = Number.isFinite(numbers[0]) ? numbers[0]! : 0;
|
|
1704
|
+
if (mode === 0) cells.length = Math.min(cells.length, col);
|
|
1705
|
+
else if (mode === 1) {
|
|
1706
|
+
for (let i = 0; i <= col && i < cells.length; i += 1) cells[i] = " ";
|
|
1707
|
+
} else if (mode === 2) cells.length = 0;
|
|
1708
|
+
break;
|
|
1709
|
+
}
|
|
1710
|
+
case "J": {
|
|
1711
|
+
const mode = Number.isFinite(numbers[0]) ? numbers[0]! : 0;
|
|
1712
|
+
if (mode === 0) {
|
|
1713
|
+
line().length = Math.min(line().length, col);
|
|
1714
|
+
rows.length = Math.min(rows.length, row + 1);
|
|
1715
|
+
} else if (mode === 1) {
|
|
1716
|
+
for (let i = 0; i < row; i += 1) rows[i] = undefined;
|
|
1717
|
+
const cells = line();
|
|
1718
|
+
for (let i = 0; i <= col && i < cells.length; i += 1) cells[i] = " ";
|
|
1719
|
+
} else {
|
|
1720
|
+
rows.length = 0;
|
|
1721
|
+
}
|
|
1722
|
+
break;
|
|
1723
|
+
}
|
|
1724
|
+
default:
|
|
1725
|
+
break;
|
|
1726
|
+
}
|
|
1727
|
+
clamp();
|
|
1728
|
+
}
|
|
1729
|
+
return rows
|
|
1730
|
+
.map((cells) => Array.from(cells ?? [], (cell) => cell ?? " ").join(""))
|
|
1731
|
+
.join("\n");
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function candidateHttpsUrls(value: string): string[] {
|
|
1735
|
+
const plain = stripTerminalControl(value);
|
|
1736
|
+
return [...plain.matchAll(/https:\/\/[^\s<>"'\x00-\x1f]+/g)]
|
|
1737
|
+
.map((match) => (match[0] ?? "").replace(/[),.;]+$/g, ""))
|
|
1738
|
+
.filter(Boolean);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function parseSafeHttpsUrl(value: string): URL | null {
|
|
1742
|
+
if (value.length < 1 || value.length > MAX_ENGINE_LOGIN_URL_CHARS) return null;
|
|
1743
|
+
try {
|
|
1744
|
+
const url = new URL(value);
|
|
1745
|
+
if (
|
|
1746
|
+
url.protocol !== "https:" ||
|
|
1747
|
+
url.username ||
|
|
1748
|
+
url.password ||
|
|
1749
|
+
!url.hostname
|
|
1750
|
+
) {
|
|
1751
|
+
return null;
|
|
1752
|
+
}
|
|
1753
|
+
return url.toString().length <= MAX_ENGINE_LOGIN_URL_CHARS ? url : null;
|
|
1754
|
+
} catch {
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/** Exported narrow parsers make secret-boundary regressions easy to fuzz. */
|
|
1760
|
+
export function findSafeHttpsUrl(value: string): string | null {
|
|
1761
|
+
for (const candidate of candidateHttpsUrls(value)) {
|
|
1762
|
+
const parsed = parseSafeHttpsUrl(candidate);
|
|
1763
|
+
if (parsed) return parsed.toString();
|
|
1764
|
+
}
|
|
1765
|
+
return null;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* Codex `--device-auth` banner: a verification URL plus a short one-time
|
|
1770
|
+
* code ("AC57-MDV9G" shape). Both the phrase gate and the code shape are
|
|
1771
|
+
* required so container names, hashes, or URL fragments cannot fake a
|
|
1772
|
+
* device prompt. Reads the merged tail — codex prints to stderr.
|
|
1773
|
+
*/
|
|
1774
|
+
export function findCodexDeviceAuthorization(
|
|
1775
|
+
value: string,
|
|
1776
|
+
): { verificationUrl: string; userCode: string } | null {
|
|
1777
|
+
const plain = stripTerminalControl(value);
|
|
1778
|
+
if (!/one-time code|device code/i.test(plain)) return null;
|
|
1779
|
+
const verificationUrl = findSafeHttpsUrl(plain);
|
|
1780
|
+
if (!verificationUrl) return null;
|
|
1781
|
+
const code = /(?:^|\s)([A-Z0-9]{4,8}-[A-Z0-9]{4,10})(?:\s|$)/m.exec(plain);
|
|
1782
|
+
if (!code) return null;
|
|
1783
|
+
return { verificationUrl, userCode: code[1]! };
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Grok `login --device-auth` banner (host 0.9.105): "To sign in, open this
|
|
1788
|
+
* URL in your browser: https://accounts.x.ai/oauth2/device?user_code=XXXX-XXXX
|
|
1789
|
+
* … Confirm this code in your browser: XXXX-XXXX". Phrase gate + code shape,
|
|
1790
|
+
* same posture as the codex parser; grok prints to stderr.
|
|
1791
|
+
*/
|
|
1792
|
+
export function findGrokDeviceAuthorization(
|
|
1793
|
+
value: string,
|
|
1794
|
+
): { verificationUrl: string; userCode: string } | null {
|
|
1795
|
+
const plain = stripTerminalControl(value);
|
|
1796
|
+
if (!/confirm this code|open this url/i.test(plain)) return null;
|
|
1797
|
+
const verificationUrl = findSafeHttpsUrl(plain);
|
|
1798
|
+
if (!verificationUrl) return null;
|
|
1799
|
+
const code =
|
|
1800
|
+
/user_code=([A-Z0-9]{4,8}-[A-Z0-9]{4,10})\b/.exec(plain) ??
|
|
1801
|
+
/(?:^|\s)([A-Z0-9]{4,8}-[A-Z0-9]{4,10})(?:\s|$)/m.exec(plain);
|
|
1802
|
+
if (!code) return null;
|
|
1803
|
+
return { verificationUrl, userCode: code[1]! };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
export function extractClaudeOAuthToken(value: string): string | null {
|
|
1807
|
+
const plain = stripTerminalControl(value);
|
|
1808
|
+
// The FULL `sk-ant-oat01-` prefix and a realistic minimum length are both
|
|
1809
|
+
// load-bearing: the TUI's diff renderer skips screen cells that already
|
|
1810
|
+
// hold the right character, so naive control-stripping glues the runs into
|
|
1811
|
+
// `sk-ant-at01-…` — one character short, token-shaped, wrong — which a
|
|
1812
|
+
// permissive scan once persisted as a "successful" login whose agents then
|
|
1813
|
+
// 401'd forever (live 2026-08-21). The strict prefix makes any mangled
|
|
1814
|
+
// capture a non-match; the actual token match happens against the
|
|
1815
|
+
// reconstructed screen (renderTerminalScreenText), where it is contiguous.
|
|
1816
|
+
const match =
|
|
1817
|
+
/(?:^|[^A-Za-z0-9_-])(sk-ant-oat01-[A-Za-z0-9_-]{64,4096})(?![A-Za-z0-9_-])/.exec(
|
|
1818
|
+
plain,
|
|
1819
|
+
);
|
|
1820
|
+
return match?.[1] ?? null;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
function isLocalhost(hostname: string): boolean {
|
|
1824
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
1825
|
+
return (
|
|
1826
|
+
normalized === "localhost" ||
|
|
1827
|
+
normalized === "127.0.0.1" ||
|
|
1828
|
+
normalized === "::1"
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function codexCallbackTarget(authorizeUrl: URL): CodexCallbackTarget | null {
|
|
1833
|
+
const redirectValues = [
|
|
1834
|
+
...authorizeUrl.searchParams.getAll("redirect_uri"),
|
|
1835
|
+
...authorizeUrl.searchParams.getAll("redirect_url"),
|
|
1836
|
+
];
|
|
1837
|
+
const states = authorizeUrl.searchParams.getAll("state");
|
|
1838
|
+
if (
|
|
1839
|
+
redirectValues.length !== 1 ||
|
|
1840
|
+
!redirectValues[0] ||
|
|
1841
|
+
states.length !== 1 ||
|
|
1842
|
+
!states[0] ||
|
|
1843
|
+
states[0].length > MAX_ENGINE_LOGIN_QUERY_CHARS
|
|
1844
|
+
) {
|
|
1845
|
+
return null;
|
|
1846
|
+
}
|
|
1847
|
+
const redirectValue = redirectValues[0];
|
|
1848
|
+
let redirect: URL;
|
|
1849
|
+
try {
|
|
1850
|
+
redirect = new URL(redirectValue);
|
|
1851
|
+
} catch {
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|
|
1854
|
+
if (
|
|
1855
|
+
redirect.protocol !== "http:" ||
|
|
1856
|
+
!isLocalhost(redirect.hostname) ||
|
|
1857
|
+
!redirect.port ||
|
|
1858
|
+
redirect.username ||
|
|
1859
|
+
redirect.password ||
|
|
1860
|
+
redirect.search ||
|
|
1861
|
+
redirect.hash ||
|
|
1862
|
+
!/^\d{1,5}$/.test(redirect.port)
|
|
1863
|
+
) {
|
|
1864
|
+
return null;
|
|
1865
|
+
}
|
|
1866
|
+
const port = Number(redirect.port);
|
|
1867
|
+
if (port < 1 || port > 65_535) return null;
|
|
1868
|
+
return {
|
|
1869
|
+
origin: redirect.origin,
|
|
1870
|
+
pathname: redirect.pathname || "/",
|
|
1871
|
+
port: redirect.port,
|
|
1872
|
+
hostname: redirect.hostname,
|
|
1873
|
+
state: states[0],
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function findCodexAuthorization(
|
|
1878
|
+
value: string,
|
|
1879
|
+
): { authorizeUrl: string; target: CodexCallbackTarget } | null {
|
|
1880
|
+
for (const candidate of candidateHttpsUrls(value)) {
|
|
1881
|
+
const authorizeUrl = parseSafeHttpsUrl(candidate);
|
|
1882
|
+
if (!authorizeUrl) continue;
|
|
1883
|
+
const target = codexCallbackTarget(authorizeUrl);
|
|
1884
|
+
if (target) return { authorizeUrl: authorizeUrl.toString(), target };
|
|
1885
|
+
}
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function validateCodexCallback(
|
|
1890
|
+
value: string,
|
|
1891
|
+
target: CodexCallbackTarget,
|
|
1892
|
+
): string | null {
|
|
1893
|
+
if (
|
|
1894
|
+
value.length < 1 ||
|
|
1895
|
+
value.length > MAX_ENGINE_LOGIN_QUERY_CHARS ||
|
|
1896
|
+
value.trim() !== value ||
|
|
1897
|
+
/[\0\r\n]/.test(value)
|
|
1898
|
+
) {
|
|
1899
|
+
return null;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
let query: string;
|
|
1903
|
+
if (/^https?:\/\//i.test(value)) {
|
|
1904
|
+
let callback: URL;
|
|
1905
|
+
try {
|
|
1906
|
+
callback = new URL(value);
|
|
1907
|
+
} catch {
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
if (
|
|
1911
|
+
callback.protocol !== "http:" ||
|
|
1912
|
+
!isLocalhost(callback.hostname) ||
|
|
1913
|
+
callback.hostname !== target.hostname ||
|
|
1914
|
+
callback.port !== target.port ||
|
|
1915
|
+
callback.pathname !== target.pathname ||
|
|
1916
|
+
callback.username ||
|
|
1917
|
+
callback.password ||
|
|
1918
|
+
callback.hash
|
|
1919
|
+
) {
|
|
1920
|
+
return null;
|
|
1921
|
+
}
|
|
1922
|
+
query = callback.search.slice(1);
|
|
1923
|
+
} else {
|
|
1924
|
+
if (value.includes("#") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
|
|
1925
|
+
return null;
|
|
1926
|
+
}
|
|
1927
|
+
query = value.startsWith("?") ? value.slice(1) : value;
|
|
1928
|
+
}
|
|
1929
|
+
if (!query || query.length > MAX_ENGINE_LOGIN_QUERY_CHARS) return null;
|
|
1930
|
+
|
|
1931
|
+
const params = new URLSearchParams(query);
|
|
1932
|
+
const codes = params.getAll("code");
|
|
1933
|
+
const states = params.getAll("state");
|
|
1934
|
+
if (
|
|
1935
|
+
codes.length !== 1 ||
|
|
1936
|
+
!codes[0] ||
|
|
1937
|
+
states.length !== 1 ||
|
|
1938
|
+
!states[0] ||
|
|
1939
|
+
states[0] !== target.state
|
|
1940
|
+
) {
|
|
1941
|
+
return null;
|
|
1942
|
+
}
|
|
1943
|
+
const normalized = params.toString();
|
|
1944
|
+
return normalized.length <= MAX_ENGINE_LOGIN_QUERY_CHARS ? normalized : null;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function callbackReplayUrl(
|
|
1948
|
+
target: CodexCallbackTarget,
|
|
1949
|
+
query: string,
|
|
1950
|
+
): string {
|
|
1951
|
+
const callback = new URL(target.pathname, target.origin);
|
|
1952
|
+
callback.search = query;
|
|
1953
|
+
return callback.toString();
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
const LOGS_FOLLOW_ATTEMPTS = 20;
|
|
1957
|
+
const LOGS_FOLLOW_RETRY_MS = 200;
|
|
1958
|
+
const LOGS_FOLLOW_ATTACH_PROBE_MS = 500;
|
|
1959
|
+
|
|
1960
|
+
function delayMs(ms: number): Promise<void> {
|
|
1961
|
+
return new Promise((resolve) => {
|
|
1962
|
+
const timer = setTimeout(resolve, ms);
|
|
1963
|
+
unrefTimer(timer);
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Stream a login container's output via `docker logs --follow`, which
|
|
1969
|
+
* replays from byte zero — immune to the run/attach race that loses the
|
|
1970
|
+
* container's first burst (see startLoginContainer). The container may not
|
|
1971
|
+
* exist yet when the first attempt fires, and the client's own
|
|
1972
|
+
* "No such container" stderr must never leak into the operation's output
|
|
1973
|
+
* tail, so each attempt buffers until it is provably attached: container
|
|
1974
|
+
* stdout, or survival past a probe window, count as proof; a quick exit
|
|
1975
|
+
* with the not-found error is a retry.
|
|
1976
|
+
*/
|
|
1977
|
+
async function followLoginContainerLogs(
|
|
1978
|
+
containerName: string,
|
|
1979
|
+
runChild: { once(event: "close", listener: () => void): unknown },
|
|
1980
|
+
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
1981
|
+
): Promise<void> {
|
|
1982
|
+
let runClosed = false;
|
|
1983
|
+
runChild.once("close", () => {
|
|
1984
|
+
runClosed = true;
|
|
1985
|
+
});
|
|
1986
|
+
for (let attempt = 0; attempt < LOGS_FOLLOW_ATTEMPTS; attempt += 1) {
|
|
1987
|
+
const outcome = await followLoginContainerLogsOnce(
|
|
1988
|
+
containerName,
|
|
1989
|
+
onOutput,
|
|
1990
|
+
);
|
|
1991
|
+
if (outcome === "done") return;
|
|
1992
|
+
// The container is absent. Before it ever starts that means "retry";
|
|
1993
|
+
// after the run child closed it means the container already exited and
|
|
1994
|
+
// was removed — nothing will ever appear.
|
|
1995
|
+
if (runClosed) return;
|
|
1996
|
+
await delayMs(LOGS_FOLLOW_RETRY_MS);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function followLoginContainerLogsOnce(
|
|
2001
|
+
containerName: string,
|
|
2002
|
+
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
2003
|
+
): Promise<"done" | "absent"> {
|
|
2004
|
+
return new Promise((resolve) => {
|
|
2005
|
+
const follower = spawn(
|
|
2006
|
+
engineLoginBackend().command,
|
|
2007
|
+
["logs", "--follow", containerName],
|
|
2008
|
+
{ shell: false, stdio: ["ignore", "pipe", "pipe"] },
|
|
2009
|
+
);
|
|
2010
|
+
follower.stdout.setEncoding("utf8");
|
|
2011
|
+
follower.stderr.setEncoding("utf8");
|
|
2012
|
+
const buffered: Array<{ chunk: string; stream: "stdout" | "stderr" }> = [];
|
|
2013
|
+
let stderrTail = "";
|
|
2014
|
+
let attached = false;
|
|
2015
|
+
const flush = (): void => {
|
|
2016
|
+
attached = true;
|
|
2017
|
+
for (const entry of buffered) onOutput(entry.chunk, entry.stream);
|
|
2018
|
+
buffered.length = 0;
|
|
2019
|
+
};
|
|
2020
|
+
const deliver = (chunk: string, stream: "stdout" | "stderr"): void => {
|
|
2021
|
+
if (attached) {
|
|
2022
|
+
onOutput(chunk, stream);
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
buffered.push({ chunk, stream });
|
|
2026
|
+
if (buffered.length > 256) buffered.shift();
|
|
2027
|
+
};
|
|
2028
|
+
follower.stdout.on("data", (chunk: string) => {
|
|
2029
|
+
// Container stdout is proof of attachment — the docker client itself
|
|
2030
|
+
// never writes to stdout here.
|
|
2031
|
+
if (!attached) flush();
|
|
2032
|
+
onOutput(chunk, "stdout");
|
|
2033
|
+
});
|
|
2034
|
+
follower.stderr.on("data", (chunk: string) => {
|
|
2035
|
+
stderrTail = appendUtf8Tail(stderrTail, chunk, 4_096);
|
|
2036
|
+
deliver(chunk, "stderr");
|
|
2037
|
+
});
|
|
2038
|
+
const probe = setTimeout(() => {
|
|
2039
|
+
// Still running after the probe window: the client accepted the
|
|
2040
|
+
// container and is following; anything buffered is container stderr.
|
|
2041
|
+
if (follower.exitCode === null) flush();
|
|
2042
|
+
}, LOGS_FOLLOW_ATTACH_PROBE_MS);
|
|
2043
|
+
unrefTimer(probe);
|
|
2044
|
+
follower.once("error", () => {
|
|
2045
|
+
clearTimeout(probe);
|
|
2046
|
+
resolve("absent");
|
|
2047
|
+
});
|
|
2048
|
+
follower.once("close", () => {
|
|
2049
|
+
clearTimeout(probe);
|
|
2050
|
+
if (
|
|
2051
|
+
!attached &&
|
|
2052
|
+
/no such container|dead or marked for removal/i.test(stderrTail)
|
|
2053
|
+
) {
|
|
2054
|
+
resolve("absent");
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
// A real follow that ended (container exited): deliver whatever was
|
|
2058
|
+
// buffered — a fast-crashing container's stderr arrives exactly here.
|
|
2059
|
+
if (!attached) flush();
|
|
2060
|
+
resolve("done");
|
|
2061
|
+
});
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/** Exported for the follower-path tests and live harnesses. */
|
|
2066
|
+
export async function startLoginContainer(
|
|
2067
|
+
request: EngineLoginContainerRequest,
|
|
2068
|
+
onOutput: (chunk: string, stream?: "stdout" | "stderr") => void,
|
|
2069
|
+
): Promise<EngineLoginProcess> {
|
|
2070
|
+
const apple = engineLoginBackend().apple;
|
|
2071
|
+
const args = engineLoginContainerArgs(request);
|
|
2072
|
+
const child = spawn(engineLoginBackend().command, args, {
|
|
2073
|
+
shell: false,
|
|
2074
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2075
|
+
});
|
|
2076
|
+
child.stdout.setEncoding("utf8");
|
|
2077
|
+
child.stderr.setEncoding("utf8");
|
|
2078
|
+
if (apple) {
|
|
2079
|
+
// The Apple CLI keeps the attach-stdio path: its behavior differs from
|
|
2080
|
+
// Docker's and the claude flow's continuous TUI repaint has proven it
|
|
2081
|
+
// live; the transcript fallback covers its noise-corruption cases.
|
|
2082
|
+
child.stdout.on("data", (chunk: string) => onOutput(chunk, "stdout"));
|
|
2083
|
+
child.stderr.on("data", (chunk: string) => onOutput(chunk, "stderr"));
|
|
2084
|
+
} else {
|
|
2085
|
+
// Docker: the attached stream can MISS the container's first output
|
|
2086
|
+
// burst — `docker run` starts the container and the attach stream races
|
|
2087
|
+
// its earliest writes. codex login prints its authorize URL within
|
|
2088
|
+
// milliseconds and then goes silent, so a lost burst is a dead login
|
|
2089
|
+
// (live 2026-08-21 on the first Linux hosts: dockerd had the URL,
|
|
2090
|
+
// `docker logs` showed it, the attached client never relayed a byte).
|
|
2091
|
+
// Output truth therefore comes from `docker logs --follow`, which
|
|
2092
|
+
// replays from byte zero; the run child keeps ONLY stdin and lifecycle.
|
|
2093
|
+
// Its stdio still drains so the client can never block on backpressure.
|
|
2094
|
+
child.stdout.on("data", () => {});
|
|
2095
|
+
child.stderr.on("data", () => {});
|
|
2096
|
+
const logsDone = followLoginContainerLogs(
|
|
2097
|
+
request.containerName,
|
|
2098
|
+
child,
|
|
2099
|
+
onOutput,
|
|
2100
|
+
);
|
|
2101
|
+
// Without `--rm`, the exited container outlives the run child so the
|
|
2102
|
+
// follower can finish its replay; remove it once the follower is done
|
|
2103
|
+
// (bounded — a wedged follower must not leak containers).
|
|
2104
|
+
child.once("close", () => {
|
|
2105
|
+
void (async () => {
|
|
2106
|
+
await Promise.race([logsDone, delayMs(2_000)]);
|
|
2107
|
+
await removeEngineLoginContainer(request.containerName).catch(
|
|
2108
|
+
() => undefined,
|
|
2109
|
+
);
|
|
2110
|
+
})();
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
child.stdin.on("error", () => {});
|
|
2114
|
+
|
|
2115
|
+
let stopped = false;
|
|
2116
|
+
let settled = false;
|
|
2117
|
+
let resolveCompletion: (result: EngineLoginProcessResult) => void = () => {};
|
|
2118
|
+
const completion = new Promise<EngineLoginProcessResult>((resolve) => {
|
|
2119
|
+
resolveCompletion = resolve;
|
|
2120
|
+
});
|
|
2121
|
+
const settle = (result: EngineLoginProcessResult): void => {
|
|
2122
|
+
if (settled) return;
|
|
2123
|
+
settled = true;
|
|
2124
|
+
resolveCompletion(result);
|
|
2125
|
+
};
|
|
2126
|
+
child.once("error", () => settle({ code: null, spawnFailed: true }));
|
|
2127
|
+
child.once("close", (code) => settle({ code }));
|
|
2128
|
+
|
|
2129
|
+
return {
|
|
2130
|
+
containerName: request.containerName,
|
|
2131
|
+
completion,
|
|
2132
|
+
writeInput(value) {
|
|
2133
|
+
if (stopped || child.stdin.destroyed) throw new Error("login input closed");
|
|
2134
|
+
child.stdin.write(value);
|
|
2135
|
+
},
|
|
2136
|
+
async stop() {
|
|
2137
|
+
if (stopped) return;
|
|
2138
|
+
stopped = true;
|
|
2139
|
+
await stopEngineLoginContainerProcess({
|
|
2140
|
+
completion,
|
|
2141
|
+
endInput: () => child.stdin.end(),
|
|
2142
|
+
terminate: () => child.kill("SIGTERM"),
|
|
2143
|
+
kill: () => child.kill("SIGKILL"),
|
|
2144
|
+
remove: () => removeEngineLoginContainer(request.containerName),
|
|
2145
|
+
});
|
|
2146
|
+
},
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
export interface EngineLoginProcessStopControl {
|
|
2151
|
+
readonly completion: Promise<EngineLoginProcessResult>;
|
|
2152
|
+
endInput(): void;
|
|
2153
|
+
terminate(): void;
|
|
2154
|
+
kill(): void;
|
|
2155
|
+
remove(): Promise<void>;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
/**
|
|
2159
|
+
* Close the docker-run client before proving/removing the daemon resource.
|
|
2160
|
+
* Otherwise an in-flight `docker run` can create the container just after an
|
|
2161
|
+
* early absence check, orphaning it across generation teardown.
|
|
2162
|
+
*/
|
|
2163
|
+
export async function stopEngineLoginContainerProcess(
|
|
2164
|
+
control: EngineLoginProcessStopControl,
|
|
2165
|
+
): Promise<void> {
|
|
2166
|
+
control.endInput();
|
|
2167
|
+
control.terminate();
|
|
2168
|
+
if (
|
|
2169
|
+
!(await promiseSettlesWithin(
|
|
2170
|
+
control.completion,
|
|
2171
|
+
DOCKER_RUN_STOP_GRACE_MS,
|
|
2172
|
+
))
|
|
2173
|
+
) {
|
|
2174
|
+
control.kill();
|
|
2175
|
+
if (
|
|
2176
|
+
!(await promiseSettlesWithin(
|
|
2177
|
+
control.completion,
|
|
2178
|
+
DOCKER_RUN_STOP_GRACE_MS,
|
|
2179
|
+
))
|
|
2180
|
+
) {
|
|
2181
|
+
throw new Error("login docker client did not stop");
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
await control.remove();
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
async function promiseSettlesWithin(
|
|
2188
|
+
promise: Promise<unknown>,
|
|
2189
|
+
timeoutMs: number,
|
|
2190
|
+
): Promise<boolean> {
|
|
2191
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
2192
|
+
try {
|
|
2193
|
+
return await Promise.race([
|
|
2194
|
+
promise.then(
|
|
2195
|
+
() => true,
|
|
2196
|
+
() => true,
|
|
2197
|
+
),
|
|
2198
|
+
new Promise<boolean>((resolve) => {
|
|
2199
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
2200
|
+
timer.unref?.();
|
|
2201
|
+
}),
|
|
2202
|
+
]);
|
|
2203
|
+
} finally {
|
|
2204
|
+
if (timer) clearTimeout(timer);
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/** Pure command builder kept exported for the isolation regression test. */
|
|
2209
|
+
export function engineLoginContainerArgs(
|
|
2210
|
+
request: EngineLoginContainerRequest,
|
|
2211
|
+
): string[] {
|
|
2212
|
+
if (/[,\0\r\n]/.test(request.tempDir)) {
|
|
2213
|
+
throw new Error("unsafe temporary path");
|
|
2214
|
+
}
|
|
2215
|
+
const uidArgs =
|
|
2216
|
+
typeof process.getuid === "function" && typeof process.getgid === "function"
|
|
2217
|
+
? ["--user", `${process.getuid()}:${process.getgid()}`]
|
|
2218
|
+
: [];
|
|
2219
|
+
const command =
|
|
2220
|
+
request.engine === "claude"
|
|
2221
|
+
? [
|
|
2222
|
+
"/usr/bin/script",
|
|
2223
|
+
"-qefc",
|
|
2224
|
+
// A wide PTY first: `script` allocates 80×24 when stdin is no
|
|
2225
|
+
// terminal, the CLI hard-wraps the OAuth URL at that width, and
|
|
2226
|
+
// the streamed capture then sees only the first fragment — the
|
|
2227
|
+
// operator lands on claude.ai with "Missing redirect_uri" and half
|
|
2228
|
+
// a client id (live 2026-08-18). stty targets the PTY `script`
|
|
2229
|
+
// just allocated.
|
|
2230
|
+
"stty cols 500 rows 50 2>/dev/null; exec claude setup-token",
|
|
2231
|
+
// The PTY transcript lands in the op's private bind leaf (same
|
|
2232
|
+
// trust class as the codex auth.json captured there; the leaf is
|
|
2233
|
+
// 0700 and deleted at cleanup). A stuck flow can then be read
|
|
2234
|
+
// VERBATIM from the host — the 2026-08-20 hang was undiagnosable
|
|
2235
|
+
// because the typescript went to /dev/null.
|
|
2236
|
+
`${LOGIN_MOUNT}/typescript`,
|
|
2237
|
+
]
|
|
2238
|
+
: request.engine === "grok"
|
|
2239
|
+
? ["sh", "-c", GROK_LOGIN_SHELL]
|
|
2240
|
+
: // Device-code flow: no localhost callback, no paste — the CLI prints
|
|
2241
|
+
// a verification URL + one-time code, the operator confirms on the
|
|
2242
|
+
// provider's page, and the CLI polls to completion (ADR-116 v2,
|
|
2243
|
+
// 2026-08-24). The callback machinery below stays as the fallback
|
|
2244
|
+
// for CLIs that still print the localhost authorize URL.
|
|
2245
|
+
["codex", "login", "--device-auth"];
|
|
2246
|
+
if (engineLoginBackend().apple) {
|
|
2247
|
+
// Same containment contract, Apple dialect: read-only root, all caps
|
|
2248
|
+
// dropped, a plain tmpfs at /tmp (the Apple CLI takes no mount options —
|
|
2249
|
+
// the VM boundary already denies device/suid tricks, which is also why
|
|
2250
|
+
// the missing no-new-privileges knob costs nothing here), and -v mounts
|
|
2251
|
+
// for the shared runtime volume + private temp leaf. The container runs
|
|
2252
|
+
// as node: ownership maps transparently across the VM boundary, so the
|
|
2253
|
+
// host uid/gid pin that Docker needs would be meaningless.
|
|
2254
|
+
return [
|
|
2255
|
+
"run",
|
|
2256
|
+
"--rm",
|
|
2257
|
+
"--name",
|
|
2258
|
+
request.containerName,
|
|
2259
|
+
"--label",
|
|
2260
|
+
`${ENGINE_LOGIN_CONTAINER_LABEL}=${ENGINE_LOGIN_CONTAINER_LABEL_VALUE}`,
|
|
2261
|
+
"--init",
|
|
2262
|
+
"--interactive",
|
|
2263
|
+
"--read-only",
|
|
2264
|
+
"--cap-drop",
|
|
2265
|
+
"ALL",
|
|
2266
|
+
"--tmpfs",
|
|
2267
|
+
"/tmp",
|
|
2268
|
+
"-v",
|
|
2269
|
+
`${ASDF_DATA_VOLUME}:/opt/asdf-data:ro`,
|
|
2270
|
+
"-v",
|
|
2271
|
+
`${request.tempDir}:${LOGIN_MOUNT}`,
|
|
2272
|
+
"--user",
|
|
2273
|
+
"node",
|
|
2274
|
+
"--workdir",
|
|
2275
|
+
"/home/node",
|
|
2276
|
+
"--env",
|
|
2277
|
+
`HOME=${LOGIN_HOME}`,
|
|
2278
|
+
"--env",
|
|
2279
|
+
`XDG_CONFIG_HOME=${LOGIN_HOME}/.config`,
|
|
2280
|
+
"--env",
|
|
2281
|
+
`XDG_DATA_HOME=${LOGIN_HOME}/.local/share`,
|
|
2282
|
+
"--env",
|
|
2283
|
+
`XDG_CACHE_HOME=${LOGIN_HOME}/.cache`,
|
|
2284
|
+
"--env",
|
|
2285
|
+
`CODEX_HOME=${CODEX_HOME}`,
|
|
2286
|
+
"--env",
|
|
2287
|
+
`GROK_HOME=${GROK_HOME}`,
|
|
2288
|
+
"--env",
|
|
2289
|
+
"ASDF_CONFIG_FILE=/dev/null",
|
|
2290
|
+
"--env",
|
|
2291
|
+
"ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions",
|
|
2292
|
+
STANDARD_IMAGE_TAG,
|
|
2293
|
+
...command,
|
|
2294
|
+
];
|
|
2295
|
+
}
|
|
2296
|
+
// No `--rm` in the Docker dialect: output truth comes from
|
|
2297
|
+
// `docker logs --follow` (see startLoginContainer), and an auto-removed
|
|
2298
|
+
// fast-exiting container would take its logs with it. The host removes
|
|
2299
|
+
// the container explicitly when the run child closes; the labeled stale
|
|
2300
|
+
// reconcile sweeps anything a crash leaves behind.
|
|
2301
|
+
return [
|
|
2302
|
+
"run",
|
|
2303
|
+
"--name",
|
|
2304
|
+
request.containerName,
|
|
2305
|
+
"--label",
|
|
2306
|
+
`${ENGINE_LOGIN_CONTAINER_LABEL}=${ENGINE_LOGIN_CONTAINER_LABEL_VALUE}`,
|
|
2307
|
+
"--init",
|
|
2308
|
+
"--interactive",
|
|
2309
|
+
"--read-only",
|
|
2310
|
+
"--cap-drop",
|
|
2311
|
+
"ALL",
|
|
2312
|
+
"--security-opt",
|
|
2313
|
+
"no-new-privileges:true",
|
|
2314
|
+
"--tmpfs",
|
|
2315
|
+
"/tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777",
|
|
2316
|
+
"--mount",
|
|
2317
|
+
`type=volume,src=${ASDF_DATA_VOLUME},dst=/opt/asdf-data,readonly`,
|
|
2318
|
+
"--mount",
|
|
2319
|
+
`type=bind,src=${request.tempDir},dst=${LOGIN_MOUNT}`,
|
|
2320
|
+
...uidArgs,
|
|
2321
|
+
"--workdir",
|
|
2322
|
+
"/home/node",
|
|
2323
|
+
"--env",
|
|
2324
|
+
`HOME=${LOGIN_HOME}`,
|
|
2325
|
+
"--env",
|
|
2326
|
+
`XDG_CONFIG_HOME=${LOGIN_HOME}/.config`,
|
|
2327
|
+
"--env",
|
|
2328
|
+
`XDG_DATA_HOME=${LOGIN_HOME}/.local/share`,
|
|
2329
|
+
"--env",
|
|
2330
|
+
`XDG_CACHE_HOME=${LOGIN_HOME}/.cache`,
|
|
2331
|
+
"--env",
|
|
2332
|
+
`CODEX_HOME=${CODEX_HOME}`,
|
|
2333
|
+
"--env",
|
|
2334
|
+
`GROK_HOME=${GROK_HOME}`,
|
|
2335
|
+
"--env",
|
|
2336
|
+
"ASDF_CONFIG_FILE=/dev/null",
|
|
2337
|
+
"--env",
|
|
2338
|
+
"ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions",
|
|
2339
|
+
STANDARD_IMAGE_TAG,
|
|
2340
|
+
...command,
|
|
2341
|
+
];
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
async function replayCodexCallback(
|
|
2345
|
+
containerName: string,
|
|
2346
|
+
callbackUrl: string,
|
|
2347
|
+
): Promise<void> {
|
|
2348
|
+
const request = codexCallbackReplayDockerRequest(containerName, callbackUrl);
|
|
2349
|
+
const result = await defaultEngineLoginDockerExec(request.args, {
|
|
2350
|
+
input: request.input,
|
|
2351
|
+
timeoutMs: DOCKER_CONTROL_TIMEOUT_MS,
|
|
2352
|
+
maxOutputBytes: 8 * 1_024,
|
|
2353
|
+
});
|
|
2354
|
+
if (result.status !== 0) throw new Error("callback replay failed");
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/**
|
|
2358
|
+
* Docker and the in-container curl process receive a fixed argv. The full
|
|
2359
|
+
* callback (including code/state) exists only in the child's stdin, so it is
|
|
2360
|
+
* absent from process listings, daemon metadata, and Docker error messages.
|
|
2361
|
+
*/
|
|
2362
|
+
export function codexCallbackReplayDockerRequest(
|
|
2363
|
+
containerName: string,
|
|
2364
|
+
callbackUrl: string,
|
|
2365
|
+
): { args: string[]; input: string } {
|
|
2366
|
+
if (!isEngineLoginContainerName(containerName)) {
|
|
2367
|
+
throw new Error("invalid login container name");
|
|
2368
|
+
}
|
|
2369
|
+
if (/[\0\r\n]/.test(callbackUrl)) {
|
|
2370
|
+
throw new Error("invalid callback URL");
|
|
2371
|
+
}
|
|
2372
|
+
const configUrl = callbackUrl
|
|
2373
|
+
.replace(/\\/g, "\\\\")
|
|
2374
|
+
.replace(/"/g, '\\"');
|
|
2375
|
+
return {
|
|
2376
|
+
args: [
|
|
2377
|
+
"exec",
|
|
2378
|
+
"--interactive",
|
|
2379
|
+
containerName,
|
|
2380
|
+
"/usr/bin/curl",
|
|
2381
|
+
"--fail",
|
|
2382
|
+
"--silent",
|
|
2383
|
+
"--show-error",
|
|
2384
|
+
// Follow the callback's redirect to the CLI's /success page: codex
|
|
2385
|
+
// writes auth.json on the callback but EXITS only after serving that
|
|
2386
|
+
// page — a real browser follows; a replay that stops at the redirect
|
|
2387
|
+
// leaves a completed login waiting forever (live 2026-08-24: the
|
|
2388
|
+
// first-ever completed pane codex login needed a manual /success
|
|
2389
|
+
// fetch to let the CLI exit).
|
|
2390
|
+
"--location",
|
|
2391
|
+
"--max-time",
|
|
2392
|
+
"10",
|
|
2393
|
+
"--output",
|
|
2394
|
+
"/dev/null",
|
|
2395
|
+
"--config",
|
|
2396
|
+
"-",
|
|
2397
|
+
],
|
|
2398
|
+
input: `url = "${configUrl}"\n`,
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const ENGINE_LOGIN_CONTAINER_NAME = new RegExp(
|
|
2403
|
+
`^uai-login-(?:${ENGINE_LOGIN_KINDS.join("|")})-[0-9a-f]{16}$`,
|
|
2404
|
+
);
|
|
2405
|
+
|
|
2406
|
+
/** `uai-login-<kind>-<16 hex>` for every login kind. The sweep, the log
|
|
2407
|
+
* follower, the remover, and the codex callback replay all gate on this, so
|
|
2408
|
+
* a kind missing here strands its containers AND blocks every later login. */
|
|
2409
|
+
export function isEngineLoginContainerName(value: string): boolean {
|
|
2410
|
+
return ENGINE_LOGIN_CONTAINER_NAME.test(value);
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/** The CLI that owns login containers (ADR-106): the ambient docker client,
|
|
2414
|
+
* or the bundled Apple CLI when that backend is pinned. Resolved per call so
|
|
2415
|
+
* an HMR reload cannot strand a stale path; the pin itself never changes
|
|
2416
|
+
* within a process. */
|
|
2417
|
+
function engineLoginBackend(): { command: string; apple: boolean } {
|
|
2418
|
+
if (pinnedContainerRuntimeProvider() === "apple-container") {
|
|
2419
|
+
const binding = appleContainerRuntimeBinding();
|
|
2420
|
+
if (binding !== null) {
|
|
2421
|
+
return { command: binding.containerCliPath, apple: true };
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
return { command: "docker", apple: false };
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
const defaultEngineLoginDockerExec: EngineLoginDockerExec = (args, options) => {
|
|
2428
|
+
const backend = engineLoginBackend();
|
|
2429
|
+
if (!backend.apple) return dockerCli(args, options);
|
|
2430
|
+
return new Promise((resolveExec) => {
|
|
2431
|
+
const child = spawn(backend.command, args, {
|
|
2432
|
+
shell: false,
|
|
2433
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2434
|
+
});
|
|
2435
|
+
let stdout = "";
|
|
2436
|
+
let stderr = "";
|
|
2437
|
+
let settled = false;
|
|
2438
|
+
const timer = setTimeout(() => {
|
|
2439
|
+
child.kill("SIGKILL");
|
|
2440
|
+
const settle = setTimeout(() => settleResult(null), 5_000);
|
|
2441
|
+
settle.unref?.();
|
|
2442
|
+
}, options?.timeoutMs ?? 30_000);
|
|
2443
|
+
child.stdout.setEncoding("utf8");
|
|
2444
|
+
child.stderr.setEncoding("utf8");
|
|
2445
|
+
child.stdout.on("data", (chunk: string) => (stdout += chunk));
|
|
2446
|
+
child.stderr.on("data", (chunk: string) => (stderr += chunk));
|
|
2447
|
+
child.stdin.on("error", () => {});
|
|
2448
|
+
if (options?.input !== undefined) child.stdin.write(options.input);
|
|
2449
|
+
child.stdin.end();
|
|
2450
|
+
const settleResult = (status: number | null): void => {
|
|
2451
|
+
if (settled) return;
|
|
2452
|
+
settled = true;
|
|
2453
|
+
clearTimeout(timer);
|
|
2454
|
+
resolveExec({ status, stdout, stderr });
|
|
2455
|
+
};
|
|
2456
|
+
child.on("error", () => settleResult(null));
|
|
2457
|
+
child.on("close", (code) => settleResult(code));
|
|
2458
|
+
});
|
|
2459
|
+
};
|
|
2460
|
+
|
|
2461
|
+
/** The Apple CLI exits nonzero for both a missing container and a broken
|
|
2462
|
+
* apiserver; only the not-found error naming this exact container proves
|
|
2463
|
+
* absence. */
|
|
2464
|
+
function appleReportsAbsent(
|
|
2465
|
+
result: { status: number | null; stdout: string; stderr: string },
|
|
2466
|
+
containerName: string,
|
|
2467
|
+
): boolean {
|
|
2468
|
+
// The complete bounded error line, never a substring: a not-found answer
|
|
2469
|
+
// for `<name>-shadow` must not prove the absence of `<name>`.
|
|
2470
|
+
return (
|
|
2471
|
+
result.status !== 0 &&
|
|
2472
|
+
result.stdout.trim() === "" &&
|
|
2473
|
+
result.stderr
|
|
2474
|
+
.split("\n")
|
|
2475
|
+
.some(
|
|
2476
|
+
(line) => line.trim() === `Error: container not found: ${containerName}`,
|
|
2477
|
+
)
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/** Force-remove one owned container, proving either success or exact absence. */
|
|
2482
|
+
export async function removeEngineLoginContainer(
|
|
2483
|
+
containerName: string,
|
|
2484
|
+
exec: EngineLoginDockerExec = defaultEngineLoginDockerExec,
|
|
2485
|
+
): Promise<void> {
|
|
2486
|
+
if (!isEngineLoginContainerName(containerName)) {
|
|
2487
|
+
throw new Error("invalid login container name");
|
|
2488
|
+
}
|
|
2489
|
+
const apple = engineLoginBackend().apple;
|
|
2490
|
+
for (let attempt = 0; attempt < DOCKER_CLEANUP_ATTEMPTS; attempt += 1) {
|
|
2491
|
+
let observedLabel: string | null = null;
|
|
2492
|
+
if (apple) {
|
|
2493
|
+
const inspected = await exec(["inspect", containerName], {
|
|
2494
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
2495
|
+
maxOutputBytes: 256 * 1_024,
|
|
2496
|
+
});
|
|
2497
|
+
if (inspected.status !== 0) {
|
|
2498
|
+
if (await engineLoginContainerIsAbsent(containerName, exec)) return;
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
try {
|
|
2502
|
+
const parsed = JSON.parse(inspected.stdout) as Array<{
|
|
2503
|
+
configuration?: { labels?: Record<string, unknown> };
|
|
2504
|
+
}>;
|
|
2505
|
+
const label =
|
|
2506
|
+
parsed[0]?.configuration?.labels?.[ENGINE_LOGIN_CONTAINER_LABEL];
|
|
2507
|
+
observedLabel = typeof label === "string" ? label : null;
|
|
2508
|
+
} catch {
|
|
2509
|
+
continue;
|
|
2510
|
+
}
|
|
2511
|
+
} else {
|
|
2512
|
+
const inspected = await exec(
|
|
2513
|
+
[
|
|
2514
|
+
"container",
|
|
2515
|
+
"inspect",
|
|
2516
|
+
"--format",
|
|
2517
|
+
`{{ index .Config.Labels \"${ENGINE_LOGIN_CONTAINER_LABEL}\" }}`,
|
|
2518
|
+
containerName,
|
|
2519
|
+
],
|
|
2520
|
+
{
|
|
2521
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
2522
|
+
maxOutputBytes: 8 * 1_024,
|
|
2523
|
+
},
|
|
2524
|
+
);
|
|
2525
|
+
if (inspected.status !== 0) {
|
|
2526
|
+
if (await engineLoginContainerIsAbsent(containerName, exec)) return;
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
observedLabel = inspected.stdout.trim();
|
|
2530
|
+
}
|
|
2531
|
+
if (observedLabel !== ENGINE_LOGIN_CONTAINER_LABEL_VALUE) {
|
|
2532
|
+
throw new Error("login container ownership could not be confirmed");
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
const removed = await exec(
|
|
2536
|
+
apple
|
|
2537
|
+
? ["delete", "--force", containerName]
|
|
2538
|
+
: ["rm", "--force", containerName],
|
|
2539
|
+
{
|
|
2540
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
2541
|
+
maxOutputBytes: 8 * 1_024,
|
|
2542
|
+
},
|
|
2543
|
+
);
|
|
2544
|
+
// Even a zero exit is followed by an exact-name absence proof. This closes
|
|
2545
|
+
// daemon/client races and detects a same-name replacement before cleanup
|
|
2546
|
+
// can be declared settled.
|
|
2547
|
+
if (await engineLoginContainerIsAbsent(containerName, exec)) return;
|
|
2548
|
+
}
|
|
2549
|
+
throw new Error("login container cleanup could not be confirmed");
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
async function engineLoginContainerIsAbsent(
|
|
2553
|
+
containerName: string,
|
|
2554
|
+
exec: EngineLoginDockerExec,
|
|
2555
|
+
): Promise<boolean> {
|
|
2556
|
+
if (engineLoginBackend().apple) {
|
|
2557
|
+
const inspected = await exec(["inspect", containerName], {
|
|
2558
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
2559
|
+
maxOutputBytes: 256 * 1_024,
|
|
2560
|
+
});
|
|
2561
|
+
return appleReportsAbsent(inspected, containerName);
|
|
2562
|
+
}
|
|
2563
|
+
const inventory = await exec(
|
|
2564
|
+
[
|
|
2565
|
+
"container",
|
|
2566
|
+
"ls",
|
|
2567
|
+
"--all",
|
|
2568
|
+
"--quiet",
|
|
2569
|
+
"--filter",
|
|
2570
|
+
`name=^/${containerName}$`,
|
|
2571
|
+
],
|
|
2572
|
+
{
|
|
2573
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
2574
|
+
maxOutputBytes: 8 * 1_024,
|
|
2575
|
+
},
|
|
2576
|
+
);
|
|
2577
|
+
return inventory.status === 0 && inventory.stdout.trim() === "";
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/** Bounded boot sweep for containers left by a killed host or daemon blip. */
|
|
2581
|
+
export async function reconcileStaleEngineLoginContainers(
|
|
2582
|
+
exec: EngineLoginDockerExec = defaultEngineLoginDockerExec,
|
|
2583
|
+
): Promise<void> {
|
|
2584
|
+
let names: string[] | null = null;
|
|
2585
|
+
const apple = engineLoginBackend().apple;
|
|
2586
|
+
for (let attempt = 0; attempt < DOCKER_CLEANUP_ATTEMPTS; attempt += 1) {
|
|
2587
|
+
if (apple) {
|
|
2588
|
+
// No server-side label filter exists; list everything and select by the
|
|
2589
|
+
// ownership label client-side (the container id IS its name).
|
|
2590
|
+
const inventory = await exec(["list", "--all", "--format", "json"], {
|
|
2591
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
2592
|
+
maxOutputBytes: 4 * 1_024 * 1_024,
|
|
2593
|
+
});
|
|
2594
|
+
if (inventory.status !== 0) continue;
|
|
2595
|
+
try {
|
|
2596
|
+
const parsed = JSON.parse(inventory.stdout) as Array<{
|
|
2597
|
+
configuration?: { id?: unknown; labels?: Record<string, unknown> };
|
|
2598
|
+
}>;
|
|
2599
|
+
names = parsed
|
|
2600
|
+
.filter(
|
|
2601
|
+
(item) =>
|
|
2602
|
+
item.configuration?.labels?.[ENGINE_LOGIN_CONTAINER_LABEL] ===
|
|
2603
|
+
ENGINE_LOGIN_CONTAINER_LABEL_VALUE,
|
|
2604
|
+
)
|
|
2605
|
+
.map((item) => item.configuration?.id)
|
|
2606
|
+
.filter((id): id is string => typeof id === "string");
|
|
2607
|
+
} catch {
|
|
2608
|
+
continue;
|
|
2609
|
+
}
|
|
2610
|
+
break;
|
|
2611
|
+
}
|
|
2612
|
+
const inventory = await exec(
|
|
2613
|
+
[
|
|
2614
|
+
"container",
|
|
2615
|
+
"ls",
|
|
2616
|
+
"--all",
|
|
2617
|
+
"--format",
|
|
2618
|
+
"{{.Names}}",
|
|
2619
|
+
"--filter",
|
|
2620
|
+
`label=${ENGINE_LOGIN_CONTAINER_LABEL}=${ENGINE_LOGIN_CONTAINER_LABEL_VALUE}`,
|
|
2621
|
+
],
|
|
2622
|
+
{
|
|
2623
|
+
timeoutMs: DOCKER_CLEANUP_TIMEOUT_MS,
|
|
2624
|
+
maxOutputBytes: 64 * 1_024,
|
|
2625
|
+
},
|
|
2626
|
+
);
|
|
2627
|
+
if (inventory.status !== 0) continue;
|
|
2628
|
+
names = inventory.stdout
|
|
2629
|
+
.split("\n")
|
|
2630
|
+
.map((name) => name.trim())
|
|
2631
|
+
.filter(Boolean);
|
|
2632
|
+
break;
|
|
2633
|
+
}
|
|
2634
|
+
if (names === null) {
|
|
2635
|
+
throw new Error("stale login container inventory unavailable");
|
|
2636
|
+
}
|
|
2637
|
+
if (
|
|
2638
|
+
names.length > MAX_STALE_LOGIN_CONTAINERS ||
|
|
2639
|
+
names.some((name) => !isEngineLoginContainerName(name))
|
|
2640
|
+
) {
|
|
2641
|
+
throw new Error("stale login container inventory is invalid");
|
|
2642
|
+
}
|
|
2643
|
+
for (const name of names) await removeEngineLoginContainer(name, exec);
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
function engineLoginTempMarkerBody(name: string): string {
|
|
2647
|
+
return `${ENGINE_LOGIN_TEMP_MARKER_VERSION}:${name}\n`;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
/** Create the private login workspace and its no-follow ownership marker. */
|
|
2651
|
+
export async function createEngineLoginTempDir(
|
|
2652
|
+
requestedRoot?: string,
|
|
2653
|
+
): Promise<string> {
|
|
2654
|
+
const root = requestedRoot ?? tmpdir();
|
|
2655
|
+
if (requestedRoot !== undefined) await ensurePrivateEngineLoginTempRoot(root);
|
|
2656
|
+
const path = await mkdtemp(join(root, "uai-engine-login-"));
|
|
2657
|
+
let marker: Awaited<ReturnType<typeof open>> | null = null;
|
|
2658
|
+
try {
|
|
2659
|
+
const name = basename(path);
|
|
2660
|
+
if (!ENGINE_LOGIN_TEMP_DIR_PATTERN.test(name)) {
|
|
2661
|
+
throw new Error("login temp directory name is invalid");
|
|
2662
|
+
}
|
|
2663
|
+
await chmod(path, 0o700);
|
|
2664
|
+
marker = await open(join(path, ENGINE_LOGIN_TEMP_MARKER), "wx", 0o600);
|
|
2665
|
+
await marker.writeFile(engineLoginTempMarkerBody(name), "utf8");
|
|
2666
|
+
await marker.sync();
|
|
2667
|
+
await marker.close();
|
|
2668
|
+
marker = null;
|
|
2669
|
+
// The container is told HOME and CODEX_HOME live under the leaf, and
|
|
2670
|
+
// codex refuses to start when CODEX_HOME does not exist (claude
|
|
2671
|
+
// self-creates its HOME). Create both up front.
|
|
2672
|
+
await mkdir(join(path, "home"), { mode: 0o700 });
|
|
2673
|
+
await mkdir(join(path, "codex"), { mode: 0o700 });
|
|
2674
|
+
await mkdir(join(path, "grok"), { mode: 0o700 });
|
|
2675
|
+
await syncDirectory(path);
|
|
2676
|
+
return path;
|
|
2677
|
+
} catch (error) {
|
|
2678
|
+
if (marker) await marker.close().catch(() => undefined);
|
|
2679
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
2680
|
+
throw error;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
/** Prepare only the dedicated bind leaf, never a broader Uai/home directory. */
|
|
2685
|
+
async function ensurePrivateEngineLoginTempRoot(root: string): Promise<void> {
|
|
2686
|
+
if (
|
|
2687
|
+
!root.startsWith("/") ||
|
|
2688
|
+
resolve(root) !== root ||
|
|
2689
|
+
root.length <= 1 ||
|
|
2690
|
+
root.length > 4_096 ||
|
|
2691
|
+
/[\0\r\n]/.test(root)
|
|
2692
|
+
) {
|
|
2693
|
+
throw new Error("login temp root is invalid");
|
|
2694
|
+
}
|
|
2695
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
2696
|
+
const entry = await lstat(root);
|
|
2697
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
2698
|
+
if (
|
|
2699
|
+
!entry.isDirectory() ||
|
|
2700
|
+
entry.isSymbolicLink() ||
|
|
2701
|
+
(uid !== null && entry.uid !== uid) ||
|
|
2702
|
+
(await realpath(root)) !== root
|
|
2703
|
+
) {
|
|
2704
|
+
throw new Error("login temp root is unsafe");
|
|
2705
|
+
}
|
|
2706
|
+
await chmod(root, 0o700);
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
export interface EngineLoginTempReconcileOptions {
|
|
2710
|
+
readonly tempRoot?: string;
|
|
2711
|
+
readonly currentUid?: number | null;
|
|
2712
|
+
readonly readNames?: (root: string) => Promise<string[]>;
|
|
2713
|
+
readonly statEntry?: (path: string) => Promise<{
|
|
2714
|
+
isDirectory(): boolean;
|
|
2715
|
+
isSymbolicLink(): boolean;
|
|
2716
|
+
readonly uid: number;
|
|
2717
|
+
}>;
|
|
2718
|
+
readonly markerIsValid?: (
|
|
2719
|
+
path: string,
|
|
2720
|
+
name: string,
|
|
2721
|
+
currentUid: number | null,
|
|
2722
|
+
) => Promise<boolean>;
|
|
2723
|
+
readonly removeEntry?: (path: string) => Promise<void>;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
/**
|
|
2727
|
+
* Remove only direct, exact mkdtemp-shaped directories owned by this service
|
|
2728
|
+
* user. Symlinks, foreign-owned directories, and unrelated `/tmp` entries are
|
|
2729
|
+
* deliberately untouched. Callers must reconcile labeled containers first so
|
|
2730
|
+
* no still-live login process can own one of these mounts.
|
|
2731
|
+
*/
|
|
2732
|
+
export async function reconcileStaleEngineLoginTempDirs(
|
|
2733
|
+
options: EngineLoginTempReconcileOptions = {},
|
|
2734
|
+
): Promise<void> {
|
|
2735
|
+
const root = options.tempRoot ?? tmpdir();
|
|
2736
|
+
// Injected inventory seams own their synthetic root metadata. Production
|
|
2737
|
+
// reconciliation always uses the real reader and validates the exact leaf.
|
|
2738
|
+
if (options.tempRoot !== undefined && options.readNames === undefined) {
|
|
2739
|
+
await ensurePrivateEngineLoginTempRoot(root);
|
|
2740
|
+
}
|
|
2741
|
+
const names = await (options.readNames ?? ((path) => readdir(path)))(root);
|
|
2742
|
+
const candidates = names.filter((name) =>
|
|
2743
|
+
ENGINE_LOGIN_TEMP_DIR_PATTERN.test(name),
|
|
2744
|
+
);
|
|
2745
|
+
const uid =
|
|
2746
|
+
options.currentUid === undefined
|
|
2747
|
+
? typeof process.getuid === "function"
|
|
2748
|
+
? process.getuid()
|
|
2749
|
+
: null
|
|
2750
|
+
: options.currentUid;
|
|
2751
|
+
const statEntry = options.statEntry ?? lstat;
|
|
2752
|
+
const markerIsValid = options.markerIsValid ?? engineLoginTempMarkerIsValid;
|
|
2753
|
+
const removeEntry =
|
|
2754
|
+
options.removeEntry ?? ((path: string) => rm(path, { recursive: true, force: true }));
|
|
2755
|
+
|
|
2756
|
+
const owned: string[] = [];
|
|
2757
|
+
for (const name of candidates) {
|
|
2758
|
+
const path = join(root, name);
|
|
2759
|
+
let stat: Awaited<ReturnType<typeof statEntry>>;
|
|
2760
|
+
try {
|
|
2761
|
+
stat = await statEntry(path);
|
|
2762
|
+
} catch (error) {
|
|
2763
|
+
if (isNodeErrorCode(error, "ENOENT")) continue;
|
|
2764
|
+
throw error;
|
|
2765
|
+
}
|
|
2766
|
+
if (
|
|
2767
|
+
stat.isSymbolicLink() ||
|
|
2768
|
+
!stat.isDirectory() ||
|
|
2769
|
+
(uid !== null && stat.uid !== uid)
|
|
2770
|
+
) {
|
|
2771
|
+
continue;
|
|
2772
|
+
}
|
|
2773
|
+
if (!(await markerIsValid(path, name, uid))) continue;
|
|
2774
|
+
owned.push(path);
|
|
2775
|
+
}
|
|
2776
|
+
if (owned.length > MAX_STALE_LOGIN_TEMP_DIRS) {
|
|
2777
|
+
throw new Error("stale login temp inventory is invalid");
|
|
2778
|
+
}
|
|
2779
|
+
for (const path of owned) await removeEntry(path);
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
async function engineLoginTempMarkerIsValid(
|
|
2783
|
+
path: string,
|
|
2784
|
+
name: string,
|
|
2785
|
+
currentUid: number | null,
|
|
2786
|
+
): Promise<boolean> {
|
|
2787
|
+
const markerPath = join(path, ENGINE_LOGIN_TEMP_MARKER);
|
|
2788
|
+
let before: Awaited<ReturnType<typeof lstat>>;
|
|
2789
|
+
try {
|
|
2790
|
+
before = await lstat(markerPath);
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
if (isNodeErrorCode(error, "ENOENT")) return false;
|
|
2793
|
+
throw error;
|
|
2794
|
+
}
|
|
2795
|
+
const expected = Buffer.from(engineLoginTempMarkerBody(name), "utf8");
|
|
2796
|
+
if (
|
|
2797
|
+
before.isSymbolicLink() ||
|
|
2798
|
+
!before.isFile() ||
|
|
2799
|
+
before.size !== expected.byteLength ||
|
|
2800
|
+
(before.mode & 0o777) !== 0o600 ||
|
|
2801
|
+
(currentUid !== null && before.uid !== currentUid)
|
|
2802
|
+
) {
|
|
2803
|
+
return false;
|
|
2804
|
+
}
|
|
2805
|
+
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
|
2806
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
2807
|
+
try {
|
|
2808
|
+
handle = await open(markerPath, constants.O_RDONLY | noFollow);
|
|
2809
|
+
} catch (error) {
|
|
2810
|
+
if (isNodeErrorCode(error, "ENOENT") || isNodeErrorCode(error, "ELOOP")) {
|
|
2811
|
+
return false;
|
|
2812
|
+
}
|
|
2813
|
+
throw error;
|
|
2814
|
+
}
|
|
2815
|
+
try {
|
|
2816
|
+
const opened = await handle.stat();
|
|
2817
|
+
if (
|
|
2818
|
+
!opened.isFile() ||
|
|
2819
|
+
opened.dev !== before.dev ||
|
|
2820
|
+
opened.ino !== before.ino ||
|
|
2821
|
+
opened.size !== expected.byteLength ||
|
|
2822
|
+
(opened.mode & 0o777) !== 0o600 ||
|
|
2823
|
+
(currentUid !== null && opened.uid !== currentUid)
|
|
2824
|
+
) {
|
|
2825
|
+
return false;
|
|
2826
|
+
}
|
|
2827
|
+
const body = await handle.readFile();
|
|
2828
|
+
return body.equals(expected);
|
|
2829
|
+
} finally {
|
|
2830
|
+
await handle.close();
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
/** Container absence is proven before any secret-bearing temp is removed. */
|
|
2835
|
+
export async function reconcileStaleEngineLoginResources(
|
|
2836
|
+
options: EngineLoginTempReconcileOptions = {},
|
|
2837
|
+
): Promise<void> {
|
|
2838
|
+
await reconcileStaleEngineLoginContainers();
|
|
2839
|
+
await reconcileStaleEngineLoginTempDirs(options);
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
function isNodeErrorCode(error: unknown, code: string): boolean {
|
|
2843
|
+
return (
|
|
2844
|
+
error instanceof Error &&
|
|
2845
|
+
"code" in error &&
|
|
2846
|
+
(error as NodeJS.ErrnoException).code === code
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
async function readBoundedRegularFile(
|
|
2851
|
+
file: string,
|
|
2852
|
+
maxBytes: number,
|
|
2853
|
+
): Promise<Buffer> {
|
|
2854
|
+
const before = await lstat(file);
|
|
2855
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes) {
|
|
2856
|
+
throw new Error("credential output is not a bounded regular file");
|
|
2857
|
+
}
|
|
2858
|
+
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
|
2859
|
+
const handle = await open(file, constants.O_RDONLY | noFollow);
|
|
2860
|
+
try {
|
|
2861
|
+
const opened = await handle.stat();
|
|
2862
|
+
if (!opened.isFile() || opened.size < 1 || opened.size > maxBytes) {
|
|
2863
|
+
throw new Error("credential output is not a bounded regular file");
|
|
2864
|
+
}
|
|
2865
|
+
const body = await handle.readFile();
|
|
2866
|
+
if (body.byteLength < 1 || body.byteLength > maxBytes) {
|
|
2867
|
+
throw new Error("credential output exceeds its size limit");
|
|
2868
|
+
}
|
|
2869
|
+
return body;
|
|
2870
|
+
} finally {
|
|
2871
|
+
await handle.close();
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
async function writeSensitiveFileAtomic(file: string, body: Buffer): Promise<void> {
|
|
2876
|
+
const parent = dirname(file);
|
|
2877
|
+
await ensureSensitiveParent(parent);
|
|
2878
|
+
const temporary = join(
|
|
2879
|
+
parent,
|
|
2880
|
+
`.auth.json.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
|
|
2881
|
+
);
|
|
2882
|
+
let handle: Awaited<ReturnType<typeof open>> | null = null;
|
|
2883
|
+
try {
|
|
2884
|
+
handle = await open(temporary, "wx", 0o600);
|
|
2885
|
+
await handle.writeFile(body);
|
|
2886
|
+
await handle.sync();
|
|
2887
|
+
await handle.close();
|
|
2888
|
+
handle = null;
|
|
2889
|
+
await rename(temporary, file);
|
|
2890
|
+
await chmod(file, 0o600);
|
|
2891
|
+
await syncDirectory(parent);
|
|
2892
|
+
} finally {
|
|
2893
|
+
if (handle) await handle.close().catch(() => undefined);
|
|
2894
|
+
await unlink(temporary).catch((error: NodeJS.ErrnoException) => {
|
|
2895
|
+
if (error.code !== "ENOENT") throw error;
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
async function ensureSensitiveParent(parent: string): Promise<void> {
|
|
2901
|
+
try {
|
|
2902
|
+
const current = await lstat(parent);
|
|
2903
|
+
if (!current.isDirectory() || current.isSymbolicLink()) {
|
|
2904
|
+
throw new Error("credential directory is not a local directory");
|
|
2905
|
+
}
|
|
2906
|
+
return;
|
|
2907
|
+
} catch (error) {
|
|
2908
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
2912
|
+
// Re-check after creation. This catches an existing symlink followed by a
|
|
2913
|
+
// recursive mkdir and a same-user replacement racing the initial ENOENT.
|
|
2914
|
+
const created = await lstat(parent);
|
|
2915
|
+
if (!created.isDirectory() || created.isSymbolicLink()) {
|
|
2916
|
+
throw new Error("credential directory is not a local directory");
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
async function syncDirectory(directory: string): Promise<void> {
|
|
2921
|
+
let handle: Awaited<ReturnType<typeof open>> | null = null;
|
|
2922
|
+
try {
|
|
2923
|
+
handle = await open(directory, "r");
|
|
2924
|
+
await handle.sync();
|
|
2925
|
+
} catch (error) {
|
|
2926
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
2927
|
+
if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") {
|
|
2928
|
+
throw error;
|
|
2929
|
+
}
|
|
2930
|
+
} finally {
|
|
2931
|
+
if (handle) await handle.close();
|
|
2932
|
+
}
|
|
2933
|
+
}
|