@app-vitals/agent-runtime 0.0.0-stage → 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 +40 -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,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/gh-helpers.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for getCurrentUser("acme") and ghGraphql() in gh-helpers.ts
|
|
5
|
+
* (relocated from check-helpers.unit.test.ts, ARD-4.1).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
getCurrentUser,
|
|
20
|
+
ghGraphql,
|
|
21
|
+
ghJson,
|
|
22
|
+
ghRun,
|
|
23
|
+
ownerOfRepo,
|
|
24
|
+
} from "./gh-helpers.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Write a fake `gh` binary into dir that returns the given GraphQL viewer
|
|
28
|
+
* response JSON. Returns the path to the fake binary.
|
|
29
|
+
*
|
|
30
|
+
* The fake binary is added to PATH by the test beforeEach/afterEach helpers.
|
|
31
|
+
*/
|
|
32
|
+
function writeFakeGhBinary(dir: string, viewerLogin: string): string {
|
|
33
|
+
const binPath = join(dir, "gh");
|
|
34
|
+
const response = JSON.stringify({ data: { viewer: { login: viewerLogin } } });
|
|
35
|
+
// A minimal shell script that ignores all args and prints the baked response.
|
|
36
|
+
writeFileSync(binPath, `#!/bin/sh\nprintf '%s\\n' '${response}'\n`);
|
|
37
|
+
chmodSync(binPath, 0o755);
|
|
38
|
+
return binPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Write a fake `gh` binary that always fails with a given exit code. */
|
|
42
|
+
function writeFailingGhBinary(
|
|
43
|
+
dir: string,
|
|
44
|
+
exitCode: number,
|
|
45
|
+
stderr: string,
|
|
46
|
+
): string {
|
|
47
|
+
const binPath = join(dir, "gh");
|
|
48
|
+
writeFileSync(
|
|
49
|
+
binPath,
|
|
50
|
+
`#!/bin/sh\nprintf '%s\\n' '${stderr}' >&2\nexit ${exitCode}\n`,
|
|
51
|
+
);
|
|
52
|
+
chmodSync(binPath, 0o755);
|
|
53
|
+
return binPath;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("getCurrentUser", () => {
|
|
57
|
+
let tmpDir: string;
|
|
58
|
+
let savedPath: string | undefined;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
tmpDir = mkdtempSync(join(tmpdir(), "get-current-user-test-"));
|
|
62
|
+
savedPath = process.env.PATH;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
if (savedPath !== undefined) {
|
|
67
|
+
process.env.PATH = savedPath;
|
|
68
|
+
}
|
|
69
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("returns login as-is for a regular PAT user", async () => {
|
|
73
|
+
writeFakeGhBinary(tmpDir, "dmcaulay");
|
|
74
|
+
process.env.PATH = `${tmpDir}:${savedPath}`;
|
|
75
|
+
const result = await getCurrentUser("acme");
|
|
76
|
+
expect(result).toBe("dmcaulay");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("normalises [bot] suffix to app/ prefix for GitHub App identity", async () => {
|
|
80
|
+
writeFakeGhBinary(tmpDir, "my-app[bot]");
|
|
81
|
+
process.env.PATH = `${tmpDir}:${savedPath}`;
|
|
82
|
+
const result = await getCurrentUser("acme");
|
|
83
|
+
expect(result).toBe("app/my-app");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("handles hyphenated app name in [bot] normalisation", async () => {
|
|
87
|
+
writeFakeGhBinary(tmpDir, "example-repo-agent[bot]");
|
|
88
|
+
process.env.PATH = `${tmpDir}:${savedPath}`;
|
|
89
|
+
const result = await getCurrentUser("acme");
|
|
90
|
+
expect(result).toBe("app/example-repo-agent");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("throws when gh exits non-zero", async () => {
|
|
94
|
+
writeFailingGhBinary(tmpDir, 1, "not authenticated");
|
|
95
|
+
process.env.PATH = `${tmpDir}:${savedPath}`;
|
|
96
|
+
await expect(getCurrentUser("acme")).rejects.toThrow(
|
|
97
|
+
"gh api graphql failed",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("ghGraphql retry", () => {
|
|
103
|
+
let tmpDir: string;
|
|
104
|
+
let savedPath: string | undefined;
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gh-graphql-retry-test-"));
|
|
108
|
+
savedPath = process.env.PATH;
|
|
109
|
+
process.env.PATH = `${tmpDir}:${savedPath}`;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
if (savedPath !== undefined) {
|
|
114
|
+
process.env.PATH = savedPath;
|
|
115
|
+
}
|
|
116
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** Fake gh that fails `failures` times with `stderr`, then succeeds. Counts calls. */
|
|
120
|
+
function writeFlakyGh(failures: number, stderr: string): string {
|
|
121
|
+
const counter = join(tmpDir, "count");
|
|
122
|
+
writeFileSync(counter, "0");
|
|
123
|
+
writeFileSync(
|
|
124
|
+
join(tmpDir, "gh"),
|
|
125
|
+
`#!/bin/sh
|
|
126
|
+
n=$(cat "${counter}"); n=$((n+1)); echo $n > "${counter}"
|
|
127
|
+
if [ $n -le ${failures} ]; then printf '%s\\n' '${stderr}' >&2; exit 1; fi
|
|
128
|
+
printf '%s\\n' '{"ok":true}'
|
|
129
|
+
`,
|
|
130
|
+
);
|
|
131
|
+
chmodSync(join(tmpDir, "gh"), 0o755);
|
|
132
|
+
return counter;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const calls = (counter: string) => Number(readFileSync(counter, "utf8"));
|
|
136
|
+
|
|
137
|
+
test("retries a transient 504 and succeeds", async () => {
|
|
138
|
+
const counter = writeFlakyGh(2, "gh: HTTP 504");
|
|
139
|
+
const result = await ghGraphql<{ ok: boolean }>("query {}", "acme", {
|
|
140
|
+
baseDelayMs: 1,
|
|
141
|
+
});
|
|
142
|
+
expect(result).toEqual({ ok: true });
|
|
143
|
+
expect(calls(counter)).toBe(3);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("throws the standard error after exhausting retries", async () => {
|
|
147
|
+
const counter = writeFlakyGh(99, "gh: HTTP 504");
|
|
148
|
+
await expect(
|
|
149
|
+
ghGraphql("query {}", "acme", { baseDelayMs: 1 }),
|
|
150
|
+
).rejects.toThrow("gh api graphql failed (exit 1): gh: HTTP 504");
|
|
151
|
+
expect(calls(counter)).toBe(3);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("does not retry a non-transient failure", async () => {
|
|
155
|
+
const counter = writeFlakyGh(99, "gh: HTTP 401 bad credentials");
|
|
156
|
+
await expect(
|
|
157
|
+
ghGraphql("query {}", "acme", { baseDelayMs: 1 }),
|
|
158
|
+
).rejects.toThrow("gh api graphql failed");
|
|
159
|
+
expect(calls(counter)).toBe(1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("ghGraphql owner hint", () => {
|
|
164
|
+
let tmpDir: string;
|
|
165
|
+
let savedPath: string | undefined;
|
|
166
|
+
let savedOwner: string | undefined;
|
|
167
|
+
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gh-graphql-owner-test-"));
|
|
170
|
+
savedPath = process.env.PATH;
|
|
171
|
+
savedOwner = process.env.SHIPWRIGHT_GH_OWNER;
|
|
172
|
+
delete process.env.SHIPWRIGHT_GH_OWNER;
|
|
173
|
+
process.env.PATH = `${tmpDir}:${savedPath}`;
|
|
174
|
+
// Fake gh echoing the owner hint it was spawned with.
|
|
175
|
+
writeFileSync(
|
|
176
|
+
join(tmpDir, "gh"),
|
|
177
|
+
`#!/bin/sh\nprintf '{"owner":"%s"}\\n' "$SHIPWRIGHT_GH_OWNER"\n`,
|
|
178
|
+
);
|
|
179
|
+
chmodSync(join(tmpDir, "gh"), 0o755);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
if (savedPath !== undefined) process.env.PATH = savedPath;
|
|
184
|
+
if (savedOwner === undefined) delete process.env.SHIPWRIGHT_GH_OWNER;
|
|
185
|
+
else process.env.SHIPWRIGHT_GH_OWNER = savedOwner;
|
|
186
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("ghGraphql sets SHIPWRIGHT_GH_OWNER in the spawned env", async () => {
|
|
190
|
+
const result = await ghGraphql<{ owner: string }>("query {}", "acme");
|
|
191
|
+
expect(result.owner).toBe("acme");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("ghJson sets SHIPWRIGHT_GH_OWNER in the spawned env", async () => {
|
|
195
|
+
const result = await ghJson<{ owner: string }>(["api", "x"], "acme");
|
|
196
|
+
expect(result.owner).toBe("acme");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("ghRun sets SHIPWRIGHT_GH_OWNER in the spawned env", async () => {
|
|
200
|
+
const out = join(tmpDir, "run-owner");
|
|
201
|
+
writeFileSync(
|
|
202
|
+
join(tmpDir, "gh"),
|
|
203
|
+
`#!/bin/sh\nprintf '%s' "$SHIPWRIGHT_GH_OWNER" > "${out}"\n`,
|
|
204
|
+
);
|
|
205
|
+
chmodSync(join(tmpDir, "gh"), 0o755);
|
|
206
|
+
await ghRun(["api", "x"], "acme");
|
|
207
|
+
expect(readFileSync(out, "utf8")).toBe("acme");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("the explicit owner overrides an ambient SHIPWRIGHT_GH_OWNER", async () => {
|
|
211
|
+
process.env.SHIPWRIGHT_GH_OWNER = "ambient";
|
|
212
|
+
const result = await ghGraphql<{ owner: string }>("query {}", "acme");
|
|
213
|
+
expect(result.owner).toBe("acme");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("getCurrentUser forwards its owner to the gh env", async () => {
|
|
217
|
+
writeFileSync(
|
|
218
|
+
join(tmpDir, "gh"),
|
|
219
|
+
`#!/bin/sh\nprintf '{"data":{"viewer":{"login":"%s"}}}\\n' "$SHIPWRIGHT_GH_OWNER"\n`,
|
|
220
|
+
);
|
|
221
|
+
chmodSync(join(tmpDir, "gh"), 0o755);
|
|
222
|
+
expect(await getCurrentUser("acme")).toBe("acme");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("does not mutate the parent process env", async () => {
|
|
226
|
+
await ghGraphql("query {}", "acme");
|
|
227
|
+
expect(process.env.SHIPWRIGHT_GH_OWNER).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("owner is required at the type level", () => {
|
|
231
|
+
const never = async () => {
|
|
232
|
+
// @ts-expect-error owner is required
|
|
233
|
+
await ghJson<unknown>(["api", "x"]);
|
|
234
|
+
// @ts-expect-error owner is required
|
|
235
|
+
await ghRun(["api", "x"]);
|
|
236
|
+
// @ts-expect-error owner is required
|
|
237
|
+
await ghGraphql("query {}");
|
|
238
|
+
// @ts-expect-error owner is required
|
|
239
|
+
await getCurrentUser();
|
|
240
|
+
};
|
|
241
|
+
expect(typeof never).toBe("function");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("ownerOfRepo", () => {
|
|
246
|
+
test("returns the org half of org/repo", () => {
|
|
247
|
+
expect(ownerOfRepo("acme/widgets")).toBe("acme");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("throws rather than guessing when there is no org", () => {
|
|
251
|
+
expect(() => ownerOfRepo("widgets")).toThrow("Cannot derive GitHub owner");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { writeFileAtomic, writeTokenFiles } from "./gh-token-files.ts";
|
|
12
|
+
|
|
13
|
+
let home: string;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
home = mkdtempSync(join(tmpdir(), "gh-token-files-"));
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
18
|
+
|
|
19
|
+
describe("writeTokenFiles", () => {
|
|
20
|
+
test("single installation writes gh-token and its per-owner file", () => {
|
|
21
|
+
writeTokenFiles(home, { defaultToken: "t1", ownerTokens: { Acme: "t1" } });
|
|
22
|
+
expect(readFileSync(join(home, "gh-token"), "utf8")).toBe("t1");
|
|
23
|
+
expect(readFileSync(join(home, "gh-token.d", "acme"), "utf8")).toBe("t1");
|
|
24
|
+
expect(readdirSync(home).sort()).toEqual(["gh-token", "gh-token.d"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("files are mode 0600", () => {
|
|
28
|
+
writeTokenFiles(home, { defaultToken: "t1" });
|
|
29
|
+
expect(statSync(join(home, "gh-token")).mode & 0o777).toBe(0o600);
|
|
30
|
+
writeTokenFiles(home, { defaultToken: "t2" }); // overwrite keeps mode
|
|
31
|
+
expect(statSync(join(home, "gh-token")).mode & 0o777).toBe(0o600);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("multiple installations write lowercase per-owner files", () => {
|
|
35
|
+
writeTokenFiles(home, {
|
|
36
|
+
defaultToken: "a",
|
|
37
|
+
ownerTokens: { Acme: "a", Other: "b" },
|
|
38
|
+
});
|
|
39
|
+
expect(readFileSync(join(home, "gh-token.d", "acme"), "utf8")).toBe("a");
|
|
40
|
+
expect(readFileSync(join(home, "gh-token.d", "other"), "utf8")).toBe("b");
|
|
41
|
+
expect(statSync(join(home, "gh-token.d", "other")).mode & 0o777).toBe(
|
|
42
|
+
0o600,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("stale per-owner files are removed; the directory is kept", () => {
|
|
47
|
+
writeTokenFiles(home, {
|
|
48
|
+
defaultToken: "a",
|
|
49
|
+
ownerTokens: { acme: "a", other: "b", third: "c" },
|
|
50
|
+
});
|
|
51
|
+
writeTokenFiles(home, {
|
|
52
|
+
defaultToken: "a",
|
|
53
|
+
ownerTokens: { acme: "a", other: "b" },
|
|
54
|
+
});
|
|
55
|
+
expect(readdirSync(join(home, "gh-token.d")).sort()).toEqual([
|
|
56
|
+
"acme",
|
|
57
|
+
"other",
|
|
58
|
+
]);
|
|
59
|
+
writeTokenFiles(home, { defaultToken: "a", ownerTokens: { acme: "a" } });
|
|
60
|
+
expect(readdirSync(join(home, "gh-token.d"))).toEqual(["acme"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("failedOwners keep their last good file", () => {
|
|
64
|
+
writeTokenFiles(home, {
|
|
65
|
+
defaultToken: "a",
|
|
66
|
+
ownerTokens: { acme: "a", other: "b" },
|
|
67
|
+
});
|
|
68
|
+
writeTokenFiles(home, {
|
|
69
|
+
defaultToken: "a2",
|
|
70
|
+
ownerTokens: { acme: "a2" },
|
|
71
|
+
failedOwners: ["Other"],
|
|
72
|
+
});
|
|
73
|
+
expect(readFileSync(join(home, "gh-token"), "utf8")).toBe("a2");
|
|
74
|
+
expect(readFileSync(join(home, "gh-token.d", "acme"), "utf8")).toBe("a2");
|
|
75
|
+
expect(readFileSync(join(home, "gh-token.d", "other"), "utf8")).toBe("b");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("owners neither minted nor failed are still cleaned up; the dir is kept with one owner", () => {
|
|
79
|
+
writeTokenFiles(home, {
|
|
80
|
+
defaultToken: "a",
|
|
81
|
+
ownerTokens: { acme: "a", other: "b", third: "c" },
|
|
82
|
+
});
|
|
83
|
+
writeTokenFiles(home, {
|
|
84
|
+
defaultToken: "a",
|
|
85
|
+
ownerTokens: { acme: "a" },
|
|
86
|
+
failedOwners: ["other"],
|
|
87
|
+
});
|
|
88
|
+
expect(readdirSync(join(home, "gh-token.d")).sort()).toEqual([
|
|
89
|
+
"acme",
|
|
90
|
+
"other",
|
|
91
|
+
]);
|
|
92
|
+
writeTokenFiles(home, { defaultToken: "a", ownerTokens: { acme: "a" } });
|
|
93
|
+
expect(readdirSync(join(home, "gh-token.d"))).toEqual(["acme"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("never logs a removal or token values when the owner set shrinks", () => {
|
|
97
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
98
|
+
try {
|
|
99
|
+
writeTokenFiles(home, {
|
|
100
|
+
defaultToken: "secret-a",
|
|
101
|
+
ownerTokens: { acme: "secret-a", other: "secret-b" },
|
|
102
|
+
});
|
|
103
|
+
writeTokenFiles(home, {
|
|
104
|
+
defaultToken: "secret-a",
|
|
105
|
+
ownerTokens: { acme: "secret-a" },
|
|
106
|
+
});
|
|
107
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
108
|
+
} finally {
|
|
109
|
+
logSpy.mockRestore();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("absent defaultToken leaves gh-token untouched", () => {
|
|
114
|
+
writeTokenFiles(home, { defaultToken: "keep" });
|
|
115
|
+
writeTokenFiles(home, { ownerTokens: { acme: "a", other: "b" } });
|
|
116
|
+
expect(readFileSync(join(home, "gh-token"), "utf8")).toBe("keep");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("path-traversal owner is rejected before anything is written", () => {
|
|
120
|
+
expect(() =>
|
|
121
|
+
writeTokenFiles(home, {
|
|
122
|
+
defaultToken: "a",
|
|
123
|
+
ownerTokens: { "../x": "a", ok: "b" },
|
|
124
|
+
}),
|
|
125
|
+
).toThrow();
|
|
126
|
+
expect(readdirSync(home)).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("writeFileAtomic", () => {
|
|
131
|
+
test("leaves no temp files and never exposes a torn file under racing writers", async () => {
|
|
132
|
+
const path = join(home, "gh-token");
|
|
133
|
+
const values = ["a".repeat(4096), "b".repeat(4096)];
|
|
134
|
+
writeFileAtomic(path, values[0]);
|
|
135
|
+
await Promise.all(
|
|
136
|
+
Array.from({ length: 50 }, async (_, i) => {
|
|
137
|
+
writeFileAtomic(path, values[i % 2]);
|
|
138
|
+
expect(values).toContain(readFileSync(path, "utf8"));
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
expect(readdirSync(home)).toEqual(["gh-token"]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/gh-token-files.ts
|
|
3
|
+
*
|
|
4
|
+
* Atomic token-file layer. `gh-token` always holds the default installation's
|
|
5
|
+
* token; `gh-token.d/<lowercase-owner>` always holds per-owner tokens for
|
|
6
|
+
* every usable installation, even when only one is usable (App auth only —
|
|
7
|
+
* PAT auth never reaches this module). Two processes (entrypoint-main and
|
|
8
|
+
* index.ts) write the same files, so every write is temp file + rename —
|
|
9
|
+
* readers never observe a torn file.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
chmodSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
/** GitHub owner (user/org) login: alphanumerics and single hyphens, max 39, no leading/trailing hyphen. */
|
|
24
|
+
const GITHUB_OWNER_PATTERN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
|
|
25
|
+
|
|
26
|
+
export function validateOwner(owner: string): string {
|
|
27
|
+
if (!GITHUB_OWNER_PATTERN.test(owner)) {
|
|
28
|
+
throw new Error(`Invalid GitHub owner name: ${JSON.stringify(owner)}`);
|
|
29
|
+
}
|
|
30
|
+
return owner.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Write via a unique temp file in the same directory, then rename over the target (mode 0600). */
|
|
34
|
+
export function writeFileAtomic(path: string, content: string): void {
|
|
35
|
+
const tmp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
38
|
+
chmodSync(tmp, 0o600); // mode above is masked by umask and ignored on overwrite
|
|
39
|
+
renameSync(tmp, path);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
rmSync(tmp, { force: true });
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TokenFilesInput {
|
|
47
|
+
/** Default installation's token, written to `gh-token`. Absent → `gh-token` is left as is. */
|
|
48
|
+
defaultToken?: string;
|
|
49
|
+
/** Per-owner tokens for every usable installation (including the default's owner). */
|
|
50
|
+
ownerTokens?: Record<string, string>;
|
|
51
|
+
/**
|
|
52
|
+
* Owners whose mint failed transiently. Their existing per-owner files are
|
|
53
|
+
* left untouched and are not pruned.
|
|
54
|
+
*/
|
|
55
|
+
failedOwners?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Write `gh-token` and the per-owner files (dir mode 0700, files 0600), then
|
|
60
|
+
* prune per-owner entries that are neither minted nor failed. The directory is
|
|
61
|
+
* kept even when only one installation remains.
|
|
62
|
+
*/
|
|
63
|
+
export function writeTokenFiles(
|
|
64
|
+
agentHome: string,
|
|
65
|
+
input: TokenFilesInput,
|
|
66
|
+
): void {
|
|
67
|
+
const dir = join(agentHome, "gh-token.d");
|
|
68
|
+
const owners = Object.entries(input.ownerTokens ?? {}).map(
|
|
69
|
+
([owner, token]) => [validateOwner(owner), token] as const,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const failed = (input.failedOwners ?? []).map(validateOwner);
|
|
73
|
+
|
|
74
|
+
if (input.defaultToken !== undefined) {
|
|
75
|
+
writeFileAtomic(join(agentHome, "gh-token"), input.defaultToken);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Nothing to track (e.g. a default-token-only write): don't create an empty
|
|
79
|
+
// directory, which would make the credential helper deny every owner.
|
|
80
|
+
if (owners.length === 0 && failed.length === 0 && !existsSync(dir)) return;
|
|
81
|
+
|
|
82
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
83
|
+
chmodSync(dir, 0o700);
|
|
84
|
+
for (const [owner, token] of owners) writeFileAtomic(join(dir, owner), token);
|
|
85
|
+
|
|
86
|
+
const keep = new Set([...owners.map(([owner]) => owner), ...failed]);
|
|
87
|
+
for (const name of readdirSync(dir)) {
|
|
88
|
+
if (!keep.has(name) && !name.endsWith(".tmp")) {
|
|
89
|
+
rmSync(join(dir, name), { force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { validateOwner, writeTokenFiles } from "./gh-token-files.ts";
|
|
13
|
+
|
|
14
|
+
describe("validateOwner", () => {
|
|
15
|
+
test("lowercases valid owners", () => {
|
|
16
|
+
expect(validateOwner("App-Vitals")).toBe("app-vitals");
|
|
17
|
+
expect(validateOwner("a")).toBe("a");
|
|
18
|
+
});
|
|
19
|
+
test.each([
|
|
20
|
+
"../x",
|
|
21
|
+
"a/b",
|
|
22
|
+
"",
|
|
23
|
+
"-a",
|
|
24
|
+
"a-",
|
|
25
|
+
"a--b",
|
|
26
|
+
"a.b",
|
|
27
|
+
"x".repeat(40),
|
|
28
|
+
"a\0b",
|
|
29
|
+
])("rejects %p", (bad) => {
|
|
30
|
+
expect(() => validateOwner(bad)).toThrow();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("writeTokenFiles — single installation", () => {
|
|
35
|
+
let home: string;
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
home = mkdtempSync(join(tmpdir(), "gh-token-files-unit-"));
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
40
|
+
|
|
41
|
+
test("writes the lowercase owner file (0600) in a 0700 directory", () => {
|
|
42
|
+
writeTokenFiles(home, { defaultToken: "t1", ownerTokens: { Acme: "t1" } });
|
|
43
|
+
expect(readFileSync(join(home, "gh-token"), "utf8")).toBe("t1");
|
|
44
|
+
expect(readFileSync(join(home, "gh-token.d", "acme"), "utf8")).toBe("t1");
|
|
45
|
+
expect(statSync(join(home, "gh-token.d", "acme")).mode & 0o777).toBe(0o600);
|
|
46
|
+
expect(statSync(join(home, "gh-token.d")).mode & 0o777).toBe(0o700);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("the directory is kept when only one installation remains; stale owners are pruned", () => {
|
|
50
|
+
writeTokenFiles(home, {
|
|
51
|
+
defaultToken: "a",
|
|
52
|
+
ownerTokens: { acme: "a", other: "b" },
|
|
53
|
+
});
|
|
54
|
+
writeTokenFiles(home, { defaultToken: "a", ownerTokens: { acme: "a" } });
|
|
55
|
+
expect(existsSync(join(home, "gh-token.d"))).toBe(true);
|
|
56
|
+
expect(readdirSync(join(home, "gh-token.d"))).toEqual(["acme"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a default-token-only write creates no gh-token.d", () => {
|
|
60
|
+
writeTokenFiles(home, { defaultToken: "t1" });
|
|
61
|
+
expect(readdirSync(home)).toEqual(["gh-token"]);
|
|
62
|
+
});
|
|
63
|
+
});
|