@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
package/lib/engines.ts
ADDED
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine connect/disconnect for the local host UI (ADR-028, ADR-044 P2).
|
|
3
|
+
*
|
|
4
|
+
* A single descriptor table for the AI coding engines the host can run
|
|
5
|
+
* (Claude, Codex, Kimi, Grok, Cursor) plus the connect/disconnect/status logic
|
|
6
|
+
* the local UI drives (the desktop app renders that same UI). It writes the
|
|
7
|
+
* `.env.local` the RUNNING launchd/npm host reads (UAI_HOME) and sets
|
|
8
|
+
* `process.env` so a connect takes effect WITHOUT a host restart — the
|
|
9
|
+
* adapters' `available()` and task-up's env injection both read `process.env`.
|
|
10
|
+
*
|
|
11
|
+
* CLI resolution: a host supervised by the desktop app (or launchd) inherits a
|
|
12
|
+
* minimal GUI PATH that lacks wherever the engine CLIs live (~/.local/bin,
|
|
13
|
+
* npm/nvm/asdf prefixes, Homebrew), so spawning a bare `claude`/`codex` would
|
|
14
|
+
* ENOENT even though the CLI is installed. We resolve binaries the way the
|
|
15
|
+
* owner's terminal would — well-known install dirs first, then the login
|
|
16
|
+
* shell's PATH (same trick as the desktop's docker.ts) — and spawn with that
|
|
17
|
+
* PATH so `#!/usr/bin/env node` shebangs resolve too.
|
|
18
|
+
*
|
|
19
|
+
* Three auth modes:
|
|
20
|
+
* - token-command (Claude): `claude setup-token` shows a token; we run the
|
|
21
|
+
* CLI under a PTY (script(1)), capture the `sk-ant-oat…`
|
|
22
|
+
* value live from the terminal stream and persist it as
|
|
23
|
+
* CLAUDE_CODE_OAUTH_TOKEN. A pasted token is accepted too.
|
|
24
|
+
* The PTY is load-bearing: the CLI's Ink UI buffers ALL
|
|
25
|
+
* output until unmount when stdout isn't a TTY and its
|
|
26
|
+
* final screens wait for a keypress — under plain pipes it
|
|
27
|
+
* emits nothing and never exits (the eternal "Connecting…"
|
|
28
|
+
* of 0.8.11). We resolve as soon as the token appears and
|
|
29
|
+
* kill the CLI rather than wait for it to end on its own.
|
|
30
|
+
* - login-command (Codex/Kimi/Grok): `<cli> login` runs a browser OAuth and
|
|
31
|
+
* writes a config file; success = that file appearing.
|
|
32
|
+
* - api-key (Cursor): no command — persist a pasted CURSOR_API_KEY.
|
|
33
|
+
*
|
|
34
|
+
* Besides its primary mode, an engine can accept a pasted API key as an
|
|
35
|
+
* alternative (`apiKeyHint`/`apiKeyUrl` in the catalog drive the UI):
|
|
36
|
+
* - claude → ANTHROPIC_API_KEY (the adapter already forwards it; a pasted
|
|
37
|
+
* `sk-ant-oat…` still lands in CLAUDE_CODE_OAUTH_TOKEN — classified by
|
|
38
|
+
* prefix, so either credential works in either paste box).
|
|
39
|
+
* - codex → ~/.codex/auth.json `{"OPENAI_API_KEY": …}`, the same plaintext
|
|
40
|
+
* file `codex login --with-api-key` writes. Written directly (not via the
|
|
41
|
+
* CLI): task-up docker-cp's that FILE into containers, so a keychain-backed
|
|
42
|
+
* CLI login would break task auth, and this works with no host CLI at all.
|
|
43
|
+
* - grok → XAI_API_KEY (documented pay-as-you-go fallback of the grok CLI;
|
|
44
|
+
* the adapter forwards it into task containers).
|
|
45
|
+
* - kimi → none: Kimi Code reads keys only from its config.toml via the
|
|
46
|
+
* interactive `/login`, never from the environment — so we don't offer a
|
|
47
|
+
* paste box we can't honor.
|
|
48
|
+
*
|
|
49
|
+
* Detection mirrors each adapter's `available()`: env/`.env.local` for
|
|
50
|
+
* claude/cursor, a config file under the owner home for codex/kimi/grok.
|
|
51
|
+
*
|
|
52
|
+
* Every side-effecting seam (spawn, the `.env.local` path, the owner home,
|
|
53
|
+
* process.env) is injectable so the flows are unit-testable without touching
|
|
54
|
+
* the real filesystem or spawning anything.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import {
|
|
58
|
+
execFile as nodeExecFile,
|
|
59
|
+
spawn as nodeSpawn,
|
|
60
|
+
type ChildProcess,
|
|
61
|
+
} from "node:child_process";
|
|
62
|
+
import {
|
|
63
|
+
chmodSync,
|
|
64
|
+
existsSync,
|
|
65
|
+
mkdirSync,
|
|
66
|
+
readdirSync,
|
|
67
|
+
readFileSync,
|
|
68
|
+
rmSync,
|
|
69
|
+
writeFileSync,
|
|
70
|
+
} from "node:fs";
|
|
71
|
+
import { homedir } from "node:os";
|
|
72
|
+
import { dirname, join } from "node:path";
|
|
73
|
+
|
|
74
|
+
import { env } from "./env";
|
|
75
|
+
|
|
76
|
+
export type EngineKind =
|
|
77
|
+
| "claude"
|
|
78
|
+
| "codex"
|
|
79
|
+
| "kimi"
|
|
80
|
+
| "grok"
|
|
81
|
+
| "cursor"
|
|
82
|
+
| "opencode";
|
|
83
|
+
export type EngineAuthMode =
|
|
84
|
+
| "token-command"
|
|
85
|
+
| "login-command"
|
|
86
|
+
| "api-key"
|
|
87
|
+
// The owner signs in with the engine's OWN interactive CLI in their terminal
|
|
88
|
+
// (e.g. `opencode auth login` — an arrow-key TUI we can't drive from the
|
|
89
|
+
// fire-and-log connect panel), and we just detect the credential it writes.
|
|
90
|
+
| "external-login";
|
|
91
|
+
|
|
92
|
+
/** One entry of the engine catalog the UI's "Add engine" panel renders. */
|
|
93
|
+
export interface EngineCatalogEntry {
|
|
94
|
+
kind: EngineKind;
|
|
95
|
+
label: string;
|
|
96
|
+
authMode: EngineAuthMode;
|
|
97
|
+
/** One-line help shown under the engine in the picker. */
|
|
98
|
+
notes: string | null;
|
|
99
|
+
/** Where to mint an API key (api-key mode only). */
|
|
100
|
+
getKeyUrl: string | null;
|
|
101
|
+
/** Placeholder for the pasted-API-key alternative; null = not supported. */
|
|
102
|
+
apiKeyHint: string | null;
|
|
103
|
+
/** Where to mint a key for the pasted-API-key alternative. */
|
|
104
|
+
apiKeyUrl: string | null;
|
|
105
|
+
/**
|
|
106
|
+
* Human-readable install command the UI shows next to "Install & connect"
|
|
107
|
+
* when the CLI is missing (null = nothing to install, e.g. Cursor).
|
|
108
|
+
*/
|
|
109
|
+
installHint: string | null;
|
|
110
|
+
/**
|
|
111
|
+
* The command the owner runs in their OWN terminal to sign in
|
|
112
|
+
* (external-login mode only; null otherwise).
|
|
113
|
+
*/
|
|
114
|
+
loginCmd: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface EngineDescriptor {
|
|
118
|
+
label: string;
|
|
119
|
+
authMode: EngineAuthMode;
|
|
120
|
+
notes: string;
|
|
121
|
+
getKeyUrl?: string;
|
|
122
|
+
/** Pasted-API-key alternative (absent = login/token only, e.g. kimi). */
|
|
123
|
+
apiKeyHint?: string;
|
|
124
|
+
apiKeyUrl?: string;
|
|
125
|
+
/** external-login: the CLI command the owner runs in their terminal. */
|
|
126
|
+
loginCmd?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Static descriptor table — the single source of truth for engine metadata. */
|
|
130
|
+
const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
|
|
131
|
+
claude: {
|
|
132
|
+
label: "Claude",
|
|
133
|
+
authMode: "token-command",
|
|
134
|
+
notes:
|
|
135
|
+
"Opens your browser to authorize Claude, then captures the token automatically.",
|
|
136
|
+
apiKeyHint: "sk-ant-…",
|
|
137
|
+
apiKeyUrl: "https://console.anthropic.com/settings/keys",
|
|
138
|
+
},
|
|
139
|
+
codex: {
|
|
140
|
+
label: "Codex",
|
|
141
|
+
authMode: "login-command",
|
|
142
|
+
notes: "Opens your browser to sign in to your OpenAI Codex account.",
|
|
143
|
+
apiKeyHint: "sk-…",
|
|
144
|
+
apiKeyUrl: "https://platform.openai.com/api-keys",
|
|
145
|
+
},
|
|
146
|
+
kimi: {
|
|
147
|
+
label: "Kimi Code",
|
|
148
|
+
authMode: "login-command",
|
|
149
|
+
notes:
|
|
150
|
+
"Sign in with your Moonshot Kimi Code subscription. Activates after the next image rebuild.",
|
|
151
|
+
},
|
|
152
|
+
grok: {
|
|
153
|
+
label: "Grok",
|
|
154
|
+
authMode: "login-command",
|
|
155
|
+
notes:
|
|
156
|
+
"Sign in with your xAI Grok subscription. Activates after the next image rebuild.",
|
|
157
|
+
apiKeyHint: "xai-…",
|
|
158
|
+
apiKeyUrl: "https://console.x.ai",
|
|
159
|
+
},
|
|
160
|
+
cursor: {
|
|
161
|
+
label: "Cursor",
|
|
162
|
+
authMode: "api-key",
|
|
163
|
+
notes: "Paste a Cursor API key. Requires Cursor Pro.",
|
|
164
|
+
getKeyUrl: "https://cursor.com/dashboard?tab=integrations",
|
|
165
|
+
},
|
|
166
|
+
opencode: {
|
|
167
|
+
label: "OpenCode",
|
|
168
|
+
// `opencode auth login` is an interactive arrow-key TUI (provider picker →
|
|
169
|
+
// per-provider auth) — it can't be driven from the fire-and-log connect
|
|
170
|
+
// panel, so the owner runs it in their own terminal and we detect the
|
|
171
|
+
// auth.json it writes.
|
|
172
|
+
authMode: "external-login",
|
|
173
|
+
loginCmd: "opencode auth login",
|
|
174
|
+
notes:
|
|
175
|
+
"Sign in from your terminal with `opencode auth login` (an interactive picker), then check the connection here. Activates after the next image rebuild.",
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Display order (matches the cloud picker's ordering intent). */
|
|
180
|
+
const ORDER: EngineKind[] = [
|
|
181
|
+
"claude",
|
|
182
|
+
"codex",
|
|
183
|
+
"kimi",
|
|
184
|
+
"grok",
|
|
185
|
+
"cursor",
|
|
186
|
+
"opencode",
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Injectable seams.
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/** How a login/token command is spawned. Tests provide a fake. */
|
|
194
|
+
export type EngineSpawn = (
|
|
195
|
+
command: string,
|
|
196
|
+
args: string[],
|
|
197
|
+
env: NodeJS.ProcessEnv,
|
|
198
|
+
) => ChildProcess;
|
|
199
|
+
|
|
200
|
+
export interface EngineSeams {
|
|
201
|
+
/** Spawn a CLI, piping stdin/stdout/stderr. */
|
|
202
|
+
spawn: EngineSpawn;
|
|
203
|
+
/** Absolute path to the `.env.local` the running host reads (UAI_HOME). */
|
|
204
|
+
envLocalPath: () => string;
|
|
205
|
+
/** The owner's real home — where codex/kimi/grok write their config dirs. */
|
|
206
|
+
ownerHome: () => string;
|
|
207
|
+
/** The live process env (adapters + task-up read creds from here). */
|
|
208
|
+
procEnv: NodeJS.ProcessEnv;
|
|
209
|
+
/** PATH as the owner's login shell sees it (null when unprobeable). */
|
|
210
|
+
loginShellPath: () => Promise<string | null>;
|
|
211
|
+
/** System-wide bin dirs probed for engine CLIs (tests pin to []). */
|
|
212
|
+
systemBinDirs: string[];
|
|
213
|
+
/**
|
|
214
|
+
* Wrap a CLI invocation so the child gets a real PTY (script(1)), or null
|
|
215
|
+
* to spawn directly. Tests pin null; token-command needs the wrap (see
|
|
216
|
+
* defaultPtyWrap for why).
|
|
217
|
+
*/
|
|
218
|
+
ptyWrap: ((bin: string, args: string[]) => PtyCommand | null) | null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A wrapped invocation: what to actually spawn to run a CLI under a PTY. */
|
|
222
|
+
export interface PtyCommand {
|
|
223
|
+
command: string;
|
|
224
|
+
args: string[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function defaultSeams(): EngineSeams {
|
|
228
|
+
return {
|
|
229
|
+
spawn: (command, args, spawnEnv) =>
|
|
230
|
+
nodeSpawn(command, args, {
|
|
231
|
+
// macOS: stdin MUST be /dev/null ("ignore"), never a node pipe. Node's
|
|
232
|
+
// child pipes are SOCKETPAIRS, and Apple's BSD script(1) hard-exits on
|
|
233
|
+
// a socket stdin ("tcgetattr/ioctl: Operation not supported on
|
|
234
|
+
// socket") while tolerating /dev/null (ENOTTY → notty mode). The
|
|
235
|
+
// "Press Enter" auto-ack is therefore Linux-only; the real token flow
|
|
236
|
+
// needs no input before the token renders (verified empirically).
|
|
237
|
+
stdio: [
|
|
238
|
+
process.platform === "darwin" ? "ignore" : "pipe",
|
|
239
|
+
"pipe",
|
|
240
|
+
"pipe",
|
|
241
|
+
],
|
|
242
|
+
env: spawnEnv,
|
|
243
|
+
}),
|
|
244
|
+
envLocalPath: () => join(env.uaiHome, ".env.local"),
|
|
245
|
+
ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
|
|
246
|
+
procEnv: process.env,
|
|
247
|
+
loginShellPath: probeLoginShellPath,
|
|
248
|
+
systemBinDirs: ["/opt/homebrew/bin", "/usr/local/bin"],
|
|
249
|
+
ptyWrap: defaultPtyWrap,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Lend a CLI a real PTY via script(1) — no native deps, ships with macOS
|
|
255
|
+
* (BSD) and Linux (util-linux; argument conventions differ, hence the
|
|
256
|
+
* branch). Ink-based CLIs (claude) render nothing under plain pipes: Ink
|
|
257
|
+
* detects a non-TTY stdout and buffers every frame until unmount, and the
|
|
258
|
+
* interactive screens can't complete without a terminal — so the child sat
|
|
259
|
+
* silent forever. The inner `stty cols 400` matters too: at the default 80
|
|
260
|
+
* columns the token line WRAPS and a wrapped token extracts truncated.
|
|
261
|
+
*/
|
|
262
|
+
function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
|
|
263
|
+
if (!existsSync("/usr/bin/script")) return null;
|
|
264
|
+
const stty = "stty cols 400 rows 100 2>/dev/null";
|
|
265
|
+
if (process.platform === "darwin") {
|
|
266
|
+
// BSD script: `script -q /dev/null command [args…]` — command is exec'd
|
|
267
|
+
// as an argv (no shell), so route through sh only to set the width.
|
|
268
|
+
return {
|
|
269
|
+
command: "/usr/bin/script",
|
|
270
|
+
args: ["-q", "/dev/null", "/bin/sh", "-c", `${stty}; exec "$0" "$@"`, bin, ...args],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (process.platform === "linux") {
|
|
274
|
+
// util-linux script: the command is a single shell string (-c).
|
|
275
|
+
const quoted = [bin, ...args]
|
|
276
|
+
.map((a) => `'${a.replace(/'/g, `'\\''`)}'`)
|
|
277
|
+
.join(" ");
|
|
278
|
+
return {
|
|
279
|
+
command: "/usr/bin/script",
|
|
280
|
+
args: ["-qec", `${stty}; exec ${quoted}`, "/dev/null"],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The PATH the owner's shell exports. Probes an INTERACTIVE login shell
|
|
288
|
+
* first: nvm/asdf/fnm put their PATH setup in `.zshrc`/`.bashrc`, which a
|
|
289
|
+
* plain login shell (`-lc`) never sources — that gap hid `npm i -g` installs
|
|
290
|
+
* from the probe. Falls back to `-lc` for profiles that misbehave when run
|
|
291
|
+
* interactively without a terminal. Null on failure of both; callers fall
|
|
292
|
+
* back to the inherited PATH.
|
|
293
|
+
*
|
|
294
|
+
* Cached (module-level, 5 min): the CLI-found statuses probe on every
|
|
295
|
+
* /api/engines poll and shelling out each time would be rude. Staleness is
|
|
296
|
+
* harmless — every installer this module runs lands in a candidate dir,
|
|
297
|
+
* which is checked before the probe.
|
|
298
|
+
*/
|
|
299
|
+
export async function probeLoginShellPath(
|
|
300
|
+
budgetMs: number = LOGIN_PATH_PROBE_BUDGET_MS,
|
|
301
|
+
seams: Partial<LoginShellProbeSeams> = {},
|
|
302
|
+
): Promise<string | null> {
|
|
303
|
+
const { runShell, now: clock } = { ...defaultProbeSeams(), ...seams };
|
|
304
|
+
const now = clock();
|
|
305
|
+
if (loginPathCache && now - loginPathCache.at < LOGIN_PATH_CACHE_MS) {
|
|
306
|
+
return loginPathCache.value;
|
|
307
|
+
}
|
|
308
|
+
// ONE deadline for the pair. A per-spawn timeout bounds each attempt but not
|
|
309
|
+
// the request: two 8s attempts is a 16s stall on a live /api/engines poll.
|
|
310
|
+
//
|
|
311
|
+
// The pair shares the budget, but `-lic` may not SPEND it all: the reserve
|
|
312
|
+
// below keeps the `-lc` retry reachable. Sharing one deadline outright would
|
|
313
|
+
// starve the fallback in exactly the case it exists for — see the reserve.
|
|
314
|
+
const deadline = now + budgetMs;
|
|
315
|
+
const value =
|
|
316
|
+
(await runBounded(
|
|
317
|
+
runShell,
|
|
318
|
+
clock,
|
|
319
|
+
"-lic",
|
|
320
|
+
deadline - fallbackReserve(budgetMs),
|
|
321
|
+
)) ?? (await runBounded(runShell, clock, "-lc", deadline));
|
|
322
|
+
loginPathCache = { value, at: now };
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Convert the remaining slice of the shared budget into one attempt's timeout,
|
|
328
|
+
* skipping the spawn entirely when a previous attempt already consumed it.
|
|
329
|
+
*/
|
|
330
|
+
async function runBounded(
|
|
331
|
+
runShell: LoginShellProbeSeams["runShell"],
|
|
332
|
+
clock: LoginShellProbeSeams["now"],
|
|
333
|
+
flags: string,
|
|
334
|
+
deadline: number,
|
|
335
|
+
): Promise<string | null> {
|
|
336
|
+
const remaining = deadline - clock();
|
|
337
|
+
if (remaining <= 0) return null;
|
|
338
|
+
return runShell(flags, remaining);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Drop the memoized login PATH. Exported for tests (module-level cache). */
|
|
342
|
+
export function resetLoginShellPathCache(): void {
|
|
343
|
+
loginPathCache = null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let loginPathCache: { value: string | null; at: number } | null = null;
|
|
347
|
+
const LOGIN_PATH_CACHE_MS = 5 * 60_000;
|
|
348
|
+
/**
|
|
349
|
+
* Total wall-clock budget for the login-shell PATH probe, across BOTH
|
|
350
|
+
* attempts. `/api/engines` awaits this on a cold cache, so the bound has to
|
|
351
|
+
* be on the pair — see probeLoginShellPath.
|
|
352
|
+
*/
|
|
353
|
+
const LOGIN_PATH_PROBE_BUDGET_MS = 8_000;
|
|
354
|
+
/**
|
|
355
|
+
* Slice of the budget held back so the `-lc` retry can always run.
|
|
356
|
+
*
|
|
357
|
+
* The interactive attempt is the one that stalls — it sources `.zshrc`, which
|
|
358
|
+
* is what makes it slow — and that is precisely when the non-interactive
|
|
359
|
+
* fallback is worth trying, because `-lc` skips that file. Letting `-lic`
|
|
360
|
+
* spend the whole budget therefore starves the fallback in the one case it
|
|
361
|
+
* exists for, and the probe resolves null: NOT "no login PATH" but "we gave
|
|
362
|
+
* up", cached as the former for LOGIN_PATH_CACHE_MS. `/api/engines` then
|
|
363
|
+
* reports every CLI absent for five minutes on a box where they are installed.
|
|
364
|
+
*
|
|
365
|
+
* Bounding the pair and keeping the fallback are not in tension: the total is
|
|
366
|
+
* still `budgetMs`, it is just no longer spendable entirely by the attempt
|
|
367
|
+
* most likely to hang.
|
|
368
|
+
*/
|
|
369
|
+
const LOGIN_PATH_FALLBACK_RESERVE_MS = 3_000;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Never larger than half the budget, so a caller-shrunk budget (tests) still
|
|
373
|
+
* gives both attempts a real slice instead of collapsing one to zero.
|
|
374
|
+
*/
|
|
375
|
+
function fallbackReserve(budgetMs: number): number {
|
|
376
|
+
return Math.min(LOGIN_PATH_FALLBACK_RESERVE_MS, Math.floor(budgetMs / 2));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The probe's two side effects, injectable like every other seam in this file.
|
|
381
|
+
*
|
|
382
|
+
* Budget arithmetic is the whole point of this code and it is a function of
|
|
383
|
+
* elapsed time, so a test that races real `sleep`s against a real clock is
|
|
384
|
+
* measuring the runner's load, not the logic — it fails on a busy self-hosted
|
|
385
|
+
* runner exactly like the stall it was written to prevent. Pin both and the
|
|
386
|
+
* assertions become the timeouts handed to each attempt.
|
|
387
|
+
*/
|
|
388
|
+
export interface LoginShellProbeSeams {
|
|
389
|
+
/** Run `$SHELL <flags> /usr/bin/env`, bounded by `timeoutMs`. */
|
|
390
|
+
runShell: (flags: string, timeoutMs: number) => Promise<string | null>;
|
|
391
|
+
now: () => number;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function defaultProbeSeams(): LoginShellProbeSeams {
|
|
395
|
+
return { runShell: shellPath, now: Date.now };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Run `$SHELL <flags> /usr/bin/env` and pull PATH out. `/usr/bin/env` (not
|
|
400
|
+
* `echo $PATH`) so the value is the real colon-joined exported PATH under any
|
|
401
|
+
* shell — fish's `$PATH` is a list and would echo space-joined — and the
|
|
402
|
+
* LAST `PATH=` line wins so profile echo noise can't spoof it.
|
|
403
|
+
*/
|
|
404
|
+
function shellPath(flags: string, timeoutMs: number): Promise<string | null> {
|
|
405
|
+
return new Promise((resolve) => {
|
|
406
|
+
const shell = process.env.SHELL?.trim() || "/bin/zsh";
|
|
407
|
+
nodeExecFile(
|
|
408
|
+
shell,
|
|
409
|
+
[flags, "/usr/bin/env"],
|
|
410
|
+
{ timeout: timeoutMs },
|
|
411
|
+
(err, stdout) => {
|
|
412
|
+
if (err) return resolve(null);
|
|
413
|
+
const lines = String(stdout)
|
|
414
|
+
.split(/\r?\n/)
|
|
415
|
+
.filter((l) => l.startsWith("PATH="));
|
|
416
|
+
const last = lines[lines.length - 1];
|
|
417
|
+
resolve(last ? last.slice("PATH=".length).trim() || null : null);
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
|
|
424
|
+
return { ...defaultSeams(), ...seams };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Public catalog / status.
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/** The static engine catalog (kind, label, authMode, notes, key URLs). */
|
|
432
|
+
export function engineCatalog(): EngineCatalogEntry[] {
|
|
433
|
+
return ORDER.map((kind) => {
|
|
434
|
+
const d = DESCRIPTORS[kind];
|
|
435
|
+
return {
|
|
436
|
+
kind,
|
|
437
|
+
label: d.label,
|
|
438
|
+
authMode: d.authMode,
|
|
439
|
+
notes: d.notes,
|
|
440
|
+
getKeyUrl: d.getKeyUrl ?? null,
|
|
441
|
+
apiKeyHint: d.apiKeyHint ?? null,
|
|
442
|
+
apiKeyUrl: d.apiKeyUrl ?? null,
|
|
443
|
+
installHint:
|
|
444
|
+
kind === "codex" ? CODEX_INSTALL_HINT : (INSTALLERS[kind] ?? null),
|
|
445
|
+
loginCmd: d.loginCmd ?? null,
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** kind → whether the engine's CLI is resolvable on this host right now. */
|
|
451
|
+
export async function engineCliStatuses(
|
|
452
|
+
seams: Partial<EngineSeams> = {},
|
|
453
|
+
): Promise<Record<EngineKind, boolean>> {
|
|
454
|
+
const s = withDefaults(seams);
|
|
455
|
+
// Resolve the login PATH at most ONCE per call. Every kind that isn't in a
|
|
456
|
+
// candidate dir falls through to the probe, so six kinds would otherwise
|
|
457
|
+
// await it six times — bounding each probe wouldn't bound the request. The
|
|
458
|
+
// default seam memoizes for 5 min, but the per-request bound must not
|
|
459
|
+
// depend on that cache being warm.
|
|
460
|
+
let loginOnce: Promise<string | null> | null = null;
|
|
461
|
+
const scoped: EngineSeams = {
|
|
462
|
+
...s,
|
|
463
|
+
loginShellPath: () => (loginOnce ??= s.loginShellPath()),
|
|
464
|
+
};
|
|
465
|
+
const out = {} as Record<EngineKind, boolean>;
|
|
466
|
+
for (const kind of ORDER) out[kind] = await engineCliFound(kind, scoped);
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** kind → connected, for every engine. */
|
|
471
|
+
export function engineStatuses(
|
|
472
|
+
seams: Partial<EngineSeams> = {},
|
|
473
|
+
): Record<EngineKind, boolean> {
|
|
474
|
+
const s = withDefaults(seams);
|
|
475
|
+
return {
|
|
476
|
+
claude: detect("claude", s),
|
|
477
|
+
codex: detect("codex", s),
|
|
478
|
+
kimi: detect("kimi", s),
|
|
479
|
+
grok: detect("grok", s),
|
|
480
|
+
cursor: detect("cursor", s),
|
|
481
|
+
opencode: detect("opencode", s),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Whether a single engine is connected on this host right now. */
|
|
486
|
+
export function detectEngine(
|
|
487
|
+
kind: EngineKind,
|
|
488
|
+
seams: Partial<EngineSeams> = {},
|
|
489
|
+
): boolean {
|
|
490
|
+
return detect(kind, withDefaults(seams));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function isEngineKind(value: unknown): value is EngineKind {
|
|
494
|
+
return (
|
|
495
|
+
value === "claude" ||
|
|
496
|
+
value === "codex" ||
|
|
497
|
+
value === "kimi" ||
|
|
498
|
+
value === "grok" ||
|
|
499
|
+
value === "cursor" ||
|
|
500
|
+
value === "opencode"
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// Connect / disconnect.
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
export interface ConnectOptions {
|
|
509
|
+
/** A pasted API key — primary for Cursor, alternative for claude/codex/grok. */
|
|
510
|
+
apiKey?: string;
|
|
511
|
+
/** token-command manual fallback: a pasted token or API key (Claude). */
|
|
512
|
+
pastedToken?: string;
|
|
513
|
+
/**
|
|
514
|
+
* ADR-076: when set, a token captured by the token-command flow (or pasted)
|
|
515
|
+
* is handed to this sink INSTEAD of being persisted as the default credential
|
|
516
|
+
* — used to register an EXTRA account. The sink's result becomes the connect
|
|
517
|
+
* result.
|
|
518
|
+
*/
|
|
519
|
+
onToken?: (token: string) => ConnectResult | Promise<ConnectResult>;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export interface ConnectResult {
|
|
523
|
+
ok: boolean;
|
|
524
|
+
message: string;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Connect an engine. api-key/paste resolve synchronously; login/token spawn a
|
|
529
|
+
* CLI and stream its output to `onLog` (the browser OAuth happens meanwhile).
|
|
530
|
+
*/
|
|
531
|
+
export async function connectEngine(
|
|
532
|
+
kind: EngineKind,
|
|
533
|
+
opts: ConnectOptions,
|
|
534
|
+
onLog: (line: string) => void,
|
|
535
|
+
seams: Partial<EngineSeams> = {},
|
|
536
|
+
): Promise<ConnectResult> {
|
|
537
|
+
const s = withDefaults(seams);
|
|
538
|
+
const d = DESCRIPTORS[kind];
|
|
539
|
+
|
|
540
|
+
// A pasted API key connects without spawning anything — the only flow for
|
|
541
|
+
// api-key engines (Cursor), an alternative for the others.
|
|
542
|
+
if (d.authMode === "api-key" || opts.apiKey !== undefined) {
|
|
543
|
+
return saveApiKey(kind, d, opts.apiKey ?? "", s);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// external-login (OpenCode): the owner ran the engine's own interactive CLI
|
|
547
|
+
// in their terminal — we never spawn it. "Connect" just re-detects the
|
|
548
|
+
// credential that login wrote.
|
|
549
|
+
if (d.authMode === "external-login") {
|
|
550
|
+
return detect(kind, s)
|
|
551
|
+
? { ok: true, message: `${d.label} connected.` }
|
|
552
|
+
: {
|
|
553
|
+
ok: false,
|
|
554
|
+
message: d.loginCmd
|
|
555
|
+
? `Not signed in yet. Run \`${d.loginCmd}\` in your terminal, then check again.`
|
|
556
|
+
: `${d.label} isn't signed in yet.`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (d.authMode === "token-command") {
|
|
561
|
+
// Manual paste fallback — accept a token (or API key) without spawning.
|
|
562
|
+
if (opts.pastedToken !== undefined) {
|
|
563
|
+
const token = opts.pastedToken.trim();
|
|
564
|
+
if (!token) return { ok: false, message: "Paste a token." };
|
|
565
|
+
if (/\s/.test(token) || token.length < 20) {
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
message: "That doesn't look like a token. Paste just the value.",
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
// Adding an extra account? Route the pasted value to the sink instead of
|
|
572
|
+
// the default credential.
|
|
573
|
+
if (opts.onToken) return await opts.onToken(token);
|
|
574
|
+
// One paste box serves both credential kinds — classify by prefix so an
|
|
575
|
+
// `sk-ant-api…` platform key doesn't get stored as an OAuth token.
|
|
576
|
+
upsertEnvLocal(
|
|
577
|
+
token.startsWith("sk-ant-api")
|
|
578
|
+
? "ANTHROPIC_API_KEY"
|
|
579
|
+
: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
580
|
+
token,
|
|
581
|
+
s,
|
|
582
|
+
);
|
|
583
|
+
return { ok: true, message: `${d.label} connected.` };
|
|
584
|
+
}
|
|
585
|
+
return runTokenCommand(kind, d.label, onLog, s, opts.onToken);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return runLoginCommand(kind, d.label, onLog, s);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Persist a pasted API key where the engine's adapter actually reads it. */
|
|
592
|
+
function saveApiKey(
|
|
593
|
+
kind: EngineKind,
|
|
594
|
+
d: EngineDescriptor,
|
|
595
|
+
raw: string,
|
|
596
|
+
s: EngineSeams,
|
|
597
|
+
): ConnectResult {
|
|
598
|
+
const key = raw.trim();
|
|
599
|
+
if (!key) return { ok: false, message: `Paste your ${d.label} API key.` };
|
|
600
|
+
if (/\s/.test(key) || key.length < 8) {
|
|
601
|
+
return {
|
|
602
|
+
ok: false,
|
|
603
|
+
message: "That doesn't look like an API key. Paste just the value.",
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
switch (kind) {
|
|
607
|
+
case "cursor":
|
|
608
|
+
upsertEnvLocal("CURSOR_API_KEY", key, s);
|
|
609
|
+
break;
|
|
610
|
+
case "claude":
|
|
611
|
+
// Accept either credential kind here too (see the paste fallback above).
|
|
612
|
+
upsertEnvLocal(
|
|
613
|
+
key.startsWith("sk-ant-oat") ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY",
|
|
614
|
+
key,
|
|
615
|
+
s,
|
|
616
|
+
);
|
|
617
|
+
break;
|
|
618
|
+
case "grok":
|
|
619
|
+
// The grok CLI's documented pay-as-you-go fallback; the adapter forwards
|
|
620
|
+
// it into task containers.
|
|
621
|
+
upsertEnvLocal("XAI_API_KEY", key, s);
|
|
622
|
+
break;
|
|
623
|
+
case "codex":
|
|
624
|
+
writeCodexAuthJson(key, s);
|
|
625
|
+
break;
|
|
626
|
+
case "kimi":
|
|
627
|
+
return {
|
|
628
|
+
ok: false,
|
|
629
|
+
message:
|
|
630
|
+
"Kimi Code authenticates via subscription sign-in — it doesn't read API keys from the environment.",
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
return { ok: true, message: `${d.label} connected.` };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Write `~/.codex/auth.json` the way `codex login --with-api-key` does. We
|
|
638
|
+
* write the plaintext file directly (not via the CLI) because task-up
|
|
639
|
+
* docker-cp's this exact FILE into containers — a keychain-backed CLI login
|
|
640
|
+
* would leave nothing to copy — and it must work without a host codex CLI.
|
|
641
|
+
*/
|
|
642
|
+
function writeCodexAuthJson(key: string, s: EngineSeams): void {
|
|
643
|
+
const file = join(s.ownerHome(), ".codex", "auth.json");
|
|
644
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
645
|
+
writeFileSync(file, `${JSON.stringify({ OPENAI_API_KEY: key }, null, 2)}\n`, {
|
|
646
|
+
mode: 0o600,
|
|
647
|
+
});
|
|
648
|
+
try {
|
|
649
|
+
chmodSync(file, 0o600);
|
|
650
|
+
} catch {
|
|
651
|
+
/* best effort */
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Disconnect an engine: forget its credential (env line and/or config file). */
|
|
656
|
+
export function disconnectEngine(
|
|
657
|
+
kind: EngineKind,
|
|
658
|
+
seams: Partial<EngineSeams> = {},
|
|
659
|
+
): void {
|
|
660
|
+
const s = withDefaults(seams);
|
|
661
|
+
if (kind === "cursor") {
|
|
662
|
+
removeEnvLocal("CURSOR_API_KEY", s);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (kind === "claude") {
|
|
666
|
+
removeEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", s);
|
|
667
|
+
removeEnvLocal("ANTHROPIC_API_KEY", s);
|
|
668
|
+
removeEnvLocal("ANTHROPIC_AUTH_TOKEN", s);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// grok can be connected via login (auth.json) OR a pasted key — drop both.
|
|
672
|
+
if (kind === "grok") removeEnvLocal("XAI_API_KEY", s);
|
|
673
|
+
// login-command engines: remove the config file the adapter detects.
|
|
674
|
+
try {
|
|
675
|
+
rmSync(configPath(kind, s), { force: true });
|
|
676
|
+
} catch {
|
|
677
|
+
/* best effort */
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// Install a missing engine CLI (ADR-074).
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Pinned installer one-liners — the same vendor installers the standard task
|
|
687
|
+
* image already trusts (host-agent/images/standard/Dockerfile), shown
|
|
688
|
+
* VERBATIM in the UI before running. Every one lands its binary in a
|
|
689
|
+
* candidateBins dir, so the next resolve finds it with no restart:
|
|
690
|
+
* claude → ~/.local/bin, kimi → ~/.kimi-code/bin, grok → ~/.grok/bin.
|
|
691
|
+
* Codex has no vendor script — resolved at run time (brew, then npm).
|
|
692
|
+
*/
|
|
693
|
+
const INSTALLERS: Partial<Record<EngineKind, string>> = {
|
|
694
|
+
claude: "curl -fsSL https://claude.ai/install.sh | bash",
|
|
695
|
+
kimi: "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
|
|
696
|
+
grok: "curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
697
|
+
opencode: "curl -fsSL https://opencode.ai/install | bash",
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const CODEX_INSTALL_HINT = "brew install codex — or: npm i -g @openai/codex";
|
|
701
|
+
|
|
702
|
+
/** Installers can compile/download; give them real time. */
|
|
703
|
+
const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
704
|
+
|
|
705
|
+
export interface InstallResult {
|
|
706
|
+
ok: boolean;
|
|
707
|
+
message: string;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Install an engine's CLI with its pinned installer, streaming output to
|
|
712
|
+
* `onLog`. Refuses when there's nothing to install (api-key engines) and
|
|
713
|
+
* short-circuits when the CLI is already resolvable. Success = the installer
|
|
714
|
+
* exits 0 AND the CLI resolves afterwards.
|
|
715
|
+
*/
|
|
716
|
+
export async function installEngineCli(
|
|
717
|
+
kind: EngineKind,
|
|
718
|
+
onLog: (line: string) => void,
|
|
719
|
+
seams: Partial<EngineSeams> = {},
|
|
720
|
+
): Promise<InstallResult> {
|
|
721
|
+
const s = withDefaults(seams);
|
|
722
|
+
const d = DESCRIPTORS[kind];
|
|
723
|
+
if (d.authMode === "api-key") {
|
|
724
|
+
return {
|
|
725
|
+
ok: false,
|
|
726
|
+
message: `${d.label} doesn't use a CLI on the host — paste an API key instead.`,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (await engineCliFound(kind, s)) {
|
|
730
|
+
return { ok: true, message: `${d.label} CLI is already installed.` };
|
|
731
|
+
}
|
|
732
|
+
const inv = await resolveInstaller(kind, s);
|
|
733
|
+
if (!inv) {
|
|
734
|
+
return {
|
|
735
|
+
ok: false,
|
|
736
|
+
message:
|
|
737
|
+
"No way to install Codex automatically: install Homebrew or Node.js first, then `brew install codex` or `npm i -g @openai/codex`.",
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
let child: ChildProcess;
|
|
741
|
+
try {
|
|
742
|
+
child = s.spawn(inv.command, inv.args, {
|
|
743
|
+
...s.procEnv,
|
|
744
|
+
...(inv.path ? { PATH: inv.path } : {}),
|
|
745
|
+
});
|
|
746
|
+
} catch (err) {
|
|
747
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
748
|
+
}
|
|
749
|
+
return new Promise((resolve) => {
|
|
750
|
+
let settled = false;
|
|
751
|
+
let timer: NodeJS.Timeout | undefined;
|
|
752
|
+
const done = (r: InstallResult, kill = false): void => {
|
|
753
|
+
if (settled) return;
|
|
754
|
+
settled = true;
|
|
755
|
+
clearTimeout(timer);
|
|
756
|
+
if (kill) {
|
|
757
|
+
try {
|
|
758
|
+
child.kill();
|
|
759
|
+
} catch {
|
|
760
|
+
/* already gone */
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
resolve(r);
|
|
764
|
+
};
|
|
765
|
+
const relay = createLineRelay(onLog);
|
|
766
|
+
child.stdout?.on("data", relay);
|
|
767
|
+
child.stderr?.on("data", relay);
|
|
768
|
+
child.on("error", (err: NodeJS.ErrnoException) =>
|
|
769
|
+
done({ ok: false, message: err.message }),
|
|
770
|
+
);
|
|
771
|
+
child.on("exit", (code) => {
|
|
772
|
+
void (async () => {
|
|
773
|
+
if (code !== 0) {
|
|
774
|
+
done({
|
|
775
|
+
ok: false,
|
|
776
|
+
message: `Install failed (exit ${code ?? "?"}) — see the log above.`,
|
|
777
|
+
});
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
done(
|
|
781
|
+
(await engineCliFound(kind, s))
|
|
782
|
+
? { ok: true, message: `${d.label} CLI installed.` }
|
|
783
|
+
: {
|
|
784
|
+
ok: false,
|
|
785
|
+
message:
|
|
786
|
+
"The installer finished but the CLI still isn't findable — install it manually, then try Connect.",
|
|
787
|
+
},
|
|
788
|
+
);
|
|
789
|
+
})();
|
|
790
|
+
});
|
|
791
|
+
timer = setTimeout(
|
|
792
|
+
() =>
|
|
793
|
+
done(
|
|
794
|
+
{ ok: false, message: "The installer timed out — see the log above." },
|
|
795
|
+
true,
|
|
796
|
+
),
|
|
797
|
+
INSTALL_TIMEOUT_MS,
|
|
798
|
+
);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
interface InstallInvocation {
|
|
803
|
+
command: string;
|
|
804
|
+
args: string[];
|
|
805
|
+
/** Child PATH override (npm needs its own dir leading so node resolves). */
|
|
806
|
+
path?: string;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Vendor script via sh for most engines; brew-then-npm for Codex. */
|
|
810
|
+
async function resolveInstaller(
|
|
811
|
+
kind: EngineKind,
|
|
812
|
+
s: EngineSeams,
|
|
813
|
+
): Promise<InstallInvocation | null> {
|
|
814
|
+
if (kind !== "codex") {
|
|
815
|
+
const script = INSTALLERS[kind];
|
|
816
|
+
return script ? { command: "/bin/sh", args: ["-c", script] } : null;
|
|
817
|
+
}
|
|
818
|
+
const brew = s.systemBinDirs.map((dir) => join(dir, "brew")).find(existsSync);
|
|
819
|
+
if (brew) return { command: brew, args: ["install", "codex"] };
|
|
820
|
+
const npm = await findBinNamed("npm", s);
|
|
821
|
+
if (npm) {
|
|
822
|
+
return {
|
|
823
|
+
command: npm,
|
|
824
|
+
args: ["install", "-g", "@openai/codex"],
|
|
825
|
+
path: prependPath([dirname(npm)], s.procEnv.PATH ?? ""),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Find an arbitrary tool the same way engine CLIs are found. */
|
|
832
|
+
async function findBinNamed(
|
|
833
|
+
name: string,
|
|
834
|
+
s: EngineSeams,
|
|
835
|
+
): Promise<string | null> {
|
|
836
|
+
const home = s.ownerHome();
|
|
837
|
+
const dirs = [
|
|
838
|
+
join(home, ".local", "bin"),
|
|
839
|
+
...nodeManagerBinDirs(home),
|
|
840
|
+
...s.systemBinDirs,
|
|
841
|
+
];
|
|
842
|
+
for (const dir of dirs) {
|
|
843
|
+
const file = join(dir, name);
|
|
844
|
+
if (existsSync(file)) return file;
|
|
845
|
+
}
|
|
846
|
+
const login = await s.loginShellPath();
|
|
847
|
+
const dir = login?.split(":").find((p) => p && existsSync(join(p, name)));
|
|
848
|
+
return dir ? join(dir, name) : null;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Whether an engine's CLI resolves right now (candidates, then login PATH). */
|
|
852
|
+
async function engineCliFound(
|
|
853
|
+
kind: EngineKind,
|
|
854
|
+
s: EngineSeams,
|
|
855
|
+
): Promise<boolean> {
|
|
856
|
+
if (DESCRIPTORS[kind].authMode === "api-key") return true; // never spawned
|
|
857
|
+
if (candidateBins(kind, s).some(existsSync)) return true;
|
|
858
|
+
const login = await s.loginShellPath();
|
|
859
|
+
return !!login
|
|
860
|
+
?.split(":")
|
|
861
|
+
.some((dir) => dir.length > 0 && existsSync(join(dir, kind)));
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Accumulate a child's output and forward each distinct, meaningful,
|
|
866
|
+
* COMPLETE line to `onLog` once — same discipline as the token flow's
|
|
867
|
+
* relay, for plain (non-PTY) installer output with \r progress noise.
|
|
868
|
+
*/
|
|
869
|
+
function createLineRelay(onLog: (line: string) => void): (b: Buffer) => void {
|
|
870
|
+
let raw = "";
|
|
871
|
+
const forwarded = new Set<string>();
|
|
872
|
+
return (b) => {
|
|
873
|
+
raw += b.toString("utf8");
|
|
874
|
+
if (raw.length > 262_144) raw = raw.slice(-131_072);
|
|
875
|
+
const text = sanitizeTerminalOutput(raw);
|
|
876
|
+
for (const line of significantLines(text.slice(0, text.lastIndexOf("\n") + 1))) {
|
|
877
|
+
if (!forwarded.has(line)) {
|
|
878
|
+
forwarded.add(line);
|
|
879
|
+
onLog(line);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Flatten a PTY byte stream into plain text lines. Strips OSC sequences
|
|
887
|
+
* (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
|
|
888
|
+
* sequences (cursor-column moves become a space — Ink positions words with
|
|
889
|
+
* `ESC[<n>G` instead of writing spaces), stray ESC singles, and normalizes
|
|
890
|
+
* `\r` to `\n`. Identity on already-plain text apart from whitespace runs.
|
|
891
|
+
*/
|
|
892
|
+
export function sanitizeTerminalOutput(raw: string): string {
|
|
893
|
+
return raw
|
|
894
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC …BEL / …ST
|
|
895
|
+
.replace(/\x1b\[[0-9;:?<=>]*[!-/]*[@-~]/g, " ") // CSI
|
|
896
|
+
.replace(/\x1b[()][0-9A-B]/g, "") // charset selects
|
|
897
|
+
.replace(/\x1b[78=>DEHM]/g, "") // save/restore cursor etc.
|
|
898
|
+
.replace(/\r\n?/g, "\n")
|
|
899
|
+
.replace(/[^\S\n]+/g, " ");
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Lines worth showing a human: non-blank, not spinner or logo-art glyphs. */
|
|
903
|
+
function significantLines(text: string): string[] {
|
|
904
|
+
return text
|
|
905
|
+
.split("\n")
|
|
906
|
+
.map((l) => l.trim())
|
|
907
|
+
.filter((l) => l.length > 1 && !/^[·✢✳✶✻✽*+.…|/\\\-░▒▓█▄▀\s]+$/u.test(l));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Extract a Claude OAuth token from `claude setup-token` output. The CLI shows
|
|
912
|
+
* the token (an `sk-ant-oat…` value) on its own line near the end; scan from the
|
|
913
|
+
* bottom for it, falling back to a lone long token-charset line.
|
|
914
|
+
*/
|
|
915
|
+
export function extractClaudeToken(stdout: string): string | null {
|
|
916
|
+
const lines = stdout
|
|
917
|
+
.split(/\r?\n/)
|
|
918
|
+
.map((l) => l.trim())
|
|
919
|
+
.filter(Boolean);
|
|
920
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
921
|
+
const m = /(sk-ant-[A-Za-z0-9_-]{16,})/.exec(lines[i] ?? "");
|
|
922
|
+
if (m?.[1]) return m[1];
|
|
923
|
+
}
|
|
924
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
925
|
+
const line = lines[i] ?? "";
|
|
926
|
+
if (/^[A-Za-z0-9_-]{40,}$/.test(line)) return line;
|
|
927
|
+
}
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
// Internals.
|
|
933
|
+
// ---------------------------------------------------------------------------
|
|
934
|
+
|
|
935
|
+
function detect(kind: EngineKind, s: EngineSeams): boolean {
|
|
936
|
+
switch (kind) {
|
|
937
|
+
case "claude":
|
|
938
|
+
return (
|
|
939
|
+
envOrFileHas("CLAUDE_CODE_OAUTH_TOKEN", s) ||
|
|
940
|
+
envOrFileHas("ANTHROPIC_API_KEY", s) ||
|
|
941
|
+
envOrFileHas("ANTHROPIC_AUTH_TOKEN", s)
|
|
942
|
+
);
|
|
943
|
+
case "cursor":
|
|
944
|
+
return envOrFileHas("CURSOR_API_KEY", s);
|
|
945
|
+
case "grok":
|
|
946
|
+
return existsSync(configPath(kind, s)) || envOrFileHas("XAI_API_KEY", s);
|
|
947
|
+
case "codex":
|
|
948
|
+
case "kimi":
|
|
949
|
+
case "opencode":
|
|
950
|
+
return existsSync(configPath(kind, s));
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** True when `key` is set in process.env or present in `.env.local`. */
|
|
955
|
+
function envOrFileHas(key: string, s: EngineSeams): boolean {
|
|
956
|
+
if (s.procEnv[key]) return true;
|
|
957
|
+
try {
|
|
958
|
+
const body = readFileSync(s.envLocalPath(), "utf8");
|
|
959
|
+
return new RegExp(`^${key}=.+`, "m").test(body);
|
|
960
|
+
} catch {
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** Config file whose presence gates a login-command engine. */
|
|
966
|
+
function configPath(kind: EngineKind, s: EngineSeams): string {
|
|
967
|
+
const home = s.ownerHome();
|
|
968
|
+
if (kind === "codex") return join(home, ".codex", "auth.json");
|
|
969
|
+
if (kind === "kimi") {
|
|
970
|
+
return join(home, ".kimi-code", "credentials", "kimi-code.json");
|
|
971
|
+
}
|
|
972
|
+
if (kind === "opencode") {
|
|
973
|
+
return join(home, ".local", "share", "opencode", "auth.json");
|
|
974
|
+
}
|
|
975
|
+
return join(home, ".grok", "auth.json");
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Well-known install locations for an engine CLI — the dirs a GUI-launched
|
|
980
|
+
* host's minimal PATH misses. Home-relative entries resolve against the OWNER
|
|
981
|
+
* home (seam-injectable). The binary name equals the engine kind for all four
|
|
982
|
+
* CLI engines.
|
|
983
|
+
*/
|
|
984
|
+
function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
|
|
985
|
+
const home = s.ownerHome();
|
|
986
|
+
const common = [
|
|
987
|
+
join(home, ".local", "bin"),
|
|
988
|
+
...nodeManagerBinDirs(home),
|
|
989
|
+
...s.systemBinDirs,
|
|
990
|
+
];
|
|
991
|
+
switch (kind) {
|
|
992
|
+
case "claude":
|
|
993
|
+
// Native installer symlinks ~/.local/bin/claude (preferred); the older
|
|
994
|
+
// "local install" migration keeps a wrapper at ~/.claude/local/claude.
|
|
995
|
+
return [
|
|
996
|
+
join(home, ".local", "bin"),
|
|
997
|
+
join(home, ".claude", "local"),
|
|
998
|
+
...nodeManagerBinDirs(home),
|
|
999
|
+
...s.systemBinDirs,
|
|
1000
|
+
].map((d) => join(d, "claude"));
|
|
1001
|
+
case "codex":
|
|
1002
|
+
return common.map((d) => join(d, "codex"));
|
|
1003
|
+
case "kimi":
|
|
1004
|
+
return [join(home, ".kimi-code", "bin"), ...common].map((d) =>
|
|
1005
|
+
join(d, "kimi"),
|
|
1006
|
+
);
|
|
1007
|
+
case "grok":
|
|
1008
|
+
return [join(home, ".grok", "bin"), ...common].map((d) => join(d, "grok"));
|
|
1009
|
+
case "opencode":
|
|
1010
|
+
// opencode.ai/install lands the binary in ~/.opencode/bin.
|
|
1011
|
+
return [join(home, ".opencode", "bin"), ...common].map((d) =>
|
|
1012
|
+
join(d, "opencode"),
|
|
1013
|
+
);
|
|
1014
|
+
case "cursor":
|
|
1015
|
+
return []; // api-key mode — never spawned
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Bin dirs of the common node version managers — `npm i -g` lands CLIs here,
|
|
1021
|
+
* and none of these are on a GUI PATH or even in a NON-interactive login
|
|
1022
|
+
* shell's PATH (their init lives in .zshrc/.bashrc), which is exactly how an
|
|
1023
|
+
* installed `codex` still read "not found". Deterministic dirs beat shell
|
|
1024
|
+
* probing: no subprocess, no profile side effects.
|
|
1025
|
+
*/
|
|
1026
|
+
function nodeManagerBinDirs(home: string): string[] {
|
|
1027
|
+
const dirs = [
|
|
1028
|
+
join(home, ".asdf", "shims"), // shims exec asdf by absolute path
|
|
1029
|
+
join(home, ".volta", "bin"),
|
|
1030
|
+
join(home, ".bun", "bin"),
|
|
1031
|
+
join(home, ".npm-global", "bin"),
|
|
1032
|
+
// fnm's stable default-alias bin — the per-shell multishell paths its
|
|
1033
|
+
// shell init exports are ephemeral. macOS, XDG, and legacy layouts.
|
|
1034
|
+
join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin"),
|
|
1035
|
+
join(home, ".local", "share", "fnm", "aliases", "default", "bin"),
|
|
1036
|
+
join(home, ".fnm", "aliases", "default", "bin"),
|
|
1037
|
+
];
|
|
1038
|
+
// nvm has no stable "current" symlink — enumerate installed versions,
|
|
1039
|
+
// newest first (numeric compare: lexicographic would put v9 over v22).
|
|
1040
|
+
// A version dir's bin also holds its `node`, so the shebang of whatever
|
|
1041
|
+
// we find there resolves against the same dir once it leads PATH.
|
|
1042
|
+
try {
|
|
1043
|
+
const versions = join(home, ".nvm", "versions", "node");
|
|
1044
|
+
dirs.push(
|
|
1045
|
+
...readdirSync(versions)
|
|
1046
|
+
.sort(compareVersionDesc)
|
|
1047
|
+
.map((v) => join(versions, v, "bin")),
|
|
1048
|
+
);
|
|
1049
|
+
} catch {
|
|
1050
|
+
/* no nvm */
|
|
1051
|
+
}
|
|
1052
|
+
return dirs;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function compareVersionDesc(a: string, b: string): number {
|
|
1056
|
+
const parts = (s: string): number[] =>
|
|
1057
|
+
s.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
1058
|
+
const [a1 = 0, a2 = 0, a3 = 0] = parts(a);
|
|
1059
|
+
const [b1 = 0, b2 = 0, b3 = 0] = parts(b);
|
|
1060
|
+
return b1 - a1 || b2 - a2 || b3 - a3;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
interface ResolvedBin {
|
|
1064
|
+
/** Command to spawn — absolute when found, else the bare name. */
|
|
1065
|
+
bin: string;
|
|
1066
|
+
/** PATH for the child, augmented so nested lookups (node, git) resolve. */
|
|
1067
|
+
path: string;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Find the engine CLI the way the owner's terminal would: well-known install
|
|
1072
|
+
* dirs first (fast, no subprocess), then the login shell's PATH (sources the
|
|
1073
|
+
* owner's profile — covers nvm/asdf/npm prefixes), else the bare name against
|
|
1074
|
+
* the inherited PATH so a genuinely missing CLI still surfaces the friendly
|
|
1075
|
+
* ENOENT "install it first" message.
|
|
1076
|
+
*/
|
|
1077
|
+
async function resolveEngineBin(
|
|
1078
|
+
kind: EngineKind,
|
|
1079
|
+
s: EngineSeams,
|
|
1080
|
+
): Promise<ResolvedBin> {
|
|
1081
|
+
const inherited = s.procEnv.PATH ?? "";
|
|
1082
|
+
for (const file of candidateBins(kind, s)) {
|
|
1083
|
+
if (existsSync(file)) {
|
|
1084
|
+
return { bin: file, path: prependPath([dirname(file)], inherited) };
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const login = await s.loginShellPath();
|
|
1088
|
+
if (login) {
|
|
1089
|
+
const dirs = login.split(":").filter(Boolean);
|
|
1090
|
+
const dir = dirs.find((d) => existsSync(join(d, kind)));
|
|
1091
|
+
// Found or not, hand the login PATH to the child — it is the environment
|
|
1092
|
+
// where the CLI (and whatever it shells out to) is known to work.
|
|
1093
|
+
return {
|
|
1094
|
+
bin: dir ? join(dir, kind) : kind,
|
|
1095
|
+
path: prependPath(dirs, inherited),
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
return { bin: kind, path: inherited };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** Prepend dirs to a colon-joined PATH, deduped, order-preserving. */
|
|
1102
|
+
function prependPath(prepend: string[], current: string): string {
|
|
1103
|
+
const seen = new Set<string>();
|
|
1104
|
+
return [...prepend, ...current.split(":")]
|
|
1105
|
+
.filter((p) => p.length > 0 && !seen.has(p) && (seen.add(p), true))
|
|
1106
|
+
.join(":");
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** Resolve the CLI and spawn it (optionally under a PTY) with the augmented PATH. */
|
|
1110
|
+
async function spawnEngineCli(
|
|
1111
|
+
kind: EngineKind,
|
|
1112
|
+
args: string[],
|
|
1113
|
+
s: EngineSeams,
|
|
1114
|
+
pty = false,
|
|
1115
|
+
): Promise<ChildProcess> {
|
|
1116
|
+
const { bin, path } = await resolveEngineBin(kind, s);
|
|
1117
|
+
const wrapped = pty && s.ptyWrap ? s.ptyWrap(bin, args) : null;
|
|
1118
|
+
const spawnEnv: NodeJS.ProcessEnv = { ...s.procEnv, PATH: path };
|
|
1119
|
+
// A GUI-supervised host has no TERM; give the PTY child a sane one.
|
|
1120
|
+
if (wrapped && !spawnEnv.TERM) spawnEnv.TERM = "xterm-256color";
|
|
1121
|
+
return wrapped
|
|
1122
|
+
? s.spawn(wrapped.command, wrapped.args, spawnEnv)
|
|
1123
|
+
: s.spawn(bin, args, spawnEnv);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** Browser authorization is human-paced — give it real time before bailing. */
|
|
1127
|
+
const TOKEN_FLOW_TIMEOUT_MS = 10 * 60_000;
|
|
1128
|
+
|
|
1129
|
+
const TOKEN_FLOW_FALLBACK =
|
|
1130
|
+
"Try again, or run `claude setup-token` in your terminal and paste the token here.";
|
|
1131
|
+
|
|
1132
|
+
async function runTokenCommand(
|
|
1133
|
+
kind: EngineKind,
|
|
1134
|
+
label: string,
|
|
1135
|
+
onLog: (line: string) => void,
|
|
1136
|
+
s: EngineSeams,
|
|
1137
|
+
onToken?: (token: string) => ConnectResult | Promise<ConnectResult>,
|
|
1138
|
+
): Promise<ConnectResult> {
|
|
1139
|
+
let child: ChildProcess;
|
|
1140
|
+
try {
|
|
1141
|
+
child = await spawnEngineCli(kind, ["setup-token"], s, true);
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
1144
|
+
}
|
|
1145
|
+
return new Promise((resolve) => {
|
|
1146
|
+
let settled = false;
|
|
1147
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1148
|
+
const done = (r: ConnectResult, kill = false): void => {
|
|
1149
|
+
if (settled) return;
|
|
1150
|
+
settled = true;
|
|
1151
|
+
clearTimeout(timer);
|
|
1152
|
+
if (kill) {
|
|
1153
|
+
try {
|
|
1154
|
+
child.kill();
|
|
1155
|
+
} catch {
|
|
1156
|
+
/* already gone */
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
resolve(r);
|
|
1160
|
+
};
|
|
1161
|
+
let raw = "";
|
|
1162
|
+
let capturing = false;
|
|
1163
|
+
const forwarded = new Set<string>();
|
|
1164
|
+
const acked = new Set<string>();
|
|
1165
|
+
const saveToken = (token: string): void => {
|
|
1166
|
+
// The token renders on several redraws (and again on exit) — capture once.
|
|
1167
|
+
if (capturing) return;
|
|
1168
|
+
capturing = true;
|
|
1169
|
+
// ADR-076: adding an EXTRA account routes the captured token to the sink
|
|
1170
|
+
// (which stores it as a separate account) instead of the default env.
|
|
1171
|
+
if (onToken) {
|
|
1172
|
+
Promise.resolve(onToken(token))
|
|
1173
|
+
.then((r) => done(r, true))
|
|
1174
|
+
.catch((err) =>
|
|
1175
|
+
done(
|
|
1176
|
+
{ ok: false, message: err instanceof Error ? err.message : String(err) },
|
|
1177
|
+
true,
|
|
1178
|
+
),
|
|
1179
|
+
);
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
|
|
1183
|
+
done({ ok: true, message: `${label} connected.` }, true);
|
|
1184
|
+
};
|
|
1185
|
+
const onChunk = (b: Buffer): void => {
|
|
1186
|
+
raw += b.toString("utf8");
|
|
1187
|
+
// A pending spinner redraws for minutes — keep only the tail (the token
|
|
1188
|
+
// and final screens are at the end) so re-sanitizing stays cheap.
|
|
1189
|
+
if (raw.length > 262_144) raw = raw.slice(-131_072);
|
|
1190
|
+
const text = sanitizeTerminalOutput(raw);
|
|
1191
|
+
// Act only on COMPLETE lines: a trailing partial may still be missing
|
|
1192
|
+
// the rest of an escape sequence — or the rest of the TOKEN, and the
|
|
1193
|
+
// extractor would happily return a truncated prefix.
|
|
1194
|
+
const complete = text.slice(0, text.lastIndexOf("\n") + 1);
|
|
1195
|
+
for (const line of significantLines(complete)) {
|
|
1196
|
+
// Each distinct line once — Ink redraws the same screen constantly
|
|
1197
|
+
// and would flood the UI log.
|
|
1198
|
+
if (!forwarded.has(line)) {
|
|
1199
|
+
forwarded.add(line);
|
|
1200
|
+
onLog(line);
|
|
1201
|
+
}
|
|
1202
|
+
// Auto-acknowledge keypress gates (once per distinct screen text) —
|
|
1203
|
+
// with a PTY the CLI accepts input, and nobody is at its keyboard.
|
|
1204
|
+
if (/press enter/i.test(line) && !acked.has(line)) {
|
|
1205
|
+
acked.add(line);
|
|
1206
|
+
try {
|
|
1207
|
+
child.stdin?.write("\r");
|
|
1208
|
+
} catch {
|
|
1209
|
+
/* best effort */
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// The whole point: capture the token the moment it renders. The CLI's
|
|
1214
|
+
// final screen lingers for a keypress; we don't wait for exit.
|
|
1215
|
+
const token = extractClaudeToken(complete);
|
|
1216
|
+
if (token) saveToken(token);
|
|
1217
|
+
};
|
|
1218
|
+
child.stdout?.on("data", onChunk);
|
|
1219
|
+
child.stderr?.on("data", onChunk);
|
|
1220
|
+
child.on("error", (err: NodeJS.ErrnoException) =>
|
|
1221
|
+
done({
|
|
1222
|
+
ok: false,
|
|
1223
|
+
message:
|
|
1224
|
+
err.code === "ENOENT"
|
|
1225
|
+
? `${label} CLI not found on PATH — install Claude Code first.`
|
|
1226
|
+
: err.message,
|
|
1227
|
+
}),
|
|
1228
|
+
);
|
|
1229
|
+
child.on("exit", () => {
|
|
1230
|
+
const token = extractClaudeToken(sanitizeTerminalOutput(raw));
|
|
1231
|
+
if (!token) {
|
|
1232
|
+
done({
|
|
1233
|
+
ok: false,
|
|
1234
|
+
message: `The sign-in didn't finish. ${TOKEN_FLOW_FALLBACK}`,
|
|
1235
|
+
});
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
saveToken(token);
|
|
1239
|
+
});
|
|
1240
|
+
timer = setTimeout(
|
|
1241
|
+
() =>
|
|
1242
|
+
done(
|
|
1243
|
+
{
|
|
1244
|
+
ok: false,
|
|
1245
|
+
message: `Timed out waiting for the browser authorization. ${TOKEN_FLOW_FALLBACK}`,
|
|
1246
|
+
},
|
|
1247
|
+
true,
|
|
1248
|
+
),
|
|
1249
|
+
TOKEN_FLOW_TIMEOUT_MS,
|
|
1250
|
+
);
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function runLoginCommand(
|
|
1255
|
+
kind: EngineKind,
|
|
1256
|
+
label: string,
|
|
1257
|
+
onLog: (line: string) => void,
|
|
1258
|
+
s: EngineSeams,
|
|
1259
|
+
): Promise<ConnectResult> {
|
|
1260
|
+
let child: ChildProcess;
|
|
1261
|
+
try {
|
|
1262
|
+
child = await spawnEngineCli(kind, ["login"], s);
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
1265
|
+
}
|
|
1266
|
+
return new Promise((resolve) => {
|
|
1267
|
+
let settled = false;
|
|
1268
|
+
const done = (r: ConnectResult): void => {
|
|
1269
|
+
if (!settled) {
|
|
1270
|
+
settled = true;
|
|
1271
|
+
resolve(r);
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
child.stdout?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
|
|
1275
|
+
child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
|
|
1276
|
+
child.on("error", (err: NodeJS.ErrnoException) =>
|
|
1277
|
+
done({
|
|
1278
|
+
ok: false,
|
|
1279
|
+
message:
|
|
1280
|
+
err.code === "ENOENT"
|
|
1281
|
+
? `${label} CLI not found — install it first.`
|
|
1282
|
+
: err.message,
|
|
1283
|
+
}),
|
|
1284
|
+
);
|
|
1285
|
+
child.on("exit", () => {
|
|
1286
|
+
done(
|
|
1287
|
+
detect(kind, s)
|
|
1288
|
+
? { ok: true, message: `${label} connected.` }
|
|
1289
|
+
: { ok: false, message: `${label} login didn't complete.` },
|
|
1290
|
+
);
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function relay(text: string, onLog: (line: string) => void): void {
|
|
1296
|
+
for (const l of text.split(/\r?\n/)) {
|
|
1297
|
+
if (l.trim()) onLog(l.trim());
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** Upsert KEY=value into the host's `.env.local` (0600) AND process.env. */
|
|
1302
|
+
function upsertEnvLocal(key: string, value: string, s: EngineSeams): void {
|
|
1303
|
+
const file = s.envLocalPath();
|
|
1304
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1305
|
+
let body = "";
|
|
1306
|
+
try {
|
|
1307
|
+
body = readFileSync(file, "utf8");
|
|
1308
|
+
} catch {
|
|
1309
|
+
/* new file */
|
|
1310
|
+
}
|
|
1311
|
+
const line = `${key}=${value}`;
|
|
1312
|
+
const re = new RegExp(`^${key}=.*$`, "m");
|
|
1313
|
+
body = re.test(body)
|
|
1314
|
+
? body.replace(re, line)
|
|
1315
|
+
: body + (body && !body.endsWith("\n") ? "\n" : "") + line + "\n";
|
|
1316
|
+
writeFileSync(file, body, { mode: 0o600 });
|
|
1317
|
+
try {
|
|
1318
|
+
chmodSync(file, 0o600);
|
|
1319
|
+
} catch {
|
|
1320
|
+
/* best effort */
|
|
1321
|
+
}
|
|
1322
|
+
// Immediate effect: the running host reads creds from process.env (adapters'
|
|
1323
|
+
// available() + task-up env injection), not by reloading .env.local — so set
|
|
1324
|
+
// it here or a connect wouldn't take effect until a restart.
|
|
1325
|
+
s.procEnv[key] = value;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** Remove a KEY line from `.env.local` and process.env (no-op if absent). */
|
|
1329
|
+
function removeEnvLocal(key: string, s: EngineSeams): void {
|
|
1330
|
+
const file = s.envLocalPath();
|
|
1331
|
+
try {
|
|
1332
|
+
const body = readFileSync(file, "utf8");
|
|
1333
|
+
const next = body
|
|
1334
|
+
.split(/\r?\n/)
|
|
1335
|
+
.filter((l) => !new RegExp(`^${key}=`).test(l))
|
|
1336
|
+
.join("\n");
|
|
1337
|
+
writeFileSync(file, next, { mode: 0o600 });
|
|
1338
|
+
} catch {
|
|
1339
|
+
/* no file */
|
|
1340
|
+
}
|
|
1341
|
+
delete s.procEnv[key];
|
|
1342
|
+
}
|