@app-vitals/agent-runtime 0.0.1 → 0.1.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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/github-auth-deps.ts
|
|
3
|
+
*
|
|
4
|
+
* Builds the production GitHubAuthDeps for setupGitHubAuth() — shared by
|
|
5
|
+
* entrypoint-main.ts (one-shot boot call, Step 5) and index.ts
|
|
6
|
+
* (retry-on-tick via github-auth-startup.ts's startGitHubAuthIfPossible())
|
|
7
|
+
* so the two separate processes wire identical spawnSync/writeToken/
|
|
8
|
+
* tokenPath/credentialHelperPath behavior instead of duplicating it.
|
|
9
|
+
*
|
|
10
|
+
* Only index.ts enables installation discovery (`withInstallations`): it owns
|
|
11
|
+
* repo scope (agentReposRef) and the per-owner `gh-token.d/` files. The
|
|
12
|
+
* entrypoint process keeps the single pinned-installation path, whose
|
|
13
|
+
* refresh rewrites `gh-token` only so it never clobbers index.ts's per-owner
|
|
14
|
+
* files.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { createAppAuth } from "@octokit/auth-app";
|
|
19
|
+
import { agentReposRef } from "./agent-repos-ref.ts";
|
|
20
|
+
import { writeFileAtomic, writeTokenFiles } from "./gh-token-files.ts";
|
|
21
|
+
import {
|
|
22
|
+
createGitHubTokenManager,
|
|
23
|
+
type GitHubTokenManager,
|
|
24
|
+
getBotIdentity,
|
|
25
|
+
} from "./github-app-auth.ts";
|
|
26
|
+
import {
|
|
27
|
+
githubInstallationsManagerRef,
|
|
28
|
+
scopeOwnersOf,
|
|
29
|
+
} from "./github-auth-startup.ts";
|
|
30
|
+
import { GitHubInstallationsManager } from "./github-installations.ts";
|
|
31
|
+
import {
|
|
32
|
+
type GitHubInstallationsReporter,
|
|
33
|
+
NoopGitHubInstallationsReporter,
|
|
34
|
+
startInstallationsHeartbeat,
|
|
35
|
+
} from "./github-installations-reporter.ts";
|
|
36
|
+
import type {
|
|
37
|
+
GitHubAuthDeps,
|
|
38
|
+
GitHubInstallationsDeps,
|
|
39
|
+
} from "./setup-github-auth.ts";
|
|
40
|
+
|
|
41
|
+
type InstallationAuthFn = ConstructorParameters<
|
|
42
|
+
typeof GitHubTokenManager
|
|
43
|
+
>[0]["auth"];
|
|
44
|
+
|
|
45
|
+
/** Production installations manager built from live GH_APP_* env vars. */
|
|
46
|
+
function createInstallationsManager(
|
|
47
|
+
onTokensChanged: () => void,
|
|
48
|
+
reporter: GitHubInstallationsReporter,
|
|
49
|
+
): GitHubInstallationsManager {
|
|
50
|
+
const env = process.env;
|
|
51
|
+
const appAuth = createAppAuth({
|
|
52
|
+
appId: env.GH_APP_ID ?? "",
|
|
53
|
+
privateKey: (env.GH_APP_PRIVATE_KEY ?? "").replace(/\\n/g, "\n"),
|
|
54
|
+
});
|
|
55
|
+
const auth: InstallationAuthFn = async (params) =>
|
|
56
|
+
appAuth(
|
|
57
|
+
params as Parameters<typeof appAuth>[0],
|
|
58
|
+
) as ReturnType<InstallationAuthFn>;
|
|
59
|
+
const pin = Number(env.GH_APP_INSTALLATION_ID);
|
|
60
|
+
const manager: GitHubInstallationsManager = new GitHubInstallationsManager({
|
|
61
|
+
auth,
|
|
62
|
+
pinnedId: Number.isInteger(pin) && pin > 0 ? pin : null,
|
|
63
|
+
onEvent: (event) => {
|
|
64
|
+
if (event.type === "minted") onTokensChanged();
|
|
65
|
+
},
|
|
66
|
+
onChange: (state) => {
|
|
67
|
+
onTokensChanged();
|
|
68
|
+
void reporter.report(state);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
startInstallationsHeartbeat(reporter, () => manager.getState());
|
|
72
|
+
return manager;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param agentHome persistent agent home dir — the token file lives at `${agentHome}/gh-token`.
|
|
77
|
+
* @param scriptsBin directory containing `git-credential-shipwright.sh`.
|
|
78
|
+
* @param opts.withInstallations enable installation discovery (index.ts only).
|
|
79
|
+
*/
|
|
80
|
+
export function buildGitHubAuthDeps(
|
|
81
|
+
agentHome: string,
|
|
82
|
+
scriptsBin: string,
|
|
83
|
+
opts: {
|
|
84
|
+
withInstallations?: boolean;
|
|
85
|
+
installationsReporter?: GitHubInstallationsReporter;
|
|
86
|
+
} = {},
|
|
87
|
+
): GitHubAuthDeps {
|
|
88
|
+
const tokenPath = join(agentHome, "gh-token");
|
|
89
|
+
|
|
90
|
+
const installations: GitHubInstallationsDeps | undefined =
|
|
91
|
+
opts.withInstallations
|
|
92
|
+
? {
|
|
93
|
+
createManager: (onTokensChanged) =>
|
|
94
|
+
createInstallationsManager(
|
|
95
|
+
onTokensChanged,
|
|
96
|
+
opts.installationsReporter ??
|
|
97
|
+
new NoopGitHubInstallationsReporter(),
|
|
98
|
+
),
|
|
99
|
+
getScopeOwners: () => scopeOwnersOf(agentReposRef),
|
|
100
|
+
writeTokenFiles: (input) => writeTokenFiles(agentHome, input),
|
|
101
|
+
onActivated: (manager) => githubInstallationsManagerRef.set(manager),
|
|
102
|
+
}
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
env: process.env as Record<string, string | undefined>,
|
|
107
|
+
createTokenManager: createGitHubTokenManager,
|
|
108
|
+
getBotIdentity,
|
|
109
|
+
spawnSync: (cmd, args, opts) => {
|
|
110
|
+
const proc = Bun.spawnSync([cmd, ...args], {
|
|
111
|
+
stdio:
|
|
112
|
+
opts.stdio === "inherit"
|
|
113
|
+
? ["inherit", "inherit", "inherit"]
|
|
114
|
+
: ["pipe", "pipe", "pipe"],
|
|
115
|
+
env: opts.env as Record<string, string>,
|
|
116
|
+
});
|
|
117
|
+
return { status: proc.exitCode };
|
|
118
|
+
},
|
|
119
|
+
// Single-installation path: `gh-token` only — `gh-token.d/` belongs to
|
|
120
|
+
// the installations manager (see module docstring).
|
|
121
|
+
writeToken: (token: string) => {
|
|
122
|
+
writeFileAtomic(tokenPath, token);
|
|
123
|
+
},
|
|
124
|
+
tokenPath,
|
|
125
|
+
credentialHelperPath: join(scriptsBin, "git-credential-shipwright.sh"),
|
|
126
|
+
installations,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/github-auth-startup.ts
|
|
3
|
+
*
|
|
4
|
+
* Decision logic + live "already active" flag for (re)starting GitHub App
|
|
5
|
+
* auth once credentials are available — whether that happens at boot
|
|
6
|
+
* (entrypoint-main.ts's one-shot setupGitHubAuth() call, Step 5) or later,
|
|
7
|
+
* when index.ts's syncConfig() picks up GH_APP_ID / GH_APP_PRIVATE_KEY
|
|
8
|
+
* (plus the optional GH_APP_INSTALLATION_ID pin) saved to the admin DB after
|
|
9
|
+
* this agent-server process already started, or when an App installation
|
|
10
|
+
* appears later.
|
|
11
|
+
*
|
|
12
|
+
* Why this exists: entrypoint-main.ts spawns index.ts as a SEPARATE child
|
|
13
|
+
* process (spawnAgentServer) and just waits for it to exit — index.ts does
|
|
14
|
+
* not share in-memory state (e.g. a GitHubTokenManager instance) with
|
|
15
|
+
* entrypoint-main.ts's process. If a GitHub App is installed after the pod
|
|
16
|
+
* boots, entrypoint-main.ts's one-shot setupGitHubAuth() call already ran
|
|
17
|
+
* and found no credentials; index.ts's syncConfig() re-applies process.env
|
|
18
|
+
* every tick but had no hook to retry GitHub App auth setup in its own
|
|
19
|
+
* process — the agent stayed unable to authenticate as the App until a full
|
|
20
|
+
* pod restart. This module gives syncConfig() a single, shared, testable
|
|
21
|
+
* decision: "are credentials live now, and has GitHub App auth not already
|
|
22
|
+
* been set up successfully in this process? If so, set it up — exactly
|
|
23
|
+
* once."
|
|
24
|
+
*
|
|
25
|
+
* setupGitHubAuth() (setup-github-auth.ts) itself is NOT safely
|
|
26
|
+
* re-invokable: each call creates a brand-new GitHubTokenManager and calls
|
|
27
|
+
* startBackgroundRefresh() on it. A manager's startBackgroundRefresh() only
|
|
28
|
+
* stops ITS OWN prior interval — a second, independent manager from a
|
|
29
|
+
* second setupGitHubAuth() call has its own separate interval that the
|
|
30
|
+
* first manager's stopBackgroundRefresh() never touches, leaking a
|
|
31
|
+
* duplicate background refresh. The isActive() guard below is what must
|
|
32
|
+
* prevent a second call after a successful setup — setupGitHubAuth() itself
|
|
33
|
+
* does not protect against repeated invocation.
|
|
34
|
+
*
|
|
35
|
+
* Kept dependency-injected and I/O-free itself (matches the project's
|
|
36
|
+
* no-`mock.module()` isolation rule) — the real setupGitHubAuth() call is
|
|
37
|
+
* injected by the caller (index.ts), so this module's own tests use
|
|
38
|
+
* fakes/spies instead of a real GitHub App. Mirrors slack-startup.ts's
|
|
39
|
+
* ref/guard style for the "already active" flag and the start-in-flight
|
|
40
|
+
* guard so no module-scope mutable state leaks across test cases (each test
|
|
41
|
+
* constructs its own via createGitHubAuthActiveRef()/
|
|
42
|
+
* createGitHubAuthStartGuard()).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { AgentReposRef } from "./agent-repos-ref.ts";
|
|
46
|
+
import type { InstallationsSnapshot } from "./github-installations.ts";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True iff the GitHub App id and private key are present. The installation
|
|
50
|
+
* id (GH_APP_INSTALLATION_ID) is an optional pin — without it, installations
|
|
51
|
+
* are discovered at setup time, so credentials alone don't guarantee setup
|
|
52
|
+
* activates (see setupGitHubAuth's boolean result).
|
|
53
|
+
*/
|
|
54
|
+
export function hasGitHubAppCredentials(
|
|
55
|
+
env: Record<string, string | undefined>,
|
|
56
|
+
): boolean {
|
|
57
|
+
return Boolean(env.GH_APP_ID && env.GH_APP_PRIVATE_KEY);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The slice of GitHubInstallationsManager the config-sync tick needs. */
|
|
61
|
+
interface ReconcilableInstallations {
|
|
62
|
+
reconcile(scopeOwners: string[] | null): Promise<void>;
|
|
63
|
+
/** Current installations (the real manager has it); used for the default owner. */
|
|
64
|
+
getState?(): InstallationsSnapshot;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface GitHubInstallationsManagerRef {
|
|
68
|
+
/** The installations manager App auth activated with, or null if none. */
|
|
69
|
+
get(): ReconcilableInstallations | null;
|
|
70
|
+
set(manager: ReconcilableInstallations | null): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createGitHubInstallationsManagerRef(): GitHubInstallationsManagerRef {
|
|
74
|
+
let manager: ReconcilableInstallations | null = null;
|
|
75
|
+
return {
|
|
76
|
+
get: () => manager,
|
|
77
|
+
set: (next) => {
|
|
78
|
+
manager = next;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Process-wide installations manager ref — set by setupGitHubAuth() once App
|
|
85
|
+
* auth activates via the manager, read by index.ts's syncConfig() tick.
|
|
86
|
+
*/
|
|
87
|
+
export const githubInstallationsManagerRef: GitHubInstallationsManagerRef =
|
|
88
|
+
createGitHubInstallationsManagerRef();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Unique lowercase repo owners the agent is scoped to, or null while scope
|
|
92
|
+
* has never synced — the installations manager treats null as pinned-only.
|
|
93
|
+
*/
|
|
94
|
+
export function scopeOwnersOf(
|
|
95
|
+
reposRef: Pick<AgentReposRef, "get" | "hasSynced">,
|
|
96
|
+
): string[] | null {
|
|
97
|
+
if (!reposRef.hasSynced()) return null;
|
|
98
|
+
const owners = reposRef
|
|
99
|
+
.get()
|
|
100
|
+
.map((repo) => repo.split("/")[0]?.toLowerCase())
|
|
101
|
+
.filter((owner): owner is string => Boolean(owner));
|
|
102
|
+
return [...new Set(owners)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Re-runs installation discovery/selection against the current repo scope.
|
|
107
|
+
* Returns false (no-op) when App auth hasn't activated via the manager.
|
|
108
|
+
*/
|
|
109
|
+
export async function reconcileGitHubInstallations(
|
|
110
|
+
managerRef: GitHubInstallationsManagerRef,
|
|
111
|
+
reposRef: Pick<AgentReposRef, "get" | "hasSynced">,
|
|
112
|
+
): Promise<boolean> {
|
|
113
|
+
const manager = managerRef.get();
|
|
114
|
+
if (!manager) return false;
|
|
115
|
+
await manager.reconcile(scopeOwnersOf(reposRef));
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface GitHubAuthActiveRef {
|
|
120
|
+
/** True once GitHub App auth has been set up successfully in this process. */
|
|
121
|
+
isActive(): boolean;
|
|
122
|
+
setActive(active: boolean): void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Creates a new, independent "already active" ref defaulting to false. */
|
|
126
|
+
export function createGitHubAuthActiveRef(): GitHubAuthActiveRef {
|
|
127
|
+
let active = false;
|
|
128
|
+
return {
|
|
129
|
+
isActive: (): boolean => active,
|
|
130
|
+
setActive: (next: boolean): void => {
|
|
131
|
+
active = next;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The process-wide "already active" ref used by index.ts's syncConfig()
|
|
138
|
+
* call site.
|
|
139
|
+
*/
|
|
140
|
+
export const githubAuthActiveRef: GitHubAuthActiveRef =
|
|
141
|
+
createGitHubAuthActiveRef();
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Guards startGitHubAuthIfPossible() against overlapping callers — e.g. two
|
|
145
|
+
* syncConfig() ticks racing because syncConfig() is dispatched via an
|
|
146
|
+
* unawaited setInterval. Kept as its own tiny ref (rather than a
|
|
147
|
+
* module-scope `let`) so tests can construct an independent, disposable
|
|
148
|
+
* guard instead of mutating shared state that would otherwise leak between
|
|
149
|
+
* test cases.
|
|
150
|
+
*/
|
|
151
|
+
interface GitHubAuthStartGuard {
|
|
152
|
+
isInFlight(): boolean;
|
|
153
|
+
set(inFlight: boolean): void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createGitHubAuthStartGuard(): GitHubAuthStartGuard {
|
|
157
|
+
let inFlight = false;
|
|
158
|
+
return {
|
|
159
|
+
isInFlight: (): boolean => inFlight,
|
|
160
|
+
set: (next: boolean): void => {
|
|
161
|
+
inFlight = next;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The process-wide start-in-flight guard used by index.ts's syncConfig()
|
|
168
|
+
* call site. A single shared instance is required in production so
|
|
169
|
+
* overlapping ticks can't race each other; tests should construct their own
|
|
170
|
+
* via createGitHubAuthStartGuard() instead of reusing this singleton.
|
|
171
|
+
*/
|
|
172
|
+
const githubAuthStartGuard: GitHubAuthStartGuard = createGitHubAuthStartGuard();
|
|
173
|
+
|
|
174
|
+
export interface StartGitHubAuthDeps {
|
|
175
|
+
/** Live process env, read at call time (not a static snapshot). */
|
|
176
|
+
env: Record<string, string | undefined>;
|
|
177
|
+
/** True once GitHub App auth has already been set up successfully (by any prior call in this process). */
|
|
178
|
+
isActive: () => boolean;
|
|
179
|
+
/** Records that setup succeeded so isActive() reflects it on the next call. */
|
|
180
|
+
markActive: () => void;
|
|
181
|
+
/**
|
|
182
|
+
* Performs the actual setup — real prod wiring or a test fake. Resolves
|
|
183
|
+
* true iff GitHub App auth activated; false (e.g. zero usable
|
|
184
|
+
* installations, PAT path) leaves it inactive so the next tick retries.
|
|
185
|
+
* Errors propagate.
|
|
186
|
+
*/
|
|
187
|
+
setupGitHubAuth: () => Promise<boolean>;
|
|
188
|
+
/** Start-in-flight guard — defaults to the process-wide singleton; tests inject their own. */
|
|
189
|
+
guard?: GitHubAuthStartGuard;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Attempts to set up GitHub App auth if — and only if — credentials are
|
|
194
|
+
* complete right now AND setup hasn't already succeeded in this process.
|
|
195
|
+
* Safe to call on every syncConfig() tick: a no-op when credentials are
|
|
196
|
+
* still incomplete, and a no-op once a prior call already succeeded.
|
|
197
|
+
*
|
|
198
|
+
* Returns true iff this call ran setup AND App auth activated; false for
|
|
199
|
+
* every no-op path (incomplete credentials, already active, a racing call
|
|
200
|
+
* that lost the in-flight guard) and for a setup that did not activate (no
|
|
201
|
+
* pin and no usable installation yet) — that case is retried next tick.
|
|
202
|
+
*
|
|
203
|
+
* Errors from `setupGitHubAuth()` propagate to the caller (not swallowed
|
|
204
|
+
* here) so index.ts's call site can log a start failure distinctly from a
|
|
205
|
+
* config-bundle fetch/env-sync failure — see the call site in index.ts.
|
|
206
|
+
*/
|
|
207
|
+
export async function startGitHubAuthIfPossible(
|
|
208
|
+
deps: StartGitHubAuthDeps,
|
|
209
|
+
): Promise<boolean> {
|
|
210
|
+
const guard = deps.guard ?? githubAuthStartGuard;
|
|
211
|
+
|
|
212
|
+
if (guard.isInFlight()) return false;
|
|
213
|
+
if (deps.isActive()) return false;
|
|
214
|
+
if (!hasGitHubAppCredentials(deps.env)) return false;
|
|
215
|
+
|
|
216
|
+
guard.set(true);
|
|
217
|
+
try {
|
|
218
|
+
// Re-check after acquiring the guard in case another call already
|
|
219
|
+
// succeeded between the check above and here — defense in depth on top
|
|
220
|
+
// of the guard itself (JS has no preemption between the isActive check
|
|
221
|
+
// and the set(true) above, so this branch is unreachable today, but
|
|
222
|
+
// keeps the invariant obvious even if that ordering ever changes).
|
|
223
|
+
if (deps.isActive()) return false;
|
|
224
|
+
|
|
225
|
+
if (!(await deps.setupGitHubAuth())) return false;
|
|
226
|
+
deps.markActive();
|
|
227
|
+
return true;
|
|
228
|
+
} finally {
|
|
229
|
+
guard.set(false);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/github-auth-startup.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for startGitHubAuthIfPossible() and the "already active" ref —
|
|
5
|
+
* pure logic, no I/O. The real setupGitHubAuth() call is a fake/spy injected
|
|
6
|
+
* via StartGitHubAuthDeps; no real network, git, or `mock.module()` usage.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { createAgentReposRef } from "./agent-repos-ref.ts";
|
|
11
|
+
import {
|
|
12
|
+
createGitHubAuthActiveRef,
|
|
13
|
+
createGitHubAuthStartGuard,
|
|
14
|
+
createGitHubInstallationsManagerRef,
|
|
15
|
+
githubAuthActiveRef,
|
|
16
|
+
hasGitHubAppCredentials,
|
|
17
|
+
reconcileGitHubInstallations,
|
|
18
|
+
type StartGitHubAuthDeps,
|
|
19
|
+
scopeOwnersOf,
|
|
20
|
+
startGitHubAuthIfPossible,
|
|
21
|
+
} from "./github-auth-startup.ts";
|
|
22
|
+
|
|
23
|
+
const COMPLETE_ENV: Record<string, string | undefined> = {
|
|
24
|
+
GH_APP_ID: "12345",
|
|
25
|
+
GH_APP_INSTALLATION_ID: "67890",
|
|
26
|
+
GH_APP_PRIVATE_KEY:
|
|
27
|
+
"-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Builds a fresh, fully-independent set of fakes for one test case —
|
|
32
|
+
* mirrors production semantics (isActive flips true only after markActive)
|
|
33
|
+
* but with no real GitHub App I/O, and returns spy call counters for
|
|
34
|
+
* assertions.
|
|
35
|
+
*/
|
|
36
|
+
function buildFakeDeps(envOverrides?: Record<string, string | undefined>) {
|
|
37
|
+
const activeRef = createGitHubAuthActiveRef();
|
|
38
|
+
const guard = createGitHubAuthStartGuard();
|
|
39
|
+
const calls = { setupGitHubAuth: 0 };
|
|
40
|
+
|
|
41
|
+
const deps: StartGitHubAuthDeps = {
|
|
42
|
+
env: { ...COMPLETE_ENV, ...envOverrides },
|
|
43
|
+
isActive: activeRef.isActive,
|
|
44
|
+
markActive: () => activeRef.setActive(true),
|
|
45
|
+
setupGitHubAuth: async () => {
|
|
46
|
+
calls.setupGitHubAuth++;
|
|
47
|
+
return true;
|
|
48
|
+
},
|
|
49
|
+
guard,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return { deps, calls, activeRef, guard };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("hasGitHubAppCredentials", () => {
|
|
56
|
+
it("is true when all three GitHub App env vars are present", () => {
|
|
57
|
+
expect(hasGitHubAppCredentials(COMPLETE_ENV)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("is false when GH_APP_ID is missing", () => {
|
|
61
|
+
expect(
|
|
62
|
+
hasGitHubAppCredentials({ ...COMPLETE_ENV, GH_APP_ID: undefined }),
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("is true when GH_APP_INSTALLATION_ID is missing (the installation id is an optional pin)", () => {
|
|
67
|
+
expect(
|
|
68
|
+
hasGitHubAppCredentials({
|
|
69
|
+
...COMPLETE_ENV,
|
|
70
|
+
GH_APP_INSTALLATION_ID: undefined,
|
|
71
|
+
}),
|
|
72
|
+
).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is false when GH_APP_PRIVATE_KEY is missing", () => {
|
|
76
|
+
expect(
|
|
77
|
+
hasGitHubAppCredentials({
|
|
78
|
+
...COMPLETE_ENV,
|
|
79
|
+
GH_APP_PRIVATE_KEY: undefined,
|
|
80
|
+
}),
|
|
81
|
+
).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("is false when all three are missing", () => {
|
|
85
|
+
expect(
|
|
86
|
+
hasGitHubAppCredentials({
|
|
87
|
+
GH_APP_ID: undefined,
|
|
88
|
+
GH_APP_INSTALLATION_ID: undefined,
|
|
89
|
+
GH_APP_PRIVATE_KEY: undefined,
|
|
90
|
+
}),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("startGitHubAuthIfPossible", () => {
|
|
96
|
+
it("credentials complete on a later tick (not boot) triggers exactly one GitHub auth setup", async () => {
|
|
97
|
+
const { deps, calls, activeRef } = buildFakeDeps();
|
|
98
|
+
|
|
99
|
+
const started = await startGitHubAuthIfPossible(deps);
|
|
100
|
+
|
|
101
|
+
expect(started).toBe(true);
|
|
102
|
+
expect(calls.setupGitHubAuth).toBe(1);
|
|
103
|
+
expect(activeRef.isActive()).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("a later tick with GitHub auth already active does not re-trigger setup (regression: no duplicate refresh interval)", async () => {
|
|
107
|
+
const { deps, calls } = buildFakeDeps();
|
|
108
|
+
|
|
109
|
+
const first = await startGitHubAuthIfPossible(deps);
|
|
110
|
+
const second = await startGitHubAuthIfPossible(deps);
|
|
111
|
+
|
|
112
|
+
expect(first).toBe(true);
|
|
113
|
+
expect(second).toBe(false);
|
|
114
|
+
// Critical: setupGitHubAuth must be called exactly once across both
|
|
115
|
+
// ticks — a second call would create a second GitHubTokenManager with
|
|
116
|
+
// its own independent background-refresh interval that the first
|
|
117
|
+
// manager's stopBackgroundRefresh() never touches (see module docstring).
|
|
118
|
+
expect(calls.setupGitHubAuth).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("a tick with incomplete credentials (missing GH_APP_ID) does not attempt setup", async () => {
|
|
122
|
+
const { deps, calls } = buildFakeDeps({ GH_APP_ID: undefined });
|
|
123
|
+
|
|
124
|
+
const started = await startGitHubAuthIfPossible(deps);
|
|
125
|
+
|
|
126
|
+
expect(started).toBe(false);
|
|
127
|
+
expect(calls.setupGitHubAuth).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("a tick with App id + key but no GH_APP_INSTALLATION_ID pin attempts setup (discovery may find installations)", async () => {
|
|
131
|
+
const { deps, calls } = buildFakeDeps({
|
|
132
|
+
GH_APP_INSTALLATION_ID: undefined,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const started = await startGitHubAuthIfPossible(deps);
|
|
136
|
+
|
|
137
|
+
expect(started).toBe(true);
|
|
138
|
+
expect(calls.setupGitHubAuth).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("setup that does not activate App auth (e.g. zero installations) is not marked active and is retried next tick", async () => {
|
|
142
|
+
const { deps, calls, activeRef, guard } = buildFakeDeps({
|
|
143
|
+
GH_APP_INSTALLATION_ID: undefined,
|
|
144
|
+
});
|
|
145
|
+
deps.setupGitHubAuth = async () => {
|
|
146
|
+
calls.setupGitHubAuth++;
|
|
147
|
+
return false;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
expect(await startGitHubAuthIfPossible(deps)).toBe(false);
|
|
151
|
+
expect(activeRef.isActive()).toBe(false);
|
|
152
|
+
expect(guard.isInFlight()).toBe(false);
|
|
153
|
+
|
|
154
|
+
expect(await startGitHubAuthIfPossible(deps)).toBe(false);
|
|
155
|
+
expect(calls.setupGitHubAuth).toBe(2);
|
|
156
|
+
|
|
157
|
+
// Installations appear later: setup activates exactly once, then stops.
|
|
158
|
+
deps.setupGitHubAuth = async () => {
|
|
159
|
+
calls.setupGitHubAuth++;
|
|
160
|
+
return true;
|
|
161
|
+
};
|
|
162
|
+
expect(await startGitHubAuthIfPossible(deps)).toBe(true);
|
|
163
|
+
expect(await startGitHubAuthIfPossible(deps)).toBe(false);
|
|
164
|
+
expect(calls.setupGitHubAuth).toBe(3);
|
|
165
|
+
expect(activeRef.isActive()).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("a tick with incomplete credentials (missing GH_APP_PRIVATE_KEY) does not attempt setup", async () => {
|
|
169
|
+
const { deps, calls } = buildFakeDeps({ GH_APP_PRIVATE_KEY: undefined });
|
|
170
|
+
|
|
171
|
+
const started = await startGitHubAuthIfPossible(deps);
|
|
172
|
+
|
|
173
|
+
expect(started).toBe(false);
|
|
174
|
+
expect(calls.setupGitHubAuth).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("a tick with all credentials missing does not attempt setup", async () => {
|
|
178
|
+
const { deps, calls } = buildFakeDeps({
|
|
179
|
+
GH_APP_ID: undefined,
|
|
180
|
+
GH_APP_INSTALLATION_ID: undefined,
|
|
181
|
+
GH_APP_PRIVATE_KEY: undefined,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const started = await startGitHubAuthIfPossible(deps);
|
|
185
|
+
|
|
186
|
+
expect(started).toBe(false);
|
|
187
|
+
expect(calls.setupGitHubAuth).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("overlapping/concurrent calls (two ticks racing before the first setup resolves) do not double-setup", async () => {
|
|
191
|
+
const { deps, calls } = buildFakeDeps();
|
|
192
|
+
|
|
193
|
+
let releaseSetup: () => void = () => {};
|
|
194
|
+
const setupGate = new Promise<void>((resolve) => {
|
|
195
|
+
releaseSetup = resolve;
|
|
196
|
+
});
|
|
197
|
+
deps.setupGitHubAuth = async () => {
|
|
198
|
+
await setupGate;
|
|
199
|
+
calls.setupGitHubAuth++;
|
|
200
|
+
return true;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const firstCall = startGitHubAuthIfPossible(deps);
|
|
204
|
+
const secondCall = startGitHubAuthIfPossible(deps);
|
|
205
|
+
|
|
206
|
+
releaseSetup();
|
|
207
|
+
const [first, second] = await Promise.all([firstCall, secondCall]);
|
|
208
|
+
|
|
209
|
+
expect([first, second].filter(Boolean)).toHaveLength(1);
|
|
210
|
+
expect(calls.setupGitHubAuth).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("errors from setupGitHubAuth propagate to the caller rather than being swallowed", async () => {
|
|
214
|
+
const { deps } = buildFakeDeps();
|
|
215
|
+
deps.setupGitHubAuth = async () => {
|
|
216
|
+
throw new Error("boom");
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
await expect(startGitHubAuthIfPossible(deps)).rejects.toThrow("boom");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("errors from setupGitHubAuth do not mark GitHub auth active, so a subsequent retry can proceed", async () => {
|
|
223
|
+
const { deps, calls, activeRef, guard } = buildFakeDeps();
|
|
224
|
+
deps.setupGitHubAuth = async () => {
|
|
225
|
+
throw new Error("boom");
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
await expect(startGitHubAuthIfPossible(deps)).rejects.toThrow("boom");
|
|
229
|
+
expect(activeRef.isActive()).toBe(false);
|
|
230
|
+
expect(guard.isInFlight()).toBe(false);
|
|
231
|
+
|
|
232
|
+
// Retry with working deps — should succeed since setup never actually completed.
|
|
233
|
+
deps.setupGitHubAuth = async () => {
|
|
234
|
+
calls.setupGitHubAuth++;
|
|
235
|
+
return true;
|
|
236
|
+
};
|
|
237
|
+
const started = await startGitHubAuthIfPossible(deps);
|
|
238
|
+
expect(started).toBe(true);
|
|
239
|
+
expect(activeRef.isActive()).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("GitHubAuthActiveRef", () => {
|
|
244
|
+
it("createGitHubAuthActiveRef() defaults to inactive", () => {
|
|
245
|
+
const ref = createGitHubAuthActiveRef();
|
|
246
|
+
expect(ref.isActive()).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("setActive(true) is reflected by the next isActive()", () => {
|
|
250
|
+
const ref = createGitHubAuthActiveRef();
|
|
251
|
+
ref.setActive(true);
|
|
252
|
+
expect(ref.isActive()).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("multiple independent ref instances don't share state", () => {
|
|
256
|
+
const refA = createGitHubAuthActiveRef();
|
|
257
|
+
const refB = createGitHubAuthActiveRef();
|
|
258
|
+
|
|
259
|
+
refA.setActive(true);
|
|
260
|
+
|
|
261
|
+
expect(refA.isActive()).toBe(true);
|
|
262
|
+
expect(refB.isActive()).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("githubAuthActiveRef (process-wide singleton)", () => {
|
|
267
|
+
it("is a working ref, independent of createGitHubAuthActiveRef() instances", () => {
|
|
268
|
+
const independent = createGitHubAuthActiveRef();
|
|
269
|
+
independent.setActive(true);
|
|
270
|
+
|
|
271
|
+
expect(githubAuthActiveRef.isActive()).toBe(false);
|
|
272
|
+
githubAuthActiveRef.setActive(true);
|
|
273
|
+
expect(githubAuthActiveRef.isActive()).toBe(true);
|
|
274
|
+
expect(independent.isActive()).toBe(true);
|
|
275
|
+
|
|
276
|
+
// Reset the process-wide singleton so this test doesn't leak state into siblings.
|
|
277
|
+
githubAuthActiveRef.setActive(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("scopeOwnersOf", () => {
|
|
282
|
+
it("is null while scope has never synced (pinned-only)", () => {
|
|
283
|
+
expect(scopeOwnersOf(createAgentReposRef())).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("returns the unique, lowercased owners of the synced repos", () => {
|
|
287
|
+
const ref = createAgentReposRef();
|
|
288
|
+
ref.set(["Acme/api", "acme/web", "Other/tool"]);
|
|
289
|
+
expect(scopeOwnersOf(ref)).toEqual(["acme", "other"]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("is an empty list (not null) when synced to an empty scope", () => {
|
|
293
|
+
const ref = createAgentReposRef();
|
|
294
|
+
ref.set([]);
|
|
295
|
+
expect(scopeOwnersOf(ref)).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("reconcileGitHubInstallations", () => {
|
|
300
|
+
it("is a no-op when no installations manager is active", async () => {
|
|
301
|
+
const managerRef = createGitHubInstallationsManagerRef();
|
|
302
|
+
const reposRef = createAgentReposRef();
|
|
303
|
+
reposRef.set(["acme/api"]);
|
|
304
|
+
await expect(
|
|
305
|
+
reconcileGitHubInstallations(managerRef, reposRef),
|
|
306
|
+
).resolves.toBe(false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("reconciles the active manager against the current scope owners", async () => {
|
|
310
|
+
const managerRef = createGitHubInstallationsManagerRef();
|
|
311
|
+
const reposRef = createAgentReposRef();
|
|
312
|
+
const seen: (string[] | null)[] = [];
|
|
313
|
+
managerRef.set({
|
|
314
|
+
reconcile: async (owners) => {
|
|
315
|
+
seen.push(owners);
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(await reconcileGitHubInstallations(managerRef, reposRef)).toBe(true);
|
|
320
|
+
reposRef.set(["Acme/api"]);
|
|
321
|
+
expect(await reconcileGitHubInstallations(managerRef, reposRef)).toBe(true);
|
|
322
|
+
|
|
323
|
+
expect(seen).toEqual([null, ["acme"]]);
|
|
324
|
+
});
|
|
325
|
+
});
|