agent-yes 1.228.0 → 1.229.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/dist/{JsonlStore-Bm5Nj6v_.js → JsonlStore-CWOA3aDH.js} +1 -1
- package/dist/{SUPPORTED_CLIS-C5d64Xqj.js → SUPPORTED_CLIS-CvLtp6LS.js} +2 -2
- package/dist/SUPPORTED_CLIS-DJEnqsp2.js +11 -0
- package/dist/{agentShare-CpglhRYb.js → agentShare-CQwvWWfd.js} +4 -4
- package/dist/{callback-B3N2VJ1i.js → callback-BFZDARQq.js} +4 -4
- package/dist/{callback-CCEQtwXX.js → callback-BN-tH9dB.js} +3 -3
- package/dist/cli.js +9 -9
- package/dist/{cwdConflictWarn-CJZRp4S0.js → cwdConflictWarn-CKUsQ5_o.js} +2 -2
- package/dist/{globalPidIndex-CoNr7tS8.js → globalPidIndex-Ca4sTr1b.js} +1 -1
- package/dist/index.js +8 -8
- package/dist/{messageLog-Bb_Ie3Ck.js → messageLog-zVGWLX7w.js} +1 -1
- package/dist/{notifyDaemon-CfQN5blR.js → notifyDaemon-CENKHs1M.js} +4 -4
- package/dist/pidStore-Cp3HekUh.js +6 -0
- package/dist/{pidStore-DCqpU_bI.js → pidStore-Hjba3K6Q.js} +3 -3
- package/dist/{reaper-BNKvujYy.js → reaper-iQXyufgp.js} +1 -1
- package/dist/{reaper-BBc2AIKA.js → reaper-pJpMYKmj.js} +1 -1
- package/dist/{runningLock-CNMl13dC.js → runningLock-BFk3Mo8K.js} +1 -1
- package/dist/{rustBinary-B8IcqSw1.js → rustBinary-BjApGser.js} +2 -2
- package/dist/{schedule-B-RjgOOR.js → schedule-D0O3sb5A.js} +9 -9
- package/dist/{serve-DwF03sgB.js → serve-m-WChRrO.js} +67 -21
- package/dist/{setup-cZFxfS89.js → setup-fuwC5wlF.js} +2 -2
- package/dist/spawnGate-5jC6eiqk.js +5 -0
- package/dist/{spawnGate-CeFYH-Ib.js → spawnGate-CMlGqVN4.js} +2 -2
- package/dist/{subcommands-DSyCT0vl.js → subcommands-DzJbnYAx.js} +12 -12
- package/dist/{subcommands-UseeaeYM.js → subcommands-lwgJxJex.js} +3 -3
- package/dist/{todoAutomation-Cwnh4aWo.js → todoAutomation-x6guaQXc.js} +115 -6
- package/dist/{todoCli-6v1CYdxN.js → todoCli-Cjhx3DqQ.js} +38 -6
- package/dist/{tray-DBt8X-Qj.js → tray-C8VfnS7s.js} +2 -2
- package/dist/{ts-Cl3CmLH9.js → ts-oYrTk4Z_.js} +103 -20
- package/dist/{versionChecker-Dhjyjxs_.js → versionChecker-CgCXc8CJ.js} +2 -2
- package/dist/{ws-Dw2fGxTI.js → ws-D5q3R1kY.js} +4 -4
- package/package.json +1 -1
- package/ts/askApi.spec.ts +358 -0
- package/ts/askApi.ts +255 -0
- package/ts/index.ts +2 -0
- package/ts/serve.ts +131 -0
- package/ts/todoBlock.spec.ts +10 -0
- package/ts/todoBlock.ts +14 -2
- package/ts/todoCli.spec.ts +101 -0
- package/ts/todoCli.ts +69 -1
- package/ts/todoStore.spec.ts +126 -0
- package/ts/todoStore.ts +130 -2
- package/dist/SUPPORTED_CLIS-Bfresz1n.js +0 -11
- package/dist/pidStore-CatWvn5W.js +0 -6
- package/dist/spawnGate-CIC7b7nm.js +0 -5
package/ts/serve.ts
CHANGED
|
@@ -37,11 +37,13 @@ import {
|
|
|
37
37
|
writeToIpc,
|
|
38
38
|
type CommonOpts,
|
|
39
39
|
} from "./subcommands.ts";
|
|
40
|
+
import { answerAsk, listAsks } from "./askApi.ts";
|
|
40
41
|
import { TYPING_BADGE } from "./badges.ts";
|
|
41
42
|
import { isCallbackRevoked, loadCallbackSecretReadOnly } from "./callback.ts";
|
|
42
43
|
import { CLAUDE_SESSION_PIN_ENV } from "./sessionEnv.ts";
|
|
43
44
|
import { MAX_CALLBACK_MSG_BYTES, frameVisitorMessage, verifyCapability } from "./callbackCore.ts";
|
|
44
45
|
import { isTerminalReply } from "./terminalReply.ts";
|
|
46
|
+
import { removeControlCharacters } from "./removeControlCharacters.ts";
|
|
45
47
|
import { parseStatusText } from "./statusText.ts";
|
|
46
48
|
import { ensureNodeRuntime, liveEnv } from "./nodeRuntime.ts";
|
|
47
49
|
import { acquireWebrtcHostLock, type ServeLockOwner } from "./serveLock.ts";
|
|
@@ -2053,6 +2055,135 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2053
2055
|
}
|
|
2054
2056
|
}
|
|
2055
2057
|
|
|
2058
|
+
// Both /api/asks routes below share this: "every project" is derived the
|
|
2059
|
+
// same way `ay ls` aggregates across projects — the distinct `cwd`s in
|
|
2060
|
+
// the live-agent registry — since there is no separate "registered
|
|
2061
|
+
// projects" list to read; a project this host has never run an agent in
|
|
2062
|
+
// has nothing to aggregate anyway.
|
|
2063
|
+
const liveProjectRoots = async (): Promise<string[]> => {
|
|
2064
|
+
const records = await listRecords(undefined, defaultOpts({ all: true }));
|
|
2065
|
+
return [...new Set(records.map((r) => r.cwd).filter(Boolean))];
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2068
|
+
// GET /api/asks — the `/ask` decision panel (A7): every `blocked-by-human`
|
|
2069
|
+
// task across every project this host's agents have registered, one
|
|
2070
|
+
// human-facing list.
|
|
2071
|
+
if (req.method === "GET" && p === "/api/asks") {
|
|
2072
|
+
try {
|
|
2073
|
+
const asks = await listAsks(await liveProjectRoots());
|
|
2074
|
+
return Response.json(asks);
|
|
2075
|
+
} catch (e) {
|
|
2076
|
+
return new Response((e as Error).message, { status: 500 });
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// POST /api/asks/answer body {projectRoot, taskId, choice?, acknowledged?}
|
|
2081
|
+
// — the closed loop's single request: `answerAsk()` clears the block and
|
|
2082
|
+
// (for human/decision-kind tasks) advances the transition atomically, so
|
|
2083
|
+
// there is no intermediate state visible to a second request. As a
|
|
2084
|
+
// best-effort follow-up (never blocks the response on it), if the task's
|
|
2085
|
+
// owner resolves to a currently-live agent IN THIS SAME PROJECT, the
|
|
2086
|
+
// answer is written straight into that agent's terminal via the exact
|
|
2087
|
+
// same FIFO mechanism `/api/send` already uses — the real-time half of
|
|
2088
|
+
// the closed loop, for whichever agent happens to be alive and watching
|
|
2089
|
+
// right now.
|
|
2090
|
+
if (req.method === "POST" && p === "/api/asks/answer") {
|
|
2091
|
+
let raw: unknown;
|
|
2092
|
+
try {
|
|
2093
|
+
raw = await req.json();
|
|
2094
|
+
} catch {
|
|
2095
|
+
return new Response("invalid JSON body", { status: 400 });
|
|
2096
|
+
}
|
|
2097
|
+
// Type-validate every field explicitly — an untyped JS client (or a
|
|
2098
|
+
// bug) can send a non-string projectRoot/taskId or a truthy
|
|
2099
|
+
// non-boolean `acknowledged`, which would otherwise surface as an
|
|
2100
|
+
// opaque 500 deep inside answerAsk() instead of a clean 400
|
|
2101
|
+
// (codex-review nitpick).
|
|
2102
|
+
if (typeof raw !== "object" || raw === null) {
|
|
2103
|
+
return new Response("body must be a JSON object", { status: 400 });
|
|
2104
|
+
}
|
|
2105
|
+
const body = raw as Record<string, unknown>;
|
|
2106
|
+
const { projectRoot, taskId, choice, acknowledged, expectedBlockRev } = body;
|
|
2107
|
+
if (typeof projectRoot !== "string" || !projectRoot) {
|
|
2108
|
+
return new Response("projectRoot must be a non-empty string", { status: 400 });
|
|
2109
|
+
}
|
|
2110
|
+
if (typeof taskId !== "string" || !taskId) {
|
|
2111
|
+
return new Response("taskId must be a non-empty string", { status: 400 });
|
|
2112
|
+
}
|
|
2113
|
+
if (choice !== undefined && typeof choice !== "string") {
|
|
2114
|
+
return new Response("choice must be a string", { status: 400 });
|
|
2115
|
+
}
|
|
2116
|
+
if (acknowledged !== undefined && typeof acknowledged !== "boolean") {
|
|
2117
|
+
return new Response("acknowledged must be a boolean", { status: 400 });
|
|
2118
|
+
}
|
|
2119
|
+
if (expectedBlockRev !== undefined && !Number.isSafeInteger(expectedBlockRev)) {
|
|
2120
|
+
// A boundary route must never forward NaN/Infinity/fractional values
|
|
2121
|
+
// into the store's blockRev comparison — those can never legitimately
|
|
2122
|
+
// equal a real blockRev (always a safe, non-negative integer), so
|
|
2123
|
+
// letting them through just produces a confusing rejection deep
|
|
2124
|
+
// inside answerAsk() instead of a clean 400 here (codex-review
|
|
2125
|
+
// Important).
|
|
2126
|
+
return new Response("expectedBlockRev must be a safe integer", { status: 400 });
|
|
2127
|
+
}
|
|
2128
|
+
// Scoped to the SAME live-agent-derived project set GET/api/asks
|
|
2129
|
+
// aggregates over — not merely "a store happens to exist here"
|
|
2130
|
+
// (codex-review Important: hasTodoStore() alone still let a caller
|
|
2131
|
+
// target any readable/writable ay todo store path on the host, not
|
|
2132
|
+
// just ones actually surfaced by this API).
|
|
2133
|
+
const registeredRoots = await liveProjectRoots();
|
|
2134
|
+
if (!registeredRoots.includes(projectRoot)) {
|
|
2135
|
+
return new Response(`no registered project at ${projectRoot}`, { status: 404 });
|
|
2136
|
+
}
|
|
2137
|
+
let result: Awaited<ReturnType<typeof answerAsk>>;
|
|
2138
|
+
try {
|
|
2139
|
+
result = await answerAsk(projectRoot, taskId, { choice, acknowledged, expectedBlockRev });
|
|
2140
|
+
} catch (e) {
|
|
2141
|
+
return new Response((e as Error).message, { status: 400 });
|
|
2142
|
+
}
|
|
2143
|
+
const { record: rec, answerText } = result;
|
|
2144
|
+
// Best-effort, fire-and-forget: the task is already correctly updated
|
|
2145
|
+
// above regardless of whether this succeeds. Deliberately NOT awaited —
|
|
2146
|
+
// a stalled FIFO write must never hang the response, which is the
|
|
2147
|
+
// whole point of "best-effort" (codex-review Important: an earlier
|
|
2148
|
+
// version awaited this, so a stuck agent could hang answerAsk's caller
|
|
2149
|
+
// even though the todo state had already been updated). This is a real,
|
|
2150
|
+
// accepted limitation, not a bug: if owner resolution or the FIFO
|
|
2151
|
+
// write fails, there is no retry queue here — the durable, correct
|
|
2152
|
+
// outcome is that the task's stored state already reflects the
|
|
2153
|
+
// answer (see answerAsk's own doc comment), and the owning agent will
|
|
2154
|
+
// pick it up on its own next normal check (`ay todo get`, a reconcile
|
|
2155
|
+
// pass, etc.) rather than through a guaranteed real-time push. A
|
|
2156
|
+
// persistent notification-retry mechanism would be new scope well
|
|
2157
|
+
// beyond this route.
|
|
2158
|
+
if (rec.owner) {
|
|
2159
|
+
// Scoped to THIS project's cwd — an unscoped search could match a
|
|
2160
|
+
// same-named owner in a DIFFERENT project and write the answer into
|
|
2161
|
+
// the wrong agent's terminal (codex-review Important).
|
|
2162
|
+
resolveOne(rec.owner, defaultOpts({ all: true, cwdScope: projectRoot }))
|
|
2163
|
+
.then((owner) => {
|
|
2164
|
+
if (!owner.fifo_file) return;
|
|
2165
|
+
// `answerText` comes from answerAsk()'s own validated result, NOT
|
|
2166
|
+
// re-derived from the raw request body here — the earlier
|
|
2167
|
+
// version recomputed `choice ?? "acknowledged"` independently,
|
|
2168
|
+
// which had the SAME unvalidated-choice gap answerAsk() itself
|
|
2169
|
+
// just fixed (codex-review Important). Also strip terminal
|
|
2170
|
+
// control sequences AND collapse newlines: even a genuinely
|
|
2171
|
+
// validated choice option is free text set at ask-creation time
|
|
2172
|
+
// and could still contain control bytes or embedded newlines
|
|
2173
|
+
// that inject extra "typed" lines/escape sequences into the
|
|
2174
|
+
// live agent's terminal via this raw IPC write (codex-review
|
|
2175
|
+
// Important).
|
|
2176
|
+
const safeAnswerText = removeControlCharacters(answerText).replace(/[\r\n]+/g, " ");
|
|
2177
|
+
return writeToIpc(owner.fifo_file, `[/ask] ${taskId} answered: ${safeAnswerText}\n`);
|
|
2178
|
+
})
|
|
2179
|
+
.catch(() => {
|
|
2180
|
+
// owner not currently live / not resolvable / FIFO write failed —
|
|
2181
|
+
// best-effort only, nothing to report back to this request.
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
return Response.json(rec);
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2056
2187
|
// GET /api/search?q= — chat-history search across every agent's log.
|
|
2057
2188
|
// v1 ("simple" scope, by design): no sidecar index yet — each agent's raw
|
|
2058
2189
|
// log TAIL (SEARCH_TAIL_BYTES) is rendered through the same headless-xterm
|
package/ts/todoBlock.spec.ts
CHANGED
|
@@ -30,4 +30,14 @@ describe("todoBlock", () => {
|
|
|
30
30
|
expect(rendered[3]).toContain("xyz789");
|
|
31
31
|
expect(new Set(rendered).size).toBe(4); // all distinct
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
it("describeBlock includes the actionLink when a blocked-by-human ask is action-shaped (A7)", () => {
|
|
35
|
+
const rendered = describeBlock({
|
|
36
|
+
type: "blocked-by-human",
|
|
37
|
+
who: "taku",
|
|
38
|
+
actionLink: "https://example/oauth",
|
|
39
|
+
});
|
|
40
|
+
expect(rendered).toContain("taku");
|
|
41
|
+
expect(rendered).toContain("https://example/oauth");
|
|
42
|
+
});
|
|
33
43
|
});
|
package/ts/todoBlock.ts
CHANGED
|
@@ -23,7 +23,19 @@
|
|
|
23
23
|
|
|
24
24
|
export type TodoBlock =
|
|
25
25
|
| { type: "blocked-by-task"; taskId: string }
|
|
26
|
-
|
|
26
|
+
// `options` (choice-shape, A7): the human picks one via /ask's buttons.
|
|
27
|
+
// `actionLink` (action-shape, A7): the human must personally complete
|
|
28
|
+
// something at that URL (e.g. an OAuth/CAPTCHA flow) — /ask renders an
|
|
29
|
+
// "open link, then confirm" button instead of a choice list. At most one
|
|
30
|
+
// of the two is expected to be set; neither is required (a bare question
|
|
31
|
+
// with no options/actionLink just needs an acknowledgement).
|
|
32
|
+
| {
|
|
33
|
+
type: "blocked-by-human";
|
|
34
|
+
who: string;
|
|
35
|
+
question?: string;
|
|
36
|
+
options?: string[];
|
|
37
|
+
actionLink?: string;
|
|
38
|
+
}
|
|
27
39
|
| { type: "blocked-by-external"; signal: string; checkFn?: string }
|
|
28
40
|
| { type: "waiting-on-agent"; agentId: string };
|
|
29
41
|
|
|
@@ -52,7 +64,7 @@ export function describeBlock(block: TodoBlock): string {
|
|
|
52
64
|
case "blocked-by-task":
|
|
53
65
|
return `blocked by task ${block.taskId}`;
|
|
54
66
|
case "blocked-by-human":
|
|
55
|
-
return `waiting on ${block.who}${block.question ? `: ${block.question}` : ""}`;
|
|
67
|
+
return `waiting on ${block.who}${block.question ? `: ${block.question}` : ""}${block.actionLink ? ` (action: ${block.actionLink})` : ""}`;
|
|
56
68
|
case "blocked-by-external":
|
|
57
69
|
return `waiting on external signal: ${block.signal}`;
|
|
58
70
|
case "waiting-on-agent":
|
package/ts/todoCli.spec.ts
CHANGED
|
@@ -221,6 +221,107 @@ describe("ay todo CLI", () => {
|
|
|
221
221
|
await expect(run("block", "T1")).rejects.toThrow(/usage: ay todo block/);
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
it("block --type blocked-by-human --action-link produces an action-shaped ask (A7)", async () => {
|
|
225
|
+
await run("new", "e", "--kind", "code");
|
|
226
|
+
const action = await run(
|
|
227
|
+
"block",
|
|
228
|
+
"T1",
|
|
229
|
+
"--type",
|
|
230
|
+
"blocked-by-human",
|
|
231
|
+
"--who",
|
|
232
|
+
"taku",
|
|
233
|
+
"--action-link",
|
|
234
|
+
"https://example/oauth",
|
|
235
|
+
);
|
|
236
|
+
expect(action.out).toContain("taku");
|
|
237
|
+
expect(action.out).toContain("https://example/oauth");
|
|
238
|
+
const got = await run("get", "T1", "--format", "json");
|
|
239
|
+
expect(JSON.parse(got.out).block).toEqual({
|
|
240
|
+
type: "blocked-by-human",
|
|
241
|
+
who: "taku",
|
|
242
|
+
actionLink: "https://example/oauth",
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("block --type blocked-by-human refuses --options together with --action-link (mutually exclusive ask shapes, codex-review round-9 Important)", async () => {
|
|
247
|
+
await run("new", "f", "--kind", "code");
|
|
248
|
+
await expect(
|
|
249
|
+
run(
|
|
250
|
+
"block",
|
|
251
|
+
"T1",
|
|
252
|
+
"--type",
|
|
253
|
+
"blocked-by-human",
|
|
254
|
+
"--who",
|
|
255
|
+
"taku",
|
|
256
|
+
"--options",
|
|
257
|
+
"a",
|
|
258
|
+
"b",
|
|
259
|
+
"--action-link",
|
|
260
|
+
"https://example/oauth",
|
|
261
|
+
),
|
|
262
|
+
).rejects.toThrow(/mutually exclusive/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("block --action-link refuses a non-http(s) URL scheme (javascript:/data: would be an XSS vector when /ask renders it into an <a href>, codex-review round-10 Important)", async () => {
|
|
266
|
+
await run("new", "g", "--kind", "code");
|
|
267
|
+
await expect(
|
|
268
|
+
run(
|
|
269
|
+
"block",
|
|
270
|
+
"T1",
|
|
271
|
+
"--type",
|
|
272
|
+
"blocked-by-human",
|
|
273
|
+
"--who",
|
|
274
|
+
"taku",
|
|
275
|
+
"--action-link",
|
|
276
|
+
"javascript:alert(1)",
|
|
277
|
+
),
|
|
278
|
+
).rejects.toThrow(/must be a valid http/);
|
|
279
|
+
await expect(
|
|
280
|
+
run(
|
|
281
|
+
"block",
|
|
282
|
+
"T1",
|
|
283
|
+
"--type",
|
|
284
|
+
"blocked-by-human",
|
|
285
|
+
"--who",
|
|
286
|
+
"taku",
|
|
287
|
+
"--action-link",
|
|
288
|
+
"data:text/html,x",
|
|
289
|
+
),
|
|
290
|
+
).rejects.toThrow(/must be a valid http/);
|
|
291
|
+
const ok = await run(
|
|
292
|
+
"block",
|
|
293
|
+
"T1",
|
|
294
|
+
"--type",
|
|
295
|
+
"blocked-by-human",
|
|
296
|
+
"--who",
|
|
297
|
+
"taku",
|
|
298
|
+
"--action-link",
|
|
299
|
+
"https://example/oauth",
|
|
300
|
+
);
|
|
301
|
+
expect(ok.out).toContain("https://example/oauth");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("block --action-link stores the URL PARSER's normalized href, not the raw input — a value with embedded control characters that WHATWG URL parsing tolerates/strips must never persist those bytes verbatim, since describeBlock() later renders this as terminal/log text (codex-review round-16 Important)", async () => {
|
|
305
|
+
await run("new", "h", "--kind", "code");
|
|
306
|
+
const withTab = await run(
|
|
307
|
+
"block",
|
|
308
|
+
"T1",
|
|
309
|
+
"--type",
|
|
310
|
+
"blocked-by-human",
|
|
311
|
+
"--who",
|
|
312
|
+
"taku",
|
|
313
|
+
"--action-link",
|
|
314
|
+
"https://example/oauth\tinjected",
|
|
315
|
+
);
|
|
316
|
+
const got = JSON.parse((await run("get", "T1", "--format", "json")).out);
|
|
317
|
+
// whatever the parser accepted, it must be exactly what's stored — never
|
|
318
|
+
// the raw argv string re-used after validation passed on a DIFFERENT
|
|
319
|
+
// (parser-normalized) value
|
|
320
|
+
expect(got.block.actionLink).toBe(new URL("https://example/oauth\tinjected").href);
|
|
321
|
+
expect(got.block.actionLink).not.toContain("\t");
|
|
322
|
+
expect(withTab.out).not.toContain("\t");
|
|
323
|
+
});
|
|
324
|
+
|
|
224
325
|
it("dep add/rm, tree, and digest render real output and surface cycles as a clean error", async () => {
|
|
225
326
|
await run("new", "a", "--kind", "code");
|
|
226
327
|
await run("new", "b", "--kind", "code");
|
package/ts/todoCli.ts
CHANGED
|
@@ -45,11 +45,33 @@ import { describeBlock, type TodoBlock } from "./todoBlock.ts";
|
|
|
45
45
|
import { renderDigest, renderTree, buildTreeJSON, unblockedTasks } from "./todoDigest.ts";
|
|
46
46
|
import { reconcileTodos, type LiveAgent } from "./todoAutomation.ts";
|
|
47
47
|
import { readGlobalPids } from "./globalPidIndex.ts";
|
|
48
|
+
import { removeControlCharacters } from "./removeControlCharacters.ts";
|
|
48
49
|
|
|
49
50
|
function fail(message: string): never {
|
|
50
51
|
throw new Error(message);
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Same check ask.html's own render-time guard uses — a real URL parse, not
|
|
56
|
+
* just a scheme-prefix regex, which could still let a malformed string
|
|
57
|
+
* through. Returns the parser's own normalized `.href` (not the raw input)
|
|
58
|
+
* on success, `null` otherwise: WHATWG URL parsing tolerates/strips some
|
|
59
|
+
* control characters (e.g. embedded tabs/newlines) from the input while
|
|
60
|
+
* still returning a valid http(s) URL, so validating against `s` but then
|
|
61
|
+
* STORING `s` verbatim could still persist those raw control characters
|
|
62
|
+
* into the store — later rendered as terminal/log text by `describeBlock()`
|
|
63
|
+
* (codex-review Important). Storing `.href` instead guarantees the store
|
|
64
|
+
* only ever holds what the parser actually validated.
|
|
65
|
+
*/
|
|
66
|
+
function normalizeHttpUrl(s: string): string | null {
|
|
67
|
+
try {
|
|
68
|
+
const u = new URL(s);
|
|
69
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.href : null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
function parseKind(raw: string | undefined): LifecycleKind {
|
|
54
76
|
if (!raw) fail(`--kind is required (one of: ${Object.keys(LIFECYCLES).join(", ")})`);
|
|
55
77
|
if (!isKnownKind(raw))
|
|
@@ -286,6 +308,7 @@ const blockCmd: CommandModule<
|
|
|
286
308
|
who: string | undefined;
|
|
287
309
|
question: string | undefined;
|
|
288
310
|
options: string[] | undefined;
|
|
311
|
+
"action-link": string | undefined;
|
|
289
312
|
signal: string | undefined;
|
|
290
313
|
agent: string | undefined;
|
|
291
314
|
}
|
|
@@ -300,7 +323,16 @@ const blockCmd: CommandModule<
|
|
|
300
323
|
.option("task", { type: "string", describe: "required for --type blocked-by-task" })
|
|
301
324
|
.option("who", { type: "string", describe: "required for --type blocked-by-human" })
|
|
302
325
|
.option("question", { type: "string" })
|
|
303
|
-
.option("options", {
|
|
326
|
+
.option("options", {
|
|
327
|
+
type: "string",
|
|
328
|
+
array: true,
|
|
329
|
+
describe: "choice-shape ask (/ask renders buttons)",
|
|
330
|
+
})
|
|
331
|
+
.option("action-link", {
|
|
332
|
+
type: "string",
|
|
333
|
+
describe:
|
|
334
|
+
"action-shape ask (/ask renders an 'open link, then confirm' button) — e.g. an OAuth/CAPTCHA URL",
|
|
335
|
+
})
|
|
304
336
|
.option("signal", { type: "string", describe: "required for --type blocked-by-external" })
|
|
305
337
|
.option("agent", { type: "string", describe: "required for --type waiting-on-agent" }),
|
|
306
338
|
handler: async (argv) => {
|
|
@@ -317,11 +349,47 @@ const blockCmd: CommandModule<
|
|
|
317
349
|
break;
|
|
318
350
|
case "blocked-by-human":
|
|
319
351
|
if (!argv.who) fail("--who <name> is required for --type blocked-by-human");
|
|
352
|
+
// choice-shape (--options) and action-shape (--action-link) are
|
|
353
|
+
// mutually exclusive — reject the combination outright here rather
|
|
354
|
+
// than let it depend on askApi.ts's own tie-breaking precedence
|
|
355
|
+
// (which, as of codex-review round-9, checks actionLink first in
|
|
356
|
+
// BOTH listAsksForProject() and answerAsk(), so the two now agree
|
|
357
|
+
// with each other even if this guard were bypassed by a direct
|
|
358
|
+
// library caller — but a block should simply never be created with
|
|
359
|
+
// both set in the first place).
|
|
360
|
+
if (argv.options?.length && argv["action-link"]) {
|
|
361
|
+
fail(
|
|
362
|
+
"--options and --action-link are mutually exclusive (choice-shape vs action-shape ask)",
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
let normalizedActionLink: string | undefined;
|
|
366
|
+
if (argv["action-link"]) {
|
|
367
|
+
const normalized = normalizeHttpUrl(argv["action-link"]);
|
|
368
|
+
if (!normalized) {
|
|
369
|
+
// The /ask page renders this straight into an <a href>. HTML-
|
|
370
|
+
// escaping the text does NOT block dangerous URL schemes
|
|
371
|
+
// (`javascript:`, `data:`) — only the scheme itself does. Reject
|
|
372
|
+
// at write time so a non-http(s) link can never reach the store
|
|
373
|
+
// at all (codex-review Important). Uses the same `URL` parser
|
|
374
|
+
// ask.html's own render-time check uses (codex-review nitpick: a
|
|
375
|
+
// regex prefix match alone would still store a malformed string
|
|
376
|
+
// like "https:/notreallyaurl" that happens to match the prefix).
|
|
377
|
+
// The rejected input is echoed for the operator's benefit, but
|
|
378
|
+
// never verbatim — an invalid value can still contain control
|
|
379
|
+
// characters/newlines, and this string reaches a terminal
|
|
380
|
+
// (codex-review round-18 nitpick).
|
|
381
|
+
fail(
|
|
382
|
+
`--action-link must be a valid http:// or https:// URL (got "${removeControlCharacters(argv["action-link"]).replace(/[\r\n]+/g, " ")}")`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
normalizedActionLink = normalized;
|
|
386
|
+
}
|
|
320
387
|
block = {
|
|
321
388
|
type: "blocked-by-human",
|
|
322
389
|
who: argv.who,
|
|
323
390
|
question: argv.question,
|
|
324
391
|
options: argv.options,
|
|
392
|
+
actionLink: normalizedActionLink,
|
|
325
393
|
};
|
|
326
394
|
break;
|
|
327
395
|
case "blocked-by-external":
|
package/ts/todoStore.spec.ts
CHANGED
|
@@ -573,4 +573,130 @@ describe("TodoStore", () => {
|
|
|
573
573
|
// refused, not erased: the newer block is still exactly what it was
|
|
574
574
|
expect(s.get(t._id)?.block).toEqual({ type: "blocked-by-human", who: "taku" });
|
|
575
575
|
});
|
|
576
|
+
|
|
577
|
+
it("clearBlockIfMatches() — the generalized guard — clears only when the FRESH record's blockRev still equals the snapshot, and refuses (without erasing) a block that changed since decided (codex-review round-15 Important)", async () => {
|
|
578
|
+
const s = await openStore(TEST_ROOT);
|
|
579
|
+
const t = await s.create({ summary: "x", kind: "code" });
|
|
580
|
+
const original = {
|
|
581
|
+
type: "blocked-by-human",
|
|
582
|
+
who: "taku",
|
|
583
|
+
question: "canary or beta?",
|
|
584
|
+
} as const;
|
|
585
|
+
const afterSet = await s.setBlock(t._id, original);
|
|
586
|
+
const cleared = await s.clearBlockIfMatches(t._id, afterSet.blockRev ?? 0);
|
|
587
|
+
expect(cleared.block).toBeNull();
|
|
588
|
+
|
|
589
|
+
const afterReplace = await s.setBlock(t._id, {
|
|
590
|
+
type: "blocked-by-human",
|
|
591
|
+
who: "taku",
|
|
592
|
+
question: "a NEW question",
|
|
593
|
+
});
|
|
594
|
+
await expect(s.clearBlockIfMatches(t._id, (afterReplace.blockRev ?? 0) - 1)).rejects.toThrow(
|
|
595
|
+
/block changed since this was decided/,
|
|
596
|
+
);
|
|
597
|
+
// refused, not erased: the newer block survives untouched
|
|
598
|
+
expect(s.get(t._id)?.block).toEqual({
|
|
599
|
+
type: "blocked-by-human",
|
|
600
|
+
who: "taku",
|
|
601
|
+
question: "a NEW question",
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("clearBlockIfMatches() catches an ABA race — a block replaced with BYTE-FOR-BYTE IDENTICAL content still gets a fresh blockRev, so a stale caller is refused rather than erasing that distinct, newer block instance (codex-review round-17 Important)", async () => {
|
|
606
|
+
const s = await openStore(TEST_ROOT);
|
|
607
|
+
const t = await s.create({ summary: "x", kind: "code" });
|
|
608
|
+
const identicalBlock = {
|
|
609
|
+
type: "blocked-by-human",
|
|
610
|
+
who: "taku",
|
|
611
|
+
question: "canary or beta?",
|
|
612
|
+
} as const;
|
|
613
|
+
const firstSet = await s.setBlock(t._id, identicalBlock);
|
|
614
|
+
const capturedRev = firstSet.blockRev ?? 0;
|
|
615
|
+
// Someone re-sets the EXACT same block content — a content-only
|
|
616
|
+
// comparison would see no difference at all.
|
|
617
|
+
await s.setBlock(t._id, identicalBlock);
|
|
618
|
+
await expect(s.clearBlockIfMatches(t._id, capturedRev)).rejects.toThrow(
|
|
619
|
+
/block changed since this was decided/,
|
|
620
|
+
);
|
|
621
|
+
// refused, not erased: the (identical-looking but distinct, newer)
|
|
622
|
+
// block instance survives
|
|
623
|
+
expect(s.get(t._id)?.block).toEqual(identicalBlock);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("answerHumanBlock() applies a gate's evidence, its transition, AND the block clear in ONE atomic write — never a partial application (codex-review round-18 Important, replacing the old approve()+transition()+clearBlockIfMatches() composition)", async () => {
|
|
627
|
+
const s = await openStore(TEST_ROOT);
|
|
628
|
+
const t = await s.create({ summary: "pick a channel", kind: "decision" });
|
|
629
|
+
const afterSet = await s.setBlock(t._id, {
|
|
630
|
+
type: "blocked-by-human",
|
|
631
|
+
who: "taku",
|
|
632
|
+
options: ["canary", "beta"],
|
|
633
|
+
});
|
|
634
|
+
const answered = await s.answerHumanBlock(t._id, afterSet.blockRev ?? 0, {
|
|
635
|
+
name: "human-decided",
|
|
636
|
+
toState: "decided",
|
|
637
|
+
validator: "taku",
|
|
638
|
+
note: "canary",
|
|
639
|
+
});
|
|
640
|
+
expect(answered.block).toBeNull();
|
|
641
|
+
expect(answered.state).toBe("decided");
|
|
642
|
+
expect(answered.verifyEvidence).toEqual([
|
|
643
|
+
expect.objectContaining({ gate: "human-decided", validator: "taku", note: "canary" }),
|
|
644
|
+
]);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("answerHumanBlock() refuses (without applying anything) a stale expectedBlockRev, and separately refuses independent-verification violations (validator === owner) — matching approve()'s own unconditional rule (codex-review round-18 Important)", async () => {
|
|
648
|
+
const s = await openStore(TEST_ROOT);
|
|
649
|
+
const t = await s.create({ summary: "x", kind: "decision" });
|
|
650
|
+
const afterSet = await s.setBlock(t._id, { type: "blocked-by-human", who: "taku" });
|
|
651
|
+
await s.setBlock(t._id, { type: "blocked-by-human", who: "taku" }); // bumps blockRev again
|
|
652
|
+
await expect(
|
|
653
|
+
s.answerHumanBlock(t._id, afterSet.blockRev ?? 0, {
|
|
654
|
+
name: "human-decided",
|
|
655
|
+
toState: "decided",
|
|
656
|
+
validator: "taku",
|
|
657
|
+
note: "acknowledged",
|
|
658
|
+
}),
|
|
659
|
+
).rejects.toThrow(/this ask has changed since it was loaded/);
|
|
660
|
+
expect(s.get(t._id)?.state).toBe("deciding"); // unchanged — refused before any write
|
|
661
|
+
|
|
662
|
+
const owned = await s.create({ summary: "y", kind: "decision", owner: "taku" });
|
|
663
|
+
const ownedBlock = await s.setBlock(owned._id, { type: "blocked-by-human", who: "taku" });
|
|
664
|
+
await expect(
|
|
665
|
+
s.answerHumanBlock(owned._id, ownedBlock.blockRev ?? 0, {
|
|
666
|
+
name: "human-decided",
|
|
667
|
+
toState: "decided",
|
|
668
|
+
validator: "taku", // same identity as owner
|
|
669
|
+
note: "acknowledged",
|
|
670
|
+
}),
|
|
671
|
+
).rejects.toThrow(/independent verification required/);
|
|
672
|
+
expect(s.get(owned._id)?.state).toBe("deciding"); // unchanged
|
|
673
|
+
expect(s.get(owned._id)?.block).not.toBeNull(); // NOT cleared either — refused, not partially applied
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("answerHumanBlock() re-validates canTransition against the FRESH state, not a value captured before the call — a state change UNRELATED to block (so invisible to the blockRev check alone) must still be caught, matching transition()'s own concurrent-state-drift defense (codex-review round-18 Important)", async () => {
|
|
677
|
+
const s = await openStore(TEST_ROOT);
|
|
678
|
+
const t = await s.create({ summary: "x", kind: "decision" });
|
|
679
|
+
const afterSet = await s.setBlock(t._id, { type: "blocked-by-human", who: "taku" });
|
|
680
|
+
// Simulate a concurrent writer moving the task's STATE (not its block)
|
|
681
|
+
// to "done" — the same append-only-merge-line technique the
|
|
682
|
+
// markOrphaned() test above uses. blockRev is untouched, so the
|
|
683
|
+
// blockRev check alone would see nothing wrong; only re-validating
|
|
684
|
+
// canTransition against the fresh state catches this.
|
|
685
|
+
await appendFile(
|
|
686
|
+
path.join(TEST_ROOT, ".agent-yes", "todos.jsonl"),
|
|
687
|
+
JSON.stringify({ _id: t._id, state: "done" }) + "\n",
|
|
688
|
+
);
|
|
689
|
+
await expect(
|
|
690
|
+
s.answerHumanBlock(t._id, afterSet.blockRev ?? 0, {
|
|
691
|
+
name: "human-decided",
|
|
692
|
+
toState: "decided",
|
|
693
|
+
validator: "taku",
|
|
694
|
+
note: "acknowledged",
|
|
695
|
+
}),
|
|
696
|
+
).rejects.toThrow(/no transition done -> decided.*state changed concurrently/);
|
|
697
|
+
// refused, not partially applied: block survives untouched, state
|
|
698
|
+
// stays exactly what the concurrent writer set it to
|
|
699
|
+
expect(s.get(t._id)?.block).not.toBeNull();
|
|
700
|
+
expect(s.get(t._id)?.state).toBe("done");
|
|
701
|
+
});
|
|
576
702
|
});
|