@moondeck/station 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §11 on the runner: the MCP gateway serves a task's connections from
|
|
3
|
+
* its lease, inject-only.
|
|
4
|
+
*
|
|
5
|
+
* - The gateway serves exactly what the registry committed: the connections
|
|
6
|
+
* of the view its gate opened on (`openMcpGate`), never a grant's candidate
|
|
7
|
+
* before that, open gate or not. Closing the gate (`closeMcpGate`) stops
|
|
8
|
+
* every request under way for the task, streams and bodies still uploading
|
|
9
|
+
* included, and the gateway answers 503 until the gate opens again, never
|
|
10
|
+
* falling back to anything else.
|
|
11
|
+
* - A credential change within the run updates a connection that is
|
|
12
|
+
* otherwise the same (credential, revision, URL and kind): a refreshed
|
|
13
|
+
* token, or its material becoming readable again or no longer readable.
|
|
14
|
+
* Anything else waits for the gate.
|
|
15
|
+
* - Every request under way stays bound to the exact connection it began on:
|
|
16
|
+
* when what is served changes (a credential change, or the gate opening on
|
|
17
|
+
* a committed view), a request whose connection is no longer served as it
|
|
18
|
+
* was, readable, is stopped at once, streams included. A refreshed token of
|
|
19
|
+
* the same connection keeps it.
|
|
20
|
+
* - Every request is bound to its task's run, its connection's credential,
|
|
21
|
+
* revision and URL when it begins: a request that finds any of them
|
|
22
|
+
* changed after a refresh is refused, never sent under another run's or
|
|
23
|
+
* another account's credential to another server.
|
|
24
|
+
* - The gateway attaches the connection's header (a static key, or an OAuth
|
|
25
|
+
* access token as a bearer). It never calls a token endpoint and never sees
|
|
26
|
+
* a refresh token or a client secret: on a 401, or once the token's expiry
|
|
27
|
+
* has passed, it asks the registry for a reactive refresh (§7) and tries
|
|
28
|
+
* once more only when that installed a newer token.
|
|
29
|
+
* - A cleanup closes the task's gate, waits for every request under way to
|
|
30
|
+
* end, and drops what it names.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type {
|
|
34
|
+
CredentialCleanupRequest,
|
|
35
|
+
CredentialInstallRequest,
|
|
36
|
+
TaskAssignment,
|
|
37
|
+
TaskCredentialLeaseRegistry,
|
|
38
|
+
TaskCredentialView,
|
|
39
|
+
TaskMcpConnection,
|
|
40
|
+
} from "../cloud-credential-leases";
|
|
41
|
+
|
|
42
|
+
/** The registry's typed refresh answer (`credential.refresh.result`). */
|
|
43
|
+
type RefreshResult = Awaited<ReturnType<TaskCredentialLeaseRegistry["requestCredentialRefresh"]>>;
|
|
44
|
+
|
|
45
|
+
export interface LeasedMcpConnection {
|
|
46
|
+
credentialId: string;
|
|
47
|
+
slug: string;
|
|
48
|
+
url: string;
|
|
49
|
+
authKind: "none" | "token" | "oauth";
|
|
50
|
+
revision: string | null;
|
|
51
|
+
/** What the gateway attaches; null for a connection that needs nothing. */
|
|
52
|
+
header: { name: string; value: string } | null;
|
|
53
|
+
/** An OAuth access token's expiry, when known. */
|
|
54
|
+
expiresAt: number | null;
|
|
55
|
+
/** The lease parameter's version this material was read at. */
|
|
56
|
+
version: number | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A connection the manifest names but whose material could not be read is
|
|
61
|
+
* kept as unavailable, under the identity it was named with: the gateway
|
|
62
|
+
* answers 503 for it, never "no such connection", and never calls it
|
|
63
|
+
* unauthenticated.
|
|
64
|
+
*/
|
|
65
|
+
export type ServedMcpConnection =
|
|
66
|
+
| LeasedMcpConnection
|
|
67
|
+
| {
|
|
68
|
+
slug: string;
|
|
69
|
+
credentialId: string;
|
|
70
|
+
url: string;
|
|
71
|
+
authKind: "token" | "oauth";
|
|
72
|
+
revision: string | null;
|
|
73
|
+
unavailable: true;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface TaskMcpLease {
|
|
77
|
+
assignment: TaskAssignment;
|
|
78
|
+
/** False while the registry's gate is closed: serve nothing. */
|
|
79
|
+
open: boolean;
|
|
80
|
+
connections: ReadonlyMap<string, ServedMcpConnection>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The registry's answer to a reactive refresh (the frozen contract). */
|
|
84
|
+
export type McpCredentialRefresher = (
|
|
85
|
+
assignment: TaskAssignment,
|
|
86
|
+
credentialId: string,
|
|
87
|
+
revision: string,
|
|
88
|
+
) => Promise<Pick<RefreshResult, "outcome" | "version" | "expiresAt">>;
|
|
89
|
+
|
|
90
|
+
interface TaskMcpState {
|
|
91
|
+
assignment: TaskAssignment;
|
|
92
|
+
/** Null while the gate is closed. */
|
|
93
|
+
served: Map<string, ServedMcpConnection> | null;
|
|
94
|
+
/** Requests under way, each with the connection it began on: aborted when
|
|
95
|
+
* the gate closes, or once that connection is no longer served as it
|
|
96
|
+
* was, readable. */
|
|
97
|
+
requests: Map<AbortController, LeasedMcpConnection>;
|
|
98
|
+
drained: Array<() => void>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tasks = new Map<string, TaskMcpState>();
|
|
102
|
+
let refresher: McpCredentialRefresher | null = null;
|
|
103
|
+
|
|
104
|
+
function connectionOf(connection: TaskMcpConnection): ServedMcpConnection {
|
|
105
|
+
const { manifest, material } = connection;
|
|
106
|
+
if (manifest.authKind === "none") {
|
|
107
|
+
return {
|
|
108
|
+
credentialId: manifest.credentialId,
|
|
109
|
+
slug: manifest.slug,
|
|
110
|
+
url: manifest.url,
|
|
111
|
+
authKind: "none",
|
|
112
|
+
revision: null,
|
|
113
|
+
header: null,
|
|
114
|
+
expiresAt: null,
|
|
115
|
+
version: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (connection.availability !== "available" || !material) {
|
|
119
|
+
return {
|
|
120
|
+
slug: manifest.slug,
|
|
121
|
+
credentialId: manifest.credentialId,
|
|
122
|
+
url: manifest.url,
|
|
123
|
+
authKind: manifest.authKind,
|
|
124
|
+
revision: manifest.revision,
|
|
125
|
+
unavailable: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
credentialId: manifest.credentialId,
|
|
130
|
+
slug: manifest.slug,
|
|
131
|
+
url: manifest.url,
|
|
132
|
+
authKind: manifest.authKind,
|
|
133
|
+
revision: manifest.revision,
|
|
134
|
+
header:
|
|
135
|
+
material.authKind === "token"
|
|
136
|
+
? { name: material.header.name, value: material.header.value }
|
|
137
|
+
: { name: "Authorization", value: `Bearer ${material.accessToken}` },
|
|
138
|
+
expiresAt: material.authKind === "oauth" ? material.expiresAt : null,
|
|
139
|
+
version: connection.observedVersion,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function connectionsOf(view: TaskCredentialView): Map<string, ServedMcpConnection> {
|
|
144
|
+
return new Map(view.mcp.map((connection) => [connection.manifest.slug, connectionOf(connection)]));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isLeased(connection: ServedMcpConnection | undefined): connection is LeasedMcpConnection {
|
|
148
|
+
return connection !== undefined && !("unavailable" in connection);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The same connection, all but its material (or whether it could be read):
|
|
152
|
+
* a credential change. */
|
|
153
|
+
function sameConnection(a: ServedMcpConnection, b: ServedMcpConnection): boolean {
|
|
154
|
+
return a.credentialId === b.credentialId && a.revision === b.revision && a.url === b.url && a.authKind === b.authKind;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stateFor(assignment: TaskAssignment): TaskMcpState {
|
|
158
|
+
const current = tasks.get(assignment.taskId);
|
|
159
|
+
if (current && sameAssignment(current.assignment, assignment)) return current;
|
|
160
|
+
// Another run: whatever the last one had is closed and stopped first.
|
|
161
|
+
if (current) stopRequests(current);
|
|
162
|
+
const fresh: TaskMcpState = { assignment: { ...assignment }, served: null, requests: new Map(), drained: [] };
|
|
163
|
+
tasks.set(assignment.taskId, fresh);
|
|
164
|
+
return fresh;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A grant's install is a candidate: nothing of it is served until the
|
|
169
|
+
* registry commits it and opens its gate on it (`openMcpGate`), even with the
|
|
170
|
+
* gate open on the run's previous grant. A credential change of this run
|
|
171
|
+
* takes effect now, on a connection already served that is otherwise the
|
|
172
|
+
* same (credential, revision, URL, kind): a refreshed token, or its material
|
|
173
|
+
* becoming readable again, or no longer readable, which stops the requests
|
|
174
|
+
* under way on it.
|
|
175
|
+
*/
|
|
176
|
+
export function installMcpConnections(request: CredentialInstallRequest): void {
|
|
177
|
+
const current = tasks.get(request.assignment.taskId);
|
|
178
|
+
// An install for an older run, or a change for another run than the one
|
|
179
|
+
// served, changes nothing.
|
|
180
|
+
if (current && current.assignment.runEpoch > request.assignment.runEpoch) return;
|
|
181
|
+
if (request.reason === "credential-change" && current && !sameAssignment(current.assignment, request.assignment)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// On cloud credentials from its first install, gate closed until opened:
|
|
185
|
+
// never the runner's own store.
|
|
186
|
+
const state = stateFor(request.assignment);
|
|
187
|
+
if (request.reason !== "credential-change" || !state.served) return;
|
|
188
|
+
const next = connectionsOf(request.next);
|
|
189
|
+
for (const [slug, served] of state.served) {
|
|
190
|
+
const proposed = next.get(slug);
|
|
191
|
+
if (!proposed) {
|
|
192
|
+
// Gone from the view: never served past it, gate or not.
|
|
193
|
+
state.served.delete(slug);
|
|
194
|
+
} else if (sameConnection(served, proposed)) {
|
|
195
|
+
state.served.set(slug, proposed);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
stopUnserved(state);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The registry opened its gate on this committed view: serve exactly it,
|
|
202
|
+
* and stop what is under way on a connection it no longer serves as it
|
|
203
|
+
* was. An older run's opening changes nothing. */
|
|
204
|
+
export function openMcpGate(assignment: TaskAssignment, view: TaskCredentialView): void {
|
|
205
|
+
const current = tasks.get(assignment.taskId);
|
|
206
|
+
if (current && current.assignment.runEpoch > assignment.runEpoch) return;
|
|
207
|
+
const state = stateFor(assignment);
|
|
208
|
+
state.served = connectionsOf(view);
|
|
209
|
+
stopUnserved(state);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The registry closed its gate: serve nothing, and stop what is under way. */
|
|
213
|
+
export function closeMcpGate(assignment: TaskAssignment): void {
|
|
214
|
+
const state = tasks.get(assignment.taskId);
|
|
215
|
+
if (!state || !sameAssignment(state.assignment, assignment)) return;
|
|
216
|
+
state.served = null;
|
|
217
|
+
stopRequests(state);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stopRequests(state: TaskMcpState): void {
|
|
221
|
+
for (const controller of state.requests.keys()) controller.abort();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Stop every request under way whose connection the task no longer serves
|
|
225
|
+
* as it began, readable: gone, unreadable, or another credential, revision,
|
|
226
|
+
* URL or kind. A refreshed token of the same connection keeps it. */
|
|
227
|
+
function stopUnserved(state: TaskMcpState): void {
|
|
228
|
+
for (const [controller, began] of state.requests) {
|
|
229
|
+
const now = state.served?.get(began.slug);
|
|
230
|
+
if (!isLeased(now) || !sameConnection(now, began)) controller.abort();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The promise's outcome, or a rejection once `signal` aborts first. */
|
|
235
|
+
function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
236
|
+
if (signal.aborted) return Promise.reject(new Error("aborted"));
|
|
237
|
+
return new Promise<T>((resolve, reject) => {
|
|
238
|
+
const onAbort = (): void => reject(new Error("aborted"));
|
|
239
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
240
|
+
promise.then(
|
|
241
|
+
(value) => {
|
|
242
|
+
signal.removeEventListener("abort", onAbort);
|
|
243
|
+
resolve(value);
|
|
244
|
+
},
|
|
245
|
+
(error: unknown) => {
|
|
246
|
+
signal.removeEventListener("abort", onAbort);
|
|
247
|
+
reject(error);
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A cleanup: the gate closes, every request under way ends, and the task's
|
|
254
|
+
* record stays, closed (a full cleanup) until a new grant opens it. */
|
|
255
|
+
export async function cleanupMcpConnections(request: CredentialCleanupRequest): Promise<void> {
|
|
256
|
+
const state = tasks.get(request.assignment.taskId);
|
|
257
|
+
if (!state || !sameAssignment(state.assignment, request.assignment)) {
|
|
258
|
+
if (request.mode === "full") stateFor(request.assignment);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
state.served = null;
|
|
262
|
+
stopRequests(state);
|
|
263
|
+
if (state.requests.size > 0) await new Promise<void>((resolve) => state.drained.push(resolve));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The task is gone (teardown): forget its record entirely. */
|
|
267
|
+
export function forgetLeasedMcp(taskId: string): void {
|
|
268
|
+
const state = tasks.get(taskId);
|
|
269
|
+
if (state) stopRequests(state);
|
|
270
|
+
tasks.delete(taskId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The task's leased MCP connections, or undefined for a task that is not on
|
|
274
|
+
* cloud credentials. */
|
|
275
|
+
export function leasedMcpFor(taskId: string): TaskMcpLease | undefined {
|
|
276
|
+
const state = tasks.get(taskId);
|
|
277
|
+
if (!state) return undefined;
|
|
278
|
+
return { assignment: { ...state.assignment }, open: state.served !== null, connections: state.served ?? new Map() };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The (id, slug) pairs the task's gateway ACL and MCP configs list. */
|
|
282
|
+
export function leasedMcpAclEntries(taskId: string): Array<{ id: string; slug: string }> {
|
|
283
|
+
const served = tasks.get(taskId)?.served;
|
|
284
|
+
if (!served) return [];
|
|
285
|
+
// Membership follows the committed manifest, not whether its material was
|
|
286
|
+
// readable on this pass. A named-but-unreadable connection remains in agent
|
|
287
|
+
// config so the gateway can distinguish the temporary 503 from an absent
|
|
288
|
+
// connection's 404, and so a later material refresh needs no process
|
|
289
|
+
// restart just to make the tool visible again.
|
|
290
|
+
return [...served.values()].map((connection) => ({ id: connection.credentialId, slug: connection.slug }));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function setMcpCredentialRefresher(next: McpCredentialRefresher | null): void {
|
|
294
|
+
refresher = next;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* One gateway request's hold on its task: bound to the run and the
|
|
299
|
+
* connection it began with, aborted when the gate closes, counted until it
|
|
300
|
+
* ends (a cleanup waits for it).
|
|
301
|
+
*/
|
|
302
|
+
export interface McpRequestHold {
|
|
303
|
+
signal: AbortSignal;
|
|
304
|
+
/** The connection this request began with. */
|
|
305
|
+
connection: LeasedMcpConnection;
|
|
306
|
+
/** The same connection as now served, with whatever material it has now;
|
|
307
|
+
* null when it changed (another run, credential, revision or URL), the
|
|
308
|
+
* gate closed, or the request ended. */
|
|
309
|
+
current(): LeasedMcpConnection | null;
|
|
310
|
+
/** Ask for a newer token after `used` failed: the connection as served
|
|
311
|
+
* then, only when it carries a newer token than `used` that is not past
|
|
312
|
+
* its expiry; null otherwise (busy, unavailable, needs a new sign-in,
|
|
313
|
+
* expired, or the connection moved). */
|
|
314
|
+
refresh(used: LeasedMcpConnection): Promise<LeasedMcpConnection | null>;
|
|
315
|
+
end(): void;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Hold a served connection for one request; null when the gate is closed
|
|
319
|
+
* or the task serves no such leased connection now. */
|
|
320
|
+
export function holdLeasedMcp(taskId: string, slug: string): McpRequestHold | null {
|
|
321
|
+
const state = tasks.get(taskId);
|
|
322
|
+
const connection = state?.served?.get(slug);
|
|
323
|
+
if (!state || !isLeased(connection)) return null;
|
|
324
|
+
const controller = new AbortController();
|
|
325
|
+
state.requests.set(controller, connection);
|
|
326
|
+
const assignment = { ...state.assignment };
|
|
327
|
+
let ended = false;
|
|
328
|
+
const current = (): LeasedMcpConnection | null => {
|
|
329
|
+
const live = tasks.get(taskId);
|
|
330
|
+
if (ended || controller.signal.aborted || live !== state || !sameAssignment(live.assignment, assignment)) return null;
|
|
331
|
+
const now = live.served?.get(slug);
|
|
332
|
+
return isLeased(now) && sameConnection(now, connection) ? now : null;
|
|
333
|
+
};
|
|
334
|
+
return {
|
|
335
|
+
signal: controller.signal,
|
|
336
|
+
connection,
|
|
337
|
+
current,
|
|
338
|
+
async refresh(used) {
|
|
339
|
+
if (!refresher || used.authKind !== "oauth" || used.revision === null) return null;
|
|
340
|
+
let outcome: Awaited<ReturnType<McpCredentialRefresher>>["outcome"];
|
|
341
|
+
try {
|
|
342
|
+
// A gate that closes meanwhile ends the wait: the registry's refresh
|
|
343
|
+
// carries on for the next request, this one does not hold a cleanup.
|
|
344
|
+
({ outcome } = await untilAborted(refresher(assignment, used.credentialId, used.revision), controller.signal));
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
if (outcome !== "refreshed" && outcome !== "fresh") return null;
|
|
349
|
+
const now = current();
|
|
350
|
+
// Only a newer token is worth sending again, and never one already
|
|
351
|
+
// past its expiry.
|
|
352
|
+
if (!now || (now.version ?? 0) <= (used.version ?? 0)) return null;
|
|
353
|
+
if (now.authKind === "oauth" && now.expiresAt !== null && now.expiresAt <= Date.now()) return null;
|
|
354
|
+
return now;
|
|
355
|
+
},
|
|
356
|
+
end() {
|
|
357
|
+
if (ended) return;
|
|
358
|
+
ended = true;
|
|
359
|
+
state.requests.delete(controller);
|
|
360
|
+
if (state.requests.size === 0) for (const resolve of state.drained.splice(0)) resolve();
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Process shutdown: every request under way, for any task, ended. A closed
|
|
366
|
+
* gate has aborted them; each ends as its request does. */
|
|
367
|
+
export function drainLeasedMcpRequests(): Promise<void> {
|
|
368
|
+
return Promise.all(
|
|
369
|
+
[...tasks.values()].map((state) =>
|
|
370
|
+
state.requests.size === 0 ? undefined : new Promise<void>((resolve) => state.drained.push(resolve)),
|
|
371
|
+
),
|
|
372
|
+
).then(() => undefined);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Tests only. */
|
|
376
|
+
export function resetLeasedMcpForTests(): void {
|
|
377
|
+
for (const state of tasks.values()) stopRequests(state);
|
|
378
|
+
tasks.clear();
|
|
379
|
+
refresher = null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sameAssignment(a: TaskAssignment, b: TaskAssignment): boolean {
|
|
383
|
+
return a.taskId === b.taskId && a.runEpoch === b.runEpoch;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Tests only: requests under way for a task. */
|
|
387
|
+
export function leasedMcpRequestsForTests(taskId: string): number {
|
|
388
|
+
return tasks.get(taskId)?.requests.size ?? 0;
|
|
389
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §6: how a credential file gets into a task. Shared by the
|
|
3
|
+
* consumers that write one (GitHub and SSH, engine accounts).
|
|
4
|
+
*
|
|
5
|
+
* - Only by streaming the content over an exec's stdin, never staged on the
|
|
6
|
+
* runner's disk, in a clean environment (`env -i`, absolute paths, the
|
|
7
|
+
* sanitized overlay for anything a provider adds before it).
|
|
8
|
+
* - Under the task's home only, through the no-follow writer: each directory
|
|
9
|
+
* opened from its parent's descriptor, the content written to an
|
|
10
|
+
* intermediate created exclusively and renamed into place, so the file
|
|
11
|
+
* never lands anywhere the cleanup inventory does not name.
|
|
12
|
+
* - Each exec is admitted at the provider's last moment by the caller's
|
|
13
|
+
* `admit`, against the task's live state.
|
|
14
|
+
* - A failure is a fixed message: nothing a subprocess printed rides it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { CLEAN_CONTAINER_ENV, SANITIZED_CONTAINER_ENV } from "../github-tokens";
|
|
18
|
+
import { RUNTIME_AUTHORITY_ENV } from "../runtime-authority";
|
|
19
|
+
import type { TaskEnvironmentHandle } from "../task-environment/types";
|
|
20
|
+
|
|
21
|
+
export const TASK_HOME = "/home/node";
|
|
22
|
+
/** Where a file is written before it is renamed into place. */
|
|
23
|
+
export const STAGED_SUFFIX = ".uai-tmp";
|
|
24
|
+
|
|
25
|
+
const EXEC_TIMEOUT_MS = 30_000;
|
|
26
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
export type TaskWriteEnvironment = Pick<TaskEnvironmentHandle, "exec" | "descriptor">;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `argv` in the task with nothing inherited (`env -i`, absolute paths,
|
|
32
|
+
* the sanitized overlay for anything a provider adds before it), admitted
|
|
33
|
+
* at the provider's last moment by `admit`. A failure is a fixed message:
|
|
34
|
+
* nothing a subprocess printed rides the error.
|
|
35
|
+
*/
|
|
36
|
+
export async function runInTask(
|
|
37
|
+
environment: TaskWriteEnvironment,
|
|
38
|
+
argv: readonly [string, ...string[]],
|
|
39
|
+
stdin: string | null,
|
|
40
|
+
what: string,
|
|
41
|
+
admit: () => void,
|
|
42
|
+
user: "node" | "root" = "node",
|
|
43
|
+
): Promise<Uint8Array> {
|
|
44
|
+
const [first, ...rest] = [...CLEAN_CONTAINER_ENV, ...argv];
|
|
45
|
+
const result = await environment.exec({
|
|
46
|
+
argv: [first!, ...rest],
|
|
47
|
+
user,
|
|
48
|
+
env: { ...SANITIZED_CONTAINER_ENV, ...RUNTIME_AUTHORITY_ENV },
|
|
49
|
+
projectEnvProfile: "maintenance",
|
|
50
|
+
...(stdin !== null ? { stdin: Buffer.from(stdin, "utf8") } : {}),
|
|
51
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
52
|
+
maxOutputBytes: MAX_OUTPUT_BYTES,
|
|
53
|
+
admit,
|
|
54
|
+
});
|
|
55
|
+
if (result.exitCode !== 0 || result.stdoutTruncated) {
|
|
56
|
+
throw new Error(`${what} failed in the task (exit ${result.exitCode ?? result.signal ?? "unknown"})`);
|
|
57
|
+
}
|
|
58
|
+
return result.stdout;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Write stdin to one file under the task's home without following anything
|
|
63
|
+
* the task could have put in the way: each directory is opened from its
|
|
64
|
+
* parent's descriptor with O_NOFOLLOW (created owner-only when missing), the
|
|
65
|
+
* content goes to `<name>.uai-tmp` created exclusively, and a rename relative
|
|
66
|
+
* to that same directory puts it in place. The rename replaces whatever entry
|
|
67
|
+
* is there, a symlink included, and fails on a directory: the file never
|
|
68
|
+
* lands anywhere the inventory does not name.
|
|
69
|
+
*/
|
|
70
|
+
export const TASK_FILE_WRITE_HELPER = String.raw`
|
|
71
|
+
import os
|
|
72
|
+
import sys
|
|
73
|
+
|
|
74
|
+
root, relative, mode = sys.argv[1], sys.argv[2], int(sys.argv[3], 8)
|
|
75
|
+
parts = relative.split("/")
|
|
76
|
+
if any(part in ("", ".", "..") for part in parts):
|
|
77
|
+
raise SystemExit(2)
|
|
78
|
+
data = sys.stdin.buffer.read()
|
|
79
|
+
directory = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
80
|
+
fd = os.open(root, directory)
|
|
81
|
+
try:
|
|
82
|
+
for part in parts[:-1]:
|
|
83
|
+
try:
|
|
84
|
+
os.mkdir(part, 0o700, dir_fd=fd)
|
|
85
|
+
except FileExistsError:
|
|
86
|
+
pass
|
|
87
|
+
child = os.open(part, directory, dir_fd=fd)
|
|
88
|
+
os.close(fd)
|
|
89
|
+
fd = child
|
|
90
|
+
name = parts[-1]
|
|
91
|
+
staged = name + ".uai-tmp"
|
|
92
|
+
try:
|
|
93
|
+
os.unlink(staged, dir_fd=fd)
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
pass
|
|
96
|
+
out = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=fd)
|
|
97
|
+
try:
|
|
98
|
+
os.fchmod(out, mode)
|
|
99
|
+
view = memoryview(data)
|
|
100
|
+
while view:
|
|
101
|
+
view = view[os.write(out, view):]
|
|
102
|
+
os.fsync(out)
|
|
103
|
+
finally:
|
|
104
|
+
os.close(out)
|
|
105
|
+
os.rename(staged, name, src_dir_fd=fd, dst_dir_fd=fd)
|
|
106
|
+
finally:
|
|
107
|
+
os.close(fd)
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
/** The exec that writes `path` from stdin (`TASK_FILE_WRITE_HELPER`). */
|
|
111
|
+
export function writeTaskFileArgv(path: string, mode: "600" | "644"): [string, ...string[]] {
|
|
112
|
+
if (!path.startsWith(`${TASK_HOME}/`)) throw new Error("a task credential path is outside the task home");
|
|
113
|
+
return ["/usr/bin/python3", "-I", "-c", TASK_FILE_WRITE_HELPER, TASK_HOME, path.slice(TASK_HOME.length + 1), mode];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A credential is written only into the runtime of the task it belongs to:
|
|
117
|
+
* the cleanup that must delete it refuses any other task's runtime. */
|
|
118
|
+
export function assertTaskRuntime(environment: TaskWriteEnvironment, taskId: string): void {
|
|
119
|
+
if (environment.descriptor.taskId !== taskId) {
|
|
120
|
+
throw new Error("a task credential would be written into another task's runtime");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A path and the intermediate it is written through. */
|
|
125
|
+
export function withStaged(...paths: string[]): string[] {
|
|
126
|
+
return paths.flatMap((path) => [path, `${path}${STAGED_SUFFIX}`]);
|
|
127
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TaskAssignment, TaskCredentialView } from "../cloud-credential-leases";
|
|
2
|
+
const served = new Map<string, TaskCredentialView>();
|
|
3
|
+
export function openTaskSecrets(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void { if (view.assignment.taskId !== assignment.taskId || view.assignment.runEpoch !== assignment.runEpoch) throw new Error("task secret assignment mismatch"); served.set(assignment.taskId, view); }
|
|
4
|
+
export function closeTaskSecrets(assignment: Readonly<TaskAssignment>): void { const view = served.get(assignment.taskId); if (view && view.assignment.runEpoch <= assignment.runEpoch) served.delete(assignment.taskId); }
|
|
5
|
+
export function taskSecretsView(taskId: string, runEpoch?: number | null) { const view = served.get(taskId); return view && (runEpoch == null || view.assignment.runEpoch === runEpoch) ? view.taskSecrets ?? [] : []; }
|
|
6
|
+
export function deliveredTaskSecrets(taskId: string, runEpoch?: number | null): { revision: string; withheld: string[]; values: Record<string, string> } {
|
|
7
|
+
const entries = taskSecretsView(taskId, runEpoch); const values: Record<string,string> = Object.create(null) as Record<string,string>;
|
|
8
|
+
for (const e of entries) if (e.manifest.mode === "delivered" && e.material) values[e.manifest.name] = e.material.value;
|
|
9
|
+
return { revision: JSON.stringify(entries.map(e => [e.manifest.name, e.manifest.generation, e.material !== null])), withheld: entries.map(e => e.manifest.name), values };
|
|
10
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uai-operated hosts: pick up credentials the broker Lambda refreshed
|
|
3
|
+
* (ADR-135). Every 15 minutes list the engine parameters under this host's
|
|
4
|
+
* scopes — names and versions only, no decryption — and re-import whatever
|
|
5
|
+
* moved, then re-push brokered copies of that kind into running tasks. The
|
|
6
|
+
* reactive path calls `pickupNow` right after a forced refresh.
|
|
7
|
+
*
|
|
8
|
+
* Never refreshes anything: on a Uai-operated host the Lambda is the one
|
|
9
|
+
* refresher. Owned hosts never run this (no store).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { onOwnCredentials } from "./cloud-credential-host";
|
|
13
|
+
import { configuredCredentialStore, credentialStorePrefix } from "./credential-store";
|
|
14
|
+
import { engineAccountListPaths } from "./credential-store-scope";
|
|
15
|
+
import {
|
|
16
|
+
applyStoredEngineAccount,
|
|
17
|
+
engineAccountHostId,
|
|
18
|
+
parseEngineAccountParameterName,
|
|
19
|
+
} from "./engine-accounts";
|
|
20
|
+
import { setPickupNow } from "./token-broker-local";
|
|
21
|
+
|
|
22
|
+
const PICKUP_INTERVAL_MS = 15 * 60_000;
|
|
23
|
+
|
|
24
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
25
|
+
let known: Map<string, number> | null = null;
|
|
26
|
+
let inflight: Promise<PickupSummary> | null = null;
|
|
27
|
+
let repushHook: ((kind: string) => Promise<void>) | null = null;
|
|
28
|
+
|
|
29
|
+
export interface PickupSummary {
|
|
30
|
+
skipped: boolean;
|
|
31
|
+
listed: number;
|
|
32
|
+
changed: number;
|
|
33
|
+
applied: number;
|
|
34
|
+
errors: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function log(line: string): void {
|
|
38
|
+
console.log(`[credential-pickup] ${line}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One pass. The first pass records the baseline (the start-up reconcile
|
|
43
|
+
* already imported current values); later passes apply changed versions.
|
|
44
|
+
*/
|
|
45
|
+
export async function pickupChangedCredentials(): Promise<PickupSummary> {
|
|
46
|
+
const store = configuredCredentialStore();
|
|
47
|
+
const summary: PickupSummary = { skipped: false, listed: 0, changed: 0, applied: 0, errors: 0 };
|
|
48
|
+
if (!store) return { ...summary, skipped: true };
|
|
49
|
+
const prefix = credentialStorePrefix();
|
|
50
|
+
const paths = engineAccountListPaths(prefix, engineAccountHostId());
|
|
51
|
+
if (paths === null) return { ...summary, skipped: true };
|
|
52
|
+
const current = new Map<string, number>();
|
|
53
|
+
for (const path of paths) {
|
|
54
|
+
try {
|
|
55
|
+
for (const entry of await store.listVersions(path)) {
|
|
56
|
+
if (parseEngineAccountParameterName(entry.name, prefix)) current.set(entry.name, entry.version);
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
summary.errors++;
|
|
60
|
+
log(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
61
|
+
// An incomplete listing must not look like deletions; keep the baseline.
|
|
62
|
+
return summary;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
summary.listed = current.size;
|
|
66
|
+
if (known === null) {
|
|
67
|
+
known = current;
|
|
68
|
+
return summary;
|
|
69
|
+
}
|
|
70
|
+
const kinds = new Set<string>();
|
|
71
|
+
for (const [name, version] of current) {
|
|
72
|
+
if (known.get(name) === version) continue;
|
|
73
|
+
summary.changed++;
|
|
74
|
+
try {
|
|
75
|
+
const value = await store.get(name);
|
|
76
|
+
if (value === null) continue;
|
|
77
|
+
const applied = applyStoredEngineAccount(name, value);
|
|
78
|
+
if (applied) {
|
|
79
|
+
summary.applied++;
|
|
80
|
+
kinds.add(applied.kind);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
summary.errors++;
|
|
84
|
+
log(`applying ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
known.set(name, version);
|
|
88
|
+
}
|
|
89
|
+
for (const name of [...known.keys()]) if (!current.has(name)) known.delete(name);
|
|
90
|
+
for (const kind of kinds) {
|
|
91
|
+
try {
|
|
92
|
+
await repushHook?.(kind);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log(`re-push of ${kind} copies failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (summary.changed > 0) {
|
|
98
|
+
log(`${summary.changed} parameter(s) changed, ${summary.applied} applied, ${summary.errors} errors`);
|
|
99
|
+
}
|
|
100
|
+
return summary;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function pickupSerialized(): Promise<PickupSummary> {
|
|
104
|
+
if (inflight) return inflight;
|
|
105
|
+
// ADR-154: no copy picked up once the runner leaves its own credentials.
|
|
106
|
+
if (!onOwnCredentials()) return { skipped: true, listed: 0, changed: 0, applied: 0, errors: 0 };
|
|
107
|
+
inflight = pickupChangedCredentials().finally(() => {
|
|
108
|
+
inflight = null;
|
|
109
|
+
});
|
|
110
|
+
return inflight;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Uai-operated hosts on their own credentials only: baseline now, then
|
|
114
|
+
* poll every 15 minutes. */
|
|
115
|
+
export function startCredentialStorePickup(hooks: { repush: (kind: string) => Promise<void> }): void {
|
|
116
|
+
if (timer || !configuredCredentialStore() || !onOwnCredentials()) return;
|
|
117
|
+
repushHook = hooks.repush;
|
|
118
|
+
setPickupNow(async () => {
|
|
119
|
+
await pickupSerialized();
|
|
120
|
+
});
|
|
121
|
+
void pickupSerialized().catch((err) =>
|
|
122
|
+
log(`baseline failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
123
|
+
);
|
|
124
|
+
timer = setInterval(() => {
|
|
125
|
+
void pickupSerialized().catch((err) =>
|
|
126
|
+
log(`poll failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
127
|
+
);
|
|
128
|
+
}, PICKUP_INTERVAL_MS);
|
|
129
|
+
timer.unref?.();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Stop polling (ADR-154: the runner leaves its own credentials); resolves
|
|
133
|
+
* once a pickup under way has ended. */
|
|
134
|
+
export async function stopCredentialStorePickup(): Promise<void> {
|
|
135
|
+
if (timer) clearInterval(timer);
|
|
136
|
+
timer = null;
|
|
137
|
+
setPickupNow(null);
|
|
138
|
+
await inflight?.catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Tests. */
|
|
142
|
+
export function resetCredentialStorePickup(): void {
|
|
143
|
+
if (timer) clearInterval(timer);
|
|
144
|
+
timer = null;
|
|
145
|
+
known = null;
|
|
146
|
+
inflight = null;
|
|
147
|
+
repushHook = null;
|
|
148
|
+
setPickupNow(null);
|
|
149
|
+
}
|