@app-vitals/agent-runtime 0.4.0 → 0.5.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/boot.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  planCronSchedule,
32
32
  runPeriodicPasses,
33
33
  } from "./agent-extension.ts";
34
+ import { agentFeaturesRef } from "./agent-features-ref.ts";
34
35
  import { agentReposRef } from "./agent-repos-ref.ts";
35
36
  import {
36
37
  agentSlackMembershipRef,
@@ -50,7 +51,6 @@ import {
50
51
  setLiveClaudeConfig,
51
52
  } from "./claude.ts";
52
53
  import { bunExec, detectClaudeCodeVersion } from "./claude-version.ts";
53
- import { agentFeaturesRef } from "./agent-features-ref.ts";
54
54
  import { cleanupAfterDaysRef } from "./cleanup-after-days-ref.ts";
55
55
  import { SystemClock } from "./clock.ts";
56
56
  import { createConfig } from "./config.ts";
@@ -85,6 +85,10 @@ import {
85
85
  import { HttpChatServiceClient } from "./http-chat-service-client.ts";
86
86
  import { buildLogPrefix } from "./log-prefix.ts";
87
87
  import { validatePiperVoice } from "./piper-voice.ts";
88
+ import {
89
+ createRepoAccessProbe,
90
+ createRepoAccessTokenFor,
91
+ } from "./repo-access-probe.ts";
88
92
  import {
89
93
  createFileSessionStore,
90
94
  startSessionPruner,
@@ -224,6 +228,17 @@ export async function startAgent(
224
228
  apiUrl: config.shipwright.apiUrl,
225
229
  agentId: config.shipwright.agentId,
226
230
  apiKey: config.shipwright.apiKey,
231
+ repoAccessProbe: createRepoAccessProbe({
232
+ fetch,
233
+ clock: SystemClock(),
234
+ log: (message) => console.log(message),
235
+ getRepos: () => agentReposRef.get(),
236
+ isEnabled: () => agentFeaturesRef.get()?.repoAccessCheck === true,
237
+ tokenFor: createRepoAccessTokenFor(
238
+ () => githubInstallationsManagerRef.get(),
239
+ process.env,
240
+ ),
241
+ }),
227
242
  })
228
243
  : new NoopGitHubInstallationsReporter();
229
244
 
@@ -19,6 +19,7 @@ import type {
19
19
  InstallationState,
20
20
  InstallationsSnapshot,
21
21
  } from "./github-installations.ts";
22
+ import type { RepoAccessEntry, RepoAccessProbe } from "./repo-access-probe.ts";
22
23
 
23
24
  export const INSTALLATIONS_HEARTBEAT_MS = 30 * 60 * 1000;
24
25
  const REQUEST_TIMEOUT_MS = 10_000;
@@ -56,6 +57,11 @@ interface HttpGitHubInstallationsReporterOptions {
56
57
  fetchFn?: typeof fetch;
57
58
  clock?: Clock;
58
59
  heartbeatMs?: number;
60
+ /**
61
+ * COS-44.1: per-repo access probe. It gates itself (flag, once per interval,
62
+ * scope change); the reporter just merges whatever it returns into the PUT.
63
+ */
64
+ repoAccessProbe?: RepoAccessProbe;
59
65
  }
60
66
 
61
67
  export class HttpGitHubInstallationsReporter
@@ -66,6 +72,7 @@ export class HttpGitHubInstallationsReporter
66
72
  private readonly heartbeatMs: number;
67
73
  private lastSignature: string | null = null;
68
74
  private lastPostedAt = 0;
75
+ private lastRepos: RepoAccessEntry[] | undefined;
69
76
 
70
77
  constructor(private opts: HttpGitHubInstallationsReporterOptions) {
71
78
  this.fetchFn = opts.fetchFn ?? fetch;
@@ -77,13 +84,19 @@ export class HttpGitHubInstallationsReporter
77
84
  try {
78
85
  const installations = snapshot.installations.map(toReported);
79
86
  const signature = JSON.stringify(installations);
87
+ const probed = await this.opts.repoAccessProbe?.run();
88
+ if (probed) this.lastRepos = probed;
80
89
  const nowMs = this.clock.now().getTime();
81
- const changed = signature !== this.lastSignature;
90
+ const changed = signature !== this.lastSignature || probed !== undefined;
82
91
  if (!changed && nowMs - this.lastPostedAt < this.heartbeatMs) return;
83
92
  // Claim the slot before awaiting so overlapping reports don't double-post.
84
93
  this.lastSignature = signature;
85
94
  this.lastPostedAt = nowMs;
86
- await this.put(installations, new Date(nowMs).toISOString());
95
+ await this.put(
96
+ installations,
97
+ new Date(nowMs).toISOString(),
98
+ this.lastRepos,
99
+ );
87
100
  } catch (err) {
88
101
  console.warn(
89
102
  `[github-installations-reporter] report failed: ${String(err)} — swallowing`,
@@ -94,6 +107,7 @@ export class HttpGitHubInstallationsReporter
94
107
  private async put(
95
108
  installations: ReportedInstallation[],
96
109
  reportedAt: string,
110
+ repos: RepoAccessEntry[] | undefined,
97
111
  ): Promise<void> {
98
112
  const { apiUrl, agentId, apiKey } = this.opts;
99
113
  const url = `${apiUrl}/agents/${agentId}/github-installations`;
@@ -104,7 +118,11 @@ export class HttpGitHubInstallationsReporter
104
118
  "Content-Type": "application/json",
105
119
  Authorization: `Bearer ${apiKey}`,
106
120
  },
107
- body: JSON.stringify({ reportedAt, installations }),
121
+ body: JSON.stringify({
122
+ reportedAt,
123
+ installations,
124
+ ...(repos ? { repos } : {}),
125
+ }),
108
126
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
109
127
  });
110
128
  if (!res.ok) {
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
7
7
  import type { InstallationsSnapshot } from "./github-installations.ts";
8
+ import type { RepoAccessEntry, RepoAccessProbe } from "./repo-access-probe.ts";
8
9
  import {
9
10
  HttpGitHubInstallationsReporter,
10
11
  INSTALLATIONS_HEARTBEAT_MS,
@@ -31,7 +32,10 @@ function snap(
31
32
  };
32
33
  }
33
34
 
34
- function harness(responses: (Response | Error)[] = []) {
35
+ function harness(
36
+ responses: (Response | Error)[] = [],
37
+ repoAccessProbe?: RepoAccessProbe,
38
+ ) {
35
39
  let nowMs = Date.UTC(2026, 0, 1);
36
40
  const calls: { url: string; init: RequestInit | undefined }[] = [];
37
41
  let i = 0;
@@ -47,6 +51,7 @@ function harness(responses: (Response | Error)[] = []) {
47
51
  apiKey: "KEY",
48
52
  fetchFn,
49
53
  clock: { now: () => new Date(nowMs) },
54
+ repoAccessProbe,
50
55
  });
51
56
  return {
52
57
  reporter,
@@ -164,3 +169,48 @@ describe("NoopGitHubInstallationsReporter", () => {
164
169
  await new NoopGitHubInstallationsReporter().report(snap());
165
170
  });
166
171
  });
172
+
173
+ describe("HttpGitHubInstallationsReporter repo access (COS-44.1)", () => {
174
+ const entry: RepoAccessEntry = {
175
+ repo: "a",
176
+ owner: "acme",
177
+ read: true,
178
+ write: false,
179
+ checkedAt: "2026-01-01T00:00:00.000Z",
180
+ };
181
+
182
+ test("a probe result is merged into the PUT body as repos", async () => {
183
+ const h = harness([], { run: async () => [entry] });
184
+ await h.reporter.report(snap());
185
+ expect(JSON.parse(String(h.calls[0].init?.body)).repos).toEqual([entry]);
186
+ });
187
+
188
+ test("a fresh probe result posts even when installations are unchanged", async () => {
189
+ let result: RepoAccessEntry[] | undefined = [entry];
190
+ const h = harness([], { run: async () => result });
191
+ await h.reporter.report(snap());
192
+ result = [{ ...entry, write: true }];
193
+ h.advance(60_000);
194
+ await h.reporter.report(snap());
195
+ expect(h.calls).toHaveLength(2);
196
+ expect(JSON.parse(String(h.calls[1].init?.body)).repos[0].write).toBe(true);
197
+ });
198
+
199
+ test("later heartbeats re-send the last probe result", async () => {
200
+ let result: RepoAccessEntry[] | undefined = [entry];
201
+ const h = harness([], { run: async () => result });
202
+ await h.reporter.report(snap());
203
+ result = undefined;
204
+ h.advance(INSTALLATIONS_HEARTBEAT_MS);
205
+ await h.reporter.report(snap());
206
+ expect(JSON.parse(String(h.calls[1].init?.body)).repos).toEqual([entry]);
207
+ });
208
+
209
+ test("no probe result -> body has no repos key", async () => {
210
+ const h = harness([], { run: async () => undefined });
211
+ await h.reporter.report(snap());
212
+ expect(
213
+ Object.keys(JSON.parse(String(h.calls[0].init?.body))).sort(),
214
+ ).toEqual(["installations", "reportedAt"]);
215
+ });
216
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-runtime",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -40,8 +40,8 @@
40
40
  "./workspace-paths": "./workspace-paths.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@app-vitals/agent-admin": "0.4.0",
44
- "@app-vitals/agent-lib": "0.4.0",
43
+ "@app-vitals/agent-admin": "0.5.0",
44
+ "@app-vitals/agent-lib": "0.5.0",
45
45
  "@octokit/auth-app": "^8.2.0",
46
46
  "@sentry/bun": "^10.63.0",
47
47
  "@slack/bolt": "^5.0.0",
@@ -0,0 +1,193 @@
1
+ /**
2
+ * runtime/repo-access-probe.ts
3
+ *
4
+ * Per-repo GitHub access probe (COS-44.1). For each repo in the agent's scope it
5
+ * calls GET /repos/{owner}/{repo} with the owner's installation token (or the
6
+ * PAT) and maps `permissions.pull` -> read, `permissions.push` -> write. A
7
+ * failure becomes read:false, write:false plus a fixed error class
8
+ * (not_found | forbidden | rate_limited | network).
9
+ *
10
+ * Gating lives here, not in the caller: run() does nothing (zero fetch calls)
11
+ * unless the feature flag is on, and then probes at most once per interval or
12
+ * when the repo scope changed. Concurrency is capped at 3. The token is passed
13
+ * only in the Authorization header — never logged, never in the result.
14
+ * Everything is injected so it is deterministically testable.
15
+ */
16
+
17
+ import type { Clock } from "./clock.ts";
18
+
19
+ export const REPO_ACCESS_INTERVAL_MS = 30 * 60 * 1000;
20
+ const REPO_ACCESS_CONCURRENCY = 3;
21
+ const REQUEST_TIMEOUT_MS = 10_000;
22
+
23
+ export type RepoAccessErrorClass =
24
+ | "not_found"
25
+ | "forbidden"
26
+ | "rate_limited"
27
+ | "network";
28
+
29
+ export interface RepoAccessEntry {
30
+ repo: string;
31
+ owner: string;
32
+ read: boolean;
33
+ write: boolean;
34
+ checkedAt: string;
35
+ error?: RepoAccessErrorClass;
36
+ }
37
+
38
+ export interface RepoAccessProbeDeps {
39
+ fetch: typeof fetch;
40
+ clock: Clock;
41
+ /** Token for the owner's installation, the PAT, or null when there is none. */
42
+ tokenFor(owner: string): Promise<string | null>;
43
+ log(message: string): void;
44
+ /** Scoped repos as `org/repo` strings. */
45
+ getRepos(): string[];
46
+ isEnabled(): boolean;
47
+ intervalMs?: number;
48
+ }
49
+
50
+ export interface RepoAccessProbe {
51
+ /**
52
+ * Returns a fresh result when a probe round ran, or undefined when it did
53
+ * not (feature off, or not due and the scope is unchanged).
54
+ */
55
+ run(): Promise<RepoAccessEntry[] | undefined>;
56
+ }
57
+
58
+ function classify(res: Response): RepoAccessErrorClass {
59
+ if (res.status === 404) return "not_found";
60
+ if (res.status === 429) return "rate_limited";
61
+ if (res.status === 403) {
62
+ return res.headers.get("x-ratelimit-remaining") === "0"
63
+ ? "rate_limited"
64
+ : "forbidden";
65
+ }
66
+ if (res.status === 401) return "forbidden";
67
+ return "network";
68
+ }
69
+
70
+ function failed(
71
+ owner: string,
72
+ repo: string,
73
+ checkedAt: string,
74
+ error: RepoAccessErrorClass,
75
+ ): RepoAccessEntry {
76
+ return { repo, owner, read: false, write: false, checkedAt, error };
77
+ }
78
+
79
+ export function createRepoAccessProbe(
80
+ deps: RepoAccessProbeDeps,
81
+ ): RepoAccessProbe {
82
+ const intervalMs = deps.intervalMs ?? REPO_ACCESS_INTERVAL_MS;
83
+ let lastScope: string | null = null;
84
+ let lastRunAt = 0;
85
+ let running = false;
86
+
87
+ async function probeOne(slug: string): Promise<RepoAccessEntry> {
88
+ const [owner = "", repo = ""] = slug.split("/");
89
+ const checkedAt = deps.clock.now().toISOString();
90
+ try {
91
+ const token = await deps.tokenFor(owner);
92
+ if (!token) return failed(owner, repo, checkedAt, "forbidden");
93
+ const res = await deps.fetch(
94
+ `https://api.github.com/repos/${owner}/${repo}`,
95
+ {
96
+ headers: {
97
+ Authorization: `Bearer ${token}`,
98
+ Accept: "application/vnd.github+json",
99
+ },
100
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
101
+ },
102
+ );
103
+ if (!res.ok) return failed(owner, repo, checkedAt, classify(res));
104
+ const body = (await res.json()) as {
105
+ permissions?: { pull?: boolean; push?: boolean };
106
+ };
107
+ return {
108
+ repo,
109
+ owner,
110
+ read: body.permissions?.pull === true,
111
+ write: body.permissions?.push === true,
112
+ checkedAt,
113
+ };
114
+ } catch {
115
+ // Never interpolate the error: a thrown message could carry request detail.
116
+ return failed(owner, repo, checkedAt, "network");
117
+ }
118
+ }
119
+
120
+ async function probeAll(slugs: string[]): Promise<RepoAccessEntry[]> {
121
+ const results: RepoAccessEntry[] = new Array(slugs.length);
122
+ let next = 0;
123
+ const worker = async () => {
124
+ while (next < slugs.length) {
125
+ const i = next++;
126
+ results[i] = await probeOne(slugs[i] as string);
127
+ }
128
+ };
129
+ await Promise.all(
130
+ Array.from(
131
+ { length: Math.min(REPO_ACCESS_CONCURRENCY, slugs.length) },
132
+ worker,
133
+ ),
134
+ );
135
+ return results;
136
+ }
137
+
138
+ return {
139
+ async run() {
140
+ if (!deps.isEnabled() || running) return undefined;
141
+ const slugs = [...new Set(deps.getRepos())]
142
+ .filter((r) => r.includes("/"))
143
+ .sort();
144
+ const scope = slugs.join(",");
145
+ const nowMs = deps.clock.now().getTime();
146
+ const scopeChanged = scope !== lastScope;
147
+ if (!scopeChanged && nowMs - lastRunAt < intervalMs) return undefined;
148
+ running = true;
149
+ lastScope = scope;
150
+ lastRunAt = nowMs;
151
+ try {
152
+ const results = await probeAll(slugs);
153
+ deps.log(
154
+ `[repo-access] probed ${results.length} repo(s), ${results.filter((r) => r.error).length} with errors`,
155
+ );
156
+ return results;
157
+ } finally {
158
+ running = false;
159
+ }
160
+ },
161
+ };
162
+ }
163
+
164
+ interface InstallationTokenSource {
165
+ getState?(): {
166
+ installations: { id: number; owner: string | null; health: string }[];
167
+ };
168
+ /** Present on the real manager; the ref's narrower type omits it. */
169
+ getToken?(installationId: number): Promise<string>;
170
+ }
171
+
172
+ /**
173
+ * Builds `tokenFor`: the healthy installation whose owner matches (case
174
+ * insensitive), else the PAT from GH_TOKEN, else null. The manager is read
175
+ * lazily because App auth may activate after boot.
176
+ */
177
+ export function createRepoAccessTokenFor(
178
+ getManager: () => InstallationTokenSource | null | undefined,
179
+ env: Readonly<Record<string, string | undefined>>,
180
+ ): (owner: string) => Promise<string | null> {
181
+ return async (owner) => {
182
+ const manager = getManager();
183
+ const match = manager
184
+ ?.getState?.()
185
+ .installations.find(
186
+ (i) =>
187
+ i.owner?.toLowerCase() === owner.toLowerCase() &&
188
+ i.health !== "broken",
189
+ );
190
+ if (manager?.getToken && match) return manager.getToken(match.id);
191
+ return env.GH_TOKEN || null;
192
+ };
193
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * runtime/repo-access-probe.unit.test.ts
3
+ * Probe behaviour via a routed fetch fake and an injected clock (COS-44.1).
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ createRepoAccessProbe,
9
+ createRepoAccessTokenFor,
10
+ REPO_ACCESS_INTERVAL_MS,
11
+ } from "./repo-access-probe.ts";
12
+
13
+ const SECRET = "ghs_SUPERSECRETTOKEN";
14
+
15
+ type Route = Response | Error | ((req: { url: string }) => Response);
16
+
17
+ function harness(
18
+ routes: Record<string, Route>,
19
+ opts: { repos?: string[]; enabled?: boolean; token?: string | null } = {},
20
+ ) {
21
+ let nowMs = Date.UTC(2026, 0, 1);
22
+ const calls: { url: string; auth: string | null }[] = [];
23
+ const logs: string[] = [];
24
+ let inFlight = 0;
25
+ let maxInFlight = 0;
26
+ const state = {
27
+ repos: opts.repos ?? ["acme/a"],
28
+ enabled: opts.enabled ?? true,
29
+ };
30
+ const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
31
+ const url = String(input);
32
+ calls.push({
33
+ url,
34
+ auth: new Headers(init?.headers).get("authorization"),
35
+ });
36
+ inFlight++;
37
+ maxInFlight = Math.max(maxInFlight, inFlight);
38
+ await new Promise((r) => setTimeout(r, 1));
39
+ inFlight--;
40
+ const route = routes[url.replace("https://api.github.com/repos/", "")];
41
+ if (route instanceof Error) throw route;
42
+ if (typeof route === "function") return route({ url });
43
+ return route ?? new Response("{}", { status: 404 });
44
+ }) as unknown as typeof fetch;
45
+ const probe = createRepoAccessProbe({
46
+ fetch: fetchFn,
47
+ clock: { now: () => new Date(nowMs) },
48
+ tokenFor: async () => (opts.token === undefined ? SECRET : opts.token),
49
+ log: (m) => logs.push(m),
50
+ getRepos: () => state.repos,
51
+ isEnabled: () => state.enabled,
52
+ });
53
+ return {
54
+ probe,
55
+ calls,
56
+ logs,
57
+ state,
58
+ maxInFlight: () => maxInFlight,
59
+ advance: (ms: number) => {
60
+ nowMs += ms;
61
+ },
62
+ };
63
+ }
64
+
65
+ const ok = (pull: boolean, push: boolean) =>
66
+ new Response(JSON.stringify({ permissions: { pull, push } }), {
67
+ status: 200,
68
+ });
69
+
70
+ describe("createRepoAccessProbe", () => {
71
+ test("maps permissions.pull/push to read/write", async () => {
72
+ const h = harness(
73
+ {
74
+ "acme/rw": ok(true, true),
75
+ "acme/ro": ok(true, false),
76
+ },
77
+ { repos: ["acme/rw", "acme/ro"] },
78
+ );
79
+ const result = await h.probe.run();
80
+ expect(result).toEqual([
81
+ expect.objectContaining({
82
+ repo: "ro",
83
+ owner: "acme",
84
+ read: true,
85
+ write: false,
86
+ }),
87
+ expect.objectContaining({
88
+ repo: "rw",
89
+ owner: "acme",
90
+ read: true,
91
+ write: true,
92
+ }),
93
+ ]);
94
+ expect(result?.every((r) => r.error === undefined)).toBe(true);
95
+ expect(result?.[0]?.checkedAt).toBe("2026-01-01T00:00:00.000Z");
96
+ });
97
+
98
+ test.each([
99
+ ["404", new Response("", { status: 404 }), "not_found"],
100
+ ["403", new Response("", { status: 403 }), "forbidden"],
101
+ [
102
+ "403 with exhausted rate limit",
103
+ new Response("", {
104
+ status: 403,
105
+ headers: { "x-ratelimit-remaining": "0" },
106
+ }),
107
+ "rate_limited",
108
+ ],
109
+ ["429", new Response("", { status: 429 }), "rate_limited"],
110
+ ["network error", new Error("ECONNRESET"), "network"],
111
+ ["5xx", new Response("", { status: 502 }), "network"],
112
+ ] as const)(
113
+ "%s -> read:false, write:false, error %s",
114
+ async (_n, route, error) => {
115
+ const h = harness({ "acme/a": route });
116
+ const result = await h.probe.run();
117
+ expect(result).toEqual([
118
+ expect.objectContaining({
119
+ repo: "a",
120
+ read: false,
121
+ write: false,
122
+ error,
123
+ }),
124
+ ]);
125
+ },
126
+ );
127
+
128
+ test("no credential for the owner -> forbidden without a fetch", async () => {
129
+ const h = harness({}, { token: null });
130
+ const result = await h.probe.run();
131
+ expect(result?.[0]?.error).toBe("forbidden");
132
+ expect(h.calls).toHaveLength(0);
133
+ });
134
+
135
+ test("feature off -> no calls and no result", async () => {
136
+ const h = harness({ "acme/a": ok(true, true) }, { enabled: false });
137
+ expect(await h.probe.run()).toBeUndefined();
138
+ expect(h.calls).toHaveLength(0);
139
+ });
140
+
141
+ test("at most once per interval, again after it elapses", async () => {
142
+ const h = harness({ "acme/a": ok(true, true) });
143
+ expect(await h.probe.run()).toBeDefined();
144
+ h.advance(REPO_ACCESS_INTERVAL_MS - 1);
145
+ expect(await h.probe.run()).toBeUndefined();
146
+ h.advance(1);
147
+ expect(await h.probe.run()).toBeDefined();
148
+ expect(h.calls).toHaveLength(2);
149
+ });
150
+
151
+ test("a scope change probes immediately", async () => {
152
+ const h = harness({ "acme/a": ok(true, true), "acme/b": ok(true, false) });
153
+ await h.probe.run();
154
+ h.state.repos = ["acme/a", "acme/b"];
155
+ const result = await h.probe.run();
156
+ expect(result).toHaveLength(2);
157
+ // Same set in a different order is not a change.
158
+ h.state.repos = ["acme/b", "acme/a"];
159
+ expect(await h.probe.run()).toBeUndefined();
160
+ });
161
+
162
+ test("concurrency is capped at 3", async () => {
163
+ const repos = Array.from({ length: 8 }, (_, i) => `acme/r${i}`);
164
+ const routes = Object.fromEntries(repos.map((r) => [r, ok(true, true)]));
165
+ const h = harness(routes, { repos });
166
+ await h.probe.run();
167
+ expect(h.calls).toHaveLength(8);
168
+ expect(h.maxInFlight()).toBe(3);
169
+ });
170
+
171
+ test("the token is sent only as a header and never logged or returned", async () => {
172
+ const h = harness({
173
+ "acme/a": new Error(`boom with ${SECRET}`),
174
+ });
175
+ const result = await h.probe.run();
176
+ expect(h.calls[0]?.auth).toBe(`Bearer ${SECRET}`);
177
+ expect(JSON.stringify(result)).not.toContain(SECRET);
178
+ expect(h.logs.join("\n")).not.toContain(SECRET);
179
+ });
180
+ });
181
+
182
+ describe("createRepoAccessTokenFor", () => {
183
+ const manager = {
184
+ getState: () => ({
185
+ installations: [
186
+ { id: 1, owner: "Acme", health: "ok" },
187
+ { id: 2, owner: "dead", health: "broken" },
188
+ ],
189
+ }),
190
+ getToken: async (id: number) => `inst-${id}`,
191
+ };
192
+
193
+ test("uses the matching healthy installation (case-insensitive)", async () => {
194
+ const tokenFor = createRepoAccessTokenFor(() => manager, {
195
+ GH_TOKEN: "pat",
196
+ });
197
+ expect(await tokenFor("acme")).toBe("inst-1");
198
+ });
199
+
200
+ test("falls back to the PAT, then null", async () => {
201
+ expect(
202
+ await createRepoAccessTokenFor(() => manager, { GH_TOKEN: "pat" })(
203
+ "dead",
204
+ ),
205
+ ).toBe("pat");
206
+ expect(
207
+ await createRepoAccessTokenFor(() => null, { GH_TOKEN: "pat" })("x"),
208
+ ).toBe("pat");
209
+ expect(await createRepoAccessTokenFor(() => null, {})("x")).toBeNull();
210
+ });
211
+ });