@sweny-ai/core 0.1.165 → 0.1.167
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 +130 -13
- package/dist/agent-env.js +276 -20
- package/dist/cli/config-file.js +2 -0
- package/dist/cli/main.js +9 -3
- package/dist/cli/try-fixture.json +35 -0
- package/dist/cli/try.d.ts +89 -0
- package/dist/cli/try.js +284 -0
- package/dist/cli/version-check.js +3 -0
- package/dist/executor.js +35 -9
- package/dist/harness/acp.d.ts +6 -1
- package/dist/harness/acp.js +35 -17
- package/dist/harness/claude-code.d.ts +12 -0
- package/dist/harness/claude-code.js +37 -14
- package/dist/harness/codex.d.ts +5 -1
- package/dist/harness/codex.js +30 -16
- package/dist/harness/pi.d.ts +17 -1
- package/dist/harness/pi.js +54 -16
- package/dist/harness/policy.js +16 -0
- package/dist/harness/types.d.ts +16 -0
- package/dist/schema.d.ts +156 -136
- package/dist/schema.js +21 -0
- package/dist/types.d.ts +15 -3
- package/package.json +2 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny try`: a zero-credential demo (#475).
|
|
3
|
+
*
|
|
4
|
+
* Replays a recorded explain-repo run through the REAL executor, with a replay
|
|
5
|
+
* harness standing in for the agent, so the real progress line, answer block
|
|
6
|
+
* (#460), receipt, policy segment and PR-comment markdown (#396) are what a
|
|
7
|
+
* user sees. No agent, no model, no network, no env credentials, no files
|
|
8
|
+
* written (no `.sweny/runs`, no journal). The environment handed to the
|
|
9
|
+
* executor is empty by construction.
|
|
10
|
+
*
|
|
11
|
+
* The recording lives in `try-fixture.json` (metadata plus the sample answer
|
|
12
|
+
* only). Until a real run is recorded it is labelled illustrative.
|
|
13
|
+
*/
|
|
14
|
+
import type { Command } from "commander";
|
|
15
|
+
import type { AgentHarness } from "../harness/types.js";
|
|
16
|
+
import type { Workflow } from "../types.js";
|
|
17
|
+
export interface TryFixtureNode {
|
|
18
|
+
/** Recorded wall-clock time of the node. */
|
|
19
|
+
duration_ms: number;
|
|
20
|
+
/** Tool name to number of calls. Counts only: no inputs or outputs are recorded. */
|
|
21
|
+
tool_calls: Record<string, number>;
|
|
22
|
+
usage?: {
|
|
23
|
+
inputTokens?: number;
|
|
24
|
+
outputTokens?: number;
|
|
25
|
+
costUsd?: number;
|
|
26
|
+
numTurns?: number;
|
|
27
|
+
};
|
|
28
|
+
/** The node's declared output fields (the sample answer). */
|
|
29
|
+
output?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
export interface TryFixture {
|
|
32
|
+
schema_version: 1;
|
|
33
|
+
/** True until the owner replaces the file with a recording of a real run. */
|
|
34
|
+
illustrative: boolean;
|
|
35
|
+
notice: string;
|
|
36
|
+
/** Built-in template id the recording is of. */
|
|
37
|
+
workflow: string;
|
|
38
|
+
sample_repo: string;
|
|
39
|
+
harness: {
|
|
40
|
+
id: string;
|
|
41
|
+
version: string;
|
|
42
|
+
};
|
|
43
|
+
policy?: {
|
|
44
|
+
envScope: boolean;
|
|
45
|
+
sandbox: "off" | "auto" | "strict";
|
|
46
|
+
sandboxStarted: boolean;
|
|
47
|
+
};
|
|
48
|
+
/** Node id to recording, in execution order. */
|
|
49
|
+
nodes: Record<string, TryFixtureNode>;
|
|
50
|
+
}
|
|
51
|
+
/** Shape check with a message naming the first bad field. Throws on a malformed fixture. */
|
|
52
|
+
export declare function parseTryFixture(raw: unknown): TryFixture;
|
|
53
|
+
/** The shipped fixture: `try-fixture.json` next to this module (src/cli or dist/cli). */
|
|
54
|
+
export declare function loadTryFixture(file?: string): TryFixture;
|
|
55
|
+
/** The real explain-repo template, parsed and validated like any workflow file. */
|
|
56
|
+
export declare function loadTryWorkflow(templateId: string): Workflow;
|
|
57
|
+
/**
|
|
58
|
+
* An AgentHarness that plays the recording back: node N of the run gets
|
|
59
|
+
* recording N. It never spawns a process, reads env, or touches the network.
|
|
60
|
+
* `paceMs` is how long each node takes to replay (0 = instant); tool calls are
|
|
61
|
+
* reported through `onProgress` spread across that time so progress feels live.
|
|
62
|
+
*/
|
|
63
|
+
export declare function createReplayHarness(fixture: TryFixture, opts: {
|
|
64
|
+
paceMs: number;
|
|
65
|
+
}): AgentHarness;
|
|
66
|
+
export declare const TRY_BANNER_TITLE = "Recorded demo";
|
|
67
|
+
/** The real commands, same as the README quickstart. */
|
|
68
|
+
export declare const TRY_NEXT_COMMANDS: readonly ["npm install -g @sweny-ai/core", "sweny new --template explain-repo --yes", "sweny workflow run .sweny/workflows/explain-repo.yml"];
|
|
69
|
+
/** Appended to `sweny try --help`. */
|
|
70
|
+
export declare const TRY_HELP_NOTE: string;
|
|
71
|
+
export declare function formatTryBanner(fixture: TryFixture): string;
|
|
72
|
+
export declare function formatTryNext(): string;
|
|
73
|
+
export interface TryOptions {
|
|
74
|
+
/** Replay instantly. */
|
|
75
|
+
fast?: boolean;
|
|
76
|
+
/** Milliseconds each node takes to replay. Default 1200. Ignored with `fast`. */
|
|
77
|
+
paceMs?: number;
|
|
78
|
+
/** Also write the PR-comment markdown here. */
|
|
79
|
+
commentFile?: string;
|
|
80
|
+
/** Output sink. Default stdout. */
|
|
81
|
+
write?: (s: string) => void;
|
|
82
|
+
/** Rewrite progress lines in place. Default: stdout is a TTY. */
|
|
83
|
+
tty?: boolean;
|
|
84
|
+
fixture?: TryFixture;
|
|
85
|
+
}
|
|
86
|
+
export declare const DEFAULT_TRY_PACE_MS = 1200;
|
|
87
|
+
/** Replay the recording. Returns the process exit code. */
|
|
88
|
+
export declare function runTry(opts?: TryOptions): Promise<number>;
|
|
89
|
+
export declare function registerTryCommand(program: Command): Command;
|
package/dist/cli/try.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny try`: a zero-credential demo (#475).
|
|
3
|
+
*
|
|
4
|
+
* Replays a recorded explain-repo run through the REAL executor, with a replay
|
|
5
|
+
* harness standing in for the agent, so the real progress line, answer block
|
|
6
|
+
* (#460), receipt, policy segment and PR-comment markdown (#396) are what a
|
|
7
|
+
* user sees. No agent, no model, no network, no env credentials, no files
|
|
8
|
+
* written (no `.sweny/runs`, no journal). The environment handed to the
|
|
9
|
+
* executor is empty by construction.
|
|
10
|
+
*
|
|
11
|
+
* The recording lives in `try-fixture.json` (metadata plus the sample answer
|
|
12
|
+
* only). Until a real run is recorded it is labelled illustrative.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import { parse as parseYaml } from "yaml";
|
|
19
|
+
import { execute } from "../executor.js";
|
|
20
|
+
import { validateParsed } from "../loader.js";
|
|
21
|
+
import { WORKFLOW_TEMPLATES } from "./templates.js";
|
|
22
|
+
import { formatFinalOutput, resolveFinalOutput } from "./final-output.js";
|
|
23
|
+
import { formatRunComment } from "./comment-output.js";
|
|
24
|
+
import { formatReceiptDuration, renderReceiptLine, summarizeRun } from "./run-output.js";
|
|
25
|
+
import { c } from "./output.js";
|
|
26
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
27
|
+
/** Shape check with a message naming the first bad field. Throws on a malformed fixture. */
|
|
28
|
+
export function parseTryFixture(raw) {
|
|
29
|
+
const bad = (what) => {
|
|
30
|
+
throw new Error(`try-fixture.json: ${what}`);
|
|
31
|
+
};
|
|
32
|
+
if (!isObj(raw))
|
|
33
|
+
return bad("expected an object");
|
|
34
|
+
if (raw.schema_version !== 1)
|
|
35
|
+
bad("schema_version must be 1");
|
|
36
|
+
for (const key of ["notice", "workflow", "sample_repo"]) {
|
|
37
|
+
if (typeof raw[key] !== "string" || raw[key] === "")
|
|
38
|
+
bad(`${key} must be a non-empty string`);
|
|
39
|
+
}
|
|
40
|
+
if (typeof raw.illustrative !== "boolean")
|
|
41
|
+
bad("illustrative must be a boolean");
|
|
42
|
+
if (!isObj(raw.harness) || typeof raw.harness.id !== "string" || typeof raw.harness.version !== "string") {
|
|
43
|
+
bad("harness must be { id, version }");
|
|
44
|
+
}
|
|
45
|
+
if (!isObj(raw.nodes) || Object.keys(raw.nodes).length === 0)
|
|
46
|
+
return bad("nodes must be a non-empty object");
|
|
47
|
+
for (const [id, n] of Object.entries(raw.nodes)) {
|
|
48
|
+
if (!isObj(n) || typeof n.duration_ms !== "number" || !isObj(n.tool_calls)) {
|
|
49
|
+
bad(`nodes.${id} needs duration_ms and tool_calls`);
|
|
50
|
+
}
|
|
51
|
+
for (const [tool, count] of Object.entries(n.tool_calls)) {
|
|
52
|
+
if (!Number.isInteger(count) || count < 0)
|
|
53
|
+
bad(`nodes.${id}.tool_calls.${tool} must be a count`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
/** The shipped fixture: `try-fixture.json` next to this module (src/cli or dist/cli). */
|
|
59
|
+
export function loadTryFixture(file) {
|
|
60
|
+
const p = file ?? fileURLToPath(new URL("./try-fixture.json", import.meta.url));
|
|
61
|
+
return parseTryFixture(JSON.parse(fs.readFileSync(p, "utf-8")));
|
|
62
|
+
}
|
|
63
|
+
/** The real explain-repo template, parsed and validated like any workflow file. */
|
|
64
|
+
export function loadTryWorkflow(templateId) {
|
|
65
|
+
const tpl = WORKFLOW_TEMPLATES.find((t) => t.id === templateId);
|
|
66
|
+
if (!tpl)
|
|
67
|
+
throw new Error(`try-fixture.json: unknown template "${templateId}"`);
|
|
68
|
+
const loaded = validateParsed(parseYaml(tpl.yaml));
|
|
69
|
+
if (!loaded.ok)
|
|
70
|
+
throw new Error(`template ${templateId} is invalid: ${loaded.errors.map((e) => e.message).join("; ")}`);
|
|
71
|
+
return loaded.workflow;
|
|
72
|
+
}
|
|
73
|
+
// ── Replay harness ──────────────────────────────────────────────
|
|
74
|
+
const REPLAY_CAPABILITIES = {
|
|
75
|
+
structuredOutput: "prompt",
|
|
76
|
+
toolTrace: "skill-only",
|
|
77
|
+
builtinDeny: "none",
|
|
78
|
+
mcp: { inject: false, exclusive: "none" },
|
|
79
|
+
sandbox: { fs: false, network: false },
|
|
80
|
+
readOnly: "none",
|
|
81
|
+
turnLimit: "none",
|
|
82
|
+
usage: { tokens: true, costUsd: true, live: false },
|
|
83
|
+
cancel: "signal",
|
|
84
|
+
resume: false,
|
|
85
|
+
};
|
|
86
|
+
function sleep(ms, signal) {
|
|
87
|
+
if (ms <= 0)
|
|
88
|
+
return Promise.resolve();
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const t = setTimeout(resolve, ms);
|
|
91
|
+
signal?.addEventListener("abort", () => {
|
|
92
|
+
clearTimeout(t);
|
|
93
|
+
resolve();
|
|
94
|
+
}, { once: true });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* An AgentHarness that plays the recording back: node N of the run gets
|
|
99
|
+
* recording N. It never spawns a process, reads env, or touches the network.
|
|
100
|
+
* `paceMs` is how long each node takes to replay (0 = instant); tool calls are
|
|
101
|
+
* reported through `onProgress` spread across that time so progress feels live.
|
|
102
|
+
*/
|
|
103
|
+
export function createReplayHarness(fixture, opts) {
|
|
104
|
+
const order = Object.keys(fixture.nodes);
|
|
105
|
+
let next = 0;
|
|
106
|
+
return {
|
|
107
|
+
id: "mock",
|
|
108
|
+
capabilities: REPLAY_CAPABILITIES,
|
|
109
|
+
async preflight() {
|
|
110
|
+
return { ok: true, version: "replay" };
|
|
111
|
+
},
|
|
112
|
+
// Routing and judging never run in a linear recorded workflow; fail closed if asked.
|
|
113
|
+
async complete() {
|
|
114
|
+
return null;
|
|
115
|
+
},
|
|
116
|
+
async run(req) {
|
|
117
|
+
const id = order[next++];
|
|
118
|
+
const rec = id ? fixture.nodes[id] : undefined;
|
|
119
|
+
const harness = { id: fixture.harness.id, version: fixture.harness.version };
|
|
120
|
+
if (!rec) {
|
|
121
|
+
return {
|
|
122
|
+
status: "failed",
|
|
123
|
+
data: { error: "recording has no more nodes" },
|
|
124
|
+
toolCalls: [],
|
|
125
|
+
harness,
|
|
126
|
+
degraded: [],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const toolCalls = [];
|
|
130
|
+
for (const [tool, count] of Object.entries(rec.tool_calls)) {
|
|
131
|
+
for (let i = 0; i < count; i++)
|
|
132
|
+
toolCalls.push({ tool, input: {} });
|
|
133
|
+
}
|
|
134
|
+
const step = toolCalls.length > 0 ? opts.paceMs / toolCalls.length : opts.paceMs;
|
|
135
|
+
for (const tc of toolCalls) {
|
|
136
|
+
await sleep(step, req.signal);
|
|
137
|
+
req.onProgress?.(tc.tool);
|
|
138
|
+
}
|
|
139
|
+
if (toolCalls.length === 0)
|
|
140
|
+
await sleep(step, req.signal);
|
|
141
|
+
return {
|
|
142
|
+
status: "success",
|
|
143
|
+
data: { ...(rec.output ?? {}) },
|
|
144
|
+
toolCalls,
|
|
145
|
+
...(rec.usage ? { usage: rec.usage } : {}),
|
|
146
|
+
...(fixture.policy ? { policy: fixture.policy } : {}),
|
|
147
|
+
harness,
|
|
148
|
+
degraded: [],
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// ── Output ──────────────────────────────────────────────────────
|
|
154
|
+
export const TRY_BANNER_TITLE = "Recorded demo";
|
|
155
|
+
/** The real commands, same as the README quickstart. */
|
|
156
|
+
export const TRY_NEXT_COMMANDS = [
|
|
157
|
+
"npm install -g @sweny-ai/core",
|
|
158
|
+
"sweny new --template explain-repo --yes",
|
|
159
|
+
"sweny workflow run .sweny/workflows/explain-repo.yml",
|
|
160
|
+
];
|
|
161
|
+
/** Appended to `sweny try --help`. */
|
|
162
|
+
export const TRY_HELP_NOTE = "The recording is ILLUSTRATIVE SAMPLE DATA (a fictional repo, hand-written timings and token counts), not output from a real run. " +
|
|
163
|
+
"Nothing runs: no agent, no network, no credentials read, no files written.";
|
|
164
|
+
export function formatTryBanner(fixture) {
|
|
165
|
+
const lines = [
|
|
166
|
+
`${c.brand("▲")} ${chalk.bold("sweny try")} ${chalk.bold.yellow(TRY_BANNER_TITLE)}, not a live run`,
|
|
167
|
+
chalk.dim(` A replay of an ${fixture.workflow} run on a sample repo (${fixture.sample_repo}).`),
|
|
168
|
+
chalk.dim(fixture.illustrative
|
|
169
|
+
? " The sample data is illustrative, not output from your code. No agent, no network, no credentials."
|
|
170
|
+
: " No agent, no network, no credentials."),
|
|
171
|
+
];
|
|
172
|
+
return lines.map((l, i) => (i === 0 ? ` ${l}` : l)).join("\n");
|
|
173
|
+
}
|
|
174
|
+
export function formatTryNext() {
|
|
175
|
+
return [
|
|
176
|
+
` ${chalk.bold("Run it for real")} on your own repo:`,
|
|
177
|
+
"",
|
|
178
|
+
...TRY_NEXT_COMMANDS.map((cmd) => ` ${cmd}`),
|
|
179
|
+
"",
|
|
180
|
+
chalk.dim(" Needs a Claude login (`claude` signed in) or ANTHROPIC_API_KEY. Your run ends with the same receipt."),
|
|
181
|
+
].join("\n");
|
|
182
|
+
}
|
|
183
|
+
export const DEFAULT_TRY_PACE_MS = 1200;
|
|
184
|
+
const silentLogger = { info() { }, warn() { }, error() { }, debug() { } };
|
|
185
|
+
/** Replay the recording. Returns the process exit code. */
|
|
186
|
+
export async function runTry(opts = {}) {
|
|
187
|
+
const write = opts.write ?? ((s) => void process.stdout.write(s));
|
|
188
|
+
const tty = opts.tty ?? Boolean(process.stdout.isTTY);
|
|
189
|
+
const paceMs = opts.fast ? 0 : Math.max(0, opts.paceMs ?? DEFAULT_TRY_PACE_MS);
|
|
190
|
+
const fixture = opts.fixture ?? loadTryFixture();
|
|
191
|
+
const workflow = loadTryWorkflow(fixture.workflow);
|
|
192
|
+
for (const id of Object.keys(fixture.nodes)) {
|
|
193
|
+
if (!workflow.nodes[id])
|
|
194
|
+
throw new Error(`try-fixture.json: node "${id}" is not in ${fixture.workflow}`);
|
|
195
|
+
}
|
|
196
|
+
write(`\n${formatTryBanner(fixture)}\n\n`);
|
|
197
|
+
const ticks = new Map();
|
|
198
|
+
const observer = (event) => {
|
|
199
|
+
switch (event.type) {
|
|
200
|
+
case "workflow:start":
|
|
201
|
+
write(` ${c.brand("▲")} ${chalk.bold(event.workflow)}\n\n`);
|
|
202
|
+
break;
|
|
203
|
+
case "node:enter":
|
|
204
|
+
ticks.set(event.node, 0);
|
|
205
|
+
if (tty)
|
|
206
|
+
write(` ${c.subtle("○")} ${chalk.dim(event.node)}…`);
|
|
207
|
+
break;
|
|
208
|
+
case "node:progress": {
|
|
209
|
+
const n = (ticks.get(event.node) ?? 0) + 1;
|
|
210
|
+
ticks.set(event.node, n);
|
|
211
|
+
if (tty) {
|
|
212
|
+
write(`\r\x1B[2K ${c.subtle("○")} ${chalk.dim(event.node)}… ${chalk.dim(`${n} tool ${n === 1 ? "call" : "calls"}`)}`);
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case "node:exit": {
|
|
217
|
+
const rec = fixture.nodes[event.node];
|
|
218
|
+
const ok = event.result.status === "success";
|
|
219
|
+
const icon = ok ? c.ok("✓") : c.fail("✗");
|
|
220
|
+
const calls = event.result.toolCalls.length;
|
|
221
|
+
const detail = [formatReceiptDuration(rec?.duration_ms ?? 0), ...(calls > 0 ? [`${calls} tool calls`] : [])];
|
|
222
|
+
write(`${tty ? "\r\x1B[2K" : ""} ${icon} ${event.node} ${chalk.dim(detail.join(" · "))}\n`);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const { results, trace } = await execute(workflow, {}, {
|
|
228
|
+
skills: new Map(),
|
|
229
|
+
harness: createReplayHarness(fixture, { paceMs }),
|
|
230
|
+
observer,
|
|
231
|
+
logger: silentLogger,
|
|
232
|
+
// Nothing for the executor to read: no credentials, no PATH, no config.
|
|
233
|
+
env: {},
|
|
234
|
+
offline: true,
|
|
235
|
+
harnessPolicy: "warn",
|
|
236
|
+
});
|
|
237
|
+
write("\n");
|
|
238
|
+
// The recording's timings, not the replay's wall clock.
|
|
239
|
+
const durationsMs = Object.fromEntries(Object.entries(fixture.nodes).map(([id, n]) => [id, n.duration_ms]));
|
|
240
|
+
const totalMs = Object.values(durationsMs).reduce((a, b) => a + b, 0);
|
|
241
|
+
const receipt = summarizeRun(results, totalMs, false, trace);
|
|
242
|
+
const answer = resolveFinalOutput(workflow, results);
|
|
243
|
+
if (answer)
|
|
244
|
+
write(`${formatFinalOutput(answer)}\n\n`);
|
|
245
|
+
write(` ${renderReceiptLine(receipt, tty)}\n\n`);
|
|
246
|
+
const comment = formatRunComment(workflow, results, receipt, { trace, durationsMs });
|
|
247
|
+
write(` ${chalk.dim("What CI posts on a pull request (sweny workflow run --comment-file):")}\n\n`);
|
|
248
|
+
write(`${comment
|
|
249
|
+
.split("\n")
|
|
250
|
+
.map((l) => (l === "" ? l : ` ${l}`))
|
|
251
|
+
.join("\n")}\n`);
|
|
252
|
+
if (opts.commentFile) {
|
|
253
|
+
try {
|
|
254
|
+
fs.mkdirSync(path.dirname(path.resolve(opts.commentFile)), { recursive: true });
|
|
255
|
+
fs.writeFileSync(opts.commentFile, comment);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
process.stderr.write(` ⚠ could not write comment file: ${err instanceof Error ? err.message : err}\n`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
write(`${formatTryNext()}\n\n`);
|
|
262
|
+
return receipt.ok ? 0 : 1;
|
|
263
|
+
}
|
|
264
|
+
export function registerTryCommand(program) {
|
|
265
|
+
return program
|
|
266
|
+
.command("try")
|
|
267
|
+
.description("Replay a recorded demo run: no credentials, no network, no model calls (illustrative sample data)")
|
|
268
|
+
.option("--fast", "Replay instantly instead of pacing the progress")
|
|
269
|
+
.option("--pace <ms>", `Milliseconds each node takes to replay (default ${DEFAULT_TRY_PACE_MS})`)
|
|
270
|
+
.option("--comment-file <path>", "Also write the PR-comment markdown to <path>")
|
|
271
|
+
.addHelpText("after", `\n${TRY_HELP_NOTE}\n`)
|
|
272
|
+
.action(async (options) => {
|
|
273
|
+
let paceMs;
|
|
274
|
+
if (options.pace !== undefined) {
|
|
275
|
+
paceMs = Number(options.pace);
|
|
276
|
+
if (!Number.isFinite(paceMs) || paceMs < 0) {
|
|
277
|
+
console.error(chalk.red(`\n --pace must be a non-negative number of milliseconds, got "${options.pace}"\n`));
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
process.exitCode = await runTry({ fast: options.fast, paceMs, commentFile: options.commentFile });
|
|
283
|
+
});
|
|
284
|
+
}
|
|
@@ -38,6 +38,9 @@ export function shouldSkip(env, isTty, commandName) {
|
|
|
38
38
|
return true;
|
|
39
39
|
if (commandName === "upgrade" || commandName === "update")
|
|
40
40
|
return true;
|
|
41
|
+
// `sweny try` makes no network calls, the version check included.
|
|
42
|
+
if (commandName === "try")
|
|
43
|
+
return true;
|
|
41
44
|
return false;
|
|
42
45
|
}
|
|
43
46
|
export function readCache(cachePath) {
|
package/dist/executor.js
CHANGED
|
@@ -21,7 +21,7 @@ import { buildRetryPreamble } from "./retry.js";
|
|
|
21
21
|
import { resolveExecutionModel } from "./model.js";
|
|
22
22
|
import { buildToolAliases } from "./skills/index.js";
|
|
23
23
|
import { validateWorkflow } from "./schema.js";
|
|
24
|
-
import { resolveAgentAccess } from "./agent-env.js";
|
|
24
|
+
import { grantedAgentEnv, resolveAgentAccess } from "./agent-env.js";
|
|
25
25
|
import { fenceUntrusted } from "./untrusted.js";
|
|
26
26
|
import { asClaude } from "./harness/compat.js";
|
|
27
27
|
import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
|
|
@@ -236,7 +236,7 @@ export async function execute(workflow, input, options) {
|
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
const skillInstructions = resolveSkillInstructions(node.skills, skills);
|
|
239
|
-
const skillMcpServers = resolveSkillMcpServers(node.skills, skills);
|
|
239
|
+
const skillMcpServers = resolveSkillMcpServers(node.skills, skills, config);
|
|
240
240
|
// Runtime guard: if this node declares skills but none resolved, the node
|
|
241
241
|
// cannot do its job (e.g. "create a Linear issue" with no linear skill).
|
|
242
242
|
// The startup validate() warns about this possibility, but only throw when
|
|
@@ -341,8 +341,13 @@ export async function execute(workflow, input, options) {
|
|
|
341
341
|
let degradedLogged = false;
|
|
342
342
|
// #442: a staged or dry run cannot push. The harness blocks git push and
|
|
343
343
|
// withholds write tokens in this node's agent env (see withPushBlocked).
|
|
344
|
+
// Skill credentials stay in sweny: the agent gets only what the node grants
|
|
345
|
+
// with `agent_env`, never on a read-only node or in a staged run.
|
|
346
|
+
const granted = grantedAgentEnv(node.agent_env, { readOnly: readOnlyNode, staged: stageOutputs });
|
|
347
|
+
if (granted.dropped)
|
|
348
|
+
logger.warn(` ${granted.dropped}`, { node: currentId });
|
|
344
349
|
const agentAccess = {
|
|
345
|
-
...resolveAgentAccess(node.skills, skills),
|
|
350
|
+
...resolveAgentAccess(node.skills, skills, granted.grant),
|
|
346
351
|
...(stageOutputs ? { noPush: true } : {}),
|
|
347
352
|
};
|
|
348
353
|
// #365: a node or workflow that declares `permissions` or `outputs` gets
|
|
@@ -1055,12 +1060,33 @@ function dryRunNotice(skippedWrites) {
|
|
|
1055
1060
|
`Do not create, modify, post, or send anything. Do the analysis, and where this step would ` +
|
|
1056
1061
|
`normally write, describe exactly what it would have written instead.`);
|
|
1057
1062
|
}
|
|
1058
|
-
/**
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1063
|
+
/**
|
|
1064
|
+
* Only this node's resolved skills contribute external servers. A stdio
|
|
1065
|
+
* server gets its skill's declared credentials in its own `env` (explicit
|
|
1066
|
+
* server env wins): the agent env no longer carries them, so the server must
|
|
1067
|
+
* not rely on inheriting them from the agent process.
|
|
1068
|
+
*/
|
|
1069
|
+
function resolveSkillMcpServers(skillIds, skills, config = {}) {
|
|
1070
|
+
const servers = {};
|
|
1071
|
+
for (const id of skillIds) {
|
|
1072
|
+
const skill = skills.get(id);
|
|
1073
|
+
const mcp = skill?.mcp;
|
|
1074
|
+
if (!skill || !mcp)
|
|
1075
|
+
continue;
|
|
1076
|
+
const type = mcp.type ?? (mcp.command ? "stdio" : "http");
|
|
1077
|
+
if (type !== "stdio") {
|
|
1078
|
+
servers[id] = { ...mcp, type };
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
const declared = {};
|
|
1082
|
+
for (const [key, field] of Object.entries(skill.config ?? {})) {
|
|
1083
|
+
if (field.env && config[key] !== undefined)
|
|
1084
|
+
declared[field.env] = config[key];
|
|
1085
|
+
}
|
|
1086
|
+
const env = { ...declared, ...(mcp.env ?? {}) };
|
|
1087
|
+
servers[id] = { ...mcp, type, ...(Object.keys(env).length > 0 ? { env } : {}) };
|
|
1088
|
+
}
|
|
1089
|
+
return servers;
|
|
1064
1090
|
}
|
|
1065
1091
|
function resolveTools(skillIds, skills) {
|
|
1066
1092
|
return skillIds
|
package/dist/harness/acp.d.ts
CHANGED
|
@@ -162,7 +162,12 @@ export declare class AcpHarness implements AgentHarness {
|
|
|
162
162
|
ok: false;
|
|
163
163
|
reason: string;
|
|
164
164
|
}>;
|
|
165
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* Env for the agent process: scoped like Claude Code's, with only the auth
|
|
167
|
+
* vars the operator named. Skill credentials are dropped either way
|
|
168
|
+
* ({@link finishAgentEnv}) unless the node granted them with `agent_env`; an
|
|
169
|
+
* operator-named auth var stays, and the gate counts it if it is a write token.
|
|
170
|
+
*/
|
|
166
171
|
private buildEnv;
|
|
167
172
|
/** The node policy, with the harness policy mode and the watchdog budget applied. */
|
|
168
173
|
private compilePolicy;
|
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 { parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
51
|
+
import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } 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 { buildNodePrompt } from "./prompts.js";
|
|
@@ -265,20 +265,28 @@ export class AcpHarness {
|
|
|
265
265
|
}
|
|
266
266
|
return { ok: true, version: this.agentVersion };
|
|
267
267
|
}
|
|
268
|
-
/**
|
|
269
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Env for the agent process: scoped like Claude Code's, with only the auth
|
|
270
|
+
* vars the operator named. Skill credentials are dropped either way
|
|
271
|
+
* ({@link finishAgentEnv}) unless the node granted them with `agent_env`; an
|
|
272
|
+
* operator-named auth var stays, and the gate counts it if it is a write token.
|
|
273
|
+
*/
|
|
274
|
+
buildEnv(access) {
|
|
270
275
|
const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
276
|
+
const passthrough = this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH);
|
|
277
|
+
let env = full;
|
|
278
|
+
if (resolveEnvScope(process.env, this.envScope, this.logger)) {
|
|
279
|
+
const scoped = scopeAgentEnv(full, {
|
|
280
|
+
extraVars: access?.envVars ?? [],
|
|
281
|
+
passthrough,
|
|
282
|
+
authVars: this.authVars,
|
|
283
|
+
prefixes: ["LC_"],
|
|
284
|
+
logger: this.logger,
|
|
285
|
+
});
|
|
286
|
+
reportWithheldEnv(scoped.withheld, this.logger);
|
|
287
|
+
env = scoped.env;
|
|
288
|
+
}
|
|
289
|
+
return finishAgentEnv(env, { access, keep: this.authVars, passthrough, logger: this.logger }).env;
|
|
282
290
|
}
|
|
283
291
|
/** The node policy, with the harness policy mode and the watchdog budget applied. */
|
|
284
292
|
compilePolicy(req, maxTurns) {
|
|
@@ -684,7 +692,15 @@ export class AcpHarness {
|
|
|
684
692
|
async run(req) {
|
|
685
693
|
const maxTurns = req.maxTurns ?? this.maxTurns;
|
|
686
694
|
const mode = req.policy?.sandbox ?? resolveSandboxMode(process.env, this.sandboxMode, this.logger);
|
|
687
|
-
const
|
|
695
|
+
const env = withPushBlocked(this.buildEnv(req.agentAccess), req.agentAccess?.noPush);
|
|
696
|
+
const compiled = this.compilePolicy(req, maxTurns);
|
|
697
|
+
const held = heldCredentials(env, req.agentAccess?.withhold);
|
|
698
|
+
const policy = {
|
|
699
|
+
...compiled,
|
|
700
|
+
sandbox: mode,
|
|
701
|
+
...(held.length > 0 ? { agentCredentials: held } : {}),
|
|
702
|
+
...(req.agentAccess?.noPush && !compiled.readOnly ? { stagedWrite: true } : {}),
|
|
703
|
+
};
|
|
688
704
|
// ACP has no sandbox of its own: any mode but off needs the process wrapper.
|
|
689
705
|
const wrapper = mode !== "off" && this.sandboxWrapper !== null
|
|
690
706
|
? (this.sandboxWrapper ?? (await defaultSandboxWrapper()).wrapper)
|
|
@@ -716,7 +732,6 @@ export class AcpHarness {
|
|
|
716
732
|
const readOnly = policy.readOnly;
|
|
717
733
|
// Permission answers: the node's deny list, plus legacy `disallowed_tools` names that map to a class.
|
|
718
734
|
const deny = new Set([...policy.deny, ...translateDenyNames(policy.nativeDeny).classes]);
|
|
719
|
-
const env = withPushBlocked(this.buildEnv(req.agentAccess?.envVars), req.agentAccess?.noPush);
|
|
720
735
|
let bridge;
|
|
721
736
|
try {
|
|
722
737
|
// Dry run: external MCP servers cannot be classified per tool, so only
|
|
@@ -885,6 +900,8 @@ export class AcpHarness {
|
|
|
885
900
|
return failClosed(pre.reason);
|
|
886
901
|
// A judge call is a dry run with nothing allowed: same gate, same wrapper.
|
|
887
902
|
const mode = resolveSandboxMode(process.env, this.sandboxMode, this.logger);
|
|
903
|
+
const env = this.buildEnv();
|
|
904
|
+
const held = heldCredentials(env);
|
|
888
905
|
const policy = {
|
|
889
906
|
readOnly: true,
|
|
890
907
|
// Nothing to deny at the gate: there is no MCP server and every permission request is rejected below.
|
|
@@ -892,6 +909,7 @@ export class AcpHarness {
|
|
|
892
909
|
egress: [],
|
|
893
910
|
strict: this.policyMode === "strict",
|
|
894
911
|
sandbox: mode,
|
|
912
|
+
...(held.length > 0 ? { agentCredentials: held } : {}),
|
|
895
913
|
};
|
|
896
914
|
const wrapper = mode !== "off" && this.sandboxWrapper !== null
|
|
897
915
|
? (this.sandboxWrapper ?? (await defaultSandboxWrapper()).wrapper)
|
|
@@ -899,7 +917,7 @@ export class AcpHarness {
|
|
|
899
917
|
const prep = await prepareAgentSpawn({
|
|
900
918
|
caps: this.capabilities,
|
|
901
919
|
policy,
|
|
902
|
-
spawn: { command: this.agent.command, args: this.agent.args, env
|
|
920
|
+
spawn: { command: this.agent.command, args: this.agent.args, env, cwd: this.cwd },
|
|
903
921
|
wrapper: wrapper ?? null,
|
|
904
922
|
harnessEgress: this.egress,
|
|
905
923
|
env: process.env,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
|
|
12
12
|
import { type SandboxMode } from "../agent-env.js";
|
|
13
13
|
import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, ToolClass } from "./types.js";
|
|
14
|
+
import { type HarnessPolicyMode } from "./policy.js";
|
|
14
15
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
15
16
|
import { buildEvaluatePrompt } from "./prompts.js";
|
|
16
17
|
import { parseToolResultContent, summarizeToolError } from "./parse.js";
|
|
@@ -132,6 +133,14 @@ export interface ClaudeCodeHarnessOptions {
|
|
|
132
133
|
command: string;
|
|
133
134
|
args: string[];
|
|
134
135
|
};
|
|
136
|
+
/**
|
|
137
|
+
* Harness policy mode. Claude Code honors every node opinion natively, so
|
|
138
|
+
* this only decides one case: a staged or dry-run write node whose agent
|
|
139
|
+
* runs unsandboxed is refused under `strict` (its push block is env and git
|
|
140
|
+
* hooks, which a deliberate agent can undo) and reported under `warn`.
|
|
141
|
+
* Default: `warn`; the CLI passes `resolveHarnessPolicy()` (strict under GitHub Actions).
|
|
142
|
+
*/
|
|
143
|
+
policy?: HarnessPolicyMode;
|
|
135
144
|
}
|
|
136
145
|
export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
137
146
|
readonly id: "claude-code";
|
|
@@ -155,6 +164,7 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
|
155
164
|
private toolBridge;
|
|
156
165
|
private toolBridgeShim;
|
|
157
166
|
private authProbe;
|
|
167
|
+
private policyMode;
|
|
158
168
|
constructor(opts?: ClaudeCodeHarnessOptions);
|
|
159
169
|
/**
|
|
160
170
|
* Build env for the Claude Code subprocess (#360). Applies auth precedence
|
|
@@ -164,6 +174,8 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
|
164
174
|
* `extraVars` (the node's declared skill env vars) and the operator
|
|
165
175
|
* passthrough list, and the withheld names (never values) are reported once
|
|
166
176
|
* per process ({@link reportWithheldEnv}). When off, the full env passes through as before.
|
|
177
|
+
* Either way, skill credentials are then dropped ({@link finishAgentEnv}):
|
|
178
|
+
* only the names the node granted with `agent_env` reach the agent.
|
|
167
179
|
*/
|
|
168
180
|
private buildEnv;
|
|
169
181
|
/** Which harness (and SDK version) produced a result. */
|