@sweny-ai/core 0.1.176 → 0.1.178
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/agent-env.d.ts +9 -0
- package/dist/agent-env.js +4 -0
- package/dist/cli/comment-output.js +4 -5
- package/dist/cli/config-file.d.ts +37 -2
- package/dist/cli/config-file.js +68 -3
- package/dist/cli/final-output.d.ts +4 -1
- package/dist/cli/final-output.js +6 -8
- package/dist/cli/main.d.ts +2 -1
- package/dist/cli/main.js +18 -10
- package/dist/cli/resume.js +1 -0
- package/dist/cli/run-history.d.ts +3 -7
- package/dist/cli/run-history.js +5 -12
- package/dist/cli/run-output.d.ts +16 -10
- package/dist/cli/run-output.js +30 -16
- package/dist/cli/ticket.js +3 -2
- package/dist/cli/try.js +2 -3
- package/dist/decider.d.ts +176 -64
- package/dist/decider.js +475 -155
- package/dist/executor.d.ts +55 -7
- package/dist/executor.js +511 -99
- package/dist/harness/acp.js +2 -2
- package/dist/harness/claude-code.js +6 -4
- package/dist/harness/codex.js +2 -2
- package/dist/harness/pi.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/journal.d.ts +34 -5
- package/dist/journal.js +42 -13
- package/dist/safe-file.d.ts +74 -0
- package/dist/safe-file.js +211 -0
- package/dist/safe-outputs.d.ts +7 -1
- package/dist/safe-outputs.js +7 -1
- package/dist/schema.d.ts +189 -241
- package/dist/schema.js +56 -36
- package/dist/skills/git-push.d.ts +99 -10
- package/dist/skills/git-push.js +413 -52
- package/dist/skills/github.d.ts +19 -15
- package/dist/skills/github.js +91 -15
- package/dist/startup-env.d.ts +30 -0
- package/dist/startup-env.js +48 -0
- package/dist/types.d.ts +86 -4
- package/dist/types.js +22 -0
- package/dist/when.d.ts +8 -1
- package/dist/when.js +8 -1
- package/dist/workflow-builder.js +3 -1
- package/dist/workflows/browser.js +2 -2
- package/dist/workflows/implement.yml +37 -5
- package/dist/workflows/triage.yml +40 -6
- package/package.json +1 -1
package/dist/harness/acp.js
CHANGED
|
@@ -48,7 +48,7 @@ import { spawn } from "node:child_process";
|
|
|
48
48
|
import * as fs from "node:fs";
|
|
49
49
|
import * as path from "node:path";
|
|
50
50
|
import { consoleLogger } from "../types.js";
|
|
51
|
-
import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
51
|
+
import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
52
52
|
import { ACP_CAPABILITIES } from "./capabilities.js";
|
|
53
53
|
import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
|
|
54
54
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
@@ -739,7 +739,7 @@ export class AcpHarness {
|
|
|
739
739
|
try {
|
|
740
740
|
// Dry run: external MCP servers cannot be classified per tool, so only
|
|
741
741
|
// sweny's own (already read-filtered) skill tools remain.
|
|
742
|
-
const servers = readOnly
|
|
742
|
+
const servers = mcpWithheld(readOnly, req.agentAccess)
|
|
743
743
|
? {}
|
|
744
744
|
: { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
|
|
745
745
|
if (req.tools.length > 0) {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { consoleLogger } from "../types.js";
|
|
14
|
-
import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, resolveAgentSandbox, } from "../agent-env.js";
|
|
14
|
+
import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, mcpWithheld, resolveAgentSandbox, } from "../agent-env.js";
|
|
15
15
|
import { gitCredentialGap, policyGate } from "./policy.js";
|
|
16
16
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
17
17
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
@@ -324,13 +324,14 @@ export class ClaudeCodeHarness {
|
|
|
324
324
|
...req,
|
|
325
325
|
readOnly,
|
|
326
326
|
disallowedTools: disallowedTools.length > 0 ? disallowedTools : undefined,
|
|
327
|
-
strictMcp: readOnly || policy.strict || policy.exclusiveMcp === true,
|
|
327
|
+
strictMcp: mcpWithheld(readOnly, req.agentAccess) || policy.strict || policy.exclusiveMcp === true,
|
|
328
328
|
sandboxMode: policy.sandbox,
|
|
329
329
|
agentAccess: {
|
|
330
330
|
envVars: req.agentAccess?.envVars ?? [],
|
|
331
331
|
domains: policy.egress,
|
|
332
332
|
withhold: req.agentAccess?.withhold,
|
|
333
333
|
noPush: req.agentAccess?.noPush,
|
|
334
|
+
noMcp: req.agentAccess?.noMcp,
|
|
334
335
|
},
|
|
335
336
|
stagedWrite: !!req.agentAccess?.noPush && !readOnly,
|
|
336
337
|
strict: policy.strict || this.policyMode === "strict",
|
|
@@ -483,7 +484,8 @@ export class ClaudeCodeHarness {
|
|
|
483
484
|
const abort = makeAbort(timeoutMs, signal);
|
|
484
485
|
let stream;
|
|
485
486
|
try {
|
|
486
|
-
|
|
487
|
+
// Read-only and staged runs: no skill or external MCP server (it may write).
|
|
488
|
+
const allMcpServers = mcpWithheld(!!readOnly, agentAccess)
|
|
487
489
|
? {}
|
|
488
490
|
: { ...this.defaultMcpServers, ...opts.mcpServers, ...this.mcpServers };
|
|
489
491
|
if (useBridge) {
|
|
@@ -520,7 +522,7 @@ export class ClaudeCodeHarness {
|
|
|
520
522
|
// project .mcp.json, plugins). strictMcpConfig limits MCP to the
|
|
521
523
|
// servers passed above, which under readOnly is only sweny-core.
|
|
522
524
|
// A strict policy (#365) makes it exclusive for write nodes too.
|
|
523
|
-
...(readOnly || opts.strictMcp ? { strictMcpConfig: true } : {}),
|
|
525
|
+
...(mcpWithheld(!!readOnly, agentAccess) || opts.strictMcp ? { strictMcpConfig: true } : {}),
|
|
524
526
|
...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
|
|
525
527
|
// CC-08: ask the SDK to produce validated structured output when the
|
|
526
528
|
// node declares an output schema. The SDK then returns the parsed
|
package/dist/harness/codex.js
CHANGED
|
@@ -28,7 +28,7 @@ import * as fs from "node:fs";
|
|
|
28
28
|
import * as os from "node:os";
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import { consoleLogger } from "../types.js";
|
|
31
|
-
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
31
|
+
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
32
32
|
import { CODEX_CAPABILITIES } from "./capabilities.js";
|
|
33
33
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
34
34
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
@@ -816,7 +816,7 @@ export class CodexHarness {
|
|
|
816
816
|
try {
|
|
817
817
|
// Dry run: external MCP servers cannot be classified per tool, so only
|
|
818
818
|
// sweny's own (already read-filtered) skill tools remain.
|
|
819
|
-
const servers = readOnly
|
|
819
|
+
const servers = mcpWithheld(readOnly, req.agentAccess)
|
|
820
820
|
? {}
|
|
821
821
|
: { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
|
|
822
822
|
const { args: mcpArgs, unsupported } = this.mcpArgs(servers);
|
package/dist/harness/pi.js
CHANGED
|
@@ -34,7 +34,7 @@ import * as fs from "node:fs";
|
|
|
34
34
|
import * as os from "node:os";
|
|
35
35
|
import * as path from "node:path";
|
|
36
36
|
import { consoleLogger } from "../types.js";
|
|
37
|
-
import { PI_AUTH_VARS, PI_ENV_PREFIXES, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
37
|
+
import { PI_AUTH_VARS, PI_ENV_PREFIXES, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
38
38
|
import { PI_CAPABILITIES } from "./capabilities.js";
|
|
39
39
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
40
40
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
@@ -788,7 +788,7 @@ export class PiHarness {
|
|
|
788
788
|
// Dry run: external MCP servers cannot be classified per tool (and a
|
|
789
789
|
// `--tools` allowlist cannot name tools it has not seen), so only sweny's
|
|
790
790
|
// own (already read-filtered) skill tools remain.
|
|
791
|
-
const servers = readOnly
|
|
791
|
+
const servers = mcpWithheld(readOnly, req.agentAccess)
|
|
792
792
|
? {}
|
|
793
793
|
: { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
|
|
794
794
|
const { entries, unsupported } = piMcpServers(servers);
|
package/dist/index.d.ts
CHANGED
|
@@ -27,8 +27,8 @@ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRA
|
|
|
27
27
|
export { consoleLogger } from "./types.js";
|
|
28
28
|
export { execute, RouteEvaluationError } from "./executor.js";
|
|
29
29
|
export type { ExecuteOptions } from "./executor.js";
|
|
30
|
-
export { systemOneProvider
|
|
31
|
-
export type {
|
|
30
|
+
export { systemOneProvider } from "./decider.js";
|
|
31
|
+
export type { DeciderConfig, DeciderOperatorConfig } from "./decider.js";
|
|
32
32
|
export { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
|
|
33
33
|
export type { Budget, BudgetOverrun, BudgetUnit, SpendLimits } from "./budget.js";
|
|
34
34
|
export { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
|
package/dist/index.js
CHANGED
|
@@ -27,8 +27,8 @@ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRA
|
|
|
27
27
|
export { consoleLogger } from "./types.js";
|
|
28
28
|
// Executor
|
|
29
29
|
export { execute, RouteEvaluationError } from "./executor.js";
|
|
30
|
-
// Decision models
|
|
31
|
-
export { systemOneProvider
|
|
30
|
+
// Decision models (#357): config types and the provider adapter only
|
|
31
|
+
export { systemOneProvider } from "./decider.js";
|
|
32
32
|
// Least-privilege nodes and safe outputs (#365)
|
|
33
33
|
export { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
|
|
34
34
|
export { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
|
package/dist/journal.d.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* output:intent a write is about to be applied (idempotency key + tool)
|
|
30
30
|
* output:applied the write returned (key + the ids it produced)
|
|
31
31
|
* node:end final result of the visit and the write-stage counters
|
|
32
|
-
* route the edge taken after the visit (null = the run ended)
|
|
32
|
+
* route the edge taken after the visit (null = the run ended), and the rung that chose it
|
|
33
33
|
* usage spend of one agent attempt (cumulative; live while it runs, final when it returns)
|
|
34
34
|
* run:end success | failed | crashed
|
|
35
35
|
*
|
|
@@ -52,9 +52,10 @@
|
|
|
52
52
|
* (the process died between the API call and the receipt) is looked up on the
|
|
53
53
|
* provider by a marker embedded in the body before anything is re-sent.
|
|
54
54
|
*/
|
|
55
|
-
import type { Logger, NodeResult, NodeUsage, Skill, Workflow } from "./types.js";
|
|
55
|
+
import type { Logger, NodeResult, NodeUsage, RouteRung, Skill, Workflow } from "./types.js";
|
|
56
56
|
import type { SafeOutputIntent, WriteStageState } from "./safe-outputs.js";
|
|
57
57
|
import { type Spend } from "./budget.js";
|
|
58
|
+
import type { DeciderCounters } from "./decider.js";
|
|
58
59
|
/** v2: records are authenticated with a per-run HMAC key (v1 used a public checksum and is refused). */
|
|
59
60
|
export declare const JOURNAL_SCHEMA_VERSION = 2;
|
|
60
61
|
export declare const JOURNAL_DIR: string;
|
|
@@ -139,6 +140,10 @@ export interface RunStartRecord extends JournalRecord {
|
|
|
139
140
|
sweny_version?: string;
|
|
140
141
|
input: unknown;
|
|
141
142
|
input_redacted: boolean;
|
|
143
|
+
decider?: {
|
|
144
|
+
mode: "on" | "off";
|
|
145
|
+
reason?: string;
|
|
146
|
+
};
|
|
142
147
|
}
|
|
143
148
|
export interface WriteStateSnapshot {
|
|
144
149
|
counts: Array<[string, number]>;
|
|
@@ -218,6 +223,8 @@ export interface PlannedVisit {
|
|
|
218
223
|
status?: NodeResult["status"];
|
|
219
224
|
/** Route taken after the visit: a node id, null for run end, undefined when not journaled. */
|
|
220
225
|
next?: string | null;
|
|
226
|
+
/** Who chose that route, when it was a decision (#357). */
|
|
227
|
+
rung?: RouteRung;
|
|
221
228
|
/** Writes the checkpointed write stage already applied (receipts in the journal). */
|
|
222
229
|
applied: number;
|
|
223
230
|
/** Writes with an intent but no receipt: checked on the provider before anything is re-sent. */
|
|
@@ -243,6 +250,8 @@ export interface ResumePlan {
|
|
|
243
250
|
receipts: Map<string, OutputRecord>;
|
|
244
251
|
/** Spend every earlier attempt journaled: the run budget starts here on resume. */
|
|
245
252
|
priorSpend: Spend;
|
|
253
|
+
/** The decider's mode at run start and its counters after the last journaled route (#357). */
|
|
254
|
+
decider?: JournalDeciderState;
|
|
246
255
|
}
|
|
247
256
|
/**
|
|
248
257
|
* Check that the records are a sequence the executor can write: one run:start
|
|
@@ -281,6 +290,21 @@ export interface JournalBeginInfo {
|
|
|
281
290
|
config: Record<string, string>;
|
|
282
291
|
writeState: WriteStageState;
|
|
283
292
|
harnessId?: string;
|
|
293
|
+
/** Whether the run's decider ran (#357), journaled so a resume keeps the same mode. */
|
|
294
|
+
decider?: {
|
|
295
|
+
mode: "on" | "off";
|
|
296
|
+
reason?: string;
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The decider as an earlier attempt left it (#357): its mode at run start and
|
|
301
|
+
* the breaker / cap counters after the last journaled route. Cap and breaker
|
|
302
|
+
* are per logical run, so a resume continues them.
|
|
303
|
+
*/
|
|
304
|
+
export interface JournalDeciderState {
|
|
305
|
+
mode: "on" | "off";
|
|
306
|
+
reason?: string;
|
|
307
|
+
counters?: DeciderCounters;
|
|
284
308
|
}
|
|
285
309
|
export interface JournalCheckpoint {
|
|
286
310
|
result: NodeResult;
|
|
@@ -292,6 +316,7 @@ export type JournalReplay = {
|
|
|
292
316
|
kind: "complete";
|
|
293
317
|
result: NodeResult;
|
|
294
318
|
next?: string | null;
|
|
319
|
+
rung?: RouteRung;
|
|
295
320
|
} | ({
|
|
296
321
|
kind: "checkpoint";
|
|
297
322
|
} & JournalCheckpoint);
|
|
@@ -308,7 +333,8 @@ export interface ExecutionJournal {
|
|
|
308
333
|
/** The skill map the write stage applies through: write tools journaled and made idempotent. */
|
|
309
334
|
wrapWrites(node: string, iteration: number, skills: Map<string, Skill>): Map<string, Skill>;
|
|
310
335
|
nodeEnd(node: string, iteration: number, result: NodeResult, writeState: WriteStageState): void;
|
|
311
|
-
|
|
336
|
+
/** The edge taken after a visit, and for a decision the rung that chose it (#357), so resume replays it. */
|
|
337
|
+
route(from: string, to: string | null, rung?: RouteRung, decider?: DeciderCounters): void;
|
|
312
338
|
/**
|
|
313
339
|
* Spend of one agent attempt, cumulative for the attempt: `final` when the
|
|
314
340
|
* attempt returned, otherwise a live report (the journal may throttle those).
|
|
@@ -316,6 +342,8 @@ export interface ExecutionJournal {
|
|
|
316
342
|
usage?(node: string, iteration: number, attempt: number, usage: NodeUsage, final: boolean): void;
|
|
317
343
|
/** Spend earlier attempts of this run already journaled (resume); the run budget starts from it. */
|
|
318
344
|
priorSpend?(): Spend | undefined;
|
|
345
|
+
/** The decider state earlier attempts of this run journaled (resume); undefined otherwise. */
|
|
346
|
+
deciderResume?(): JournalDeciderState | undefined;
|
|
319
347
|
}
|
|
320
348
|
/** Minimum gap between two live usage records of one attempt. Final reports are always written. */
|
|
321
349
|
export declare const USAGE_JOURNAL_INTERVAL_MS = 2000;
|
|
@@ -382,18 +410,19 @@ export declare class RunJournal implements ExecutionJournal {
|
|
|
382
410
|
/** False once an append failed: the rest of the run is not journaled. */
|
|
383
411
|
get active(): boolean;
|
|
384
412
|
private takeLock;
|
|
385
|
-
/** The run dir is private (0700);
|
|
413
|
+
/** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
|
|
386
414
|
private makeDir;
|
|
387
415
|
private releaseLock;
|
|
388
416
|
private append;
|
|
389
417
|
private closeFd;
|
|
390
418
|
private clean;
|
|
391
419
|
begin(info: JournalBeginInfo): void;
|
|
420
|
+
deciderResume(): JournalDeciderState | undefined;
|
|
392
421
|
replay(node: string, iteration: number): JournalReplay | undefined;
|
|
393
422
|
nodeStart(node: string, iteration: number): void;
|
|
394
423
|
checkpoint(node: string, iteration: number, cp: JournalCheckpoint): void;
|
|
395
424
|
nodeEnd(node: string, iteration: number, result: NodeResult, writeState: WriteStageState): void;
|
|
396
|
-
route(from: string, to: string | null): void;
|
|
425
|
+
route(from: string, to: string | null, rung?: RouteRung, decider?: DeciderCounters): void;
|
|
397
426
|
usage(node: string, iteration: number, attempt: number, usage: NodeUsage, final: boolean): void;
|
|
398
427
|
priorSpend(): Spend | undefined;
|
|
399
428
|
/** Close the run: `run:end`, then release the lock. Safe to call twice (one run:end). */
|
package/dist/journal.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* output:intent a write is about to be applied (idempotency key + tool)
|
|
30
30
|
* output:applied the write returned (key + the ids it produced)
|
|
31
31
|
* node:end final result of the visit and the write-stage counters
|
|
32
|
-
* route the edge taken after the visit (null = the run ended)
|
|
32
|
+
* route the edge taken after the visit (null = the run ended), and the rung that chose it
|
|
33
33
|
* usage spend of one agent attempt (cumulative; live while it runs, final when it returns)
|
|
34
34
|
* run:end success | failed | crashed
|
|
35
35
|
*
|
|
@@ -59,6 +59,7 @@ import path from "node:path";
|
|
|
59
59
|
import { resolveNodePermissions } from "./node-policy.js";
|
|
60
60
|
import { CURRENT_SPEC_VERSION } from "./migrations.js";
|
|
61
61
|
import { spendOf } from "./budget.js";
|
|
62
|
+
import { ensureDirNoFollow, openNoFollow, writeFileNoFollow } from "./safe-file.js";
|
|
62
63
|
/** v2: records are authenticated with a per-run HMAC key (v1 used a public checksum and is refused). */
|
|
63
64
|
export const JOURNAL_SCHEMA_VERSION = 2;
|
|
64
65
|
export const JOURNAL_DIR = path.join(".sweny", "runs");
|
|
@@ -292,13 +293,15 @@ export function redact(value, secrets = []) {
|
|
|
292
293
|
return v.map((x) => walk(x, depth + 1));
|
|
293
294
|
if (v && typeof v === "object") {
|
|
294
295
|
const out = {};
|
|
296
|
+
// defineProperty, not assignment: a key named `__proto__` stays a plain key.
|
|
297
|
+
const put = (k, x) => Object.defineProperty(out, k, { value: x, enumerable: true, writable: true, configurable: true });
|
|
295
298
|
for (const [k, x] of Object.entries(v)) {
|
|
296
299
|
if (typeof x === "string" && isSecretKey(k) && x.length > 0) {
|
|
297
|
-
|
|
300
|
+
put(k, REDACTED);
|
|
298
301
|
redacted = true;
|
|
299
302
|
}
|
|
300
303
|
else if (x !== undefined && typeof x !== "function") {
|
|
301
|
-
|
|
304
|
+
put(k, walk(x, depth + 1));
|
|
302
305
|
}
|
|
303
306
|
}
|
|
304
307
|
return out;
|
|
@@ -466,6 +469,13 @@ const RECORD_TYPES = new Set([
|
|
|
466
469
|
const isPosInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
|
|
467
470
|
const isName = (v) => typeof v === "string" && v.length > 0;
|
|
468
471
|
const isAmount = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
472
|
+
const isRung = (v) => v === "expr" || v === "decider" || v === "agent";
|
|
473
|
+
const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
474
|
+
const isCounters = (v) => typeof v === "object" &&
|
|
475
|
+
v !== null &&
|
|
476
|
+
isCount(v.calls) &&
|
|
477
|
+
isCount(v.failures) &&
|
|
478
|
+
typeof v.open === "boolean";
|
|
469
479
|
/**
|
|
470
480
|
* Check that the records are a sequence the executor can write: one run:start
|
|
471
481
|
* first; nothing but run:resume after run:end; a visit starts only where the
|
|
@@ -582,6 +592,10 @@ export function checkRecordSequence(records) {
|
|
|
582
592
|
bad(r, `routes from ${String(r.from)}, but the visit that ended is ${cur.node}`);
|
|
583
593
|
if (r.to !== null && !isName(r.to))
|
|
584
594
|
bad(r, "has no valid target");
|
|
595
|
+
if (r.rung !== undefined && !isRung(r.rung))
|
|
596
|
+
bad(r, "has an unknown rung");
|
|
597
|
+
if (r.decider !== undefined && !isCounters(r.decider))
|
|
598
|
+
bad(r, "has invalid decider counters");
|
|
585
599
|
cur.routed = true;
|
|
586
600
|
next = r.to;
|
|
587
601
|
break;
|
|
@@ -662,6 +676,7 @@ export function buildResumePlan(records) {
|
|
|
662
676
|
const receipts = new Map();
|
|
663
677
|
let attempts = 1;
|
|
664
678
|
let lastStatus;
|
|
679
|
+
let deciderCounters;
|
|
665
680
|
for (const r of records) {
|
|
666
681
|
switch (r.type) {
|
|
667
682
|
case "run:resume":
|
|
@@ -680,6 +695,7 @@ export function buildResumePlan(records) {
|
|
|
680
695
|
// Re-run of a visit an earlier attempt did not finish (or finished failed).
|
|
681
696
|
delete v.end;
|
|
682
697
|
delete v.next;
|
|
698
|
+
delete v.rung;
|
|
683
699
|
}
|
|
684
700
|
break;
|
|
685
701
|
}
|
|
@@ -698,8 +714,14 @@ export function buildResumePlan(records) {
|
|
|
698
714
|
case "route": {
|
|
699
715
|
const from = r.from;
|
|
700
716
|
const v = [...order].reverse().find((x) => x.node === from);
|
|
701
|
-
if (v)
|
|
717
|
+
if (v) {
|
|
702
718
|
v.next = r.to ?? null;
|
|
719
|
+
if (isRung(r.rung))
|
|
720
|
+
v.rung = r.rung;
|
|
721
|
+
}
|
|
722
|
+
// Calls already made count even if the visit later re-runs.
|
|
723
|
+
if (isCounters(r.decider))
|
|
724
|
+
deciderCounters = r.decider;
|
|
703
725
|
break;
|
|
704
726
|
}
|
|
705
727
|
case "output:intent":
|
|
@@ -732,6 +754,7 @@ export function buildResumePlan(records) {
|
|
|
732
754
|
action: "replay",
|
|
733
755
|
status: v.end.result.status,
|
|
734
756
|
next: v.next,
|
|
757
|
+
...(v.rung ? { rung: v.rung } : {}),
|
|
735
758
|
applied,
|
|
736
759
|
unconfirmed,
|
|
737
760
|
});
|
|
@@ -775,6 +798,9 @@ export function buildResumePlan(records) {
|
|
|
775
798
|
intents,
|
|
776
799
|
receipts,
|
|
777
800
|
priorSpend: journaledSpend(records),
|
|
801
|
+
...(start.decider
|
|
802
|
+
? { decider: { ...start.decider, ...(deciderCounters ? { counters: { ...deciderCounters } } : {}) } }
|
|
803
|
+
: {}),
|
|
778
804
|
};
|
|
779
805
|
}
|
|
780
806
|
/**
|
|
@@ -1005,12 +1031,11 @@ export class RunJournal {
|
|
|
1005
1031
|
throw err;
|
|
1006
1032
|
}
|
|
1007
1033
|
this.makeDir();
|
|
1008
|
-
|
|
1034
|
+
writeFileNoFollow(lock, String(process.pid), { root: this.cwd, mode: 0o600 });
|
|
1009
1035
|
}
|
|
1010
|
-
/** The run dir is private (0700);
|
|
1036
|
+
/** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
|
|
1011
1037
|
makeDir() {
|
|
1012
|
-
|
|
1013
|
-
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
1038
|
+
ensureDirNoFollow(this.dir, this.cwd);
|
|
1014
1039
|
}
|
|
1015
1040
|
releaseLock() {
|
|
1016
1041
|
try {
|
|
@@ -1052,8 +1077,7 @@ export class RunJournal {
|
|
|
1052
1077
|
try {
|
|
1053
1078
|
if (this.fd === undefined) {
|
|
1054
1079
|
this.makeDir();
|
|
1055
|
-
this.fd =
|
|
1056
|
-
fs.fchmodSync(this.fd, 0o600);
|
|
1080
|
+
this.fd = openNoFollow(this.file, { root: this.cwd, append: true, mode: 0o600 });
|
|
1057
1081
|
}
|
|
1058
1082
|
fs.writeSync(this.fd, JSON.stringify(record) + "\n");
|
|
1059
1083
|
fs.fsyncSync(this.fd);
|
|
@@ -1126,7 +1150,7 @@ export class RunJournal {
|
|
|
1126
1150
|
try {
|
|
1127
1151
|
this.makeDir();
|
|
1128
1152
|
// Journals hold node output: keep them out of commits (an agent's `git add -A` included).
|
|
1129
|
-
|
|
1153
|
+
writeFileNoFollow(path.join(this.dir, ".gitignore"), "*\n", { root: this.cwd, mode: 0o644 });
|
|
1130
1154
|
this.takeLock();
|
|
1131
1155
|
}
|
|
1132
1156
|
catch {
|
|
@@ -1154,8 +1178,12 @@ export class RunJournal {
|
|
|
1154
1178
|
...(this.opts.swenyVersion ? { sweny_version: this.opts.swenyVersion } : {}),
|
|
1155
1179
|
input: input.value,
|
|
1156
1180
|
input_redacted: input.redacted,
|
|
1181
|
+
...(info.decider ? { decider: info.decider } : {}),
|
|
1157
1182
|
});
|
|
1158
1183
|
}
|
|
1184
|
+
deciderResume() {
|
|
1185
|
+
return this.resume?.plan.decider;
|
|
1186
|
+
}
|
|
1159
1187
|
replay(node, iteration) {
|
|
1160
1188
|
const plan = this.resume?.plan;
|
|
1161
1189
|
if (!plan)
|
|
@@ -1173,6 +1201,7 @@ export class RunJournal {
|
|
|
1173
1201
|
kind: "complete",
|
|
1174
1202
|
result: structuredClone(end.result),
|
|
1175
1203
|
...(visit.next !== undefined ? { next: visit.next } : {}),
|
|
1204
|
+
...(visit.rung ? { rung: visit.rung } : {}),
|
|
1176
1205
|
};
|
|
1177
1206
|
}
|
|
1178
1207
|
if (visit.action === "write-stage") {
|
|
@@ -1210,8 +1239,8 @@ export class RunJournal {
|
|
|
1210
1239
|
write_state: { counts: [...writeState.counts], total: writeState.total, seen: [...writeState.seen] },
|
|
1211
1240
|
});
|
|
1212
1241
|
}
|
|
1213
|
-
route(from, to) {
|
|
1214
|
-
this.append("route", { from, to });
|
|
1242
|
+
route(from, to, rung, decider) {
|
|
1243
|
+
this.append("route", { from, to, ...(rung ? { rung } : {}), ...(decider ? { decider } : {}) });
|
|
1215
1244
|
}
|
|
1216
1245
|
usage(node, iteration, attempt, usage, final) {
|
|
1217
1246
|
const s = spendOf(usage);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files the sweny process writes into an agent-writable workspace.
|
|
3
|
+
*
|
|
4
|
+
* An agent can leave a symlink or a hard link where sweny is about to write
|
|
5
|
+
* (`.sweny/runs/<id>/output.md` -> `~/.bashrc`), or swap a directory on the
|
|
6
|
+
* path for a link. These helpers never follow a link: every directory between
|
|
7
|
+
* the trusted root and the file must be a real directory owned by this user,
|
|
8
|
+
* the final component is opened `O_NOFOLLOW`, and the file written must be a
|
|
9
|
+
* regular file with one link (a replaced link or special file is unlinked and
|
|
10
|
+
* created fresh with `O_EXCL`). Data goes through the descriptor that was
|
|
11
|
+
* checked; the mode is set on it.
|
|
12
|
+
*
|
|
13
|
+
* Race with a process that swaps a checked directory for a link (TOCTOU):
|
|
14
|
+
* Node has no `openat(2)`, so the parent chain is checked by pathname and the
|
|
15
|
+
* file opened by pathname. After the open, {@link assertOpenedUnder} re-checks
|
|
16
|
+
* the chain (realpath equals the lexical path, every component a real owned
|
|
17
|
+
* directory) and requires that the pathname now names the very inode the
|
|
18
|
+
* descriptor holds (dev and ino). A swap before the open either leaves a link
|
|
19
|
+
* on the path or moves the opened inode off it; both fail closed before a byte
|
|
20
|
+
* is written. Residual risk: an `O_CREAT` through a swapped directory can leave
|
|
21
|
+
* an empty file outside the workspace before the check fails, and the unlink of
|
|
22
|
+
* a planted link or special file is by pathname (checked just before, not
|
|
23
|
+
* atomic). A regular file is never unlinked or truncated by pathname.
|
|
24
|
+
*
|
|
25
|
+
* Node only.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Create `dir` and its missing parents below `root`, checking each component
|
|
29
|
+
* under `root` with lstat: a real directory, owned by this user. `root` itself
|
|
30
|
+
* is trusted. A `dir` outside `root` is created as usual and only its last
|
|
31
|
+
* component is checked.
|
|
32
|
+
*/
|
|
33
|
+
export declare function ensureDirNoFollow(dir: string, root: string, mode?: number): void;
|
|
34
|
+
export interface NoFollowOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Trusted base dir (the workspace root): every component below it, and the
|
|
37
|
+
* file, must be owned by this user. Default: the file's own dir, with no
|
|
38
|
+
* ownership check (a runner-owned GITHUB_STEP_SUMMARY, say).
|
|
39
|
+
*/
|
|
40
|
+
root?: string;
|
|
41
|
+
/** File mode, set on the descriptor. Default 0o600. */
|
|
42
|
+
mode?: number;
|
|
43
|
+
/** Mode for directories created on the way. Default 0o700. */
|
|
44
|
+
dirMode?: number;
|
|
45
|
+
/** Append to an existing regular file instead of replacing it. */
|
|
46
|
+
append?: boolean;
|
|
47
|
+
/** Create missing directories. Default true. */
|
|
48
|
+
mkdirs?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Open `file` for writing without following a link. Replace mode unlinks what
|
|
52
|
+
* is there and creates a fresh file; append mode refuses anything but a
|
|
53
|
+
* regular, singly linked file (owned by this user when under `root`).
|
|
54
|
+
* Returns the descriptor; the caller closes it.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* Fail unless `fd` is the file `file` names now, below `root`, with no link
|
|
58
|
+
* anywhere between them: the directory's realpath is the realpath of `root`
|
|
59
|
+
* plus the same relative path, each component under `root` is a real
|
|
60
|
+
* directory (owned by this user when `owned`), and `lstat(file)` has the
|
|
61
|
+
* descriptor's dev and ino. See the module comment for the race it closes.
|
|
62
|
+
*/
|
|
63
|
+
export declare function assertOpenedUnder(fd: number, file: string, root: string, owned?: boolean): void;
|
|
64
|
+
export declare function openNoFollow(file: string, opts?: NoFollowOptions): number;
|
|
65
|
+
/**
|
|
66
|
+
* `{ root: cwd }` when `file` is inside `cwd` (the workspace, where the agent
|
|
67
|
+
* can write), else `{}`: an operator path elsewhere is only opened without
|
|
68
|
+
* following a final link.
|
|
69
|
+
*/
|
|
70
|
+
export declare function workspaceRoot(file: string, cwd?: string): {
|
|
71
|
+
root?: string;
|
|
72
|
+
};
|
|
73
|
+
/** Write (or append) `data` to `file` through {@link openNoFollow}. Throws on any refusal. */
|
|
74
|
+
export declare function writeFileNoFollow(file: string, data: string | Uint8Array, opts?: NoFollowOptions): void;
|