Skip to content

Commit bf2f5c3

Browse files
authored
Merge pull request #15 from japer-technology/copilot/ensure-lifecycle-are-rock-solid
Harden lifecycle scripts: exit-code contract, EOF safety, path guard, atomic writes
2 parents b0a49de + 623e85a commit bf2f5c3

3 files changed

Lines changed: 159 additions & 22 deletions

File tree

.github-minimum-intelligence/lifecycle/agent.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ const repo = process.env.GITHUB_REPOSITORY!;
113113
const defaultBranch = event.repository?.default_branch ?? "main";
114114

115115
// The issue number is present on both the `issues` and `issue_comment` payloads.
116+
// Guard explicitly so a misconfigured workflow trigger (e.g. a non-issue event)
117+
// fails with a clear message instead of an opaque TypeError.
118+
if (!event.issue || typeof event.issue.number !== "number") {
119+
throw new Error(
120+
`Event payload for "${eventName}" has no issue number. ` +
121+
`agent.ts only supports "issues" and "issue_comment" events.`
122+
);
123+
}
116124
const issueNumber: number = event.issue.number;
117125

118126
// Read the committed `.pi` defaults and pass them explicitly to the runtime.
@@ -464,4 +472,9 @@ async function main() {
464472
}
465473
}
466474

467-
main();
475+
main().catch((err: unknown) => {
476+
// Fail the workflow step with a readable error instead of relying on the
477+
// runtime's unhandled-rejection behaviour (which varies between versions).
478+
console.error(err instanceof Error ? err.message : String(err));
479+
process.exit(1);
480+
});

.github-minimum-intelligence/lifecycle/local-chat.test.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
* `.github-minimum-intelligence/.github-minimum-intelligence/state/`.
1010
* 2. `--list` must succeed (exit 0) regardless of cwd.
1111
* 3. `--rm` of an unknown ref must exit 2 (user error), not 1.
12+
* 4. Exit-code contract: user errors (unknown thread, invalid/taken alias)
13+
* exit 2; environment problems exit 1.
14+
* 5. EOF safety: closed stdin (non-TTY / Ctrl-D) must never hang an
15+
* interactive prompt.
1216
*/
1317

1418
import { describe, expect, test } from "bun:test";
@@ -20,12 +24,17 @@ import { join, resolve } from "path";
2024
const MI_DIR = resolve(import.meta.dir, "..");
2125
const CHAT_SCRIPT = join(MI_DIR, "lifecycle", "local-chat.ts");
2226

23-
function runChat(args: string[], cwd: string) {
27+
// Hard ceiling for each spawned runner: an EOF-handling regression would
28+
// otherwise hang the suite forever.
29+
const SPAWN_TIMEOUT_MS = 30_000;
30+
31+
function runChat(args: string[], cwd: string, extraEnv: Record<string, string | undefined> = {}) {
2432
// process.execPath points at the currently running Bun, regardless of PATH.
2533
return spawnSync(process.execPath, ["run", CHAT_SCRIPT, ...args], {
2634
cwd,
2735
encoding: "utf-8",
28-
env: { ...process.env, NO_COLOR: "1" },
36+
timeout: SPAWN_TIMEOUT_MS,
37+
env: { ...process.env, NO_COLOR: "1", ...extraEnv },
2938
});
3039
}
3140

@@ -57,4 +66,50 @@ describe("local-chat regression tests", () => {
5766
// would indicate the runner mistook missing state for a config problem.
5867
expect([0, 2]).toContain(result.status);
5968
});
69+
70+
test("--thread with an unknown ref exits 2 (user error)", () => {
71+
const result = runChat(
72+
["--thread", "no-such-thread-abc", "hello"],
73+
MI_DIR,
74+
{ OPENAI_API_KEY: "test-key", OPENAI_BASE_URL: undefined, LOCAL_LLM_BASE_URL: undefined },
75+
);
76+
expect(result.status).toBe(2);
77+
expect(result.stdout).toContain("Unknown thread");
78+
});
79+
80+
test("--new with an invalid (pure-digit) alias exits 2 (user error)", () => {
81+
const result = runChat(["--new", "--name", "12345"], MI_DIR);
82+
expect(result.status).toBe(2);
83+
expect(result.stdout).toContain("Invalid name");
84+
});
85+
86+
test("--new with a taken alias exits 2; --rm cleans it up", () => {
87+
const alias = `gmi-test-${Date.now()}-${process.pid}`;
88+
try {
89+
const first = runChat(["--new", "--name", alias], MI_DIR);
90+
expect(first.status).toBe(0);
91+
const dup = runChat(["--new", "--name", alias], MI_DIR);
92+
expect(dup.status).toBe(2);
93+
expect(dup.stdout).toContain("already taken");
94+
} finally {
95+
const rm = runChat(["--rm", alias], MI_DIR);
96+
expect(rm.status).toBe(0);
97+
}
98+
});
99+
100+
test("missing API key with closed stdin exits cleanly without hanging", () => {
101+
// With no key and stdin at EOF, the guided-recovery prompt must resolve
102+
// (treating EOF as "quit") rather than waiting forever on input.
103+
const result = runChat([], MI_DIR, {
104+
OPENAI_API_KEY: undefined,
105+
ANTHROPIC_API_KEY: undefined,
106+
OPENAI_BASE_URL: undefined,
107+
LOCAL_LLM_BASE_URL: undefined,
108+
LOCAL_PROVIDER: undefined,
109+
LOCAL_MODEL: undefined,
110+
});
111+
// Must terminate (no timeout) and quit cleanly from the recovery guide.
112+
expect(result.signal).toBeNull();
113+
expect(result.status).toBe(0);
114+
});
60115
});

.github-minimum-intelligence/lifecycle/local-chat.ts

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ import {
9090
mkdirSync, readdirSync, statSync, unlinkSync, renameSync,
9191
openSync, closeSync,
9292
} from "fs";
93-
import { resolve, join, basename } from "path";
93+
import { resolve, join, basename, sep } from "path";
9494
import { networkInterfaces } from "os";
9595
import { createInterface } from "readline";
9696
import { execFileSync, execSync } from "child_process";
@@ -246,6 +246,13 @@ const say = {
246246
},
247247
};
248248

249+
// ─── Error taxonomy ───────────────────────────────────────────────────────────
250+
// UserError marks problems caused by user input (unknown thread, taken alias,
251+
// malformed args). The top-level handler maps it to exit code 2, keeping the
252+
// documented contract: 0 success, 1 environment problem, 2 user error.
253+
254+
class UserError extends Error {}
255+
249256
// ─── Thread record schema ─────────────────────────────────────────────────────
250257

251258
type Thread = {
@@ -273,7 +280,12 @@ function readThread(id: number): Thread | null {
273280
}
274281

275282
function writeThread(t: Thread): void {
276-
writeFileSync(threadPath(t.id), JSON.stringify(t, null, 2) + "\n");
283+
// Atomic write: write to a temp file then rename over the target so a crash
284+
// mid-write can never leave a truncated/corrupt thread record behind.
285+
const target = threadPath(t.id);
286+
const tmp = `${target}.tmp-${process.pid}`;
287+
writeFileSync(tmp, JSON.stringify(t, null, 2) + "\n");
288+
renameSync(tmp, target);
277289
}
278290

279291
function listThreads(): Thread[] {
@@ -297,15 +309,15 @@ function listThreads(): Thread[] {
297309
function allocateThread(name: string | null): Thread {
298310
if (name !== null) {
299311
if (!ALIAS_PATTERN.test(name)) {
300-
throw new Error(
312+
throw new UserError(
301313
`Invalid name "${name}". Must start with a letter and contain only ` +
302314
`letters, digits, "_" or "-" (max 64 chars). Pure-digit names are ` +
303315
`reserved for IDs.`
304316
);
305317
}
306318
for (const existing of listThreads()) {
307319
if (existing.name === name) {
308-
throw new Error(
320+
throw new UserError(
309321
`Thread name "${name}" already taken by thread #${existing.id}.`
310322
);
311323
}
@@ -507,7 +519,10 @@ function getRepoName(): string {
507519
function safePath(userPath: string): string | null {
508520
const root = getRepoRoot();
509521
const resolved = resolve(root, userPath);
510-
if (!resolved.startsWith(root)) return null;
522+
// Require the repo root itself or a path under `root + sep`; a bare
523+
// startsWith(root) check would wrongly accept sibling dirs like
524+
// "/repo-evil" when root is "/repo".
525+
if (resolved !== root && !resolved.startsWith(root + sep)) return null;
511526
return resolved;
512527
}
513528

@@ -578,6 +593,34 @@ rejected (no auto-create on typos). Aliases must start with a letter.`
578593
);
579594
}
580595

596+
// ─── EOF-safe readline questions ──────────────────────────────────────────────
597+
598+
/**
599+
* Build an EOF-safe `ask` function for a readline interface. Resolves with
600+
* the user's answer, or `null` when the input reaches EOF (Ctrl-D / closed
601+
* non-TTY stdin) — readline never invokes the question callback in that case,
602+
* which would otherwise leave the promise (and the process) hanging forever.
603+
* The `pending` hand-off guarantees each promise settles exactly once even if
604+
* the close event and the question callback race. Questions must be asked
605+
* serially (await each answer before asking the next), which is how every
606+
* call site in this file uses it; concurrent questions would overwrite the
607+
* single pending resolver.
608+
*/
609+
function makeAsk(rl: ReturnType<typeof createInterface>): (q: string) => Promise<string | null> {
610+
let pending: ((v: string | null) => void) | null = null;
611+
rl.on("close", () => {
612+
const p = pending; pending = null;
613+
if (p) p(null);
614+
});
615+
return (q: string) => new Promise((res) => {
616+
pending = res;
617+
rl.question(q, (a: string) => {
618+
const p = pending; pending = null;
619+
if (p) p(a ?? "");
620+
});
621+
});
622+
}
623+
581624
/**
582625
* Interactive launcher shown when `bun run chat` is invoked with no args.
583626
* Lists existing threads and lets the user pick by row number, press Enter
@@ -644,10 +687,12 @@ async function interactiveStart(provider: string, model: string, thinking: strin
644687
console.log("");
645688

646689
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
647-
const ask = (q: string): Promise<string> => new Promise((res) => rl.question(q, res));
690+
const ask = makeAsk(rl);
648691
try {
649692
while (true) {
650-
const raw = (await ask(" Select> ")).trim();
693+
const answer = await ask(" Select> ");
694+
if (answer === null) return null; // EOF — treat as quit.
695+
const raw = answer.trim();
651696
if (raw === "q" || raw === "Q" || raw === "/exit" || raw === "/quit") {
652697
return null;
653698
}
@@ -974,9 +1019,14 @@ async function runTurn(
9741019
"and risk binding the wrong session to this thread."
9751020
);
9761021
}
977-
created.sort((a, b) =>
978-
statSync(join(sessionsDir, b)).mtimeMs - statSync(join(sessionsDir, a)).mtimeMs
979-
);
1022+
created.sort((a, b) => {
1023+
// A concurrent runner may delete/rotate a session file between the
1024+
// snapshot diff and this sort; treat vanished files as oldest.
1025+
const mtime = (f: string): number => {
1026+
try { return statSync(join(sessionsDir, f)).mtimeMs; } catch { return 0; }
1027+
};
1028+
return mtime(b) - mtime(a);
1029+
});
9801030
sessionPath = join(sessionsDir, created[0]);
9811031
}
9821032

@@ -1019,6 +1069,7 @@ function cmdRemove(ref: string): void {
10191069
const t = resolveThreadRef(ref);
10201070
if (!t) {
10211071
say.warn(`No thread matching "${ref}".`, "Use `--list` to see existing threads.");
1072+
process.exitCode = 2; // user error per the documented exit-code contract
10221073
return;
10231074
}
10241075
unlinkSync(threadPath(t.id));
@@ -1116,7 +1167,9 @@ async function repl(initial: Thread, rt: RuntimeState): Promise<void> {
11161167
console.log("");
11171168

11181169
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
1119-
const ask = (q: string): Promise<string> => new Promise((res) => rl.question(q, res));
1170+
// EOF-safe question wrapper: resolves null on Ctrl-D / closed stdin so the
1171+
// REPL exits cleanly instead of hanging on an unanswerable question.
1172+
const ask = makeAsk(rl);
11201173

11211174
function prompt(): string {
11221175
const branch = getGitBranch();
@@ -1147,7 +1200,9 @@ async function repl(initial: Thread, rt: RuntimeState): Promise<void> {
11471200
try {
11481201
// eslint-disable-next-line no-constant-condition
11491202
while (true) {
1150-
const line = (await ask(prompt())).trim();
1203+
const answer = await ask(prompt());
1204+
if (answer === null) break; // EOF — end the session cleanly.
1205+
const line = answer.trim();
11511206
if (!line) continue;
11521207

11531208
// ─── Exit ─────────────────────────────────────────────────────────────
@@ -1549,7 +1604,7 @@ async function repl(initial: Thread, rt: RuntimeState): Promise<void> {
15491604
// eslint-disable-next-line no-constant-condition
15501605
while (true) {
15511606
const more = await ask(" ... ");
1552-
if (more.trim() === "") break;
1607+
if (more === null || more.trim() === "") break;
15531608
lines.push(more);
15541609
}
15551610
const full = lines.join("\n").trim();
@@ -1588,9 +1643,9 @@ type RuntimeCfg = { provider: string; model: string; thinking: string | undefine
15881643
async function promptLine(question: string): Promise<string> {
15891644
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
15901645
try {
1591-
return await new Promise<string>((res) => {
1592-
rl.question(question, (a: string) => res(a ?? ""));
1593-
});
1646+
// makeAsk resolves null on EOF (Ctrl-D / closed stdin); map that to ""
1647+
// so callers can treat it as "user backed out".
1648+
return (await makeAsk(rl)(question)) ?? "";
15941649
} catch {
15951650
return "";
15961651
} finally {
@@ -1813,9 +1868,13 @@ async function main(): Promise<void> {
18131868

18141869
let cfg: RuntimeCfg = resolveRuntimeConfig();
18151870

1871+
// Pure allocation (`--new` without a prompt or thread ref) never contacts a
1872+
// model, so it must work without an API key.
1873+
const allocationOnly = args.newThread && !args.prompt && !args.threadRef;
1874+
18161875
// ── Validate config BEFORE creating threads ─────────────────────────────
18171876
// (so quitting from the guide doesn't leave orphan thread #1 behind.)
1818-
if (!isLocalProvider(cfg.provider)) {
1877+
if (!allocationOnly && !isLocalProvider(cfg.provider)) {
18191878
const keyName = PROVIDER_KEY_MAP[cfg.provider];
18201879
if (keyName && !process.env[keyName]) {
18211880
const updated = await guideMissingApiKey(cfg);
@@ -1859,6 +1918,7 @@ async function main(): Promise<void> {
18591918
"Use `--list` to see existing threads, or `--new` to create one. " +
18601919
"Closed-world: unknown refs are never auto-created."
18611920
);
1921+
process.exitCode = 2; // user error per the documented exit-code contract
18621922
return;
18631923
}
18641924
}
@@ -1894,5 +1954,14 @@ async function main(): Promise<void> {
18941954
await repl(activeThread, rt);
18951955
}
18961956

1897-
main();
1898-
1957+
main().catch((err: unknown) => {
1958+
// Top-level error handler: honour the documented exit-code contract
1959+
// (1 = environment problem, 2 = user error) and print a readable message
1960+
// instead of an unhandled-rejection stack trace.
1961+
say.error(
1962+
err instanceof UserError ? "Invalid request" : "Startup failed",
1963+
err instanceof Error ? err.message : String(err),
1964+
);
1965+
cleanup();
1966+
process.exit(err instanceof UserError ? 2 : 1);
1967+
});

0 commit comments

Comments
 (0)