@moondeck/station 0.0.1 → 1.0.1
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/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +230 -0
- package/db/schema.ts +871 -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 +1470 -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 +937 -0
- package/lib/env.ts +195 -0
- package/lib/first-turn-receipts.ts +62 -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 +115 -8
- 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/bridge-diagnostics.ts +39 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2585 -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/enrollment-next-steps.ts +24 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2907 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6363 -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 +5550 -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
- package/bin/moondeck-station.mjs +0 -3
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cloud (WSS bridge) connection state for the local UI (ADR-028).
|
|
3
|
+
*
|
|
4
|
+
* The bridge client in `src/main.ts` is the single writer; the local
|
|
5
|
+
* `/api/cloud` handler is a reader. Module-level singleton — there is exactly
|
|
6
|
+
* one bridge connection per host process. Never persisted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type CloudConnectionState =
|
|
10
|
+
| "connected"
|
|
11
|
+
| "reconnecting"
|
|
12
|
+
| "disconnected";
|
|
13
|
+
|
|
14
|
+
export interface CloudState {
|
|
15
|
+
state: CloudConnectionState;
|
|
16
|
+
/** Epoch ms of the last successful connect, or null if never connected. */
|
|
17
|
+
lastConnectedAt: number | null;
|
|
18
|
+
/** Count of reconnect attempts since process start. */
|
|
19
|
+
reconnectCount: number;
|
|
20
|
+
/** Most recent connection error message, or null. */
|
|
21
|
+
lastError: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const state: CloudState = {
|
|
25
|
+
state: "disconnected",
|
|
26
|
+
lastConnectedAt: null,
|
|
27
|
+
reconnectCount: 0,
|
|
28
|
+
lastError: null,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Snapshot for `/api/cloud`. */
|
|
32
|
+
export function getCloudState(): CloudState {
|
|
33
|
+
return { ...state };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function markConnected(): void {
|
|
37
|
+
state.state = "connected";
|
|
38
|
+
state.lastConnectedAt = Date.now();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The socket dropped and we will retry. Increments the reconnect counter. */
|
|
42
|
+
export function markReconnecting(error?: string | null): void {
|
|
43
|
+
state.state = "reconnecting";
|
|
44
|
+
state.reconnectCount += 1;
|
|
45
|
+
if (error !== undefined) state.lastError = error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Stopped for good (shutdown or fatal auth failure) — no retry. */
|
|
49
|
+
export function markDisconnected(error?: string | null): void {
|
|
50
|
+
state.state = "disconnected";
|
|
51
|
+
if (error !== undefined) state.lastError = error;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function markCloudError(message: string): void {
|
|
55
|
+
state.lastError = message;
|
|
56
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex credential re-injection — the symmetric partner to the GitHub reinject
|
|
3
|
+
* in {@link ./github-tokens} (ADR-027/033 → host 0.8.1).
|
|
4
|
+
*
|
|
5
|
+
* The host owner's `~/.codex` is docker-cp'd into each task container ONCE, at
|
|
6
|
+
* task-up (`scripts/agent/task-up.sh`). When the owner re-logs into Codex,
|
|
7
|
+
* `codex login` revokes and rewrites `~/.codex/auth.json` on the HOST —
|
|
8
|
+
* already-running containers keep their stale copy, so a re-login otherwise
|
|
9
|
+
* only helps NEW tasks. The live sweep refreshes the credential-bearing copy
|
|
10
|
+
* while deliberately preserving the container's `config.toml`: browser/MCP
|
|
11
|
+
* setup owns that live file and serializes its updates with agent spawning.
|
|
12
|
+
*
|
|
13
|
+
* That revoke-then-rewrite is CLIENT-SIDE and scoped to the LOCAL codex_home —
|
|
14
|
+
* not a server-side "one session per account" policy. `codex login` calls
|
|
15
|
+
* `clear_existing_auth_before_login` (openai/codex `cli/src/login.rs`), which
|
|
16
|
+
* revokes only whatever token `revocable_token()` (`login/src/auth/revoke.rs`)
|
|
17
|
+
* finds in THIS SAME codex_home's `auth.json` via `logout_with_revoke`
|
|
18
|
+
* (`login/src/auth/manager.rs`) — it never looks at, let alone reaches, another
|
|
19
|
+
* login's session. Separate logins are independent authorization_code+PKCE
|
|
20
|
+
* grants with independent refresh lineages (source analysis corroborated by a
|
|
21
|
+
* community-verified workaround, not an empirical run — full writeup:
|
|
22
|
+
* `docs/pricing-economics.md` R8/R10).
|
|
23
|
+
*
|
|
24
|
+
* What DOES matter for concurrency: refresh rotation is single-use, and a
|
|
25
|
+
* reused refresh token comes back `refresh_token_reused` — a TERMINAL
|
|
26
|
+
* `Exhausted` failure the client never retries. This file's docker-cp pattern
|
|
27
|
+
* already gives every task container its own independent filesystem copy of
|
|
28
|
+
* ONE lineage with nothing to reconcile them, so the instant any one
|
|
29
|
+
* container's own `codex` process refreshes, every sibling copy is a stale
|
|
30
|
+
* member of a now-rotated family and its next refresh is terminal, not
|
|
31
|
+
* self-healing — containers must never refresh this credential themselves.
|
|
32
|
+
*
|
|
33
|
+
* Three triggers, covering every path:
|
|
34
|
+
* - {@link watchCodexAuth} — fs.watch on `~/.codex`, for `codex login` run
|
|
35
|
+
* from a terminal while the host stays up.
|
|
36
|
+
* - {@link reinjectCodexRunningTasks} at host start — for the desktop
|
|
37
|
+
* "Connect Codex" flow, which writes `~/.codex` then restarts the host
|
|
38
|
+
* (durable sessions reattach to containers that still hold the old creds).
|
|
39
|
+
* Sequenced AFTER boot recovery (main.ts awaits recoveryComplete()).
|
|
40
|
+
* - {@link injectCodexIntoContainer} — recovery calls it right after
|
|
41
|
+
* `docker start` of an exited container, before agents can respawn.
|
|
42
|
+
*
|
|
43
|
+
* Copies go ONLY into containers docker confirms are RUNNING. A DB-active row
|
|
44
|
+
* can be a stopped container (boot, pre-recovery), and `docker cp` into a
|
|
45
|
+
* stopped container SUCCEEDS while the `docker exec … chown` that follows
|
|
46
|
+
* cannot run — stranding auth.json/config.toml mode 0600 owned by the host
|
|
47
|
+
* uid (macOS 501:20), unreadable to the container's `node` (1000:1000), and
|
|
48
|
+
* Codex dies at startup. Every docker step's exit status is checked; failures
|
|
49
|
+
* surface as actionable errors (never credential contents — only file NAMES
|
|
50
|
+
* and docker stderr are logged).
|
|
51
|
+
*
|
|
52
|
+
* Codex auth is host-wide (the operator's single `~/.codex`, not per-user), so
|
|
53
|
+
* this targets ALL running tasks — matching task-up, which copies it into every
|
|
54
|
+
* container regardless of roster.
|
|
55
|
+
*/
|
|
56
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
57
|
+
import { homedir, tmpdir } from "node:os";
|
|
58
|
+
import { dirname, join } from "node:path";
|
|
59
|
+
|
|
60
|
+
import { isNull } from "drizzle-orm";
|
|
61
|
+
|
|
62
|
+
import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
63
|
+
import { getDb, schema } from "./db";
|
|
64
|
+
import { dockerCli } from "./docker-exec";
|
|
65
|
+
import { brokeredFilesFor } from "./token-broker";
|
|
66
|
+
import {
|
|
67
|
+
listRunningTaskAppContainerNames,
|
|
68
|
+
taskAppContainerName,
|
|
69
|
+
taskContainerCli,
|
|
70
|
+
} from "./task-container-cli";
|
|
71
|
+
import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
|
|
72
|
+
|
|
73
|
+
/** The exact set task-up.sh copies into `/home/node/.codex`. */
|
|
74
|
+
const CODEX_ITEMS = [
|
|
75
|
+
"auth.json",
|
|
76
|
+
"config.toml",
|
|
77
|
+
"AGENTS.md",
|
|
78
|
+
"version.json",
|
|
79
|
+
"installation_id",
|
|
80
|
+
"rules",
|
|
81
|
+
] as const;
|
|
82
|
+
/** Live auth refresh must not overwrite MCP configuration in a running task. */
|
|
83
|
+
const LIVE_CODEX_ITEMS = CODEX_ITEMS.filter((item) => item !== "config.toml");
|
|
84
|
+
const EXEC_TIMEOUT_MS = 15_000;
|
|
85
|
+
|
|
86
|
+
/** Injectable seams (defaults hit the real DB / docker / fs) so the copy logic
|
|
87
|
+
* is testable without either. */
|
|
88
|
+
export interface CodexDeps {
|
|
89
|
+
exec?: (
|
|
90
|
+
args: string[],
|
|
91
|
+
) => Promise<{ status: number | null; stdout: string; stderr: string }>;
|
|
92
|
+
/** DB-active task containers — may include stopped ones; docker decides. */
|
|
93
|
+
activeTaskContainers?: () => string[];
|
|
94
|
+
fileExists?: (path: string) => boolean;
|
|
95
|
+
/** Reads the host's auth.json for brokering (tests inject; never the real one). */
|
|
96
|
+
readFile?: (path: string) => string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ownerCodexDir(): string {
|
|
100
|
+
return join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const defaultExec: NonNullable<CodexDeps["exec"]> = async (args) => {
|
|
104
|
+
const res = await taskContainerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
|
|
105
|
+
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function defaultActiveTaskContainers(): string[] {
|
|
109
|
+
return getDb()
|
|
110
|
+
.select({ taskId: schema.hostTasks.taskId })
|
|
111
|
+
.from(schema.hostTasks)
|
|
112
|
+
.where(isNull(schema.hostTasks.endedAt))
|
|
113
|
+
.all()
|
|
114
|
+
.map((r) => taskAppContainerName(r.taskId));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The containers docker reports as RUNNING right now, or null when docker
|
|
119
|
+
* itself didn't answer — the caller must then skip injection rather than
|
|
120
|
+
* docker-cp into containers of unknown state.
|
|
121
|
+
*/
|
|
122
|
+
async function dockerRunningNames(
|
|
123
|
+
exec: NonNullable<CodexDeps["exec"]>,
|
|
124
|
+
): Promise<Set<string> | null> {
|
|
125
|
+
// The list verb differs per backend (docker ps vs apple container list);
|
|
126
|
+
// the shared helper owns that translation. The injected exec seam is kept
|
|
127
|
+
// for tests, which script docker-shaped ps answers.
|
|
128
|
+
if (exec === defaultExec) {
|
|
129
|
+
const names = await listRunningTaskAppContainerNames();
|
|
130
|
+
return names === null ? null : new Set(names);
|
|
131
|
+
}
|
|
132
|
+
const res = await exec(["ps", "--filter", "status=running", "--format", "{{.Names}}"]);
|
|
133
|
+
if (res.status !== 0) return null;
|
|
134
|
+
return new Set(
|
|
135
|
+
res.stdout
|
|
136
|
+
.split("\n")
|
|
137
|
+
.map((l) => l.trim())
|
|
138
|
+
.filter(Boolean),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Re-copy `~/.codex/<items>` into one container — mirrors task-up.sh exactly:
|
|
144
|
+
* ensure the dir (root), cp each item that exists, chown back to node:node
|
|
145
|
+
* (docker cp preserves the HOST uid/gid, and 0600 files owned by macOS
|
|
146
|
+
* 501:20 are unreadable to the container's node). Any nonzero step throws
|
|
147
|
+
* with the step name and docker's stderr — never file contents.
|
|
148
|
+
*/
|
|
149
|
+
async function copyCodexInto(
|
|
150
|
+
container: string,
|
|
151
|
+
deps: CodexDeps,
|
|
152
|
+
items: readonly (typeof CODEX_ITEMS)[number][] = CODEX_ITEMS,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const exec = deps.exec ?? defaultExec;
|
|
155
|
+
const exists = deps.fileExists ?? existsSync;
|
|
156
|
+
const dir = ownerCodexDir();
|
|
157
|
+
const must = async (args: string[], step: string): Promise<void> => {
|
|
158
|
+
const res = await exec(args);
|
|
159
|
+
if (res.status !== 0) {
|
|
160
|
+
const detail = res.stderr.trim().slice(0, 200);
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${step} failed (${res.status === null ? "docker timeout" : `exit ${res.status}`})` +
|
|
163
|
+
(detail ? `: ${detail}` : ""),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
await must(
|
|
168
|
+
[
|
|
169
|
+
"exec",
|
|
170
|
+
"-u",
|
|
171
|
+
"root",
|
|
172
|
+
...runtimeAuthorityDockerExecArgs(),
|
|
173
|
+
container,
|
|
174
|
+
"/bin/mkdir",
|
|
175
|
+
"-p",
|
|
176
|
+
"/home/node/.codex",
|
|
177
|
+
],
|
|
178
|
+
"mkdir /home/node/.codex",
|
|
179
|
+
);
|
|
180
|
+
for (const item of items) {
|
|
181
|
+
const src = join(dir, item);
|
|
182
|
+
if (!exists(src)) continue;
|
|
183
|
+
// ADR-135: the task's copy never carries the rotating refresh token — N
|
|
184
|
+
// copies racing to refresh it revoke the whole family. cp needs a real
|
|
185
|
+
// file, so write the brokered variant to a private temp file and cp that.
|
|
186
|
+
if (item === "auth.json") {
|
|
187
|
+
let brokered: string | null = null;
|
|
188
|
+
try {
|
|
189
|
+
const raw = (deps.readFile ?? ((p: string) => readFileSync(p, "utf8")))(src);
|
|
190
|
+
const out = brokeredFilesFor(
|
|
191
|
+
{ kind: "codex", authKind: "config-dir", id: "default", scope: "host" },
|
|
192
|
+
{ "auth.json": raw },
|
|
193
|
+
);
|
|
194
|
+
brokered = out["auth.json"] !== raw ? out["auth.json"]! : null;
|
|
195
|
+
} catch {
|
|
196
|
+
brokered = null;
|
|
197
|
+
}
|
|
198
|
+
if (brokered !== null) {
|
|
199
|
+
const tmp = join(mkdtempSync(join(tmpdir(), "uai-codex-auth-")), "auth.json");
|
|
200
|
+
writeFileSync(tmp, brokered, { mode: 0o600 });
|
|
201
|
+
try {
|
|
202
|
+
await must(["cp", tmp, `${container}:/home/node/.codex/auth.json`], "docker cp auth.json (brokered)");
|
|
203
|
+
} finally {
|
|
204
|
+
rmSync(dirname(tmp), { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
await must(["cp", src, `${container}:/home/node/.codex/`], `docker cp ${item}`);
|
|
210
|
+
}
|
|
211
|
+
await must(
|
|
212
|
+
[
|
|
213
|
+
"exec",
|
|
214
|
+
"-u",
|
|
215
|
+
"root",
|
|
216
|
+
...runtimeAuthorityDockerExecArgs(),
|
|
217
|
+
container,
|
|
218
|
+
"/bin/chown",
|
|
219
|
+
"-R",
|
|
220
|
+
"node:node",
|
|
221
|
+
"/home/node/.codex",
|
|
222
|
+
],
|
|
223
|
+
"chown -R node:node /home/node/.codex",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Inject the owner's `~/.codex` into ONE container known to be running.
|
|
229
|
+
* Recovery calls this immediately after `docker start`, BEFORE uai-init and
|
|
230
|
+
* any agent respawn, so a recovered container never runs Codex against
|
|
231
|
+
* host-uid-owned credentials. Returns false after logging an actionable
|
|
232
|
+
* error (no secrets) — recovery of the task itself must not fail over Codex.
|
|
233
|
+
*/
|
|
234
|
+
export async function injectCodexIntoContainer(
|
|
235
|
+
container: string,
|
|
236
|
+
deps: CodexDeps = {},
|
|
237
|
+
): Promise<boolean> {
|
|
238
|
+
const exists = deps.fileExists ?? existsSync;
|
|
239
|
+
if (!exists(join(ownerCodexDir(), "auth.json"))) return true; // nothing to inject
|
|
240
|
+
try {
|
|
241
|
+
// ADR-154: no default slot once the runner begins leaving its own
|
|
242
|
+
// credentials; leaving waits for a copy under way.
|
|
243
|
+
await whileOnOwnCredentials(() => copyCodexInto(container, deps));
|
|
244
|
+
return true;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error(
|
|
247
|
+
`[codex] inject into ${container}: ${err instanceof Error ? err.message : err} — ` +
|
|
248
|
+
`Codex in this task will fail until fixed (run: docker exec -u root ${container} ` +
|
|
249
|
+
`chown -R node:node /home/node/.codex, or restart the host)`,
|
|
250
|
+
);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* ADR-121: deliver the DEFAULT Codex slot into a MACHINE task over the
|
|
257
|
+
* environment transport. The legacy paths (task-up docker cp, recovery
|
|
258
|
+
* inject, the reinject sweep) all speak docker grammar, so a machine task's
|
|
259
|
+
* codex agent ran without auth.json and looped 'Reconnecting' (live
|
|
260
|
+
* 2026-08-27, first EC2 task). Files land as the ssh user (node), so no
|
|
261
|
+
* chown pass is needed. Returns true when there was nothing to inject.
|
|
262
|
+
*/
|
|
263
|
+
export async function injectCodexIntoMachine(
|
|
264
|
+
environment: {
|
|
265
|
+
exec(request: {
|
|
266
|
+
argv: readonly [string, ...string[]];
|
|
267
|
+
projectEnvProfile: "maintenance" | "task";
|
|
268
|
+
timeoutMs: number;
|
|
269
|
+
maxOutputBytes: number;
|
|
270
|
+
}): Promise<{ exitCode: number | null; stderr: Uint8Array }>;
|
|
271
|
+
copy(request: {
|
|
272
|
+
direction: "into";
|
|
273
|
+
source: string;
|
|
274
|
+
destination: string;
|
|
275
|
+
}): Promise<void>;
|
|
276
|
+
},
|
|
277
|
+
deps: CodexDeps = {},
|
|
278
|
+
): Promise<boolean> {
|
|
279
|
+
// ADR-154: no default slot once the runner begins leaving its own
|
|
280
|
+
// credentials (a start on them holds its permit through this).
|
|
281
|
+
if (!onOwnCredentials()) return true;
|
|
282
|
+
const exists = deps.fileExists ?? existsSync;
|
|
283
|
+
const dir = ownerCodexDir();
|
|
284
|
+
if (!exists(join(dir, "auth.json"))) return true; // nothing to inject
|
|
285
|
+
try {
|
|
286
|
+
const made = await environment.exec({
|
|
287
|
+
projectEnvProfile: "maintenance",
|
|
288
|
+
argv: ["/bin/mkdir", "-p", "/home/node/.codex"],
|
|
289
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
290
|
+
maxOutputBytes: 64 * 1024,
|
|
291
|
+
});
|
|
292
|
+
if (made.exitCode !== 0) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`mkdir /home/node/.codex exited ${made.exitCode}: ${Buffer.from(made.stderr).toString("utf8").trim()}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
for (const item of CODEX_ITEMS) {
|
|
298
|
+
const source = join(dir, item);
|
|
299
|
+
if (!exists(source)) continue;
|
|
300
|
+
await environment.copy({
|
|
301
|
+
direction: "into",
|
|
302
|
+
source,
|
|
303
|
+
destination: `/home/node/.codex/${item}`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
307
|
+
} catch (err) {
|
|
308
|
+
console.error(
|
|
309
|
+
`[codex] machine inject: ${err instanceof Error ? err.message : err} — Codex in this task will fail until the next ensure retries`,
|
|
310
|
+
);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Refresh the freshly (re)logged-in Codex files in every task container that
|
|
317
|
+
* is both DB-active AND confirmed running by docker. `config.toml` is excluded:
|
|
318
|
+
* copying the host's file over a live task races the orchestrator's managed MCP
|
|
319
|
+
* writer and can erase the browser definition after readiness was established.
|
|
320
|
+
* Recovery/start injection still uses the full item set above, before agents
|
|
321
|
+
* can spawn. No-op when there is no `~/.codex/auth.json` or no active tasks;
|
|
322
|
+
* skipped entirely (with a warning) when docker doesn't answer. Per-container
|
|
323
|
+
* failures are surfaced and don't stop the sweep.
|
|
324
|
+
*/
|
|
325
|
+
export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
|
|
326
|
+
// ADR-154: no default slot once the runner begins leaving its own
|
|
327
|
+
// credentials. Leaving waits for a sweep under way, and no copy starts
|
|
328
|
+
// after it began.
|
|
329
|
+
await whileOnOwnCredentials(() => reinjectOwnCodex(deps));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function reinjectOwnCodex(deps: CodexDeps): Promise<void> {
|
|
333
|
+
const exists = deps.fileExists ?? existsSync;
|
|
334
|
+
if (!exists(join(ownerCodexDir(), "auth.json"))) return;
|
|
335
|
+
const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
|
|
336
|
+
if (active.length === 0) return;
|
|
337
|
+
const running = await dockerRunningNames(deps.exec ?? defaultExec);
|
|
338
|
+
if (running === null) {
|
|
339
|
+
console.warn(
|
|
340
|
+
"[codex] reinject skipped — docker didn't answer `docker ps`; will retry on the next trigger",
|
|
341
|
+
);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const targets = active.filter((c) => running.has(c));
|
|
345
|
+
const stopped = active.length - targets.length;
|
|
346
|
+
if (stopped > 0) {
|
|
347
|
+
console.log(
|
|
348
|
+
`[codex] ${stopped} active task(s) not running — skipped (recovery injects them at docker start)`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (targets.length === 0) return;
|
|
352
|
+
console.log(`[codex] refreshing auth in ${targets.length} running task(s)`);
|
|
353
|
+
for (const container of targets) {
|
|
354
|
+
if (!onOwnCredentials()) return;
|
|
355
|
+
try {
|
|
356
|
+
await copyCodexInto(container, deps, LIVE_CODEX_ITEMS);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
console.error(
|
|
359
|
+
`[codex] reinject into ${container} failed: ${err instanceof Error ? err.message : err}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let watcher: ReturnType<typeof watch> | null = null;
|
|
366
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Watch `~/.codex` for `auth.json` changes (a re-login) and re-inject into
|
|
370
|
+
* running tasks, debounced (a login writes several files). Idempotent; a
|
|
371
|
+
* missing `~/.codex` dir is a no-op — the host-start reinject already handles
|
|
372
|
+
* a first login that flips Codex available. UAI_CODEX_REINJECT=0 disables it.
|
|
373
|
+
*/
|
|
374
|
+
export function watchCodexAuth(canReinject: () => boolean = () => true): void {
|
|
375
|
+
if (watcher || process.env.UAI_CODEX_REINJECT === "0") return;
|
|
376
|
+
const dir = ownerCodexDir();
|
|
377
|
+
if (!existsSync(dir)) return;
|
|
378
|
+
try {
|
|
379
|
+
watcher = watch(dir, (_event, filename) => {
|
|
380
|
+
// filename is null on some platforms — then we can't tell, so proceed.
|
|
381
|
+
if (filename && filename !== "auth.json") return;
|
|
382
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
383
|
+
debounceTimer = setTimeout(() => {
|
|
384
|
+
if (canReinject()) void reinjectCodexRunningTasks();
|
|
385
|
+
}, 2_000);
|
|
386
|
+
debounceTimer.unref?.();
|
|
387
|
+
});
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.warn(
|
|
390
|
+
`[codex] could not watch ${dir}: ${err instanceof Error ? err.message : err}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Test/teardown hook. */
|
|
396
|
+
export function stopWatchingCodexAuth(): void {
|
|
397
|
+
watcher?.close();
|
|
398
|
+
watcher = null;
|
|
399
|
+
if (debounceTimer) {
|
|
400
|
+
clearTimeout(debounceTimer);
|
|
401
|
+
debounceTimer = null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* ADR-135: re-push the host's default Grok sign-in (`~/.grok/auth.json`,
|
|
407
|
+
* brokered) into every running task, after the owned-host loop refreshed it.
|
|
408
|
+
* Same sweep shape as the Codex reinject; only auth.json travels.
|
|
409
|
+
*/
|
|
410
|
+
export async function reinjectGrokRunningTasks(deps: CodexDeps = {}): Promise<void> {
|
|
411
|
+
// ADR-154: as for Codex, only while the runner is on its own credentials.
|
|
412
|
+
await whileOnOwnCredentials(() => reinjectOwnGrok(deps));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function reinjectOwnGrok(deps: CodexDeps): Promise<void> {
|
|
416
|
+
const exists = deps.fileExists ?? existsSync;
|
|
417
|
+
const src = join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".grok", "auth.json");
|
|
418
|
+
if (!exists(src)) return;
|
|
419
|
+
const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
|
|
420
|
+
if (active.length === 0) return;
|
|
421
|
+
const running = await dockerRunningNames(deps.exec ?? defaultExec);
|
|
422
|
+
if (running === null) return;
|
|
423
|
+
const exec = deps.exec ?? defaultExec;
|
|
424
|
+
let brokered: string;
|
|
425
|
+
try {
|
|
426
|
+
const raw = readFileSync(src, "utf8");
|
|
427
|
+
brokered = brokeredFilesFor(
|
|
428
|
+
{ kind: "grok", authKind: "config-dir", id: "default", scope: "host" },
|
|
429
|
+
{ "auth.json": raw },
|
|
430
|
+
)["auth.json"]!;
|
|
431
|
+
} catch {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const dir = mkdtempSync(join(tmpdir(), "uai-grok-auth-"));
|
|
435
|
+
const tmp = join(dir, "auth.json");
|
|
436
|
+
try {
|
|
437
|
+
writeFileSync(tmp, brokered, { mode: 0o600 });
|
|
438
|
+
for (const container of active.filter((c) => running.has(c))) {
|
|
439
|
+
if (!onOwnCredentials()) break;
|
|
440
|
+
const steps: Array<[string[], string]> = [
|
|
441
|
+
[["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/mkdir", "-p", "/home/node/.grok"], "mkdir"],
|
|
442
|
+
[["cp", tmp, `${container}:/home/node/.grok/auth.json`], "docker cp auth.json (brokered)"],
|
|
443
|
+
[["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/chown", "-R", "node:node", "/home/node/.grok"], "chown"],
|
|
444
|
+
];
|
|
445
|
+
for (const [args, step] of steps) {
|
|
446
|
+
const res = await exec(args);
|
|
447
|
+
if (res.status !== 0) {
|
|
448
|
+
console.error(`[grok] reinject into ${container}: ${step} failed (exit ${res.status ?? "none"})`);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} finally {
|
|
454
|
+
rmSync(dir, { recursive: true, force: true });
|
|
455
|
+
}
|
|
456
|
+
}
|