@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,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/github-app-auth.integration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for GitHubTokenManager — background refresh cycle using
|
|
5
|
+
* a RecordedGitHubAppClient and fake timer injection (no real timers).
|
|
6
|
+
*
|
|
7
|
+
* Isolation contract: no mock.module(), no global overrides, no real network.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
11
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
12
|
+
import { FixedClock } from "./clock.ts";
|
|
13
|
+
import {
|
|
14
|
+
createGitHubTokenManager,
|
|
15
|
+
type FetchFn,
|
|
16
|
+
fetchBotIdentity,
|
|
17
|
+
GitHubTokenManager,
|
|
18
|
+
getBotIdentity,
|
|
19
|
+
} from "./github-app-auth.ts";
|
|
20
|
+
|
|
21
|
+
// ─── Test RSA key pair (generated at test time — pure computation, no I/O) ────
|
|
22
|
+
// @octokit/auth-app signs a real JWT, so fetchBotIdentity needs a structurally
|
|
23
|
+
// valid RSA private key. Generated fresh per test run; never a real App key.
|
|
24
|
+
|
|
25
|
+
const { privateKey: TEST_PRIVATE_KEY } = generateKeyPairSync("rsa", {
|
|
26
|
+
modulusLength: 2048,
|
|
27
|
+
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
|
28
|
+
publicKeyEncoding: { type: "pkcs1", format: "pem" },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ─── Recorded client double ───────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
interface AuthCall {
|
|
34
|
+
timestamp: number;
|
|
35
|
+
params: { type: string; installationId?: number } | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class RecordedGitHubAppClient {
|
|
39
|
+
calls: AuthCall[] = [];
|
|
40
|
+
private callCount = 0;
|
|
41
|
+
|
|
42
|
+
async auth(params?: { type: string; installationId?: number }): Promise<{
|
|
43
|
+
token: string;
|
|
44
|
+
expiresAt: string;
|
|
45
|
+
type: string;
|
|
46
|
+
tokenType: string;
|
|
47
|
+
}> {
|
|
48
|
+
this.callCount += 1;
|
|
49
|
+
this.calls.push({ timestamp: Date.now(), params });
|
|
50
|
+
const tokenIndex = this.callCount;
|
|
51
|
+
// Use a fixed future timestamp so token freshness is not wall-clock dependent
|
|
52
|
+
const FIXED_FUTURE = new Date("2099-01-01T00:00:00Z");
|
|
53
|
+
return {
|
|
54
|
+
token: `fake-token-${tokenIndex}`,
|
|
55
|
+
expiresAt: FIXED_FUTURE.toISOString(),
|
|
56
|
+
type: "token",
|
|
57
|
+
tokenType: "installation",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Fake timer infrastructure ────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* FakeTimerControl provides fake setInterval/clearInterval functions that can
|
|
66
|
+
* be injected into GitHubTokenManager. Intervals are triggered manually via
|
|
67
|
+
* tick() — no real time passes.
|
|
68
|
+
*/
|
|
69
|
+
class FakeTimerControl {
|
|
70
|
+
private callbacks: Map<number, () => void> = new Map();
|
|
71
|
+
private nextId = 1;
|
|
72
|
+
clearedIds: number[] = [];
|
|
73
|
+
registeredIntervalMs: number[] = [];
|
|
74
|
+
|
|
75
|
+
/** Returns a fake setInterval suitable for constructor injection. */
|
|
76
|
+
get setIntervalFn(): typeof setInterval {
|
|
77
|
+
const self = this;
|
|
78
|
+
return function fakeSetInterval(
|
|
79
|
+
fn: unknown,
|
|
80
|
+
ms?: unknown,
|
|
81
|
+
): ReturnType<typeof setInterval> {
|
|
82
|
+
const id = self.nextId++;
|
|
83
|
+
self.callbacks.set(id, fn as () => void);
|
|
84
|
+
self.registeredIntervalMs.push(ms as number);
|
|
85
|
+
return id as unknown as ReturnType<typeof setInterval>;
|
|
86
|
+
} as unknown as typeof setInterval;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Returns a fake clearInterval suitable for constructor injection. */
|
|
90
|
+
get clearIntervalFn(): typeof clearInterval {
|
|
91
|
+
const self = this;
|
|
92
|
+
return function fakeClearInterval(id: unknown): void {
|
|
93
|
+
const numId = id as number;
|
|
94
|
+
self.callbacks.delete(numId);
|
|
95
|
+
self.clearedIds.push(numId);
|
|
96
|
+
} as unknown as typeof clearInterval;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Manually fire the interval callback N times. */
|
|
100
|
+
tick(times = 1): void {
|
|
101
|
+
for (let i = 0; i < times; i++) {
|
|
102
|
+
for (const fn of this.callbacks.values()) {
|
|
103
|
+
fn();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get activeTimerCount(): number {
|
|
109
|
+
return this.callbacks.size;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("GitHubTokenManager — background refresh (integration)", () => {
|
|
116
|
+
let client: RecordedGitHubAppClient;
|
|
117
|
+
let timers: FakeTimerControl;
|
|
118
|
+
let manager: GitHubTokenManager;
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
client = new RecordedGitHubAppClient();
|
|
122
|
+
timers = new FakeTimerControl();
|
|
123
|
+
manager = new GitHubTokenManager({
|
|
124
|
+
auth: (params) => client.auth(params),
|
|
125
|
+
installationId: 42,
|
|
126
|
+
setIntervalFn: timers.setIntervalFn,
|
|
127
|
+
clearIntervalFn: timers.clearIntervalFn,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("getToken() calls auth once and caches the result", async () => {
|
|
132
|
+
const token1 = await manager.getToken();
|
|
133
|
+
const token2 = await manager.getToken();
|
|
134
|
+
expect(token1).toBe("fake-token-1");
|
|
135
|
+
expect(token2).toBe("fake-token-1"); // cached
|
|
136
|
+
expect(client.calls.length).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("startBackgroundRefresh() fires onRefresh callback each tick", async () => {
|
|
140
|
+
const refreshed: string[] = [];
|
|
141
|
+
manager.startBackgroundRefresh(async (token) => {
|
|
142
|
+
refreshed.push(token);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Tick 3 times — each tick should call auth and invoke onRefresh
|
|
146
|
+
timers.tick(3);
|
|
147
|
+
|
|
148
|
+
// Give async callbacks time to resolve
|
|
149
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
150
|
+
|
|
151
|
+
expect(refreshed.length).toBe(3);
|
|
152
|
+
expect(refreshed[0]).toBe("fake-token-1");
|
|
153
|
+
expect(refreshed[1]).toBe("fake-token-2");
|
|
154
|
+
expect(refreshed[2]).toBe("fake-token-3");
|
|
155
|
+
// RecordedGitHubAppClient was called once per tick (cache cleared each refresh)
|
|
156
|
+
expect(client.calls.length).toBe(3);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("startBackgroundRefresh() registers exactly one interval at 30-minute cadence", async () => {
|
|
160
|
+
manager.startBackgroundRefresh(async () => {});
|
|
161
|
+
expect(timers.activeTimerCount).toBe(1);
|
|
162
|
+
expect(timers.registeredIntervalMs[0]).toBe(30 * 60 * 1000);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("stopBackgroundRefresh() clears the interval", async () => {
|
|
166
|
+
manager.startBackgroundRefresh(async () => {});
|
|
167
|
+
expect(timers.activeTimerCount).toBe(1);
|
|
168
|
+
manager.stopBackgroundRefresh();
|
|
169
|
+
expect(timers.activeTimerCount).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("calling startBackgroundRefresh() twice replaces the previous timer", async () => {
|
|
173
|
+
manager.startBackgroundRefresh(async () => {});
|
|
174
|
+
manager.startBackgroundRefresh(async () => {});
|
|
175
|
+
// Old timer was cleared, new one registered
|
|
176
|
+
expect(timers.activeTimerCount).toBe(1);
|
|
177
|
+
expect(timers.clearedIds.length).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("onRefresh receives distinct tokens across multiple ticks", async () => {
|
|
181
|
+
const tokens: string[] = [];
|
|
182
|
+
manager.startBackgroundRefresh(async (t) => {
|
|
183
|
+
tokens.push(t);
|
|
184
|
+
});
|
|
185
|
+
timers.tick(5);
|
|
186
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
187
|
+
const unique = new Set(tokens);
|
|
188
|
+
expect(unique.size).toBe(5);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("getToken() re-auths when token is near expiry (within 5-minute buffer)", async () => {
|
|
192
|
+
// Anchor clock to a fixed point in time
|
|
193
|
+
const now = new Date("2040-06-01T12:00:00Z");
|
|
194
|
+
const clock = FixedClock(now);
|
|
195
|
+
|
|
196
|
+
// Token expires 3 minutes from now — inside the 5-minute REFRESH_BUFFER_MS
|
|
197
|
+
const nearExpiryDate = new Date(now.getTime() + 3 * 60 * 1000);
|
|
198
|
+
|
|
199
|
+
// Override the RecordedGitHubAppClient to return the near-expiry timestamp
|
|
200
|
+
const nearExpiryClient = {
|
|
201
|
+
calls: [] as AuthCall[],
|
|
202
|
+
callCount: 0,
|
|
203
|
+
async auth(params?: { type: string; installationId?: number }) {
|
|
204
|
+
nearExpiryClient.callCount += 1;
|
|
205
|
+
nearExpiryClient.calls.push({ timestamp: Date.now(), params });
|
|
206
|
+
return {
|
|
207
|
+
token: `near-expiry-token-${nearExpiryClient.callCount}`,
|
|
208
|
+
expiresAt: nearExpiryDate.toISOString(),
|
|
209
|
+
type: "token",
|
|
210
|
+
tokenType: "installation",
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const nearExpiryManager = new GitHubTokenManager({
|
|
216
|
+
auth: (params) => nearExpiryClient.auth(params),
|
|
217
|
+
installationId: 42,
|
|
218
|
+
setIntervalFn: timers.setIntervalFn,
|
|
219
|
+
clearIntervalFn: timers.clearIntervalFn,
|
|
220
|
+
clock,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// First call: cache miss, fetches token
|
|
224
|
+
const token1 = await nearExpiryManager.getToken();
|
|
225
|
+
expect(token1).toBe("near-expiry-token-1");
|
|
226
|
+
expect(nearExpiryClient.callCount).toBe(1);
|
|
227
|
+
|
|
228
|
+
// Second call: token is near expiry (3 min < 5 min buffer) — must re-auth
|
|
229
|
+
const token2 = await nearExpiryManager.getToken();
|
|
230
|
+
expect(token2).toBe("near-expiry-token-2");
|
|
231
|
+
expect(nearExpiryClient.callCount).toBe(2);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ─── createGitHubTokenManager() — env var wiring & missing-config errors ─────
|
|
236
|
+
|
|
237
|
+
describe("createGitHubTokenManager() — env var wiring", () => {
|
|
238
|
+
const ORIGINAL_ENV = {
|
|
239
|
+
GH_APP_ID: process.env.GH_APP_ID,
|
|
240
|
+
GH_APP_PRIVATE_KEY: process.env.GH_APP_PRIVATE_KEY,
|
|
241
|
+
GH_APP_INSTALLATION_ID: process.env.GH_APP_INSTALLATION_ID,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
afterEach(() => {
|
|
245
|
+
process.env.GH_APP_ID = ORIGINAL_ENV.GH_APP_ID;
|
|
246
|
+
process.env.GH_APP_PRIVATE_KEY = ORIGINAL_ENV.GH_APP_PRIVATE_KEY;
|
|
247
|
+
process.env.GH_APP_INSTALLATION_ID = ORIGINAL_ENV.GH_APP_INSTALLATION_ID;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("throws when GH_APP_ID is missing", () => {
|
|
251
|
+
delete process.env.GH_APP_ID;
|
|
252
|
+
process.env.GH_APP_PRIVATE_KEY = TEST_PRIVATE_KEY;
|
|
253
|
+
process.env.GH_APP_INSTALLATION_ID = "123";
|
|
254
|
+
|
|
255
|
+
expect(() => createGitHubTokenManager()).toThrow(
|
|
256
|
+
/Missing required env vars/,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("throws when GH_APP_PRIVATE_KEY is missing", () => {
|
|
261
|
+
process.env.GH_APP_ID = "12345";
|
|
262
|
+
delete process.env.GH_APP_PRIVATE_KEY;
|
|
263
|
+
process.env.GH_APP_INSTALLATION_ID = "123";
|
|
264
|
+
|
|
265
|
+
expect(() => createGitHubTokenManager()).toThrow(
|
|
266
|
+
/Missing required env vars/,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("throws when GH_APP_INSTALLATION_ID is missing", () => {
|
|
271
|
+
process.env.GH_APP_ID = "12345";
|
|
272
|
+
process.env.GH_APP_PRIVATE_KEY = TEST_PRIVATE_KEY;
|
|
273
|
+
delete process.env.GH_APP_INSTALLATION_ID;
|
|
274
|
+
|
|
275
|
+
expect(() => createGitHubTokenManager()).toThrow(
|
|
276
|
+
/Missing required env vars/,
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("throws when GH_APP_INSTALLATION_ID is not a valid number (0/NaN is falsy)", () => {
|
|
281
|
+
process.env.GH_APP_ID = "12345";
|
|
282
|
+
process.env.GH_APP_PRIVATE_KEY = TEST_PRIVATE_KEY;
|
|
283
|
+
process.env.GH_APP_INSTALLATION_ID = "not-a-number";
|
|
284
|
+
|
|
285
|
+
expect(() => createGitHubTokenManager()).toThrow(
|
|
286
|
+
/Missing required env vars/,
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("builds a GitHubTokenManager and unescapes literal \\n sequences in the private key", () => {
|
|
291
|
+
process.env.GH_APP_ID = "12345";
|
|
292
|
+
process.env.GH_APP_PRIVATE_KEY = TEST_PRIVATE_KEY.replace(/\n/g, "\\n");
|
|
293
|
+
process.env.GH_APP_INSTALLATION_ID = "999";
|
|
294
|
+
|
|
295
|
+
const manager = createGitHubTokenManager();
|
|
296
|
+
expect(manager).toBeInstanceOf(GitHubTokenManager);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ─── fetchBotIdentity() — success & error branches (injected fetchFn) ────────
|
|
301
|
+
|
|
302
|
+
describe("fetchBotIdentity() (integration)", () => {
|
|
303
|
+
function fakeFetch(opts: {
|
|
304
|
+
appStatus?: number;
|
|
305
|
+
appBody?: unknown;
|
|
306
|
+
userStatus?: number;
|
|
307
|
+
userBody?: unknown;
|
|
308
|
+
}): FetchFn {
|
|
309
|
+
const {
|
|
310
|
+
appStatus = 200,
|
|
311
|
+
appBody = { slug: "shipwright-bot", name: "Shipwright Bot" },
|
|
312
|
+
userStatus = 200,
|
|
313
|
+
userBody = { id: 987654 },
|
|
314
|
+
} = opts;
|
|
315
|
+
|
|
316
|
+
return (async (url: string | URL | Request) => {
|
|
317
|
+
const urlStr = url.toString();
|
|
318
|
+
if (urlStr === "https://api.github.com/app") {
|
|
319
|
+
return new Response(JSON.stringify(appBody), {
|
|
320
|
+
status: appStatus,
|
|
321
|
+
statusText: appStatus >= 400 ? "Error" : "OK",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
// GET /users/:slug[bot]
|
|
325
|
+
return new Response(JSON.stringify(userBody), {
|
|
326
|
+
status: userStatus,
|
|
327
|
+
statusText: userStatus >= 400 ? "Error" : "OK",
|
|
328
|
+
});
|
|
329
|
+
}) as FetchFn;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
it("resolves the bot identity on success (slug, name, userId)", async () => {
|
|
333
|
+
const fetchFn = fakeFetch({});
|
|
334
|
+
const identity = await fetchBotIdentity("12345", TEST_PRIVATE_KEY, fetchFn);
|
|
335
|
+
|
|
336
|
+
expect(identity).toEqual({
|
|
337
|
+
slug: "shipwright-bot",
|
|
338
|
+
name: "Shipwright Bot",
|
|
339
|
+
userId: 987654,
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("throws when GET /app returns a non-ok status", async () => {
|
|
344
|
+
const fetchFn = fakeFetch({ appStatus: 401 });
|
|
345
|
+
|
|
346
|
+
let caught: unknown;
|
|
347
|
+
try {
|
|
348
|
+
await fetchBotIdentity("12345", TEST_PRIVATE_KEY, fetchFn);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
caught = err;
|
|
351
|
+
}
|
|
352
|
+
expect(caught).toBeInstanceOf(Error);
|
|
353
|
+
expect((caught as Error).message).toContain("GET /app failed: 401");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("throws when GET /users/:slug[bot] returns a non-ok status", async () => {
|
|
357
|
+
const fetchFn = fakeFetch({ userStatus: 404 });
|
|
358
|
+
|
|
359
|
+
let caught: unknown;
|
|
360
|
+
try {
|
|
361
|
+
await fetchBotIdentity("12345", TEST_PRIVATE_KEY, fetchFn);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
caught = err;
|
|
364
|
+
}
|
|
365
|
+
expect(caught).toBeInstanceOf(Error);
|
|
366
|
+
expect((caught as Error).message).toContain(
|
|
367
|
+
"GET /users/shipwright-bot[bot] failed: 404",
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("URL-encodes the bot username lookup (slug[bot])", async () => {
|
|
372
|
+
const calls: string[] = [];
|
|
373
|
+
const fetchFn: FetchFn = (async (url: string | URL | Request) => {
|
|
374
|
+
const urlStr = url.toString();
|
|
375
|
+
calls.push(urlStr);
|
|
376
|
+
if (urlStr === "https://api.github.com/app") {
|
|
377
|
+
return new Response(
|
|
378
|
+
JSON.stringify({ slug: "my-app", name: "My App" }),
|
|
379
|
+
{ status: 200 },
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return new Response(JSON.stringify({ id: 1 }), { status: 200 });
|
|
383
|
+
}) as FetchFn;
|
|
384
|
+
|
|
385
|
+
await fetchBotIdentity("12345", TEST_PRIVATE_KEY, fetchFn);
|
|
386
|
+
|
|
387
|
+
expect(calls[1]).toBe(
|
|
388
|
+
`https://api.github.com/users/${encodeURIComponent("my-app[bot]")}`,
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ─── getBotIdentity() — missing env var errors ───────────────────────────────
|
|
394
|
+
|
|
395
|
+
describe("getBotIdentity() — missing env var errors", () => {
|
|
396
|
+
const ORIGINAL_ENV = {
|
|
397
|
+
GH_APP_ID: process.env.GH_APP_ID,
|
|
398
|
+
GH_APP_PRIVATE_KEY: process.env.GH_APP_PRIVATE_KEY,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
afterEach(() => {
|
|
402
|
+
process.env.GH_APP_ID = ORIGINAL_ENV.GH_APP_ID;
|
|
403
|
+
process.env.GH_APP_PRIVATE_KEY = ORIGINAL_ENV.GH_APP_PRIVATE_KEY;
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("throws when GH_APP_ID is missing", () => {
|
|
407
|
+
delete process.env.GH_APP_ID;
|
|
408
|
+
process.env.GH_APP_PRIVATE_KEY = TEST_PRIVATE_KEY;
|
|
409
|
+
|
|
410
|
+
expect(() => getBotIdentity()).toThrow(/Missing required env vars/);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("throws when GH_APP_PRIVATE_KEY is missing", () => {
|
|
414
|
+
process.env.GH_APP_ID = "12345";
|
|
415
|
+
delete process.env.GH_APP_PRIVATE_KEY;
|
|
416
|
+
|
|
417
|
+
expect(() => getBotIdentity()).toThrow(/Missing required env vars/);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/github-app-auth.ts
|
|
3
|
+
*
|
|
4
|
+
* GitHub App authentication module.
|
|
5
|
+
*
|
|
6
|
+
* Provides installation-token management with caching and proactive refresh,
|
|
7
|
+
* plus a one-shot lookup of the App's bot identity (for git author config).
|
|
8
|
+
* Built for dependency injection so it's testable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createAppAuth } from "@octokit/auth-app";
|
|
12
|
+
import { type Clock, SystemClock } from "./clock.ts";
|
|
13
|
+
|
|
14
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
interface InstallationAuthResult {
|
|
17
|
+
token: string;
|
|
18
|
+
expiresAt: string;
|
|
19
|
+
type: string;
|
|
20
|
+
tokenType: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type AuthFunction = (params?: {
|
|
24
|
+
type: string;
|
|
25
|
+
installationId?: number;
|
|
26
|
+
}) => Promise<InstallationAuthResult>;
|
|
27
|
+
|
|
28
|
+
export interface BotIdentity {
|
|
29
|
+
slug: string;
|
|
30
|
+
name: string;
|
|
31
|
+
userId: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type FetchFn = typeof fetch;
|
|
35
|
+
|
|
36
|
+
// ─── GitHubTokenManager ───────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
interface TokenCache {
|
|
39
|
+
token: string;
|
|
40
|
+
expiresAt: Date;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
|
44
|
+
const REFRESH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
|
45
|
+
|
|
46
|
+
export class GitHubTokenManager {
|
|
47
|
+
private readonly auth: AuthFunction;
|
|
48
|
+
private readonly installationId: number;
|
|
49
|
+
private readonly setIntervalFn: typeof setInterval;
|
|
50
|
+
private readonly clearIntervalFn: typeof clearInterval;
|
|
51
|
+
private readonly clock: Clock;
|
|
52
|
+
private cache: TokenCache | null = null;
|
|
53
|
+
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(opts: {
|
|
56
|
+
auth: AuthFunction;
|
|
57
|
+
installationId: number;
|
|
58
|
+
setIntervalFn?: typeof setInterval;
|
|
59
|
+
clearIntervalFn?: typeof clearInterval;
|
|
60
|
+
clock?: Clock;
|
|
61
|
+
}) {
|
|
62
|
+
this.auth = opts.auth;
|
|
63
|
+
this.installationId = opts.installationId;
|
|
64
|
+
this.setIntervalFn = opts.setIntervalFn ?? setInterval;
|
|
65
|
+
this.clearIntervalFn = opts.clearIntervalFn ?? clearInterval;
|
|
66
|
+
this.clock = opts.clock ?? SystemClock();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async getToken(): Promise<string> {
|
|
70
|
+
if (this.cache && !this.isNearExpiry(this.cache.expiresAt)) {
|
|
71
|
+
return this.cache.token;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = await this.auth({
|
|
75
|
+
type: "installation",
|
|
76
|
+
installationId: this.installationId,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.cache = {
|
|
80
|
+
token: result.token,
|
|
81
|
+
expiresAt: new Date(result.expiresAt),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return result.token;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
startBackgroundRefresh(onRefresh: (token: string) => Promise<void>): void {
|
|
88
|
+
this.stopBackgroundRefresh();
|
|
89
|
+
|
|
90
|
+
this.refreshTimer = this.setIntervalFn(async () => {
|
|
91
|
+
try {
|
|
92
|
+
const token = await this.refreshToken();
|
|
93
|
+
await onRefresh(token);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error("[github-app-auth] background refresh failed:", err);
|
|
96
|
+
}
|
|
97
|
+
}, REFRESH_INTERVAL_MS);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stopBackgroundRefresh(): void {
|
|
101
|
+
if (this.refreshTimer !== null) {
|
|
102
|
+
this.clearIntervalFn(this.refreshTimer);
|
|
103
|
+
this.refreshTimer = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private isNearExpiry(expiresAt: Date): boolean {
|
|
108
|
+
return (
|
|
109
|
+
expiresAt.getTime() - this.clock.now().getTime() <= REFRESH_BUFFER_MS
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async refreshToken(): Promise<string> {
|
|
114
|
+
this.cache = null;
|
|
115
|
+
return this.getToken();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createGitHubTokenManager(): GitHubTokenManager {
|
|
120
|
+
const appId = process.env.GH_APP_ID;
|
|
121
|
+
const privateKey = process.env.GH_APP_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
|
122
|
+
const installationId = Number(process.env.GH_APP_INSTALLATION_ID);
|
|
123
|
+
|
|
124
|
+
if (!appId || !privateKey || !installationId) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
"Missing required env vars: GH_APP_ID, GH_APP_PRIVATE_KEY, GH_APP_INSTALLATION_ID",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const appAuth = createAppAuth({ appId, privateKey });
|
|
131
|
+
const auth: AuthFunction = async (params) =>
|
|
132
|
+
appAuth(
|
|
133
|
+
params as Parameters<typeof appAuth>[0],
|
|
134
|
+
) as Promise<InstallationAuthResult>;
|
|
135
|
+
|
|
136
|
+
return new GitHubTokenManager({ auth, installationId });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Exported (in addition to getBotIdentity) so tests can inject a fake
|
|
141
|
+
* `fetchFn` and exercise the GitHub API error branches without a real
|
|
142
|
+
* network call or a live GitHub App installation.
|
|
143
|
+
*/
|
|
144
|
+
export async function fetchBotIdentity(
|
|
145
|
+
appId: string,
|
|
146
|
+
privateKey: string,
|
|
147
|
+
fetchFn: FetchFn = fetch,
|
|
148
|
+
): Promise<BotIdentity> {
|
|
149
|
+
const appAuth = createAppAuth({ appId, privateKey });
|
|
150
|
+
const { token: jwt } = (await appAuth({ type: "app" })) as { token: string };
|
|
151
|
+
|
|
152
|
+
const headers = {
|
|
153
|
+
Authorization: `Bearer ${jwt}`,
|
|
154
|
+
Accept: "application/vnd.github+json",
|
|
155
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const appResp = await fetchFn("https://api.github.com/app", { headers });
|
|
159
|
+
if (!appResp.ok) {
|
|
160
|
+
throw new Error(`GET /app failed: ${appResp.status} ${appResp.statusText}`);
|
|
161
|
+
}
|
|
162
|
+
const { slug, name } = (await appResp.json()) as {
|
|
163
|
+
slug: string;
|
|
164
|
+
name: string;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const userUrl = `https://api.github.com/users/${encodeURIComponent(`${slug}[bot]`)}`;
|
|
168
|
+
const userResp = await fetchFn(userUrl, {
|
|
169
|
+
headers: {
|
|
170
|
+
Accept: "application/vnd.github+json",
|
|
171
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
if (!userResp.ok) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`GET /users/${slug}[bot] failed: ${userResp.status} ${userResp.statusText}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const { id: userId } = (await userResp.json()) as { id: number };
|
|
180
|
+
|
|
181
|
+
return { slug, name, userId };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function getBotIdentity(): Promise<BotIdentity> {
|
|
185
|
+
const appId = process.env.GH_APP_ID;
|
|
186
|
+
const privateKey = process.env.GH_APP_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
|
187
|
+
|
|
188
|
+
if (!appId || !privateKey) {
|
|
189
|
+
throw new Error("Missing required env vars: GH_APP_ID, GH_APP_PRIVATE_KEY");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return fetchBotIdentity(appId, privateKey);
|
|
193
|
+
}
|