@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,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local storage adapter + the owned-host refresh loop (ADR-135).
|
|
3
|
+
*
|
|
4
|
+
* On a host the operator owns, the credentials live on disk: the default
|
|
5
|
+
* slots (`~/.codex`, `~/.grok`) and every config-dir engine account
|
|
6
|
+
* (`~/.<kind>-acct-<id>`). This adapter lists them for the broker core,
|
|
7
|
+
* writes refreshed material back atomically, and keeps the broker's meta in
|
|
8
|
+
* a sidecar file (`uai-broker.json`) that never travels to a task and never
|
|
9
|
+
* mirrors to the store.
|
|
10
|
+
*
|
|
11
|
+
* The loop runs ONLY on owned hosts (`configuredCredentialStore() === null`).
|
|
12
|
+
* A Uai-operated host must not refresh — the broker Lambda does — it picks
|
|
13
|
+
* up refreshed parameters instead (credential-store-pickup.ts).
|
|
14
|
+
*
|
|
15
|
+
* ADR-154 §7: and only while the runner is on its own credentials. No runner
|
|
16
|
+
* refreshes once it leaves them: the loop stops, a cycle under way ends, and
|
|
17
|
+
* no reactive request refreshes or invokes the broker Lambda (a leased
|
|
18
|
+
* account's refresh goes through the cloud).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdtempSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
renameSync,
|
|
27
|
+
rmSync,
|
|
28
|
+
statSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { homedir, tmpdir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
34
|
+
|
|
35
|
+
import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
36
|
+
import { configuredCredentialStore, credentialStorePrefix } from "./credential-store";
|
|
37
|
+
import { getDb, schema } from "./db";
|
|
38
|
+
import {
|
|
39
|
+
BROKER_SIDECAR_FILE,
|
|
40
|
+
engineAccountParameterName,
|
|
41
|
+
mirrorEngineAccount,
|
|
42
|
+
} from "./engine-accounts";
|
|
43
|
+
import { runBrokerCycle, type BrokerCredential, type BrokerStorage } from "./token-broker";
|
|
44
|
+
import { lanesForKind } from "./token-lanes/registry";
|
|
45
|
+
import { refreshMetaFrom, type CredentialFiles, type RefreshMeta } from "./token-lanes/types";
|
|
46
|
+
import { env } from "./env";
|
|
47
|
+
|
|
48
|
+
/** Hourly: Grok tokens live six hours (observed 2026-09-09); the lanes'
|
|
49
|
+
* leads decide what is actually refreshed, so most runs do nothing. */
|
|
50
|
+
const CYCLE_INTERVAL_MS = 60 * 60 * 1000;
|
|
51
|
+
/** Kinds whose default slot lives in a home dir the broker may rewrite. */
|
|
52
|
+
const DEFAULT_SLOTS: Record<string, (home: string) => string> = {
|
|
53
|
+
codex: (home) => join(home, ".codex"),
|
|
54
|
+
grok: (home) => join(home, ".grok"),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export interface LocalBrokerSeams {
|
|
58
|
+
ownerHome?: () => string;
|
|
59
|
+
/** Rows to consider (tests inject); defaults to every stored account. */
|
|
60
|
+
accounts?: () => Array<{ id: string; kind: string; scope: string; configDir: string | null; authKind: string }>;
|
|
61
|
+
now?: () => number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ownerHome(seams: LocalBrokerSeams): string {
|
|
65
|
+
return (seams.ownerHome ?? (() => process.env.UAI_OWNER_HOME?.trim() || homedir()))();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readDirFiles(dir: string): CredentialFiles | null {
|
|
69
|
+
let names: string[];
|
|
70
|
+
try {
|
|
71
|
+
names = readdirSync(dir);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const files: CredentialFiles = {};
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
if (name === BROKER_SIDECAR_FILE || name !== "auth.json") continue;
|
|
78
|
+
try {
|
|
79
|
+
const path = join(dir, name);
|
|
80
|
+
if (!statSync(path).isFile()) continue;
|
|
81
|
+
files[name] = readFileSync(path, "utf8");
|
|
82
|
+
} catch {
|
|
83
|
+
/* unreadable file: the lane will not match */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readSidecar(dir: string): RefreshMeta | null {
|
|
90
|
+
try {
|
|
91
|
+
return refreshMetaFrom(JSON.parse(readFileSync(join(dir, BROKER_SIDECAR_FILE), "utf8")));
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** temp + rename in the same dir, 0600: a crash never leaves a torn file. */
|
|
98
|
+
export function writeFileAtomic(path: string, content: string): void {
|
|
99
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
100
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
101
|
+
renameSync(tmp, path);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A local credential name the reactive path and tests can address. */
|
|
105
|
+
export function localCredentialName(kind: string, accountId: string | null): string {
|
|
106
|
+
return accountId ? `account:${kind}:${accountId}` : `default:${kind}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class LocalBrokerStorage implements BrokerStorage {
|
|
110
|
+
private readonly dirs = new Map<string, { dir: string; accountId: string | null; kind: string }>();
|
|
111
|
+
|
|
112
|
+
constructor(private readonly seams: LocalBrokerSeams = {}) {}
|
|
113
|
+
|
|
114
|
+
async list(): Promise<BrokerCredential[]> {
|
|
115
|
+
const out: BrokerCredential[] = [];
|
|
116
|
+
const home = ownerHome(this.seams);
|
|
117
|
+
for (const [kind, dirOf] of Object.entries(DEFAULT_SLOTS)) {
|
|
118
|
+
const dir = dirOf(home);
|
|
119
|
+
if (!existsSync(join(dir, "auth.json"))) continue;
|
|
120
|
+
const files = readDirFiles(dir);
|
|
121
|
+
if (!files) continue;
|
|
122
|
+
const name = localCredentialName(kind, null);
|
|
123
|
+
this.dirs.set(name, { dir, accountId: null, kind });
|
|
124
|
+
out.push({
|
|
125
|
+
name,
|
|
126
|
+
header: { kind, authKind: "config-dir", id: "default", scope: "host" },
|
|
127
|
+
files,
|
|
128
|
+
refresh: readSidecar(dir),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const rows =
|
|
132
|
+
this.seams.accounts?.() ??
|
|
133
|
+
getDb()
|
|
134
|
+
.select()
|
|
135
|
+
.from(schema.engineAccounts)
|
|
136
|
+
.all()
|
|
137
|
+
.map((r) => ({
|
|
138
|
+
id: r.id,
|
|
139
|
+
kind: r.kind,
|
|
140
|
+
scope: r.scope ?? "host",
|
|
141
|
+
configDir: r.configDir,
|
|
142
|
+
authKind: r.authKind,
|
|
143
|
+
}));
|
|
144
|
+
for (const row of rows) {
|
|
145
|
+
if (row.authKind !== "config-dir" || !row.configDir) continue;
|
|
146
|
+
const files = readDirFiles(row.configDir);
|
|
147
|
+
if (!files) continue;
|
|
148
|
+
const name = localCredentialName(row.kind, row.id);
|
|
149
|
+
this.dirs.set(name, { dir: row.configDir, accountId: row.id, kind: row.kind });
|
|
150
|
+
out.push({
|
|
151
|
+
name,
|
|
152
|
+
header: { kind: row.kind, authKind: "config-dir", id: row.id, scope: row.scope },
|
|
153
|
+
files,
|
|
154
|
+
refresh: readSidecar(row.configDir),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async save(credential: BrokerCredential, files: CredentialFiles | null, refresh: RefreshMeta): Promise<void> {
|
|
161
|
+
const target = this.dirs.get(credential.name);
|
|
162
|
+
if (!target) throw new Error(`unknown local credential ${credential.name}`);
|
|
163
|
+
if (files) {
|
|
164
|
+
for (const [name, content] of Object.entries(files)) {
|
|
165
|
+
if (name.includes("/") || name === BROKER_SIDECAR_FILE) continue;
|
|
166
|
+
writeFileAtomic(join(target.dir, name), content);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
writeFileAtomic(join(target.dir, BROKER_SIDECAR_FILE), `${JSON.stringify(refresh, null, 2)}\n`);
|
|
170
|
+
// Mirrors are no-ops without a store; on a Uai-operated host this
|
|
171
|
+
// adapter does not run at all, so this only matters in tests.
|
|
172
|
+
if (target.accountId && files) mirrorEngineAccount(target.accountId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
kindsRefreshed(names: Iterable<string>): Set<string> {
|
|
176
|
+
const kinds = new Set<string>();
|
|
177
|
+
for (const name of names) {
|
|
178
|
+
const t = this.dirs.get(name);
|
|
179
|
+
if (t) kinds.add(t.kind);
|
|
180
|
+
}
|
|
181
|
+
return kinds;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Loop + reactive entry points.
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
export interface TokenBrokerHooks {
|
|
190
|
+
/** Re-push brokered copies of `kind` into running tasks. */
|
|
191
|
+
repush: (kind: string) => Promise<void>;
|
|
192
|
+
log?: (line: string) => void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let hooks: TokenBrokerHooks | null = null;
|
|
196
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
197
|
+
let running: Promise<void> | null = null;
|
|
198
|
+
const reactiveLastAt = new Map<string, number>();
|
|
199
|
+
const REACTIVE_MIN_INTERVAL_MS = 10 * 60_000;
|
|
200
|
+
|
|
201
|
+
export function configureTokenBroker(next: TokenBrokerHooks): void {
|
|
202
|
+
hooks = next;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Whether this host is the refresher for its own credentials. */
|
|
206
|
+
export function ownedHostRefreshes(): boolean {
|
|
207
|
+
return configuredCredentialStore() === null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function log(line: string): void {
|
|
211
|
+
(hooks?.log ?? ((l: string) => console.log(`[token-broker] ${l}`)))(line);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One local cycle: refresh what is due (or forced) and re-push the kinds
|
|
216
|
+
* that changed into running tasks. Serialized — a reactive request during a
|
|
217
|
+
* scheduled run waits for it.
|
|
218
|
+
*/
|
|
219
|
+
export async function runLocalBrokerCycle(opts: { force?: ReadonlySet<string>; seams?: LocalBrokerSeams } = {}): Promise<void> {
|
|
220
|
+
const work = async (): Promise<void> => {
|
|
221
|
+
// A cycle queued behind the one leaving waits for refreshes nothing.
|
|
222
|
+
if (!onOwnCredentials()) return;
|
|
223
|
+
const storage = new LocalBrokerStorage(opts.seams);
|
|
224
|
+
const refreshedNames = new Set<string>();
|
|
225
|
+
const summary = await runBrokerCycle({
|
|
226
|
+
storage,
|
|
227
|
+
force: opts.force,
|
|
228
|
+
now: opts.seams?.now,
|
|
229
|
+
log: (line) => {
|
|
230
|
+
if (line.startsWith("refreshed ")) refreshedNames.add(line.split(" ")[1]!);
|
|
231
|
+
log(line);
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
if (summary.checked > 0) {
|
|
235
|
+
log(`local cycle: ${summary.checked} checked, ${summary.due} due, ${summary.refreshed} refreshed, ${summary.failed} failed, ${summary.terminal} need sign-in`);
|
|
236
|
+
}
|
|
237
|
+
for (const kind of storage.kindsRefreshed(refreshedNames)) {
|
|
238
|
+
try {
|
|
239
|
+
await hooks?.repush(kind);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
log(`re-push of ${kind} copies failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const previous = running ?? Promise.resolve();
|
|
246
|
+
const current = previous.then(work, work);
|
|
247
|
+
running = current;
|
|
248
|
+
try {
|
|
249
|
+
await current;
|
|
250
|
+
} finally {
|
|
251
|
+
if (running === current) running = null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Owned hosts on their own credentials only: refresh now, then hourly.
|
|
256
|
+
* Idempotent. */
|
|
257
|
+
export function startTokenBrokerLoop(): void {
|
|
258
|
+
if (timer || !ownedHostRefreshes() || !onOwnCredentials()) return;
|
|
259
|
+
void runLocalBrokerCycle().catch((err) => log(`cycle failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
260
|
+
timer = setInterval(() => {
|
|
261
|
+
void runLocalBrokerCycle().catch((err) => log(`cycle failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
262
|
+
}, CYCLE_INTERVAL_MS);
|
|
263
|
+
timer.unref?.();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Stop the loop at once (ADR-154 §7: the runner leaves its own
|
|
267
|
+
* credentials); resolves once a cycle under way has ended. */
|
|
268
|
+
export async function stopTokenBrokerLoop(): Promise<void> {
|
|
269
|
+
if (timer) clearInterval(timer);
|
|
270
|
+
timer = null;
|
|
271
|
+
while (running !== null) await running.catch(() => undefined);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Whether an engine's error output means its access token is dead now. */
|
|
275
|
+
export function isCredentialInvalidSignal(kind: string, message: string): boolean {
|
|
276
|
+
return lanesForKind(kind).some((lane) => lane.reactiveSignals.some((re) => re.test(message)));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Reactive path: a task reported a dead token for `kind` (account `accountId`,
|
|
281
|
+
* or the default slot). Owned host: one forced local refresh + re-push.
|
|
282
|
+
* Uai-operated host: invoke the broker Lambda for that parameter, then pick
|
|
283
|
+
* up the new version. Rate-limited per credential; returns whether a refresh
|
|
284
|
+
* was requested.
|
|
285
|
+
*/
|
|
286
|
+
export async function requestCredentialRefresh(input: {
|
|
287
|
+
kind: string;
|
|
288
|
+
accountId: string | null;
|
|
289
|
+
scope: string;
|
|
290
|
+
}): Promise<boolean> {
|
|
291
|
+
// ADR-154 §7: off its own credentials the runner refreshes nothing and
|
|
292
|
+
// invokes no broker; the cloud refreshes, through the task's lease. A
|
|
293
|
+
// request on them is counted whole (its cycle or its Lambda invocation,
|
|
294
|
+
// and the pickup after): leaving them waits for it.
|
|
295
|
+
return (await whileOnOwnCredentials(() => refreshOnOwnCredentials(input))) ?? false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function refreshOnOwnCredentials(input: {
|
|
299
|
+
kind: string;
|
|
300
|
+
accountId: string | null;
|
|
301
|
+
scope: string;
|
|
302
|
+
}): Promise<boolean> {
|
|
303
|
+
const key = `${input.kind}:${input.accountId ?? "default"}`;
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
const last = reactiveLastAt.get(key) ?? 0;
|
|
306
|
+
if (now - last < REACTIVE_MIN_INTERVAL_MS) return false;
|
|
307
|
+
reactiveLastAt.set(key, now);
|
|
308
|
+
if (ownedHostRefreshes()) {
|
|
309
|
+
await runLocalBrokerCycle({ force: new Set([localCredentialName(input.kind, input.accountId)]) });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
if (!input.accountId) return false;
|
|
313
|
+
const name = engineAccountParameterName({ id: input.accountId, kind: input.kind, scope: input.scope });
|
|
314
|
+
if (!name) return false;
|
|
315
|
+
const invoked = await invokeBrokerLambda([name]);
|
|
316
|
+
if (invoked) {
|
|
317
|
+
try {
|
|
318
|
+
await pickupNow?.();
|
|
319
|
+
} catch (err) {
|
|
320
|
+
log(`pickup after reactive refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return invoked;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let pickupNow: (() => Promise<void>) | null = null;
|
|
327
|
+
/** credential-store-pickup.ts registers its immediate poll here. */
|
|
328
|
+
export function setPickupNow(fn: (() => Promise<void>) | null): void {
|
|
329
|
+
pickupNow = fn;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** `aws lambda invoke` on the broker function named by UAI_TOKEN_BROKER_FUNCTION. */
|
|
333
|
+
async function invokeBrokerLambda(names: string[]): Promise<boolean> {
|
|
334
|
+
const fn = env.UAI_TOKEN_BROKER_FUNCTION;
|
|
335
|
+
const region = env.UAI_AWS_REGION;
|
|
336
|
+
if (!fn || !region) {
|
|
337
|
+
log("reactive refresh requested but UAI_TOKEN_BROKER_FUNCTION/UAI_AWS_REGION are not set");
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
const prefix = credentialStorePrefix();
|
|
341
|
+
const safe = names.filter((n) => n.startsWith(`${prefix}/`));
|
|
342
|
+
if (safe.length === 0) return false;
|
|
343
|
+
const outDir = mkdtempSync(join(tmpdir(), "uai-broker-invoke-"));
|
|
344
|
+
const outFile = join(outDir, "out.json");
|
|
345
|
+
try {
|
|
346
|
+
const result = await new Promise<{ status: number | null; stderr: string }>((resolve) => {
|
|
347
|
+
const child = spawn(
|
|
348
|
+
"aws",
|
|
349
|
+
[
|
|
350
|
+
"--region",
|
|
351
|
+
region,
|
|
352
|
+
"--output",
|
|
353
|
+
"json",
|
|
354
|
+
"lambda",
|
|
355
|
+
"invoke",
|
|
356
|
+
"--function-name",
|
|
357
|
+
fn,
|
|
358
|
+
"--cli-binary-format",
|
|
359
|
+
"raw-in-base64-out",
|
|
360
|
+
"--payload",
|
|
361
|
+
JSON.stringify({ names: safe }),
|
|
362
|
+
outFile,
|
|
363
|
+
],
|
|
364
|
+
{ stdio: ["ignore", "ignore", "pipe"] },
|
|
365
|
+
);
|
|
366
|
+
let stderr = "";
|
|
367
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 150_000);
|
|
368
|
+
timer.unref?.();
|
|
369
|
+
child.stderr?.setEncoding("utf8");
|
|
370
|
+
child.stderr?.on("data", (c: string) => (stderr += c));
|
|
371
|
+
child.once("error", () => resolve({ status: null, stderr }));
|
|
372
|
+
child.once("close", (code) => {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
resolve({ status: code, stderr });
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
if (result.status !== 0) {
|
|
378
|
+
log(`lambda invoke failed (exit ${result.status ?? "none"}): ${result.stderr.split("\n")[0] ?? ""}`);
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
let summary = "";
|
|
382
|
+
try {
|
|
383
|
+
summary = readFileSync(outFile, "utf8").trim().slice(0, 300);
|
|
384
|
+
} catch {
|
|
385
|
+
/* no body */
|
|
386
|
+
}
|
|
387
|
+
log(`lambda refreshed on request: ${summary}`);
|
|
388
|
+
return true;
|
|
389
|
+
} finally {
|
|
390
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §7: the broker's exact-name entry point.
|
|
3
|
+
*
|
|
4
|
+
* The cloud is the only scheduler: it knows which revisions are active, so it
|
|
5
|
+
* names one revision — its broker-only part under `/uai-broker` and its
|
|
6
|
+
* lease-readable part under `/uai/lease` — and the broker refreshes exactly
|
|
7
|
+
* that one. It never lists `/uai-broker`, so a retired revision is never
|
|
8
|
+
* refreshed.
|
|
9
|
+
*
|
|
10
|
+
* Every invoke, holding the credential's lock:
|
|
11
|
+
* 1. makes the lease-readable copy agree with the broker-only material,
|
|
12
|
+
* rewriting it if a crash left it behind;
|
|
13
|
+
* 2. refreshes when due (or when the cloud forces it, for a runner's
|
|
14
|
+
* credential-invalid report), writing the broker-only part first and the
|
|
15
|
+
* lease-readable copy second, so a crash between them keeps the new
|
|
16
|
+
* refresh token and leaves the previous access token valid;
|
|
17
|
+
* 3. answers for that one credential, never with material: `refreshed` or
|
|
18
|
+
* `fresh` with the lease-readable parameter's version and the expiry,
|
|
19
|
+
* `needs_sign_in`, `busy`, or `unavailable`.
|
|
20
|
+
*
|
|
21
|
+
* An adopted revision (`legacyParameter`, owner answer 3) also keeps today's
|
|
22
|
+
* parameter it came from agreeing with it, the brokered copy only, until the
|
|
23
|
+
* fleet audit (token-broker-adopt.ts).
|
|
24
|
+
*
|
|
25
|
+
* Storage is an interface; the Lambda binds it to SSM (token-broker-ssm.ts).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
brokerErrorKind,
|
|
30
|
+
CREDENTIAL_LANE_SCHEDULES,
|
|
31
|
+
parseBrokerParameterName,
|
|
32
|
+
parseLeasedValue,
|
|
33
|
+
serializeLeasedValue,
|
|
34
|
+
type BrokerRevisionAnswer,
|
|
35
|
+
type BrokerRevisionRequest,
|
|
36
|
+
type CredentialLocation,
|
|
37
|
+
} from "../src/cloud-credentials";
|
|
38
|
+
import { parseBrokerEngineValue, type BrokerEngineValue } from "./capture-flow";
|
|
39
|
+
import { syncLegacyCopy, type LegacySync } from "./token-broker-adopt";
|
|
40
|
+
import { MCP_OAUTH_LANE, parseBrokerMcpValue, postTokenEndpoint, type BrokerMcpValue, oauthFailure } from "./mcp-capture";
|
|
41
|
+
import type { SafeHttpDeps } from "./safe-http";
|
|
42
|
+
import { backoffMs, createLaneHttp, isRefreshDue } from "./token-broker";
|
|
43
|
+
import { laneById } from "./token-lanes/registry";
|
|
44
|
+
import { isTerminalOAuthError, type CredentialFiles, type LaneHttp, type TokenLane } from "./token-lanes/types";
|
|
45
|
+
|
|
46
|
+
export interface StoredParameter {
|
|
47
|
+
value: string;
|
|
48
|
+
version: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The storage one invoke needs: read and overwrite two named parameters,
|
|
52
|
+
* and a lock per credential. */
|
|
53
|
+
export interface RevisionStore {
|
|
54
|
+
get(name: string): Promise<StoredParameter | null>;
|
|
55
|
+
/** Overwrite a parameter this invoke has just read; the new version. */
|
|
56
|
+
overwrite(name: string, value: string): Promise<number>;
|
|
57
|
+
/** The credential's lock, or null while another invoke holds a fresh one. */
|
|
58
|
+
lock(credentialId: string): Promise<(() => Promise<void>) | null>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const MAX_ERROR_CHARS = 512;
|
|
62
|
+
|
|
63
|
+
function leaseCopy(location: CredentialLocation, lane: TokenLane, files: CredentialFiles): string {
|
|
64
|
+
return serializeLeasedValue({
|
|
65
|
+
v: 1,
|
|
66
|
+
part: "lease",
|
|
67
|
+
location,
|
|
68
|
+
class: "engine",
|
|
69
|
+
authKind: "config-dir",
|
|
70
|
+
files: lane.brokeredCopy(files),
|
|
71
|
+
expiresAt: lane.expiresAt(files),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sameFiles(a: CredentialFiles, b: CredentialFiles): boolean {
|
|
76
|
+
const keys = Object.keys(a).sort();
|
|
77
|
+
const other = Object.keys(b).sort();
|
|
78
|
+
return keys.length === other.length && keys.every((k, i) => k === other[i] && a[k] === b[k]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Refresh one named revision. Never throws for a provider or storage
|
|
82
|
+
* failure: the answer says what happened. */
|
|
83
|
+
export async function refreshRevision(
|
|
84
|
+
request: BrokerRevisionRequest,
|
|
85
|
+
store: RevisionStore,
|
|
86
|
+
options: {
|
|
87
|
+
now?: () => number;
|
|
88
|
+
http?: (lane: TokenLane) => LaneHttp;
|
|
89
|
+
/** The SSRF rule's seams, for an MCP server's token endpoint. */
|
|
90
|
+
safeHttp?: SafeHttpDeps;
|
|
91
|
+
/** ADR-134 root of an adopted compatibility copy. */
|
|
92
|
+
legacyPrefix?: string;
|
|
93
|
+
} = {},
|
|
94
|
+
): Promise<BrokerRevisionAnswer> {
|
|
95
|
+
const now = options.now ?? Date.now;
|
|
96
|
+
const location = parseBrokerParameterName(request.brokerParameter)!;
|
|
97
|
+
if (location.class === "mcp") return refreshMcpRevision(request, store, location, options);
|
|
98
|
+
let release: (() => Promise<void>) | null = null;
|
|
99
|
+
try {
|
|
100
|
+
release = await store.lock(request.credentialId);
|
|
101
|
+
if (!release) return { outcome: "busy" };
|
|
102
|
+
|
|
103
|
+
const brokerPart = await store.get(request.brokerParameter);
|
|
104
|
+
const broker = brokerPart ? parseBrokerEngineValue(brokerPart.value, location) : null;
|
|
105
|
+
if (!broker) return { outcome: "unavailable", error: "the revision's broker part is missing or unreadable" };
|
|
106
|
+
const lane = laneById(broker.lane);
|
|
107
|
+
if (!lane) return { outcome: "unavailable", error: `no lane ${broker.lane}` };
|
|
108
|
+
|
|
109
|
+
// 1. The lease-readable copy agrees with the broker-only material. It is
|
|
110
|
+
// rewritten, never created: a missing one was deleted with its retired
|
|
111
|
+
// revision, which must not come back.
|
|
112
|
+
const leasePart = await store.get(request.leaseParameter);
|
|
113
|
+
if (!leasePart) return { outcome: "unavailable", error: "the revision's lease-readable part is missing" };
|
|
114
|
+
const current = parseLeasedValue(leasePart.value, location);
|
|
115
|
+
const expected = lane.brokeredCopy(broker.files);
|
|
116
|
+
let version = leasePart.version;
|
|
117
|
+
if (
|
|
118
|
+
!current ||
|
|
119
|
+
current.class !== "engine" ||
|
|
120
|
+
current.authKind !== "config-dir" ||
|
|
121
|
+
!sameFiles(current.files, expected)
|
|
122
|
+
) {
|
|
123
|
+
version = await store.overwrite(request.leaseParameter, leaseCopy(location, lane, broker.files));
|
|
124
|
+
}
|
|
125
|
+
// An adopted revision: today's parameter agrees with it too.
|
|
126
|
+
let legacy = request.legacyParameter
|
|
127
|
+
? await syncLegacyCopy(store, request.legacyParameter, lane, broker.files, options.legacyPrefix)
|
|
128
|
+
: null;
|
|
129
|
+
const withLegacy = <T extends object>(answer: T): T & { legacy?: LegacySync } =>
|
|
130
|
+
legacy === null ? answer : { ...answer, legacy };
|
|
131
|
+
|
|
132
|
+
if (broker.refresh.needsSignIn) return { outcome: "needs_sign_in" };
|
|
133
|
+
const at = now();
|
|
134
|
+
if (!isRefreshDue(lane, broker.files, broker.refresh, at, request.force)) {
|
|
135
|
+
return withLegacy({
|
|
136
|
+
outcome: "fresh" as const,
|
|
137
|
+
version,
|
|
138
|
+
expiresAt: lane.expiresAt(broker.files) ?? broker.refresh.expiresAt,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. Refresh: the broker-only part first.
|
|
143
|
+
const http = options.http?.(lane) ?? createLaneHttp(lane.endpoints);
|
|
144
|
+
const result = await lane.refresh(broker.files, http, at);
|
|
145
|
+
if (!result.ok) {
|
|
146
|
+
const next: BrokerEngineValue = {
|
|
147
|
+
...broker,
|
|
148
|
+
refresh: {
|
|
149
|
+
...broker.refresh,
|
|
150
|
+
attempts: broker.refresh.attempts + 1,
|
|
151
|
+
lastAttemptAt: at,
|
|
152
|
+
lastError: result.reason.slice(0, MAX_ERROR_CHARS),
|
|
153
|
+
needsSignIn: result.terminal,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
await store.overwrite(request.brokerParameter, JSON.stringify(next));
|
|
157
|
+
return result.terminal
|
|
158
|
+
? { outcome: "needs_sign_in" }
|
|
159
|
+
: { outcome: "unavailable", error: result.reason.slice(0, MAX_ERROR_CHARS) };
|
|
160
|
+
}
|
|
161
|
+
const refreshed: BrokerEngineValue = {
|
|
162
|
+
...broker,
|
|
163
|
+
files: { ...result.files },
|
|
164
|
+
refresh: {
|
|
165
|
+
...broker.refresh,
|
|
166
|
+
expiresAt: result.expiresAt,
|
|
167
|
+
refreshedAt: at,
|
|
168
|
+
attempts: 0,
|
|
169
|
+
lastAttemptAt: at,
|
|
170
|
+
lastError: null,
|
|
171
|
+
needsSignIn: false,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
await store.overwrite(request.brokerParameter, JSON.stringify(refreshed));
|
|
175
|
+
version = await store.overwrite(request.leaseParameter, leaseCopy(location, lane, result.files));
|
|
176
|
+
if (request.legacyParameter) {
|
|
177
|
+
legacy = await syncLegacyCopy(store, request.legacyParameter, lane, result.files, options.legacyPrefix);
|
|
178
|
+
}
|
|
179
|
+
return withLegacy({ outcome: "refreshed" as const, version, expiresAt: result.expiresAt });
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// The error's kind only: a message can quote what a request carried.
|
|
182
|
+
return { outcome: "unavailable", error: brokerErrorKind(error) };
|
|
183
|
+
} finally {
|
|
184
|
+
if (release) await release();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function mcpLeaseCopy(location: CredentialLocation, broker: BrokerMcpValue): string {
|
|
189
|
+
return serializeLeasedValue({
|
|
190
|
+
v: 1,
|
|
191
|
+
part: "lease",
|
|
192
|
+
location,
|
|
193
|
+
class: "mcp",
|
|
194
|
+
authKind: "oauth",
|
|
195
|
+
accessToken: broker.accessToken,
|
|
196
|
+
tokenType: "Bearer",
|
|
197
|
+
expiresAt: broker.refresh.expiresAt,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function mcpRefreshDue(broker: BrokerMcpValue, now: number, forced: boolean): boolean {
|
|
202
|
+
if (broker.refresh.needsSignIn) return false;
|
|
203
|
+
if (forced) return true;
|
|
204
|
+
const { attempts, lastAttemptAt } = broker.refresh;
|
|
205
|
+
if (attempts > 0 && lastAttemptAt !== null && now - lastAttemptAt < backoffMs(attempts)) return false;
|
|
206
|
+
const schedule = CREDENTIAL_LANE_SCHEDULES[MCP_OAUTH_LANE]!;
|
|
207
|
+
if (broker.refresh.expiresAt !== null) return broker.refresh.expiresAt - now <= schedule.leadMs;
|
|
208
|
+
return now - (broker.refresh.refreshedAt ?? 0) >= (schedule.everyMs ?? schedule.leadMs);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* ADR-154 §11: an MCP connection's sign-in, refreshed at its token endpoint
|
|
213
|
+
* through the SSRF rule. The same order as an engine's: the lease-readable
|
|
214
|
+
* copy agrees with the broker-only material first, then a due refresh writes
|
|
215
|
+
* the broker-only part before the copy. A sign-in that gave no refresh token,
|
|
216
|
+
* or that the provider calls dead, needs a new sign-in.
|
|
217
|
+
*/
|
|
218
|
+
async function refreshMcpRevision(
|
|
219
|
+
request: BrokerRevisionRequest,
|
|
220
|
+
store: RevisionStore,
|
|
221
|
+
location: CredentialLocation,
|
|
222
|
+
options: { now?: () => number; safeHttp?: SafeHttpDeps },
|
|
223
|
+
): Promise<BrokerRevisionAnswer> {
|
|
224
|
+
const now = options.now ?? Date.now;
|
|
225
|
+
let release: (() => Promise<void>) | null = null;
|
|
226
|
+
try {
|
|
227
|
+
release = await store.lock(request.credentialId);
|
|
228
|
+
if (!release) return { outcome: "busy" };
|
|
229
|
+
const brokerPart = await store.get(request.brokerParameter);
|
|
230
|
+
const broker = brokerPart ? parseBrokerMcpValue(brokerPart.value, location) : null;
|
|
231
|
+
if (!broker) return { outcome: "unavailable", error: "the revision's broker part is missing or unreadable" };
|
|
232
|
+
const leasePart = await store.get(request.leaseParameter);
|
|
233
|
+
if (!leasePart) return { outcome: "unavailable", error: "the revision's lease-readable part is missing" };
|
|
234
|
+
const current = parseLeasedValue(leasePart.value, location);
|
|
235
|
+
let version = leasePart.version;
|
|
236
|
+
if (
|
|
237
|
+
!current ||
|
|
238
|
+
current.class !== "mcp" ||
|
|
239
|
+
current.authKind !== "oauth" ||
|
|
240
|
+
current.accessToken !== broker.accessToken ||
|
|
241
|
+
current.expiresAt !== broker.refresh.expiresAt
|
|
242
|
+
) {
|
|
243
|
+
version = await store.overwrite(request.leaseParameter, mcpLeaseCopy(location, broker));
|
|
244
|
+
}
|
|
245
|
+
if (broker.refresh.needsSignIn) return { outcome: "needs_sign_in" };
|
|
246
|
+
const at = now();
|
|
247
|
+
if (!mcpRefreshDue(broker, at, request.force)) {
|
|
248
|
+
return { outcome: "fresh", version, expiresAt: broker.refresh.expiresAt };
|
|
249
|
+
}
|
|
250
|
+
const markFailed = async (reason: string, terminal: boolean): Promise<void> => {
|
|
251
|
+
const next: BrokerMcpValue = {
|
|
252
|
+
...broker,
|
|
253
|
+
refresh: {
|
|
254
|
+
...broker.refresh,
|
|
255
|
+
attempts: broker.refresh.attempts + 1,
|
|
256
|
+
lastAttemptAt: at,
|
|
257
|
+
lastError: reason.slice(0, MAX_ERROR_CHARS),
|
|
258
|
+
needsSignIn: terminal,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
await store.overwrite(request.brokerParameter, JSON.stringify(next));
|
|
262
|
+
};
|
|
263
|
+
if (broker.refreshToken === null) {
|
|
264
|
+
await markFailed("the server gave no refresh token", true);
|
|
265
|
+
return { outcome: "needs_sign_in" };
|
|
266
|
+
}
|
|
267
|
+
const { status, json } = await postTokenEndpoint(
|
|
268
|
+
broker.tokenEndpoint,
|
|
269
|
+
{
|
|
270
|
+
grant_type: "refresh_token",
|
|
271
|
+
refresh_token: broker.refreshToken,
|
|
272
|
+
client_id: broker.clientId,
|
|
273
|
+
resource: broker.resource,
|
|
274
|
+
},
|
|
275
|
+
{ clientId: broker.clientId, clientSecret: broker.clientSecret },
|
|
276
|
+
{ http: options.safeHttp },
|
|
277
|
+
);
|
|
278
|
+
if (status < 200 || status >= 300 || typeof json.access_token !== "string") {
|
|
279
|
+
const terminal = status >= 400 && isTerminalOAuthError(status, json);
|
|
280
|
+
// A standard OAuth error code or the status: never the endpoint's own text.
|
|
281
|
+
const reason = oauthFailure(json, status);
|
|
282
|
+
await markFailed(reason, terminal);
|
|
283
|
+
return terminal ? { outcome: "needs_sign_in" } : { outcome: "unavailable", error: reason.slice(0, MAX_ERROR_CHARS) };
|
|
284
|
+
}
|
|
285
|
+
const expiresAt = typeof json.expires_in === "number" ? at + json.expires_in * 1000 : null;
|
|
286
|
+
const refreshed: BrokerMcpValue = {
|
|
287
|
+
...broker,
|
|
288
|
+
accessToken: json.access_token,
|
|
289
|
+
// Rotating servers send a new one; the rest keep the old.
|
|
290
|
+
refreshToken: typeof json.refresh_token === "string" ? json.refresh_token : broker.refreshToken,
|
|
291
|
+
refresh: {
|
|
292
|
+
...broker.refresh,
|
|
293
|
+
expiresAt,
|
|
294
|
+
refreshedAt: at,
|
|
295
|
+
attempts: 0,
|
|
296
|
+
lastAttemptAt: at,
|
|
297
|
+
lastError: null,
|
|
298
|
+
needsSignIn: false,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
await store.overwrite(request.brokerParameter, JSON.stringify(refreshed));
|
|
302
|
+
version = await store.overwrite(request.leaseParameter, mcpLeaseCopy(location, refreshed));
|
|
303
|
+
return { outcome: "refreshed", version, expiresAt };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
// The error's kind only: a message can quote what a request carried.
|
|
306
|
+
return { outcome: "unavailable", error: brokerErrorKind(error) };
|
|
307
|
+
} finally {
|
|
308
|
+
if (release) await release();
|
|
309
|
+
}
|
|
310
|
+
}
|