@moondeck/station 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/** ADR-164: compile one validated, hard-pinned connection into a CLI launch.
|
|
2
|
+
* Only the runner calls these builders. No caller may supply arbitrary TOML,
|
|
3
|
+
* environment keys, credential-helper commands or a second destination. */
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
customConnectionEnabled,
|
|
8
|
+
isCustomConnectionReason,
|
|
9
|
+
parseCustomConnectionLease,
|
|
10
|
+
parseCustomConnectionMaterial,
|
|
11
|
+
validConnectionModels,
|
|
12
|
+
type CustomConnectionCombination,
|
|
13
|
+
type CustomConnectionLease,
|
|
14
|
+
type CustomConnectionMaterial,
|
|
15
|
+
type CustomConnectionReason,
|
|
16
|
+
} from "../src/custom-connections";
|
|
17
|
+
import { isIP } from "node:net";
|
|
18
|
+
import { isOwnedInternalConnectionRuntime } from "./internal-connections";
|
|
19
|
+
import { isPublicAddress, isInternalConnectionAddress } from "./secrets-proxy/address";
|
|
20
|
+
|
|
21
|
+
export class CustomConnectionLaunchError extends Error {
|
|
22
|
+
/** `detail` says what this runner refused or could not do, in words for the
|
|
23
|
+
* chat. Only ever about the runner's own checks: addresses, ports and names
|
|
24
|
+
* the person configured, never an upstream's text and never the key. */
|
|
25
|
+
constructor(readonly reason: CustomConnectionReason, readonly detail?: string) {
|
|
26
|
+
super(reason);
|
|
27
|
+
this.name = "CustomConnectionLaunchError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What a failed pre-launch step (home, address check, connect check) is
|
|
33
|
+
* reported as. Every local refusal says so: a step that fails for any reason
|
|
34
|
+
* the runner did not anticipate is `unsupported` with a plain account, never
|
|
35
|
+
* the `upstream-error` that `connectionFailureReason` would default an unknown
|
|
36
|
+
* exception to (that label means the provider answered badly, and nothing was
|
|
37
|
+
* asked of the provider yet).
|
|
38
|
+
*/
|
|
39
|
+
export function launchRefusal(error: unknown): { reason: CustomConnectionReason; detail?: string } {
|
|
40
|
+
if (error instanceof CustomConnectionLaunchError) return { reason: error.reason, ...(error.detail ? { detail: error.detail } : {}) };
|
|
41
|
+
const text = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
42
|
+
if (isCustomConnectionReason(text)) return { reason: text };
|
|
43
|
+
const why = text.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
44
|
+
return { reason: "unsupported", detail: `the station's pre-launch check could not run${why ? `: ${why}` : ""}` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** JSON string escaping is also valid for TOML basic string values. Never
|
|
48
|
+
* interpolate an unquoted model id, URL or label into a -c argument. */
|
|
49
|
+
export function tomlString(value: string): string {
|
|
50
|
+
return JSON.stringify(value).replace(/\u007f/g, "\\u007f");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A native account must not inherit a previous connection's route, token or
|
|
54
|
+
* model aliases. Native credentials are applied AFTER these blanks. AWS's
|
|
55
|
+
* ordinary identity is isolated below only for a connection launch. */
|
|
56
|
+
const CONNECTION_ENV_NAMES = [
|
|
57
|
+
"ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_MODEL",
|
|
58
|
+
"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST",
|
|
59
|
+
"ANTHROPIC_DEFAULT_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
60
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
61
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL",
|
|
62
|
+
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION", "CLAUDE_CODE_SUBAGENT_MODEL",
|
|
63
|
+
"CLAUDE_CODE_SUBAGENT_MODEL_FORCE", "CLAUDE_CODE_EXTRA_BODY",
|
|
64
|
+
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_MANTLE", "CLAUDE_CODE_USE_GATEWAY",
|
|
65
|
+
"CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD", "CLAUDE_CODE_API_BASE_URL",
|
|
66
|
+
"ANTHROPIC_UNIX_SOCKET", "_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL",
|
|
67
|
+
"CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY",
|
|
68
|
+
"CLAUDE_CODE_USE_ANTHROPIC_AWS", "CLAUDE_CODE_SKIP_BEDROCK_AUTH",
|
|
69
|
+
"CLAUDE_CODE_SKIP_MANTLE_AUTH", "CLAUDE_CODE_SKIP_VERTEX_AUTH",
|
|
70
|
+
"CLAUDE_CODE_SKIP_FOUNDRY_AUTH", "ANTHROPIC_BEDROCK_BASE_URL",
|
|
71
|
+
"ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "ANTHROPIC_BEDROCK_REGION_PREFIX",
|
|
72
|
+
"ANTHROPIC_VERTEX_BASE_URL", "ANTHROPIC_VERTEX_PROJECT_ID",
|
|
73
|
+
"ANTHROPIC_FOUNDRY_BASE_URL", "ANTHROPIC_FOUNDRY_RESOURCE",
|
|
74
|
+
"ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_FOUNDRY_AUTH_TOKEN",
|
|
75
|
+
"ANTHROPIC_AWS_API_KEY", "ANTHROPIC_AWS_BASE_URL", "ANTHROPIC_AWS_WORKSPACE_ID",
|
|
76
|
+
"ANTHROPIC_GOOGLE_CLOUD_BASE_URL", "CLAUDE_CODE_AUTO_MODE_MODEL",
|
|
77
|
+
"CLAUDE_CODE_BG_CLASSIFIER_MODEL", "CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL",
|
|
78
|
+
"ANTHROPIC_PROFILE", "ANTHROPIC_ORGANIZATION_ID",
|
|
79
|
+
"ANTHROPIC_WORKSPACE_ID", "ANTHROPIC_FEDERATION_RULE_ID",
|
|
80
|
+
"CLAUDE_CODE_DISABLE_MODEL_ACCESS_FALLBACK",
|
|
81
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
|
82
|
+
"OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_ORGANIZATION", "OPENAI_PROJECT",
|
|
83
|
+
"UAI_CUSTOM_API_KEY", "UAI_INFERENCE_TOKEN", "AWS_BEARER_TOKEN_BEDROCK",
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
86
|
+
const ENGINE_AUTH_BLANKS = {
|
|
87
|
+
CLAUDE_CODE_OAUTH_TOKEN: "", CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "", CCR_OAUTH_TOKEN_FILE: "",
|
|
88
|
+
CLAUDE_CODE_HOST_AUTH_ENV_VAR: "", CLAUDE_CODE_HOST_CREDS_FILE: "",
|
|
89
|
+
ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", OPENAI_API_KEY: "",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const CONNECTION_NATIVE_BLANKS: Readonly<Record<string, string>> = Object.freeze(
|
|
93
|
+
Object.fromEntries(CONNECTION_ENV_NAMES.map(name => [name, ""])),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
export function connectionNativeBlanks(): Record<string, string> {
|
|
97
|
+
return { ...CONNECTION_NATIVE_BLANKS };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The AWS SDK must never consult an ambient credential provider or endpoint.
|
|
101
|
+
* Empty files, not a nonexistent path, make profile/process lookup inert. */
|
|
102
|
+
export const CONNECTION_AWS_BLANKS: Readonly<Record<string, string>> = Object.freeze(
|
|
103
|
+
Object.fromEntries([
|
|
104
|
+
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_SECURITY_TOKEN",
|
|
105
|
+
"AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_ROLE_ARN", "AWS_ROLE_SESSION_NAME",
|
|
106
|
+
"AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
|
107
|
+
"AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_CONTAINER_AUTHORIZATION_TOKEN",
|
|
108
|
+
"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", "AWS_ENDPOINT_URL",
|
|
109
|
+
"AWS_ENDPOINT_URL_BEDROCK", "AWS_ENDPOINT_URL_BEDROCK_RUNTIME",
|
|
110
|
+
"AWS_EC2_METADATA_SERVICE_ENDPOINT", "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE",
|
|
111
|
+
"AWS_REGION", "AWS_DEFAULT_REGION", "AWS_SDK_LOAD_CONFIG",
|
|
112
|
+
].map(name => [name, ""])),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/** Shared by connection launches and the Managed Engine delegation. */
|
|
116
|
+
export function endpointAuthEnv(
|
|
117
|
+
provider: "anthropic" | "openai",
|
|
118
|
+
baseUrl: string,
|
|
119
|
+
token: string,
|
|
120
|
+
authMode: "bearer" | "x-api-key" = "bearer",
|
|
121
|
+
tokenVariable = "UAI_CUSTOM_API_KEY",
|
|
122
|
+
): Record<string, string> {
|
|
123
|
+
return provider === "anthropic"
|
|
124
|
+
? { ...connectionNativeBlanks(), ...ENGINE_AUTH_BLANKS, ANTHROPIC_BASE_URL: baseUrl,
|
|
125
|
+
[authMode === "bearer" ? "ANTHROPIC_AUTH_TOKEN" : "ANTHROPIC_API_KEY"]: token }
|
|
126
|
+
: { ...connectionNativeBlanks(), ...ENGINE_AUTH_BLANKS,
|
|
127
|
+
...(tokenVariable === "UAI_INFERENCE_TOKEN" ? { OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: token } : {}),
|
|
128
|
+
[tokenVariable]: token };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** baseUrl is already the provider's Responses root, including /v1 where
|
|
132
|
+
* appropriate. Only Managed Engine's caller appends its own /v1. */
|
|
133
|
+
export function codexProviderOverrides(
|
|
134
|
+
baseUrl: string,
|
|
135
|
+
options: { provider?: "uai" | "uai_custom"; name?: string; envKey?: "UAI_INFERENCE_TOKEN" | "UAI_CUSTOM_API_KEY" } = {},
|
|
136
|
+
): string[] {
|
|
137
|
+
const provider = options.provider ?? "uai_custom";
|
|
138
|
+
return [
|
|
139
|
+
`model_provider=${provider}`,
|
|
140
|
+
`model_providers.${provider}.name=${tomlString(options.name ?? "Custom connection")}`,
|
|
141
|
+
`model_providers.${provider}.base_url=${tomlString(baseUrl)}`,
|
|
142
|
+
`model_providers.${provider}.env_key=${tomlString(options.envKey ?? "UAI_CUSTOM_API_KEY")}`,
|
|
143
|
+
`model_providers.${provider}.wire_api="responses"`,
|
|
144
|
+
`model_providers.${provider}.requires_openai_auth=false`,
|
|
145
|
+
`model_providers.${provider}.supports_websockets=false`,
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface CustomConnectionLaunch {
|
|
150
|
+
model: string;
|
|
151
|
+
agentEnv: Record<string, string>;
|
|
152
|
+
cliConfigOverrides: string[];
|
|
153
|
+
claudeSettings?: Record<string, unknown>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function buildCustomConnectionLaunch(args: {
|
|
157
|
+
material: CustomConnectionMaterial;
|
|
158
|
+
connection: CustomConnectionLease;
|
|
159
|
+
model?: string;
|
|
160
|
+
enabled: readonly CustomConnectionCombination[];
|
|
161
|
+
/** A connection-owned home prepared without copying a native account. */
|
|
162
|
+
home: string;
|
|
163
|
+
now?: number;
|
|
164
|
+
/** Actual destination provider, never inferred from sealed settings. */
|
|
165
|
+
runtimeProvider?: string;
|
|
166
|
+
}): CustomConnectionLaunch {
|
|
167
|
+
const material = parseCustomConnectionMaterial(args.material);
|
|
168
|
+
const connection = parseCustomConnectionLease(args.connection, args.material.engine);
|
|
169
|
+
if (!material || !connection || !customConnectionEnabled(material, args.enabled)) throw new CustomConnectionLaunchError("unsupported");
|
|
170
|
+
if (material.internal === true && !isOwnedInternalConnectionRuntime(args.runtimeProvider)) throw new CustomConnectionLaunchError("unsupported");
|
|
171
|
+
if ((material.internal === true) !== (connection.internal === true)) throw new CustomConnectionLaunchError("unsupported");
|
|
172
|
+
if (new URL(material.baseUrl).host !== connection.host || material.providerType !== connection.providerType || material.authMode !== connection.authMode || material.region !== connection.region || material.transport !== connection.transport || (material.expiresAt ?? null) !== (connection.expiresAt ?? null)) throw new CustomConnectionLaunchError("unsupported");
|
|
173
|
+
if (material.expiresAt !== undefined && material.expiresAt !== null && material.expiresAt <= (args.now ?? Date.now())) throw new CustomConnectionLaunchError("expired");
|
|
174
|
+
if (!validConnectionModels(connection.models, connection.defaultModel, connection.backgroundModel, material.engine, material.apiKey)) throw new CustomConnectionLaunchError("invalid-models");
|
|
175
|
+
const model = args.model ?? connection.defaultModel;
|
|
176
|
+
if (!connection.models.includes(model)) throw new CustomConnectionLaunchError("model-not-found");
|
|
177
|
+
assertConnectionHome(args.home);
|
|
178
|
+
const aws = { ...CONNECTION_AWS_BLANKS, AWS_EC2_METADATA_DISABLED: "true",
|
|
179
|
+
AWS_CONFIG_FILE: `${args.home}/aws-config`, AWS_SHARED_CREDENTIALS_FILE: `${args.home}/aws-credentials` };
|
|
180
|
+
const agentEnv: Record<string, string> = {
|
|
181
|
+
...connectionNativeBlanks(), ...ENGINE_AUTH_BLANKS, ...aws,
|
|
182
|
+
// Bound Claude's SDK attempts as well as its CLI retry loop. The
|
|
183
|
+
// host applies the connection's separate rate-limit recovery backoff.
|
|
184
|
+
AWS_MAX_ATTEMPTS: "1",
|
|
185
|
+
UAI_CUSTOM_CONNECTION: "1",
|
|
186
|
+
};
|
|
187
|
+
if (material.providerType === "bedrock") {
|
|
188
|
+
agentEnv.AWS_REGION = material.region!;
|
|
189
|
+
agentEnv.AWS_BEARER_TOKEN_BEDROCK = material.apiKey;
|
|
190
|
+
if (material.engine === "claude") agentEnv.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
191
|
+
} else {
|
|
192
|
+
Object.assign(agentEnv, endpointAuthEnv(material.engine === "claude" ? "anthropic" : "openai", material.baseUrl, material.apiKey, material.authMode));
|
|
193
|
+
}
|
|
194
|
+
if (material.engine === "claude") {
|
|
195
|
+
Object.assign(agentEnv, {
|
|
196
|
+
// Claude 2.1.312 ignores settings-sourced provider/auth/headers when
|
|
197
|
+
// its embedding host owns routing. Keep project hooks/plugins intact.
|
|
198
|
+
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
|
|
199
|
+
// Verified on 2.1.312: replaces its ten-retry cloud-auth loop while
|
|
200
|
+
// retaining one retry for a transient 429/5xx response.
|
|
201
|
+
CLAUDE_CODE_MAX_RETRIES: "1",
|
|
202
|
+
ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_FABLE_MODEL: model,
|
|
203
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: model, ANTHROPIC_DEFAULT_SONNET_MODEL: model,
|
|
204
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: connection.backgroundModel!, CLAUDE_CODE_SUBAGENT_MODEL: model,
|
|
205
|
+
CLAUDE_CODE_AUTO_MODE_MODEL: connection.backgroundModel!, CLAUDE_CODE_BG_CLASSIFIER_MODEL: connection.backgroundModel!,
|
|
206
|
+
CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL: model,
|
|
207
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", CLAUDE_CODE_DISABLE_MODEL_ACCESS_FALLBACK: "1",
|
|
208
|
+
});
|
|
209
|
+
return { model, agentEnv, cliConfigOverrides: [], claudeSettings: {
|
|
210
|
+
apiKeyHelper: "", awsAuthRefresh: "", awsCredentialExport: "", fallbackModel: [],
|
|
211
|
+
} };
|
|
212
|
+
}
|
|
213
|
+
agentEnv.CODEX_HOME = args.home;
|
|
214
|
+
const cliConfigOverrides = material.providerType === "bedrock"
|
|
215
|
+
? [`model_provider=${tomlString(material.transport === "runtime" ? "amazon-bedrock-runtime" : "amazon-bedrock")}`,
|
|
216
|
+
`model_providers.${material.transport === "runtime" ? "amazon-bedrock-runtime" : "amazon-bedrock"}.aws.region=${tomlString(material.region!)}`]
|
|
217
|
+
: codexProviderOverrides(material.baseUrl);
|
|
218
|
+
const provider = material.providerType === "bedrock"
|
|
219
|
+
? material.transport === "runtime" ? "amazon-bedrock-runtime" : "amazon-bedrock"
|
|
220
|
+
: "uai_custom";
|
|
221
|
+
// Codex 0.164 rejects retry overrides on its built-in AWS providers.
|
|
222
|
+
// The connection runner bounds their streamed retries instead.
|
|
223
|
+
const retryOverrides = material.providerType === "bedrock" ? [] : [
|
|
224
|
+
`model_providers.${provider}.request_max_retries=1`,
|
|
225
|
+
`model_providers.${provider}.stream_max_retries=1`,
|
|
226
|
+
];
|
|
227
|
+
return { model, agentEnv, cliConfigOverrides: [...cliConfigOverrides, ...retryOverrides,
|
|
228
|
+
'web_search="disabled"'] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const HOME_ROOT = "/home/node/.uai-connection";
|
|
232
|
+
function assertConnectionHome(home: string): void {
|
|
233
|
+
if (!new RegExp(`^${HOME_ROOT.replaceAll(".", "\\.")}/[A-Za-z0-9_-]{1,128}$`).test(home)) throw new CustomConnectionLaunchError("unsupported");
|
|
234
|
+
}
|
|
235
|
+
export function newConnectionHome(credentialId: string, revision: string, agentId: string, settingsHash: string): string {
|
|
236
|
+
const segment = createHash("sha256").update(JSON.stringify([credentialId, revision, agentId, settingsHash])).digest("hex");
|
|
237
|
+
return `${HOME_ROOT}/${segment}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const HOME_PREPARE = String.raw`
|
|
241
|
+
import os, stat, sys
|
|
242
|
+
home = sys.argv[1]
|
|
243
|
+
parent, name = home.rsplit("/", 1)
|
|
244
|
+
fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
|
|
245
|
+
try:
|
|
246
|
+
for part in parent.split("/"):
|
|
247
|
+
if not part: continue
|
|
248
|
+
try: os.mkdir(part, 0o700, dir_fd=fd)
|
|
249
|
+
except FileExistsError: pass
|
|
250
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
251
|
+
os.close(fd)
|
|
252
|
+
fd = child
|
|
253
|
+
info = os.fstat(fd)
|
|
254
|
+
if info.st_uid != os.getuid() or (info.st_mode & 0o077): raise PermissionError()
|
|
255
|
+
try: os.mkdir(name, 0o700, dir_fd=fd)
|
|
256
|
+
except FileExistsError: pass
|
|
257
|
+
child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
258
|
+
try:
|
|
259
|
+
info = os.fstat(child)
|
|
260
|
+
if info.st_uid != os.getuid() or (info.st_mode & 0o077): raise PermissionError()
|
|
261
|
+
# This provider authenticates through its scoped env key. A saved
|
|
262
|
+
# interactive login is never accepted when resuming the same home.
|
|
263
|
+
try: os.stat("auth.json", dir_fd=child, follow_symlinks=False)
|
|
264
|
+
except FileNotFoundError: pass
|
|
265
|
+
else: raise PermissionError()
|
|
266
|
+
# Provider/model/tool settings are generated as CLI overrides. The
|
|
267
|
+
# only config.toml we generate is empty; reject planted commands.
|
|
268
|
+
for filename in ["config.toml", "aws-config", "aws-credentials"]:
|
|
269
|
+
file = os.open(filename, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600, dir_fd=child)
|
|
270
|
+
try:
|
|
271
|
+
info = os.fstat(file)
|
|
272
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_size or (info.st_mode & 0o077): raise PermissionError()
|
|
273
|
+
finally: os.close(file)
|
|
274
|
+
finally: os.close(child)
|
|
275
|
+
finally: os.close(fd)
|
|
276
|
+
`;
|
|
277
|
+
|
|
278
|
+
/** Execute as the task user. Nonzero exit fails the launch; there is no
|
|
279
|
+
* fallback home. Reuses only a private home with the empty generated config
|
|
280
|
+
* and empty AWS credential files. */
|
|
281
|
+
export function connectionHomePreparation(home: string): [string, ...string[]] {
|
|
282
|
+
assertConnectionHome(home);
|
|
283
|
+
return ["/usr/bin/python3", "-I", "-c", HOME_PREPARE, home];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const DNS_LOOKUP = String.raw`require("node:dns").lookup(process.argv[1],{all:true,verbatim:true},(error,answers)=>{if(error||!answers.length||answers.length>64){process.exitCode=1;return;}process.stdout.write(JSON.stringify(answers.map(x=>x.address)));});`;
|
|
287
|
+
export function connectionDnsLookupArgv(baseUrl: string): [string, ...string[]] {
|
|
288
|
+
const hostname = new URL(baseUrl).hostname.replace(/^\[|\]$/g, "");
|
|
289
|
+
return ["node", "-e", DNS_LOOKUP, "--", hostname];
|
|
290
|
+
}
|
|
291
|
+
/** The name every Docker task container gets for its host (`extra_hosts:
|
|
292
|
+
* host.docker.internal:host-gateway`, task-up.sh). What it points at depends on
|
|
293
|
+
* the runtime: Docker Desktop 192.168.65.x and Linux Docker the bridge gateway
|
|
294
|
+
* (both private, so allowed anyway), OrbStack 0.250.250.254, which sits in
|
|
295
|
+
* 0.0.0.0/8 and so fails the address classifier. Apple containers have no such
|
|
296
|
+
* name; their host is the vmnet gateway (192.168.64.x, private). */
|
|
297
|
+
export const HOST_GATEWAY_NAME = "host.docker.internal";
|
|
298
|
+
/** OrbStack's host-gateway address (checked on the owner's Mac, 2026-11-01). */
|
|
299
|
+
export const ORBSTACK_HOST_GATEWAY = "0.250.250.254";
|
|
300
|
+
|
|
301
|
+
/** Evaluate all DNS answers from inside the task runtime, not the runner's
|
|
302
|
+
* own resolver. The caller bounds exec output/time and rejects nonzero exit.
|
|
303
|
+
* Returns the answers, all of which passed.
|
|
304
|
+
*
|
|
305
|
+
* An internal connection may also reach OrbStack's host gateway, but only as
|
|
306
|
+
* that one address and only through the name `host.docker.internal` (which the
|
|
307
|
+
* container's own hosts file defines): never the rest of 0.0.0.0/8, and never
|
|
308
|
+
* another name that resolves to it. */
|
|
309
|
+
export function validateConnectionAddresses(raw: string, internal = false, hostname?: string): string[] {
|
|
310
|
+
let addresses: unknown;
|
|
311
|
+
const unresolved = new CustomConnectionLaunchError("unreachable", `${hostname ? `${hostname} ` : "the name "}did not resolve from the task`);
|
|
312
|
+
try { addresses = JSON.parse(raw); } catch { throw unresolved; }
|
|
313
|
+
if (!Array.isArray(addresses) || !addresses.length || addresses.length > 64 || !addresses.every(x => typeof x === "string")) throw unresolved;
|
|
314
|
+
const viaHostGateway = internal && hostname?.toLowerCase().replace(/\.$/, "") === HOST_GATEWAY_NAME;
|
|
315
|
+
const allowed = (address: string): boolean =>
|
|
316
|
+
(internal ? isInternalConnectionAddress(address) : isPublicAddress(address)) ||
|
|
317
|
+
(viaHostGateway && address === ORBSTACK_HOST_GATEWAY);
|
|
318
|
+
const refused = (addresses as string[]).filter((address) => !allowed(address));
|
|
319
|
+
if (refused.length > 0) {
|
|
320
|
+
const where = hostname && isIP(hostname.replace(/^\[|\]$/g, "")) === 0 ? `${hostname} resolves to ` : "";
|
|
321
|
+
const list = refused.slice(0, 3).join(", ") + (refused.length > 3 ? ", …" : "");
|
|
322
|
+
throw new CustomConnectionLaunchError(
|
|
323
|
+
"private-address",
|
|
324
|
+
internal
|
|
325
|
+
? `${where}${list}, which an internal connection may not use (loopback, link-local and 0.0.0.0/8 are refused${hostname === HOST_GATEWAY_NAME ? "" : `; to reach the station's computer use ${HOST_GATEWAY_NAME}`})`
|
|
326
|
+
: `${where}${list}, which is not a public address`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return addresses as string[];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Whether this build's launch preflight lets an internal connection reach the
|
|
334
|
+
* runner's computer through `host.docker.internal`, OrbStack's address
|
|
335
|
+
* included: the promise `INTERNAL_HOST_GATEWAY_PROTOCOL_FEATURE` makes. Asked
|
|
336
|
+
* of the check itself, not assumed, so the feature can't be advertised by a
|
|
337
|
+
* build whose preflight would refuse what the cloud then suggests.
|
|
338
|
+
*/
|
|
339
|
+
export function internalHostGatewayAdvertisable(): boolean {
|
|
340
|
+
try {
|
|
341
|
+
return validateConnectionAddresses(JSON.stringify([ORBSTACK_HOST_GATEWAY]), true, HOST_GATEWAY_NAME).length === 1;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** How long an internal endpoint gets to accept a connection at launch. */
|
|
348
|
+
export const CONNECTION_CONNECT_TIMEOUT_MS = 3000;
|
|
349
|
+
// TCP handshake only: connect, then close at once. Nothing is written, so
|
|
350
|
+
// neither the key nor any request reaches the endpoint. Run only on addresses
|
|
351
|
+
// validateConnectionAddresses accepted; any one that answers passes.
|
|
352
|
+
const CONNECT_PROBE = String.raw`const net=require("node:net");const port=Number(process.argv[1]);const hosts=process.argv.slice(2);let pending=hosts.length;setTimeout(()=>process.exit(1),${CONNECTION_CONNECT_TIMEOUT_MS});for(const host of hosts){const s=net.connect({host,port});s.once("connect",()=>{s.destroy();process.exit(0);});s.once("error",()=>{if(--pending===0)process.exit(1);});}`;
|
|
353
|
+
/** The command that checks, from inside the task runtime, that one of the
|
|
354
|
+
* validated addresses accepts a TCP connection on the URL's port. A nonzero
|
|
355
|
+
* exit means `unreachable`. */
|
|
356
|
+
export function connectionConnectArgv(baseUrl: string, addresses: readonly string[]): [string, ...string[]] {
|
|
357
|
+
const url = new URL(baseUrl);
|
|
358
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
359
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || !addresses.length || addresses.length > 64 || !addresses.every(a => isIP(a) !== 0)) {
|
|
360
|
+
throw new CustomConnectionLaunchError("unreachable", "the endpoint's port or address could not be checked");
|
|
361
|
+
}
|
|
362
|
+
return ["node", "-e", CONNECT_PROBE, "--", String(port), ...addresses];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function redactConnectionText(value: string, key: string): string {
|
|
366
|
+
if (!key) return value;
|
|
367
|
+
return value.split(key).join("[redacted]".includes(key) ? "" : "[redacted]");
|
|
368
|
+
}
|
|
369
|
+
/** A diagnostic may be inspected to choose a reason, never sent back. */
|
|
370
|
+
export function connectionFailureReason(error: unknown): CustomConnectionReason {
|
|
371
|
+
if (error instanceof CustomConnectionLaunchError) return error.reason;
|
|
372
|
+
const o = error && typeof error === "object" ? error as Record<string, unknown> : {};
|
|
373
|
+
if (isCustomConnectionReason(o.reason)) return o.reason;
|
|
374
|
+
const status = o.statusCode ?? o.status;
|
|
375
|
+
const text = typeof error === "string" ? error : error instanceof Error ? error.message : "";
|
|
376
|
+
if (isCustomConnectionReason(text)) return text;
|
|
377
|
+
if (status === 401 || /\b401\b|unauthori[sz]ed|authentication_failed|unrecognized.?client|expired.?token|token.{0,80}(?:expired|revoked)|invalid api.?key/i.test(text)) return "unauthorized";
|
|
378
|
+
if (status === 403 || /\b403\b|forbidden|access.?denied/i.test(text)) return "forbidden";
|
|
379
|
+
if (status === 404 || /\b404\b|model.{0,30}not.found/i.test(text)) return "model-not-found";
|
|
380
|
+
if (status === 429 || /\b429\b|rate.?limit|throttl/i.test(text)) return "rate-limited";
|
|
381
|
+
if (/expired/i.test(text)) return "expired";
|
|
382
|
+
if (status === 408 || /timeout|timed.out/i.test(text)) return "timeout";
|
|
383
|
+
return "upstream-error";
|
|
384
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { AgentEvent } from "./agents/types";
|
|
2
|
+
|
|
3
|
+
import { connectionFailureReason, redactConnectionText } from "./custom-connection-launch";
|
|
4
|
+
import { UPSTREAM_TEXT_WITHHELD, upstreamTextWithheld } from "../src/custom-connections";
|
|
5
|
+
import { scrubHostString } from "./obs";
|
|
6
|
+
export { connectionFailureReason } from "./custom-connection-launch";
|
|
7
|
+
|
|
8
|
+
/** Redact literal secrets recursively, including tool arguments and notices. */
|
|
9
|
+
export function redactConnectionValue<T>(value: T, key: string): T {
|
|
10
|
+
if (typeof value === "string") return redactConnectionText(value, key) as T;
|
|
11
|
+
if (Array.isArray(value)) return value.map(v => redactConnectionValue(v, key)) as T;
|
|
12
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [redactConnectionValue(k, key), redactConnectionValue(v, key)])) as T;
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The longest excerpt of an upstream error that leaves the runner. */
|
|
17
|
+
export const CONNECTION_DETAIL_MAX = 200;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A short, scrubbed account of why a connection turn failed, for the chat and
|
|
21
|
+
* the connection's last error (ADR-164 addendum, 2026-11-01; owner: "surface
|
|
22
|
+
* the upstream's status code and a short excerpt"). It replaces nothing in
|
|
23
|
+
* `connectionFailureReason`: the reason code still drives every decision.
|
|
24
|
+
*
|
|
25
|
+
* What may leave: an HTTP status (3 digits, after the words that introduce
|
|
26
|
+
* one), and the upstream's own error message when it sent one in the usual
|
|
27
|
+
* JSON shapes, else its first line; one line, control characters removed, at
|
|
28
|
+
* most CONNECTION_DETAIL_MAX characters. Scrubbed first: the connection's
|
|
29
|
+
* exact key, then the runner's pattern scrub (tokens, bearer values, URL
|
|
30
|
+
* credentials and query strings, emails, home and workspace paths), then any
|
|
31
|
+
* opaque run of 32+ token characters. Before all of that, the text is withheld
|
|
32
|
+
* entirely (status and a fixed phrase only) when it contains the key under any
|
|
33
|
+
* splitting, or any 24+ letter-and-digit run (`upstreamTextWithheld`). Null
|
|
34
|
+
* when nothing is left.
|
|
35
|
+
*/
|
|
36
|
+
export function connectionFailureDetail(raw: string, key: string): string | null {
|
|
37
|
+
if (typeof raw !== "string" || !raw) return null;
|
|
38
|
+
const text = raw.length > 8192 ? raw.slice(0, 8192) : raw;
|
|
39
|
+
// After the words that introduce a status, else a bare 4xx/5xx that is not
|
|
40
|
+
// part of an address, port, version or longer number.
|
|
41
|
+
const status =
|
|
42
|
+
/(?:\bstatus(?:\s*code)?\s*[:=]?\s*|\bAPI Error:?\s*|\bHTTP\/?[\d.]*\s*|^\s*)([1-5]\d\d)\b/i.exec(text)?.[1] ??
|
|
43
|
+
/(?<![\w.:\-/])([45]\d\d)(?![\w.:\-/])/.exec(text.slice(0, 400))?.[1];
|
|
44
|
+
let body = text;
|
|
45
|
+
const json = /\{[\s\S]*\}/.exec(text)?.[0];
|
|
46
|
+
if (json) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(json) as Record<string, unknown>;
|
|
49
|
+
const error = parsed.error;
|
|
50
|
+
const message =
|
|
51
|
+
typeof error === "string" ? error
|
|
52
|
+
: error && typeof error === "object" && typeof (error as Record<string, unknown>).message === "string" ? (error as Record<string, string>).message
|
|
53
|
+
: typeof parsed.message === "string" ? parsed.message
|
|
54
|
+
: null;
|
|
55
|
+
if (message) body = message;
|
|
56
|
+
} catch {
|
|
57
|
+
// Not JSON after all: the first line below.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (body === text) {
|
|
61
|
+
// Drop what introduced the status, so it is not said twice.
|
|
62
|
+
body = text.replace(/^\s*(?:API Error:?\s*)?(?:HTTP\/?[\d.]*\s*)?[1-5]\d\d\b[\s:.\-–—]*/i, "");
|
|
63
|
+
}
|
|
64
|
+
// A hostile endpoint can echo the key split by a space, newline, zero-width
|
|
65
|
+
// character or dot, which no pattern above would see. If the key (however
|
|
66
|
+
// split) or any 24+ letter-and-digit run is in what the upstream said, none
|
|
67
|
+
// of its text is shown: the status and a fixed phrase stand in for it. The
|
|
68
|
+
// whole text and the extracted message are both checked, since JSON escapes
|
|
69
|
+
// (\u0073k-…) only decode in the latter.
|
|
70
|
+
if (upstreamTextWithheld(body, key, [text])) return status ? `HTTP ${status}: ${UPSTREAM_TEXT_WITHHELD}` : UPSTREAM_TEXT_WITHHELD;
|
|
71
|
+
let excerpt = redactConnectionText(body, key);
|
|
72
|
+
excerpt = scrubHostString(excerpt);
|
|
73
|
+
excerpt = excerpt.replace(/[A-Za-z0-9_\-+/=]{32,}/g, "[redacted]");
|
|
74
|
+
excerpt = excerpt.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
75
|
+
if (excerpt.length > CONNECTION_DETAIL_MAX) excerpt = `${excerpt.slice(0, CONNECTION_DETAIL_MAX - 1).trimEnd()}…`;
|
|
76
|
+
if (status && excerpt) return `HTTP ${status}: ${excerpt}`;
|
|
77
|
+
if (status) return `HTTP ${status}`;
|
|
78
|
+
return excerpt || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function sanitizeConnectionEvent(event: AgentEvent, key: string): AgentEvent {
|
|
82
|
+
const redacted = redactConnectionValue(event, key);
|
|
83
|
+
if (event.type === "error") {
|
|
84
|
+
const diagnostic = event.diagnosticMessage ?? event.message;
|
|
85
|
+
const message = connectionFailureReason(diagnostic);
|
|
86
|
+
let resetsAt = Number.isSafeInteger(event.resetsAt) && event.resetsAt! > 0 ? event.resetsAt : undefined;
|
|
87
|
+
if (message === "rate-limited") {
|
|
88
|
+
// Preserve only a numeric retry deadline, never provider text/headers.
|
|
89
|
+
const retryAfter = /retry-after\s*[:=]\s*([^\r\n]+)/i.exec(diagnostic)?.[1]?.trim();
|
|
90
|
+
const deadline = retryAfter && /^\d+(?:\.\d+)?$/.test(retryAfter)
|
|
91
|
+
? Date.now() + Math.ceil(Number(retryAfter) * 1000)
|
|
92
|
+
: retryAfter ? Date.parse(retryAfter) : NaN;
|
|
93
|
+
if (Number.isSafeInteger(deadline) && deadline > 0) resetsAt = Math.max(resetsAt ?? 0, deadline);
|
|
94
|
+
}
|
|
95
|
+
// An adapter that already reduced its message (Codex) sends the excerpt it
|
|
96
|
+
// scrubbed; it is scrubbed again here, which changes nothing it did right.
|
|
97
|
+
const detail = connectionFailureDetail(event.detail ?? diagnostic, key);
|
|
98
|
+
return { type: "error", message, aborted: event.aborted, code: event.code,
|
|
99
|
+
...(detail ? { detail } : {}),
|
|
100
|
+
...(message === "rate-limited" && resetsAt !== undefined ? { resetsAt } : {}) };
|
|
101
|
+
}
|
|
102
|
+
if (event.type === "notice") return { type: "notice", message: connectionFailureReason(event.message) };
|
|
103
|
+
if (event.type === "turn_complete" && event.usage) {
|
|
104
|
+
const usage = { ...event.usage };
|
|
105
|
+
delete usage.costUsd;
|
|
106
|
+
return redactConnectionValue({ ...event, usage }, key);
|
|
107
|
+
}
|
|
108
|
+
return redacted;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A delta may end inside a credential. Retain only a possible credential
|
|
112
|
+
* prefix; no byte of a complete key can cross the output boundary. */
|
|
113
|
+
export class ConnectionEventSanitizer {
|
|
114
|
+
private tail = "";
|
|
115
|
+
constructor(private readonly key: string) {}
|
|
116
|
+
|
|
117
|
+
push(event: AgentEvent): AgentEvent[] {
|
|
118
|
+
if (event.type === "message_delta") {
|
|
119
|
+
const text = this.tail + event.text;
|
|
120
|
+
let keep = 0;
|
|
121
|
+
for (let n = Math.min(this.key.length - 1, text.length); n > 0; n--) {
|
|
122
|
+
if (text.endsWith(this.key.slice(0, n))) { keep = n; break; }
|
|
123
|
+
}
|
|
124
|
+
// Replace first: a complete key may itself end in a key prefix.
|
|
125
|
+
const safe = redactConnectionText(text, this.key);
|
|
126
|
+
keep = Math.min(keep, safe.length);
|
|
127
|
+
if (keep && !safe.endsWith(this.key.slice(0, keep))) keep = 0;
|
|
128
|
+
this.tail = keep ? safe.slice(-keep) : "";
|
|
129
|
+
const emitted = keep ? safe.slice(0, -keep) : safe;
|
|
130
|
+
return emitted ? [{ type: "message_delta", text: emitted }] : [];
|
|
131
|
+
}
|
|
132
|
+
if (event.type === "message_complete") {
|
|
133
|
+
this.tail = ""; // The complete assembled message replaces the deltas.
|
|
134
|
+
return [sanitizeConnectionEvent(event, this.key)];
|
|
135
|
+
}
|
|
136
|
+
const final = event.type === "turn_complete" || event.type === "error" || event.type === "exit";
|
|
137
|
+
const pending: AgentEvent[] = final && this.tail ? [{ type: "message_delta", text: this.tail }] : [];
|
|
138
|
+
if (final) this.tail = "";
|
|
139
|
+
return [...pending, sanitizeConnectionEvent(event, this.key)];
|
|
140
|
+
}
|
|
141
|
+
}
|
package/lib/db.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-local SQLite access (ADR-020).
|
|
3
|
+
*
|
|
4
|
+
* This DB contains only runtime state for tasks assigned to this host. Cloud
|
|
5
|
+
* metadata remains in the cloud DB and arrives over command arguments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
import Database from "better-sqlite3";
|
|
13
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
14
|
+
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
15
|
+
|
|
16
|
+
import { env } from "./env";
|
|
17
|
+
import * as schema from "../db/schema";
|
|
18
|
+
|
|
19
|
+
let cached: ReturnType<typeof drizzle> | null = null;
|
|
20
|
+
let migrated = false;
|
|
21
|
+
|
|
22
|
+
function migrationsFolder(): string {
|
|
23
|
+
return resolve(
|
|
24
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
25
|
+
"..",
|
|
26
|
+
"db",
|
|
27
|
+
"migrations",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureDataDir(): void {
|
|
32
|
+
const dir = dirname(env.hostDbPath);
|
|
33
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function migrateHostDb(): void {
|
|
37
|
+
if (migrated) return;
|
|
38
|
+
ensureDataDir();
|
|
39
|
+
const sqlite = new Database(env.hostDbPath);
|
|
40
|
+
try {
|
|
41
|
+
const journalMode = String(
|
|
42
|
+
sqlite.pragma("journal_mode = WAL", { simple: true }),
|
|
43
|
+
).toLowerCase();
|
|
44
|
+
if (journalMode !== "wal") {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`host.sqlite journal_mode must be WAL; got "${journalMode}"`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
sqlite.pragma("foreign_keys = ON");
|
|
50
|
+
migrate(drizzle(sqlite), { migrationsFolder: migrationsFolder() });
|
|
51
|
+
migrated = true;
|
|
52
|
+
} finally {
|
|
53
|
+
sqlite.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getDb(): ReturnType<typeof drizzle> {
|
|
58
|
+
if (cached) return cached;
|
|
59
|
+
migrateHostDb();
|
|
60
|
+
const sqlite = new Database(env.hostDbPath);
|
|
61
|
+
sqlite.pragma("journal_mode = WAL");
|
|
62
|
+
sqlite.pragma("foreign_keys = ON");
|
|
63
|
+
cached = drizzle(sqlite, { schema });
|
|
64
|
+
return cached;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run `use` on the connection behind `getDb()`, for what drizzle does not
|
|
69
|
+
* express: a pragma, VACUUM, a WAL checkpoint. Never a second connection:
|
|
70
|
+
* `secure_delete` is a setting of the connection, so it has to be on the one
|
|
71
|
+
* every delete goes through. VACUUM and a checkpoint run outside any
|
|
72
|
+
* transaction.
|
|
73
|
+
*/
|
|
74
|
+
export function withHostSqlite<T>(use: (sqlite: Database.Database) => T): T {
|
|
75
|
+
return use(getDb().$client);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type Db = ReturnType<typeof getDb>;
|
|
79
|
+
export type { HostTask } from "../db/schema";
|
|
80
|
+
export { schema };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { statfs } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { HostCapabilities } from "../src/protocol";
|
|
5
|
+
|
|
6
|
+
type DiskSample = NonNullable<HostCapabilities["disk"]>;
|
|
7
|
+
const dockerInfo = (args: string[]): Promise<string | null> => new Promise(resolve => {
|
|
8
|
+
execFile("docker", args, { timeout: 5_000, maxBuffer: 4096 }, (error, stdout) => {
|
|
9
|
+
resolve(error ? null : stdout.trim());
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
async function dockerRoot(): Promise<string | null> {
|
|
13
|
+
// A remote daemon (including Docker Desktop's VM) has no local filesystem
|
|
14
|
+
// we can measure. Never report a coincidentally matching local path.
|
|
15
|
+
if (process.platform !== "linux") return null;
|
|
16
|
+
const endpoint = process.env.DOCKER_HOST && !process.env.DOCKER_CONTEXT
|
|
17
|
+
? process.env.DOCKER_HOST
|
|
18
|
+
: await dockerInfo(["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]);
|
|
19
|
+
if (!endpoint?.startsWith("unix://")) return null;
|
|
20
|
+
// Do not guess /var/lib/docker: data-root can be configured elsewhere.
|
|
21
|
+
const root = await dockerInfo(["info", "--format", "{{.DockerRootDir}}"]);
|
|
22
|
+
return root?.startsWith("/") ? root : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function measure(path: string | null): Promise<DiskSample["home"]> {
|
|
26
|
+
if (!path) return null;
|
|
27
|
+
try {
|
|
28
|
+
const fs = await statfs(path);
|
|
29
|
+
const totalBytes = fs.blocks * fs.bsize;
|
|
30
|
+
// bavail (not bfree) accounts for root-reserved blocks: the host's
|
|
31
|
+
// unprivileged updater cannot use those even though root can.
|
|
32
|
+
const availableBytes = Math.max(0, fs.bavail * fs.bsize);
|
|
33
|
+
if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0 || !Number.isSafeInteger(availableBytes) || availableBytes < 0 || availableBytes > totalBytes) return null;
|
|
34
|
+
return { totalBytes, availableBytes };
|
|
35
|
+
} catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function sampleDiskPressure(): Promise<DiskSample> {
|
|
39
|
+
const [home, docker] = await Promise.all([measure(homedir()), dockerRoot().then(measure)]);
|
|
40
|
+
// No local paths are sent to the cloud; an unavailable probe is unknown.
|
|
41
|
+
return { sampledAt: Date.now(), home, docker };
|
|
42
|
+
}
|