@moondeck/station 0.0.1 → 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 +115 -8
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/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/bin/moondeck-station.mjs +0 -3
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §12 on the sign-in box: a person's GitHub sign-in and SSH key as
|
|
3
|
+
* revisions, the repository picker reading the exact revision the cloud
|
|
4
|
+
* names, and the revocation of a retired GitHub token.
|
|
5
|
+
*
|
|
6
|
+
* - GitHub: the cloud's callback obtained the token; the box writes it as the
|
|
7
|
+
* revision the cloud named and answers with the name and version.
|
|
8
|
+
* - SSH: the box mints the key in memory, writes the private half to the
|
|
9
|
+
* revision's parameter (its public half as a tag) and answers with the
|
|
10
|
+
* public half. Nothing touches the box's disk. A mint retried for a
|
|
11
|
+
* revision that already holds a key answers that key's public half, never
|
|
12
|
+
* the one it just made, so the cloud always shows the key tasks use.
|
|
13
|
+
* - Each write ends at a deadline inside the cloud's answer deadline: the
|
|
14
|
+
* AWS CLI is killed then, so nothing the cloud gave up on is sent later.
|
|
15
|
+
* - The box reads a GitHub revision back only to list the person's
|
|
16
|
+
* installations and repositories for the picker, and to revoke a retired
|
|
17
|
+
* token at GitHub with the App's client id and secret, which ride the
|
|
18
|
+
* revoke frame and are kept nowhere.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
isGithubToken,
|
|
25
|
+
isPersonGithubParameter,
|
|
26
|
+
parseLeaseParameterName,
|
|
27
|
+
parseLeasedValue,
|
|
28
|
+
serializeLeasedValue,
|
|
29
|
+
type CaptureResultWire,
|
|
30
|
+
type GithubCaptureFrame,
|
|
31
|
+
type GithubRevokeAckFrame,
|
|
32
|
+
type GithubRevokeFrame,
|
|
33
|
+
type PersonCaptureAckFrame,
|
|
34
|
+
type SshMintFrame,
|
|
35
|
+
} from "../src/cloud-credentials";
|
|
36
|
+
import { captureTags, parseCaptureFlow, writeCapture, type ParsedCaptureFlow } from "./capture-flow";
|
|
37
|
+
import { ImmutableParameterConflictError, storeFailureKind, type CredentialStore } from "./credential-store";
|
|
38
|
+
import { githubApiOrigin } from "./github-origin";
|
|
39
|
+
|
|
40
|
+
export type PersonCaptureAnswer = Omit<PersonCaptureAckFrame, "kind" | "opId">;
|
|
41
|
+
export type GithubRevokeAnswer = Omit<GithubRevokeAckFrame, "kind" | "opId">;
|
|
42
|
+
|
|
43
|
+
/** One step's whole budget, inside the cloud's 30-second answer deadline. */
|
|
44
|
+
export const PERSON_STEP_DEADLINE_MS = 25_000;
|
|
45
|
+
/** The tag an SSH revision's public half rides on: the key blob, base64. */
|
|
46
|
+
export const SSH_PUBLIC_KEY_TAG = "uai_ssh_public_key";
|
|
47
|
+
|
|
48
|
+
function refused(error: string): PersonCaptureAnswer {
|
|
49
|
+
return { capture: null, publicKey: null, error };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tookTooLong(): PersonCaptureAnswer {
|
|
53
|
+
return refused("The sign-in box took too long; nothing usable was kept. Try again.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function writePerson(
|
|
57
|
+
store: CredentialStore,
|
|
58
|
+
flow: ParsedCaptureFlow,
|
|
59
|
+
lease: string,
|
|
60
|
+
signal: AbortSignal,
|
|
61
|
+
leaseTags?: Record<string, string>,
|
|
62
|
+
): Promise<CaptureResultWire> {
|
|
63
|
+
return writeCapture(store, flow, { lease, broker: null, expiresAt: null, leaseTags }, { signal });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Write the GitHub token the cloud's callback obtained as this revision. */
|
|
67
|
+
export async function captureGithub(
|
|
68
|
+
frame: GithubCaptureFrame,
|
|
69
|
+
deps: { store: CredentialStore | null; deadlineMs?: number },
|
|
70
|
+
): Promise<PersonCaptureAnswer> {
|
|
71
|
+
const flow = parseCaptureFlow(frame.flow);
|
|
72
|
+
if (!flow || flow.location.class !== "github" || flow.brokerParameter !== null) {
|
|
73
|
+
return refused("The sign-in's destination is invalid; nothing was kept.");
|
|
74
|
+
}
|
|
75
|
+
if (!deps.store) return refused("The sign-in box has no credential store.");
|
|
76
|
+
const signal = AbortSignal.timeout(deps.deadlineMs ?? PERSON_STEP_DEADLINE_MS);
|
|
77
|
+
try {
|
|
78
|
+
// A retry for this revision carries the same token: the cloud's callback
|
|
79
|
+
// spends its state once, so one revision never meets two sign-ins.
|
|
80
|
+
const capture = await writePerson(
|
|
81
|
+
deps.store,
|
|
82
|
+
flow,
|
|
83
|
+
serializeLeasedValue({ v: 1, part: "lease", location: flow.location, class: "github", token: frame.token }),
|
|
84
|
+
signal,
|
|
85
|
+
);
|
|
86
|
+
return { capture, publicKey: null, error: null };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (signal.aborted) return tookTooLong();
|
|
89
|
+
console.warn(`[person-capture] github ${flow.credentialId}/${flow.revision}: write failed (${storeFailureKind(err)})`);
|
|
90
|
+
return refused("The credential store refused the write; nothing was kept.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Mint an SSH key as this revision. */
|
|
95
|
+
export async function mintSsh(
|
|
96
|
+
frame: SshMintFrame,
|
|
97
|
+
deps: { store: CredentialStore | null; keygen?: (comment: string) => OpenSshKeyPair; deadlineMs?: number },
|
|
98
|
+
): Promise<PersonCaptureAnswer> {
|
|
99
|
+
const flow = parseCaptureFlow(frame.flow);
|
|
100
|
+
if (!flow || flow.location.class !== "ssh" || flow.brokerParameter !== null) {
|
|
101
|
+
return refused("The key's destination is invalid; nothing was kept.");
|
|
102
|
+
}
|
|
103
|
+
if (!deps.store) return refused("The sign-in box has no credential store.");
|
|
104
|
+
const signal = AbortSignal.timeout(deps.deadlineMs ?? PERSON_STEP_DEADLINE_MS);
|
|
105
|
+
const comment = sshComment(flow);
|
|
106
|
+
let key: OpenSshKeyPair;
|
|
107
|
+
try {
|
|
108
|
+
key = (deps.keygen ?? openSshEd25519)(comment);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.warn(`[person-capture] ssh ${flow.credentialId}/${flow.revision}: no key was made (${storeFailureKind(err)})`);
|
|
111
|
+
return refused("The sign-in box could not make a key; nothing was kept.");
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const capture = await writePerson(
|
|
115
|
+
deps.store,
|
|
116
|
+
flow,
|
|
117
|
+
serializeLeasedValue({
|
|
118
|
+
v: 1,
|
|
119
|
+
part: "lease",
|
|
120
|
+
location: flow.location,
|
|
121
|
+
class: "ssh",
|
|
122
|
+
privateKey: key.privateKey,
|
|
123
|
+
publicKey: key.publicKey,
|
|
124
|
+
}),
|
|
125
|
+
signal,
|
|
126
|
+
{ [SSH_PUBLIC_KEY_TAG]: publicKeyBlob(key.publicKey) },
|
|
127
|
+
);
|
|
128
|
+
return { capture, publicKey: key.publicKey, error: null };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (signal.aborted) return tookTooLong();
|
|
131
|
+
if (err instanceof ImmutableParameterConflictError) {
|
|
132
|
+
// This revision already holds a key (an earlier attempt whose answer
|
|
133
|
+
// was lost): answer its public half. The key just made is dropped.
|
|
134
|
+
// Under the mint's own deadline: the recovery is part of the step.
|
|
135
|
+
const earlier = await keyAlreadyMinted(deps.store, flow, signal).catch(() => null);
|
|
136
|
+
if (signal.aborted) return tookTooLong();
|
|
137
|
+
if (earlier) {
|
|
138
|
+
return {
|
|
139
|
+
capture: { credentialId: flow.credentialId, revision: flow.revision, leaseVersion: 1, expiresAt: null },
|
|
140
|
+
publicKey: `ssh-ed25519 ${earlier} ${comment}`,
|
|
141
|
+
error: null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
console.warn(`[person-capture] ssh ${flow.credentialId}/${flow.revision}: write failed (${storeFailureKind(err)})`);
|
|
146
|
+
return refused("The credential store refused the write; nothing was kept.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sshComment(flow: ParsedCaptureFlow): string {
|
|
151
|
+
return `uai-${flow.location.ownerId}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The base64 key blob of an `ssh-ed25519 <blob> <comment>` line. */
|
|
155
|
+
function publicKeyBlob(publicKey: string): string {
|
|
156
|
+
return publicKey.split(" ")[1] ?? "";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The key blob an SSH revision's parameter already carries, when that
|
|
160
|
+
* parameter is exactly this revision's (every identity tag agrees) and
|
|
161
|
+
* the blob is an ed25519 public key; null otherwise. Metadata only. */
|
|
162
|
+
async function keyAlreadyMinted(
|
|
163
|
+
store: CredentialStore,
|
|
164
|
+
flow: ParsedCaptureFlow,
|
|
165
|
+
signal: AbortSignal,
|
|
166
|
+
): Promise<string | null> {
|
|
167
|
+
const observed = await store.listTags(flow.leaseParameter, { signal });
|
|
168
|
+
for (const [tag, value] of Object.entries(captureTags(flow, "lease"))) {
|
|
169
|
+
if (observed[tag] !== value) return null;
|
|
170
|
+
}
|
|
171
|
+
const blob = observed[SSH_PUBLIC_KEY_TAG];
|
|
172
|
+
return typeof blob === "string" && isEd25519Blob(blob) ? blob : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isEd25519Blob(blob: string): boolean {
|
|
176
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return false;
|
|
177
|
+
const bytes = Buffer.from(blob, "base64");
|
|
178
|
+
const type = Buffer.from("ssh-ed25519");
|
|
179
|
+
return (
|
|
180
|
+
bytes.length === 4 + type.length + 4 + 32 &&
|
|
181
|
+
bytes.readUInt32BE(0) === type.length &&
|
|
182
|
+
bytes.subarray(4, 4 + type.length).equals(type) &&
|
|
183
|
+
bytes.readUInt32BE(4 + type.length) === 32
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The token of the GitHub revision at `parameter`, which must be this
|
|
189
|
+
* person's: null when it is not there. Throws when the store cannot answer,
|
|
190
|
+
* so "could not read" is never taken for "signed out".
|
|
191
|
+
*/
|
|
192
|
+
export async function readGithubRevision(
|
|
193
|
+
store: CredentialStore,
|
|
194
|
+
parameter: string,
|
|
195
|
+
userId: string,
|
|
196
|
+
signal?: AbortSignal,
|
|
197
|
+
): Promise<string | null> {
|
|
198
|
+
if (!isPersonGithubParameter(parameter, userId)) {
|
|
199
|
+
throw new Error("the revision named is not this person's GitHub sign-in");
|
|
200
|
+
}
|
|
201
|
+
const raw = await store.get(parameter, { signal });
|
|
202
|
+
if (raw === null) return null;
|
|
203
|
+
const location = parseLeaseParameterName(parameter)!;
|
|
204
|
+
const value = parseLeasedValue(raw, location);
|
|
205
|
+
if (!value || value.class !== "github") throw new Error("the GitHub revision is malformed");
|
|
206
|
+
return value.token;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Revoke a retired revision's token at GitHub. `absent` when the parameter
|
|
211
|
+
* is gone or GitHub no longer knows the token; `retry` for anything that
|
|
212
|
+
* might pass later. The answer never carries the token.
|
|
213
|
+
*/
|
|
214
|
+
export async function revokeGithub(
|
|
215
|
+
frame: GithubRevokeFrame,
|
|
216
|
+
deps: { store: CredentialStore | null; fetch?: typeof fetch; apiOrigin?: string; deadlineMs?: number },
|
|
217
|
+
): Promise<GithubRevokeAnswer> {
|
|
218
|
+
if (!deps.store) return { outcome: "retry", error: "the sign-in box has no credential store" };
|
|
219
|
+
const location = parseLeaseParameterName(frame.parameter);
|
|
220
|
+
if (!location || location.class !== "github") {
|
|
221
|
+
return { outcome: "retry", error: "the revision named is not a GitHub sign-in" };
|
|
222
|
+
}
|
|
223
|
+
// One deadline over the whole revocation, the read and GitHub's answer
|
|
224
|
+
// together, inside the cloud's own: never two bounds stacked.
|
|
225
|
+
const signal = AbortSignal.timeout(deps.deadlineMs ?? PERSON_STEP_DEADLINE_MS);
|
|
226
|
+
let token: string | null;
|
|
227
|
+
try {
|
|
228
|
+
token = await readGithubRevision(deps.store, frame.parameter, location.ownerId, signal);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// The detail stays in the box's log; the cloud gets a fixed message.
|
|
231
|
+
console.warn(`[person-capture] revoke ${frame.parameter}: read failed (${storeFailureKind(err)})`);
|
|
232
|
+
return { outcome: "retry", error: "could not read the revision" };
|
|
233
|
+
}
|
|
234
|
+
if (token === null || !isGithubToken(token)) return { outcome: "absent", error: null };
|
|
235
|
+
const origin = deps.apiOrigin ?? githubApiOrigin();
|
|
236
|
+
const basic = Buffer.from(`${frame.clientId}:${frame.clientSecret}`).toString("base64");
|
|
237
|
+
let res: Response;
|
|
238
|
+
try {
|
|
239
|
+
res = await (deps.fetch ?? fetch)(`${origin}/applications/${encodeURIComponent(frame.clientId)}/token`, {
|
|
240
|
+
method: "DELETE",
|
|
241
|
+
headers: {
|
|
242
|
+
authorization: `Basic ${basic}`,
|
|
243
|
+
accept: "application/vnd.github+json",
|
|
244
|
+
"content-type": "application/json",
|
|
245
|
+
"user-agent": "uai-signin-box",
|
|
246
|
+
"x-github-api-version": "2022-11-28",
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify({ access_token: token }),
|
|
249
|
+
redirect: "error",
|
|
250
|
+
signal,
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.warn(`[person-capture] revoke ${frame.parameter}: GitHub did not answer (${storeFailureKind(err)})`);
|
|
254
|
+
return { outcome: "retry", error: "GitHub did not answer" };
|
|
255
|
+
}
|
|
256
|
+
// Only the status matters; GitHub's words are not read, let alone relayed.
|
|
257
|
+
void res.body?.cancel().catch(() => {});
|
|
258
|
+
// 204: revoked. 404: GitHub knows no such token (revoked, expired, or its
|
|
259
|
+
// grant was removed): the same end state.
|
|
260
|
+
if (res.status === 204) return { outcome: "revoked", error: null };
|
|
261
|
+
if (res.status === 404) return { outcome: "absent", error: null };
|
|
262
|
+
return { outcome: "retry", error: `GitHub answered HTTP ${res.status}` };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// An ed25519 key in OpenSSH's own formats, made in memory.
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
export interface OpenSshKeyPair {
|
|
272
|
+
/** `-----BEGIN OPENSSH PRIVATE KEY-----`, unencrypted. */
|
|
273
|
+
privateKey: string;
|
|
274
|
+
/** `ssh-ed25519 <base64> <comment>`. */
|
|
275
|
+
publicKey: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sshString(bytes: Buffer): Buffer {
|
|
279
|
+
const length = Buffer.alloc(4);
|
|
280
|
+
length.writeUInt32BE(bytes.length);
|
|
281
|
+
return Buffer.concat([length, bytes]);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sshUint32(value: number): Buffer {
|
|
285
|
+
const out = Buffer.alloc(4);
|
|
286
|
+
out.writeUInt32BE(value >>> 0);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The openssh-key-v1 container (PROTOCOL.key in OpenSSH) for one unencrypted
|
|
292
|
+
* ed25519 key: what `ssh-keygen -t ed25519 -N ""` writes, without a file.
|
|
293
|
+
*/
|
|
294
|
+
export function openSshEd25519(comment: string): OpenSshKeyPair {
|
|
295
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
296
|
+
const pub = Buffer.from(publicKey.export({ format: "jwk" }).x!, "base64url");
|
|
297
|
+
const seed = Buffer.from(privateKey.export({ format: "jwk" }).d!, "base64url");
|
|
298
|
+
const keyType = Buffer.from("ssh-ed25519");
|
|
299
|
+
const publicBlob = Buffer.concat([sshString(keyType), sshString(pub)]);
|
|
300
|
+
const check = randomBytes(4).readUInt32BE();
|
|
301
|
+
let secret = Buffer.concat([
|
|
302
|
+
sshUint32(check),
|
|
303
|
+
sshUint32(check),
|
|
304
|
+
sshString(keyType),
|
|
305
|
+
sshString(pub),
|
|
306
|
+
// OpenSSH stores the 32-byte seed followed by the public key.
|
|
307
|
+
sshString(Buffer.concat([seed, pub])),
|
|
308
|
+
sshString(Buffer.from(comment)),
|
|
309
|
+
]);
|
|
310
|
+
const padding: number[] = [];
|
|
311
|
+
for (let i = 1; (secret.length + padding.length) % 8 !== 0; i += 1) padding.push(i);
|
|
312
|
+
secret = Buffer.concat([secret, Buffer.from(padding)]);
|
|
313
|
+
const body = Buffer.concat([
|
|
314
|
+
Buffer.from("openssh-key-v1\0"),
|
|
315
|
+
sshString(Buffer.from("none")),
|
|
316
|
+
sshString(Buffer.from("none")),
|
|
317
|
+
sshString(Buffer.alloc(0)),
|
|
318
|
+
sshUint32(1),
|
|
319
|
+
sshString(publicBlob),
|
|
320
|
+
sshString(secret),
|
|
321
|
+
]);
|
|
322
|
+
const lines = body.toString("base64").match(/.{1,70}/g) ?? [];
|
|
323
|
+
return {
|
|
324
|
+
privateKey: ["-----BEGIN OPENSSH PRIVATE KEY-----", ...lines, "-----END OPENSSH PRIVATE KEY-----", ""].join("\n"),
|
|
325
|
+
publicKey: `ssh-ed25519 ${publicBlob.toString("base64")} ${comment}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const PREVIEW_PORT_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
4
|
+
|
|
5
|
+
export const PreviewPortDeclarationSchema = z.object({
|
|
6
|
+
name: z
|
|
7
|
+
.string()
|
|
8
|
+
.trim()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(63)
|
|
11
|
+
.regex(
|
|
12
|
+
PREVIEW_PORT_NAME_RE,
|
|
13
|
+
"name must be lowercase letters, digits, _ or -, starting with a letter or digit",
|
|
14
|
+
),
|
|
15
|
+
containerPort: z.coerce.number().int().min(1).max(65535),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const PreviewPortRuntimeSchema = z.object({
|
|
19
|
+
name: z.string().trim().min(1).max(63).regex(PREVIEW_PORT_NAME_RE),
|
|
20
|
+
hostPort: z.coerce.number().int().min(1).max(65535),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const PreviewPortDeclarationsSchema = z
|
|
24
|
+
.array(PreviewPortDeclarationSchema)
|
|
25
|
+
.max(20)
|
|
26
|
+
.superRefine((ports, ctx) => {
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
for (const [index, port] of ports.entries()) {
|
|
29
|
+
if (seen.has(port.name)) {
|
|
30
|
+
ctx.addIssue({
|
|
31
|
+
code: z.ZodIssueCode.custom,
|
|
32
|
+
path: [index, "name"],
|
|
33
|
+
message: `duplicate preview port name: ${port.name}`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
seen.add(port.name);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const PreviewPortRuntimesSchema = z
|
|
41
|
+
.array(PreviewPortRuntimeSchema)
|
|
42
|
+
.max(20)
|
|
43
|
+
.superRefine((ports, ctx) => {
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
for (const [index, port] of ports.entries()) {
|
|
46
|
+
if (seen.has(port.name)) {
|
|
47
|
+
ctx.addIssue({
|
|
48
|
+
code: z.ZodIssueCode.custom,
|
|
49
|
+
path: [index, "name"],
|
|
50
|
+
message: `duplicate preview port name: ${port.name}`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
seen.add(port.name);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export type PreviewPortDeclaration = z.infer<
|
|
58
|
+
typeof PreviewPortDeclarationSchema
|
|
59
|
+
>;
|
|
60
|
+
export type PreviewPortRuntime = z.infer<typeof PreviewPortRuntimeSchema>;
|
|
61
|
+
|
|
62
|
+
export function parsePreviewPortDeclarations(
|
|
63
|
+
raw: string | null | undefined,
|
|
64
|
+
): PreviewPortDeclaration[] {
|
|
65
|
+
return parseJsonList(raw, PreviewPortDeclarationsSchema);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parsePreviewPortRuntimes(
|
|
69
|
+
raw: string | null | undefined,
|
|
70
|
+
): PreviewPortRuntime[] {
|
|
71
|
+
return parseJsonList(raw, PreviewPortRuntimesSchema);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseJsonList<T extends z.ZodTypeAny>(
|
|
75
|
+
raw: string | null | undefined,
|
|
76
|
+
schema: T,
|
|
77
|
+
): z.infer<T> {
|
|
78
|
+
if (!raw) return [];
|
|
79
|
+
try {
|
|
80
|
+
const parsed = schema.safeParse(JSON.parse(raw));
|
|
81
|
+
return parsed.success ? parsed.data : [];
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|