@sweny-ai/core 0.1.189 → 0.1.191
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/cli/run-answers.d.ts +5 -0
- package/dist/cli/run-answers.js +72 -17
- package/dist/cli/run-history.js +7 -1
- package/dist/cli/runs.js +12 -10
- package/dist/harness/tool-bridge/protocol.d.ts +6 -2
- package/dist/harness/tool-bridge/protocol.js +9 -1
- package/dist/journal.d.ts +23 -0
- package/dist/journal.js +114 -2
- package/dist/safe-outputs.d.ts +1 -1
- package/dist/safe-outputs.js +17 -2
- package/dist/skills/git-push.d.ts +6 -0
- package/dist/skills/git-push.js +1 -1
- package/dist/skills/github.d.ts +2 -1
- package/dist/skills/github.js +105 -6
- package/dist/types.d.ts +31 -0
- package/dist/types.js +7 -0
- package/package.json +1 -1
|
@@ -41,6 +41,11 @@ export interface BuildRunAnswersInput {
|
|
|
41
41
|
results: Map<string, NodeResult>;
|
|
42
42
|
/** Secret values to redact (the same set the journal and output.md use). */
|
|
43
43
|
secrets?: string[];
|
|
44
|
+
/**
|
|
45
|
+
* The key for redacted key names, asked for only when a key needs one. Default: a random key that
|
|
46
|
+
* lives only in this process (names then differ from run to run, which a diff shows as a change).
|
|
47
|
+
*/
|
|
48
|
+
macKey?: () => Buffer;
|
|
44
49
|
}
|
|
45
50
|
/**
|
|
46
51
|
* Declared fields of each successful node, deep, redacted (values and keys). A failed or skipped
|
package/dist/cli/run-answers.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import crypto from "node:crypto";
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import path from "node:path";
|
|
24
|
-
import { redact } from "../journal.js";
|
|
24
|
+
import { redact, workspaceSecretKey } from "../journal.js";
|
|
25
25
|
import { writeFileNoFollow } from "../safe-file.js";
|
|
26
26
|
import { RUN_HISTORY_DIR, RUN_ID_RE, listRuns } from "./run-history.js";
|
|
27
27
|
export const RUN_ANSWERS_SCHEMA_VERSION = 1;
|
|
@@ -32,13 +32,56 @@ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
|
32
32
|
// ── Build, write, read ──────────────────────────────────────────
|
|
33
33
|
const has = (o, k) => Object.hasOwn(o, k);
|
|
34
34
|
const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
35
|
+
/** The JSON Schema types a schema declares: `type`, else implied by `properties` / `items`. */
|
|
36
|
+
function declaredTypes(sch) {
|
|
37
|
+
if (typeof sch.type === "string")
|
|
38
|
+
return [sch.type];
|
|
39
|
+
if (Array.isArray(sch.type))
|
|
40
|
+
return sch.type.filter((t) => typeof t === "string");
|
|
41
|
+
if (isObj(sch.properties))
|
|
42
|
+
return ["object"];
|
|
43
|
+
if (isObj(sch.items))
|
|
44
|
+
return ["array"];
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
function typeMatches(value, types) {
|
|
48
|
+
return types.some((t) => {
|
|
49
|
+
switch (t) {
|
|
50
|
+
case "string":
|
|
51
|
+
return typeof value === "string";
|
|
52
|
+
case "number":
|
|
53
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
54
|
+
case "integer":
|
|
55
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
56
|
+
case "boolean":
|
|
57
|
+
return typeof value === "boolean";
|
|
58
|
+
case "null":
|
|
59
|
+
return value === null;
|
|
60
|
+
case "object":
|
|
61
|
+
return isObj(value);
|
|
62
|
+
case "array":
|
|
63
|
+
return Array.isArray(value);
|
|
64
|
+
default:
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
35
69
|
/**
|
|
36
|
-
* Keep only what the schema declares, deep: `findings[].title`
|
|
37
|
-
*
|
|
38
|
-
*
|
|
70
|
+
* Keep only what the schema declares, deep, and only values whose shape matches it: `findings[].title`
|
|
71
|
+
* keeps `title` and nothing else, a string where an object is declared is dropped, as is a value of
|
|
72
|
+
* the wrong `type` or outside an `enum`. An object with no declared properties is dropped (nothing in
|
|
73
|
+
* it was declared). Undefined means "drop".
|
|
39
74
|
*/
|
|
40
75
|
function pick(value, schema) {
|
|
41
76
|
const sch = isObj(schema) ? schema : undefined;
|
|
77
|
+
if (sch) {
|
|
78
|
+
const types = declaredTypes(sch);
|
|
79
|
+
if (types && !typeMatches(value, types))
|
|
80
|
+
return undefined;
|
|
81
|
+
if (Array.isArray(sch.enum) && !isObj(value) && !Array.isArray(value) && !sch.enum.includes(value)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
42
85
|
if (Array.isArray(value)) {
|
|
43
86
|
const items = sch && isObj(sch.items) ? sch.items : undefined;
|
|
44
87
|
return value.map((v) => pick(v, items)).filter((v) => v !== undefined);
|
|
@@ -54,20 +97,24 @@ function pick(value, schema) {
|
|
|
54
97
|
}
|
|
55
98
|
return value;
|
|
56
99
|
}
|
|
57
|
-
/**
|
|
58
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Secrets in keys too (`redact` scrubs values and keeps keys). A key that redaction changes, or that
|
|
102
|
+
* already looks like a redacted name, becomes `[redacted-<first 16 hex of HMAC-SHA256(key)>]`, keyed
|
|
103
|
+
* with `macKey()`: injective (no two keys share a name, and no plain key can equal a generated one),
|
|
104
|
+
* stable (the same key gets the same name in every run, whatever else is present, so a diff lines
|
|
105
|
+
* them up) and not a guessing oracle for a low-entropy secret (a plain hash would be).
|
|
106
|
+
*/
|
|
107
|
+
function redactKeys(value, secrets, macKey) {
|
|
59
108
|
if (Array.isArray(value))
|
|
60
|
-
return value.map((v) => redactKeys(v, secrets));
|
|
109
|
+
return value.map((v) => redactKeys(v, secrets, macKey));
|
|
61
110
|
if (!isObj(value))
|
|
62
111
|
return value;
|
|
63
|
-
const seen = new Map();
|
|
64
112
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => {
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return [name, redactKeys(v, secrets)];
|
|
113
|
+
const scrubbed = redact(k, secrets).value;
|
|
114
|
+
const name = scrubbed !== k || k.startsWith("[redacted")
|
|
115
|
+
? `[redacted-${crypto.createHmac("sha256", macKey()).update(k).digest("hex").slice(0, 16)}]`
|
|
116
|
+
: k;
|
|
117
|
+
return [name, redactKeys(v, secrets, macKey)];
|
|
71
118
|
}));
|
|
72
119
|
}
|
|
73
120
|
/** [display value, digest tree]: strings over the cap are cut, and their full-text sha256 goes in the tree. */
|
|
@@ -97,6 +144,8 @@ function clipTree(value) {
|
|
|
97
144
|
*/
|
|
98
145
|
export function buildRunAnswers(i) {
|
|
99
146
|
const secrets = i.secrets ?? [];
|
|
147
|
+
let ephemeral;
|
|
148
|
+
const macKey = i.macKey ?? (() => (ephemeral ??= crypto.randomBytes(32)));
|
|
100
149
|
const nodes = [];
|
|
101
150
|
const digests = [];
|
|
102
151
|
for (const [id, r] of i.results) {
|
|
@@ -109,12 +158,15 @@ export function buildRunAnswers(i) {
|
|
|
109
158
|
if (isObj(props) && Object.keys(props).length > 0) {
|
|
110
159
|
fields = pick(data, schema);
|
|
111
160
|
}
|
|
112
|
-
else if (typeof data.summary === "string" &&
|
|
161
|
+
else if (typeof data.summary === "string" &&
|
|
162
|
+
data.summary.trim() !== "" &&
|
|
163
|
+
!(isObj(schema) && schema.additionalProperties === false)) {
|
|
164
|
+
// The free-form summary, unless the node's schema closes the object to its declared fields.
|
|
113
165
|
fields = { summary: data.summary };
|
|
114
166
|
}
|
|
115
167
|
if (!isObj(fields) || Object.keys(fields).length === 0)
|
|
116
168
|
continue;
|
|
117
|
-
const clean = redactKeys(redact(fields, secrets).value, secrets);
|
|
169
|
+
const clean = redactKeys(redact(fields, secrets).value, secrets, macKey);
|
|
118
170
|
const [shown, digs] = clipTree(clean);
|
|
119
171
|
nodes.push([id, shown]);
|
|
120
172
|
if (digs !== undefined)
|
|
@@ -414,7 +466,10 @@ export function sinceLastRun(runId, workflowId, cwd = process.cwd()) {
|
|
|
414
466
|
/** Save this run's answers, then compare with the previous run. Never throws. */
|
|
415
467
|
export function recordRunAnswers(i, cwd = process.cwd()) {
|
|
416
468
|
try {
|
|
417
|
-
|
|
469
|
+
// The key is per user and workspace and lives in the state dir; none usable means a process-only key.
|
|
470
|
+
let key;
|
|
471
|
+
const macKey = i.macKey ?? (() => (key ??= workspaceSecretKey(cwd, "answers") ?? crypto.randomBytes(32)));
|
|
472
|
+
if (!writeRunAnswers(buildRunAnswers({ ...i, macKey }), cwd))
|
|
418
473
|
return null;
|
|
419
474
|
return sinceLastRun(i.runId, i.workflow.id, cwd);
|
|
420
475
|
}
|
package/dist/cli/run-history.js
CHANGED
|
@@ -19,6 +19,7 @@ import { openNoFollow } from "../safe-file.js";
|
|
|
19
19
|
export const RUN_HISTORY_SCHEMA_VERSION = 1;
|
|
20
20
|
export const RUN_HISTORY_DIR = path.join(".sweny", "runs");
|
|
21
21
|
export const RUN_HISTORY_KEEP = 200;
|
|
22
|
+
const RECORD_READ_MAX_BYTES = 1024 * 1024;
|
|
22
23
|
// ── Hash + ids ──────────────────────────────────────────────────
|
|
23
24
|
function canonicalize(value) {
|
|
24
25
|
if (Array.isArray(value))
|
|
@@ -215,7 +216,12 @@ export function listRuns(cwd = process.cwd()) {
|
|
|
215
216
|
const out = [];
|
|
216
217
|
for (const f of files) {
|
|
217
218
|
try {
|
|
218
|
-
|
|
219
|
+
// A link is not history: the target may be anywhere, and an agent can plant it.
|
|
220
|
+
const file = path.join(dir, f);
|
|
221
|
+
const st = fs.lstatSync(file);
|
|
222
|
+
if (!st.isFile() || st.size > RECORD_READ_MAX_BYTES)
|
|
223
|
+
continue;
|
|
224
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
219
225
|
if (isRecord(parsed) && RUN_ID_RE.test(parsed.run_id) && f === `${parsed.run_id}.json`)
|
|
220
226
|
out.push(parsed);
|
|
221
227
|
}
|
package/dist/cli/runs.js
CHANGED
|
@@ -222,12 +222,14 @@ function formatAnswersSection(cmp, color, g) {
|
|
|
222
222
|
out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_ITEMS_SHOWN} more ${word}`));
|
|
223
223
|
}
|
|
224
224
|
};
|
|
225
|
+
// Node, field and nested field names are keys of agent-writable files: terminal text, not trusted.
|
|
226
|
+
const key = (s) => sanitizeTerminal(s, 64);
|
|
225
227
|
const fields = (list) => {
|
|
226
228
|
if (list.length === 0)
|
|
227
229
|
return;
|
|
228
|
-
const w = Math.max(...list.map((f) => f.field.length));
|
|
230
|
+
const w = Math.max(...list.map((f) => key(f.field).length));
|
|
229
231
|
for (const f of list.slice(0, MAX_FIELDS_SHOWN)) {
|
|
230
|
-
out.push(` ${pad(f.field, w)} ${valueChange(f.before, f.after, g)}`);
|
|
232
|
+
out.push(` ${pad(key(f.field), w)} ${valueChange(f.before, f.after, g)}`);
|
|
231
233
|
}
|
|
232
234
|
if (list.length > MAX_FIELDS_SHOWN)
|
|
233
235
|
out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_FIELDS_SHOWN} more fields`));
|
|
@@ -236,14 +238,14 @@ function formatAnswersSection(cmp, color, g) {
|
|
|
236
238
|
for (const node of nodes) {
|
|
237
239
|
const changed = cmp.entries.filter((e) => e.node === node && isChangedEntry(e));
|
|
238
240
|
if (changed.length === 0) {
|
|
239
|
-
out.push(` ${node} ${p.muted("unchanged")}`);
|
|
241
|
+
out.push(` ${key(node)} ${p.muted("unchanged")}`);
|
|
240
242
|
continue;
|
|
241
243
|
}
|
|
242
|
-
out.push(` ${node}`);
|
|
243
|
-
const w = Math.max(...changed.map((e) => e.field.length));
|
|
244
|
+
out.push(` ${key(node)}`);
|
|
245
|
+
const w = Math.max(...changed.map((e) => key(e.field).length));
|
|
244
246
|
for (const e of changed) {
|
|
245
247
|
if (e.kind === "collection") {
|
|
246
|
-
out.push(` ${pad(e.field, w)} ${p.muted(countsHead(e, g))}`);
|
|
248
|
+
out.push(` ${pad(key(e.field), w)} ${p.muted(countsHead(e, g))}`);
|
|
247
249
|
items("+", p.warning, e.added, "added");
|
|
248
250
|
items("-", p.success, e.resolved, "resolved");
|
|
249
251
|
for (const c of e.changed.slice(0, MAX_ITEMS_SHOWN)) {
|
|
@@ -255,18 +257,18 @@ function formatAnswersSection(cmp, color, g) {
|
|
|
255
257
|
}
|
|
256
258
|
}
|
|
257
259
|
else if (e.status === "added") {
|
|
258
|
-
out.push(` ${pad(e.field, w)} ${p.warning("added")} ${showValue(e.after, g)}`);
|
|
260
|
+
out.push(` ${pad(key(e.field), w)} ${p.warning("added")} ${showValue(e.after, g)}`);
|
|
259
261
|
}
|
|
260
262
|
else if (e.status === "resolved") {
|
|
261
|
-
out.push(` ${pad(e.field, w)} ${p.success("resolved")} ${p.muted(`(was ${showValue(e.before, g)})`)}`);
|
|
263
|
+
out.push(` ${pad(key(e.field), w)} ${p.success("resolved")} ${p.muted(`(was ${showValue(e.before, g)})`)}`);
|
|
262
264
|
}
|
|
263
265
|
else {
|
|
264
|
-
out.push(` ${pad(e.field, w)} ${valueChange(e.before, e.after, g)}`);
|
|
266
|
+
out.push(` ${pad(key(e.field), w)} ${valueChange(e.before, e.after, g)}`);
|
|
265
267
|
}
|
|
266
268
|
}
|
|
267
269
|
}
|
|
268
270
|
for (const n of cmp.not_compared) {
|
|
269
|
-
out.push(` ${n.node} ${p.muted(`not compared (answer in ${n.only_in} only)`)}`);
|
|
271
|
+
out.push(` ${key(n.node)} ${p.muted(`not compared (answer in ${n.only_in} only)`)}`);
|
|
270
272
|
}
|
|
271
273
|
if (nodes.length === 0 && cmp.not_compared.length === 0)
|
|
272
274
|
out.push(p.muted(" no declared outputs to compare"));
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* same `isError` status.
|
|
17
17
|
*/
|
|
18
18
|
import { z } from "zod";
|
|
19
|
-
import type
|
|
19
|
+
import { type JSONSchema, type Tool, type ToolContext } from "../../types.js";
|
|
20
20
|
export declare const BRIDGE_PROTOCOL_VERSION = 1;
|
|
21
21
|
/** Frames longer than this are refused and the connection is dropped. */
|
|
22
22
|
export declare const MAX_FRAME_BYTES: number;
|
|
@@ -73,7 +73,11 @@ export declare function createFrameReader(onFrame: (value: unknown) => void, onO
|
|
|
73
73
|
export declare function tokenMatches(expected: string, given: unknown): boolean;
|
|
74
74
|
/** Structural check of an incoming request frame (token checked separately). */
|
|
75
75
|
export declare function parseRequest(value: unknown): BridgeRequest | undefined;
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* A handler's return value as MCP content. Strings pass through; anything else
|
|
78
|
+
* is JSON. A {@link ToolRefusal} is a tool error (#544): the agent and the
|
|
79
|
+
* tool-call status (and so evals) see that the write did not happen.
|
|
80
|
+
*/
|
|
77
81
|
export declare function toolOutputToMcpResult(output: unknown): McpCallToolResult;
|
|
78
82
|
/** A handler's thrown error as an MCP error result. */
|
|
79
83
|
export declare function toolErrorToMcpResult(err: unknown): McpCallToolResult;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { timingSafeEqual } from "node:crypto";
|
|
19
19
|
import { z } from "zod";
|
|
20
|
+
import { isToolRefusal } from "../../types.js";
|
|
20
21
|
export const BRIDGE_PROTOCOL_VERSION = 1;
|
|
21
22
|
/** Frames longer than this are refused and the connection is dropped. */
|
|
22
23
|
export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
@@ -86,8 +87,15 @@ export function parseRequest(value) {
|
|
|
86
87
|
return value;
|
|
87
88
|
}
|
|
88
89
|
// ─── Tool result shape (shared with the in-process Claude path) ─────
|
|
89
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* A handler's return value as MCP content. Strings pass through; anything else
|
|
92
|
+
* is JSON. A {@link ToolRefusal} is a tool error (#544): the agent and the
|
|
93
|
+
* tool-call status (and so evals) see that the write did not happen.
|
|
94
|
+
*/
|
|
90
95
|
export function toolOutputToMcpResult(output) {
|
|
96
|
+
if (isToolRefusal(output)) {
|
|
97
|
+
return { content: [{ type: "text", text: `Refused: ${output.reason}\n${JSON.stringify(output)}` }], isError: true };
|
|
98
|
+
}
|
|
91
99
|
return {
|
|
92
100
|
content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
|
|
93
101
|
};
|
package/dist/journal.d.ts
CHANGED
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
* write intents, written before the write stage runs
|
|
48
48
|
* output:intent a write is about to be applied (idempotency key + tool)
|
|
49
49
|
* output:applied the write returned (key + the ids it produced)
|
|
50
|
+
* output:refused the write's handler returned a refusal (#544): key, its
|
|
51
|
+
* fixed-wording reason and any ids it names; never applied
|
|
50
52
|
* node:end final result of the visit and the write-stage counters
|
|
51
53
|
* route the edge taken after the visit (null = the run ended), and the rung that chose it
|
|
52
54
|
* usage spend of one agent attempt (cumulative; one per model turn that raised it, written
|
|
@@ -84,6 +86,15 @@
|
|
|
84
86
|
* key with a receipt is not re-applied, and a key with an intent but no receipt
|
|
85
87
|
* (the process died between the API call and the receipt) is looked up on the
|
|
86
88
|
* provider by a marker embedded in the body before anything is re-sent.
|
|
89
|
+
*
|
|
90
|
+
* A handler that returns a refusal (`ToolRefusal`) gets `output:refused`
|
|
91
|
+
* instead of `output:applied`. On resume that refusal is replayed as is: the
|
|
92
|
+
* handler is not called and the provider is not searched (a marked write the
|
|
93
|
+
* refusal named would otherwise be taken for an applied one). The one
|
|
94
|
+
* exception is a refusal marked `transient` that names nothing it produced:
|
|
95
|
+
* nothing was written, so it goes through the unconfirmed-intent path above
|
|
96
|
+
* (provider lookup, then a first send). `--allow-repeat-writes` does not
|
|
97
|
+
* override a refusal: its outcome is known, not unconfirmed.
|
|
87
98
|
*/
|
|
88
99
|
import type { Logger, NodeResult, NodeUsage, RouteRung, Skill, Workflow } from "./types.js";
|
|
89
100
|
import type { SafeOutputIntent, WriteStageState } from "./safe-outputs.js";
|
|
@@ -143,6 +154,14 @@ export declare class JournalLocationError extends Error {
|
|
|
143
154
|
* it points. Throws {@link JournalLocationError}.
|
|
144
155
|
*/
|
|
145
156
|
export declare function assertStateOutsideWorkspace(dir: string, cwd: string): void;
|
|
157
|
+
/**
|
|
158
|
+
* A 32-byte random key private to this user and workspace, for MACs that must stay the same from run to
|
|
159
|
+
* run (a run's journal key is per run, so it cannot serve). Stored hex, mode 0600, at
|
|
160
|
+
* `<state dir>/keys/<workspace-hash>.<purpose>.key`, read and created without following a link. Null
|
|
161
|
+
* when the state dir cannot be trusted (inside the workspace, where an agent can read it) or used;
|
|
162
|
+
* callers then fall back to a key that lives only in the process. Never written anywhere an agent reads.
|
|
163
|
+
*/
|
|
164
|
+
export declare function workspaceSecretKey(cwd: string, purpose: string, env?: Record<string, string | undefined>): Buffer | null;
|
|
146
165
|
/** Thrown when a journal's key is missing or unreadable: its records cannot be checked. */
|
|
147
166
|
export declare class JournalKeyError extends Error {
|
|
148
167
|
constructor(file: string, why: string);
|
|
@@ -335,6 +354,8 @@ export interface PlannedVisit {
|
|
|
335
354
|
rung?: RouteRung;
|
|
336
355
|
/** Writes the checkpointed write stage already applied (receipts in the journal). */
|
|
337
356
|
applied: number;
|
|
357
|
+
/** Writes whose handler refused (#544): the refusal is replayed, not re-sent. */
|
|
358
|
+
refused: number;
|
|
338
359
|
/** Writes with an intent but no receipt: checked on the provider before anything is re-sent. */
|
|
339
360
|
unconfirmed: number;
|
|
340
361
|
}
|
|
@@ -356,6 +377,8 @@ export interface ResumePlan {
|
|
|
356
377
|
ends: Map<string, EndRecord>;
|
|
357
378
|
intents: Map<string, OutputRecord>;
|
|
358
379
|
receipts: Map<string, OutputRecord>;
|
|
380
|
+
/** Writes whose handler refused (`output:refused`, #544): replayed on resume, never taken for applied. */
|
|
381
|
+
refusals: Map<string, OutputRecord>;
|
|
359
382
|
/** Spend every earlier attempt journaled: the run budget starts here on resume. */
|
|
360
383
|
priorSpend: Spend;
|
|
361
384
|
/**
|
package/dist/journal.js
CHANGED
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
* write intents, written before the write stage runs
|
|
48
48
|
* output:intent a write is about to be applied (idempotency key + tool)
|
|
49
49
|
* output:applied the write returned (key + the ids it produced)
|
|
50
|
+
* output:refused the write's handler returned a refusal (#544): key, its
|
|
51
|
+
* fixed-wording reason and any ids it names; never applied
|
|
50
52
|
* node:end final result of the visit and the write-stage counters
|
|
51
53
|
* route the edge taken after the visit (null = the run ended), and the rung that chose it
|
|
52
54
|
* usage spend of one agent attempt (cumulative; one per model turn that raised it, written
|
|
@@ -84,11 +86,21 @@
|
|
|
84
86
|
* key with a receipt is not re-applied, and a key with an intent but no receipt
|
|
85
87
|
* (the process died between the API call and the receipt) is looked up on the
|
|
86
88
|
* provider by a marker embedded in the body before anything is re-sent.
|
|
89
|
+
*
|
|
90
|
+
* A handler that returns a refusal (`ToolRefusal`) gets `output:refused`
|
|
91
|
+
* instead of `output:applied`. On resume that refusal is replayed as is: the
|
|
92
|
+
* handler is not called and the provider is not searched (a marked write the
|
|
93
|
+
* refusal named would otherwise be taken for an applied one). The one
|
|
94
|
+
* exception is a refusal marked `transient` that names nothing it produced:
|
|
95
|
+
* nothing was written, so it goes through the unconfirmed-intent path above
|
|
96
|
+
* (provider lookup, then a first send). `--allow-repeat-writes` does not
|
|
97
|
+
* override a refusal: its outcome is known, not unconfirmed.
|
|
87
98
|
*/
|
|
88
99
|
import crypto from "node:crypto";
|
|
89
100
|
import fs from "node:fs";
|
|
90
101
|
import os from "node:os";
|
|
91
102
|
import path from "node:path";
|
|
103
|
+
import { isToolRefusal } from "./types.js";
|
|
92
104
|
import { resolveNodePermissions } from "./node-policy.js";
|
|
93
105
|
import { CURRENT_SPEC_VERSION } from "./migrations.js";
|
|
94
106
|
import { spendOf } from "./budget.js";
|
|
@@ -301,6 +313,64 @@ export function assertStateOutsideWorkspace(dir, cwd) {
|
|
|
301
313
|
throw new JournalLocationError(real, ws);
|
|
302
314
|
}
|
|
303
315
|
}
|
|
316
|
+
/** True when `dir` (which may not exist yet) would resolve inside `cwd`: its nearest existing ancestor's real path decides. */
|
|
317
|
+
function resolvesInside(dir, cwd) {
|
|
318
|
+
const ws = realOr(cwd);
|
|
319
|
+
let existing = path.resolve(dir);
|
|
320
|
+
const rest = [];
|
|
321
|
+
while (!fs.existsSync(existing) && path.dirname(existing) !== existing) {
|
|
322
|
+
rest.unshift(path.basename(existing));
|
|
323
|
+
existing = path.dirname(existing);
|
|
324
|
+
}
|
|
325
|
+
const real = path.join(realOr(existing), ...rest);
|
|
326
|
+
return real === ws || real.startsWith(ws.endsWith(path.sep) ? ws : ws + path.sep);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* A 32-byte random key private to this user and workspace, for MACs that must stay the same from run to
|
|
330
|
+
* run (a run's journal key is per run, so it cannot serve). Stored hex, mode 0600, at
|
|
331
|
+
* `<state dir>/keys/<workspace-hash>.<purpose>.key`, read and created without following a link. Null
|
|
332
|
+
* when the state dir cannot be trusted (inside the workspace, where an agent can read it) or used;
|
|
333
|
+
* callers then fall back to a key that lives only in the process. Never written anywhere an agent reads.
|
|
334
|
+
*/
|
|
335
|
+
export function workspaceSecretKey(cwd, purpose, env = process.env) {
|
|
336
|
+
try {
|
|
337
|
+
const dir = path.join(swenyStateDir(env), "keys");
|
|
338
|
+
if (resolvesInside(dir, cwd))
|
|
339
|
+
return null;
|
|
340
|
+
makePrivateDir(dir);
|
|
341
|
+
if (resolvesInside(dir, cwd))
|
|
342
|
+
return null;
|
|
343
|
+
const file = path.join(dir, `${workspaceScope(cwd)}.${purpose.replace(/[^A-Za-z0-9_-]/g, "_")}.key`);
|
|
344
|
+
const read = () => {
|
|
345
|
+
const text = readNoFollow(file).toString("utf8").trim();
|
|
346
|
+
if (!/^[0-9a-f]{64}$/.test(text))
|
|
347
|
+
return null;
|
|
348
|
+
if (process.platform !== "win32" && (fs.lstatSync(file).mode & 0o077) !== 0)
|
|
349
|
+
return null;
|
|
350
|
+
return Buffer.from(text, "hex");
|
|
351
|
+
};
|
|
352
|
+
try {
|
|
353
|
+
return read();
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
if (err.code !== "ENOENT")
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const key = crypto.randomBytes(32);
|
|
360
|
+
try {
|
|
361
|
+
writeNew(file, key.toString("hex") + "\n");
|
|
362
|
+
return key;
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
if (err.code !== "EEXIST")
|
|
366
|
+
return null;
|
|
367
|
+
return read(); // another run created it first
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
304
374
|
/** Thrown when a journal's key is missing or unreadable: its records cannot be checked. */
|
|
305
375
|
export class JournalKeyError extends Error {
|
|
306
376
|
constructor(file, why) {
|
|
@@ -840,6 +910,7 @@ const RECORD_TYPES = new Set([
|
|
|
840
910
|
"node:checkpoint",
|
|
841
911
|
"output:intent",
|
|
842
912
|
"output:applied",
|
|
913
|
+
"output:refused",
|
|
843
914
|
"node:end",
|
|
844
915
|
"route",
|
|
845
916
|
"usage",
|
|
@@ -937,6 +1008,7 @@ export function checkRecordSequence(records) {
|
|
|
937
1008
|
}
|
|
938
1009
|
case "output:intent":
|
|
939
1010
|
case "output:applied":
|
|
1011
|
+
case "output:refused":
|
|
940
1012
|
inVisit(r);
|
|
941
1013
|
if (!checkpointed.has(visitKey(r.node, r.iteration))) {
|
|
942
1014
|
bad(r, "records a write before the visit's checkpoint");
|
|
@@ -1057,6 +1129,7 @@ export function buildResumePlan(records) {
|
|
|
1057
1129
|
const byKey = new Map();
|
|
1058
1130
|
const intents = new Map();
|
|
1059
1131
|
const receipts = new Map();
|
|
1132
|
+
const refusals = new Map();
|
|
1060
1133
|
let attempts = 1;
|
|
1061
1134
|
let lastStatus;
|
|
1062
1135
|
let deciderCounters;
|
|
@@ -1113,6 +1186,9 @@ export function buildResumePlan(records) {
|
|
|
1113
1186
|
case "output:applied":
|
|
1114
1187
|
receipts.set(r.key, r);
|
|
1115
1188
|
break;
|
|
1189
|
+
case "output:refused":
|
|
1190
|
+
refusals.set(r.key, r);
|
|
1191
|
+
break;
|
|
1116
1192
|
case "run:end":
|
|
1117
1193
|
lastStatus = r.status;
|
|
1118
1194
|
break;
|
|
@@ -1129,7 +1205,8 @@ export function buildResumePlan(records) {
|
|
|
1129
1205
|
const complete = !!v.end && !(isLast && v.end.result.status === "failed" && typeof v.next !== "string");
|
|
1130
1206
|
const keys = (rec) => [...rec.values()].filter((o) => o.node === v.node && o.iteration === v.iteration).map((o) => o.key);
|
|
1131
1207
|
const applied = keys(receipts).length;
|
|
1132
|
-
const
|
|
1208
|
+
const refused = keys(refusals).filter((k) => !receipts.has(k)).length;
|
|
1209
|
+
const unconfirmed = keys(intents).filter((k) => !receipts.has(k) && !refusals.has(k)).length;
|
|
1133
1210
|
if (complete && !resumed) {
|
|
1134
1211
|
visits.push({
|
|
1135
1212
|
node: v.node,
|
|
@@ -1139,6 +1216,7 @@ export function buildResumePlan(records) {
|
|
|
1139
1216
|
next: v.next,
|
|
1140
1217
|
...(v.rung ? { rung: v.rung } : {}),
|
|
1141
1218
|
applied,
|
|
1219
|
+
refused,
|
|
1142
1220
|
unconfirmed,
|
|
1143
1221
|
});
|
|
1144
1222
|
writeState = v.end.write_state;
|
|
@@ -1150,6 +1228,7 @@ export function buildResumePlan(records) {
|
|
|
1150
1228
|
iteration: v.iteration,
|
|
1151
1229
|
action: v.checkpoint ? "write-stage" : "rerun",
|
|
1152
1230
|
applied,
|
|
1231
|
+
refused,
|
|
1153
1232
|
unconfirmed,
|
|
1154
1233
|
});
|
|
1155
1234
|
break;
|
|
@@ -1180,6 +1259,7 @@ export function buildResumePlan(records) {
|
|
|
1180
1259
|
ends,
|
|
1181
1260
|
intents,
|
|
1182
1261
|
receipts,
|
|
1262
|
+
refusals,
|
|
1183
1263
|
priorSpend: journaledSpend(records),
|
|
1184
1264
|
unsandboxed: [...new Set(records.filter((r) => r.type === "agent:unsandboxed").map((r) => String(r.node)))],
|
|
1185
1265
|
...(start.decider
|
|
@@ -1336,6 +1416,14 @@ function slimOutput(o, depth = 0) {
|
|
|
1336
1416
|
if (!o || typeof o !== "object" || Array.isArray(o) || depth > 3)
|
|
1337
1417
|
return null;
|
|
1338
1418
|
const out = {};
|
|
1419
|
+
// A refusal keeps what it is and its fixed-wording reason (#544); never `detail`.
|
|
1420
|
+
if (depth === 0 && o.refused === true) {
|
|
1421
|
+
out.refused = true;
|
|
1422
|
+
if (isToolRefusal(o))
|
|
1423
|
+
out.reason = o.reason;
|
|
1424
|
+
if (o.transient === true)
|
|
1425
|
+
out.transient = true;
|
|
1426
|
+
}
|
|
1339
1427
|
for (const [k, v] of Object.entries(o)) {
|
|
1340
1428
|
if (["number", "identifier", "id", "html_url", "url", "success", "reused"].includes(k)) {
|
|
1341
1429
|
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
@@ -1805,6 +1893,12 @@ export class RunJournal {
|
|
|
1805
1893
|
this.logger.info(` run journal: ${tool.name} already applied before the crash; not re-sent`, { node });
|
|
1806
1894
|
return receipt.output ?? null;
|
|
1807
1895
|
}
|
|
1896
|
+
const refusal = plan?.refusals.get(key);
|
|
1897
|
+
if (refusal && !retriesRefusal(refusal.output)) {
|
|
1898
|
+
this.logger.info(` run journal: ${tool.name} was refused in an earlier attempt; not re-sent`, { node });
|
|
1899
|
+
const prior = refusal.output && typeof refusal.output === "object" ? refusal.output : {};
|
|
1900
|
+
return { reason: "the tool refused the write", ...prior, refused: true };
|
|
1901
|
+
}
|
|
1808
1902
|
const replayingStage = !!plan?.checkpoints.has(visitKey(node, iteration));
|
|
1809
1903
|
const pending = plan?.intents.has(key) === true;
|
|
1810
1904
|
if (pending || replayingStage) {
|
|
@@ -1826,12 +1920,30 @@ export class RunJournal {
|
|
|
1826
1920
|
// A write the journal cannot record is never sent: append throws first.
|
|
1827
1921
|
this.append("output:intent", { node, iteration, key, tool: tool.name });
|
|
1828
1922
|
const output = await tool.handler(withMarker(tool.name, args, key), ctx);
|
|
1829
|
-
|
|
1923
|
+
const refused = !!output && typeof output === "object" && output.refused === true;
|
|
1924
|
+
this.append(refused ? "output:refused" : "output:applied", {
|
|
1925
|
+
node,
|
|
1926
|
+
iteration,
|
|
1927
|
+
key,
|
|
1928
|
+
tool: tool.name,
|
|
1929
|
+
output: slimOutput(output),
|
|
1930
|
+
});
|
|
1830
1931
|
return output;
|
|
1831
1932
|
},
|
|
1832
1933
|
};
|
|
1833
1934
|
}
|
|
1834
1935
|
}
|
|
1936
|
+
/**
|
|
1937
|
+
* Whether a journaled refusal is tried again on resume (#544): only a
|
|
1938
|
+
* transient one that names nothing it produced. Anything else stands.
|
|
1939
|
+
*/
|
|
1940
|
+
function retriesRefusal(output) {
|
|
1941
|
+
if (!output || typeof output !== "object")
|
|
1942
|
+
return false;
|
|
1943
|
+
const o = output;
|
|
1944
|
+
// Anything besides the refusal itself is something the write produced.
|
|
1945
|
+
return o.transient === true && Object.keys(o).every((k) => k === "refused" || k === "reason" || k === "transient");
|
|
1946
|
+
}
|
|
1835
1947
|
/** What a node result needs to continue a run. Tool call inputs and outputs are dropped. */
|
|
1836
1948
|
function journalResult(r) {
|
|
1837
1949
|
return {
|
package/dist/safe-outputs.d.ts
CHANGED
|
@@ -114,7 +114,7 @@ export interface ApplySafeOutputsOptions {
|
|
|
114
114
|
}
|
|
115
115
|
export interface ApplySafeOutputsResult {
|
|
116
116
|
receipts: SafeOutputReceipt[];
|
|
117
|
-
/** Set when an apply call failed; the node fails with it. */
|
|
117
|
+
/** Set when an apply call failed or its handler refused (a `ToolRefusal`); the node fails with it. */
|
|
118
118
|
error?: string;
|
|
119
119
|
}
|
|
120
120
|
/** Check, then apply (or stage) one node's intents. See the module comment for the order of checks. */
|
package/dist/safe-outputs.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
17
|
import { readFileSync } from "node:fs";
|
|
18
|
-
import { SAFE_OUTPUT_APPLIERS } from "./types.js";
|
|
18
|
+
import { SAFE_OUTPUT_APPLIERS, isToolRefusal } from "./types.js";
|
|
19
19
|
/** Name of the intent-recording tool a node with `outputs` receives. */
|
|
20
20
|
export const EMIT_OUTPUT_TOOL = "emit_output";
|
|
21
21
|
/** Longest title the write stage accepts (after the prefix). */
|
|
@@ -574,7 +574,22 @@ export async function applySafeOutputs(o) {
|
|
|
574
574
|
});
|
|
575
575
|
const ref = refOf(output);
|
|
576
576
|
const url = urlOf(output);
|
|
577
|
-
|
|
577
|
+
const produced = { ...(ref !== undefined ? { ref } : {}), ...(url ? { url } : {}) };
|
|
578
|
+
const refused = !!output && typeof output === "object" && output.refused === true;
|
|
579
|
+
if (refused) {
|
|
580
|
+
// #544: the handler declined the write, or could not vouch for it. The
|
|
581
|
+
// receipt says refused with the handler's fixed-wording reason (and
|
|
582
|
+
// names anything it did create); the node fails and the rest are
|
|
583
|
+
// skipped, the same as a failed apply. A refusal without a usable
|
|
584
|
+
// reason still counts as one.
|
|
585
|
+
const reason = isToolRefusal(output) ? output.reason.slice(0, 200) : "the tool refused the write";
|
|
586
|
+
receipts[index] = { ...receipts[index], ...produced, status: "refused", reason };
|
|
587
|
+
for (const rest of accepted.slice(n + 1)) {
|
|
588
|
+
receipts[rest.index] = { ...receipts[rest.index], status: "skipped", reason: "an earlier write was refused" };
|
|
589
|
+
}
|
|
590
|
+
return { receipts, error: `safe output ${write.type} via ${write.via} was refused: ${reason}` };
|
|
591
|
+
}
|
|
592
|
+
receipts[index] = { ...receipts[index], ...produced };
|
|
578
593
|
o.logger.info(` safe output: ${write.type} via ${write.via} applied${ref !== undefined ? ` (${ref})` : ""}`, {
|
|
579
594
|
node: o.nodeId,
|
|
580
595
|
});
|
|
@@ -110,6 +110,12 @@ export interface PushHeadResult {
|
|
|
110
110
|
attempted: boolean;
|
|
111
111
|
/** Why nothing was pushed, or why the push failed. Never carries the token. */
|
|
112
112
|
reason?: string;
|
|
113
|
+
/**
|
|
114
|
+
* The local commit the push tried to publish. Set when the push ran and
|
|
115
|
+
* failed: `github_create_pr` then opens the PR only if the remote head is
|
|
116
|
+
* this commit (#544).
|
|
117
|
+
*/
|
|
118
|
+
sha?: string;
|
|
113
119
|
}
|
|
114
120
|
/** `owner/repo` from a GitHub remote URL (https, ssh or scp form), lowercased, or undefined. */
|
|
115
121
|
export declare function repoFromRemote(url: string): {
|
package/dist/skills/git-push.js
CHANGED
|
@@ -459,7 +459,7 @@ export async function pushHeadBranch(opts) {
|
|
|
459
459
|
], pushEnv);
|
|
460
460
|
if (r.code !== 0) {
|
|
461
461
|
const why = (r.stderr || r.stdout).trim().split("\n").at(-1) ?? "";
|
|
462
|
-
return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, token)}
|
|
462
|
+
return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, token)}`, sha };
|
|
463
463
|
}
|
|
464
464
|
return { pushed: true, attempted: true };
|
|
465
465
|
}
|
package/dist/skills/github.d.ts
CHANGED
|
@@ -19,12 +19,13 @@ export declare function githubApiBase(apiUrl: string | undefined): string;
|
|
|
19
19
|
* remotely is pushed with an empty lease (it must still not exist). One that
|
|
20
20
|
* exists is pushed only when it is unprotected and already has an open PR from
|
|
21
21
|
* this repo into `base`, leased on the sha the API returned. Any API failure
|
|
22
|
-
* means no push.
|
|
22
|
+
* means no push. A skip carries the remote head sha when the API returned one.
|
|
23
23
|
*/
|
|
24
24
|
export declare function headPushLease(repo: string, head: string, base: string, ctx: ToolContext): Promise<{
|
|
25
25
|
remoteSha: string;
|
|
26
26
|
} | {
|
|
27
27
|
skip: string;
|
|
28
|
+
remoteSha?: string;
|
|
28
29
|
}>;
|
|
29
30
|
/**
|
|
30
31
|
* Encode an LLM-chosen repo file path for safe interpolation into a
|
package/dist/skills/github.js
CHANGED
|
@@ -56,7 +56,7 @@ async function gh(path, ctx, init) {
|
|
|
56
56
|
* remotely is pushed with an empty lease (it must still not exist). One that
|
|
57
57
|
* exists is pushed only when it is unprotected and already has an open PR from
|
|
58
58
|
* this repo into `base`, leased on the sha the API returned. Any API failure
|
|
59
|
-
* means no push.
|
|
59
|
+
* means no push. A skip carries the remote head sha when the API returned one.
|
|
60
60
|
*/
|
|
61
61
|
export async function headPushLease(repo, head, base, ctx) {
|
|
62
62
|
const branch = head.replace(/^refs\/heads\//, "");
|
|
@@ -76,7 +76,7 @@ export async function headPushLease(repo, head, base, ctx) {
|
|
|
76
76
|
return { skip: `cannot check head ${branch} on ${repo}: no branch sha in the API answer` };
|
|
77
77
|
}
|
|
78
78
|
if (info.protected !== false)
|
|
79
|
-
return { skip: `head ${branch} exists on ${repo} and is protected
|
|
79
|
+
return { skip: `head ${branch} exists on ${repo} and is protected`, remoteSha: sha };
|
|
80
80
|
const owner = repo.split("/")[0];
|
|
81
81
|
let open;
|
|
82
82
|
try {
|
|
@@ -85,13 +85,45 @@ export async function headPushLease(repo, head, base, ctx) {
|
|
|
85
85
|
catch (err) {
|
|
86
86
|
return {
|
|
87
87
|
skip: `cannot check open PRs for ${branch} on ${repo}: ${err instanceof Error ? err.message : String(err)}`,
|
|
88
|
+
remoteSha: sha,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
if (!Array.isArray(open) || open.length === 0) {
|
|
91
|
-
return { skip: `head ${branch} already exists on ${repo} with no open PR into ${target}
|
|
92
|
+
return { skip: `head ${branch} already exists on ${repo} with no open PR into ${target}`, remoteSha: sha };
|
|
92
93
|
}
|
|
93
94
|
return { remoteSha: sha };
|
|
94
95
|
}
|
|
96
|
+
const SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
97
|
+
// Fixed-wording refusal reasons for `github_create_pr` (#544). Receipts and PR
|
|
98
|
+
// comments carry these; shas and upstream errors go in the result's `detail`.
|
|
99
|
+
const REFUSE_NO_LOCAL = "the head push failed and the pusher did not report the local commit";
|
|
100
|
+
const REFUSE_UNREADABLE = "the head push failed and the remote head could not be read";
|
|
101
|
+
const REFUSE_MISMATCH = "the head push failed and the remote head is not the commit this run produced";
|
|
102
|
+
const REFUSE_PR_MISMATCH = "the PR was opened but its head is not the commit this run produced";
|
|
103
|
+
const REFUSE_PR_UNVERIFIED = "the PR was opened but its head commit could not be verified";
|
|
104
|
+
/**
|
|
105
|
+
* The sha the remote `head` branch points at, read through the API (#544), or
|
|
106
|
+
* an error. A missing branch is `{ sha: null }`; anything unreadable is an error,
|
|
107
|
+
* so the caller fails closed.
|
|
108
|
+
*/
|
|
109
|
+
async function remoteHeadSha(repo, head, ctx) {
|
|
110
|
+
const branch = head.replace(/^refs\/heads\//, "");
|
|
111
|
+
const seg = branch.split("/").map(encodeURIComponent).join("/");
|
|
112
|
+
let ref;
|
|
113
|
+
try {
|
|
114
|
+
ref = (await gh(`/repos/${repo}/git/ref/heads/${seg}`, ctx));
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof GitHubApiError && err.status === 404)
|
|
118
|
+
return { sha: null };
|
|
119
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
120
|
+
}
|
|
121
|
+
const sha = ref?.object?.sha;
|
|
122
|
+
if (ref?.ref !== `refs/heads/${branch}` || typeof sha !== "string" || !SHA.test(sha)) {
|
|
123
|
+
return { error: "no matching ref in the API answer" };
|
|
124
|
+
}
|
|
125
|
+
return { sha };
|
|
126
|
+
}
|
|
95
127
|
function isAlreadyExistsError(err) {
|
|
96
128
|
if (!(err instanceof GitHubApiError) || err.status !== 422)
|
|
97
129
|
return false;
|
|
@@ -280,6 +312,10 @@ export const github = {
|
|
|
280
312
|
handler: async (input, ctx) => {
|
|
281
313
|
let pr;
|
|
282
314
|
let reused = false;
|
|
315
|
+
// The remote head this run saw, for the result (#544).
|
|
316
|
+
let observedHead;
|
|
317
|
+
// Set after sweny's own push failed: the PR's head must be this commit (#544).
|
|
318
|
+
let mustHead;
|
|
283
319
|
// #473: push the head branch from sweny with this skill's token, so the
|
|
284
320
|
// recommended `persist-credentials: false` checkout still ships the
|
|
285
321
|
// branch and the agent never holds a write token. Skipped unless the
|
|
@@ -307,12 +343,49 @@ export const github = {
|
|
|
307
343
|
remoteSha: lease.remoteSha,
|
|
308
344
|
token: ctx.config.GITHUB_TOKEN,
|
|
309
345
|
});
|
|
346
|
+
if (lease.remoteSha)
|
|
347
|
+
observedHead = lease.remoteSha;
|
|
310
348
|
if ("skip" in lease)
|
|
311
349
|
ctx.logger?.warn?.(` github_create_pr: not pushing: ${lease.skip}`);
|
|
312
350
|
else if (push.pushed)
|
|
313
351
|
ctx.logger?.info?.(` github_create_pr: pushed ${input.head} to ${input.repo}`);
|
|
314
|
-
else if (push.attempted)
|
|
315
|
-
|
|
352
|
+
else if (push.attempted) {
|
|
353
|
+
// #544: a push sweny ran and that failed. The PR would publish
|
|
354
|
+
// whatever the remote branch holds, so open it only when that is
|
|
355
|
+
// the commit this run would have pushed. Anything else, or a
|
|
356
|
+
// remote head that cannot be read, refuses.
|
|
357
|
+
const local = typeof push.sha === "string" && SHA.test(push.sha) ? push.sha : null;
|
|
358
|
+
const remote = local ? await remoteHeadSha(input.repo, input.head, ctx) : undefined;
|
|
359
|
+
const remoteSha = remote && "sha" in remote ? remote.sha : null;
|
|
360
|
+
if (!local || !remoteSha || remoteSha !== local) {
|
|
361
|
+
const [reason, why] = !local
|
|
362
|
+
? [REFUSE_NO_LOCAL, "the pusher did not report the local commit"]
|
|
363
|
+
: remote && "error" in remote
|
|
364
|
+
? [REFUSE_UNREADABLE, `cannot read the remote head of ${input.head}: ${remote.error}`]
|
|
365
|
+
: remoteSha
|
|
366
|
+
? [
|
|
367
|
+
REFUSE_MISMATCH,
|
|
368
|
+
`the remote head of ${input.head} is ${remoteSha}, not the local commit ${local}`,
|
|
369
|
+
]
|
|
370
|
+
: [REFUSE_MISMATCH, `${input.head} does not exist on ${input.repo}; the local commit is ${local}`];
|
|
371
|
+
const detail = `sweny's push failed (${push.reason}) and ${why}`;
|
|
372
|
+
ctx.logger?.warn?.(` github_create_pr: refusing to open the PR: ${detail}`);
|
|
373
|
+
const refusal = {
|
|
374
|
+
refused: true,
|
|
375
|
+
reason,
|
|
376
|
+
detail,
|
|
377
|
+
// A ref read that failed may succeed later; nothing was written.
|
|
378
|
+
...(reason === REFUSE_UNREADABLE ? { transient: true } : {}),
|
|
379
|
+
head: input.head,
|
|
380
|
+
local_sha: local,
|
|
381
|
+
remote_sha: remoteSha,
|
|
382
|
+
};
|
|
383
|
+
return refusal;
|
|
384
|
+
}
|
|
385
|
+
observedHead = remoteSha;
|
|
386
|
+
mustHead = local;
|
|
387
|
+
ctx.logger?.warn?.(` github_create_pr: ${push.reason}; the remote head is already the local commit ${local}, requesting the PR`);
|
|
388
|
+
}
|
|
316
389
|
else
|
|
317
390
|
ctx.logger?.debug?.(` github_create_pr: no sweny-side push (${push.reason})`);
|
|
318
391
|
}
|
|
@@ -347,6 +420,29 @@ export const github = {
|
|
|
347
420
|
pr = existing;
|
|
348
421
|
reused = true;
|
|
349
422
|
}
|
|
423
|
+
// #544: the remote could move between the ref read and the request. The
|
|
424
|
+
// PR exists now; it is not closed (that would be another write nobody
|
|
425
|
+
// asked for), but the result refuses to vouch for it.
|
|
426
|
+
const prHead = pr.head?.sha;
|
|
427
|
+
const prHeadSha = typeof prHead === "string" && SHA.test(prHead) ? prHead : null;
|
|
428
|
+
if (mustHead && prHeadSha !== mustHead) {
|
|
429
|
+
const detail = prHeadSha
|
|
430
|
+
? `the PR's head is ${prHeadSha}, not the local commit ${mustHead}`
|
|
431
|
+
: `GitHub did not report the PR's head commit; the local commit is ${mustHead}`;
|
|
432
|
+
ctx.logger?.warn?.(` github_create_pr: ${pr.html_url ?? `#${pr.number}`} is not verified: ${detail}`);
|
|
433
|
+
const refusal = {
|
|
434
|
+
refused: true,
|
|
435
|
+
reason: prHeadSha ? REFUSE_PR_MISMATCH : REFUSE_PR_UNVERIFIED,
|
|
436
|
+
detail,
|
|
437
|
+
...(pr.number !== undefined ? { number: pr.number } : {}),
|
|
438
|
+
...(pr.html_url ? { html_url: pr.html_url } : {}),
|
|
439
|
+
...(reused ? { reused: true } : {}),
|
|
440
|
+
head: input.head,
|
|
441
|
+
local_sha: mustHead,
|
|
442
|
+
remote_sha: prHeadSha,
|
|
443
|
+
};
|
|
444
|
+
return refusal;
|
|
445
|
+
}
|
|
350
446
|
try {
|
|
351
447
|
if (pr.number && !reused) {
|
|
352
448
|
await gh(`/repos/${input.repo}/issues/${pr.number}/labels`, ctx, {
|
|
@@ -364,7 +460,10 @@ export const github = {
|
|
|
364
460
|
const msg = err instanceof Error ? err.message : String(err);
|
|
365
461
|
ctx.logger?.warn?.(` github_create_pr: label POST failed for ${pr.html_url ?? `#${pr.number}`} — ${msg}`);
|
|
366
462
|
}
|
|
367
|
-
|
|
463
|
+
// The head the PR was opened from: GitHub's answer, else the remote head this run read.
|
|
464
|
+
const headSha = prHeadSha ?? observedHead;
|
|
465
|
+
const out = headSha ? { ...pr, head_sha: headSha } : pr;
|
|
466
|
+
return reused ? { ...out, reused: true } : out;
|
|
368
467
|
},
|
|
369
468
|
},
|
|
370
469
|
{
|
package/dist/types.d.ts
CHANGED
|
@@ -48,6 +48,11 @@ export type BranchPusher = (opts: {
|
|
|
48
48
|
pushed: boolean;
|
|
49
49
|
attempted: boolean;
|
|
50
50
|
reason?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The local commit the push tried to publish. Set when a push was attempted
|
|
53
|
+
* and failed, so the caller can check what the remote head holds (#544).
|
|
54
|
+
*/
|
|
55
|
+
sha?: string;
|
|
51
56
|
}>;
|
|
52
57
|
/**
|
|
53
58
|
* Side-effect class of a tool. `"read"` only reads. `"write"` can change
|
|
@@ -68,6 +73,32 @@ export interface Tool {
|
|
|
68
73
|
access?: ToolAccess;
|
|
69
74
|
handler: (input: any, ctx: ToolContext) => Promise<unknown>;
|
|
70
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* A write handler's structured refusal: it declined the write, or could not
|
|
78
|
+
* vouch for what it wrote, and says why. Returned, not thrown, so the caller
|
|
79
|
+
* keeps the details. `refused: true` is the same flag a refused node result
|
|
80
|
+
* carries (`data: { error, refused: true }`). The write stage records it as
|
|
81
|
+
* refused, never applied (#544).
|
|
82
|
+
*
|
|
83
|
+
* `reason` is fixed wording chosen by the handler, never model, agent or
|
|
84
|
+
* remote text: receipts and PR comments carry it. Specifics (shas, branch
|
|
85
|
+
* names, upstream errors) go in `detail` and other fields, which only the
|
|
86
|
+
* tool result carries. A refusal may still name what exists (`number`,
|
|
87
|
+
* `html_url`) when the write happened but could not be verified.
|
|
88
|
+
*/
|
|
89
|
+
export interface ToolRefusal {
|
|
90
|
+
refused: true;
|
|
91
|
+
reason: string;
|
|
92
|
+
detail?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The refusal may not hold on a later try (an upstream read failed) and
|
|
95
|
+
* nothing was written. Only such a refusal is tried again when a run is
|
|
96
|
+
* resumed; any other refusal is final for the run.
|
|
97
|
+
*/
|
|
98
|
+
transient?: boolean;
|
|
99
|
+
}
|
|
100
|
+
/** Whether a tool handler's output is a {@link ToolRefusal}. */
|
|
101
|
+
export declare function isToolRefusal(output: unknown): output is ToolRefusal;
|
|
71
102
|
/** Config field declaration — skills say what they need */
|
|
72
103
|
export interface ConfigField {
|
|
73
104
|
description: string;
|
package/dist/types.js
CHANGED
|
@@ -4,6 +4,13 @@ export { WORKFLOW_INPUT_TYPES } from "./inputs.js";
|
|
|
4
4
|
* something outside the run: create, update, delete, post, send, invoke.
|
|
5
5
|
*/
|
|
6
6
|
export const TOOL_ACCESS = ["read", "write"];
|
|
7
|
+
/** Whether a tool handler's output is a {@link ToolRefusal}. */
|
|
8
|
+
export function isToolRefusal(output) {
|
|
9
|
+
if (!output || typeof output !== "object" || Array.isArray(output))
|
|
10
|
+
return false;
|
|
11
|
+
const o = output;
|
|
12
|
+
return o.refused === true && typeof o.reason === "string" && o.reason.length > 0;
|
|
13
|
+
}
|
|
7
14
|
/**
|
|
8
15
|
* Skill categories: used for per-node validation and grouping.
|
|
9
16
|
*
|