@moondeck/station 0.0.1 → 1.0.1
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/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +230 -0
- package/db/schema.ts +871 -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 +1470 -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 +937 -0
- package/lib/env.ts +195 -0
- package/lib/first-turn-receipts.ts +62 -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/bridge-diagnostics.ts +39 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2585 -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/enrollment-next-steps.ts +24 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2907 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6363 -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 +5550 -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
package/lib/ssh.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side per-user SSH key manager (ADR-029).
|
|
3
|
+
*
|
|
4
|
+
* Each user gets one ed25519 keypair, GENERATED ON THIS HOST. The private key
|
|
5
|
+
* lives encrypted at rest with the host master key (`host_ssh_keys` —
|
|
6
|
+
* ADR-015 secret-blindness, same shape as host_github_tokens). The cloud only
|
|
7
|
+
* ever sees the PUBLIC key, which the user pastes into GitHub themselves; there
|
|
8
|
+
* is no upload path. At task-up the private key is written into the task
|
|
9
|
+
* container so git-over-SSH works as the task creator.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import {
|
|
14
|
+
lstatSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
mkdtempSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { eq } from "drizzle-orm";
|
|
25
|
+
|
|
26
|
+
import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
|
|
27
|
+
import { getDb, schema } from "./db";
|
|
28
|
+
import { env } from "./env";
|
|
29
|
+
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
30
|
+
import { signInBoxMode } from "./signin-box";
|
|
31
|
+
import { assertSafeHostTaskId } from "./task-identity";
|
|
32
|
+
|
|
33
|
+
const withNewline = (s: string): string => (s.endsWith("\n") ? s : `${s}\n`);
|
|
34
|
+
|
|
35
|
+
const EXEC_TIMEOUT_MS = 10_000;
|
|
36
|
+
|
|
37
|
+
export interface GeneratedKey {
|
|
38
|
+
publicKey: string; // "ssh-ed25519 AAAA… <comment>"
|
|
39
|
+
privateKey: string; // OpenSSH private key PEM
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generate an ed25519 keypair via `ssh-keygen` (canonical OpenSSH formats that
|
|
44
|
+
* git + GitHub accept directly). Runs in a throwaway temp dir; nothing touches
|
|
45
|
+
* the operator's own ~/.ssh.
|
|
46
|
+
*/
|
|
47
|
+
export function generateEd25519(comment: string): GeneratedKey {
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "uai-ssh-"));
|
|
49
|
+
try {
|
|
50
|
+
const keyPath = join(dir, "id_ed25519");
|
|
51
|
+
const res = spawnSync(
|
|
52
|
+
"ssh-keygen",
|
|
53
|
+
["-t", "ed25519", "-N", "", "-C", comment, "-f", keyPath],
|
|
54
|
+
{ encoding: "utf8", timeout: EXEC_TIMEOUT_MS },
|
|
55
|
+
);
|
|
56
|
+
if (res.status !== 0) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`ssh-keygen failed: ${(res.stderr || res.error?.message || "").trim()}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
privateKey: readFileSync(keyPath, "utf8"),
|
|
63
|
+
publicKey: readFileSync(`${keyPath}.pub`, "utf8").trim(),
|
|
64
|
+
};
|
|
65
|
+
} finally {
|
|
66
|
+
rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- key store --------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/** The user's public key, or null if they have no key on this host yet. */
|
|
73
|
+
export function getPublicKey(userId: string): string | null {
|
|
74
|
+
const row = getDb()
|
|
75
|
+
.select({ publicKey: schema.sshKeys.publicKey })
|
|
76
|
+
.from(schema.sshKeys)
|
|
77
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
78
|
+
.get();
|
|
79
|
+
return row?.publicKey ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function hasKey(userId: string): boolean {
|
|
83
|
+
return getPublicKey(userId) !== null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ensure the user has a key on this host; generate + persist one if not.
|
|
88
|
+
* Idempotent — returns the (existing or freshly created) public key.
|
|
89
|
+
*/
|
|
90
|
+
export function ensureKeyForUser(userId: string): string {
|
|
91
|
+
const existing = getPublicKey(userId);
|
|
92
|
+
if (existing) return existing;
|
|
93
|
+
|
|
94
|
+
const { publicKey, privateKey } = generateEd25519(`uai-${userId}`);
|
|
95
|
+
const sealed = sealAesGcm(privateKey);
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
getDb()
|
|
98
|
+
.insert(schema.sshKeys)
|
|
99
|
+
.values({
|
|
100
|
+
userId,
|
|
101
|
+
publicKey,
|
|
102
|
+
privateKeyCt: sealed.ct,
|
|
103
|
+
privateKeyNonce: sealed.nonce,
|
|
104
|
+
createdAt: now,
|
|
105
|
+
updatedAt: now,
|
|
106
|
+
})
|
|
107
|
+
.onConflictDoNothing({ target: schema.sshKeys.userId })
|
|
108
|
+
.run();
|
|
109
|
+
mirrorSshKeyToStore(userId);
|
|
110
|
+
// Re-read in case a concurrent task-up won the insert race.
|
|
111
|
+
return getPublicKey(userId) ?? publicKey;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function deleteKey(userId: string): void {
|
|
115
|
+
getDb().delete(schema.sshKeys).where(eq(schema.sshKeys.userId, userId)).run();
|
|
116
|
+
queueStoreWrite(`delete ssh key ${userId}`, (store) =>
|
|
117
|
+
store.delete(sshKeyParameterName(userId)),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Credential store mirror (ADR-134 slice 2): `<prefix>/users/<userId>/ssh`.
|
|
123
|
+
// A replacement Uai-operated host imports the SAME keypair, so the public key
|
|
124
|
+
// the user pasted into GitHub keeps working. Owned hosts: no-ops.
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
export interface StoredSshKey {
|
|
128
|
+
v: 1;
|
|
129
|
+
kind: "ssh";
|
|
130
|
+
userId: string;
|
|
131
|
+
publicKey: string;
|
|
132
|
+
privateKey: string;
|
|
133
|
+
createdAt: number;
|
|
134
|
+
updatedAt: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function sshKeyParameterName(userId: string): string {
|
|
138
|
+
return userParameterName(userId, "ssh");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The row as it is mirrored (private key decrypted). Never logged. Null
|
|
142
|
+
* for a store-held row (sign-in box): nothing here to mirror. */
|
|
143
|
+
export function sshKeyStoreValue(userId: string): StoredSshKey | null {
|
|
144
|
+
const row = getDb()
|
|
145
|
+
.select()
|
|
146
|
+
.from(schema.sshKeys)
|
|
147
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
148
|
+
.get();
|
|
149
|
+
if (!row || isStoreHeldSshRow(row)) return null;
|
|
150
|
+
return {
|
|
151
|
+
v: 1,
|
|
152
|
+
kind: "ssh",
|
|
153
|
+
userId,
|
|
154
|
+
publicKey: row.publicKey,
|
|
155
|
+
privateKey: openAesGcm(
|
|
156
|
+
Buffer.from(row.privateKeyCt as Uint8Array),
|
|
157
|
+
Buffer.from(row.privateKeyNonce as Uint8Array),
|
|
158
|
+
),
|
|
159
|
+
createdAt: row.createdAt,
|
|
160
|
+
updatedAt: row.updatedAt,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function setSshKeyStoreRef(userId: string, storeRef: string): void {
|
|
165
|
+
getDb()
|
|
166
|
+
.update(schema.sshKeys)
|
|
167
|
+
.set({ storeRef })
|
|
168
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
169
|
+
.run();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function listSshKeyStoreRows(): Array<{ userId: string; storeRef: string | null }> {
|
|
173
|
+
return getDb()
|
|
174
|
+
.select({ userId: schema.sshKeys.userId, storeRef: schema.sshKeys.storeRef })
|
|
175
|
+
.from(schema.sshKeys)
|
|
176
|
+
.all();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function mirrorSshKeyToStore(userId: string): void {
|
|
180
|
+
// ADR-136 sign-in box: rows hold no material; the store was written first.
|
|
181
|
+
if (signInBoxMode()) return;
|
|
182
|
+
queueStoreWrite(`mirror ssh key ${userId}`, async (store) => {
|
|
183
|
+
const value = sshKeyStoreValue(userId);
|
|
184
|
+
if (!value) return;
|
|
185
|
+
const name = sshKeyParameterName(userId);
|
|
186
|
+
await store.put(name, JSON.stringify(value));
|
|
187
|
+
setSshKeyStoreRef(userId, name);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Reconcile import: a keypair this host lacks. Existing rows win. */
|
|
192
|
+
export function importSshKeyFromStore(value: StoredSshKey, storeRef: string): boolean {
|
|
193
|
+
const sealed = sealAesGcm(value.privateKey);
|
|
194
|
+
const result = getDb()
|
|
195
|
+
.insert(schema.sshKeys)
|
|
196
|
+
.values({
|
|
197
|
+
userId: value.userId,
|
|
198
|
+
publicKey: value.publicKey,
|
|
199
|
+
privateKeyCt: sealed.ct,
|
|
200
|
+
privateKeyNonce: sealed.nonce,
|
|
201
|
+
createdAt: value.createdAt,
|
|
202
|
+
updatedAt: value.updatedAt,
|
|
203
|
+
storeRef,
|
|
204
|
+
})
|
|
205
|
+
.onConflictDoNothing({ target: schema.sshKeys.userId })
|
|
206
|
+
.run();
|
|
207
|
+
return result.changes > 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Decrypt + return the user's private key, or null if they have none (or
|
|
211
|
+
* the row is store-held — the sign-in box never holds one). */
|
|
212
|
+
export function privateKeyForUser(userId: string): string | null {
|
|
213
|
+
const row = getDb()
|
|
214
|
+
.select()
|
|
215
|
+
.from(schema.sshKeys)
|
|
216
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
217
|
+
.get();
|
|
218
|
+
if (!row || isStoreHeldSshRow(row)) return null;
|
|
219
|
+
return openAesGcm(
|
|
220
|
+
Buffer.from(row.privateKeyCt as Uint8Array),
|
|
221
|
+
Buffer.from(row.privateKeyNonce as Uint8Array),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Decrypt + return BOTH halves of the user's key, or null if they have none. */
|
|
226
|
+
export function keyPairForUser(
|
|
227
|
+
userId: string,
|
|
228
|
+
): { privateKey: string; publicKey: string } | null {
|
|
229
|
+
const row = getDb()
|
|
230
|
+
.select()
|
|
231
|
+
.from(schema.sshKeys)
|
|
232
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
233
|
+
.get();
|
|
234
|
+
if (!row || isStoreHeldSshRow(row)) return null;
|
|
235
|
+
return {
|
|
236
|
+
privateKey: openAesGcm(
|
|
237
|
+
Buffer.from(row.privateKeyCt as Uint8Array),
|
|
238
|
+
Buffer.from(row.privateKeyNonce as Uint8Array),
|
|
239
|
+
),
|
|
240
|
+
publicKey: row.publicKey,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// ADR-136 sign-in box: a key row here holds NO private key (`private_key_ct`
|
|
246
|
+
// empty — "store-held"); the keypair goes to the store first and only the
|
|
247
|
+
// public half stays, which is all the box ever answers (`ssh.key.get`). The
|
|
248
|
+
// public key also rides as a tag so a replacement box lists it without
|
|
249
|
+
// reading the value.
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export const SSH_KEY_TAGS = { publicKey: "uai_public_key", createdAt: "uai_created_at" } as const;
|
|
253
|
+
|
|
254
|
+
export function isStoreHeldSshRow(row: { privateKeyCt: unknown }): boolean {
|
|
255
|
+
const ct = row.privateKeyCt;
|
|
256
|
+
if (ct === null || ct === undefined) return true;
|
|
257
|
+
if (ct instanceof Uint8Array) return ct.byteLength === 0;
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function sshKeyMetadataTags(value: { publicKey: string; createdAt: number }): Record<string, string> {
|
|
262
|
+
return {
|
|
263
|
+
[SSH_KEY_TAGS.publicKey]: value.publicKey,
|
|
264
|
+
[SSH_KEY_TAGS.createdAt]: String(value.createdAt),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A key row with no private half (sign-in box). Existing rows win. */
|
|
269
|
+
export function insertStoreHeldSshKeyRow(args: {
|
|
270
|
+
userId: string;
|
|
271
|
+
publicKey: string;
|
|
272
|
+
createdAt: number;
|
|
273
|
+
updatedAt: number;
|
|
274
|
+
storeRef: string;
|
|
275
|
+
}): boolean {
|
|
276
|
+
const result = getDb()
|
|
277
|
+
.insert(schema.sshKeys)
|
|
278
|
+
.values({
|
|
279
|
+
userId: args.userId,
|
|
280
|
+
publicKey: args.publicKey,
|
|
281
|
+
privateKeyCt: Buffer.alloc(0),
|
|
282
|
+
privateKeyNonce: Buffer.alloc(0),
|
|
283
|
+
createdAt: args.createdAt,
|
|
284
|
+
updatedAt: args.updatedAt,
|
|
285
|
+
storeRef: args.storeRef,
|
|
286
|
+
})
|
|
287
|
+
.onConflictDoNothing({ target: schema.sshKeys.userId })
|
|
288
|
+
.run();
|
|
289
|
+
return result.changes > 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Delete the row only (no store call). */
|
|
293
|
+
export function deleteSshKeyRowLocal(userId: string): void {
|
|
294
|
+
getDb().delete(schema.sshKeys).where(eq(schema.sshKeys.userId, userId)).run();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* `ensureKeyForUser` for the sign-in box: generate, write the keypair to the
|
|
299
|
+
* store FIRST (awaited — a refused put is the operation's failure and leaves
|
|
300
|
+
* nothing), tag the public key, then keep a store-held row. A row whose
|
|
301
|
+
* public key this box could not learn (a legacy parameter it may not read)
|
|
302
|
+
* is refused rather than silently regenerated over the user's real key.
|
|
303
|
+
*/
|
|
304
|
+
export async function ensureSshKeyStoreFirst(userId: string): Promise<string> {
|
|
305
|
+
const existing = getDb()
|
|
306
|
+
.select({ publicKey: schema.sshKeys.publicKey })
|
|
307
|
+
.from(schema.sshKeys)
|
|
308
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
309
|
+
.get();
|
|
310
|
+
if (existing) {
|
|
311
|
+
if (existing.publicKey) return existing.publicKey;
|
|
312
|
+
throw new Error(
|
|
313
|
+
"this user's SSH key exists in the credential store but its public half is not tagged yet; re-run the sign-in box bootstrap with the transitional read",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const store = configuredCredentialStore();
|
|
317
|
+
if (!store) throw new Error("the sign-in box has no credential store to hold the key");
|
|
318
|
+
const { publicKey, privateKey } = generateEd25519(`uai-${userId}`);
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
const name = sshKeyParameterName(userId);
|
|
321
|
+
const value: StoredSshKey = {
|
|
322
|
+
v: 1,
|
|
323
|
+
kind: "ssh",
|
|
324
|
+
userId,
|
|
325
|
+
publicKey,
|
|
326
|
+
privateKey,
|
|
327
|
+
createdAt: now,
|
|
328
|
+
updatedAt: now,
|
|
329
|
+
};
|
|
330
|
+
await store.put(name, JSON.stringify(value));
|
|
331
|
+
try {
|
|
332
|
+
await store.tag(name, sshKeyMetadataTags(value));
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.warn(
|
|
335
|
+
`[ssh] user ${userId}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box needs the transitional read to list this key`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
insertStoreHeldSshKeyRow({ userId, publicKey, createdAt: now, updatedAt: now, storeRef: name });
|
|
339
|
+
return getPublicKey(userId) ?? publicKey;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- per-task on-disk identity (optional SSH fallback + signing, ADR-029) -----
|
|
343
|
+
//
|
|
344
|
+
// task-up.sh uses the creator's key for host Git only when GitHub is not
|
|
345
|
+
// connected, and installs it in the container for signing (plus that same
|
|
346
|
+
// fallback). The cloud relays ssh.key.get/ensure/delete for the Account card
|
|
347
|
+
// via the store functions above.
|
|
348
|
+
|
|
349
|
+
function taskIdentityDir(taskId: string): string {
|
|
350
|
+
return resolve(env.dataDir, "identity", "tasks", taskId);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Materialize the task creator's keypair to a per-task on-disk identity that
|
|
355
|
+
* task-up.sh uses for the disconnected user's host-side SSH fallback and the
|
|
356
|
+
* container's optional signing/fallback identity (ADR-029). Connected users
|
|
357
|
+
* clone + push with their GitHub App user token over HTTPS instead.
|
|
358
|
+
* Returns the dir, or null when the creator has no key on this host. A task
|
|
359
|
+
* never borrows the host operator's identity: without a connected GitHub
|
|
360
|
+
* credential, SSH transport is available only through this owner's key. The
|
|
361
|
+
* decrypted key lives only on the trusted host (ADR-015) and is removed right
|
|
362
|
+
* after task-up — see {@link removeTaskIdentity}.
|
|
363
|
+
*/
|
|
364
|
+
export function writeTaskIdentity(
|
|
365
|
+
taskId: string,
|
|
366
|
+
userId: string | null | undefined,
|
|
367
|
+
): string | null {
|
|
368
|
+
if (!userId) return null;
|
|
369
|
+
const pair = keyPairForUser(userId);
|
|
370
|
+
if (!pair) return null;
|
|
371
|
+
const dir = taskIdentityDir(taskId);
|
|
372
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
373
|
+
writeFileSync(resolve(dir, "id_ed25519"), withNewline(pair.privateKey), {
|
|
374
|
+
mode: 0o600,
|
|
375
|
+
});
|
|
376
|
+
writeFileSync(resolve(dir, "id_ed25519.pub"), withNewline(pair.publicKey), {
|
|
377
|
+
mode: 0o644,
|
|
378
|
+
});
|
|
379
|
+
return dir;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Remove a task's on-disk identity (idempotent; call after task-up). */
|
|
383
|
+
export function removeTaskIdentity(taskId: string): void {
|
|
384
|
+
rmSync(taskIdentityDir(taskId), { recursive: true, force: true });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Orphan GC may acknowledge only after the materialized private key is
|
|
388
|
+
* positively absent. The caller first retires every task-scoped ensure, so no
|
|
389
|
+
* trusted host continuation can recreate this directory after the check. */
|
|
390
|
+
export function removeTaskIdentityStrict(taskId: string): void {
|
|
391
|
+
assertSafeHostTaskId(taskId);
|
|
392
|
+
const dir = taskIdentityDir(taskId);
|
|
393
|
+
rmSync(dir, { recursive: true, force: true });
|
|
394
|
+
try {
|
|
395
|
+
lstatSync(dir);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
throw new Error("task SSH identity remains after removal");
|
|
401
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { CONTEXT_HASH_LABEL } from "./standard-image-pin";
|
|
5
|
+
|
|
6
|
+
type Result = { code: number | null; stdout: string; stderr: string };
|
|
7
|
+
export type DockerCommand = (command: string, args: string[], timeoutMs?: number) => Promise<Result>;
|
|
8
|
+
const IMAGE_ID = /^sha256:[a-f0-9]{64}$/;
|
|
9
|
+
const SWEEP_LIMIT = 16;
|
|
10
|
+
const HISTORY_LIMIT = 128;
|
|
11
|
+
const HISTORY_BYTES = 16 * 1024;
|
|
12
|
+
|
|
13
|
+
/** Context labels are inherited by task images. Authority to delete comes
|
|
14
|
+
* from observing this exact id under the standard tag, never from a scan. */
|
|
15
|
+
export class StandardImageCleanup {
|
|
16
|
+
private readonly observed = new Set<string>();
|
|
17
|
+
private loading: Promise<void> | undefined;
|
|
18
|
+
private statePath: string | undefined;
|
|
19
|
+
|
|
20
|
+
constructor(private readonly stateFile?: () => Promise<string>) {}
|
|
21
|
+
|
|
22
|
+
private async load(): Promise<void> {
|
|
23
|
+
if (!this.stateFile) return;
|
|
24
|
+
try {
|
|
25
|
+
this.statePath = await this.stateFile();
|
|
26
|
+
const fd = openSync(this.statePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
27
|
+
try {
|
|
28
|
+
const stat = fstatSync(fd);
|
|
29
|
+
if (!stat.isFile() || stat.size > HISTORY_BYTES) return;
|
|
30
|
+
const bytes = Buffer.alloc(HISTORY_BYTES + 1);
|
|
31
|
+
const length = readSync(fd, bytes, 0, bytes.length, 0);
|
|
32
|
+
if (length > HISTORY_BYTES) return;
|
|
33
|
+
const value = JSON.parse(bytes.subarray(0, length).toString("utf8")) as { version?: unknown; ids?: unknown };
|
|
34
|
+
if (value?.version !== 1 || !Array.isArray(value.ids) || value.ids.length > HISTORY_LIMIT || !value.ids.every(id => typeof id === "string" && IMAGE_ID.test(id))) return;
|
|
35
|
+
for (const id of value.ids) this.observed.add(id as string);
|
|
36
|
+
} finally { closeSync(fd); }
|
|
37
|
+
} catch { /* Missing, corrupt or unreadable history authorizes nothing. */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private save(): void {
|
|
41
|
+
if (!this.statePath) return;
|
|
42
|
+
const temporary = `${this.statePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 });
|
|
45
|
+
writeFileSync(temporary, JSON.stringify({ version: 1, ids: [...this.observed] }), { flag: "wx", mode: 0o600 });
|
|
46
|
+
renameSync(temporary, this.statePath);
|
|
47
|
+
} catch {
|
|
48
|
+
// ENOSPC must not block a build or safe cleanup using in-memory proof.
|
|
49
|
+
console.warn("[host-agent] could not persist standard-image history");
|
|
50
|
+
} finally {
|
|
51
|
+
try { unlinkSync(temporary); } catch { /* Renamed or never created. */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async observe(run: DockerCommand, ref = "moondeck-standard:dev"): Promise<string | null> {
|
|
56
|
+
await (this.loading ??= this.load());
|
|
57
|
+
const result = await run("docker", ["image", "inspect", "--format", "{{json .}}", ref]);
|
|
58
|
+
if (result.code !== 0) return null;
|
|
59
|
+
try {
|
|
60
|
+
const image = JSON.parse(result.stdout) as { Id?: string; Config?: { Labels?: Record<string, string> } };
|
|
61
|
+
if (!IMAGE_ID.test(image.Id ?? "") || !/^[a-f0-9]{32}$/.test(image.Config?.Labels?.[CONTEXT_HASH_LABEL] ?? "")) return null;
|
|
62
|
+
if (ref === "moondeck-standard:dev") {
|
|
63
|
+
this.observed.delete(image.Id!);
|
|
64
|
+
this.observed.add(image.Id!);
|
|
65
|
+
if (this.observed.size > HISTORY_LIMIT) this.observed.delete(this.observed.values().next().value!);
|
|
66
|
+
this.save();
|
|
67
|
+
}
|
|
68
|
+
return image.Id!;
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async sweep(run: DockerCommand, previousId: string | null): Promise<void> {
|
|
73
|
+
const deadline = Date.now() + 30_000;
|
|
74
|
+
const boundedRun: DockerCommand = (command, args) => run(command, args, 5_000);
|
|
75
|
+
const currentId = await this.observe(boundedRun);
|
|
76
|
+
if (!currentId) return;
|
|
77
|
+
const history = await boundedRun("docker", ["image", "history", "--quiet", "--no-trunc", currentId]);
|
|
78
|
+
if (history.code !== 0) return;
|
|
79
|
+
const currentChain = new Set([currentId, ...history.stdout.trim().split(/\s+/)]);
|
|
80
|
+
const candidates = new Set([...(previousId ? [previousId] : []), ...this.observed]);
|
|
81
|
+
for (const id of [...candidates].filter(id => this.observed.has(id) && !currentChain.has(id)).slice(0, SWEEP_LIMIT)) {
|
|
82
|
+
if (Date.now() >= deadline) break;
|
|
83
|
+
// Round-robin retained/in-use ids so a busy prefix cannot starve later ids.
|
|
84
|
+
this.observed.delete(id);
|
|
85
|
+
this.observed.add(id);
|
|
86
|
+
const tags = await boundedRun("docker", ["image", "inspect", "--format", "{{json .RepoTags}}", id]);
|
|
87
|
+
if (tags.code !== 0) continue;
|
|
88
|
+
try { const parsed: unknown = JSON.parse(tags.stdout); if (parsed !== null && (!Array.isArray(parsed) || parsed.length !== 0)) continue; } catch { continue; }
|
|
89
|
+
const containers = await boundedRun("docker", ["ps", "--all", "--quiet", "--filter", `ancestor=${id}`]);
|
|
90
|
+
if (containers.code !== 0 || containers.stdout.trim()) continue;
|
|
91
|
+
// Normal Docker removal reclaims otherwise-unused untagged parents.
|
|
92
|
+
// Shared ancestors of the current image (or any other child/container)
|
|
93
|
+
// remain protected by the daemon. Never force or target a parent id.
|
|
94
|
+
const removed = await boundedRun("docker", ["image", "rm", id]);
|
|
95
|
+
if (removed.code === 0) {
|
|
96
|
+
this.observed.delete(id);
|
|
97
|
+
console.log("[host-agent] removed unused previous standard image", id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
this.save();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Per-process exponential retry, keyed by the desired content/configuration.
|
|
105
|
+
* A new host payload/engine configuration retries immediately. A restart also
|
|
106
|
+
* retries once; repeated failures back off 2h, 4h, …, 24h instead of hourly. */
|
|
107
|
+
export class StandardImageBuildBackoff {
|
|
108
|
+
private state: { key: string; failures: number; retryAt: number; error: string } | null = null;
|
|
109
|
+
remaining(key: string, now = Date.now()): { delay: number; retryAt: number; error: string } | null {
|
|
110
|
+
return this.state?.key === key && this.state.retryAt > now ? { delay: this.state.retryAt - now, retryAt: this.state.retryAt, error: this.state.error } : null;
|
|
111
|
+
}
|
|
112
|
+
failed(key: string, error: string, now = Date.now()): void {
|
|
113
|
+
const failures = this.state?.key === key ? this.state.failures + 1 : 1;
|
|
114
|
+
this.state = { key, failures, retryAt: now + Math.min(24, 2 ** Math.min(failures, 5)) * 3_600_000, error };
|
|
115
|
+
}
|
|
116
|
+
clear(): void { this.state = null; }
|
|
117
|
+
}
|