@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,2144 @@
|
|
|
1
|
+
import { LEASE_PARAMETER_ROOT, BROKER_PARAMETER_ROOT, MAX_CREDENTIAL_ID_CHARS, MAX_PARAMETER_NAME_CHARS, leaseParameterName, brokerParameterName, idPathSegment, parseIdPathSegment, isEngineKind, parseLeaseParameterName, parseBrokerParameterName, sameLocation, isIdValue, isTagValue, type CredentialClass, type CredentialScope, type CredentialLocation } from "./credential-location";
|
|
2
|
+
export * from "./credential-location";
|
|
3
|
+
import { parseCaptureFlowWire, parseCaptureResultWire, type CaptureFlowWire, type CaptureResultWire } from "./capture-wire";
|
|
4
|
+
export * from "./capture-wire";
|
|
5
|
+
import { exactObject, isPositiveInteger, isEpochMs } from "./credential-wire-validation";
|
|
6
|
+
import { parseCustomConnectionMaterial, parseCustomConnectionLease, parseCustomConnectionEnabled, type CustomConnectionMaterial, type CustomConnectionLease, type CustomConnectionCombination } from "./custom-connections";
|
|
7
|
+
export * from "./custom-connections";
|
|
8
|
+
import { MAX_TASK_SECRETS, TASK_SECRET_NAME, TASK_SECRET_STAGING_ID, type TaskSecretManifestEntry } from "./task-secrets";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ADR-154 — the shared contract for cloud-only credentials.
|
|
12
|
+
*
|
|
13
|
+
* Every repository env value, AI account, MCP connection, GitHub sign-in and
|
|
14
|
+
* SSH key has one copy, in Parameter Store. A runner (owned or task machine)
|
|
15
|
+
* reads what one live task needs under that task's lease and keeps it in
|
|
16
|
+
* memory only. This module is what the cloud and the runner agree on:
|
|
17
|
+
*
|
|
18
|
+
* - the parameter layout: lease-readable copies under `/uai/lease`, the
|
|
19
|
+
* broker-only OAuth material under the disjoint root `/uai-broker`, each
|
|
20
|
+
* credential's material in immutable revisions;
|
|
21
|
+
* - the v1 grant: the task's credential manifest (names and versions, never
|
|
22
|
+
* values) and the STS sessions that read it;
|
|
23
|
+
* - the frames that renew, narrow, refresh and activate it.
|
|
24
|
+
*
|
|
25
|
+
* It holds no I/O and imports nothing, so the cloud, the runner and the
|
|
26
|
+
* sign-in box can all load it. Every parser is exact: unknown keys, wrong
|
|
27
|
+
* types and out-of-bound sizes are refused, never dropped or repaired.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Advertised by a runner that reads credentials only through v1 leases. */
|
|
31
|
+
export const HOST_CLOUD_CREDENTIALS_FEATURE = "host-cloud-credentials-v1";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Advertised by a task machine only when it can install a task-scoped Files
|
|
35
|
+
* session and mount it from the v1 grant. This is deliberately separate from
|
|
36
|
+
* the general credentials feature: owned runners do not need Files-on-machine
|
|
37
|
+
* support to leave the legacy credential mode. The cloud must require both
|
|
38
|
+
* features for a task machine's activation, taskUp dispatch and v1 lease;
|
|
39
|
+
* this advertisement alone is not an admission check.
|
|
40
|
+
*/
|
|
41
|
+
export const HOST_TASK_FILES_LEASE_FEATURE = "host-task-files-lease-v1";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Advertised by a runner whose in-task `uai files` reads cloud links instead
|
|
45
|
+
* of a bind-mounted shared folder (ADR-161 §1.11). A task machine that still
|
|
46
|
+
* installs a Files session advertises the lease feature as well; the cloud
|
|
47
|
+
* treats "both" as a legacy session plus cloud links, and it does not mount
|
|
48
|
+
* the legacy folder into that container.
|
|
49
|
+
*/
|
|
50
|
+
export const HOST_AGENT_FILES_FEATURE = "host-agent-files-v1";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set to `1` in a Uai Cloud task machine's root-only launch state
|
|
54
|
+
* (`/etc/uai/sidecar.env`) when the cloud launched it on cloud credentials
|
|
55
|
+
* (ADR-154 §16; the cloud records the same machine on its floor). Its runner
|
|
56
|
+
* then runs nothing on its own credentials: it is pending from process start
|
|
57
|
+
* until it activates, and never falls back to them.
|
|
58
|
+
*/
|
|
59
|
+
export const TASK_MACHINE_CREDENTIALS_V1_ENV = "UAI_TASK_MACHINE_CREDENTIALS_V1";
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Parameter layout
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Lease-readable values
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** An Advanced-tier parameter holds 8 KiB; every envelope must fit. */
|
|
70
|
+
export const MAX_CREDENTIAL_VALUE_BYTES = 8 * 1024;
|
|
71
|
+
|
|
72
|
+
/** The env vars an env-token engine's account may set, per engine kind. A
|
|
73
|
+
* value naming any other variable is refused, so a stored account can never
|
|
74
|
+
* set `LD_PRELOAD` or `PATH` on an agent's spawn. */
|
|
75
|
+
export const ENGINE_ENV_CREDENTIAL_KEYS: Readonly<Record<string, readonly string[]>> = {
|
|
76
|
+
claude: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
77
|
+
cursor: ["CURSOR_API_KEY"],
|
|
78
|
+
grok: ["XAI_API_KEY"],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The files a config-directory engine's account may carry, per engine kind:
|
|
82
|
+
* only what the lane owns, written into the engine's home, nothing else. */
|
|
83
|
+
export const ENGINE_CREDENTIAL_FILES: Readonly<Record<string, readonly string[]>> = {
|
|
84
|
+
codex: ["auth.json"],
|
|
85
|
+
grok: ["auth.json"],
|
|
86
|
+
opencode: ["auth.json"],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The engine kinds whose agents run on an AI account from the lease. */
|
|
90
|
+
export const LEASED_ENGINE_KINDS: ReadonlySet<string> = new Set([
|
|
91
|
+
...Object.keys(ENGINE_ENV_CREDENTIAL_KEYS),
|
|
92
|
+
...Object.keys(ENGINE_CREDENTIAL_FILES),
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/** ADR-128: the Managed Engine. It needs no AI account (it runs on Uai's
|
|
96
|
+
* gateway), but its agent's task token is its bearer: on a leased task its
|
|
97
|
+
* launches are admitted as that token (ADR-154 §6). */
|
|
98
|
+
export const MANAGED_ENGINE_KIND = "uai";
|
|
99
|
+
|
|
100
|
+
/** ADR-154 §10: kinds whose agents carry no credential at all; every other
|
|
101
|
+
* kind outside the leased lanes (Kimi) stays down on a leased task until
|
|
102
|
+
* its lane exists. */
|
|
103
|
+
export const CREDENTIAL_FREE_KINDS: ReadonlySet<string> = new Set(["mock"]);
|
|
104
|
+
|
|
105
|
+
/** ADR-154 §10: every kind a task on cloud credentials can run, and so every
|
|
106
|
+
* kind a runner on them advertises. The Managed Engine is one, though it
|
|
107
|
+
* leases nothing. Kimi, with no lane yet, is not. */
|
|
108
|
+
export const CLOUD_CREDENTIAL_KINDS: ReadonlySet<string> = new Set([
|
|
109
|
+
...LEASED_ENGINE_KINDS,
|
|
110
|
+
MANAGED_ENGINE_KIND,
|
|
111
|
+
...CREDENTIAL_FREE_KINDS,
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
interface LeasedValueBase {
|
|
115
|
+
v: 1;
|
|
116
|
+
part: "lease";
|
|
117
|
+
/** The parameter this value belongs to, which the reader checks: a value
|
|
118
|
+
* copied to another credential's or revision's parameter is refused. */
|
|
119
|
+
location: CredentialLocation;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type LeasedValue =
|
|
123
|
+
| (LeasedValueBase & { class: "engine"; authKind: "custom-connection"; connection: CustomConnectionMaterial })
|
|
124
|
+
| (LeasedValueBase & { class: "engine"; authKind: "env"; env: Record<string, string> })
|
|
125
|
+
| (LeasedValueBase & {
|
|
126
|
+
class: "engine";
|
|
127
|
+
authKind: "config-dir";
|
|
128
|
+
/** The brokered copy: access token in, refresh token replaced by the
|
|
129
|
+
* broker's placeholder. */
|
|
130
|
+
files: Record<string, string>;
|
|
131
|
+
expiresAt: number | null;
|
|
132
|
+
})
|
|
133
|
+
| (LeasedValueBase & { class: "mcp"; authKind: "token"; header: { name: string; value: string } })
|
|
134
|
+
| (LeasedValueBase & {
|
|
135
|
+
class: "mcp";
|
|
136
|
+
authKind: "oauth";
|
|
137
|
+
accessToken: string;
|
|
138
|
+
tokenType: "Bearer";
|
|
139
|
+
expiresAt: number | null;
|
|
140
|
+
})
|
|
141
|
+
| (LeasedValueBase & { class: "github"; token: string })
|
|
142
|
+
| (LeasedValueBase & { class: "ssh"; privateKey: string; publicKey: string });
|
|
143
|
+
|
|
144
|
+
const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
|
|
145
|
+
/** Hop-by-hop and routing headers a stored connection may not set. */
|
|
146
|
+
const FORBIDDEN_HEADER_NAMES = new Set([
|
|
147
|
+
"connection",
|
|
148
|
+
"content-length",
|
|
149
|
+
"host",
|
|
150
|
+
"keep-alive",
|
|
151
|
+
"proxy-authorization",
|
|
152
|
+
"te",
|
|
153
|
+
"trailer",
|
|
154
|
+
"transfer-encoding",
|
|
155
|
+
"upgrade",
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
function parseLocationField(value: unknown): CredentialLocation | null {
|
|
160
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
161
|
+
const o = value as WireObject;
|
|
162
|
+
const keys = Object.keys(o);
|
|
163
|
+
const allowed = new Set(["class", "scope", "ownerId", "engineKind", "credentialId", "revision"]);
|
|
164
|
+
if (keys.some((key) => !allowed.has(key))) return null;
|
|
165
|
+
if (
|
|
166
|
+
(o.class !== "engine" && o.class !== "mcp" && o.class !== "github" && o.class !== "ssh") ||
|
|
167
|
+
!isScope(o.scope) ||
|
|
168
|
+
!isIdValue(o.ownerId) ||
|
|
169
|
+
!isIdValue(o.revision) ||
|
|
170
|
+
!(o.engineKind === undefined || isEngineKind(o.engineKind)) ||
|
|
171
|
+
!(o.credentialId === undefined || isIdValue(o.credentialId))
|
|
172
|
+
) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const location: CredentialLocation = {
|
|
176
|
+
class: o.class,
|
|
177
|
+
scope: o.scope,
|
|
178
|
+
ownerId: o.ownerId,
|
|
179
|
+
...(o.engineKind !== undefined ? { engineKind: o.engineKind as string } : {}),
|
|
180
|
+
...(o.credentialId !== undefined ? { credentialId: o.credentialId as string } : {}),
|
|
181
|
+
revision: o.revision,
|
|
182
|
+
};
|
|
183
|
+
try {
|
|
184
|
+
leaseParameterName(location);
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
return location;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseStringMap(
|
|
192
|
+
value: unknown,
|
|
193
|
+
allowedKeys: readonly string[],
|
|
194
|
+
maxChars: number,
|
|
195
|
+
): Record<string, string> | null {
|
|
196
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
197
|
+
const entries = Object.entries(value as WireObject);
|
|
198
|
+
if (entries.length === 0) return null;
|
|
199
|
+
const out: Record<string, string> = {};
|
|
200
|
+
for (const [key, item] of entries) {
|
|
201
|
+
if (!allowedKeys.includes(key) || !isBoundedString(item, maxChars)) return null;
|
|
202
|
+
out[key] = item;
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The JSON a writer stores in a lease-readable parameter. */
|
|
208
|
+
export function serializeLeasedValue(value: LeasedValue): string {
|
|
209
|
+
const json = canonicalJson(value);
|
|
210
|
+
if (new TextEncoder().encode(json).length > MAX_CREDENTIAL_VALUE_BYTES) {
|
|
211
|
+
throw new Error("credential value too large for one parameter");
|
|
212
|
+
}
|
|
213
|
+
return json;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A lease-readable value, read from the parameter at `expected`, or null.
|
|
218
|
+
* Exact like every parser here, and bound to its parameter: the envelope's
|
|
219
|
+
* location must be the one the reader asked for, so a value copied into
|
|
220
|
+
* another credential's or revision's parameter is refused, not used.
|
|
221
|
+
*/
|
|
222
|
+
export function parseLeasedValue(
|
|
223
|
+
raw: string,
|
|
224
|
+
expected: CredentialLocation,
|
|
225
|
+
): LeasedValue | null {
|
|
226
|
+
if (
|
|
227
|
+
typeof raw !== "string" ||
|
|
228
|
+
new TextEncoder().encode(raw).length > MAX_CREDENTIAL_VALUE_BYTES
|
|
229
|
+
) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
let parsed: unknown;
|
|
233
|
+
try {
|
|
234
|
+
parsed = JSON.parse(raw);
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
239
|
+
const o = parsed as WireObject;
|
|
240
|
+
if (o.v !== 1 || o.part !== "lease") return null;
|
|
241
|
+
const location = parseLocationField(o.location);
|
|
242
|
+
if (!location || !sameLocation(location, expected) || o.class !== location.class) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const base = { v: 1 as const, part: "lease" as const, location };
|
|
246
|
+
switch (location.class) {
|
|
247
|
+
case "engine": {
|
|
248
|
+
const kind = location.engineKind!;
|
|
249
|
+
if (o.authKind === "custom-connection") {
|
|
250
|
+
const connection = exactObject(o, ["v", "part", "location", "class", "authKind", "connection"]) ? parseCustomConnectionMaterial(o.connection) : null;
|
|
251
|
+
return connection && connection.engine === kind ? { ...base, class: "engine", authKind: "custom-connection", connection } : null;
|
|
252
|
+
}
|
|
253
|
+
if (o.authKind === "env") {
|
|
254
|
+
const env = exactObject(o, ["v", "part", "location", "class", "authKind", "env"])
|
|
255
|
+
? parseStringMap(o.env, ENGINE_ENV_CREDENTIAL_KEYS[kind] ?? [], 4096)
|
|
256
|
+
: null;
|
|
257
|
+
return env ? { ...base, class: "engine", authKind: "env", env } : null;
|
|
258
|
+
}
|
|
259
|
+
if (o.authKind === "config-dir") {
|
|
260
|
+
if (
|
|
261
|
+
!exactObject(o, ["v", "part", "location", "class", "authKind", "files", "expiresAt"]) ||
|
|
262
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt))
|
|
263
|
+
) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const files = parseStringMap(o.files, ENGINE_CREDENTIAL_FILES[kind] ?? [], MAX_CREDENTIAL_VALUE_BYTES);
|
|
267
|
+
return files
|
|
268
|
+
? { ...base, class: "engine", authKind: "config-dir", files, expiresAt: o.expiresAt }
|
|
269
|
+
: null;
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
case "mcp": {
|
|
274
|
+
if (o.authKind === "token") {
|
|
275
|
+
const header = exactObject(o.header, ["name", "value"]);
|
|
276
|
+
if (
|
|
277
|
+
!exactObject(o, ["v", "part", "location", "class", "authKind", "header"]) ||
|
|
278
|
+
!header ||
|
|
279
|
+
typeof header.name !== "string" ||
|
|
280
|
+
!HEADER_NAME.test(header.name) ||
|
|
281
|
+
FORBIDDEN_HEADER_NAMES.has(header.name.toLowerCase()) ||
|
|
282
|
+
!isBoundedString(header.value, 4096) ||
|
|
283
|
+
/[\r\n]/.test(header.value)
|
|
284
|
+
) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
...base,
|
|
289
|
+
class: "mcp",
|
|
290
|
+
authKind: "token",
|
|
291
|
+
header: { name: header.name, value: header.value },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (o.authKind === "oauth") {
|
|
295
|
+
if (
|
|
296
|
+
!exactObject(o, ["v", "part", "location", "class", "authKind", "accessToken", "tokenType", "expiresAt"]) ||
|
|
297
|
+
!isBoundedString(o.accessToken, 4096) ||
|
|
298
|
+
/[\r\n]/.test(o.accessToken) ||
|
|
299
|
+
o.tokenType !== "Bearer" ||
|
|
300
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt))
|
|
301
|
+
) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
...base,
|
|
306
|
+
class: "mcp",
|
|
307
|
+
authKind: "oauth",
|
|
308
|
+
accessToken: o.accessToken,
|
|
309
|
+
tokenType: "Bearer",
|
|
310
|
+
expiresAt: o.expiresAt,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
case "github":
|
|
316
|
+
return exactObject(o, ["v", "part", "location", "class", "token"]) &&
|
|
317
|
+
isBoundedString(o.token, 512) &&
|
|
318
|
+
!/\s/.test(o.token)
|
|
319
|
+
? { ...base, class: "github", token: o.token }
|
|
320
|
+
: null;
|
|
321
|
+
case "ssh":
|
|
322
|
+
return exactObject(o, ["v", "part", "location", "class", "privateKey", "publicKey"]) &&
|
|
323
|
+
isBoundedString(o.privateKey, 4096) &&
|
|
324
|
+
isBoundedString(o.publicKey, 1024)
|
|
325
|
+
? { ...base, class: "ssh", privateKey: o.privateKey, publicKey: o.publicKey }
|
|
326
|
+
: null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Capture flows (cloud ↔ sign-in box)
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/** Advertised by a sign-in box that writes captures as ADR-154 revisions. */
|
|
335
|
+
export const SIGNIN_BOX_CAPTURE_FLOWS_FEATURE = "signin-box-capture-flows-v1";
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Session tags
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Every v1 `credentials` session carries `lease=v1`, and every role statement
|
|
343
|
+
* that grants `/uai/lease` requires it, so the statements v1 adds never widen
|
|
344
|
+
* a v0 session (which carries no such tag).
|
|
345
|
+
*/
|
|
346
|
+
export const LEASE_V1_SESSION_TAG = { key: "lease", value: "v1" } as const;
|
|
347
|
+
/** The session tag the role policy puts into `/uai/lease/orgs/${…}` paths:
|
|
348
|
+
* the workspace id as `idPathSegment` writes it. `org` keeps the raw id,
|
|
349
|
+
* which resource-tag conditions compare. */
|
|
350
|
+
export const ORG_PATH_SESSION_TAG_KEY = "orgseg";
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// The v1 grant and its manifest
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
export const MAX_LEASE_SESSIONS = 5;
|
|
357
|
+
export const MAX_LEASE_CREDENTIAL_SESSIONS = 4;
|
|
358
|
+
export const MAX_MANIFEST_PROJECTS = 32;
|
|
359
|
+
export const MAX_MANIFEST_ENGINES = 64;
|
|
360
|
+
export const MAX_MANIFEST_MCP = 64;
|
|
361
|
+
export const MAX_MANIFEST_LABEL_CHARS = 64;
|
|
362
|
+
export const MAX_MANIFEST_URL_CHARS = 2048;
|
|
363
|
+
export const MAX_MANIFEST_SLUG_CHARS = 64;
|
|
364
|
+
export const MAX_LEASE_ERROR_CHARS = 512;
|
|
365
|
+
const MAX_LEASE_OP_ID_CHARS = 128;
|
|
366
|
+
const MAX_SESSION_SECRET_CHARS = 4096;
|
|
367
|
+
const MAX_REGION_CHARS = 32;
|
|
368
|
+
const MAX_BUCKET_CHARS = 63;
|
|
369
|
+
const MAX_PREFIX_CHARS = 512;
|
|
370
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
371
|
+
const OP_ID = /^[A-Za-z0-9_-]+$/;
|
|
372
|
+
|
|
373
|
+
export type LeaseSessionPurpose = "files" | "credentials";
|
|
374
|
+
|
|
375
|
+
/** One STS session. A task machine's grant has exactly one `files` session,
|
|
376
|
+
* which only Mountpoint uses; an owned runner's has none. */
|
|
377
|
+
export interface LeaseSession {
|
|
378
|
+
id: string;
|
|
379
|
+
purpose: LeaseSessionPurpose;
|
|
380
|
+
accessKeyId: string;
|
|
381
|
+
secretAccessKey: string;
|
|
382
|
+
sessionToken: string;
|
|
383
|
+
/** Epoch ms. */
|
|
384
|
+
expiresAt: number;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface LeaseManifestProject {
|
|
388
|
+
projectId: string;
|
|
389
|
+
/** The session whose policy names this project's env values. */
|
|
390
|
+
sessionId: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export interface LeaseManifestEngine {
|
|
394
|
+
credentialId: string;
|
|
395
|
+
revision: string;
|
|
396
|
+
/** The revision's lease-readable parameter. */
|
|
397
|
+
parameter: string;
|
|
398
|
+
/** Its Parameter Store version as the cloud last recorded it. */
|
|
399
|
+
version: number;
|
|
400
|
+
expiresAt: number | null;
|
|
401
|
+
sessionId: string;
|
|
402
|
+
engineKind: string;
|
|
403
|
+
scope: CredentialScope;
|
|
404
|
+
label: string;
|
|
405
|
+
authKind: "env" | "config-dir" | "custom-connection";
|
|
406
|
+
connection?: CustomConnectionLease;
|
|
407
|
+
/** Position within its scope; the list itself is already in try order. */
|
|
408
|
+
position: number;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export interface LeaseManifestMcp {
|
|
412
|
+
credentialId: string;
|
|
413
|
+
slug: string;
|
|
414
|
+
url: string;
|
|
415
|
+
scope: CredentialScope;
|
|
416
|
+
authKind: "oauth" | "token" | "none";
|
|
417
|
+
/** Null exactly when `authKind` is `none`: nothing secret to read. */
|
|
418
|
+
revision: string | null;
|
|
419
|
+
parameter: string | null;
|
|
420
|
+
version: number | null;
|
|
421
|
+
expiresAt: number | null;
|
|
422
|
+
sessionId: string | null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface LeaseManifestStatic {
|
|
426
|
+
credentialId: string;
|
|
427
|
+
revision: string;
|
|
428
|
+
parameter: string;
|
|
429
|
+
version: number;
|
|
430
|
+
sessionId: string;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** What a task may read, by name and version — never a value. */
|
|
434
|
+
export interface CredentialManifest {
|
|
435
|
+
/** Platform connections.enabled, included in the digest; absent fails closed. */
|
|
436
|
+
connectionsEnabled?: CustomConnectionCombination[];
|
|
437
|
+
/** Pins refused by connection policy, never secret material. Omitted if empty. */
|
|
438
|
+
connectionsUnsupported?: string[];
|
|
439
|
+
/** Omitted on older peers and when empty, preserving old digests. */
|
|
440
|
+
taskSecrets?: TaskSecretManifestEntry[];
|
|
441
|
+
/** The task's projects, lead first. */
|
|
442
|
+
projects: LeaseManifestProject[];
|
|
443
|
+
/** Per engine kind: the owner's personal accounts, then the workspace's. */
|
|
444
|
+
engines: LeaseManifestEngine[];
|
|
445
|
+
mcp: LeaseManifestMcp[];
|
|
446
|
+
github: LeaseManifestStatic | null;
|
|
447
|
+
ssh: LeaseManifestStatic | null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export interface LeaseFiles {
|
|
451
|
+
bucket: string;
|
|
452
|
+
orgPrefix: string | null;
|
|
453
|
+
userPrefix: string;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** ADR-154 §4: the grant a v1 runner installs for one live task. */
|
|
457
|
+
export interface TaskLeaseV1 {
|
|
458
|
+
v: 1;
|
|
459
|
+
leaseId: string;
|
|
460
|
+
taskId: string;
|
|
461
|
+
orgId: string;
|
|
462
|
+
userId: string;
|
|
463
|
+
/** The task's run epoch: advanced by the cloud at every start, resume and
|
|
464
|
+
* wake. With the runner the task is assigned to, it is the assignment a
|
|
465
|
+
* grant, a replica frame and the cleanup inventory are fenced to. */
|
|
466
|
+
runEpoch: number;
|
|
467
|
+
/** Only grows per task. */
|
|
468
|
+
manifestRevision: number;
|
|
469
|
+
/** sha256 (lowercase hex) of `canonicalManifestJson(manifest)`. */
|
|
470
|
+
manifestDigest: string;
|
|
471
|
+
region: string;
|
|
472
|
+
sessions: LeaseSession[];
|
|
473
|
+
/** Present exactly when a `files` session is. */
|
|
474
|
+
files: LeaseFiles | null;
|
|
475
|
+
manifest: CredentialManifest;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* A task command's run epoch (`TaskCommandTask.runEpoch`): undefined when
|
|
480
|
+
* absent, since a cloud that predates ADR-154 sends none. A present value that
|
|
481
|
+
* is not a positive safe integer throws; it is never read as absent.
|
|
482
|
+
*/
|
|
483
|
+
export function runEpochFromWire(
|
|
484
|
+
value: unknown,
|
|
485
|
+
field = "runEpoch",
|
|
486
|
+
): number | undefined {
|
|
487
|
+
if (value === undefined) return undefined;
|
|
488
|
+
if (!isPositiveInteger(value)) throw new Error(`invalid ${field}`);
|
|
489
|
+
return value;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** A grant expires with its earliest session. */
|
|
493
|
+
export function taskLeaseV1ExpiresAt(lease: TaskLeaseV1): number {
|
|
494
|
+
return Math.min(...lease.sessions.map((session) => session.expiresAt));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The bytes `manifestDigest` covers: JSON with every object's keys sorted,
|
|
499
|
+
* arrays in order, no whitespace. Callers hash it with their own crypto, so
|
|
500
|
+
* this module stays loadable everywhere.
|
|
501
|
+
*/
|
|
502
|
+
export function canonicalManifestJson(manifest: CredentialManifest): string {
|
|
503
|
+
return canonicalJson(manifest);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function canonicalJson(value: unknown): string {
|
|
507
|
+
if (value === null || typeof value !== "object") {
|
|
508
|
+
return JSON.stringify(value);
|
|
509
|
+
}
|
|
510
|
+
if (Array.isArray(value)) {
|
|
511
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
512
|
+
}
|
|
513
|
+
const record = value as Record<string, unknown>;
|
|
514
|
+
const keys = Object.keys(record)
|
|
515
|
+
.filter((key) => record[key] !== undefined)
|
|
516
|
+
.sort();
|
|
517
|
+
return `{${keys
|
|
518
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
519
|
+
.join(",")}}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// Frames
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
/** Runner → cloud: a v1 lease request for one exact task run. */
|
|
527
|
+
export interface LeaseRequestV1Frame {
|
|
528
|
+
kind: "lease.request";
|
|
529
|
+
opId: string;
|
|
530
|
+
taskId: string;
|
|
531
|
+
runEpoch: number;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Cloud → runner: a v1 grant answering `lease.request`. A separate kind from
|
|
535
|
+
* ADR-133's `lease.grant`, so a runner that predates v1 drops it unread. */
|
|
536
|
+
export interface LeaseGrantV1Frame {
|
|
537
|
+
kind: "lease.grant.v1";
|
|
538
|
+
opId: string;
|
|
539
|
+
lease: TaskLeaseV1;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Cloud → runner: STS or the network failed; ask again later. Not a denial:
|
|
543
|
+
* the runner keeps an unexpired grant that is not below `minRevision`. */
|
|
544
|
+
export interface LeaseUnavailableFrame {
|
|
545
|
+
kind: "lease.unavailable";
|
|
546
|
+
opId: string;
|
|
547
|
+
error: string;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Cloud → runner: the task's credentials changed. Renew until a grant at or
|
|
552
|
+
* above `wantedRevision` is installed. With `minRevision`, grants below it are
|
|
553
|
+
* unusable at once and `removed` names the credentials that left, whose
|
|
554
|
+
* cleanup runs before the next grant is installed.
|
|
555
|
+
*/
|
|
556
|
+
export interface LeaseStaleFrame {
|
|
557
|
+
kind: "lease.stale";
|
|
558
|
+
taskId: string;
|
|
559
|
+
runEpoch: number;
|
|
560
|
+
wantedRevision: number;
|
|
561
|
+
minRevision: number | null;
|
|
562
|
+
removed: string[];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Cloud → runner: a credential the task's manifest names has a new version
|
|
566
|
+
* of its lease-readable parameter (a broker refresh). */
|
|
567
|
+
export interface CredentialChangedFrame {
|
|
568
|
+
kind: "credential.changed";
|
|
569
|
+
taskId: string;
|
|
570
|
+
runEpoch: number;
|
|
571
|
+
credentialId: string;
|
|
572
|
+
revision: string;
|
|
573
|
+
version: number;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Runner → cloud: a credential-invalid signal or an MCP 401. */
|
|
577
|
+
export interface CredentialRefreshFrame {
|
|
578
|
+
kind: "credential.refresh";
|
|
579
|
+
opId: string;
|
|
580
|
+
taskId: string;
|
|
581
|
+
runEpoch: number;
|
|
582
|
+
credentialId: string;
|
|
583
|
+
revision: string;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export type CredentialRefreshOutcome =
|
|
587
|
+
| "refreshed"
|
|
588
|
+
| "fresh"
|
|
589
|
+
| "needs_sign_in"
|
|
590
|
+
| "busy"
|
|
591
|
+
| "unavailable";
|
|
592
|
+
|
|
593
|
+
/** Cloud → runner: the broker's typed answer, never material. `version` and
|
|
594
|
+
* `expiresAt` accompany `refreshed` and `fresh` only. */
|
|
595
|
+
export interface CredentialRefreshResultFrame {
|
|
596
|
+
kind: "credential.refresh.result";
|
|
597
|
+
opId: string;
|
|
598
|
+
outcome: CredentialRefreshOutcome;
|
|
599
|
+
version: number | null;
|
|
600
|
+
expiresAt: number | null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export type CredentialsActivationPhase = "prepare" | "commit";
|
|
604
|
+
|
|
605
|
+
/** Runner → cloud: ADR-154 §13's prepare and commit steps. */
|
|
606
|
+
export interface CredentialsActivateFrame {
|
|
607
|
+
kind: "credentials.activate";
|
|
608
|
+
opId: string;
|
|
609
|
+
phase: CredentialsActivationPhase;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export interface CredentialsActivateResultFrame {
|
|
613
|
+
kind: "credentials.activate.result";
|
|
614
|
+
opId: string;
|
|
615
|
+
phase: CredentialsActivationPhase;
|
|
616
|
+
ok: boolean;
|
|
617
|
+
error: string | null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Runner → cloud: the purge is complete; the runner now advertises v1. */
|
|
621
|
+
export interface CredentialsActivatedFrame {
|
|
622
|
+
kind: "credentials.activated";
|
|
623
|
+
opId: string;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
// Parsers
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
type WireObject = Record<string, unknown>;
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
function isBoundedString(value: unknown, max: number): value is string {
|
|
634
|
+
return typeof value === "string" && value.length > 0 && value.length <= max;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function isOpId(value: unknown): value is string {
|
|
638
|
+
return isBoundedString(value, MAX_LEASE_OP_ID_CHARS) && OP_ID.test(value);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
function isNonNegativeInteger(value: unknown): value is number {
|
|
644
|
+
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
function isScope(value: unknown): value is CredentialScope {
|
|
650
|
+
return value === "org" || value === "user";
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function uniqueBy<T>(items: readonly T[], key: (item: T) => string): boolean {
|
|
654
|
+
return new Set(items.map(key)).size === items.length;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function parseSession(value: unknown): LeaseSession | null {
|
|
658
|
+
const o = exactObject(value, [
|
|
659
|
+
"id",
|
|
660
|
+
"purpose",
|
|
661
|
+
"accessKeyId",
|
|
662
|
+
"secretAccessKey",
|
|
663
|
+
"sessionToken",
|
|
664
|
+
"expiresAt",
|
|
665
|
+
]);
|
|
666
|
+
if (
|
|
667
|
+
!o ||
|
|
668
|
+
!isOpId(o.id) ||
|
|
669
|
+
(o.purpose !== "files" && o.purpose !== "credentials") ||
|
|
670
|
+
!isBoundedString(o.accessKeyId, 128) ||
|
|
671
|
+
!isBoundedString(o.secretAccessKey, MAX_SESSION_SECRET_CHARS) ||
|
|
672
|
+
!isBoundedString(o.sessionToken, MAX_SESSION_SECRET_CHARS) ||
|
|
673
|
+
!isEpochMs(o.expiresAt)
|
|
674
|
+
) {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
id: o.id,
|
|
679
|
+
purpose: o.purpose,
|
|
680
|
+
accessKeyId: o.accessKeyId,
|
|
681
|
+
secretAccessKey: o.secretAccessKey,
|
|
682
|
+
sessionToken: o.sessionToken,
|
|
683
|
+
expiresAt: o.expiresAt,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** The owner a lease parameter must belong to, for a grant's org and user. */
|
|
688
|
+
function belongsTo(
|
|
689
|
+
location: CredentialLocation,
|
|
690
|
+
grant: { orgId: string; userId: string },
|
|
691
|
+
): boolean {
|
|
692
|
+
return location.scope === "org"
|
|
693
|
+
? location.ownerId === grant.orgId
|
|
694
|
+
: location.ownerId === grant.userId;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function parseEngine(
|
|
698
|
+
value: unknown,
|
|
699
|
+
grant: { orgId: string; userId: string },
|
|
700
|
+
credentialSessions: ReadonlySet<string>,
|
|
701
|
+
): LeaseManifestEngine | null {
|
|
702
|
+
const o = exactObject(value, [
|
|
703
|
+
"credentialId",
|
|
704
|
+
"revision",
|
|
705
|
+
"parameter",
|
|
706
|
+
"version",
|
|
707
|
+
"expiresAt",
|
|
708
|
+
"sessionId",
|
|
709
|
+
"engineKind",
|
|
710
|
+
"scope",
|
|
711
|
+
"label",
|
|
712
|
+
"authKind",
|
|
713
|
+
"position",
|
|
714
|
+
], ["connection"]);
|
|
715
|
+
if (
|
|
716
|
+
!o ||
|
|
717
|
+
!isIdValue(o.credentialId) ||
|
|
718
|
+
!isIdValue(o.revision) ||
|
|
719
|
+
typeof o.parameter !== "string" ||
|
|
720
|
+
!isPositiveInteger(o.version) ||
|
|
721
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt)) ||
|
|
722
|
+
typeof o.sessionId !== "string" ||
|
|
723
|
+
!credentialSessions.has(o.sessionId) ||
|
|
724
|
+
!isEngineKind(o.engineKind) ||
|
|
725
|
+
!isScope(o.scope) ||
|
|
726
|
+
!isBoundedString(o.label, MAX_MANIFEST_LABEL_CHARS) ||
|
|
727
|
+
(o.authKind !== "env" && o.authKind !== "config-dir" && o.authKind !== "custom-connection") ||
|
|
728
|
+
!isNonNegativeInteger(o.position)
|
|
729
|
+
) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
const connection = o.connection === undefined ? undefined : parseCustomConnectionLease(o.connection, o.engineKind);
|
|
733
|
+
if (connection === null || ((o.authKind === "custom-connection") !== (connection !== undefined)) || (connection && (connection.expiresAt ?? null) !== o.expiresAt)) return null;
|
|
734
|
+
const location = parseLeaseParameterName(o.parameter);
|
|
735
|
+
if (
|
|
736
|
+
!location ||
|
|
737
|
+
location.class !== "engine" ||
|
|
738
|
+
location.scope !== o.scope ||
|
|
739
|
+
location.engineKind !== o.engineKind ||
|
|
740
|
+
location.credentialId !== o.credentialId ||
|
|
741
|
+
location.revision !== o.revision ||
|
|
742
|
+
!belongsTo(location, grant)
|
|
743
|
+
) {
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
credentialId: o.credentialId,
|
|
748
|
+
revision: o.revision,
|
|
749
|
+
parameter: o.parameter,
|
|
750
|
+
version: o.version,
|
|
751
|
+
expiresAt: o.expiresAt,
|
|
752
|
+
sessionId: o.sessionId,
|
|
753
|
+
engineKind: o.engineKind,
|
|
754
|
+
scope: o.scope,
|
|
755
|
+
label: o.label,
|
|
756
|
+
authKind: o.authKind,
|
|
757
|
+
position: o.position,
|
|
758
|
+
...(connection ? { connection } : {}),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function isMcpUrl(value: unknown): value is string {
|
|
763
|
+
if (!isBoundedString(value, MAX_MANIFEST_URL_CHARS)) return false;
|
|
764
|
+
try {
|
|
765
|
+
const url = new URL(value);
|
|
766
|
+
return url.protocol === "https:" && url.username === "" && url.password === "";
|
|
767
|
+
} catch {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function parseMcp(
|
|
773
|
+
value: unknown,
|
|
774
|
+
grant: { orgId: string; userId: string },
|
|
775
|
+
credentialSessions: ReadonlySet<string>,
|
|
776
|
+
): LeaseManifestMcp | null {
|
|
777
|
+
const o = exactObject(value, [
|
|
778
|
+
"credentialId",
|
|
779
|
+
"slug",
|
|
780
|
+
"url",
|
|
781
|
+
"scope",
|
|
782
|
+
"authKind",
|
|
783
|
+
"revision",
|
|
784
|
+
"parameter",
|
|
785
|
+
"version",
|
|
786
|
+
"expiresAt",
|
|
787
|
+
"sessionId",
|
|
788
|
+
]);
|
|
789
|
+
if (
|
|
790
|
+
!o ||
|
|
791
|
+
!isIdValue(o.credentialId) ||
|
|
792
|
+
!isBoundedString(o.slug, MAX_MANIFEST_SLUG_CHARS) ||
|
|
793
|
+
!/^[a-z0-9][a-z0-9-]*$/.test(o.slug) ||
|
|
794
|
+
!isMcpUrl(o.url) ||
|
|
795
|
+
!isScope(o.scope) ||
|
|
796
|
+
(o.authKind !== "oauth" && o.authKind !== "token" && o.authKind !== "none")
|
|
797
|
+
) {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
const authKind: LeaseManifestMcp["authKind"] = o.authKind;
|
|
801
|
+
const base = {
|
|
802
|
+
credentialId: o.credentialId,
|
|
803
|
+
slug: o.slug,
|
|
804
|
+
url: o.url,
|
|
805
|
+
scope: o.scope,
|
|
806
|
+
authKind,
|
|
807
|
+
};
|
|
808
|
+
if (authKind === "none") {
|
|
809
|
+
return o.revision === null &&
|
|
810
|
+
o.parameter === null &&
|
|
811
|
+
o.version === null &&
|
|
812
|
+
o.expiresAt === null &&
|
|
813
|
+
o.sessionId === null
|
|
814
|
+
? {
|
|
815
|
+
...base,
|
|
816
|
+
revision: null,
|
|
817
|
+
parameter: null,
|
|
818
|
+
version: null,
|
|
819
|
+
expiresAt: null,
|
|
820
|
+
sessionId: null,
|
|
821
|
+
}
|
|
822
|
+
: null;
|
|
823
|
+
}
|
|
824
|
+
if (
|
|
825
|
+
!isIdValue(o.revision) ||
|
|
826
|
+
typeof o.parameter !== "string" ||
|
|
827
|
+
!isPositiveInteger(o.version) ||
|
|
828
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt)) ||
|
|
829
|
+
typeof o.sessionId !== "string" ||
|
|
830
|
+
!credentialSessions.has(o.sessionId)
|
|
831
|
+
) {
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
const location = parseLeaseParameterName(o.parameter);
|
|
835
|
+
if (
|
|
836
|
+
!location ||
|
|
837
|
+
location.class !== "mcp" ||
|
|
838
|
+
location.scope !== o.scope ||
|
|
839
|
+
location.credentialId !== o.credentialId ||
|
|
840
|
+
location.revision !== o.revision ||
|
|
841
|
+
!belongsTo(location, grant)
|
|
842
|
+
) {
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
...base,
|
|
847
|
+
revision: o.revision,
|
|
848
|
+
parameter: o.parameter,
|
|
849
|
+
version: o.version,
|
|
850
|
+
expiresAt: o.expiresAt,
|
|
851
|
+
sessionId: o.sessionId,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function parseStatic(
|
|
856
|
+
value: unknown,
|
|
857
|
+
credentialClass: "github" | "ssh",
|
|
858
|
+
grant: { orgId: string; userId: string },
|
|
859
|
+
credentialSessions: ReadonlySet<string>,
|
|
860
|
+
): LeaseManifestStatic | null {
|
|
861
|
+
const o = exactObject(value, [
|
|
862
|
+
"credentialId",
|
|
863
|
+
"revision",
|
|
864
|
+
"parameter",
|
|
865
|
+
"version",
|
|
866
|
+
"sessionId",
|
|
867
|
+
]);
|
|
868
|
+
if (
|
|
869
|
+
!o ||
|
|
870
|
+
!isIdValue(o.credentialId) ||
|
|
871
|
+
!isIdValue(o.revision) ||
|
|
872
|
+
typeof o.parameter !== "string" ||
|
|
873
|
+
!isPositiveInteger(o.version) ||
|
|
874
|
+
typeof o.sessionId !== "string" ||
|
|
875
|
+
!credentialSessions.has(o.sessionId)
|
|
876
|
+
) {
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
const location = parseLeaseParameterName(o.parameter);
|
|
880
|
+
if (
|
|
881
|
+
!location ||
|
|
882
|
+
location.class !== credentialClass ||
|
|
883
|
+
location.revision !== o.revision ||
|
|
884
|
+
location.ownerId !== grant.userId
|
|
885
|
+
) {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
credentialId: o.credentialId,
|
|
890
|
+
revision: o.revision,
|
|
891
|
+
parameter: o.parameter,
|
|
892
|
+
version: o.version,
|
|
893
|
+
sessionId: o.sessionId,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function parseManifest(
|
|
898
|
+
value: unknown,
|
|
899
|
+
grant: { orgId: string; userId: string; taskId: string },
|
|
900
|
+
credentialSessions: ReadonlySet<string>,
|
|
901
|
+
): CredentialManifest | null {
|
|
902
|
+
const o = exactObject(value, ["projects", "engines", "mcp", "github", "ssh"], ["taskSecrets", "connectionsEnabled", "connectionsUnsupported"]);
|
|
903
|
+
if (
|
|
904
|
+
!o ||
|
|
905
|
+
!Array.isArray(o.projects) ||
|
|
906
|
+
o.projects.length > MAX_MANIFEST_PROJECTS ||
|
|
907
|
+
!Array.isArray(o.engines) ||
|
|
908
|
+
o.engines.length > MAX_MANIFEST_ENGINES ||
|
|
909
|
+
!Array.isArray(o.mcp) ||
|
|
910
|
+
o.mcp.length > MAX_MANIFEST_MCP
|
|
911
|
+
) {
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
const connectionsEnabled = o.connectionsEnabled === undefined ? undefined : parseCustomConnectionEnabled(o.connectionsEnabled);
|
|
915
|
+
if (connectionsEnabled === null) return null;
|
|
916
|
+
const connectionsUnsupported = o.connectionsUnsupported;
|
|
917
|
+
if (connectionsUnsupported !== undefined && (!Array.isArray(connectionsUnsupported) ||
|
|
918
|
+
connectionsUnsupported.length === 0 || connectionsUnsupported.length > MAX_MANIFEST_ENGINES ||
|
|
919
|
+
!connectionsUnsupported.every(isIdValue) || new Set(connectionsUnsupported).size !== connectionsUnsupported.length)) return null;
|
|
920
|
+
const customManifest = { ...(connectionsEnabled ? { connectionsEnabled } : {}),
|
|
921
|
+
...(connectionsUnsupported ? { connectionsUnsupported: [...connectionsUnsupported] as string[] } : {}) };
|
|
922
|
+
const projects: LeaseManifestProject[] = [];
|
|
923
|
+
for (const item of o.projects) {
|
|
924
|
+
const p = exactObject(item, ["projectId", "sessionId"]);
|
|
925
|
+
if (
|
|
926
|
+
!p ||
|
|
927
|
+
!isIdValue(p.projectId) ||
|
|
928
|
+
typeof p.sessionId !== "string" ||
|
|
929
|
+
!credentialSessions.has(p.sessionId)
|
|
930
|
+
) {
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
projects.push({ projectId: p.projectId, sessionId: p.sessionId });
|
|
934
|
+
}
|
|
935
|
+
const engines: LeaseManifestEngine[] = [];
|
|
936
|
+
for (const item of o.engines) {
|
|
937
|
+
const engine = parseEngine(item, grant, credentialSessions);
|
|
938
|
+
if (!engine) return null;
|
|
939
|
+
engines.push(engine);
|
|
940
|
+
}
|
|
941
|
+
const mcp: LeaseManifestMcp[] = [];
|
|
942
|
+
for (const item of o.mcp) {
|
|
943
|
+
const connection = parseMcp(item, grant, credentialSessions);
|
|
944
|
+
if (!connection) return null;
|
|
945
|
+
mcp.push(connection);
|
|
946
|
+
}
|
|
947
|
+
const github =
|
|
948
|
+
o.github === null
|
|
949
|
+
? null
|
|
950
|
+
: parseStatic(o.github, "github", grant, credentialSessions);
|
|
951
|
+
const ssh =
|
|
952
|
+
o.ssh === null ? null : parseStatic(o.ssh, "ssh", grant, credentialSessions);
|
|
953
|
+
if ((o.github !== null && !github) || (o.ssh !== null && !ssh)) return null;
|
|
954
|
+
if (
|
|
955
|
+
!uniqueBy(projects, (p) => p.projectId) ||
|
|
956
|
+
!uniqueBy(engines, (e) => e.credentialId) ||
|
|
957
|
+
!uniqueBy(mcp, (m) => m.credentialId) ||
|
|
958
|
+
!uniqueBy(mcp, (m) => m.slug)
|
|
959
|
+
) {
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
if (o.taskSecrets !== undefined) {
|
|
963
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(grant.taskId) || !Array.isArray(o.taskSecrets) || !o.taskSecrets.length || o.taskSecrets.length > MAX_TASK_SECRETS) return null;
|
|
964
|
+
const entries: TaskSecretManifestEntry[] = [];
|
|
965
|
+
for (const item of o.taskSecrets) {
|
|
966
|
+
const e = exactObject(item, ["credentialId", "name", "stagingId", "generation", "parameter", "version", "mode", "domains", "placeholder", "sessionId"]);
|
|
967
|
+
if (!e || typeof e.name !== "string" || !TASK_SECRET_NAME.test(e.name) ||
|
|
968
|
+
typeof e.credentialId !== "string" || e.credentialId !== `task-secret:${e.name}` ||
|
|
969
|
+
typeof e.stagingId !== "string" || !TASK_SECRET_STAGING_ID.test(e.stagingId) || !isPositiveInteger(e.generation) || e.version !== 1 ||
|
|
970
|
+
typeof e.parameter !== "string" || (!e.parameter.startsWith(`/uai/lease/tasks/${grant.taskId}/secrets/`) || !/^[0-9a-f]{64}\/[0-9a-f-]{36}$/.test(e.parameter.slice(`/uai/lease/tasks/${grant.taskId}/secrets/`.length)) || !e.parameter.endsWith(`/${e.stagingId}`)) ||
|
|
971
|
+
(e.mode !== "proxied" && e.mode !== "delivered") || !Array.isArray(e.domains) || e.domains.length > 16 ||
|
|
972
|
+
!e.domains.every(d => typeof d === "string" && /^[a-z0-9.-]+\.[a-z]{2,}$/.test(d)) ||
|
|
973
|
+
(e.mode === "proxied" && !e.domains.length) || (e.mode === "delivered" && e.domains.length) ||
|
|
974
|
+
typeof e.sessionId !== "string" || !credentialSessions.has(e.sessionId)) return null;
|
|
975
|
+
if (e.placeholder !== null) {
|
|
976
|
+
const shape = exactObject(e.placeholder, ["prefix", "totalLength"]);
|
|
977
|
+
if (!shape || typeof shape.prefix !== "string" || !/^(?:[A-Za-z0-9._~-]*[_-])?$/.test(shape.prefix) ||
|
|
978
|
+
!isPositiveInteger(shape.totalLength) || shape.totalLength < shape.prefix.length + 32 || shape.totalLength > 256) return null;
|
|
979
|
+
}
|
|
980
|
+
entries.push(e as unknown as TaskSecretManifestEntry);
|
|
981
|
+
}
|
|
982
|
+
if (!uniqueBy(entries, e => e.name)) return null;
|
|
983
|
+
return { projects, engines, mcp, github, ssh, taskSecrets: entries, ...customManifest };
|
|
984
|
+
}
|
|
985
|
+
return { projects, engines, mcp, github, ssh, ...customManifest };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function parseFiles(value: unknown): LeaseFiles | null {
|
|
989
|
+
const o = exactObject(value, ["bucket", "orgPrefix", "userPrefix"]);
|
|
990
|
+
if (
|
|
991
|
+
!o ||
|
|
992
|
+
!isBoundedString(o.bucket, MAX_BUCKET_CHARS) ||
|
|
993
|
+
!(o.orgPrefix === null || isBoundedString(o.orgPrefix, MAX_PREFIX_CHARS)) ||
|
|
994
|
+
!isBoundedString(o.userPrefix, MAX_PREFIX_CHARS)
|
|
995
|
+
) {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
return { bucket: o.bucket, orgPrefix: o.orgPrefix, userPrefix: o.userPrefix };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* The exact v1 grant, rebuilt field by field, or null. Beyond shapes it
|
|
1003
|
+
* checks the grant is self-consistent: one to four `credentials` sessions
|
|
1004
|
+
* and at most one `files` session, `files` present exactly with that
|
|
1005
|
+
* session, unique session ids, every manifest entry read by a `credentials`
|
|
1006
|
+
* session, and every parameter a lease-readable name owned by the grant's
|
|
1007
|
+
* workspace or user.
|
|
1008
|
+
*/
|
|
1009
|
+
export function parseTaskLeaseV1(value: unknown): TaskLeaseV1 | null {
|
|
1010
|
+
const o = exactObject(value, [
|
|
1011
|
+
"v",
|
|
1012
|
+
"leaseId",
|
|
1013
|
+
"taskId",
|
|
1014
|
+
"orgId",
|
|
1015
|
+
"userId",
|
|
1016
|
+
"runEpoch",
|
|
1017
|
+
"manifestRevision",
|
|
1018
|
+
"manifestDigest",
|
|
1019
|
+
"region",
|
|
1020
|
+
"sessions",
|
|
1021
|
+
"files",
|
|
1022
|
+
"manifest",
|
|
1023
|
+
]);
|
|
1024
|
+
if (
|
|
1025
|
+
!o ||
|
|
1026
|
+
o.v !== 1 ||
|
|
1027
|
+
!isOpId(o.leaseId) ||
|
|
1028
|
+
!isIdValue(o.taskId) ||
|
|
1029
|
+
!isIdValue(o.orgId) ||
|
|
1030
|
+
!isIdValue(o.userId) ||
|
|
1031
|
+
!isPositiveInteger(o.runEpoch) ||
|
|
1032
|
+
!isPositiveInteger(o.manifestRevision) ||
|
|
1033
|
+
typeof o.manifestDigest !== "string" ||
|
|
1034
|
+
!DIGEST.test(o.manifestDigest) ||
|
|
1035
|
+
!isBoundedString(o.region, MAX_REGION_CHARS) ||
|
|
1036
|
+
!Array.isArray(o.sessions) ||
|
|
1037
|
+
o.sessions.length === 0 ||
|
|
1038
|
+
o.sessions.length > MAX_LEASE_SESSIONS
|
|
1039
|
+
) {
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
const sessions: LeaseSession[] = [];
|
|
1043
|
+
for (const item of o.sessions) {
|
|
1044
|
+
const session = parseSession(item);
|
|
1045
|
+
if (!session) return null;
|
|
1046
|
+
sessions.push(session);
|
|
1047
|
+
}
|
|
1048
|
+
const credentialSessions = new Set(
|
|
1049
|
+
sessions.filter((s) => s.purpose === "credentials").map((s) => s.id),
|
|
1050
|
+
);
|
|
1051
|
+
const filesSessions = sessions.filter((s) => s.purpose === "files").length;
|
|
1052
|
+
if (
|
|
1053
|
+
!uniqueBy(sessions, (s) => s.id) ||
|
|
1054
|
+
credentialSessions.size === 0 ||
|
|
1055
|
+
credentialSessions.size > MAX_LEASE_CREDENTIAL_SESSIONS ||
|
|
1056
|
+
filesSessions > 1 ||
|
|
1057
|
+
(filesSessions === 1) !== (o.files !== null)
|
|
1058
|
+
) {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
const files = o.files === null ? null : parseFiles(o.files);
|
|
1062
|
+
if (o.files !== null && !files) return null;
|
|
1063
|
+
const grant = { orgId: o.orgId, userId: o.userId, taskId: o.taskId };
|
|
1064
|
+
const manifest = parseManifest(o.manifest, grant, credentialSessions);
|
|
1065
|
+
if (!manifest) return null;
|
|
1066
|
+
return {
|
|
1067
|
+
v: 1,
|
|
1068
|
+
leaseId: o.leaseId,
|
|
1069
|
+
taskId: o.taskId,
|
|
1070
|
+
orgId: o.orgId,
|
|
1071
|
+
userId: o.userId,
|
|
1072
|
+
runEpoch: o.runEpoch,
|
|
1073
|
+
manifestRevision: o.manifestRevision,
|
|
1074
|
+
manifestDigest: o.manifestDigest,
|
|
1075
|
+
region: o.region,
|
|
1076
|
+
sessions,
|
|
1077
|
+
files,
|
|
1078
|
+
manifest,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
export function parseLeaseGrantV1Frame(value: unknown): LeaseGrantV1Frame | null {
|
|
1083
|
+
const o = exactObject(value, ["kind", "opId", "lease"]);
|
|
1084
|
+
if (!o || o.kind !== "lease.grant.v1" || !isOpId(o.opId)) return null;
|
|
1085
|
+
const lease = parseTaskLeaseV1(o.lease);
|
|
1086
|
+
return lease ? { kind: "lease.grant.v1", opId: o.opId, lease } : null;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
export function parseLeaseRequestV1Frame(
|
|
1090
|
+
value: unknown,
|
|
1091
|
+
): LeaseRequestV1Frame | null {
|
|
1092
|
+
const o = exactObject(value, ["kind", "opId", "taskId", "runEpoch"]);
|
|
1093
|
+
return o &&
|
|
1094
|
+
o.kind === "lease.request" &&
|
|
1095
|
+
isOpId(o.opId) &&
|
|
1096
|
+
isIdValue(o.taskId) &&
|
|
1097
|
+
isPositiveInteger(o.runEpoch)
|
|
1098
|
+
? {
|
|
1099
|
+
kind: "lease.request",
|
|
1100
|
+
opId: o.opId,
|
|
1101
|
+
taskId: o.taskId,
|
|
1102
|
+
runEpoch: o.runEpoch,
|
|
1103
|
+
}
|
|
1104
|
+
: null;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
export function parseLeaseUnavailableFrame(
|
|
1108
|
+
value: unknown,
|
|
1109
|
+
): LeaseUnavailableFrame | null {
|
|
1110
|
+
const o = exactObject(value, ["kind", "opId", "error"]);
|
|
1111
|
+
return o &&
|
|
1112
|
+
o.kind === "lease.unavailable" &&
|
|
1113
|
+
isOpId(o.opId) &&
|
|
1114
|
+
isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
1115
|
+
? { kind: "lease.unavailable", opId: o.opId, error: o.error }
|
|
1116
|
+
: null;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export function parseLeaseStaleFrame(value: unknown): LeaseStaleFrame | null {
|
|
1120
|
+
const o = exactObject(value, [
|
|
1121
|
+
"kind",
|
|
1122
|
+
"taskId",
|
|
1123
|
+
"runEpoch",
|
|
1124
|
+
"wantedRevision",
|
|
1125
|
+
"minRevision",
|
|
1126
|
+
"removed",
|
|
1127
|
+
]);
|
|
1128
|
+
if (
|
|
1129
|
+
!o ||
|
|
1130
|
+
o.kind !== "lease.stale" ||
|
|
1131
|
+
!isIdValue(o.taskId) ||
|
|
1132
|
+
!isPositiveInteger(o.runEpoch) ||
|
|
1133
|
+
!isPositiveInteger(o.wantedRevision) ||
|
|
1134
|
+
!(o.minRevision === null || isPositiveInteger(o.minRevision)) ||
|
|
1135
|
+
!Array.isArray(o.removed) ||
|
|
1136
|
+
o.removed.length > MAX_MANIFEST_ENGINES + MAX_MANIFEST_MCP + 2 ||
|
|
1137
|
+
!o.removed.every(isIdValue) ||
|
|
1138
|
+
new Set(o.removed).size !== o.removed.length
|
|
1139
|
+
) {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
// Removing a credential is what makes older grants unusable, so the two
|
|
1143
|
+
// travel together: `removed` without `minRevision` (or a floor above the
|
|
1144
|
+
// revision the runner is told to want) is refused.
|
|
1145
|
+
if (o.minRevision === null && o.removed.length > 0) return null;
|
|
1146
|
+
if (o.minRevision !== null && o.minRevision > o.wantedRevision) return null;
|
|
1147
|
+
return {
|
|
1148
|
+
kind: "lease.stale",
|
|
1149
|
+
taskId: o.taskId,
|
|
1150
|
+
runEpoch: o.runEpoch,
|
|
1151
|
+
wantedRevision: o.wantedRevision,
|
|
1152
|
+
minRevision: o.minRevision,
|
|
1153
|
+
removed: [...(o.removed as string[])],
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
export function parseCredentialChangedFrame(
|
|
1158
|
+
value: unknown,
|
|
1159
|
+
): CredentialChangedFrame | null {
|
|
1160
|
+
const o = exactObject(value, [
|
|
1161
|
+
"kind",
|
|
1162
|
+
"taskId",
|
|
1163
|
+
"runEpoch",
|
|
1164
|
+
"credentialId",
|
|
1165
|
+
"revision",
|
|
1166
|
+
"version",
|
|
1167
|
+
]);
|
|
1168
|
+
return o &&
|
|
1169
|
+
o.kind === "credential.changed" &&
|
|
1170
|
+
isIdValue(o.taskId) &&
|
|
1171
|
+
isPositiveInteger(o.runEpoch) &&
|
|
1172
|
+
isIdValue(o.credentialId) &&
|
|
1173
|
+
isIdValue(o.revision) &&
|
|
1174
|
+
isPositiveInteger(o.version)
|
|
1175
|
+
? {
|
|
1176
|
+
kind: "credential.changed",
|
|
1177
|
+
taskId: o.taskId,
|
|
1178
|
+
runEpoch: o.runEpoch,
|
|
1179
|
+
credentialId: o.credentialId,
|
|
1180
|
+
revision: o.revision,
|
|
1181
|
+
version: o.version,
|
|
1182
|
+
}
|
|
1183
|
+
: null;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export function parseCredentialRefreshFrame(
|
|
1187
|
+
value: unknown,
|
|
1188
|
+
): CredentialRefreshFrame | null {
|
|
1189
|
+
const o = exactObject(value, [
|
|
1190
|
+
"kind",
|
|
1191
|
+
"opId",
|
|
1192
|
+
"taskId",
|
|
1193
|
+
"runEpoch",
|
|
1194
|
+
"credentialId",
|
|
1195
|
+
"revision",
|
|
1196
|
+
]);
|
|
1197
|
+
return o &&
|
|
1198
|
+
o.kind === "credential.refresh" &&
|
|
1199
|
+
isOpId(o.opId) &&
|
|
1200
|
+
isIdValue(o.taskId) &&
|
|
1201
|
+
isPositiveInteger(o.runEpoch) &&
|
|
1202
|
+
isIdValue(o.credentialId) &&
|
|
1203
|
+
isIdValue(o.revision)
|
|
1204
|
+
? {
|
|
1205
|
+
kind: "credential.refresh",
|
|
1206
|
+
opId: o.opId,
|
|
1207
|
+
taskId: o.taskId,
|
|
1208
|
+
runEpoch: o.runEpoch,
|
|
1209
|
+
credentialId: o.credentialId,
|
|
1210
|
+
revision: o.revision,
|
|
1211
|
+
}
|
|
1212
|
+
: null;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const REFRESH_OUTCOMES: readonly CredentialRefreshOutcome[] = [
|
|
1216
|
+
"refreshed",
|
|
1217
|
+
"fresh",
|
|
1218
|
+
"needs_sign_in",
|
|
1219
|
+
"busy",
|
|
1220
|
+
"unavailable",
|
|
1221
|
+
];
|
|
1222
|
+
|
|
1223
|
+
export function parseCredentialRefreshResultFrame(
|
|
1224
|
+
value: unknown,
|
|
1225
|
+
): CredentialRefreshResultFrame | null {
|
|
1226
|
+
const o = exactObject(value, [
|
|
1227
|
+
"kind",
|
|
1228
|
+
"opId",
|
|
1229
|
+
"outcome",
|
|
1230
|
+
"version",
|
|
1231
|
+
"expiresAt",
|
|
1232
|
+
]);
|
|
1233
|
+
if (
|
|
1234
|
+
!o ||
|
|
1235
|
+
o.kind !== "credential.refresh.result" ||
|
|
1236
|
+
!isOpId(o.opId) ||
|
|
1237
|
+
!(REFRESH_OUTCOMES as readonly unknown[]).includes(o.outcome)
|
|
1238
|
+
) {
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
const outcome = o.outcome as CredentialRefreshOutcome;
|
|
1242
|
+
const carriesVersion = outcome === "refreshed" || outcome === "fresh";
|
|
1243
|
+
if (carriesVersion) {
|
|
1244
|
+
if (
|
|
1245
|
+
!isPositiveInteger(o.version) ||
|
|
1246
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt))
|
|
1247
|
+
) {
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
} else if (o.version !== null || o.expiresAt !== null) {
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
return {
|
|
1254
|
+
kind: "credential.refresh.result",
|
|
1255
|
+
opId: o.opId,
|
|
1256
|
+
outcome,
|
|
1257
|
+
version: o.version as number | null,
|
|
1258
|
+
expiresAt: o.expiresAt as number | null,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function isPhase(value: unknown): value is CredentialsActivationPhase {
|
|
1263
|
+
return value === "prepare" || value === "commit";
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
export function parseCredentialsActivateFrame(
|
|
1267
|
+
value: unknown,
|
|
1268
|
+
): CredentialsActivateFrame | null {
|
|
1269
|
+
const o = exactObject(value, ["kind", "opId", "phase"]);
|
|
1270
|
+
return o &&
|
|
1271
|
+
o.kind === "credentials.activate" &&
|
|
1272
|
+
isOpId(o.opId) &&
|
|
1273
|
+
isPhase(o.phase)
|
|
1274
|
+
? { kind: "credentials.activate", opId: o.opId, phase: o.phase }
|
|
1275
|
+
: null;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
export function parseCredentialsActivateResultFrame(
|
|
1279
|
+
value: unknown,
|
|
1280
|
+
): CredentialsActivateResultFrame | null {
|
|
1281
|
+
const o = exactObject(value, ["kind", "opId", "phase", "ok", "error"]);
|
|
1282
|
+
if (
|
|
1283
|
+
!o ||
|
|
1284
|
+
o.kind !== "credentials.activate.result" ||
|
|
1285
|
+
!isOpId(o.opId) ||
|
|
1286
|
+
!isPhase(o.phase) ||
|
|
1287
|
+
typeof o.ok !== "boolean"
|
|
1288
|
+
) {
|
|
1289
|
+
return null;
|
|
1290
|
+
}
|
|
1291
|
+
if (o.ok ? o.error !== null : !isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)) {
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
return {
|
|
1295
|
+
kind: "credentials.activate.result",
|
|
1296
|
+
opId: o.opId,
|
|
1297
|
+
phase: o.phase,
|
|
1298
|
+
ok: o.ok,
|
|
1299
|
+
error: o.error as string | null,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
export function parseCredentialsActivatedFrame(
|
|
1304
|
+
value: unknown,
|
|
1305
|
+
): CredentialsActivatedFrame | null {
|
|
1306
|
+
const o = exactObject(value, ["kind", "opId"]);
|
|
1307
|
+
return o && o.kind === "credentials.activated" && isOpId(o.opId)
|
|
1308
|
+
? { kind: "credentials.activated", opId: o.opId }
|
|
1309
|
+
: null;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// ---------------------------------------------------------------------------
|
|
1313
|
+
// ADR-154 §7: the broker's exact-name entry point
|
|
1314
|
+
// ---------------------------------------------------------------------------
|
|
1315
|
+
|
|
1316
|
+
const HOUR_MS = 60 * 60_000;
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* When the cloud's scheduler names a lane's revision to the broker: this
|
|
1320
|
+
* long before its access token's known expiry, or on this cadence when the
|
|
1321
|
+
* expiry cannot be read. The broker's lanes keep the same numbers; the broker
|
|
1322
|
+
* decides from the material itself and answers `fresh` when not due.
|
|
1323
|
+
*/
|
|
1324
|
+
export const CREDENTIAL_LANE_SCHEDULES: Readonly<
|
|
1325
|
+
Record<string, { leadMs: number; everyMs?: number }>
|
|
1326
|
+
> = {
|
|
1327
|
+
"openai-oauth": { leadMs: 48 * HOUR_MS },
|
|
1328
|
+
"xai-oauth": { leadMs: 3 * HOUR_MS, everyMs: 4 * HOUR_MS },
|
|
1329
|
+
// ADR-154 §11: MCP access tokens are often an hour long; one that names no
|
|
1330
|
+
// expiry is refreshed on a cadence below that.
|
|
1331
|
+
"mcp-oauth": { leadMs: 10 * 60_000, everyMs: 45 * 60_000 },
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
/** Cloud → broker: refresh exactly this revision of this credential. */
|
|
1335
|
+
export interface BrokerRevisionRequest {
|
|
1336
|
+
v: 1;
|
|
1337
|
+
credentialId: string;
|
|
1338
|
+
revision: string;
|
|
1339
|
+
brokerParameter: string;
|
|
1340
|
+
leaseParameter: string;
|
|
1341
|
+
/** A runner reported the credential invalid: refresh even if not due. */
|
|
1342
|
+
force: boolean;
|
|
1343
|
+
/**
|
|
1344
|
+
* An adopted revision only (owner answer 3): today's parameter it was
|
|
1345
|
+
* adopted from, which the broker keeps agreeing with the revision (the
|
|
1346
|
+
* brokered copy, never the refresh token) for the runners still reading
|
|
1347
|
+
* it, until the fleet audit. Never created when it is gone.
|
|
1348
|
+
*/
|
|
1349
|
+
legacyParameter?: string;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** Broker → cloud: the answer for that one credential, never material.
|
|
1353
|
+
* For an adopted revision (a request with `legacyParameter`), `legacy`
|
|
1354
|
+
* says whether today's parameter now agrees with it: `behind` when its
|
|
1355
|
+
* write did not land, which the cloud asks the broker to repair soon. */
|
|
1356
|
+
export type BrokerRevisionAnswer =
|
|
1357
|
+
| { outcome: "refreshed" | "fresh"; version: number; expiresAt: number | null; legacy?: "synced" | "behind" }
|
|
1358
|
+
| { outcome: "needs_sign_in" }
|
|
1359
|
+
| { outcome: "busy" }
|
|
1360
|
+
| { outcome: "unavailable"; error: string };
|
|
1361
|
+
|
|
1362
|
+
/** The request, exactly, or null: both parameters must name the same
|
|
1363
|
+
* revision of the same credential, of a class the broker refreshes. */
|
|
1364
|
+
export function parseBrokerRevisionRequest(
|
|
1365
|
+
value: unknown,
|
|
1366
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1367
|
+
): BrokerRevisionRequest | null {
|
|
1368
|
+
const fields = ["v", "credentialId", "revision", "brokerParameter", "leaseParameter", "force"];
|
|
1369
|
+
const o = exactObject(value, fields) ?? exactObject(value, [...fields, "legacyParameter"]);
|
|
1370
|
+
if (
|
|
1371
|
+
!o ||
|
|
1372
|
+
o.v !== 1 ||
|
|
1373
|
+
!isIdValue(o.credentialId) ||
|
|
1374
|
+
!isIdValue(o.revision) ||
|
|
1375
|
+
typeof o.brokerParameter !== "string" ||
|
|
1376
|
+
typeof o.leaseParameter !== "string" ||
|
|
1377
|
+
typeof o.force !== "boolean"
|
|
1378
|
+
) {
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
const broker = parseBrokerParameterName(o.brokerParameter);
|
|
1382
|
+
const lease = parseLeaseParameterName(o.leaseParameter);
|
|
1383
|
+
if (
|
|
1384
|
+
!broker ||
|
|
1385
|
+
!lease ||
|
|
1386
|
+
!sameLocation(broker, lease) ||
|
|
1387
|
+
(broker.class !== "engine" && broker.class !== "mcp") ||
|
|
1388
|
+
broker.credentialId !== o.credentialId ||
|
|
1389
|
+
broker.revision !== o.revision
|
|
1390
|
+
) {
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
if (o.legacyParameter !== undefined && !adoptedFrom(o.legacyParameter, broker, legacyPrefix)) return null;
|
|
1394
|
+
return {
|
|
1395
|
+
v: 1,
|
|
1396
|
+
credentialId: o.credentialId,
|
|
1397
|
+
revision: o.revision,
|
|
1398
|
+
brokerParameter: o.brokerParameter,
|
|
1399
|
+
leaseParameter: o.leaseParameter,
|
|
1400
|
+
force: o.force,
|
|
1401
|
+
...(o.legacyParameter !== undefined ? { legacyParameter: o.legacyParameter as string } : {}),
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** The broker's answer, exactly, or null. */
|
|
1406
|
+
export function parseBrokerRevisionAnswer(value: unknown): BrokerRevisionAnswer | null {
|
|
1407
|
+
const outcome = (value as { outcome?: unknown } | null)?.outcome;
|
|
1408
|
+
if (outcome === "refreshed" || outcome === "fresh") {
|
|
1409
|
+
const fields = ["outcome", "version", "expiresAt"];
|
|
1410
|
+
const o = exactObject(value, fields) ?? exactObject(value, [...fields, "legacy"]);
|
|
1411
|
+
if (
|
|
1412
|
+
!o ||
|
|
1413
|
+
!isPositiveInteger(o.version) ||
|
|
1414
|
+
!(o.expiresAt === null || isEpochMs(o.expiresAt)) ||
|
|
1415
|
+
!(o.legacy === undefined || o.legacy === "synced" || o.legacy === "behind")
|
|
1416
|
+
) {
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
outcome,
|
|
1421
|
+
version: o.version,
|
|
1422
|
+
expiresAt: o.expiresAt,
|
|
1423
|
+
...(o.legacy !== undefined ? { legacy: o.legacy as "synced" | "behind" } : {}),
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
if (outcome === "needs_sign_in" || outcome === "busy") {
|
|
1427
|
+
return exactObject(value, ["outcome"]) ? { outcome } : null;
|
|
1428
|
+
}
|
|
1429
|
+
if (outcome === "unavailable") {
|
|
1430
|
+
const o = exactObject(value, ["outcome", "error"]);
|
|
1431
|
+
return o && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
1432
|
+
? { outcome, error: o.error }
|
|
1433
|
+
: null;
|
|
1434
|
+
}
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* What a broker answer may say about a failure: the error's kind (an AWS or
|
|
1440
|
+
* Node error's name), never its message, which can quote a value a request
|
|
1441
|
+
* carried.
|
|
1442
|
+
*/
|
|
1443
|
+
export function brokerErrorKind(error: unknown): string {
|
|
1444
|
+
const name = error instanceof Error ? error.name : "";
|
|
1445
|
+
return /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(name) ? name : "Error";
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// ---------------------------------------------------------------------------
|
|
1449
|
+
// ADR-154, owner answer 3: Uai Cloud's workspace AI accounts adopted in place
|
|
1450
|
+
// ---------------------------------------------------------------------------
|
|
1451
|
+
|
|
1452
|
+
/** ADR-134's default root. Installed Uai-operated tiers may configure a
|
|
1453
|
+
* different root; the cloud and broker must agree on the exact value. */
|
|
1454
|
+
export const DEFAULT_CREDENTIAL_STORE_PREFIX = "/uai";
|
|
1455
|
+
const CREDENTIAL_STORE_PREFIX = /^\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/;
|
|
1456
|
+
|
|
1457
|
+
/** A credential-store root in the same form accepted by the host setting. */
|
|
1458
|
+
export function parseCredentialStorePrefix(value: unknown): string | null {
|
|
1459
|
+
return typeof value === "string" && CREDENTIAL_STORE_PREFIX.test(value) ? value : null;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function credentialStoreRoot(prefix: string, suffix: string): string {
|
|
1463
|
+
const parsed = parseCredentialStorePrefix(prefix);
|
|
1464
|
+
if (!parsed) throw new Error("invalid credential-store prefix");
|
|
1465
|
+
return `${parsed}/${suffix}`;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/** Today's layout (ADR-134) for workspace AI accounts. */
|
|
1469
|
+
export function legacyEngineParameterRoot(prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX): string {
|
|
1470
|
+
return credentialStoreRoot(prefix, "orgs");
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Where the broker records adoption, with the legacy path below it. */
|
|
1474
|
+
export function legacyAdoptedRoot(prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX): string {
|
|
1475
|
+
return credentialStoreRoot(prefix, "adopted");
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** Default-root aliases retained for callers describing the standard layout. */
|
|
1479
|
+
export const LEGACY_ENGINE_PARAMETER_ROOT = legacyEngineParameterRoot();
|
|
1480
|
+
export const LEGACY_ADOPTED_ROOT = legacyAdoptedRoot();
|
|
1481
|
+
/** At most this many accounts in one listing page. */
|
|
1482
|
+
export const MAX_LEGACY_ACCOUNTS = 500;
|
|
1483
|
+
const MAX_LEGACY_LABEL_CHARS = 256;
|
|
1484
|
+
/** ADR-134's segment rule, as its ids are actually minted (no dots). */
|
|
1485
|
+
const LEGACY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
1486
|
+
|
|
1487
|
+
/** A parameter under today's layout the cloud adopts: a workspace AI
|
|
1488
|
+
* account. People's MCP connections wait for a fence against the v0 task
|
|
1489
|
+
* machines that refresh them themselves. */
|
|
1490
|
+
export type LegacyParameter = { kind: "engine"; orgId: string; engineKind: string; accountId: string };
|
|
1491
|
+
|
|
1492
|
+
export type LegacyEngineParameter = Extract<LegacyParameter, { kind: "engine" }>;
|
|
1493
|
+
|
|
1494
|
+
/** A parameter under today's layout the cloud adopts, or null. */
|
|
1495
|
+
export function parseLegacyParameterName(
|
|
1496
|
+
name: unknown,
|
|
1497
|
+
prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1498
|
+
): LegacyParameter | null {
|
|
1499
|
+
const parsedPrefix = parseCredentialStorePrefix(prefix);
|
|
1500
|
+
if (typeof name !== "string" || !parsedPrefix) return null;
|
|
1501
|
+
const engineRoot = `${parsedPrefix}/orgs`;
|
|
1502
|
+
if (name.startsWith(`${engineRoot}/`)) {
|
|
1503
|
+
const parts = name.slice(engineRoot.length + 1).split("/");
|
|
1504
|
+
if (parts.length !== 4 || parts[1] !== "engines") return null;
|
|
1505
|
+
const [orgId, , engineKind, accountId] = parts as [string, string, string, string];
|
|
1506
|
+
if (!LEGACY_SEGMENT.test(orgId) || !isEngineKind(engineKind) || !LEGACY_SEGMENT.test(accountId)) return null;
|
|
1507
|
+
return { kind: "engine", orgId, engineKind, accountId };
|
|
1508
|
+
}
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** A workspace AI account's parameter under today's layout, or null. */
|
|
1513
|
+
export function parseLegacyEngineParameterName(
|
|
1514
|
+
name: unknown,
|
|
1515
|
+
prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1516
|
+
): LegacyEngineParameter | null {
|
|
1517
|
+
const legacy = parseLegacyParameterName(name, prefix);
|
|
1518
|
+
return legacy?.kind === "engine" ? legacy : null;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/** The adoption marker for a legacy parameter: the same path under that
|
|
1522
|
+
* store's adopted root (`<prefix>/adopted/orgs/…`). */
|
|
1523
|
+
export function legacyAdoptionMarkerName(
|
|
1524
|
+
legacyParameter: string,
|
|
1525
|
+
prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1526
|
+
): string {
|
|
1527
|
+
if (!parseLegacyParameterName(legacyParameter, prefix)) {
|
|
1528
|
+
throw new Error("not a legacy parameter the cloud adopts");
|
|
1529
|
+
}
|
|
1530
|
+
return `${legacyAdoptedRoot(prefix)}${legacyParameter.slice(prefix.length)}`;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** A parameter under today's layout may be adopted into (and be kept
|
|
1534
|
+
* agreeing with) exactly this credential's location: a workspace AI account
|
|
1535
|
+
* into that workspace's account of the same kind. */
|
|
1536
|
+
function adoptedFrom(
|
|
1537
|
+
legacyParameter: unknown,
|
|
1538
|
+
location: CredentialLocation,
|
|
1539
|
+
prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1540
|
+
): boolean {
|
|
1541
|
+
const legacy = parseLegacyParameterName(legacyParameter, prefix);
|
|
1542
|
+
if (legacy?.kind === "engine") {
|
|
1543
|
+
return (
|
|
1544
|
+
location.class === "engine" &&
|
|
1545
|
+
location.scope === "org" &&
|
|
1546
|
+
location.ownerId === legacy.orgId &&
|
|
1547
|
+
location.engineKind === legacy.engineKind
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Cloud → broker: one page of today's workspace AI accounts, metadata
|
|
1554
|
+
* only, by parameter name, after `after` (null: from the first). */
|
|
1555
|
+
export interface BrokerLegacyListRequest {
|
|
1556
|
+
v: 1;
|
|
1557
|
+
op: "legacy.list";
|
|
1558
|
+
after: string | null;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
export interface BrokerLegacyAccount {
|
|
1562
|
+
parameter: string;
|
|
1563
|
+
orgId: string;
|
|
1564
|
+
engineKind: string;
|
|
1565
|
+
accountId: string;
|
|
1566
|
+
label: string;
|
|
1567
|
+
authKind: "env" | "config-dir";
|
|
1568
|
+
position: number;
|
|
1569
|
+
/** The lane that refreshes it; null for a key that does not rotate. */
|
|
1570
|
+
lane: string | null;
|
|
1571
|
+
/** The revision it was adopted into, once it was. */
|
|
1572
|
+
adopted: { credentialId: string; revision: string } | null;
|
|
1573
|
+
/** Why it cannot be adopted as it is (a fixed reason code), or null. */
|
|
1574
|
+
unsupported: string | null;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
export type BrokerLegacyListAnswer =
|
|
1578
|
+
/** `next`: the name to list after for the following page, or null. */
|
|
1579
|
+
| { outcome: "listed"; accounts: BrokerLegacyAccount[]; next: string | null }
|
|
1580
|
+
| { outcome: "unavailable"; error: string };
|
|
1581
|
+
|
|
1582
|
+
/** Cloud → broker: adopt one parameter under today's layout as this
|
|
1583
|
+
* revision, which the cloud has opened for it in `orgId` (and for `userId`,
|
|
1584
|
+
* a person's). */
|
|
1585
|
+
export interface BrokerAdoptRequest {
|
|
1586
|
+
v: 1;
|
|
1587
|
+
op: "legacy.adopt";
|
|
1588
|
+
legacyParameter: string;
|
|
1589
|
+
credentialId: string;
|
|
1590
|
+
revision: string;
|
|
1591
|
+
leaseParameter: string;
|
|
1592
|
+
/** The broker-only part, exactly when the material rotates. */
|
|
1593
|
+
brokerParameter: string | null;
|
|
1594
|
+
orgId: string;
|
|
1595
|
+
userId: string | null;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
export type BrokerAdoptAnswer =
|
|
1599
|
+
/** Both parts exist; `version` is the lease-readable part's. */
|
|
1600
|
+
| { outcome: "adopted"; version: number; expiresAt: number | null }
|
|
1601
|
+
/** Today's parameter is gone: there is nothing to adopt. */
|
|
1602
|
+
| { outcome: "absent" }
|
|
1603
|
+
/** It cannot be adopted as it is, ever (a fixed reason code). */
|
|
1604
|
+
| { outcome: "unsupported"; reason: string }
|
|
1605
|
+
| { outcome: "busy" }
|
|
1606
|
+
| { outcome: "unavailable"; error: string };
|
|
1607
|
+
|
|
1608
|
+
const isReasonCode = (value: unknown): value is string =>
|
|
1609
|
+
typeof value === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(value);
|
|
1610
|
+
|
|
1611
|
+
export function parseBrokerLegacyListRequest(
|
|
1612
|
+
value: unknown,
|
|
1613
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1614
|
+
): BrokerLegacyListRequest | null {
|
|
1615
|
+
const o = exactObject(value, ["v", "op", "after"]);
|
|
1616
|
+
if (!o || o.v !== 1 || o.op !== "legacy.list") return null;
|
|
1617
|
+
if (o.after !== null && !parseLegacyEngineParameterName(o.after, legacyPrefix)) return null;
|
|
1618
|
+
return { v: 1, op: "legacy.list", after: o.after as string | null };
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
export function parseBrokerAdoptRequest(
|
|
1622
|
+
value: unknown,
|
|
1623
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1624
|
+
): BrokerAdoptRequest | null {
|
|
1625
|
+
const o = exactObject(value, [
|
|
1626
|
+
"v",
|
|
1627
|
+
"op",
|
|
1628
|
+
"legacyParameter",
|
|
1629
|
+
"credentialId",
|
|
1630
|
+
"revision",
|
|
1631
|
+
"leaseParameter",
|
|
1632
|
+
"brokerParameter",
|
|
1633
|
+
"orgId",
|
|
1634
|
+
"userId",
|
|
1635
|
+
]);
|
|
1636
|
+
if (
|
|
1637
|
+
!o ||
|
|
1638
|
+
o.v !== 1 ||
|
|
1639
|
+
o.op !== "legacy.adopt" ||
|
|
1640
|
+
typeof o.legacyParameter !== "string" ||
|
|
1641
|
+
!isIdValue(o.credentialId) ||
|
|
1642
|
+
!isIdValue(o.revision) ||
|
|
1643
|
+
typeof o.leaseParameter !== "string" ||
|
|
1644
|
+
(o.brokerParameter !== null && typeof o.brokerParameter !== "string") ||
|
|
1645
|
+
!isIdValue(o.orgId) ||
|
|
1646
|
+
(o.userId !== null && !isIdValue(o.userId))
|
|
1647
|
+
) {
|
|
1648
|
+
return null;
|
|
1649
|
+
}
|
|
1650
|
+
const lease = parseLeaseParameterName(o.leaseParameter);
|
|
1651
|
+
if (
|
|
1652
|
+
!lease ||
|
|
1653
|
+
!adoptedFrom(o.legacyParameter, lease, legacyPrefix) ||
|
|
1654
|
+
lease.credentialId !== o.credentialId ||
|
|
1655
|
+
lease.revision !== o.revision ||
|
|
1656
|
+
// The flow's owners: a workspace's account is the workspace's alone; a
|
|
1657
|
+
// person's connection is theirs, in a workspace.
|
|
1658
|
+
(lease.scope === "org" ? o.userId !== null || o.orgId !== lease.ownerId : o.userId !== lease.ownerId)
|
|
1659
|
+
) {
|
|
1660
|
+
return null;
|
|
1661
|
+
}
|
|
1662
|
+
if (o.brokerParameter !== null) {
|
|
1663
|
+
const broker = parseBrokerParameterName(o.brokerParameter);
|
|
1664
|
+
if (!broker || !sameLocation(broker, lease)) return null;
|
|
1665
|
+
}
|
|
1666
|
+
return {
|
|
1667
|
+
v: 1,
|
|
1668
|
+
op: "legacy.adopt",
|
|
1669
|
+
legacyParameter: o.legacyParameter,
|
|
1670
|
+
credentialId: o.credentialId,
|
|
1671
|
+
revision: o.revision,
|
|
1672
|
+
leaseParameter: o.leaseParameter,
|
|
1673
|
+
brokerParameter: o.brokerParameter as string | null,
|
|
1674
|
+
orgId: o.orgId,
|
|
1675
|
+
userId: o.userId as string | null,
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function parseLegacyAccount(
|
|
1680
|
+
value: unknown,
|
|
1681
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1682
|
+
): BrokerLegacyAccount | null {
|
|
1683
|
+
const o = exactObject(value, [
|
|
1684
|
+
"parameter",
|
|
1685
|
+
"orgId",
|
|
1686
|
+
"engineKind",
|
|
1687
|
+
"accountId",
|
|
1688
|
+
"label",
|
|
1689
|
+
"authKind",
|
|
1690
|
+
"position",
|
|
1691
|
+
"lane",
|
|
1692
|
+
"adopted",
|
|
1693
|
+
"unsupported",
|
|
1694
|
+
]);
|
|
1695
|
+
const legacy = o ? parseLegacyEngineParameterName(o.parameter, legacyPrefix) : null;
|
|
1696
|
+
if (
|
|
1697
|
+
!o ||
|
|
1698
|
+
!legacy ||
|
|
1699
|
+
legacy.orgId !== o.orgId ||
|
|
1700
|
+
legacy.engineKind !== o.engineKind ||
|
|
1701
|
+
legacy.accountId !== o.accountId ||
|
|
1702
|
+
!isBoundedString(o.label, MAX_LEGACY_LABEL_CHARS) ||
|
|
1703
|
+
(o.authKind !== "env" && o.authKind !== "config-dir") ||
|
|
1704
|
+
typeof o.position !== "number" ||
|
|
1705
|
+
!Number.isSafeInteger(o.position) ||
|
|
1706
|
+
(o.lane !== null && !isBoundedString(o.lane, 64)) ||
|
|
1707
|
+
(o.unsupported !== null && !isReasonCode(o.unsupported))
|
|
1708
|
+
) {
|
|
1709
|
+
return null;
|
|
1710
|
+
}
|
|
1711
|
+
let adopted: BrokerLegacyAccount["adopted"] = null;
|
|
1712
|
+
if (o.adopted !== null) {
|
|
1713
|
+
const a = exactObject(o.adopted, ["credentialId", "revision"]);
|
|
1714
|
+
if (!a || !isIdValue(a.credentialId) || !isIdValue(a.revision)) return null;
|
|
1715
|
+
adopted = { credentialId: a.credentialId, revision: a.revision };
|
|
1716
|
+
}
|
|
1717
|
+
return {
|
|
1718
|
+
parameter: o.parameter as string,
|
|
1719
|
+
orgId: legacy.orgId,
|
|
1720
|
+
engineKind: legacy.engineKind,
|
|
1721
|
+
accountId: legacy.accountId,
|
|
1722
|
+
label: o.label,
|
|
1723
|
+
authKind: o.authKind,
|
|
1724
|
+
position: o.position,
|
|
1725
|
+
lane: o.lane as string | null,
|
|
1726
|
+
adopted,
|
|
1727
|
+
unsupported: o.unsupported as string | null,
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/** The broker's listing page, exactly, or null. */
|
|
1732
|
+
export function parseBrokerLegacyListAnswer(
|
|
1733
|
+
value: unknown,
|
|
1734
|
+
legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
|
|
1735
|
+
): BrokerLegacyListAnswer | null {
|
|
1736
|
+
const outcome = (value as { outcome?: unknown } | null)?.outcome;
|
|
1737
|
+
if (outcome === "listed") {
|
|
1738
|
+
const o = exactObject(value, ["outcome", "accounts", "next"]);
|
|
1739
|
+
if (!o || !Array.isArray(o.accounts) || o.accounts.length > MAX_LEGACY_ACCOUNTS) return null;
|
|
1740
|
+
if (o.next !== null && !parseLegacyEngineParameterName(o.next, legacyPrefix)) return null;
|
|
1741
|
+
const accounts: BrokerLegacyAccount[] = [];
|
|
1742
|
+
for (const entry of o.accounts) {
|
|
1743
|
+
const account = parseLegacyAccount(entry, legacyPrefix);
|
|
1744
|
+
if (!account) return null;
|
|
1745
|
+
accounts.push(account);
|
|
1746
|
+
}
|
|
1747
|
+
return { outcome, accounts, next: o.next as string | null };
|
|
1748
|
+
}
|
|
1749
|
+
if (outcome === "unavailable") {
|
|
1750
|
+
const o = exactObject(value, ["outcome", "error"]);
|
|
1751
|
+
return o && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS) ? { outcome, error: o.error } : null;
|
|
1752
|
+
}
|
|
1753
|
+
return null;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** The broker's answer to an adoption, exactly, or null. */
|
|
1757
|
+
export function parseBrokerAdoptAnswer(value: unknown): BrokerAdoptAnswer | null {
|
|
1758
|
+
const outcome = (value as { outcome?: unknown } | null)?.outcome;
|
|
1759
|
+
if (outcome === "adopted") {
|
|
1760
|
+
const o = exactObject(value, ["outcome", "version", "expiresAt"]);
|
|
1761
|
+
return o && isPositiveInteger(o.version) && (o.expiresAt === null || isEpochMs(o.expiresAt))
|
|
1762
|
+
? { outcome, version: o.version, expiresAt: o.expiresAt }
|
|
1763
|
+
: null;
|
|
1764
|
+
}
|
|
1765
|
+
if (outcome === "absent" || outcome === "busy") {
|
|
1766
|
+
return exactObject(value, ["outcome"]) ? { outcome } : null;
|
|
1767
|
+
}
|
|
1768
|
+
if (outcome === "unsupported") {
|
|
1769
|
+
const o = exactObject(value, ["outcome", "reason"]);
|
|
1770
|
+
return o && isReasonCode(o.reason) ? { outcome, reason: o.reason } : null;
|
|
1771
|
+
}
|
|
1772
|
+
if (outcome === "unavailable") {
|
|
1773
|
+
const o = exactObject(value, ["outcome", "error"]);
|
|
1774
|
+
return o && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS) ? { outcome, error: o.error } : null;
|
|
1775
|
+
}
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// ---------------------------------------------------------------------------
|
|
1780
|
+
// ADR-154 §11: an MCP connection's handshake on the sign-in box
|
|
1781
|
+
// ---------------------------------------------------------------------------
|
|
1782
|
+
|
|
1783
|
+
/** Advertised by a sign-in box that runs MCP handshakes as capture flows. */
|
|
1784
|
+
export const SIGNIN_BOX_MCP_CAPTURE_FEATURE = "signin-box-mcp-capture-v1";
|
|
1785
|
+
|
|
1786
|
+
const MAX_MCP_URL_CHARS = 2048;
|
|
1787
|
+
const MAX_MCP_STATE_CHARS = 512;
|
|
1788
|
+
const MAX_MCP_CODE_CHARS = 4096;
|
|
1789
|
+
const MAX_MCP_CLIENT_CHARS = 1024;
|
|
1790
|
+
const MAX_MCP_SCOPES = 64;
|
|
1791
|
+
|
|
1792
|
+
/** Cloud → box: probe an MCP server for a connection being added. */
|
|
1793
|
+
export interface McpCaptureProbeFrame {
|
|
1794
|
+
kind: "mcp.capture.probe";
|
|
1795
|
+
opId: string;
|
|
1796
|
+
flow: CaptureFlowWire;
|
|
1797
|
+
url: string;
|
|
1798
|
+
/** A static header the server takes, when the person gave one. */
|
|
1799
|
+
header: { name: string; value: string } | null;
|
|
1800
|
+
/** A client the person (or the catalog) brings, when the authorization
|
|
1801
|
+
* server cannot register one. */
|
|
1802
|
+
clientId: string | null;
|
|
1803
|
+
clientSecret: string | null;
|
|
1804
|
+
redirectUri: string;
|
|
1805
|
+
state: string;
|
|
1806
|
+
/** The scopes to ask for (a catalog entry's); null asks for none and
|
|
1807
|
+
* takes the authorization server's default. Never its whole
|
|
1808
|
+
* `scopes_supported`, which is a capability list, not a need. */
|
|
1809
|
+
scopes: string[] | null;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** Cloud → box: the authorization server redirected back. */
|
|
1813
|
+
export interface McpCaptureCompleteFrame {
|
|
1814
|
+
kind: "mcp.capture.complete";
|
|
1815
|
+
opId: string;
|
|
1816
|
+
flow: CaptureFlowWire;
|
|
1817
|
+
code: string;
|
|
1818
|
+
state: string;
|
|
1819
|
+
/** RFC 9207: the issuer the redirect named, when it named one. */
|
|
1820
|
+
iss: string | null;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/** Cloud → box: the cloud ended this sign-in (expired, cancelled): forget its
|
|
1824
|
+
* verifier and client secret now. No answer. */
|
|
1825
|
+
export interface McpCaptureCancelFrame {
|
|
1826
|
+
kind: "mcp.capture.cancel";
|
|
1827
|
+
opId: string;
|
|
1828
|
+
flow: CaptureFlowWire;
|
|
1829
|
+
state: string;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
export type McpCaptureOutcome = "none" | "token" | "auth_required" | "connected" | "error";
|
|
1833
|
+
|
|
1834
|
+
/** Box → cloud: what the handshake came to. `capture` is the revision it
|
|
1835
|
+
* wrote (`token`, `connected`); `authorizeUrl` where the person signs in
|
|
1836
|
+
* (`auth_required`); `error` why it failed. Never material. */
|
|
1837
|
+
export interface McpCaptureAckFrame {
|
|
1838
|
+
kind: "mcp.capture.ack";
|
|
1839
|
+
opId: string;
|
|
1840
|
+
outcome: McpCaptureOutcome;
|
|
1841
|
+
capture: CaptureResultWire | null;
|
|
1842
|
+
authorizeUrl: string | null;
|
|
1843
|
+
scopes: string[] | null;
|
|
1844
|
+
error: string | null;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function isHttpsUrl(value: unknown): value is string {
|
|
1848
|
+
if (!isBoundedString(value, MAX_MCP_URL_CHARS)) return false;
|
|
1849
|
+
try {
|
|
1850
|
+
const url = new URL(value);
|
|
1851
|
+
return url.protocol === "https:" && url.username === "" && url.password === "" && url.hostname !== "";
|
|
1852
|
+
} catch {
|
|
1853
|
+
return false;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function isMcpFlow(value: unknown): value is CaptureFlowWire {
|
|
1858
|
+
const flow = parseCaptureFlowWire(value);
|
|
1859
|
+
return flow !== null && flow.location.class === "mcp";
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
function isOptionalClient(value: unknown): value is string | null {
|
|
1863
|
+
return value === null || isBoundedString(value, MAX_MCP_CLIENT_CHARS);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/** RFC 6749 scope tokens: printable ASCII but space, `"` and `\\`. */
|
|
1867
|
+
const SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]{1,128}$/;
|
|
1868
|
+
|
|
1869
|
+
export function isScopeList(value: unknown): value is string[] {
|
|
1870
|
+
return (
|
|
1871
|
+
Array.isArray(value) &&
|
|
1872
|
+
value.length <= MAX_MCP_SCOPES &&
|
|
1873
|
+
value.every((scope) => typeof scope === "string" && SCOPE_TOKEN.test(scope))
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
export function parseMcpCaptureProbeFrame(value: unknown): McpCaptureProbeFrame | null {
|
|
1878
|
+
const o = exactObject(value, [
|
|
1879
|
+
"kind",
|
|
1880
|
+
"opId",
|
|
1881
|
+
"flow",
|
|
1882
|
+
"url",
|
|
1883
|
+
"header",
|
|
1884
|
+
"clientId",
|
|
1885
|
+
"clientSecret",
|
|
1886
|
+
"redirectUri",
|
|
1887
|
+
"state",
|
|
1888
|
+
"scopes",
|
|
1889
|
+
]);
|
|
1890
|
+
if (
|
|
1891
|
+
!o ||
|
|
1892
|
+
o.kind !== "mcp.capture.probe" ||
|
|
1893
|
+
!isOpId(o.opId) ||
|
|
1894
|
+
!isMcpFlow(o.flow) ||
|
|
1895
|
+
!isHttpsUrl(o.url) ||
|
|
1896
|
+
!isOptionalClient(o.clientId) ||
|
|
1897
|
+
!isOptionalClient(o.clientSecret) ||
|
|
1898
|
+
!isHttpsUrl(o.redirectUri) ||
|
|
1899
|
+
!isBoundedString(o.state, MAX_MCP_STATE_CHARS) ||
|
|
1900
|
+
!(o.scopes === null || isScopeList(o.scopes))
|
|
1901
|
+
) {
|
|
1902
|
+
return null;
|
|
1903
|
+
}
|
|
1904
|
+
let header: { name: string; value: string } | null = null;
|
|
1905
|
+
if (o.header !== null) {
|
|
1906
|
+
const h = exactObject(o.header, ["name", "value"]);
|
|
1907
|
+
if (
|
|
1908
|
+
!h ||
|
|
1909
|
+
typeof h.name !== "string" ||
|
|
1910
|
+
!HEADER_NAME.test(h.name) ||
|
|
1911
|
+
FORBIDDEN_HEADER_NAMES.has(h.name.toLowerCase()) ||
|
|
1912
|
+
!isBoundedString(h.value, 4096) ||
|
|
1913
|
+
/[\r\n]/.test(h.value)
|
|
1914
|
+
) {
|
|
1915
|
+
return null;
|
|
1916
|
+
}
|
|
1917
|
+
header = { name: h.name, value: h.value };
|
|
1918
|
+
}
|
|
1919
|
+
return {
|
|
1920
|
+
kind: "mcp.capture.probe",
|
|
1921
|
+
opId: o.opId,
|
|
1922
|
+
flow: o.flow as CaptureFlowWire,
|
|
1923
|
+
url: o.url,
|
|
1924
|
+
header,
|
|
1925
|
+
clientId: o.clientId,
|
|
1926
|
+
clientSecret: o.clientSecret,
|
|
1927
|
+
redirectUri: o.redirectUri,
|
|
1928
|
+
state: o.state,
|
|
1929
|
+
scopes: o.scopes === null ? null : [...(o.scopes as string[])],
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
export function parseMcpCaptureCompleteFrame(value: unknown): McpCaptureCompleteFrame | null {
|
|
1934
|
+
const o = exactObject(value, ["kind", "opId", "flow", "code", "state", "iss"]);
|
|
1935
|
+
return o &&
|
|
1936
|
+
o.kind === "mcp.capture.complete" &&
|
|
1937
|
+
isOpId(o.opId) &&
|
|
1938
|
+
isMcpFlow(o.flow) &&
|
|
1939
|
+
isBoundedString(o.code, MAX_MCP_CODE_CHARS) &&
|
|
1940
|
+
isBoundedString(o.state, MAX_MCP_STATE_CHARS) &&
|
|
1941
|
+
(o.iss === null || isBoundedString(o.iss, MAX_MCP_URL_CHARS))
|
|
1942
|
+
? {
|
|
1943
|
+
kind: "mcp.capture.complete",
|
|
1944
|
+
opId: o.opId,
|
|
1945
|
+
flow: o.flow as CaptureFlowWire,
|
|
1946
|
+
code: o.code,
|
|
1947
|
+
state: o.state,
|
|
1948
|
+
iss: o.iss,
|
|
1949
|
+
}
|
|
1950
|
+
: null;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
export function parseMcpCaptureCancelFrame(value: unknown): McpCaptureCancelFrame | null {
|
|
1954
|
+
const o = exactObject(value, ["kind", "opId", "flow", "state"]);
|
|
1955
|
+
return o &&
|
|
1956
|
+
o.kind === "mcp.capture.cancel" &&
|
|
1957
|
+
isOpId(o.opId) &&
|
|
1958
|
+
isMcpFlow(o.flow) &&
|
|
1959
|
+
isBoundedString(o.state, MAX_MCP_STATE_CHARS)
|
|
1960
|
+
? { kind: "mcp.capture.cancel", opId: o.opId, flow: o.flow as CaptureFlowWire, state: o.state }
|
|
1961
|
+
: null;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
export function parseMcpCaptureAckFrame(value: unknown): McpCaptureAckFrame | null {
|
|
1965
|
+
const o = exactObject(value, ["kind", "opId", "outcome", "capture", "authorizeUrl", "scopes", "error"]);
|
|
1966
|
+
if (!o || o.kind !== "mcp.capture.ack" || !isOpId(o.opId)) return null;
|
|
1967
|
+
const outcome = o.outcome;
|
|
1968
|
+
const capture = o.capture === null ? null : parseCaptureResultWire(o.capture);
|
|
1969
|
+
if (o.capture !== null && !capture) return null;
|
|
1970
|
+
const scopes = o.scopes === null ? null : isScopeList(o.scopes) ? [...o.scopes] : undefined;
|
|
1971
|
+
if (scopes === undefined) return null;
|
|
1972
|
+
const base = { kind: "mcp.capture.ack" as const, opId: o.opId, scopes };
|
|
1973
|
+
switch (outcome) {
|
|
1974
|
+
case "none":
|
|
1975
|
+
return capture === null && o.authorizeUrl === null && o.error === null
|
|
1976
|
+
? { ...base, outcome, capture: null, authorizeUrl: null, error: null }
|
|
1977
|
+
: null;
|
|
1978
|
+
case "token":
|
|
1979
|
+
case "connected":
|
|
1980
|
+
return capture !== null && o.authorizeUrl === null && o.error === null
|
|
1981
|
+
? { ...base, outcome, capture, authorizeUrl: null, error: null }
|
|
1982
|
+
: null;
|
|
1983
|
+
case "auth_required":
|
|
1984
|
+
return capture === null && isHttpsUrl(o.authorizeUrl) && o.error === null
|
|
1985
|
+
? { ...base, outcome, capture: null, authorizeUrl: o.authorizeUrl, error: null }
|
|
1986
|
+
: null;
|
|
1987
|
+
case "error":
|
|
1988
|
+
return capture === null && o.authorizeUrl === null && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
1989
|
+
? { ...base, outcome, capture: null, authorizeUrl: null, error: o.error }
|
|
1990
|
+
: null;
|
|
1991
|
+
default:
|
|
1992
|
+
return null;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// ---------------------------------------------------------------------------
|
|
1997
|
+
// GitHub sign-ins and SSH keys on the sign-in box (ADR-154 §12)
|
|
1998
|
+
// ---------------------------------------------------------------------------
|
|
1999
|
+
|
|
2000
|
+
/** Advertised by a sign-in box that writes GitHub and SSH revisions, lists a
|
|
2001
|
+
* person's GitHub installations from the revision the cloud names, and
|
|
2002
|
+
* revokes a retired GitHub token. */
|
|
2003
|
+
export const SIGNIN_BOX_PERSON_CREDENTIALS_FEATURE = "signin-box-person-credentials-v1";
|
|
2004
|
+
|
|
2005
|
+
const MAX_GITHUB_TOKEN_CHARS = 512;
|
|
2006
|
+
const MAX_GITHUB_CLIENT_ID_CHARS = 128;
|
|
2007
|
+
const MAX_GITHUB_CLIENT_SECRET_CHARS = 256;
|
|
2008
|
+
export const MAX_SSH_PUBLIC_KEY_CHARS = 1024;
|
|
2009
|
+
|
|
2010
|
+
/** Cloud → box: write the GitHub user token the cloud's callback obtained as
|
|
2011
|
+
* this revision. This frame is the token's only trip past the cloud. */
|
|
2012
|
+
export interface GithubCaptureFrame {
|
|
2013
|
+
kind: "github.capture";
|
|
2014
|
+
opId: string;
|
|
2015
|
+
flow: CaptureFlowWire;
|
|
2016
|
+
token: string;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/** Cloud → box: mint an SSH key as this revision. The private half goes to
|
|
2020
|
+
* the revision's parameter and nowhere else. */
|
|
2021
|
+
export interface SshMintFrame {
|
|
2022
|
+
kind: "ssh.mint";
|
|
2023
|
+
opId: string;
|
|
2024
|
+
flow: CaptureFlowWire;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/** Box → cloud: the revision written (with an SSH key's public half), or why
|
|
2028
|
+
* not. Never material. */
|
|
2029
|
+
export interface PersonCaptureAckFrame {
|
|
2030
|
+
kind: "person.capture.ack";
|
|
2031
|
+
opId: string;
|
|
2032
|
+
capture: CaptureResultWire | null;
|
|
2033
|
+
publicKey: string | null;
|
|
2034
|
+
error: string | null;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
/** Cloud → box: revoke a retired GitHub revision's token at GitHub. The App's
|
|
2038
|
+
* client id and secret ride the frame; the box keeps neither. */
|
|
2039
|
+
export interface GithubRevokeFrame {
|
|
2040
|
+
kind: "github.revoke";
|
|
2041
|
+
opId: string;
|
|
2042
|
+
/** The retired revision's lease-readable parameter. */
|
|
2043
|
+
parameter: string;
|
|
2044
|
+
clientId: string;
|
|
2045
|
+
clientSecret: string;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
/** `revoked`: GitHub confirmed; `absent`: the token, or its parameter, is
|
|
2049
|
+
* gone already; `retry`: ask again later. */
|
|
2050
|
+
export type GithubRevokeOutcome = "revoked" | "absent" | "retry";
|
|
2051
|
+
|
|
2052
|
+
export interface GithubRevokeAckFrame {
|
|
2053
|
+
kind: "github.revoke.ack";
|
|
2054
|
+
opId: string;
|
|
2055
|
+
outcome: GithubRevokeOutcome;
|
|
2056
|
+
error: string | null;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function isPersonFlow(value: unknown, credentialClass: "github" | "ssh"): value is CaptureFlowWire {
|
|
2060
|
+
const flow = parseCaptureFlowWire(value);
|
|
2061
|
+
return flow !== null && flow.location.class === credentialClass;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/** A GitHub token as the leased value holds it: bounded, one word. */
|
|
2065
|
+
export function isGithubToken(value: unknown): value is string {
|
|
2066
|
+
return isBoundedString(value, MAX_GITHUB_TOKEN_CHARS) && !/\s/.test(value);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
export function parseGithubCaptureFrame(value: unknown): GithubCaptureFrame | null {
|
|
2070
|
+
const o = exactObject(value, ["kind", "opId", "flow", "token"]);
|
|
2071
|
+
return o &&
|
|
2072
|
+
o.kind === "github.capture" &&
|
|
2073
|
+
isOpId(o.opId) &&
|
|
2074
|
+
isPersonFlow(o.flow, "github") &&
|
|
2075
|
+
isGithubToken(o.token)
|
|
2076
|
+
? { kind: "github.capture", opId: o.opId, flow: o.flow as CaptureFlowWire, token: o.token }
|
|
2077
|
+
: null;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
export function parseSshMintFrame(value: unknown): SshMintFrame | null {
|
|
2081
|
+
const o = exactObject(value, ["kind", "opId", "flow"]);
|
|
2082
|
+
return o && o.kind === "ssh.mint" && isOpId(o.opId) && isPersonFlow(o.flow, "ssh")
|
|
2083
|
+
? { kind: "ssh.mint", opId: o.opId, flow: o.flow as CaptureFlowWire }
|
|
2084
|
+
: null;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
export function parsePersonCaptureAckFrame(value: unknown): PersonCaptureAckFrame | null {
|
|
2088
|
+
const o = exactObject(value, ["kind", "opId", "capture", "publicKey", "error"]);
|
|
2089
|
+
if (!o || o.kind !== "person.capture.ack" || !isOpId(o.opId)) return null;
|
|
2090
|
+
const capture = o.capture === null ? null : parseCaptureResultWire(o.capture);
|
|
2091
|
+
if (o.capture !== null && !capture) return null;
|
|
2092
|
+
if (!(o.publicKey === null || (isBoundedString(o.publicKey, MAX_SSH_PUBLIC_KEY_CHARS) && !/[\r\n]/.test(o.publicKey)))) {
|
|
2093
|
+
return null;
|
|
2094
|
+
}
|
|
2095
|
+
if (capture !== null) {
|
|
2096
|
+
return o.error === null
|
|
2097
|
+
? { kind: "person.capture.ack", opId: o.opId, capture, publicKey: o.publicKey, error: null }
|
|
2098
|
+
: null;
|
|
2099
|
+
}
|
|
2100
|
+
return o.publicKey === null && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
2101
|
+
? { kind: "person.capture.ack", opId: o.opId, capture: null, publicKey: null, error: o.error }
|
|
2102
|
+
: null;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
export function parseGithubRevokeFrame(value: unknown): GithubRevokeFrame | null {
|
|
2106
|
+
const o = exactObject(value, ["kind", "opId", "parameter", "clientId", "clientSecret"]);
|
|
2107
|
+
if (
|
|
2108
|
+
!o ||
|
|
2109
|
+
o.kind !== "github.revoke" ||
|
|
2110
|
+
!isOpId(o.opId) ||
|
|
2111
|
+
typeof o.parameter !== "string" ||
|
|
2112
|
+
parseLeaseParameterName(o.parameter)?.class !== "github" ||
|
|
2113
|
+
!isBoundedString(o.clientId, MAX_GITHUB_CLIENT_ID_CHARS) ||
|
|
2114
|
+
!isBoundedString(o.clientSecret, MAX_GITHUB_CLIENT_SECRET_CHARS)
|
|
2115
|
+
) {
|
|
2116
|
+
return null;
|
|
2117
|
+
}
|
|
2118
|
+
return {
|
|
2119
|
+
kind: "github.revoke",
|
|
2120
|
+
opId: o.opId,
|
|
2121
|
+
parameter: o.parameter,
|
|
2122
|
+
clientId: o.clientId,
|
|
2123
|
+
clientSecret: o.clientSecret,
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
export function parseGithubRevokeAckFrame(value: unknown): GithubRevokeAckFrame | null {
|
|
2128
|
+
const o = exactObject(value, ["kind", "opId", "outcome", "error"]);
|
|
2129
|
+
if (!o || o.kind !== "github.revoke.ack" || !isOpId(o.opId)) return null;
|
|
2130
|
+
if (o.outcome === "revoked" || o.outcome === "absent") {
|
|
2131
|
+
return o.error === null ? { kind: "github.revoke.ack", opId: o.opId, outcome: o.outcome, error: null } : null;
|
|
2132
|
+
}
|
|
2133
|
+
return o.outcome === "retry" && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
|
|
2134
|
+
? { kind: "github.revoke.ack", opId: o.opId, outcome: "retry", error: o.error }
|
|
2135
|
+
: null;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/** A GitHub revision the cloud names for the repository picker: it must be
|
|
2139
|
+
* that person's own. */
|
|
2140
|
+
export function isPersonGithubParameter(parameter: unknown, userId: string): parameter is string {
|
|
2141
|
+
if (typeof parameter !== "string") return false;
|
|
2142
|
+
const location = parseLeaseParameterName(parameter);
|
|
2143
|
+
return location !== null && location.class === "github" && location.ownerId === userId;
|
|
2144
|
+
}
|