@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,2439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side GitHub token manager (ADR-027).
|
|
3
|
+
*
|
|
4
|
+
* Owns the encrypted refresh token (`host_github_tokens`), the per-task access
|
|
5
|
+
* token mint + container injection, and the refresh schedule. Access tokens are
|
|
6
|
+
* never stored — they go straight into the container's gh config. The refresh
|
|
7
|
+
* exchange is a host→cloud HTTP POST to /api/github/oauth/exchange.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { and, eq } from "drizzle-orm";
|
|
11
|
+
|
|
12
|
+
import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
|
|
13
|
+
import { getDb, schema } from "./db";
|
|
14
|
+
import { githubApiOrigin } from "./github-origin";
|
|
15
|
+
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
16
|
+
import { dockerCli } from "./docker-exec";
|
|
17
|
+
import {
|
|
18
|
+
removeTaskAppContainer,
|
|
19
|
+
taskAppContainerName,
|
|
20
|
+
taskAppContainerState,
|
|
21
|
+
taskContainerCli,
|
|
22
|
+
} from "./task-container-cli";
|
|
23
|
+
import {
|
|
24
|
+
RUNTIME_AUTHORITY_ENV,
|
|
25
|
+
runtimeAuthorityDockerExecArgs,
|
|
26
|
+
} from "./runtime-authority";
|
|
27
|
+
import {
|
|
28
|
+
backgroundContainerWorkAllowed,
|
|
29
|
+
ContainerRuntimeUnavailableError,
|
|
30
|
+
requireContainerRuntimeOperational,
|
|
31
|
+
} from "./runtime-guard";
|
|
32
|
+
import {
|
|
33
|
+
configureTaskSshTransport,
|
|
34
|
+
ensureTaskSshIdentity,
|
|
35
|
+
} from "./git-identity";
|
|
36
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
37
|
+
import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
38
|
+
import { sidecarMode } from "./sidecar";
|
|
39
|
+
import { signInBoxMode } from "./signin-box";
|
|
40
|
+
import {
|
|
41
|
+
MAX_GITHUB_INSTALLATIONS,
|
|
42
|
+
MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
|
|
43
|
+
type CloudToHost,
|
|
44
|
+
type GitHubRepositoryAccessErrorCode,
|
|
45
|
+
} from "../src/protocol";
|
|
46
|
+
|
|
47
|
+
const REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
|
|
48
|
+
const EXEC_TIMEOUT_MS = 10_000;
|
|
49
|
+
// Cap the cloud token-exchange round-trip. Generous on purpose: aborting an
|
|
50
|
+
// exchange that GitHub already completed burns the single-use refresh token
|
|
51
|
+
// (we never see the rotated replacement), so a slow success beats a fast
|
|
52
|
+
// ambiguous abort.
|
|
53
|
+
const EXCHANGE_TIMEOUT_MS = 30_000;
|
|
54
|
+
const MANAGED_GH_CONFIG_DIR = "/home/node/.config/gh";
|
|
55
|
+
const SAFE_CONTAINER_PATH = "/usr/bin:/bin";
|
|
56
|
+
|
|
57
|
+
// `docker exec` inherits the task container's project environment. Override
|
|
58
|
+
// loader/config variables before the first executable starts, then use env -i
|
|
59
|
+
// so arbitrary GIT_CONFIG_KEY_* and similar project keys cannot influence the
|
|
60
|
+
// managed gh/git mutation. Commands themselves use absolute paths.
|
|
61
|
+
const SANITIZED_DOCKER_EXEC_ENV = [
|
|
62
|
+
"-e",
|
|
63
|
+
"HOME=/home/node",
|
|
64
|
+
"-e",
|
|
65
|
+
`GH_CONFIG_DIR=${MANAGED_GH_CONFIG_DIR}`,
|
|
66
|
+
"-e",
|
|
67
|
+
`PATH=${SAFE_CONTAINER_PATH}`,
|
|
68
|
+
"-e",
|
|
69
|
+
"XDG_CONFIG_HOME=",
|
|
70
|
+
"-e",
|
|
71
|
+
"LD_PRELOAD=",
|
|
72
|
+
"-e",
|
|
73
|
+
"LD_LIBRARY_PATH=",
|
|
74
|
+
"-e",
|
|
75
|
+
"DYLD_INSERT_LIBRARIES=",
|
|
76
|
+
"-e",
|
|
77
|
+
"DYLD_LIBRARY_PATH=",
|
|
78
|
+
"-e",
|
|
79
|
+
"GIT_CONFIG=",
|
|
80
|
+
"-e",
|
|
81
|
+
"GIT_CONFIG_GLOBAL=",
|
|
82
|
+
"-e",
|
|
83
|
+
"GIT_CONFIG_SYSTEM=",
|
|
84
|
+
"-e",
|
|
85
|
+
"GIT_CONFIG_NOSYSTEM=",
|
|
86
|
+
"-e",
|
|
87
|
+
"GIT_CONFIG_COUNT=0",
|
|
88
|
+
"-e",
|
|
89
|
+
"GIT_EXEC_PATH=",
|
|
90
|
+
"-e",
|
|
91
|
+
"GIT_SSH=",
|
|
92
|
+
"-e",
|
|
93
|
+
"GIT_SSH_COMMAND=",
|
|
94
|
+
"-e",
|
|
95
|
+
"GIT_ASKPASS=",
|
|
96
|
+
"-e",
|
|
97
|
+
"SSH_ASKPASS=",
|
|
98
|
+
] as const;
|
|
99
|
+
|
|
100
|
+
export const CLEAN_CONTAINER_ENV = [
|
|
101
|
+
"/usr/bin/env",
|
|
102
|
+
"-i",
|
|
103
|
+
"HOME=/home/node",
|
|
104
|
+
`GH_CONFIG_DIR=${MANAGED_GH_CONFIG_DIR}`,
|
|
105
|
+
`PATH=${SAFE_CONTAINER_PATH}`,
|
|
106
|
+
] as const;
|
|
107
|
+
|
|
108
|
+
/** Provider-exec form of {@link SANITIZED_DOCKER_EXEC_ENV}: same overrides,
|
|
109
|
+
* carried as a bounded env record instead of docker `-e` flags. */
|
|
110
|
+
export const SANITIZED_CONTAINER_ENV: Readonly<Record<string, string>> =
|
|
111
|
+
Object.freeze({
|
|
112
|
+
HOME: "/home/node",
|
|
113
|
+
GH_CONFIG_DIR: MANAGED_GH_CONFIG_DIR,
|
|
114
|
+
PATH: SAFE_CONTAINER_PATH,
|
|
115
|
+
XDG_CONFIG_HOME: "",
|
|
116
|
+
LD_PRELOAD: "",
|
|
117
|
+
LD_LIBRARY_PATH: "",
|
|
118
|
+
DYLD_INSERT_LIBRARIES: "",
|
|
119
|
+
DYLD_LIBRARY_PATH: "",
|
|
120
|
+
GIT_CONFIG: "",
|
|
121
|
+
GIT_CONFIG_GLOBAL: "",
|
|
122
|
+
GIT_CONFIG_SYSTEM: "",
|
|
123
|
+
GIT_CONFIG_NOSYSTEM: "",
|
|
124
|
+
GIT_CONFIG_COUNT: "0",
|
|
125
|
+
GIT_EXEC_PATH: "",
|
|
126
|
+
GIT_SSH: "",
|
|
127
|
+
GIT_SSH_COMMAND: "",
|
|
128
|
+
GIT_ASKPASS: "",
|
|
129
|
+
SSH_ASKPASS: "",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function managedContainerExecArgs(
|
|
133
|
+
container: string,
|
|
134
|
+
command: string[],
|
|
135
|
+
stdin = false,
|
|
136
|
+
): string[] {
|
|
137
|
+
return [
|
|
138
|
+
"exec",
|
|
139
|
+
...(stdin ? ["-i"] : []),
|
|
140
|
+
"-u",
|
|
141
|
+
"node",
|
|
142
|
+
...SANITIZED_DOCKER_EXEC_ENV,
|
|
143
|
+
...runtimeAuthorityDockerExecArgs(),
|
|
144
|
+
container,
|
|
145
|
+
...CLEAN_CONTAINER_ENV,
|
|
146
|
+
...command,
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
|
|
151
|
+
|
|
152
|
+
// Every local grant replacement/removal advances the user's generation. Slow
|
|
153
|
+
// token exchanges and container mutations capture the generation they began
|
|
154
|
+
// under and must not publish their result after a newer connect/disconnect.
|
|
155
|
+
const githubAuthGenerations = new Map<string, number>();
|
|
156
|
+
const githubConnectionTransitionQueues = new Map<string, Promise<void>>();
|
|
157
|
+
|
|
158
|
+
function githubAuthGeneration(userId: string): number {
|
|
159
|
+
return githubAuthGenerations.get(userId) ?? 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function advanceGithubAuthGeneration(userId: string): number {
|
|
163
|
+
const next = githubAuthGeneration(userId) + 1;
|
|
164
|
+
githubAuthGenerations.set(userId, next);
|
|
165
|
+
accessCache.delete(userId);
|
|
166
|
+
// The request itself cannot be cancelled safely (the refresh token may have
|
|
167
|
+
// rotated at GitHub), but a new grant must never join that stale promise.
|
|
168
|
+
inflightExchanges.delete(userId);
|
|
169
|
+
return next;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Serialize connect/set/clear for one user. Without this outer fence, a clear
|
|
173
|
+
* that lands while set is awaiting task-up invalidation can be overwritten by
|
|
174
|
+
* the older set storing its token afterward. */
|
|
175
|
+
export function runGithubConnectionTransition<T>(
|
|
176
|
+
userId: string,
|
|
177
|
+
transition: () => Promise<T>,
|
|
178
|
+
admit?: () => void,
|
|
179
|
+
): Promise<T> {
|
|
180
|
+
const previous =
|
|
181
|
+
githubConnectionTransitionQueues.get(userId) ?? Promise.resolve();
|
|
182
|
+
let result!: Promise<T>;
|
|
183
|
+
let tail!: Promise<void>;
|
|
184
|
+
result = previous
|
|
185
|
+
.catch(() => {})
|
|
186
|
+
.then(() => {
|
|
187
|
+
admit?.();
|
|
188
|
+
return transition();
|
|
189
|
+
})
|
|
190
|
+
.finally(() => {
|
|
191
|
+
if (githubConnectionTransitionQueues.get(userId) === tail) {
|
|
192
|
+
githubConnectionTransitionQueues.delete(userId);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
tail = result.then(
|
|
196
|
+
() => {},
|
|
197
|
+
() => {},
|
|
198
|
+
);
|
|
199
|
+
githubConnectionTransitionQueues.set(userId, tail);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Persist a monotonic credential mutation fence before changing the token row.
|
|
205
|
+
* The generation survives disconnect because it lives in a separate table, so
|
|
206
|
+
* a delayed older connect cannot recreate a credential after a newer clear.
|
|
207
|
+
*/
|
|
208
|
+
export type GitHubCredentialMutation = "set" | "clear";
|
|
209
|
+
|
|
210
|
+
export interface GitHubCredentialGenerationClaim {
|
|
211
|
+
generation: number;
|
|
212
|
+
replay: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function claimGithubCredentialGeneration(
|
|
216
|
+
userId: string,
|
|
217
|
+
requestedGeneration: number | undefined,
|
|
218
|
+
mutation: GitHubCredentialMutation,
|
|
219
|
+
): GitHubCredentialGenerationClaim | null {
|
|
220
|
+
if (
|
|
221
|
+
requestedGeneration !== undefined &&
|
|
222
|
+
(!Number.isSafeInteger(requestedGeneration) || requestedGeneration < 0)
|
|
223
|
+
) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return getDb().transaction((tx) => {
|
|
227
|
+
const current = tx
|
|
228
|
+
.select({
|
|
229
|
+
generation: schema.githubCredentialGenerations.generation,
|
|
230
|
+
mutation: schema.githubCredentialGenerations.mutation,
|
|
231
|
+
})
|
|
232
|
+
.from(schema.githubCredentialGenerations)
|
|
233
|
+
.where(eq(schema.githubCredentialGenerations.userId, userId))
|
|
234
|
+
.get();
|
|
235
|
+
// Generationless frames belong permanently to the legacy generation-zero
|
|
236
|
+
// namespace. Never derive a newer value from local state: a delayed old
|
|
237
|
+
// cloud frame must not outrank an explicit generation from a newer cloud.
|
|
238
|
+
// Once a clear has closed generation zero, the normal clear->set replay
|
|
239
|
+
// rule below deliberately requires a cloud upgrade before reconnecting.
|
|
240
|
+
const generation = requestedGeneration ?? 0;
|
|
241
|
+
if (!Number.isSafeInteger(generation) || generation < 0) return null;
|
|
242
|
+
if (current && current.generation > generation) return null;
|
|
243
|
+
if (current && current.generation === generation) {
|
|
244
|
+
if (current.mutation === mutation) {
|
|
245
|
+
return { generation, replay: true };
|
|
246
|
+
}
|
|
247
|
+
// Clearing the exact generation that was just stored is the safe
|
|
248
|
+
// compensation path when the cloud mirror is superseded between host
|
|
249
|
+
// storage and DB attachment. The reverse transition would resurrect a
|
|
250
|
+
// cleared credential and is always rejected.
|
|
251
|
+
if (current.mutation === "set" && mutation === "clear") {
|
|
252
|
+
tx.update(schema.githubCredentialGenerations)
|
|
253
|
+
.set({ mutation: "clear", updatedAt: Date.now() })
|
|
254
|
+
.where(eq(schema.githubCredentialGenerations.userId, userId))
|
|
255
|
+
.run();
|
|
256
|
+
return { generation, replay: false };
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const values = { userId, generation, mutation, updatedAt: Date.now() };
|
|
261
|
+
tx.insert(schema.githubCredentialGenerations)
|
|
262
|
+
.values(values)
|
|
263
|
+
.onConflictDoUpdate({
|
|
264
|
+
target: schema.githubCredentialGenerations.userId,
|
|
265
|
+
set: { generation, mutation, updatedAt: values.updatedAt },
|
|
266
|
+
})
|
|
267
|
+
.run();
|
|
268
|
+
return { generation, replay: false };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function githubCredentialGenerationMatches(
|
|
273
|
+
userId: string,
|
|
274
|
+
generation: number,
|
|
275
|
+
): boolean {
|
|
276
|
+
if (!Number.isSafeInteger(generation) || generation < 0) return false;
|
|
277
|
+
return getDb().transaction((tx) => {
|
|
278
|
+
const fence = tx
|
|
279
|
+
.select({
|
|
280
|
+
generation: schema.githubCredentialGenerations.generation,
|
|
281
|
+
mutation: schema.githubCredentialGenerations.mutation,
|
|
282
|
+
})
|
|
283
|
+
.from(schema.githubCredentialGenerations)
|
|
284
|
+
.where(eq(schema.githubCredentialGenerations.userId, userId))
|
|
285
|
+
.get();
|
|
286
|
+
const token = tx
|
|
287
|
+
.select({ generation: schema.githubTokens.generation })
|
|
288
|
+
.from(schema.githubTokens)
|
|
289
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
290
|
+
.get();
|
|
291
|
+
return (
|
|
292
|
+
fence?.mutation === "set" &&
|
|
293
|
+
fence.generation === generation &&
|
|
294
|
+
token?.generation === generation
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- token store ------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Encrypt + persist the user's token (gh.connect.set handler). ADR-033: a
|
|
303
|
+
* non-expiring grant carries `accessToken` (stored kind="access", injected
|
|
304
|
+
* directly); a legacy expiring grant carries `refreshToken` (kind="refresh",
|
|
305
|
+
* exchanged per task). Exactly one is present.
|
|
306
|
+
*/
|
|
307
|
+
export interface ConnectSetDeps {
|
|
308
|
+
invalidateCredentials?: (userId: string) => Promise<void>;
|
|
309
|
+
readStored?: (
|
|
310
|
+
userId: string,
|
|
311
|
+
) => StoredGitHubGrant | null;
|
|
312
|
+
deleteLocal?: (userId: string) => void;
|
|
313
|
+
storeLocal?: (
|
|
314
|
+
userId: string,
|
|
315
|
+
fields: {
|
|
316
|
+
installationId: number;
|
|
317
|
+
generation: number;
|
|
318
|
+
refreshTokenCt: Buffer;
|
|
319
|
+
refreshTokenNonce: Buffer;
|
|
320
|
+
refreshTokenExpiresAt: number | null;
|
|
321
|
+
kind: string;
|
|
322
|
+
updatedAt: number;
|
|
323
|
+
},
|
|
324
|
+
) => void;
|
|
325
|
+
taskIds?: (userId: string) => string[];
|
|
326
|
+
reconcile?: (taskId: string, userId: string) => Promise<boolean>;
|
|
327
|
+
revoke?: (token: string) => Promise<void>;
|
|
328
|
+
seal?: typeof sealAesGcm;
|
|
329
|
+
notifyChange?: () => void;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function onConnectSet(
|
|
333
|
+
frame: GhConnectSet,
|
|
334
|
+
deps: ConnectSetDeps = {},
|
|
335
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
336
|
+
let oldGrantRemoved = false;
|
|
337
|
+
let replacementStored = false;
|
|
338
|
+
let priorGrant: StoredGitHubGrant | null = null;
|
|
339
|
+
try {
|
|
340
|
+
const token = frame.accessToken ?? frame.refreshToken;
|
|
341
|
+
if (!token) return { ok: false, error: "gh.connect.set carried no token" };
|
|
342
|
+
const kind = frame.accessToken ? "access" : "refresh";
|
|
343
|
+
const credentialGeneration = frame.generation ?? 0;
|
|
344
|
+
const sealed = (deps.seal ?? sealAesGcm)(token);
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
const fields = {
|
|
347
|
+
installationId: frame.installationId,
|
|
348
|
+
generation: credentialGeneration,
|
|
349
|
+
refreshTokenCt: sealed.ct,
|
|
350
|
+
refreshTokenNonce: sealed.nonce,
|
|
351
|
+
refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
|
|
352
|
+
kind,
|
|
353
|
+
updatedAt: now,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
priorGrant = (deps.readStored ?? readStoredToken)(frame.userId);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
// A corrupt superseded row must not prevent replacement. It can still be
|
|
360
|
+
// deleted locally and every container is authoritatively scrubbed below.
|
|
361
|
+
console.warn(
|
|
362
|
+
`[github] user ${frame.userId}: could not read token before replacement: ${
|
|
363
|
+
err instanceof Error ? err.message : err
|
|
364
|
+
}`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// A retry of the exact same operation is safe and still reconciles tasks;
|
|
369
|
+
// reusing one generation for different credential material is not. Without
|
|
370
|
+
// this check a replayed opId/generation could replace the token while the
|
|
371
|
+
// cloud continued to trust the original binding generation.
|
|
372
|
+
if (
|
|
373
|
+
priorGrant?.generation === credentialGeneration &&
|
|
374
|
+
(priorGrant.token !== token ||
|
|
375
|
+
priorGrant.kind !== kind ||
|
|
376
|
+
priorGrant.installationId !== frame.installationId)
|
|
377
|
+
) {
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
error: "conflicting GitHub credential replay for one generation",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Remove the old grant before fencing host-side Git. A task that begins
|
|
385
|
+
// while account switching is in progress must not mint or seed the old
|
|
386
|
+
// user's credential. The replacement is already sealed above, so failures
|
|
387
|
+
// before this point leave the existing connection untouched.
|
|
388
|
+
(deps.deleteLocal ?? deleteStoredTokenRow)(frame.userId);
|
|
389
|
+
oldGrantRemoved = true;
|
|
390
|
+
advanceGithubAuthGeneration(frame.userId);
|
|
391
|
+
|
|
392
|
+
// Account switching must also retire A remotely. Skip when GitHub sent the
|
|
393
|
+
// same access token again, which would otherwise revoke replacement B.
|
|
394
|
+
if (
|
|
395
|
+
priorGrant?.kind === "access" &&
|
|
396
|
+
priorGrant.token !== token
|
|
397
|
+
) {
|
|
398
|
+
await bestEffortRevoke(
|
|
399
|
+
frame.userId,
|
|
400
|
+
priorGrant.token,
|
|
401
|
+
deps.revoke ?? revokeAtGitHub,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Existing task-up operations may still be using the old ephemeral cache.
|
|
406
|
+
// Invalidate and drain them before publishing the replacement grant.
|
|
407
|
+
await deps.invalidateCredentials?.(frame.userId);
|
|
408
|
+
|
|
409
|
+
if (deps.storeLocal) deps.storeLocal(frame.userId, fields);
|
|
410
|
+
else if (signInBoxMode()) {
|
|
411
|
+
// ADR-136 sign-in box: the store first, awaited; a row with no
|
|
412
|
+
// material; the token in the bounded cache only.
|
|
413
|
+
await storeGithubTokenStoreFirst(
|
|
414
|
+
frame.userId,
|
|
415
|
+
{
|
|
416
|
+
installationId: fields.installationId,
|
|
417
|
+
generation: fields.generation,
|
|
418
|
+
refreshTokenExpiresAt: fields.refreshTokenExpiresAt,
|
|
419
|
+
kind,
|
|
420
|
+
updatedAt: now,
|
|
421
|
+
},
|
|
422
|
+
token,
|
|
423
|
+
);
|
|
424
|
+
} else storeTokenRow(frame.userId, fields);
|
|
425
|
+
replacementStored = true;
|
|
426
|
+
// Advance again only after the replacement is durable. This detaches work
|
|
427
|
+
// started during the no-grant fence and lets reconciliation capture B.
|
|
428
|
+
advanceGithubAuthGeneration(frame.userId);
|
|
429
|
+
(deps.notifyChange ?? notifyGithubChange)();
|
|
430
|
+
|
|
431
|
+
// Account-switch acknowledgement is a credential boundary: wait until each
|
|
432
|
+
// container that could retain A has been removed or scrubbed and configured
|
|
433
|
+
// with B. Main injects a task-lifecycle wrapper so a concurrent task-up is
|
|
434
|
+
// completed before this per-task credential transition runs.
|
|
435
|
+
const taskIds = (deps.taskIds ?? credentialCleanupTaskIdsForUser)(
|
|
436
|
+
frame.userId,
|
|
437
|
+
);
|
|
438
|
+
const reconcile = deps.reconcile ?? reconcileTaskGitAuth;
|
|
439
|
+
const settled = await Promise.allSettled(
|
|
440
|
+
taskIds.map((taskId) => reconcile(taskId, frame.userId)),
|
|
441
|
+
);
|
|
442
|
+
const rejected = settled.find(
|
|
443
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
444
|
+
);
|
|
445
|
+
if (rejected) throw rejected.reason;
|
|
446
|
+
if (settled.some((result) => result.status === "fulfilled" && !result.value)) {
|
|
447
|
+
throw new Error("GitHub credential replacement did not reach every task");
|
|
448
|
+
}
|
|
449
|
+
return { ok: true };
|
|
450
|
+
} catch (err) {
|
|
451
|
+
// If the old grant was already removed, advertise the authoritative local
|
|
452
|
+
// state and scrub live containers through the normal no-token reconciler.
|
|
453
|
+
// Never leave a failed account switch looking connected to the old user.
|
|
454
|
+
if (oldGrantRemoved && !replacementStored) {
|
|
455
|
+
(deps.notifyChange ?? notifyGithubChange)();
|
|
456
|
+
const taskIds = (deps.taskIds ?? credentialCleanupTaskIdsForUser)(
|
|
457
|
+
frame.userId,
|
|
458
|
+
);
|
|
459
|
+
const reconcile = deps.reconcile ?? reconcileTaskGitAuth;
|
|
460
|
+
await Promise.allSettled(
|
|
461
|
+
taskIds.map((taskId) => reconcile(taskId, frame.userId)),
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return { ok: false, error: err instanceof Error ? err.message : "store failed" };
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function deleteStoredTokenRow(userId: string): void {
|
|
469
|
+
forgetGithubToken(userId);
|
|
470
|
+
const result = getDb()
|
|
471
|
+
.delete(schema.githubTokens)
|
|
472
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
473
|
+
.run();
|
|
474
|
+
// A connect clears any prior grant first; with none stored there is no
|
|
475
|
+
// parameter to delete either.
|
|
476
|
+
if (result.changes > 0) deleteGithubTokenFromStore(userId);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function storeTokenRow(
|
|
480
|
+
userId: string,
|
|
481
|
+
fields: {
|
|
482
|
+
installationId: number;
|
|
483
|
+
generation: number;
|
|
484
|
+
refreshTokenCt: Buffer;
|
|
485
|
+
refreshTokenNonce: Buffer;
|
|
486
|
+
refreshTokenExpiresAt: number | null;
|
|
487
|
+
kind: string;
|
|
488
|
+
updatedAt: number;
|
|
489
|
+
},
|
|
490
|
+
): void {
|
|
491
|
+
getDb().transaction((tx) => {
|
|
492
|
+
const fence = tx
|
|
493
|
+
.select({
|
|
494
|
+
generation: schema.githubCredentialGenerations.generation,
|
|
495
|
+
mutation: schema.githubCredentialGenerations.mutation,
|
|
496
|
+
})
|
|
497
|
+
.from(schema.githubCredentialGenerations)
|
|
498
|
+
.where(eq(schema.githubCredentialGenerations.userId, userId))
|
|
499
|
+
.get();
|
|
500
|
+
if (
|
|
501
|
+
fence?.mutation !== "set" ||
|
|
502
|
+
fence.generation !== fields.generation
|
|
503
|
+
) {
|
|
504
|
+
throw new Error("GitHub credential generation changed before storage");
|
|
505
|
+
}
|
|
506
|
+
tx.insert(schema.githubTokens)
|
|
507
|
+
.values({ userId, ...fields })
|
|
508
|
+
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
|
|
509
|
+
.run();
|
|
510
|
+
});
|
|
511
|
+
mirrorGithubTokenToStore(userId);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Credential store mirror (ADR-134 slice 2): `<prefix>/users/<userId>/github`.
|
|
516
|
+
// The generation rides along so a replacement host imports the row behind the
|
|
517
|
+
// same durable fence (a later disconnect this host never saw still wins).
|
|
518
|
+
// Owned hosts: no-ops.
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
export interface StoredGithubToken {
|
|
522
|
+
v: 1;
|
|
523
|
+
kind: "github";
|
|
524
|
+
userId: string;
|
|
525
|
+
installationId: number;
|
|
526
|
+
generation: number;
|
|
527
|
+
tokenKind: string;
|
|
528
|
+
token: string;
|
|
529
|
+
expiresAt: number | null;
|
|
530
|
+
updatedAt: number;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function githubTokenParameterName(userId: string): string {
|
|
534
|
+
return userParameterName(userId, "github");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The row as it is mirrored (token decrypted). Never logged. On the
|
|
538
|
+
* sign-in box a store-held row yields the cached token, or null when the
|
|
539
|
+
* cache is cold — there is nothing here to mirror then. */
|
|
540
|
+
export function githubTokenStoreValue(userId: string): StoredGithubToken | null {
|
|
541
|
+
const row = getDb()
|
|
542
|
+
.select()
|
|
543
|
+
.from(schema.githubTokens)
|
|
544
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
545
|
+
.get();
|
|
546
|
+
if (!row) return null;
|
|
547
|
+
const token = isStoreHeldGithubRow(row)
|
|
548
|
+
? cachedGithubToken(userId)
|
|
549
|
+
: openAesGcm(
|
|
550
|
+
Buffer.from(row.refreshTokenCt as Uint8Array),
|
|
551
|
+
Buffer.from(row.refreshTokenNonce as Uint8Array),
|
|
552
|
+
);
|
|
553
|
+
if (token === null) return null;
|
|
554
|
+
return {
|
|
555
|
+
v: 1,
|
|
556
|
+
kind: "github",
|
|
557
|
+
userId,
|
|
558
|
+
installationId: row.installationId,
|
|
559
|
+
generation: row.generation,
|
|
560
|
+
tokenKind: row.kind,
|
|
561
|
+
token,
|
|
562
|
+
expiresAt: row.refreshTokenExpiresAt ?? null,
|
|
563
|
+
updatedAt: row.updatedAt,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function setGithubTokenStoreRef(userId: string, storeRef: string): void {
|
|
568
|
+
getDb()
|
|
569
|
+
.update(schema.githubTokens)
|
|
570
|
+
.set({ storeRef })
|
|
571
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
572
|
+
.run();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function listGithubTokenStoreRows(): Array<{ userId: string; storeRef: string | null }> {
|
|
576
|
+
return getDb()
|
|
577
|
+
.select({ userId: schema.githubTokens.userId, storeRef: schema.githubTokens.storeRef })
|
|
578
|
+
.from(schema.githubTokens)
|
|
579
|
+
.all();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function mirrorGithubTokenToStore(userId: string): void {
|
|
583
|
+
// ADR-136 sign-in box: rows hold no material; the store was written first.
|
|
584
|
+
if (signInBoxMode()) return;
|
|
585
|
+
queueStoreWrite(`mirror github token ${userId}`, async (store) => {
|
|
586
|
+
const value = githubTokenStoreValue(userId);
|
|
587
|
+
if (!value) return;
|
|
588
|
+
const name = githubTokenParameterName(userId);
|
|
589
|
+
await store.put(name, JSON.stringify(value));
|
|
590
|
+
setGithubTokenStoreRef(userId, name);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function deleteGithubTokenFromStore(userId: string): void {
|
|
595
|
+
queueStoreWrite(`delete github token ${userId}`, (store) =>
|
|
596
|
+
store.delete(githubTokenParameterName(userId)),
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Reconcile import: a token this host lacks. Claims the stored generation as
|
|
602
|
+
* a `set` first — refused (false) when this host's fence already moved past
|
|
603
|
+
* it, so a disconnect that outran the store's delete can never resurrect a
|
|
604
|
+
* token here.
|
|
605
|
+
*/
|
|
606
|
+
export function importGithubTokenFromStore(
|
|
607
|
+
value: StoredGithubToken,
|
|
608
|
+
storeRef: string,
|
|
609
|
+
): boolean {
|
|
610
|
+
const claim = claimGithubCredentialGeneration(value.userId, value.generation, "set");
|
|
611
|
+
if (!claim) return false;
|
|
612
|
+
const sealed = sealAesGcm(value.token);
|
|
613
|
+
const fields = {
|
|
614
|
+
installationId: value.installationId,
|
|
615
|
+
generation: value.generation,
|
|
616
|
+
refreshTokenCt: sealed.ct,
|
|
617
|
+
refreshTokenNonce: sealed.nonce,
|
|
618
|
+
refreshTokenExpiresAt: value.expiresAt,
|
|
619
|
+
kind: value.tokenKind,
|
|
620
|
+
updatedAt: value.updatedAt,
|
|
621
|
+
storeRef,
|
|
622
|
+
};
|
|
623
|
+
getDb()
|
|
624
|
+
.insert(schema.githubTokens)
|
|
625
|
+
.values({ userId: value.userId, ...fields })
|
|
626
|
+
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
|
|
627
|
+
.run();
|
|
628
|
+
notifyGithubChange();
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function bestEffortRevoke(
|
|
633
|
+
userId: string,
|
|
634
|
+
token: string,
|
|
635
|
+
revoke: (token: string) => Promise<void>,
|
|
636
|
+
): Promise<void> {
|
|
637
|
+
try {
|
|
638
|
+
await revoke(token);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
console.warn(
|
|
641
|
+
`[github] user ${userId}: token revoke failed after local removal: ${
|
|
642
|
+
err instanceof Error ? err.message : err
|
|
643
|
+
}`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Disconnect GitHub on this host (gh.connect.clear handler). The surrounding
|
|
650
|
+
* claimed `clear` generation makes the stored row inert immediately. Keep that
|
|
651
|
+
* row recoverable until every live container has dropped the credential and
|
|
652
|
+
* selected its disconnected transport; only then delete it and best-effort
|
|
653
|
+
* revoke remotely. A runtime loss can therefore reject without turning a
|
|
654
|
+
* retryable scrub into an irreversible half-transition.
|
|
655
|
+
*
|
|
656
|
+
* ADR-033: only a non-expiring ACCESS token can be revoked by token
|
|
657
|
+
* (`DELETE /applications/{client_id}/token` matches access tokens only — a
|
|
658
|
+
* refresh token 404s, which the cloud endpoint would mis-report as success).
|
|
659
|
+
* Legacy refresh tokens aren't single-token revocable; the short-lived access
|
|
660
|
+
* tokens they mint expire on their own.
|
|
661
|
+
*/
|
|
662
|
+
export interface ConnectClearDeps {
|
|
663
|
+
readStored?: (
|
|
664
|
+
userId: string,
|
|
665
|
+
) => StoredGitHubGrant | null;
|
|
666
|
+
deleteLocal?: (userId: string) => void;
|
|
667
|
+
activeTaskIds?: (userId: string) => string[];
|
|
668
|
+
reconcile?: (taskId: string, userId: string) => Promise<boolean>;
|
|
669
|
+
revoke?: (token: string) => Promise<void>;
|
|
670
|
+
invalidateCredentials?: (userId: string) => Promise<void>;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export async function onConnectClear(
|
|
674
|
+
userId: string,
|
|
675
|
+
deps: ConnectClearDeps = {},
|
|
676
|
+
): Promise<void> {
|
|
677
|
+
let stored: StoredGitHubGrant | null = null;
|
|
678
|
+
try {
|
|
679
|
+
stored = (deps.readStored ?? readStoredToken)(userId);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
// A corrupt legacy row must not make Disconnect impossible. We can still
|
|
682
|
+
// delete it locally and clean every live container; only remote revocation
|
|
683
|
+
// is unavailable because the token could not be decrypted.
|
|
684
|
+
console.warn(
|
|
685
|
+
`[github] user ${userId}: could not read token before disconnect: ${
|
|
686
|
+
err instanceof Error ? err.message : err
|
|
687
|
+
}`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Incrementing the host-Git generation prevents any not-yet-started use of
|
|
692
|
+
// an old prepared handle. Await already-started task-up operations before
|
|
693
|
+
// discovering and scrubbing containers: a task may create its app container
|
|
694
|
+
// while Disconnect is in flight.
|
|
695
|
+
await deps.invalidateCredentials?.(userId);
|
|
696
|
+
|
|
697
|
+
const activeTasks = (deps.activeTaskIds ?? credentialCleanupTaskIdsForUser)(
|
|
698
|
+
userId,
|
|
699
|
+
);
|
|
700
|
+
for (const taskId of activeTasks) {
|
|
701
|
+
clearRefresh(taskId);
|
|
702
|
+
}
|
|
703
|
+
const reconcile = deps.reconcile ?? reconcileTaskGitAuth;
|
|
704
|
+
const settled = await Promise.allSettled(
|
|
705
|
+
activeTasks.map((taskId) => reconcile(taskId, userId)),
|
|
706
|
+
);
|
|
707
|
+
const failed = settled.find(
|
|
708
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
709
|
+
);
|
|
710
|
+
if (failed) throw failed.reason;
|
|
711
|
+
|
|
712
|
+
// The claimed clear already removed this generation from every authority
|
|
713
|
+
// read. Delete the recoverable ciphertext only after all container scrubs
|
|
714
|
+
// crossed their durable boundary, so the same-generation operation can be
|
|
715
|
+
// retried if Docker disappeared mid-transition.
|
|
716
|
+
(deps.deleteLocal ?? deleteToken)(userId);
|
|
717
|
+
|
|
718
|
+
// Await best-effort remote revocation while the caller still owns the
|
|
719
|
+
// per-user transition lock. revokeAtGitHub catches its own failures; the
|
|
720
|
+
// wrapper also contains failures from injected test/alternate implementations.
|
|
721
|
+
if (stored?.kind === "access") {
|
|
722
|
+
await bestEffortRevoke(
|
|
723
|
+
userId,
|
|
724
|
+
stored.token,
|
|
725
|
+
deps.revoke ?? revokeAtGitHub,
|
|
726
|
+
);
|
|
727
|
+
} else if (stored) {
|
|
728
|
+
console.warn(
|
|
729
|
+
`[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Fail-closed rollback for a generation that was claimed for set but could
|
|
735
|
+
* not be fully reconciled. The tombstone transition happens before cleanup is
|
|
736
|
+
* awaited, so task/token readers stop trusting the credential immediately. */
|
|
737
|
+
export async function rollbackClaimedGitHubCredentialSet(
|
|
738
|
+
userId: string,
|
|
739
|
+
generation: number,
|
|
740
|
+
deps: ConnectClearDeps = {},
|
|
741
|
+
): Promise<boolean> {
|
|
742
|
+
const rollback = claimGithubCredentialGeneration(
|
|
743
|
+
userId,
|
|
744
|
+
generation,
|
|
745
|
+
"clear",
|
|
746
|
+
);
|
|
747
|
+
if (!rollback) return false;
|
|
748
|
+
try {
|
|
749
|
+
await onConnectClear(userId, deps);
|
|
750
|
+
} catch (err) {
|
|
751
|
+
console.warn(
|
|
752
|
+
`[github] user ${userId}: claimed set rollback cleanup failed: ${
|
|
753
|
+
err instanceof Error ? err.message : err
|
|
754
|
+
}`,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
interface StoredGitHubGrant {
|
|
761
|
+
token: string;
|
|
762
|
+
kind: string;
|
|
763
|
+
installationId?: number;
|
|
764
|
+
generation?: number;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
type PersistedGitHubGrant = StoredGitHubGrant & {
|
|
768
|
+
installationId: number;
|
|
769
|
+
generation: number;
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
/** Decrypt any stored token, including an inert row from an older generation.
|
|
773
|
+
* Transition handlers need the old ciphertext solely so they can revoke it;
|
|
774
|
+
* task and verification readers use `readCurrentStoredToken` below. */
|
|
775
|
+
function readStoredToken(
|
|
776
|
+
userId: string,
|
|
777
|
+
): PersistedGitHubGrant | null {
|
|
778
|
+
const row = getDb()
|
|
779
|
+
.select()
|
|
780
|
+
.from(schema.githubTokens)
|
|
781
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
782
|
+
.get();
|
|
783
|
+
if (!row) return null;
|
|
784
|
+
// ADR-136 sign-in box: a store-held row answers from the bounded cache
|
|
785
|
+
// (filled by `loadGithubTokenIntoCache` on the async paths) or not at all.
|
|
786
|
+
const token = isStoreHeldGithubRow(row)
|
|
787
|
+
? cachedGithubToken(userId)
|
|
788
|
+
: openAesGcm(
|
|
789
|
+
Buffer.from(row.refreshTokenCt as Uint8Array),
|
|
790
|
+
Buffer.from(row.refreshTokenNonce as Uint8Array),
|
|
791
|
+
);
|
|
792
|
+
if (token === null) return null;
|
|
793
|
+
return {
|
|
794
|
+
token,
|
|
795
|
+
kind: row.kind,
|
|
796
|
+
installationId: row.installationId,
|
|
797
|
+
generation: row.generation,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function readCurrentStoredToken(userId: string): PersistedGitHubGrant | null {
|
|
802
|
+
const stored = readStoredToken(userId);
|
|
803
|
+
return stored &&
|
|
804
|
+
githubCredentialGenerationMatches(userId, stored.generation)
|
|
805
|
+
? stored
|
|
806
|
+
: null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export function deleteToken(userId: string): void {
|
|
810
|
+
forgetGithubToken(userId);
|
|
811
|
+
getDb()
|
|
812
|
+
.delete(schema.githubTokens)
|
|
813
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
814
|
+
.run();
|
|
815
|
+
deleteGithubTokenFromStore(userId);
|
|
816
|
+
advanceGithubAuthGeneration(userId);
|
|
817
|
+
notifyGithubChange();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function hasToken(userId: string): boolean {
|
|
821
|
+
// Metadata only: a store-held row (sign-in box) counts as connected whether
|
|
822
|
+
// or not its token is in the cache right now.
|
|
823
|
+
const row = getDb()
|
|
824
|
+
.select({ generation: schema.githubTokens.generation })
|
|
825
|
+
.from(schema.githubTokens)
|
|
826
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
827
|
+
.get();
|
|
828
|
+
return row !== undefined && githubCredentialGenerationMatches(userId, row.generation);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** All cloud user ids with a GitHub token on this host (for capabilities). */
|
|
832
|
+
export function connectedUserIds(): string[] {
|
|
833
|
+
return getDb()
|
|
834
|
+
.select({ userId: schema.githubTokens.userId })
|
|
835
|
+
.from(schema.githubTokens)
|
|
836
|
+
.innerJoin(
|
|
837
|
+
schema.githubCredentialGenerations,
|
|
838
|
+
and(
|
|
839
|
+
eq(
|
|
840
|
+
schema.githubCredentialGenerations.userId,
|
|
841
|
+
schema.githubTokens.userId,
|
|
842
|
+
),
|
|
843
|
+
eq(
|
|
844
|
+
schema.githubCredentialGenerations.generation,
|
|
845
|
+
schema.githubTokens.generation,
|
|
846
|
+
),
|
|
847
|
+
),
|
|
848
|
+
)
|
|
849
|
+
.where(eq(schema.githubCredentialGenerations.mutation, "set"))
|
|
850
|
+
.all()
|
|
851
|
+
.map((r) => r.userId);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Fires whenever the set of stored tokens changes (add/remove), so the host
|
|
855
|
+
// can re-advertise capabilities and the UI reflects per-host gh state promptly.
|
|
856
|
+
let githubChangeHandler: (() => void) | null = null;
|
|
857
|
+
export function onGithubChange(fn: (() => void) | null): void {
|
|
858
|
+
githubChangeHandler = fn;
|
|
859
|
+
}
|
|
860
|
+
function notifyGithubChange(): void {
|
|
861
|
+
try {
|
|
862
|
+
githubChangeHandler?.();
|
|
863
|
+
} catch {
|
|
864
|
+
/* never let a listener break token handling */
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export function taskMayRetainGithubCredential(task: {
|
|
869
|
+
endedAt: number | null;
|
|
870
|
+
composeProject: string | null;
|
|
871
|
+
statusMirror: string | null;
|
|
872
|
+
}): boolean {
|
|
873
|
+
// Active/starting rows may gain a container while Disconnect is running.
|
|
874
|
+
// Ended rows need cleanup only when their compose identity is preserved;
|
|
875
|
+
// task-down clears composeProject after destroying the stack. A failed
|
|
876
|
+
// task-up can leave a conventional app container before compose metadata was
|
|
877
|
+
// persisted, so error rows are probed by task id as well.
|
|
878
|
+
return (
|
|
879
|
+
task.endedAt == null ||
|
|
880
|
+
task.composeProject != null ||
|
|
881
|
+
task.statusMirror === "error"
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function taskCredentialRowsForUser(userId: string) {
|
|
886
|
+
return getDb()
|
|
887
|
+
.select({
|
|
888
|
+
taskId: schema.hostTasks.taskId,
|
|
889
|
+
endedAt: schema.hostTasks.endedAt,
|
|
890
|
+
composeProject: schema.hostTasks.composeProject,
|
|
891
|
+
statusMirror: schema.hostTasks.statusMirror,
|
|
892
|
+
})
|
|
893
|
+
.from(schema.hostTasks)
|
|
894
|
+
.where(eq(schema.hostTasks.ownerUserId, userId))
|
|
895
|
+
.all();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function credentialCleanupTaskIdsForUser(userId: string): string[] {
|
|
899
|
+
return taskCredentialRowsForUser(userId)
|
|
900
|
+
.filter(taskMayRetainGithubCredential)
|
|
901
|
+
.map((r) => r.taskId);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function runningTaskIdsForUser(userId: string): string[] {
|
|
905
|
+
return taskCredentialRowsForUser(userId)
|
|
906
|
+
.filter((task) => task.endedAt == null)
|
|
907
|
+
.map((task) => task.taskId);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Re-mint + inject a (re)connected token into the user's live tasks — so a
|
|
912
|
+
* reconnect on Account applies to running containers, not only new tasks. Clear
|
|
913
|
+
* any armed retry chain first (a fresh grant must not be swallowed by an outage
|
|
914
|
+
* backoff, and `setupTaskGithub`'s duplicate-chain guard would no-op it) then
|
|
915
|
+
* run setup fresh. Fire-and-forget per task; best-effort by construction.
|
|
916
|
+
*/
|
|
917
|
+
export function reinjectRunningTasks(userId: string): void {
|
|
918
|
+
for (const taskId of runningTaskIdsForUser(userId)) {
|
|
919
|
+
clearRefresh(taskId);
|
|
920
|
+
void reconcileTaskGitAuth(taskId, userId).catch((err) =>
|
|
921
|
+
console.warn(
|
|
922
|
+
`[github] task ${taskId}: credential reconciliation failed: ${
|
|
923
|
+
err instanceof Error ? err.message : err
|
|
924
|
+
}`,
|
|
925
|
+
),
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// --- access-token exchange --------------------------------------------------
|
|
931
|
+
|
|
932
|
+
function cloudHttpBase(): string {
|
|
933
|
+
const cloud = process.env.UAI_CLOUD_URL ?? "ws://127.0.0.1:8789/host";
|
|
934
|
+
const u = new URL(cloud);
|
|
935
|
+
const proto =
|
|
936
|
+
u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
|
|
937
|
+
return `${proto}//${u.host}`;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function exchangeUrl(): string {
|
|
941
|
+
return `${cloudHttpBase()}/api/github/oauth/exchange`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Revoke a token at GitHub via the cloud (which holds the App client secret —
|
|
946
|
+
* the host can't revoke alone). Same host→cloud HTTP pattern as the exchange;
|
|
947
|
+
* the token transits the cloud transiently, never stored (ADR-015/ADR-033).
|
|
948
|
+
* Best-effort — callers proceed to delete locally regardless.
|
|
949
|
+
*/
|
|
950
|
+
async function revokeAtGitHub(token: string): Promise<void> {
|
|
951
|
+
try {
|
|
952
|
+
const res = await fetch(`${cloudHttpBase()}/api/github/revoke`, {
|
|
953
|
+
method: "POST",
|
|
954
|
+
headers: {
|
|
955
|
+
"content-type": "application/json",
|
|
956
|
+
"x-uai-host-token": process.env.UAI_HOST_TOKEN ?? "",
|
|
957
|
+
},
|
|
958
|
+
body: JSON.stringify({ token }),
|
|
959
|
+
signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
|
|
960
|
+
});
|
|
961
|
+
if (!res.ok) {
|
|
962
|
+
console.warn(`[github] token revoke returned HTTP ${res.status}`);
|
|
963
|
+
}
|
|
964
|
+
} catch (err) {
|
|
965
|
+
console.warn(
|
|
966
|
+
`[github] token revoke failed (deleting locally anyway): ${err instanceof Error ? err.message : err}`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
interface ExchangeResult {
|
|
972
|
+
accessToken: string;
|
|
973
|
+
expiresAt: number;
|
|
974
|
+
refreshToken: string | null;
|
|
975
|
+
refreshTokenExpiresAt: number | null;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Mint a fresh access token for the user via the cloud exchange. Persists a
|
|
980
|
+
* rotated refresh token if GitHub returned one. Returns null when the user has
|
|
981
|
+
* no token on this host. Throws on exchange failure.
|
|
982
|
+
*
|
|
983
|
+
* Deduped PER USER: GitHub refresh tokens are single-use (each exchange
|
|
984
|
+
* rotates them), and GitHub treats reuse of a consumed token as a compromise
|
|
985
|
+
* signal that can kill the whole grant. Concurrent task setups for the same
|
|
986
|
+
* user (several task-ups at once, or every channel re-ensure after a host
|
|
987
|
+
* restart) used to race the rotation — the loser burned the grant and every
|
|
988
|
+
* exchange afterwards failed until a manual re-connect. The access token is
|
|
989
|
+
* per-user anyway, so concurrent callers share one in-flight exchange.
|
|
990
|
+
*/
|
|
991
|
+
const inflightExchanges = new Map<
|
|
992
|
+
string,
|
|
993
|
+
{
|
|
994
|
+
generation: number;
|
|
995
|
+
promise: Promise<{ accessToken: string; expiresAt: number } | null>;
|
|
996
|
+
}
|
|
997
|
+
>();
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Per-user ACCESS-token cache. Access tokens live ~8h, but every exchange
|
|
1001
|
+
* ROTATES the single-use refresh token — and a rotation whose response is
|
|
1002
|
+
* lost mid-flight (our timeout firing while GitHub already rotated) burns the
|
|
1003
|
+
* grant, forcing a manual re-connect. So exchange as rarely as possible: all
|
|
1004
|
+
* task setups and refresh timers for a user share one cached access token
|
|
1005
|
+
* until it nears expiry. The refresh timers fire at expiry−5min, inside the
|
|
1006
|
+
* 15min stale window, so exactly one of them performs the real exchange (the
|
|
1007
|
+
* in-flight dedupe serializes any ties) and the rest re-inject from cache.
|
|
1008
|
+
*/
|
|
1009
|
+
const ACCESS_CACHE_LEAD_MS = 15 * 60 * 1000;
|
|
1010
|
+
const accessCache = new Map<
|
|
1011
|
+
string,
|
|
1012
|
+
{ accessToken: string; expiresAt: number }
|
|
1013
|
+
>();
|
|
1014
|
+
|
|
1015
|
+
const INSTALLATION_PAGE_SIZE = 100;
|
|
1016
|
+
const REPOSITORY_PAGE_SIZE = 100;
|
|
1017
|
+
const REPOSITORY_PAGE_CAP = Math.ceil(
|
|
1018
|
+
MAX_GITHUB_REPOSITORIES_PER_INSTALLATION / REPOSITORY_PAGE_SIZE,
|
|
1019
|
+
);
|
|
1020
|
+
const GITHUB_LIST_TIMEOUT_MS = 10_000;
|
|
1021
|
+
// Bound only the GitHub listing phase. The installation bridge allows 60s so a
|
|
1022
|
+
// legacy refresh-token exchange (up to 30s) can precede this 25s window; the
|
|
1023
|
+
// repository bridge uses 30s because the complete installation call has just
|
|
1024
|
+
// warmed that user's access-token cache. Every REST page gets the smaller of
|
|
1025
|
+
// its normal timeout and the remaining listing budget.
|
|
1026
|
+
const GITHUB_LIST_OPERATION_TIMEOUT_MS = 25_000;
|
|
1027
|
+
|
|
1028
|
+
function githubListSignal(deadline: number): AbortSignal {
|
|
1029
|
+
const remaining = deadline - Date.now();
|
|
1030
|
+
if (remaining <= 0) throw new Error("github list operation timed out");
|
|
1031
|
+
return AbortSignal.timeout(Math.min(GITHUB_LIST_TIMEOUT_MS, remaining));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
export class GitHubRepositoryAccessError extends Error {
|
|
1035
|
+
constructor(
|
|
1036
|
+
readonly code: GitHubRepositoryAccessErrorCode,
|
|
1037
|
+
message: string,
|
|
1038
|
+
) {
|
|
1039
|
+
super(message);
|
|
1040
|
+
this.name = "GitHubRepositoryAccessError";
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
interface GitHubListDeps {
|
|
1045
|
+
requestAccessToken?: typeof requestAccessToken;
|
|
1046
|
+
fetch?: typeof fetch;
|
|
1047
|
+
credentialGenerationMatches?: typeof githubCredentialGenerationMatches;
|
|
1048
|
+
/** When the whole operation must end (the token's read included). Absent,
|
|
1049
|
+
* the listing alone gets its own bound once a token is in hand. */
|
|
1050
|
+
deadlineAt?: number;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* The token could not be read where it is kept (the sign-in box's store):
|
|
1055
|
+
* a fixed message, and a transient code, never the store's own words.
|
|
1056
|
+
*/
|
|
1057
|
+
export class GitHubTokenUnreadableError extends Error {
|
|
1058
|
+
constructor() {
|
|
1059
|
+
super("the GitHub sign-in could not be read");
|
|
1060
|
+
this.name = "GitHubTokenUnreadableError";
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Every installation GitHub currently lists for this user, read with the token
|
|
1067
|
+
* this host already holds — the host-side half of `gh.installations.list`.
|
|
1068
|
+
*
|
|
1069
|
+
* This is what lets a misbound installation row be pruned without the user
|
|
1070
|
+
* reconnecting: the cloud mirror predates `(user_id, installation_id)` keying
|
|
1071
|
+
* and cannot be trusted, but the host can ask GitHub on the user's behalf at
|
|
1072
|
+
* any time, with no browser round trip.
|
|
1073
|
+
*
|
|
1074
|
+
* THROWS rather than returning a partial list. The caller prunes bindings that
|
|
1075
|
+
* are absent from the result, so a truncated or failed read would delete
|
|
1076
|
+
* legitimate grants — absence is only meaningful in a list we fully retrieved.
|
|
1077
|
+
*/
|
|
1078
|
+
export async function listUserInstallationIds(
|
|
1079
|
+
userId: string,
|
|
1080
|
+
deps: GitHubListDeps = {},
|
|
1081
|
+
generation = 0,
|
|
1082
|
+
): Promise<number[]> {
|
|
1083
|
+
if (
|
|
1084
|
+
!(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
|
|
1085
|
+
userId,
|
|
1086
|
+
generation,
|
|
1087
|
+
)
|
|
1088
|
+
) {
|
|
1089
|
+
throw new GitHubRepositoryAccessError(
|
|
1090
|
+
"reauth_required",
|
|
1091
|
+
"the host GitHub credential belongs to a different binding generation",
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
let tok: Awaited<ReturnType<typeof requestAccessToken>>;
|
|
1095
|
+
try {
|
|
1096
|
+
tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
if (err instanceof GitHubTokenUnreadableError) {
|
|
1099
|
+
throw new GitHubRepositoryAccessError("github_unavailable", err.message);
|
|
1100
|
+
}
|
|
1101
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1102
|
+
throw new GitHubRepositoryAccessError(
|
|
1103
|
+
isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
|
|
1104
|
+
reason,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
if (!tok) {
|
|
1108
|
+
throw new GitHubRepositoryAccessError(
|
|
1109
|
+
"token_missing",
|
|
1110
|
+
`no github token stored for user ${userId}`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
const ids = new Set<number>();
|
|
1114
|
+
const deadline = deps.deadlineAt ?? Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
|
|
1115
|
+
let res: Response;
|
|
1116
|
+
try {
|
|
1117
|
+
res = await (deps.fetch ?? fetch)(
|
|
1118
|
+
`${githubApiOrigin()}/user/installations` +
|
|
1119
|
+
`?per_page=${INSTALLATION_PAGE_SIZE}&page=1`,
|
|
1120
|
+
{
|
|
1121
|
+
headers: {
|
|
1122
|
+
authorization: `Bearer ${tok.accessToken}`,
|
|
1123
|
+
accept: "application/vnd.github+json",
|
|
1124
|
+
"user-agent": "uai-host",
|
|
1125
|
+
},
|
|
1126
|
+
signal: githubListSignal(deadline),
|
|
1127
|
+
},
|
|
1128
|
+
);
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
throw new GitHubRepositoryAccessError(
|
|
1131
|
+
"github_unavailable",
|
|
1132
|
+
err instanceof Error ? err.message : String(err),
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
if (!res.ok) {
|
|
1136
|
+
throw new GitHubRepositoryAccessError(
|
|
1137
|
+
res.status === 401 ? "reauth_required" : "github_unavailable",
|
|
1138
|
+
`github installation list failed: HTTP ${res.status}`,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
let data: { total_count?: unknown; installations?: unknown };
|
|
1142
|
+
try {
|
|
1143
|
+
data = (await res.json()) as {
|
|
1144
|
+
total_count?: unknown;
|
|
1145
|
+
installations?: unknown;
|
|
1146
|
+
};
|
|
1147
|
+
} catch {
|
|
1148
|
+
throw new GitHubRepositoryAccessError(
|
|
1149
|
+
"invalid_response",
|
|
1150
|
+
"github installation list returned invalid json",
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
if (
|
|
1154
|
+
!Number.isSafeInteger(data.total_count) ||
|
|
1155
|
+
(data.total_count as number) < 0 ||
|
|
1156
|
+
(data.total_count as number) > MAX_GITHUB_INSTALLATIONS ||
|
|
1157
|
+
!Array.isArray(data.installations) ||
|
|
1158
|
+
data.installations.length !== data.total_count
|
|
1159
|
+
) {
|
|
1160
|
+
throw new GitHubRepositoryAccessError(
|
|
1161
|
+
"invalid_response",
|
|
1162
|
+
"github installation list returned an incomplete body",
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
for (const inst of data.installations) {
|
|
1166
|
+
// Reject the whole response rather than skipping the entry. The caller
|
|
1167
|
+
// prunes bindings absent from this list, so a silently dropped or duplicate
|
|
1168
|
+
// id is indistinguishable from a complete GitHub verdict.
|
|
1169
|
+
if (!inst || typeof inst !== "object") {
|
|
1170
|
+
throw new GitHubRepositoryAccessError(
|
|
1171
|
+
"invalid_response",
|
|
1172
|
+
"github returned a malformed installation entry",
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
const id = (inst as { id?: unknown }).id;
|
|
1176
|
+
if (
|
|
1177
|
+
!Number.isSafeInteger(id) ||
|
|
1178
|
+
(id as number) <= 0 ||
|
|
1179
|
+
ids.has(id as number)
|
|
1180
|
+
) {
|
|
1181
|
+
throw new GitHubRepositoryAccessError(
|
|
1182
|
+
"invalid_response",
|
|
1183
|
+
"github returned a malformed or duplicate installation id",
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
ids.add(id as number);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// A newer set/clear may have landed while GitHub was answering. Returning
|
|
1190
|
+
// the old actor's complete list after that point would let the cloud prune
|
|
1191
|
+
// bindings under the newer credential, so re-check at the publication edge.
|
|
1192
|
+
if (
|
|
1193
|
+
!(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
|
|
1194
|
+
userId,
|
|
1195
|
+
generation,
|
|
1196
|
+
)
|
|
1197
|
+
) {
|
|
1198
|
+
throw new GitHubRepositoryAccessError(
|
|
1199
|
+
"reauth_required",
|
|
1200
|
+
"the host GitHub credential changed during installation verification",
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return [...ids];
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
export async function listUserInstallationRepositoryIds(
|
|
1208
|
+
userId: string,
|
|
1209
|
+
installationId: number,
|
|
1210
|
+
deps: GitHubListDeps = {},
|
|
1211
|
+
generation = 0,
|
|
1212
|
+
): Promise<{ repositoryIds: number[]; truncated: boolean }> {
|
|
1213
|
+
if (
|
|
1214
|
+
!(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
|
|
1215
|
+
userId,
|
|
1216
|
+
generation,
|
|
1217
|
+
)
|
|
1218
|
+
) {
|
|
1219
|
+
throw new GitHubRepositoryAccessError(
|
|
1220
|
+
"reauth_required",
|
|
1221
|
+
"the host GitHub credential belongs to a different binding generation",
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
let tok: Awaited<ReturnType<typeof requestAccessToken>>;
|
|
1225
|
+
try {
|
|
1226
|
+
tok = await (deps.requestAccessToken ?? requestAccessToken)(userId);
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
if (err instanceof GitHubTokenUnreadableError) {
|
|
1229
|
+
throw new GitHubRepositoryAccessError("github_unavailable", err.message);
|
|
1230
|
+
}
|
|
1231
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1232
|
+
throw new GitHubRepositoryAccessError(
|
|
1233
|
+
isTransientGithubError(reason) ? "github_unavailable" : "reauth_required",
|
|
1234
|
+
reason,
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
if (!tok) {
|
|
1238
|
+
throw new GitHubRepositoryAccessError(
|
|
1239
|
+
"token_missing",
|
|
1240
|
+
`no github token stored for user ${userId}`,
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
const repositoryIds = new Set<number>();
|
|
1244
|
+
let truncated = false;
|
|
1245
|
+
const deadline = deps.deadlineAt ?? Date.now() + GITHUB_LIST_OPERATION_TIMEOUT_MS;
|
|
1246
|
+
for (let page = 1; page <= REPOSITORY_PAGE_CAP; page++) {
|
|
1247
|
+
let res: Response;
|
|
1248
|
+
try {
|
|
1249
|
+
res = await (deps.fetch ?? fetch)(
|
|
1250
|
+
`${githubApiOrigin()}/user/installations/${installationId}/repositories` +
|
|
1251
|
+
`?per_page=${REPOSITORY_PAGE_SIZE}&page=${page}`,
|
|
1252
|
+
{
|
|
1253
|
+
headers: {
|
|
1254
|
+
authorization: `Bearer ${tok.accessToken}`,
|
|
1255
|
+
accept: "application/vnd.github+json",
|
|
1256
|
+
"user-agent": "uai-host",
|
|
1257
|
+
},
|
|
1258
|
+
signal: githubListSignal(deadline),
|
|
1259
|
+
},
|
|
1260
|
+
);
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
throw new GitHubRepositoryAccessError(
|
|
1263
|
+
"github_unavailable",
|
|
1264
|
+
err instanceof Error ? err.message : String(err),
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
if (!res.ok) {
|
|
1268
|
+
const code: GitHubRepositoryAccessErrorCode =
|
|
1269
|
+
res.status === 401
|
|
1270
|
+
? "reauth_required"
|
|
1271
|
+
: res.status === 404
|
|
1272
|
+
? "installation_inaccessible"
|
|
1273
|
+
: "github_unavailable";
|
|
1274
|
+
throw new GitHubRepositoryAccessError(
|
|
1275
|
+
code,
|
|
1276
|
+
`github repository list failed for installation ${installationId}: HTTP ${res.status}`,
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
let data: { repositories?: unknown };
|
|
1280
|
+
try {
|
|
1281
|
+
data = (await res.json()) as { repositories?: unknown };
|
|
1282
|
+
} catch {
|
|
1283
|
+
throw new GitHubRepositoryAccessError(
|
|
1284
|
+
"invalid_response",
|
|
1285
|
+
"github repository list returned invalid json",
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
if (!Array.isArray(data.repositories)) {
|
|
1289
|
+
throw new GitHubRepositoryAccessError(
|
|
1290
|
+
"invalid_response",
|
|
1291
|
+
"github repository list returned an invalid body",
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
const batch = data.repositories;
|
|
1295
|
+
for (const repo of batch) {
|
|
1296
|
+
if (!repo || typeof repo !== "object") {
|
|
1297
|
+
// Repository results are an allowlist. Omitting an unparseable entry
|
|
1298
|
+
// can only withhold access, so preserve the valid IDs but make the
|
|
1299
|
+
// partial result explicit. (Installation-list entries are different:
|
|
1300
|
+
// omission there could drive a destructive prune and must throw.)
|
|
1301
|
+
truncated = true;
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
const id = (repo as { id?: unknown }).id;
|
|
1305
|
+
if (!Number.isSafeInteger(id) || (id as number) <= 0) {
|
|
1306
|
+
truncated = true;
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
repositoryIds.add(id as number);
|
|
1310
|
+
}
|
|
1311
|
+
if (batch.length < REPOSITORY_PAGE_SIZE) break;
|
|
1312
|
+
if (page === REPOSITORY_PAGE_CAP) truncated = true;
|
|
1313
|
+
}
|
|
1314
|
+
if (
|
|
1315
|
+
!(deps.credentialGenerationMatches ?? githubCredentialGenerationMatches)(
|
|
1316
|
+
userId,
|
|
1317
|
+
generation,
|
|
1318
|
+
)
|
|
1319
|
+
) {
|
|
1320
|
+
throw new GitHubRepositoryAccessError(
|
|
1321
|
+
"reauth_required",
|
|
1322
|
+
"the host GitHub credential changed during repository verification",
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
return {
|
|
1326
|
+
repositoryIds: [...repositoryIds].slice(
|
|
1327
|
+
0,
|
|
1328
|
+
MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
|
|
1329
|
+
),
|
|
1330
|
+
truncated:
|
|
1331
|
+
truncated || repositoryIds.size > MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Test hook: reset the access-token cache. */
|
|
1336
|
+
export function clearAllAccessCache(): void {
|
|
1337
|
+
accessCache.clear();
|
|
1338
|
+
inflightExchanges.clear();
|
|
1339
|
+
githubAuthGenerations.clear();
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* ADR-154 §13, the cutover purge, once the token rows are gone: no GitHub
|
|
1344
|
+
* token stays in this process. Each user with a cached access token or an
|
|
1345
|
+
* exchange under way moves to a new generation, as at a sign-out, which
|
|
1346
|
+
* drops both; an exchange that ends later then caches and stores nothing.
|
|
1347
|
+
* The sign-in box's cache goes too. Unlike the test hook above, no
|
|
1348
|
+
* generation goes back, so nothing fenced off before is let in again.
|
|
1349
|
+
*/
|
|
1350
|
+
export function forgetGithubTokensInMemory(): void {
|
|
1351
|
+
for (const userId of new Set([...accessCache.keys(), ...inflightExchanges.keys()])) {
|
|
1352
|
+
advanceGithubAuthGeneration(userId);
|
|
1353
|
+
}
|
|
1354
|
+
boxTokens.clear();
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
export async function requestAccessToken(
|
|
1358
|
+
userId: string,
|
|
1359
|
+
): Promise<{ accessToken: string; expiresAt: number | null } | null> {
|
|
1360
|
+
// ADR-136 sign-in box: the token is read from the store on demand for
|
|
1361
|
+
// the installation listing and a legacy exchange, never kept on disk.
|
|
1362
|
+
if (signInBoxMode()) {
|
|
1363
|
+
try {
|
|
1364
|
+
await loadGithubTokenIntoCache(userId);
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
console.warn(
|
|
1367
|
+
`[github] user ${userId}: could not read the token from the credential store: ${err instanceof Error ? err.message : String(err)}`,
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
const generation = githubAuthGeneration(userId);
|
|
1372
|
+
// ADR-033 non-expiring path: the stored token IS the access token — return it
|
|
1373
|
+
// directly, no exchange/cache/rotation. `expiresAt: null` ⇒ no refresh.
|
|
1374
|
+
const row = readCurrentStoredToken(userId);
|
|
1375
|
+
if (!row) return Promise.resolve(null);
|
|
1376
|
+
if (row.kind === "access") {
|
|
1377
|
+
return Promise.resolve({ accessToken: row.token, expiresAt: null });
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Legacy expiring path (ADR-027): cache + in-flight dedupe + exchange.
|
|
1381
|
+
const cached = accessCache.get(userId);
|
|
1382
|
+
if (cached && cached.expiresAt - Date.now() > ACCESS_CACHE_LEAD_MS) {
|
|
1383
|
+
return Promise.resolve(cached);
|
|
1384
|
+
}
|
|
1385
|
+
const existing = inflightExchanges.get(userId);
|
|
1386
|
+
if (existing?.generation === generation) return existing.promise;
|
|
1387
|
+
let run!: Promise<{ accessToken: string; expiresAt: number } | null>;
|
|
1388
|
+
run = doRequestAccessToken(userId, generation)
|
|
1389
|
+
.then((tok) => {
|
|
1390
|
+
if (tok && githubAuthGeneration(userId) === generation) {
|
|
1391
|
+
accessCache.set(userId, tok);
|
|
1392
|
+
}
|
|
1393
|
+
return tok;
|
|
1394
|
+
})
|
|
1395
|
+
.finally(() => {
|
|
1396
|
+
if (inflightExchanges.get(userId)?.promise === run) {
|
|
1397
|
+
inflightExchanges.delete(userId);
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
inflightExchanges.set(userId, { generation, promise: run });
|
|
1401
|
+
return run;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
async function doRequestAccessToken(
|
|
1405
|
+
userId: string,
|
|
1406
|
+
generation: number,
|
|
1407
|
+
): Promise<{ accessToken: string; expiresAt: number } | null> {
|
|
1408
|
+
const row = getDb().transaction((tx) => {
|
|
1409
|
+
const token = tx
|
|
1410
|
+
.select()
|
|
1411
|
+
.from(schema.githubTokens)
|
|
1412
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
1413
|
+
.get();
|
|
1414
|
+
if (!token) return null;
|
|
1415
|
+
const fence = tx
|
|
1416
|
+
.select({
|
|
1417
|
+
generation: schema.githubCredentialGenerations.generation,
|
|
1418
|
+
mutation: schema.githubCredentialGenerations.mutation,
|
|
1419
|
+
})
|
|
1420
|
+
.from(schema.githubCredentialGenerations)
|
|
1421
|
+
.where(eq(schema.githubCredentialGenerations.userId, userId))
|
|
1422
|
+
.get();
|
|
1423
|
+
return fence?.mutation === "set" &&
|
|
1424
|
+
fence.generation === token.generation
|
|
1425
|
+
? token
|
|
1426
|
+
: null;
|
|
1427
|
+
});
|
|
1428
|
+
if (!row) return null;
|
|
1429
|
+
|
|
1430
|
+
const refreshToken = isStoreHeldGithubRow(row)
|
|
1431
|
+
? cachedGithubToken(userId)
|
|
1432
|
+
: openAesGcm(
|
|
1433
|
+
Buffer.from(row.refreshTokenCt as Uint8Array),
|
|
1434
|
+
Buffer.from(row.refreshTokenNonce as Uint8Array),
|
|
1435
|
+
);
|
|
1436
|
+
if (refreshToken === null) return null;
|
|
1437
|
+
const res = await fetch(exchangeUrl(), {
|
|
1438
|
+
method: "POST",
|
|
1439
|
+
headers: {
|
|
1440
|
+
"content-type": "application/json",
|
|
1441
|
+
"x-uai-host-token": process.env.UAI_HOST_TOKEN ?? "",
|
|
1442
|
+
},
|
|
1443
|
+
body: JSON.stringify({ refreshToken }),
|
|
1444
|
+
signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
|
|
1445
|
+
});
|
|
1446
|
+
if (!res.ok) {
|
|
1447
|
+
// Include the cloud's error body: it passes GitHub's OAuth error code
|
|
1448
|
+
// through (e.g. "bad_refresh_token"/"invalid_grant" inside the 502
|
|
1449
|
+
// message), which is the ONLY reliable revocation signal. The bare HTTP
|
|
1450
|
+
// status is not one — the cloud returns 401 solely for a host-token
|
|
1451
|
+
// mismatch and wraps every GitHub-side failure as 502.
|
|
1452
|
+
let detail = "";
|
|
1453
|
+
try {
|
|
1454
|
+
const body = (await res.json()) as { message?: string };
|
|
1455
|
+
if (body && typeof body.message === "string") detail = ` — ${body.message}`;
|
|
1456
|
+
} catch {
|
|
1457
|
+
// non-JSON body — status alone will have to do
|
|
1458
|
+
}
|
|
1459
|
+
throw new Error(`gh token exchange failed: HTTP ${res.status}${detail}`);
|
|
1460
|
+
}
|
|
1461
|
+
const data = (await res.json()) as ExchangeResult;
|
|
1462
|
+
// A reconnect/disconnect may have replaced the DB row while the network
|
|
1463
|
+
// exchange was in flight. Never overwrite that newer grant with the rotated
|
|
1464
|
+
// refresh token from the superseded exchange.
|
|
1465
|
+
if (
|
|
1466
|
+
data.refreshToken &&
|
|
1467
|
+
githubAuthGeneration(userId) === generation &&
|
|
1468
|
+
isStoreHeldGithubRow(row)
|
|
1469
|
+
) {
|
|
1470
|
+
// ADR-136 sign-in box: the rotated token goes to the cache and the store,
|
|
1471
|
+
// never onto the row.
|
|
1472
|
+
rememberGithubToken(userId, data.refreshToken);
|
|
1473
|
+
const rotated: StoredGithubToken = {
|
|
1474
|
+
v: 1,
|
|
1475
|
+
kind: "github",
|
|
1476
|
+
userId,
|
|
1477
|
+
installationId: row.installationId,
|
|
1478
|
+
generation: row.generation,
|
|
1479
|
+
tokenKind: row.kind,
|
|
1480
|
+
token: data.refreshToken,
|
|
1481
|
+
expiresAt: data.refreshTokenExpiresAt ?? row.refreshTokenExpiresAt ?? null,
|
|
1482
|
+
updatedAt: Date.now(),
|
|
1483
|
+
};
|
|
1484
|
+
queueStoreWrite(`rotate github token ${userId}`, (store) =>
|
|
1485
|
+
store.put(githubTokenParameterName(userId), JSON.stringify(rotated)),
|
|
1486
|
+
);
|
|
1487
|
+
} else if (
|
|
1488
|
+
data.refreshToken &&
|
|
1489
|
+
githubAuthGeneration(userId) === generation
|
|
1490
|
+
) {
|
|
1491
|
+
const sealed = sealAesGcm(data.refreshToken);
|
|
1492
|
+
getDb().transaction((tx) => {
|
|
1493
|
+
const fence = tx
|
|
1494
|
+
.select({
|
|
1495
|
+
generation: schema.githubCredentialGenerations.generation,
|
|
1496
|
+
mutation: schema.githubCredentialGenerations.mutation,
|
|
1497
|
+
})
|
|
1498
|
+
.from(schema.githubCredentialGenerations)
|
|
1499
|
+
.where(eq(schema.githubCredentialGenerations.userId, userId))
|
|
1500
|
+
.get();
|
|
1501
|
+
if (
|
|
1502
|
+
fence?.mutation !== "set" ||
|
|
1503
|
+
fence.generation !== row.generation
|
|
1504
|
+
) {
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
tx.update(schema.githubTokens)
|
|
1508
|
+
.set({
|
|
1509
|
+
refreshTokenCt: sealed.ct,
|
|
1510
|
+
refreshTokenNonce: sealed.nonce,
|
|
1511
|
+
refreshTokenExpiresAt:
|
|
1512
|
+
data.refreshTokenExpiresAt ?? row.refreshTokenExpiresAt,
|
|
1513
|
+
updatedAt: Date.now(),
|
|
1514
|
+
})
|
|
1515
|
+
.where(
|
|
1516
|
+
and(
|
|
1517
|
+
eq(schema.githubTokens.userId, userId),
|
|
1518
|
+
eq(schema.githubTokens.generation, row.generation),
|
|
1519
|
+
),
|
|
1520
|
+
)
|
|
1521
|
+
.run();
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
return { accessToken: data.accessToken, expiresAt: data.expiresAt };
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// ---------------------------------------------------------------------------
|
|
1528
|
+
// ADR-136 sign-in box: a token row here holds NO ciphertext — `refresh_token_ct`
|
|
1529
|
+
// is empty, the row is "store-held" — the token lives in the store and, only
|
|
1530
|
+
// while the box needs it (the installation listing, a legacy exchange, the
|
|
1531
|
+
// revoke of a superseded grant), in this bounded in-memory cache. Never on
|
|
1532
|
+
// disk. The row's metadata still drives `hasToken` / `connectedUserIds` and
|
|
1533
|
+
// the generation fence exactly as on any host.
|
|
1534
|
+
// ---------------------------------------------------------------------------
|
|
1535
|
+
|
|
1536
|
+
const BOX_TOKEN_CACHE_MS = 10 * 60_000;
|
|
1537
|
+
const boxTokens = new Map<string, { token: string; until: number }>();
|
|
1538
|
+
|
|
1539
|
+
export const GITHUB_TOKEN_TAGS = {
|
|
1540
|
+
installationId: "uai_installation_id",
|
|
1541
|
+
generation: "uai_generation",
|
|
1542
|
+
tokenKind: "uai_token_kind",
|
|
1543
|
+
expiresAt: "uai_expires_at",
|
|
1544
|
+
updatedAt: "uai_updated_at",
|
|
1545
|
+
} as const;
|
|
1546
|
+
|
|
1547
|
+
export function isStoreHeldGithubRow(row: { refreshTokenCt: unknown }): boolean {
|
|
1548
|
+
const ct = row.refreshTokenCt;
|
|
1549
|
+
if (ct === null || ct === undefined) return true;
|
|
1550
|
+
if (ct instanceof Uint8Array) return ct.byteLength === 0;
|
|
1551
|
+
return false;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
export function rememberGithubToken(userId: string, token: string): void {
|
|
1555
|
+
boxTokens.set(userId, { token, until: Date.now() + BOX_TOKEN_CACHE_MS });
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
export function cachedGithubToken(userId: string): string | null {
|
|
1559
|
+
const entry = boxTokens.get(userId);
|
|
1560
|
+
if (!entry) return null;
|
|
1561
|
+
if (entry.until <= Date.now()) {
|
|
1562
|
+
boxTokens.delete(userId);
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
return entry.token;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
export function forgetGithubToken(userId: string): void {
|
|
1569
|
+
boxTokens.delete(userId);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** Test seam. */
|
|
1573
|
+
export function clearGithubTokenCacheForTesting(): void {
|
|
1574
|
+
boxTokens.clear();
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** The secret-free metadata a token parameter carries as tags, so a
|
|
1578
|
+
* replacement box lists the user as connected without reading the value. */
|
|
1579
|
+
export function githubTokenMetadataTags(value: {
|
|
1580
|
+
installationId: number;
|
|
1581
|
+
generation: number;
|
|
1582
|
+
tokenKind: string;
|
|
1583
|
+
expiresAt: number | null;
|
|
1584
|
+
updatedAt: number;
|
|
1585
|
+
}): Record<string, string> {
|
|
1586
|
+
return {
|
|
1587
|
+
[GITHUB_TOKEN_TAGS.installationId]: String(value.installationId),
|
|
1588
|
+
[GITHUB_TOKEN_TAGS.generation]: String(value.generation),
|
|
1589
|
+
[GITHUB_TOKEN_TAGS.tokenKind]: value.tokenKind,
|
|
1590
|
+
[GITHUB_TOKEN_TAGS.expiresAt]: value.expiresAt === null ? "" : String(value.expiresAt),
|
|
1591
|
+
[GITHUB_TOKEN_TAGS.updatedAt]: String(value.updatedAt),
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Fill the cache from the store for a store-held row. True when a token is
|
|
1596
|
+
* cached afterwards (or the row is not store-held); false when there is no
|
|
1597
|
+
* row, no store, or no parameter. Throws on a store failure. */
|
|
1598
|
+
export async function loadGithubTokenIntoCache(userId: string): Promise<boolean> {
|
|
1599
|
+
if (cachedGithubToken(userId) !== null) return true;
|
|
1600
|
+
const row = getDb()
|
|
1601
|
+
.select({
|
|
1602
|
+
refreshTokenCt: schema.githubTokens.refreshTokenCt,
|
|
1603
|
+
generation: schema.githubTokens.generation,
|
|
1604
|
+
})
|
|
1605
|
+
.from(schema.githubTokens)
|
|
1606
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
1607
|
+
.get();
|
|
1608
|
+
if (!row) return false;
|
|
1609
|
+
if (!isStoreHeldGithubRow(row)) return true;
|
|
1610
|
+
const store = configuredCredentialStore();
|
|
1611
|
+
if (!store) return false;
|
|
1612
|
+
const raw = await store.get(githubTokenParameterName(userId));
|
|
1613
|
+
if (!raw) return false;
|
|
1614
|
+
const parsed: unknown = JSON.parse(raw);
|
|
1615
|
+
const value =
|
|
1616
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
1617
|
+
? (parsed as { token?: unknown; generation?: unknown })
|
|
1618
|
+
: {};
|
|
1619
|
+
if (typeof value.token !== "string" || value.token.length === 0) {
|
|
1620
|
+
throw new Error("the stored GitHub token is malformed");
|
|
1621
|
+
}
|
|
1622
|
+
if (value.generation !== row.generation) {
|
|
1623
|
+
console.warn(
|
|
1624
|
+
`[github] user ${userId}: the credential store holds generation ${String(value.generation)}, this box recorded ${row.generation}`,
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
rememberGithubToken(userId, value.token);
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/** A token row with no material (sign-in box). Same fence as `storeTokenRow`. */
|
|
1632
|
+
export function insertStoreHeldGithubTokenRow(
|
|
1633
|
+
userId: string,
|
|
1634
|
+
fields: {
|
|
1635
|
+
installationId: number;
|
|
1636
|
+
generation: number;
|
|
1637
|
+
refreshTokenExpiresAt: number | null;
|
|
1638
|
+
kind: string;
|
|
1639
|
+
updatedAt: number;
|
|
1640
|
+
storeRef: string;
|
|
1641
|
+
},
|
|
1642
|
+
): void {
|
|
1643
|
+
const values = {
|
|
1644
|
+
...fields,
|
|
1645
|
+
refreshTokenCt: Buffer.alloc(0),
|
|
1646
|
+
refreshTokenNonce: Buffer.alloc(0),
|
|
1647
|
+
};
|
|
1648
|
+
getDb().transaction((tx) => {
|
|
1649
|
+
const fence = tx
|
|
1650
|
+
.select({
|
|
1651
|
+
generation: schema.githubCredentialGenerations.generation,
|
|
1652
|
+
mutation: schema.githubCredentialGenerations.mutation,
|
|
1653
|
+
})
|
|
1654
|
+
.from(schema.githubCredentialGenerations)
|
|
1655
|
+
.where(eq(schema.githubCredentialGenerations.userId, userId))
|
|
1656
|
+
.get();
|
|
1657
|
+
if (fence?.mutation !== "set" || fence.generation !== fields.generation) {
|
|
1658
|
+
throw new Error("GitHub credential generation changed before storage");
|
|
1659
|
+
}
|
|
1660
|
+
tx.insert(schema.githubTokens)
|
|
1661
|
+
.values({ userId, ...values })
|
|
1662
|
+
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: values })
|
|
1663
|
+
.run();
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/** Sign-in box (ADR-136): the store first, awaited — a refused put is the
|
|
1668
|
+
* connect's failure — then tags, a store-held row, and the cache. */
|
|
1669
|
+
export async function storeGithubTokenStoreFirst(
|
|
1670
|
+
userId: string,
|
|
1671
|
+
fields: {
|
|
1672
|
+
installationId: number;
|
|
1673
|
+
generation: number;
|
|
1674
|
+
refreshTokenExpiresAt: number | null;
|
|
1675
|
+
kind: string;
|
|
1676
|
+
updatedAt: number;
|
|
1677
|
+
},
|
|
1678
|
+
token: string,
|
|
1679
|
+
): Promise<void> {
|
|
1680
|
+
const store = configuredCredentialStore();
|
|
1681
|
+
if (!store) throw new Error("the sign-in box has no credential store to hold the token");
|
|
1682
|
+
const name = githubTokenParameterName(userId);
|
|
1683
|
+
const value: StoredGithubToken = {
|
|
1684
|
+
v: 1,
|
|
1685
|
+
kind: "github",
|
|
1686
|
+
userId,
|
|
1687
|
+
installationId: fields.installationId,
|
|
1688
|
+
generation: fields.generation,
|
|
1689
|
+
tokenKind: fields.kind,
|
|
1690
|
+
token,
|
|
1691
|
+
expiresAt: fields.refreshTokenExpiresAt,
|
|
1692
|
+
updatedAt: fields.updatedAt,
|
|
1693
|
+
};
|
|
1694
|
+
await store.put(name, JSON.stringify(value));
|
|
1695
|
+
try {
|
|
1696
|
+
await store.tag(name, githubTokenMetadataTags(value));
|
|
1697
|
+
} catch (err) {
|
|
1698
|
+
console.warn(
|
|
1699
|
+
`[github] user ${userId}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box reads the value once to list this user`,
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
insertStoreHeldGithubTokenRow(userId, { ...fields, storeRef: name });
|
|
1703
|
+
rememberGithubToken(userId, token);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/** Delete the row only (no store call) — the start-time reconcile's "gone
|
|
1707
|
+
* elsewhere" move on the sign-in box. */
|
|
1708
|
+
export function deleteGithubTokenRowLocal(userId: string): void {
|
|
1709
|
+
forgetGithubToken(userId);
|
|
1710
|
+
getDb()
|
|
1711
|
+
.delete(schema.githubTokens)
|
|
1712
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
1713
|
+
.run();
|
|
1714
|
+
notifyGithubChange();
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// --- container injection ----------------------------------------------------
|
|
1718
|
+
|
|
1719
|
+
/** Injectable docker-exec seam (mocked in tests). Async so the docker call
|
|
1720
|
+
* doesn't block the host event loop (runs on every channel ensure + refresh). */
|
|
1721
|
+
export type DockerExec = (
|
|
1722
|
+
args: string[],
|
|
1723
|
+
input: string,
|
|
1724
|
+
) => Promise<{ status: number | null; stderr: string }>;
|
|
1725
|
+
|
|
1726
|
+
const defaultExec: DockerExec = async (args, input) => {
|
|
1727
|
+
const res = await taskContainerCli(args, {
|
|
1728
|
+
input,
|
|
1729
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
1730
|
+
});
|
|
1731
|
+
return { status: res.status, stderr: res.stderr };
|
|
1732
|
+
};
|
|
1733
|
+
|
|
1734
|
+
/**
|
|
1735
|
+
* Write the access token into the container's gh config and make gh Git's
|
|
1736
|
+
* HTTPS credential helper. Also removes the legacy global HTTPS→SSH rewrite so
|
|
1737
|
+
* reconnecting heals already-running containers without recreation.
|
|
1738
|
+
*/
|
|
1739
|
+
export async function injectIntoContainer(
|
|
1740
|
+
taskId: string,
|
|
1741
|
+
accessToken: string,
|
|
1742
|
+
exec: DockerExec = defaultExec,
|
|
1743
|
+
admitContainerWork: () => void = requireContainerRuntimeOperational,
|
|
1744
|
+
environment?: Pick<TaskEnvironmentHandle, "exec">,
|
|
1745
|
+
): Promise<void> {
|
|
1746
|
+
const container = taskAppContainerName(taskId);
|
|
1747
|
+
// Provider-neutral path: same in-container `/usr/bin/env -i` command, same
|
|
1748
|
+
// sanitized-plus-authority environment, argv/stdin separation preserved.
|
|
1749
|
+
const run: (
|
|
1750
|
+
command: string[],
|
|
1751
|
+
input: string,
|
|
1752
|
+
) => Promise<{ status: number | null; stderr: string }> = environment
|
|
1753
|
+
? async (command, input) => {
|
|
1754
|
+
const [first, ...rest] = [...CLEAN_CONTAINER_ENV, ...command];
|
|
1755
|
+
const result = await environment.exec({
|
|
1756
|
+
projectEnvProfile: "maintenance",
|
|
1757
|
+
argv: [first!, ...rest],
|
|
1758
|
+
user: "node",
|
|
1759
|
+
env: { ...SANITIZED_CONTAINER_ENV, ...RUNTIME_AUTHORITY_ENV },
|
|
1760
|
+
...(input ? { stdin: Buffer.from(input) } : {}),
|
|
1761
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
1762
|
+
maxOutputBytes: 256 * 1024,
|
|
1763
|
+
});
|
|
1764
|
+
return {
|
|
1765
|
+
status: result.exitCode,
|
|
1766
|
+
stderr: Buffer.from(result.stderr).toString("utf8"),
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
: (command, input) =>
|
|
1770
|
+
exec(
|
|
1771
|
+
managedContainerExecArgs(container, command, input.length > 0),
|
|
1772
|
+
input,
|
|
1773
|
+
);
|
|
1774
|
+
admitContainerWork();
|
|
1775
|
+
const res = await run(
|
|
1776
|
+
[
|
|
1777
|
+
"/usr/bin/gh",
|
|
1778
|
+
"auth",
|
|
1779
|
+
"login",
|
|
1780
|
+
"--with-token",
|
|
1781
|
+
"--hostname",
|
|
1782
|
+
"github.com",
|
|
1783
|
+
"--git-protocol",
|
|
1784
|
+
"https",
|
|
1785
|
+
],
|
|
1786
|
+
`${accessToken}\n`,
|
|
1787
|
+
);
|
|
1788
|
+
admitContainerWork();
|
|
1789
|
+
if (res.status !== 0) {
|
|
1790
|
+
throw new Error(`gh auth login failed in ${container}: ${res.stderr.trim()}`);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
admitContainerWork();
|
|
1794
|
+
const setup = await run(
|
|
1795
|
+
["/usr/bin/gh", "auth", "setup-git", "--hostname", "github.com"],
|
|
1796
|
+
"",
|
|
1797
|
+
);
|
|
1798
|
+
admitContainerWork();
|
|
1799
|
+
if (setup.status !== 0) {
|
|
1800
|
+
throw new Error(
|
|
1801
|
+
`gh git credential setup failed in ${container}: ${setup.stderr.trim()}`,
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
admitContainerWork();
|
|
1806
|
+
const transport = await run(
|
|
1807
|
+
[
|
|
1808
|
+
"/bin/sh",
|
|
1809
|
+
"-c",
|
|
1810
|
+
"/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true; " +
|
|
1811
|
+
"/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true; " +
|
|
1812
|
+
"/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'; " +
|
|
1813
|
+
"/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
|
|
1814
|
+
],
|
|
1815
|
+
"",
|
|
1816
|
+
);
|
|
1817
|
+
admitContainerWork();
|
|
1818
|
+
if (transport.status !== 0) {
|
|
1819
|
+
throw new Error(
|
|
1820
|
+
`Git HTTPS transport setup failed in ${container}: ${transport.stderr.trim()}`,
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// --- refresh schedule -------------------------------------------------------
|
|
1826
|
+
|
|
1827
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1828
|
+
|
|
1829
|
+
type AuthExpiredHandler = (taskId: string, userId: string, reason: string) => void;
|
|
1830
|
+
let authExpiredHandler: AuthExpiredHandler | null = null;
|
|
1831
|
+
|
|
1832
|
+
/** Phase D wires this to the task-chat "auth expired" system message. */
|
|
1833
|
+
export function setAuthExpiredHandler(fn: AuthExpiredHandler | null): void {
|
|
1834
|
+
authExpiredHandler = fn;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
export function scheduleRefresh(
|
|
1838
|
+
taskId: string,
|
|
1839
|
+
userId: string,
|
|
1840
|
+
expiresAt: number,
|
|
1841
|
+
generation = githubAuthGeneration(userId),
|
|
1842
|
+
): void {
|
|
1843
|
+
clearRefresh(taskId);
|
|
1844
|
+
const delay = Math.max(0, expiresAt - Date.now() - REFRESH_LEAD_MS);
|
|
1845
|
+
const timer = setTimeout(() => {
|
|
1846
|
+
timers.delete(taskId);
|
|
1847
|
+
if (githubAuthGeneration(userId) !== generation) return;
|
|
1848
|
+
if (!backgroundContainerWorkAllowed()) {
|
|
1849
|
+
scheduleGithubRetry(taskId, userId, 0, {}, generation);
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
void enqueueTaskGitAuth(taskId, userId, {}, {
|
|
1853
|
+
generation,
|
|
1854
|
+
getGeneration: () => githubAuthGeneration(userId),
|
|
1855
|
+
attempt: 0,
|
|
1856
|
+
}).catch((err) => {
|
|
1857
|
+
if (githubAuthGeneration(userId) !== generation) return;
|
|
1858
|
+
console.warn(
|
|
1859
|
+
`[github] task ${taskId}: refresh reconciliation failed: ${
|
|
1860
|
+
err instanceof Error ? err.message : err
|
|
1861
|
+
}`,
|
|
1862
|
+
);
|
|
1863
|
+
scheduleGithubRetry(taskId, userId, 0, {}, generation);
|
|
1864
|
+
});
|
|
1865
|
+
}, delay);
|
|
1866
|
+
timer.unref?.();
|
|
1867
|
+
timers.set(taskId, timer);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* True when the exchange error names a GitHub-side revocation — the refresh
|
|
1872
|
+
* token itself is dead, so retrying is pointless and the user must re-grant.
|
|
1873
|
+
* Deliberately keyed on GitHub's OAuth error strings (passed through the
|
|
1874
|
+
* cloud's 502 body by requestAccessToken), NOT on bare HTTP status: the cloud
|
|
1875
|
+
* returns 401 only for a host-token mismatch and wraps all GitHub failures as
|
|
1876
|
+
* 502, so a status like "401"/"403" alone never means "revoked" — the old
|
|
1877
|
+
* \b40[13]\b match here deleted VALID refresh tokens on host-auth/proxy noise.
|
|
1878
|
+
*/
|
|
1879
|
+
function isRevokedTokenError(reason: string): boolean {
|
|
1880
|
+
return /revoked|invalid_grant|bad_refresh/i.test(reason);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* True when the failure names a genuinely bad credential — GitHub answered the
|
|
1885
|
+
* validation with 401/403 ("Bad credentials"), so the token really is wrong and
|
|
1886
|
+
* only a reconnect fixes it. Distinct from a 5xx blip (see below).
|
|
1887
|
+
*/
|
|
1888
|
+
function isBadCredentialError(reason: string): boolean {
|
|
1889
|
+
return /validating token: HTTP 40[13]\b|Bad credentials|401 Unauthorized|403 Forbidden/i.test(
|
|
1890
|
+
reason,
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* True when the failure is a GitHub-side / network blip that a retry will heal
|
|
1896
|
+
* — the API returned 5xx (as happens when `gh auth login` validates the token
|
|
1897
|
+
* against api.github.com during an outage), a rate-limit, or the fetch itself
|
|
1898
|
+
* failed. Such a failure must NOT be surfaced as "authentication expired —
|
|
1899
|
+
* reconnect": the token is fine, GitHub is briefly unavailable. A genuine bad
|
|
1900
|
+
* credential (401/403) is explicitly excluded so it still routes to reconnect.
|
|
1901
|
+
*/
|
|
1902
|
+
export function isTransientGithubError(reason: string): boolean {
|
|
1903
|
+
if (isBadCredentialError(reason)) return false;
|
|
1904
|
+
// A blank reason proves nothing about the credential. Seen live 2026-08-25:
|
|
1905
|
+
// `gh auth login` raced its own task's teardown (the container died under
|
|
1906
|
+
// the exec), exited nonzero with EMPTY stderr, and the fall-through verdict
|
|
1907
|
+
// told the user their auth expired and to reconnect — it hadn't. Unknown
|
|
1908
|
+
// must route to the retrying/soft path, never to a reconnect demand.
|
|
1909
|
+
if (reason.trim() === "") return true;
|
|
1910
|
+
return (
|
|
1911
|
+
/HTTP 5\d\d|Service Unavailable|Bad Gateway|Gateway Time-?out|server error|rate limit|too many requests|secondary rate|unavailable|unreachable/i.test(
|
|
1912
|
+
reason,
|
|
1913
|
+
) ||
|
|
1914
|
+
/\bfetch failed\b|timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|ENOTFOUND|EAI_AGAIN|socket hang up|\bnetwork\b/i.test(
|
|
1915
|
+
reason,
|
|
1916
|
+
)
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
export function clearRefresh(taskId: string): void {
|
|
1921
|
+
clearGithubRetry(taskId);
|
|
1922
|
+
const t = timers.get(taskId);
|
|
1923
|
+
if (t) {
|
|
1924
|
+
clearTimeout(t);
|
|
1925
|
+
timers.delete(taskId);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// --- self-heal: retry a failed gh setup with bounded backoff ----------------
|
|
1930
|
+
// A gh setup/refresh can fail transiently — e.g. the cloud token-exchange fetch
|
|
1931
|
+
// times out while the host is briefly unreachable (as happened when the bridge
|
|
1932
|
+
// was flapping). Without a retry the task is stranded on a stale/invalid token
|
|
1933
|
+
// until someone runs /retry-gh, because ensureSessions won't re-inject once a
|
|
1934
|
+
// channel's sessions are started. Retry with bounded backoff; cleared on
|
|
1935
|
+
// success (clearGithubRetry) and on task teardown (via clearRefresh).
|
|
1936
|
+
const RETRY_BACKOFF_MS = [5_000, 20_000, 60_000, 180_000, 300_000];
|
|
1937
|
+
const retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1938
|
+
|
|
1939
|
+
function clearGithubRetry(taskId: string): void {
|
|
1940
|
+
const t = retryTimers.get(taskId);
|
|
1941
|
+
if (t) {
|
|
1942
|
+
clearTimeout(t);
|
|
1943
|
+
retryTimers.delete(taskId);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** Is the task still live on this host? Retries for ended tasks would docker
|
|
1948
|
+
* exec against a removed container and post notes into a finished chat. */
|
|
1949
|
+
function taskIsActive(taskId: string): boolean {
|
|
1950
|
+
const row = getDb()
|
|
1951
|
+
.select({ endedAt: schema.hostTasks.endedAt })
|
|
1952
|
+
.from(schema.hostTasks)
|
|
1953
|
+
.where(eq(schema.hostTasks.taskId, taskId))
|
|
1954
|
+
.get();
|
|
1955
|
+
return row != null && row.endedAt == null;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function scheduleGithubRetry(
|
|
1959
|
+
taskId: string,
|
|
1960
|
+
userId: string,
|
|
1961
|
+
attempt: number,
|
|
1962
|
+
deps: SetupTaskDeps = {},
|
|
1963
|
+
generation = githubAuthGeneration(userId),
|
|
1964
|
+
): void {
|
|
1965
|
+
clearGithubRetry(taskId);
|
|
1966
|
+
if (attempt >= RETRY_BACKOFF_MS.length) {
|
|
1967
|
+
// attempt counts retries fired so far; +1 for the initial try.
|
|
1968
|
+
console.warn(
|
|
1969
|
+
`[github] task ${taskId}: gh setup still failing after ${attempt + 1} attempts — giving up until /retry-gh`,
|
|
1970
|
+
);
|
|
1971
|
+
authExpiredHandler?.(
|
|
1972
|
+
taskId,
|
|
1973
|
+
userId,
|
|
1974
|
+
"gh setup kept failing — send /retry-gh once the connection recovers",
|
|
1975
|
+
);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
const delay = RETRY_BACKOFF_MS[attempt] ?? 300_000;
|
|
1979
|
+
const timer = setTimeout(() => {
|
|
1980
|
+
retryTimers.delete(taskId);
|
|
1981
|
+
if (githubAuthGeneration(userId) !== generation) return;
|
|
1982
|
+
const isActive = deps.taskIsActive ?? taskIsActive;
|
|
1983
|
+
if (!isActive(taskId)) return; // task ended while we backed off
|
|
1984
|
+
if (!backgroundContainerWorkAllowed()) {
|
|
1985
|
+
// Runtime downtime is not a failed GitHub attempt. Preserve the same
|
|
1986
|
+
// retry budget and wait for another backoff window instead of spawning
|
|
1987
|
+
// Docker while the host is deliberately fail-closed.
|
|
1988
|
+
scheduleGithubRetry(taskId, userId, attempt, deps, generation);
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
void enqueueTaskGitAuth(taskId, userId, {}, {
|
|
1992
|
+
generation,
|
|
1993
|
+
getGeneration: () => githubAuthGeneration(userId),
|
|
1994
|
+
setupDeps: deps,
|
|
1995
|
+
attempt: attempt + 1,
|
|
1996
|
+
}).catch((err) => {
|
|
1997
|
+
if (githubAuthGeneration(userId) !== generation) return;
|
|
1998
|
+
console.warn(
|
|
1999
|
+
`[github] task ${taskId}: retry reconciliation failed: ${
|
|
2000
|
+
err instanceof Error ? err.message : err
|
|
2001
|
+
}`,
|
|
2002
|
+
);
|
|
2003
|
+
scheduleGithubRetry(taskId, userId, attempt + 1, deps, generation);
|
|
2004
|
+
});
|
|
2005
|
+
}, delay);
|
|
2006
|
+
timer.unref?.();
|
|
2007
|
+
retryTimers.set(taskId, timer);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/** Test/diagnostic: is a gh-setup retry currently armed for this task? */
|
|
2011
|
+
export function hasPendingGithubRetry(taskId: string): boolean {
|
|
2012
|
+
return retryTimers.has(taskId);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Injectable seams for {@link setupTaskGithub} (mocked in tests so the decision
|
|
2017
|
+
* logic needs neither the host DB nor docker). All default to the real impls.
|
|
2018
|
+
*/
|
|
2019
|
+
export interface SetupTaskDeps {
|
|
2020
|
+
hasToken?: (userId: string) => boolean;
|
|
2021
|
+
requestAccessToken?: (
|
|
2022
|
+
userId: string,
|
|
2023
|
+
) => Promise<{ accessToken: string; expiresAt: number | null } | null>;
|
|
2024
|
+
inject?: (taskId: string, token: string) => void | Promise<void>;
|
|
2025
|
+
schedule?: (taskId: string, userId: string, expiresAt: number) => void;
|
|
2026
|
+
deleteToken?: (userId: string) => void;
|
|
2027
|
+
taskIsActive?: (taskId: string) => boolean;
|
|
2028
|
+
scrub?: (taskId: string) => Promise<boolean | void>;
|
|
2029
|
+
/** Late-bound provider handle for the default injection path. Absent (or
|
|
2030
|
+
* null at call time) keeps the legacy Docker fallback. */
|
|
2031
|
+
environment?: () => Pick<TaskEnvironmentHandle, "exec"> | null;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Set up GitHub auth for a task at task-up (ADR-027). Connected user → mint +
|
|
2036
|
+
* inject an access token + schedule refresh. Else UAI_GH_PAT_FALLBACK → inject
|
|
2037
|
+
* the PAT (no refresh; attributes to the PAT owner). Else skip. Best-effort:
|
|
2038
|
+
* a failure emits the auth-expired note but never aborts task-up. Returns true
|
|
2039
|
+
* iff a token was injected.
|
|
2040
|
+
*/
|
|
2041
|
+
interface SetupExecutionGuard {
|
|
2042
|
+
generation: number;
|
|
2043
|
+
isCurrent: () => boolean;
|
|
2044
|
+
admitContainerWork: () => void;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
export async function setupTaskGithub(
|
|
2048
|
+
taskId: string,
|
|
2049
|
+
userId: string,
|
|
2050
|
+
deps: SetupTaskDeps = {},
|
|
2051
|
+
attempt = 0,
|
|
2052
|
+
guard?: SetupExecutionGuard,
|
|
2053
|
+
): Promise<boolean> {
|
|
2054
|
+
const capturedGeneration =
|
|
2055
|
+
guard?.generation ?? githubAuthGeneration(userId);
|
|
2056
|
+
const isCurrent =
|
|
2057
|
+
guard?.isCurrent ??
|
|
2058
|
+
(() => githubAuthGeneration(userId) === capturedGeneration);
|
|
2059
|
+
const _hasToken = deps.hasToken ?? hasToken;
|
|
2060
|
+
const _request = deps.requestAccessToken ?? requestAccessToken;
|
|
2061
|
+
const _inject =
|
|
2062
|
+
deps.inject ??
|
|
2063
|
+
((t: string, tok: string) =>
|
|
2064
|
+
injectIntoContainer(
|
|
2065
|
+
t,
|
|
2066
|
+
tok,
|
|
2067
|
+
defaultExec,
|
|
2068
|
+
guard?.admitContainerWork ?? requireContainerRuntimeOperational,
|
|
2069
|
+
deps.environment?.() ?? undefined,
|
|
2070
|
+
));
|
|
2071
|
+
if (!isCurrent()) return false;
|
|
2072
|
+
try {
|
|
2073
|
+
if (_hasToken(userId)) {
|
|
2074
|
+
const tok = await _request(userId);
|
|
2075
|
+
if (tok && isCurrent()) {
|
|
2076
|
+
guard?.admitContainerWork();
|
|
2077
|
+
await _inject(taskId, tok.accessToken);
|
|
2078
|
+
guard?.admitContainerWork();
|
|
2079
|
+
// The mutation itself cannot be interrupted, but a newer transition is
|
|
2080
|
+
// already queued behind us. Do not let this stale run arm more work.
|
|
2081
|
+
if (!isCurrent()) return false;
|
|
2082
|
+
// Non-expiring (ADR-033) tokens (expiresAt null) need no refresh timer.
|
|
2083
|
+
if (tok.expiresAt !== null) {
|
|
2084
|
+
if (deps.schedule) {
|
|
2085
|
+
deps.schedule(taskId, userId, tok.expiresAt);
|
|
2086
|
+
} else {
|
|
2087
|
+
scheduleRefresh(
|
|
2088
|
+
taskId,
|
|
2089
|
+
userId,
|
|
2090
|
+
tok.expiresAt,
|
|
2091
|
+
capturedGeneration,
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
clearGithubRetry(taskId);
|
|
2096
|
+
console.log(`[github] task ${taskId}: injected user access token`);
|
|
2097
|
+
return true;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
if (!isCurrent()) return false;
|
|
2101
|
+
const pat = process.env.UAI_GH_PAT_FALLBACK;
|
|
2102
|
+
if (pat) {
|
|
2103
|
+
// The PAT is ONE host-wide credential, and injectIntoContainer wires it
|
|
2104
|
+
// into `gh` AND all git transport. Injecting it for whoever happens to
|
|
2105
|
+
// own the task therefore authenticates that user's container as the
|
|
2106
|
+
// PAT's owner, handing every unconnected user the owner's full
|
|
2107
|
+
// read/write repository access. That was invisible while a host had one
|
|
2108
|
+
// user and became a silent cross-user credential leak the moment it had
|
|
2109
|
+
// two. Bind the PAT to the single user it belongs to and fail closed
|
|
2110
|
+
// otherwise: a host that upgrades into multi-user must not start
|
|
2111
|
+
// leaking because nobody remembered to unset an env var.
|
|
2112
|
+
const patOwner = process.env.UAI_GH_PAT_FALLBACK_USER_ID;
|
|
2113
|
+
if (!patOwner) {
|
|
2114
|
+
console.warn(
|
|
2115
|
+
`[github] task ${taskId}: UAI_GH_PAT_FALLBACK is set but ` +
|
|
2116
|
+
"UAI_GH_PAT_FALLBACK_USER_ID is not — refusing to inject a " +
|
|
2117
|
+
"host-wide PAT into a task. Set the owner's user id to keep the " +
|
|
2118
|
+
"single-user fallback, or remove the PAT and connect GitHub per " +
|
|
2119
|
+
"user.",
|
|
2120
|
+
);
|
|
2121
|
+
} else if (patOwner !== userId) {
|
|
2122
|
+
console.warn(
|
|
2123
|
+
`[github] task ${taskId}: PAT fallback is owned by ${patOwner}, ` +
|
|
2124
|
+
`not task owner ${userId} — skipping.`,
|
|
2125
|
+
);
|
|
2126
|
+
} else {
|
|
2127
|
+
guard?.admitContainerWork();
|
|
2128
|
+
await _inject(taskId, pat);
|
|
2129
|
+
guard?.admitContainerWork();
|
|
2130
|
+
clearGithubRetry(taskId);
|
|
2131
|
+
console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
|
|
2132
|
+
return true;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
console.log(`[github] task ${taskId}: gh not configured for user ${userId}`);
|
|
2136
|
+
return false;
|
|
2137
|
+
} catch (err) {
|
|
2138
|
+
// A newer connect/disconnect owns the final state and is queued behind this
|
|
2139
|
+
// operation. The superseded run must neither delete its grant nor retry.
|
|
2140
|
+
if (!isCurrent()) return false;
|
|
2141
|
+
// Runtime unavailability is not an ordinary "GitHub not configured" or
|
|
2142
|
+
// retryable credential verdict. Reconciliation callers must reject so a
|
|
2143
|
+
// destructive set/clear transition cannot be acknowledged as complete.
|
|
2144
|
+
if (err instanceof ContainerRuntimeUnavailableError) throw err;
|
|
2145
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2146
|
+
console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
|
|
2147
|
+
// A task already torn down must not receive notes: its thread is
|
|
2148
|
+
// read-only, and a gh failure RACING that teardown (exec into a dying
|
|
2149
|
+
// container, live 2026-08-25) says nothing a reader can act on.
|
|
2150
|
+
const taskStillActive = (deps.taskIsActive ?? taskIsActive)(taskId);
|
|
2151
|
+
// A revoked refresh token can't recover by retrying — drop it so the user
|
|
2152
|
+
// gets a clean re-grant, and tell the chat once.
|
|
2153
|
+
if (isRevokedTokenError(reason)) {
|
|
2154
|
+
(deps.deleteToken ?? deleteToken)(userId);
|
|
2155
|
+
if (taskStillActive) authExpiredHandler?.(taskId, userId, reason);
|
|
2156
|
+
return false;
|
|
2157
|
+
}
|
|
2158
|
+
// A retry chain already armed for this task means a sibling entry point
|
|
2159
|
+
// (task-up and channel-ensure both call setup) already reported this and
|
|
2160
|
+
// owns recovery — don't post a duplicate note or start a competing chain.
|
|
2161
|
+
// `/retry-gh` clears the chain first, so a manual retry is never swallowed.
|
|
2162
|
+
if (attempt === 0 && retryTimers.has(taskId)) return false;
|
|
2163
|
+
// Transient: self-heal with bounded backoff. Post the chat note only on
|
|
2164
|
+
// the FIRST failure of a chain — each retry re-enters this catch, and six
|
|
2165
|
+
// "gh auth" notes for one outage is noise. Exhaustion posts its own note
|
|
2166
|
+
// (in scheduleGithubRetry). The handler classifies the reason (transient
|
|
2167
|
+
// 5xx blip vs. genuine expiry) and words the note accordingly.
|
|
2168
|
+
if (attempt === 0 && taskStillActive) {
|
|
2169
|
+
authExpiredHandler?.(taskId, userId, reason);
|
|
2170
|
+
}
|
|
2171
|
+
scheduleGithubRetry(
|
|
2172
|
+
taskId,
|
|
2173
|
+
userId,
|
|
2174
|
+
attempt,
|
|
2175
|
+
deps,
|
|
2176
|
+
capturedGeneration,
|
|
2177
|
+
);
|
|
2178
|
+
return false;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// One task-local queue owns transport transitions. Connect/disconnect frames,
|
|
2183
|
+
// channel recovery, and /retry-gh can otherwise overlap: a slow old-token
|
|
2184
|
+
// injection must never win after a newer disconnect (or vice versa).
|
|
2185
|
+
const gitAuthReconcileQueues = new Map<string, Promise<boolean>>();
|
|
2186
|
+
|
|
2187
|
+
const REMOVE_MANAGED_GITHUB_AUTH = [
|
|
2188
|
+
"set -eu",
|
|
2189
|
+
"/usr/bin/rm -f -- \"$GH_CONFIG_DIR/hosts.yml\"",
|
|
2190
|
+
"test ! -e \"$GH_CONFIG_DIR/hosts.yml\"",
|
|
2191
|
+
"/usr/bin/git config --global --unset-all 'credential.https://github.com.helper' >/dev/null 2>&1 || true",
|
|
2192
|
+
"/usr/bin/git config --global --unset-all 'credential.https://gist.github.com.helper' >/dev/null 2>&1 || true",
|
|
2193
|
+
"/usr/bin/git config --global --unset-all 'url.git@github.com:.insteadOf' >/dev/null 2>&1 || true",
|
|
2194
|
+
"/usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true",
|
|
2195
|
+
"/usr/bin/git config --global --replace-all 'url.https://github.com/.insteadOf' 'git@github.com:'",
|
|
2196
|
+
"/usr/bin/git config --global --add 'url.https://github.com/.insteadOf' 'ssh://git@github.com/'",
|
|
2197
|
+
"test -z \"$(/usr/bin/git config --global --get-all 'credential.https://github.com.helper' 2>/dev/null || true)\"",
|
|
2198
|
+
"test -z \"$(/usr/bin/git config --global --get-all 'credential.https://gist.github.com.helper' 2>/dev/null || true)\"",
|
|
2199
|
+
].join("; ");
|
|
2200
|
+
|
|
2201
|
+
type DockerCommand = typeof dockerCli;
|
|
2202
|
+
|
|
2203
|
+
async function checkedDockerCommand(
|
|
2204
|
+
run: DockerCommand,
|
|
2205
|
+
args: string[],
|
|
2206
|
+
label: string,
|
|
2207
|
+
timeoutMs = EXEC_TIMEOUT_MS,
|
|
2208
|
+
admitContainerWork: () => void = requireContainerRuntimeOperational,
|
|
2209
|
+
): Promise<Awaited<ReturnType<DockerCommand>>> {
|
|
2210
|
+
admitContainerWork();
|
|
2211
|
+
const result = await run(args, { timeoutMs });
|
|
2212
|
+
admitContainerWork();
|
|
2213
|
+
if (result.status !== 0) {
|
|
2214
|
+
throw new Error(
|
|
2215
|
+
`${label} failed: ${result.stderr.trim() || `exit ${result.status}`}`,
|
|
2216
|
+
);
|
|
2217
|
+
}
|
|
2218
|
+
return result;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
export async function removeGithubFromContainer(
|
|
2222
|
+
taskId: string,
|
|
2223
|
+
run: DockerCommand = taskContainerCli,
|
|
2224
|
+
admitContainerWork: () => void = requireContainerRuntimeOperational,
|
|
2225
|
+
): Promise<boolean> {
|
|
2226
|
+
// ADR-133: a task machine has no container runtime and no app container —
|
|
2227
|
+
// the credential lives in the machine's own gh config, installed over the
|
|
2228
|
+
// environment at task-up (`machineTaskGithubAuth`). Inspecting Docker here
|
|
2229
|
+
// threw `spawn docker ENOENT` on every session start, so no agent ever
|
|
2230
|
+
// came up on the first live hostless run (2026-09-10, smoke 6). Same answer
|
|
2231
|
+
// a Docker host gives for a machine task: no container, nothing to scrub.
|
|
2232
|
+
if (sidecarMode()) return false;
|
|
2233
|
+
const container = taskAppContainerName(taskId);
|
|
2234
|
+
admitContainerWork();
|
|
2235
|
+
const observed = await taskAppContainerState(container, run as never);
|
|
2236
|
+
admitContainerWork();
|
|
2237
|
+
if (observed.kind === "unknown") {
|
|
2238
|
+
throw new Error(`inspect ${container} failed: ${observed.detail}`);
|
|
2239
|
+
}
|
|
2240
|
+
if (observed.kind === "absent") {
|
|
2241
|
+
return false; // no container means no credential to retain
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
if (observed.kind !== "running") {
|
|
2245
|
+
// Never start or unpause untrusted task code while its old credential is
|
|
2246
|
+
// still on disk. The app container is disposable; workspace/repository
|
|
2247
|
+
// state lives outside it and provisioning recreates it on Resume.
|
|
2248
|
+
admitContainerWork();
|
|
2249
|
+
await removeTaskAppContainer(container, run as never);
|
|
2250
|
+
admitContainerWork();
|
|
2251
|
+
return false;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
await checkedDockerCommand(
|
|
2255
|
+
run,
|
|
2256
|
+
managedContainerExecArgs(container, [
|
|
2257
|
+
"/bin/sh",
|
|
2258
|
+
"-c",
|
|
2259
|
+
REMOVE_MANAGED_GITHUB_AUTH,
|
|
2260
|
+
]),
|
|
2261
|
+
`remove managed GitHub auth from ${container}`,
|
|
2262
|
+
EXEC_TIMEOUT_MS,
|
|
2263
|
+
admitContainerWork,
|
|
2264
|
+
);
|
|
2265
|
+
return true;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
export interface ReconcileTaskGitAuthDeps {
|
|
2269
|
+
hasToken?: (userId: string) => boolean;
|
|
2270
|
+
setupGithub?: (taskId: string, userId: string) => Promise<boolean>;
|
|
2271
|
+
setupDeps?: SetupTaskDeps;
|
|
2272
|
+
authGeneration?: (userId: string) => number;
|
|
2273
|
+
logoutContainer?: (taskId: string) => Promise<boolean | void>;
|
|
2274
|
+
ensureSsh?: (taskId: string, userId: string) => Promise<boolean>;
|
|
2275
|
+
selectSsh?: (taskId: string) => Promise<boolean>;
|
|
2276
|
+
/** Last-hop Docker admission. Recovery supplies an epoch-scoped predicate
|
|
2277
|
+
* while the public runtime deliberately remains `checking`. */
|
|
2278
|
+
admitContainerWork?: () => void;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
interface ReconcileInvocation {
|
|
2282
|
+
generation: number;
|
|
2283
|
+
getGeneration: () => number;
|
|
2284
|
+
setupDeps?: SetupTaskDeps;
|
|
2285
|
+
attempt: number;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* Re-read the current host credential state and select exactly one Git
|
|
2290
|
+
* transport for a live task: connected user token over HTTPS, otherwise the
|
|
2291
|
+
* task owner's SSH identity. Serialized per task for reconnect safety.
|
|
2292
|
+
*/
|
|
2293
|
+
export function reconcileTaskGitAuth(
|
|
2294
|
+
taskId: string,
|
|
2295
|
+
userId: string,
|
|
2296
|
+
deps: ReconcileTaskGitAuthDeps = {},
|
|
2297
|
+
): Promise<boolean> {
|
|
2298
|
+
const getGeneration = () =>
|
|
2299
|
+
(deps.authGeneration ?? githubAuthGeneration)(userId);
|
|
2300
|
+
return enqueueTaskGitAuth(taskId, userId, deps, {
|
|
2301
|
+
generation: getGeneration(),
|
|
2302
|
+
getGeneration,
|
|
2303
|
+
setupDeps: deps.setupDeps,
|
|
2304
|
+
attempt: 0,
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/**
|
|
2309
|
+
* ADR-154: the runner's own GitHub sign-in (or its SSH key) reaches a task
|
|
2310
|
+
* only while the runner is on its own credentials. Leaving them waits for a
|
|
2311
|
+
* reconciliation already under way, and each of its steps, the last write
|
|
2312
|
+
* included, is fenced on them: none starts once leaving has begun.
|
|
2313
|
+
*/
|
|
2314
|
+
function enqueueTaskGitAuth(
|
|
2315
|
+
taskId: string,
|
|
2316
|
+
userId: string,
|
|
2317
|
+
deps: ReconcileTaskGitAuthDeps,
|
|
2318
|
+
invocation: ReconcileInvocation,
|
|
2319
|
+
): Promise<boolean> {
|
|
2320
|
+
return (
|
|
2321
|
+
whileOnOwnCredentials(() => enqueueOwnTaskGitAuth(taskId, userId, deps, invocation)) ??
|
|
2322
|
+
Promise.resolve(false)
|
|
2323
|
+
);
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function enqueueOwnTaskGitAuth(
|
|
2327
|
+
taskId: string,
|
|
2328
|
+
userId: string,
|
|
2329
|
+
deps: ReconcileTaskGitAuthDeps,
|
|
2330
|
+
invocation: ReconcileInvocation,
|
|
2331
|
+
): Promise<boolean> {
|
|
2332
|
+
const previous = gitAuthReconcileQueues.get(taskId) ?? Promise.resolve(false);
|
|
2333
|
+
let queued: Promise<boolean>;
|
|
2334
|
+
queued = previous
|
|
2335
|
+
.catch(() => false)
|
|
2336
|
+
.then(async () => {
|
|
2337
|
+
const isCurrent = () =>
|
|
2338
|
+
invocation.getGeneration() === invocation.generation && onOwnCredentials();
|
|
2339
|
+
const admitContainerWork =
|
|
2340
|
+
deps.admitContainerWork ?? requireContainerRuntimeOperational;
|
|
2341
|
+
if (!isCurrent()) return false;
|
|
2342
|
+
admitContainerWork();
|
|
2343
|
+
|
|
2344
|
+
const connected =
|
|
2345
|
+
deps.hasToken ?? invocation.setupDeps?.hasToken ?? hasToken;
|
|
2346
|
+
const shouldConnect = connected(userId);
|
|
2347
|
+
if (!shouldConnect) clearRefresh(taskId);
|
|
2348
|
+
|
|
2349
|
+
// Authoritative replacement, not gh's multi-account add/switch behavior:
|
|
2350
|
+
// remove every managed account/helper first, in the same per-task queue,
|
|
2351
|
+
// then install only the current user's credential. Non-running containers
|
|
2352
|
+
// are removed rather than executed with the old credential on disk.
|
|
2353
|
+
const logoutContainer =
|
|
2354
|
+
deps.logoutContainer ??
|
|
2355
|
+
invocation.setupDeps?.scrub ??
|
|
2356
|
+
((id: string) =>
|
|
2357
|
+
removeGithubFromContainer(id, taskContainerCli, admitContainerWork));
|
|
2358
|
+
const canConfigureLiveTransport = await logoutContainer(taskId);
|
|
2359
|
+
if (!isCurrent()) return false;
|
|
2360
|
+
admitContainerWork();
|
|
2361
|
+
if (canConfigureLiveTransport === false) {
|
|
2362
|
+
// No credential-bearing container remains. A connected credential will
|
|
2363
|
+
// be installed when Compose recreates it; disconnected state is already
|
|
2364
|
+
// fully scrubbed.
|
|
2365
|
+
return shouldConnect;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
if (shouldConnect) {
|
|
2369
|
+
if (deps.setupGithub) {
|
|
2370
|
+
admitContainerWork();
|
|
2371
|
+
const result = await deps.setupGithub(taskId, userId);
|
|
2372
|
+
if (!isCurrent()) return false;
|
|
2373
|
+
admitContainerWork();
|
|
2374
|
+
return result;
|
|
2375
|
+
}
|
|
2376
|
+
admitContainerWork();
|
|
2377
|
+
const result = await setupTaskGithub(
|
|
2378
|
+
taskId,
|
|
2379
|
+
userId,
|
|
2380
|
+
invocation.setupDeps,
|
|
2381
|
+
invocation.attempt,
|
|
2382
|
+
{
|
|
2383
|
+
generation: invocation.generation,
|
|
2384
|
+
isCurrent,
|
|
2385
|
+
admitContainerWork,
|
|
2386
|
+
},
|
|
2387
|
+
);
|
|
2388
|
+
if (!isCurrent()) return false;
|
|
2389
|
+
admitContainerWork();
|
|
2390
|
+
return result;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// SSH remains an opt-in fallback for users who did not connect GitHub;
|
|
2394
|
+
// ensuring the identity also keeps commit/tag signing available. Never
|
|
2395
|
+
// select SSH transport unless a usable identity was actually asserted.
|
|
2396
|
+
admitContainerWork();
|
|
2397
|
+
const hasSshIdentity = deps.ensureSsh
|
|
2398
|
+
? await deps.ensureSsh(taskId, userId)
|
|
2399
|
+
: await ensureTaskSshIdentity(taskId, userId, undefined, {
|
|
2400
|
+
// Machine/sidecar tasks install the key over the environment.
|
|
2401
|
+
environment: () => invocation.setupDeps?.environment?.() ?? null,
|
|
2402
|
+
});
|
|
2403
|
+
if (!isCurrent()) return false;
|
|
2404
|
+
admitContainerWork();
|
|
2405
|
+
if (hasSshIdentity) {
|
|
2406
|
+
admitContainerWork();
|
|
2407
|
+
const selected = await (
|
|
2408
|
+
deps.selectSsh ?? configureTaskSshTransport
|
|
2409
|
+
)(taskId);
|
|
2410
|
+
if (!isCurrent()) return false;
|
|
2411
|
+
admitContainerWork();
|
|
2412
|
+
if (!selected) {
|
|
2413
|
+
throw new Error(
|
|
2414
|
+
`task ${taskId}: SSH fallback transport could not be configured`,
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
} else {
|
|
2418
|
+
console.log(
|
|
2419
|
+
`[github] task ${taskId}: managed GitHub credential removed; using credential-free HTTPS`,
|
|
2420
|
+
);
|
|
2421
|
+
}
|
|
2422
|
+
return false;
|
|
2423
|
+
})
|
|
2424
|
+
.finally(() => {
|
|
2425
|
+
if (gitAuthReconcileQueues.get(taskId) === queued) {
|
|
2426
|
+
gitAuthReconcileQueues.delete(taskId);
|
|
2427
|
+
}
|
|
2428
|
+
});
|
|
2429
|
+
gitAuthReconcileQueues.set(taskId, queued);
|
|
2430
|
+
return queued;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
/** Test/maintenance hook: drop all scheduled refresh + retry timers. */
|
|
2434
|
+
export function clearAllRefreshTimers(): void {
|
|
2435
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
2436
|
+
timers.clear();
|
|
2437
|
+
for (const t of retryTimers.values()) clearTimeout(t);
|
|
2438
|
+
retryTimers.clear();
|
|
2439
|
+
}
|