@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.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +38 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. 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
+ });