@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/env.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agent env access. Runtime state lives in host.sqlite on the host
|
|
3
|
+
* machine; cloud metadata arrives over the host protocol.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, resolve, sep } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import { STATION } from "../src/station-identity";
|
|
14
|
+
|
|
15
|
+
const schema = z.object({
|
|
16
|
+
// Base dir for mutable host state (.env.local + the data dir). See the
|
|
17
|
+
// resolution of `uaiHome` below. Optional — defaults are repo-aware.
|
|
18
|
+
UAI_HOME: z.string().min(1).optional(),
|
|
19
|
+
UAI_DATA_DIR: z.string().min(1).optional(),
|
|
20
|
+
UAI_AGENT_DIR: z.string().min(1).optional(),
|
|
21
|
+
UAI_HOST_TOKEN: z.string().min(32).optional(),
|
|
22
|
+
UAI_HOST_BRIDGE_PORT: z.coerce.number().int().min(1).max(65535).default(8789),
|
|
23
|
+
UAI_CLOUD_URL: z.string().min(1).optional(),
|
|
24
|
+
UAI_HOST_ID: z.string().min(1).optional(),
|
|
25
|
+
// ADR-101: container-runtime.ts validates this separately. Keeping the raw
|
|
26
|
+
// value here prevents one misspelled runtime preference from making the
|
|
27
|
+
// schema fallback discard every otherwise-valid host setting.
|
|
28
|
+
UAI_RUNTIME: z.string().optional(),
|
|
29
|
+
// Host master key file (ADR-027) — 32 bytes, AES-256-GCM for the host
|
|
30
|
+
// secret store. Defaults to $UAI_DATA_DIR/host-master.key; generated on
|
|
31
|
+
// first use.
|
|
32
|
+
UAI_HOST_KEY_FILE: z.string().min(1).optional(),
|
|
33
|
+
// Transitional single-user GitHub PAT, for the one user who has not
|
|
34
|
+
// connected GitHub. Inert without UAI_GH_PAT_FALLBACK_USER_ID: the PAT is a
|
|
35
|
+
// host-wide credential, so it may only be injected into tasks owned by the
|
|
36
|
+
// user it belongs to. See setupTaskGithub().
|
|
37
|
+
UAI_GH_PAT_FALLBACK: z.string().min(1).optional(),
|
|
38
|
+
UAI_GH_PAT_FALLBACK_USER_ID: z.string().min(1).optional(),
|
|
39
|
+
UAI_WORKSPACE_ROOT: z.string().min(1).default(`~/${STATION.homeDirName}`),
|
|
40
|
+
// Crash reporting (ADR-071, docs/observability.md). Default-ON for
|
|
41
|
+
// installed hosts via the DSN baked into lib/obs.ts; UAI_SENTRY_DSN
|
|
42
|
+
// overrides it (self-hosted Sentry), UAI_TELEMETRY_DISABLED=1 turns it
|
|
43
|
+
// off entirely — zero telemetry bytes leave the machine.
|
|
44
|
+
UAI_SENTRY_DSN: z.string().min(1).optional(),
|
|
45
|
+
UAI_TELEMETRY_DISABLED: z.string().optional(),
|
|
46
|
+
// ADR-134 credential store. ONLY Uai-operated hosts set `aws-ssm`: engine
|
|
47
|
+
// accounts are then mirrored to AWS Parameter Store (SecureString, KMS) and
|
|
48
|
+
// reconciled at start. Owned hosts keep `local` — credentials never leave
|
|
49
|
+
// the machine. See docs/credential-store.md.
|
|
50
|
+
UAI_CREDENTIAL_STORE: z.enum(["local", "aws-ssm"]).default("local"),
|
|
51
|
+
UAI_CREDENTIAL_STORE_PREFIX: z
|
|
52
|
+
.string()
|
|
53
|
+
.regex(/^\/[A-Za-z0-9_.\-]+(?:\/[A-Za-z0-9_.\-]+)*$/)
|
|
54
|
+
.default("/uai"),
|
|
55
|
+
UAI_CREDENTIAL_STORE_KMS_KEY: z.string().min(1).default("alias/uai-credentials"),
|
|
56
|
+
// Shared with the ADR-121 machine provider (read raw there); the store
|
|
57
|
+
// needs it too, so surface it here for one validated read.
|
|
58
|
+
UAI_AWS_REGION: z.string().min(1).optional(),
|
|
59
|
+
// ADR-135: the broker Lambda a Uai-operated host invokes for a reactive
|
|
60
|
+
// refresh (name or ARN). Unset = scheduled refreshes only.
|
|
61
|
+
UAI_TOKEN_BROKER_FUNCTION: z.string().min(1).optional(),
|
|
62
|
+
// Who keeps the standard image and agent CLIs current. `host` (owned hosts):
|
|
63
|
+
// this process, hourly. `external` (Uai-operated hosts): a cloud job keeps
|
|
64
|
+
// the payload AMI fresh and this host only reconciles once at start.
|
|
65
|
+
UAI_IMAGE_MAINTENANCE: z.enum(["host", "external"]).default("host"),
|
|
66
|
+
NODE_ENV: z
|
|
67
|
+
.enum(["development", "production", "test"])
|
|
68
|
+
.default("development"),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const parsed = schema.safeParse(process.env);
|
|
72
|
+
|
|
73
|
+
if (!parsed.success) {
|
|
74
|
+
console.error(
|
|
75
|
+
"uai host-agent: invalid environment",
|
|
76
|
+
parsed.error.flatten().fieldErrors,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const raw = parsed.success ? parsed.data : schema.parse({});
|
|
81
|
+
// The PAT is validated above but kept nowhere here: its one reader takes it
|
|
82
|
+
// from process.env when it injects it (github-tokens.ts), and the cutover
|
|
83
|
+
// purge removes it from there (ADR-154 §13). A long-lived copy in this
|
|
84
|
+
// module would outlive that purge for the life of the process.
|
|
85
|
+
delete raw.UAI_GH_PAT_FALLBACK;
|
|
86
|
+
const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/lib
|
|
87
|
+
|
|
88
|
+
function expandHome(path: string): string {
|
|
89
|
+
if (path === "~") return homedir();
|
|
90
|
+
if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
|
|
91
|
+
return path;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Walk up from `start` until `pred` holds; null at the filesystem root. */
|
|
95
|
+
function findUp(start: string, pred: (dir: string) => boolean): string | null {
|
|
96
|
+
let dir = start;
|
|
97
|
+
for (;;) {
|
|
98
|
+
if (pred(dir)) return dir;
|
|
99
|
+
const parent = dirname(dir);
|
|
100
|
+
if (parent === dir) return null;
|
|
101
|
+
dir = parent;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Monorepo root iff we're running from a checkout: a dir holding BOTH
|
|
106
|
+
// pnpm-workspace.yaml and a host-agent/ child. The node_modules guard makes
|
|
107
|
+
// the "null when installed" comment TRUE (review finding: a tarball beneath
|
|
108
|
+
// a foreign pnpm workspace with a sibling host-agent/ resolved uaiHome — and
|
|
109
|
+
// the telemetry disclosure marker — into that workspace instead of ~/.uai).
|
|
110
|
+
// Real path first: pnpm reaches this file through symlinks. Keep in sync
|
|
111
|
+
// with src/load-env.ts, which applies the same rule.
|
|
112
|
+
const realHere = (() => {
|
|
113
|
+
try {
|
|
114
|
+
return realpathSync(here);
|
|
115
|
+
} catch {
|
|
116
|
+
return here;
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
const repoRoot = realHere.split(sep).includes("node_modules")
|
|
120
|
+
? null
|
|
121
|
+
: findUp(
|
|
122
|
+
realHere,
|
|
123
|
+
(d) =>
|
|
124
|
+
existsSync(resolve(d, "pnpm-workspace.yaml")) &&
|
|
125
|
+
existsSync(resolve(d, "host-agent")),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// UAI_HOME: where .env.local + the data dir live. Explicit UAI_HOME wins; then
|
|
129
|
+
// the parent of an explicit UAI_DATA_DIR; then the repo root (dev checkout);
|
|
130
|
+
// then ~/.moondeck (installed; a station never uses the old host's ~/.uai). Keep this in sync with src/load-env.ts,
|
|
131
|
+
// which resolves the same base independently (it runs before this module).
|
|
132
|
+
const uaiHome = raw.UAI_HOME
|
|
133
|
+
? resolve(expandHome(raw.UAI_HOME))
|
|
134
|
+
: raw.UAI_DATA_DIR
|
|
135
|
+
? dirname(resolve(expandHome(raw.UAI_DATA_DIR)))
|
|
136
|
+
: (repoRoot ?? resolve(homedir(), STATION.homeDirName));
|
|
137
|
+
|
|
138
|
+
// Data dir: explicit UAI_DATA_DIR wins, kept verbatim (e.g. the live host's
|
|
139
|
+
// pinned <repo>/.data); else <repo>/.data in a checkout, or ~/.moondeck/data when
|
|
140
|
+
// installed.
|
|
141
|
+
const dataDir = raw.UAI_DATA_DIR
|
|
142
|
+
? resolve(expandHome(raw.UAI_DATA_DIR))
|
|
143
|
+
: resolve(uaiHome, repoRoot ? ".data" : "data");
|
|
144
|
+
|
|
145
|
+
const workspaceRoot = resolve(expandHome(raw.UAI_WORKSPACE_ROOT));
|
|
146
|
+
|
|
147
|
+
export const env = {
|
|
148
|
+
...raw,
|
|
149
|
+
uaiHome,
|
|
150
|
+
dataDir,
|
|
151
|
+
hostDbPath: resolve(dataDir, "host.sqlite"),
|
|
152
|
+
hostKeyPath: raw.UAI_HOST_KEY_FILE
|
|
153
|
+
? resolve(uaiHome, expandHome(raw.UAI_HOST_KEY_FILE))
|
|
154
|
+
: resolve(dataDir, "host-master.key"),
|
|
155
|
+
commandDbDir: resolve(dataDir, "host-command-dbs"),
|
|
156
|
+
// Agent scripts ship inside the package; lib/agent.ts resolves them off the
|
|
157
|
+
// package root by default. Only an explicit UAI_AGENT_DIR override surfaces
|
|
158
|
+
// here (undefined → use the shipped scripts).
|
|
159
|
+
agentDir: raw.UAI_AGENT_DIR
|
|
160
|
+
? resolve(uaiHome, expandHome(raw.UAI_AGENT_DIR))
|
|
161
|
+
: undefined,
|
|
162
|
+
workspaceRoot,
|
|
163
|
+
} as const;
|
|
164
|
+
|
|
165
|
+
export type Env = typeof env;
|
|
166
|
+
|
|
167
|
+
/** Absolute path to a project's uai-owned directory (holds its bare mirror). */
|
|
168
|
+
export function projectDir(projectId: string): string {
|
|
169
|
+
return resolve(env.workspaceRoot, "projects", projectId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Absolute path to a project's single bare mirror (ADR-022: one repo/project). */
|
|
173
|
+
export function projectRepoMirror(projectId: string): string {
|
|
174
|
+
return resolve(projectDir(projectId), "repo.git");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Absolute path to a task's directory (top-level; a task spans 0..N projects). */
|
|
178
|
+
export function taskDir(taskId: string): string {
|
|
179
|
+
return resolve(env.workspaceRoot, "tasks", taskId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Absolute path to a task's workspace root (parent of per-project worktrees). */
|
|
183
|
+
export function taskWorkspaceDir(taskId: string): string {
|
|
184
|
+
return resolve(taskDir(taskId), "workspace");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Absolute path to one selected project's worktree within a task. */
|
|
188
|
+
export function taskProjectWorktree(taskId: string, projectSlug: string): string {
|
|
189
|
+
return resolve(taskWorkspaceDir(taskId), projectSlug);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Absolute path to a task's ephemeral `.uai/` render dir (generated compose). */
|
|
193
|
+
export function taskUaiDir(taskId: string): string {
|
|
194
|
+
return resolve(taskDir(taskId), ".uai");
|
|
195
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §13: removing a credential by one name must not leave it under
|
|
3
|
+
* another. Shared by the cutover purge and the credential store's cleanup of
|
|
4
|
+
* staged writes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { lstatSync, readdirSync, readlinkSync } from "node:fs";
|
|
8
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Throws, naming each, while removing `path` would leave another name of
|
|
12
|
+
* what is in it: a link in it that leads out of it (what it leads to stays,
|
|
13
|
+
* and the runner's own writes, which follow links, may have filled it), and
|
|
14
|
+
* a file in it with a second name (a hard link: the other name keeps its
|
|
15
|
+
* bytes). A link that leads somewhere inside it goes with it. Walked with
|
|
16
|
+
* lstat, following nothing; a link's target is taken from its own
|
|
17
|
+
* directory, by name, which is how the kernel follows it: one that leads
|
|
18
|
+
* out through another link on the way is caught at that link. `path` itself
|
|
19
|
+
* is no link (the callers checked).
|
|
20
|
+
*/
|
|
21
|
+
export function assertNoAliases(path: string): void {
|
|
22
|
+
const found: string[] = [];
|
|
23
|
+
const visit = (at: string): void => {
|
|
24
|
+
const stats = lstatSync(at, { bigint: true });
|
|
25
|
+
if (stats.isSymbolicLink()) {
|
|
26
|
+
const to = resolve(dirname(at), readlinkSync(at));
|
|
27
|
+
if (!isWithin(path, to)) found.push(`${at} (a symbolic link to ${to})`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (stats.isDirectory()) {
|
|
31
|
+
for (const name of readdirSync(at)) visit(join(at, name));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (stats.nlink > 1n) found.push(`${at} (a file with ${stats.nlink} names)`);
|
|
35
|
+
};
|
|
36
|
+
visit(path);
|
|
37
|
+
if (found.length > 0) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`${found.join(", ")}: removing it would leave what the station wrote through ` +
|
|
40
|
+
`${found.length === 1 ? "that" : "those"}; nothing here is removed until ` +
|
|
41
|
+
`${found.length === 1 ? "it is" : "they are"} gone`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isWithin(dir: string, path: string): boolean {
|
|
47
|
+
const rel = relative(dir, path);
|
|
48
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
49
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §6, §12: what task-up's host-side clone and fetches authenticate
|
|
3
|
+
* with, served by this process from memory for exactly as long as the
|
|
4
|
+
* operation holds the task owner's credentials (`holdTaskGitCredentials`).
|
|
5
|
+
*
|
|
6
|
+
* - GitHub over HTTPS: git's credential helper is a small script that asks
|
|
7
|
+
* this process for github.com's token over a private socket, each time git
|
|
8
|
+
* needs it. Nothing caches it: no `git credential-cache` daemon outlives
|
|
9
|
+
* the clone.
|
|
10
|
+
* - SSH: an ssh-agent this process speaks itself, holding only the task
|
|
11
|
+
* owner's ed25519 key (what the sign-in box mints), in memory. Only the
|
|
12
|
+
* public key is written, so ssh can name the identity it wants.
|
|
13
|
+
*
|
|
14
|
+
* Everything lives in a 0700 directory under /tmp, whose path has no
|
|
15
|
+
* whitespace (git runs the helper through a shell). The service closes when
|
|
16
|
+
* the hold's signal aborts or the caller closes it: the sockets stop
|
|
17
|
+
* answering and the directory is removed. A process that dies takes its
|
|
18
|
+
* sockets with it, and what it leaves on disk holds no secret.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createPrivateKey, createPublicKey, sign, type KeyObject } from "node:crypto";
|
|
22
|
+
import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
import type { TaskGitCredentialHold } from "./credential-consumers/github";
|
|
27
|
+
|
|
28
|
+
/** What task-up is told, all non-secret paths. */
|
|
29
|
+
export interface CloneCredentialEnv {
|
|
30
|
+
/** The executable git runs as its credential helper (github.com only). */
|
|
31
|
+
UAI_GITHUB_CREDENTIAL_HELPER?: string;
|
|
32
|
+
/** The agent socket holding the owner's SSH key. */
|
|
33
|
+
UAI_TASK_SSH_AUTH_SOCK?: string;
|
|
34
|
+
/** The owner's public key: the identity ssh asks the agent for. */
|
|
35
|
+
UAI_TASK_SSH_PUBLIC_KEY?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CloneCredentialService {
|
|
39
|
+
env: CloneCredentialEnv;
|
|
40
|
+
/** Stop answering and remove the directory. Idempotent. */
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An SSH key the agent cannot hold: not an unencrypted ed25519 key, or one
|
|
45
|
+
* whose public half does not match. */
|
|
46
|
+
export class UnsupportedSshKey extends Error {
|
|
47
|
+
constructor(reason: string) {
|
|
48
|
+
super(`the SSH key cannot be used for the clone: ${reason}`);
|
|
49
|
+
this.name = "UnsupportedSshKey";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Serve `hold`'s credentials to task-up until the hold's signal aborts or
|
|
55
|
+
* `close()` is called. Rejects (serving nothing) when the hold is already
|
|
56
|
+
* aborted or its SSH key cannot be held.
|
|
57
|
+
*/
|
|
58
|
+
export async function serveCloneCredentials(
|
|
59
|
+
hold: Pick<TaskGitCredentialHold, "github" | "ssh" | "signal">,
|
|
60
|
+
): Promise<CloneCredentialService> {
|
|
61
|
+
if (hold.signal.aborted) throw new Error("the task's credentials were taken before the clone");
|
|
62
|
+
const key = hold.ssh ? ed25519Identity(hold.ssh.privateKey, hold.ssh.publicKey) : null;
|
|
63
|
+
const directory = mkdtempSync(join("/tmp", "uai-clone-credentials-"));
|
|
64
|
+
chmodSync(directory, 0o700);
|
|
65
|
+
const servers: Server[] = [];
|
|
66
|
+
const sockets = new Set<Socket>();
|
|
67
|
+
/** Listens not yet settled: closing fails them, so a start can never wait
|
|
68
|
+
* on a server closed under it. */
|
|
69
|
+
const starting = new Set<(error: Error) => void>();
|
|
70
|
+
let closing: Promise<void> | null = null;
|
|
71
|
+
const close = (): Promise<void> => {
|
|
72
|
+
closing ??= (async () => {
|
|
73
|
+
hold.signal.removeEventListener("abort", onAbort);
|
|
74
|
+
for (const fail of [...starting]) fail(new Error("the task's credentials were taken before the clone"));
|
|
75
|
+
for (const socket of sockets) socket.destroy();
|
|
76
|
+
await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
77
|
+
rmSync(directory, { recursive: true, force: true });
|
|
78
|
+
})();
|
|
79
|
+
return closing;
|
|
80
|
+
};
|
|
81
|
+
const onAbort = (): void => void close();
|
|
82
|
+
hold.signal.addEventListener("abort", onAbort);
|
|
83
|
+
|
|
84
|
+
const listen = async (path: string, handle: (socket: Socket) => void): Promise<void> => {
|
|
85
|
+
const server = createServer((socket) => {
|
|
86
|
+
if (closing) {
|
|
87
|
+
socket.destroy();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
sockets.add(socket);
|
|
91
|
+
socket.on("close", () => sockets.delete(socket));
|
|
92
|
+
socket.on("error", () => socket.destroy());
|
|
93
|
+
handle(socket);
|
|
94
|
+
});
|
|
95
|
+
servers.push(server);
|
|
96
|
+
await new Promise<void>((resolve, reject) => {
|
|
97
|
+
const fail = (error: Error): void => {
|
|
98
|
+
starting.delete(fail);
|
|
99
|
+
reject(error);
|
|
100
|
+
};
|
|
101
|
+
starting.add(fail);
|
|
102
|
+
server.once("error", fail);
|
|
103
|
+
server.listen(path, () => {
|
|
104
|
+
starting.delete(fail);
|
|
105
|
+
resolve();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
if (closing) throw new Error("the task's credentials were taken before the clone");
|
|
109
|
+
chmodSync(path, 0o600);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const env: CloneCredentialEnv = {};
|
|
113
|
+
try {
|
|
114
|
+
if (hold.github) {
|
|
115
|
+
const token = hold.github.token;
|
|
116
|
+
const socketPath = join(directory, "github.sock");
|
|
117
|
+
await listen(socketPath, (socket) => answerGithub(socket, token));
|
|
118
|
+
const node = join(directory, "node");
|
|
119
|
+
symlinkSync(process.execPath, node);
|
|
120
|
+
const helper = join(directory, "git-credential-uai");
|
|
121
|
+
writeFileSync(helper, helperScript(node, socketPath), { mode: 0o700 });
|
|
122
|
+
env.UAI_GITHUB_CREDENTIAL_HELPER = helper;
|
|
123
|
+
}
|
|
124
|
+
if (key) {
|
|
125
|
+
const socketPath = join(directory, "ssh-agent.sock");
|
|
126
|
+
await listen(socketPath, (socket) => answerAgent(socket, key));
|
|
127
|
+
const publicKey = join(directory, "id_ed25519.pub");
|
|
128
|
+
writeFileSync(publicKey, `${key.publicLine}\n`, { mode: 0o600 });
|
|
129
|
+
env.UAI_TASK_SSH_AUTH_SOCK = socketPath;
|
|
130
|
+
env.UAI_TASK_SSH_PUBLIC_KEY = publicKey;
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
await close();
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
if (hold.signal.aborted) {
|
|
137
|
+
await close();
|
|
138
|
+
throw new Error("the task's credentials were taken before the clone");
|
|
139
|
+
}
|
|
140
|
+
return { env, close };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// GitHub: the credential helper and what it asks
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/** The helper git runs: only `get`, only https://github.com; it asks this
|
|
148
|
+
* process and prints what it answers (nothing once the service closed). */
|
|
149
|
+
function helperScript(node: string, socketPath: string): string {
|
|
150
|
+
return `#!${node}
|
|
151
|
+
"use strict";
|
|
152
|
+
if (process.argv[2] !== "get") process.exit(0);
|
|
153
|
+
let input = "";
|
|
154
|
+
process.stdin.setEncoding("utf8");
|
|
155
|
+
process.stdin.on("data", (chunk) => { input += chunk; });
|
|
156
|
+
process.stdin.on("end", () => {
|
|
157
|
+
const fields = {};
|
|
158
|
+
for (const line of input.split("\\n")) {
|
|
159
|
+
const at = line.indexOf("=");
|
|
160
|
+
if (at > 0) fields[line.slice(0, at)] = line.slice(at + 1);
|
|
161
|
+
}
|
|
162
|
+
if (fields.protocol !== "https" || fields.host !== "github.com") process.exit(0);
|
|
163
|
+
const socket = require("node:net").connect(${JSON.stringify(socketPath)});
|
|
164
|
+
let answer = "";
|
|
165
|
+
socket.setEncoding("utf8");
|
|
166
|
+
socket.on("data", (chunk) => { answer += chunk; });
|
|
167
|
+
socket.on("end", () => { process.stdout.write(answer); });
|
|
168
|
+
socket.on("error", () => process.exit(0));
|
|
169
|
+
socket.end("protocol=https\\nhost=github.com\\n");
|
|
170
|
+
});
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function answerGithub(socket: Socket, token: string): void {
|
|
175
|
+
let request = "";
|
|
176
|
+
socket.setEncoding("utf8");
|
|
177
|
+
socket.on("data", (chunk: string) => {
|
|
178
|
+
request += chunk;
|
|
179
|
+
if (request.length > 1024) socket.destroy();
|
|
180
|
+
});
|
|
181
|
+
socket.on("end", () => {
|
|
182
|
+
const fields = new Set(request.split("\n"));
|
|
183
|
+
if (fields.has("protocol=https") && fields.has("host=github.com")) {
|
|
184
|
+
socket.end(`username=x-access-token\npassword=${token}\n`);
|
|
185
|
+
} else {
|
|
186
|
+
socket.end();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// SSH: an agent holding one ed25519 key (draft-miller-ssh-agent)
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
const SSH_AGENT_FAILURE = 5;
|
|
196
|
+
const SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
197
|
+
const SSH_AGENT_IDENTITIES_ANSWER = 12;
|
|
198
|
+
const SSH_AGENTC_SIGN_REQUEST = 13;
|
|
199
|
+
const SSH_AGENT_SIGN_RESPONSE = 14;
|
|
200
|
+
/** A message larger than any request this agent answers. */
|
|
201
|
+
const MAX_AGENT_MESSAGE = 256 * 1024;
|
|
202
|
+
/** PKCS#8 for an ed25519 private key, up to its 32-byte seed (RFC 8410). */
|
|
203
|
+
const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
204
|
+
|
|
205
|
+
interface Ed25519Identity {
|
|
206
|
+
blob: Buffer;
|
|
207
|
+
comment: string;
|
|
208
|
+
publicLine: string;
|
|
209
|
+
privateKey: KeyObject;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sshString(bytes: Buffer): Buffer {
|
|
213
|
+
const length = Buffer.alloc(4);
|
|
214
|
+
length.writeUInt32BE(bytes.length);
|
|
215
|
+
return Buffer.concat([length, bytes]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Reads SSH wire fields in order; throws on anything short or malformed. */
|
|
219
|
+
class Reader {
|
|
220
|
+
#offset = 0;
|
|
221
|
+
constructor(private readonly bytes: Buffer) {}
|
|
222
|
+
uint32(): number {
|
|
223
|
+
if (this.#offset + 4 > this.bytes.length) throw new UnsupportedSshKey("a truncated field");
|
|
224
|
+
const value = this.bytes.readUInt32BE(this.#offset);
|
|
225
|
+
this.#offset += 4;
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
string(): Buffer {
|
|
229
|
+
const length = this.uint32();
|
|
230
|
+
if (this.#offset + length > this.bytes.length) throw new UnsupportedSshKey("a truncated field");
|
|
231
|
+
const value = this.bytes.subarray(this.#offset, this.#offset + length);
|
|
232
|
+
this.#offset += length;
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The one ed25519 identity in an unencrypted openssh-key-v1 private key,
|
|
238
|
+
* checked against its public line. */
|
|
239
|
+
export function ed25519Identity(privateKeyPem: string, publicLine: string): Ed25519Identity {
|
|
240
|
+
const pem = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/.exec(privateKeyPem);
|
|
241
|
+
if (!pem) throw new UnsupportedSshKey("not an OpenSSH private key");
|
|
242
|
+
const body = Buffer.from(pem[1]!.replace(/\s+/g, ""), "base64");
|
|
243
|
+
const magic = Buffer.from("openssh-key-v1\0");
|
|
244
|
+
if (!body.subarray(0, magic.length).equals(magic)) throw new UnsupportedSshKey("not an OpenSSH private key");
|
|
245
|
+
const outer = new Reader(body.subarray(magic.length));
|
|
246
|
+
const cipher = outer.string().toString();
|
|
247
|
+
const kdf = outer.string().toString();
|
|
248
|
+
outer.string();
|
|
249
|
+
if (cipher !== "none" || kdf !== "none") throw new UnsupportedSshKey("an encrypted key");
|
|
250
|
+
if (outer.uint32() !== 1) throw new UnsupportedSshKey("more than one key");
|
|
251
|
+
const blob = Buffer.from(outer.string());
|
|
252
|
+
const secret = new Reader(outer.string());
|
|
253
|
+
if (secret.uint32() !== secret.uint32()) throw new UnsupportedSshKey("a corrupt key");
|
|
254
|
+
if (secret.string().toString() !== "ssh-ed25519") throw new UnsupportedSshKey("not an ed25519 key");
|
|
255
|
+
const pub = secret.string();
|
|
256
|
+
const pair = secret.string();
|
|
257
|
+
const comment = secret.string().toString();
|
|
258
|
+
if (pub.length !== 32 || pair.length !== 64 || !pair.subarray(32).equals(pub)) {
|
|
259
|
+
throw new UnsupportedSshKey("a corrupt ed25519 key");
|
|
260
|
+
}
|
|
261
|
+
if (!blob.equals(Buffer.concat([sshString(Buffer.from("ssh-ed25519")), sshString(pub)]))) {
|
|
262
|
+
throw new UnsupportedSshKey("a corrupt ed25519 key");
|
|
263
|
+
}
|
|
264
|
+
const [type, encoded] = publicLine.trim().split(/\s+/);
|
|
265
|
+
if (type !== "ssh-ed25519" || !encoded || !Buffer.from(encoded, "base64").equals(blob)) {
|
|
266
|
+
throw new UnsupportedSshKey("its public key does not match");
|
|
267
|
+
}
|
|
268
|
+
const privateKey = createPrivateKey({
|
|
269
|
+
key: Buffer.concat([PKCS8_ED25519_PREFIX, pair.subarray(0, 32)]),
|
|
270
|
+
format: "der",
|
|
271
|
+
type: "pkcs8",
|
|
272
|
+
});
|
|
273
|
+
// The seed's own public key: the one the agent advertises must be the one
|
|
274
|
+
// it signs with.
|
|
275
|
+
const derived = Buffer.from(createPublicKey(privateKey).export({ format: "jwk" }).x ?? "", "base64url");
|
|
276
|
+
if (!derived.equals(pub)) throw new UnsupportedSshKey("a corrupt ed25519 key");
|
|
277
|
+
return { blob, comment, publicLine: `ssh-ed25519 ${encoded}`, privateKey };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function agentMessage(type: number, payload: Buffer = Buffer.alloc(0)): Buffer {
|
|
281
|
+
const header = Buffer.alloc(5);
|
|
282
|
+
header.writeUInt32BE(payload.length + 1);
|
|
283
|
+
header.writeUInt8(type, 4);
|
|
284
|
+
return Buffer.concat([header, payload]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The agent's answer to one request: its identity, a signature by it, or
|
|
288
|
+
* failure for everything else (adding, removing, locking, extensions). */
|
|
289
|
+
export function agentAnswer(request: Buffer, key: Ed25519Identity): Buffer {
|
|
290
|
+
const type = request.readUInt8(0);
|
|
291
|
+
if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
|
|
292
|
+
const count = Buffer.alloc(4);
|
|
293
|
+
count.writeUInt32BE(1);
|
|
294
|
+
return agentMessage(
|
|
295
|
+
SSH_AGENT_IDENTITIES_ANSWER,
|
|
296
|
+
Buffer.concat([count, sshString(key.blob), sshString(Buffer.from(key.comment))]),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (type === SSH_AGENTC_SIGN_REQUEST) {
|
|
300
|
+
try {
|
|
301
|
+
const reader = new Reader(request.subarray(1));
|
|
302
|
+
const blob = reader.string();
|
|
303
|
+
const data = reader.string();
|
|
304
|
+
reader.uint32();
|
|
305
|
+
if (!blob.equals(key.blob)) return agentMessage(SSH_AGENT_FAILURE);
|
|
306
|
+
const signature = sign(null, data, key.privateKey);
|
|
307
|
+
return agentMessage(
|
|
308
|
+
SSH_AGENT_SIGN_RESPONSE,
|
|
309
|
+
sshString(Buffer.concat([sshString(Buffer.from("ssh-ed25519")), sshString(signature)])),
|
|
310
|
+
);
|
|
311
|
+
} catch {
|
|
312
|
+
return agentMessage(SSH_AGENT_FAILURE);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return agentMessage(SSH_AGENT_FAILURE);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function answerAgent(socket: Socket, key: Ed25519Identity): void {
|
|
319
|
+
let pending = Buffer.alloc(0);
|
|
320
|
+
socket.on("data", (chunk: Buffer) => {
|
|
321
|
+
pending = Buffer.concat([pending, chunk]);
|
|
322
|
+
while (pending.length >= 4) {
|
|
323
|
+
const length = pending.readUInt32BE(0);
|
|
324
|
+
if (length === 0 || length > MAX_AGENT_MESSAGE) {
|
|
325
|
+
socket.destroy();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (pending.length < 4 + length) return;
|
|
329
|
+
const request = pending.subarray(4, 4 + length);
|
|
330
|
+
pending = pending.subarray(4 + length);
|
|
331
|
+
socket.write(agentAnswer(request, key));
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|