@sweny-ai/core 0.1.149 → 0.1.150

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/packs.js CHANGED
@@ -10,11 +10,15 @@
10
10
  * - validates with no credentials
11
11
  * - every node declares an `output` schema and at least one `eval` gate
12
12
  * - value and function gates first; a judge only where a rule cannot decide
13
- * - every node is read-only except the delivery node, and each node's write
14
- * tools are named in `pack.writes`; the delivery node also restricts its
15
- * tool list with `tools.allow`
16
- * - harness-agnostic: instructions name SWEny skill tools and plain
17
- * read-only shell commands, never a specific agent's built-in tools
13
+ * - safe outputs (#365): every GitHub write (issue, comment) is a declared
14
+ * output with a cap, and a pin or title prefix where one applies; sweny
15
+ * applies it after the node. No node holds a GitHub write tool.
16
+ * - least privilege: a workflow-level `permissions` ceiling, and every node
17
+ * is `read` except a delivery node that needs a write no safe output
18
+ * covers (Slack); its write tools are named in `pack.writes` and its tool
19
+ * list is restricted with `tools.allow`
20
+ * - harness-agnostic: instructions name SWEny skill tools, never a specific
21
+ * agent's built-in tools
18
22
  */
19
23
  const DIGEST_YAML = `id: weekly-digest
20
24
  name: Weekly Repo Digest
@@ -37,13 +41,20 @@ inputs:
37
41
  enum: [issue, slack, none]
38
42
  default: issue
39
43
 
44
+ # Ceiling (#365): write only for the Slack post in publish. The digest issue is
45
+ # a safe output, applied by sweny after publish, at most one per run.
46
+ permissions: write
47
+ safe_outputs:
48
+ allow: [issue]
49
+ max: 1
50
+
40
51
  nodes:
41
52
  collect:
42
53
  name: Collect the Week
43
54
  instruction: |
44
55
  Gather facts about the last N days, where N is context.input.days
45
- (default 7). The repo is context.input.repo; if empty, use the
46
- GITHUB_REPOSITORY environment variable, else the origin remote.
56
+ (default 7). The repo is context.input.repo; if empty, read the origin
57
+ remote URL from .git/config in the checkout.
47
58
  Compute the window as UTC dates: until is today, since is N days ago.
48
59
 
49
60
  1. Commits on the default branch in the window:
@@ -54,18 +65,19 @@ nodes:
54
65
  3. Issues opened and issues closed in the window:
55
66
  github_search_issues with "is:issue created:>=SINCE" and
56
67
  "is:issue closed:>=SINCE".
57
- 4. Files touched, with churn: run a read-only git command in the
58
- checkout, for example "git log --since=SINCE --no-merges
59
- --numstat --format=", and sum added plus deleted lines per path.
60
- Keep the 15 paths with the most churn.
68
+ 4. Files touched, with churn: github_list_pr_files for each merged PR
69
+ (at most 20, newest first), and sum additions plus deletions per
70
+ path. Keep the 15 paths with the most churn.
61
71
 
62
- Only read. Never create, edit, comment, post, or push anything.
63
- Report only what the tools returned. A quiet week is a valid result.
72
+ Only read. Report only what the tools returned. A quiet week is a
73
+ valid result.
64
74
  skills: [github]
75
+ permissions: read
65
76
  tools:
66
77
  allow:
67
78
  - github_list_recent_commits
68
79
  - github_search_issues
80
+ - github_list_pr_files
69
81
  max_turns: 30
70
82
  output:
71
83
  type: object
@@ -199,6 +211,7 @@ nodes:
199
211
  Copy the counts exactly from the collected facts; never estimate.
200
212
  watch_next is at most 3 short items a maintainer should look at next
201
213
  week (for example, a risky file that changed without a test).
214
+ permissions: read
202
215
  max_turns: 5
203
216
  output:
204
217
  type: object
@@ -271,19 +284,27 @@ nodes:
271
284
 
272
285
  - issue: first call github_search_issues for an open issue labelled
273
286
  sweny-digest whose title is "Weekly digest SINCE to UNTIL". If one
274
- exists, do not create another. Otherwise call github_create_issue
275
- with that title, the body, and the label sweny-digest.
287
+ exists, do not request another. Otherwise request it with
288
+ emit_output: type issue, that title, the body, and dedupe_key
289
+ "weekly-digest-SINCE". sweny files it after this step and adds the
290
+ sweny-digest label.
276
291
  - slack: call slack_send_message once with the body as mrkdwn.
277
- - none: call no tool; just return the body.
292
+ - none: request nothing; just return the body.
278
293
 
279
- This is the only step allowed to write, and only to the destination
280
- above.
294
+ delivered means the issue was requested or the Slack message was sent.
281
295
  skills: [github, slack]
296
+ # Write only for slack_send_message (no safe output covers Slack). The
297
+ # issue is a declared output: capped at one, labelled, title-prefixed.
298
+ permissions: write
282
299
  tools:
283
300
  allow:
284
301
  - github_search_issues
285
- - github_create_issue
286
302
  - slack_send_message
303
+ outputs:
304
+ - type: issue
305
+ max: 1
306
+ title_prefix: "Weekly digest "
307
+ labels: [sweny-digest]
287
308
  max_turns: 8
288
309
  output:
289
310
  type: object
@@ -307,12 +328,13 @@ nodes:
307
328
  output_matches:
308
329
  - path: destination
309
330
  in: [issue, slack, none]
310
- - name: delivery_never_opens_a_pr
331
+ - name: delivery_never_writes_github_directly
311
332
  kind: function
312
333
  rule:
313
334
  no_tool_called:
314
335
  - github_create_pr
315
336
  - github_add_comment
337
+ - github_create_issue
316
338
 
317
339
  edges:
318
340
  - from: collect
@@ -342,7 +364,8 @@ jobs:
342
364
  with:
343
365
  workflow: .sweny/workflows/weekly-digest.yml
344
366
  claude-oauth-token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
345
- # input: '{"days": 7, "deliver": "slack"}'
367
+ input: '{"repo": "\${{ github.repository }}"}'
368
+ # input: '{"repo": "\${{ github.repository }}", "days": 7, "deliver": "slack"}'
346
369
  env:
347
370
  GITHUB_TOKEN: \${{ github.token }}
348
371
  # SLACK_WEBHOOK_URL: \${{ secrets.SLACK_WEBHOOK_URL }}
@@ -381,6 +404,13 @@ inputs:
381
404
  enum: [low, medium, high, critical]
382
405
  default: medium
383
406
 
407
+ # Ceiling (#365): every node is read-only. The one issue (or the one comment
408
+ # on it) is a safe output, applied by sweny after file-issue.
409
+ permissions: read
410
+ safe_outputs:
411
+ allow: [issue, comment]
412
+ max: 1
413
+
384
414
  nodes:
385
415
  inventory:
386
416
  name: Read the Lockfiles
@@ -446,7 +476,7 @@ nodes:
446
476
  name: Fetch Open Advisories
447
477
  instruction: |
448
478
  Call github_list_dependabot_alerts for the repo (context.input.repo;
449
- if empty, GITHUB_REPOSITORY or the origin remote).
479
+ if empty, the origin remote URL in .git/config).
450
480
 
451
481
  If the tool returns unavailable: true, record available as false and
452
482
  return no alerts. Do NOT guess advisories from memory: an advisory you
@@ -589,24 +619,31 @@ nodes:
589
619
  Always start with github_search_issues for an open issue labelled
590
620
  sweny-drift (query: "is:issue is:open label:sweny-drift").
591
621
 
592
- - If action is "none": create nothing. Return result "none".
622
+ - If action is "none": request nothing. Return result "none".
593
623
  - If an open sweny-drift issue exists and lists the same advisory ids
594
624
  and drift paths: change nothing. Return result "unchanged".
595
- - If it exists but the set changed: call github_add_comment once on
596
- that issue with only what is new. Return result "updated".
597
- - If none exists: call github_create_issue once, titled
598
- "Dependency drift: N actionable", label sweny-drift. The body has a
599
- table (package, severity, advisory id, why it matters, fix), the
600
- drift paths with reasons, and the deferred count. Return "created".
601
-
602
- Never open a pull request. Never edit code or lockfiles. At most one
603
- issue is created per run.
625
+ - If it exists but the set changed: request one comment on that issue
626
+ (emit_output type comment, number = the issue number) with only what
627
+ is new. Return result "updated".
628
+ - If none exists: request one issue (emit_output type issue) titled
629
+ "Dependency drift: N actionable". The body has a table (package,
630
+ severity, advisory id, why it matters, fix), the drift paths with
631
+ reasons, and the deferred count. Return "created". sweny files it
632
+ after this step and adds the sweny-drift label.
633
+
634
+ You cannot write to GitHub directly here: requests are applied after
635
+ this step, at most one per run.
604
636
  skills: [github]
605
637
  tools:
606
638
  allow:
607
639
  - github_search_issues
608
- - github_create_issue
609
- - github_add_comment
640
+ outputs:
641
+ - type: issue
642
+ max: 1
643
+ title_prefix: "Dependency drift: "
644
+ labels: [sweny-drift]
645
+ - type: comment
646
+ max: 1
610
647
  max_turns: 8
611
648
  output:
612
649
  type: object
@@ -627,11 +664,13 @@ nodes:
627
664
  rule:
628
665
  any_tool_called:
629
666
  - github_search_issues
630
- - name: no_code_writes
667
+ - name: no_direct_github_writes
631
668
  kind: function
632
669
  rule:
633
670
  no_tool_called:
634
671
  - github_create_pr
672
+ - github_create_issue
673
+ - github_add_comment
635
674
  - name: result_is_valid
636
675
  kind: value
637
676
  rule:
@@ -669,6 +708,7 @@ jobs:
669
708
  with:
670
709
  workflow: .sweny/workflows/dependency-drift.yml
671
710
  claude-oauth-token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
711
+ input: '{"repo": "\${{ github.repository }}"}'
672
712
  env:
673
713
  # The built-in token cannot read Dependabot alerts. Use a fine-grained
674
714
  # token with Dependabot alerts: read, Issues: write, Contents: read.
@@ -703,6 +743,14 @@ inputs:
703
743
  description: Pull request number. The Action snippet passes github.event.pull_request.number.
704
744
  default: 0
705
745
 
746
+ # Ceiling (#365): every node is read-only. The review comment is a safe
747
+ # output pinned to context.input.pr_number, applied by sweny after
748
+ # post-comment.
749
+ permissions: read
750
+ safe_outputs:
751
+ allow: [comment]
752
+ max: 1
753
+
706
754
  nodes:
707
755
  scope:
708
756
  name: Measure the Change
@@ -876,9 +924,9 @@ nodes:
876
924
  post-comment:
877
925
  name: Post the Review Comment
878
926
  instruction: |
879
- Post exactly one comment on the pull request with github_add_comment
880
- (repo context.input.repo or GITHUB_REPOSITORY, issue_number
881
- context.input.pr_number).
927
+ Request exactly one comment on the pull request with emit_output
928
+ (type comment). It can only land on context.input.pr_number; sweny
929
+ posts it after this step.
882
930
 
883
931
  Start the body with the line: <!-- sweny-pr-risk -->
884
932
  Then: "Risk: LEVEL" with the size, then the reasons as short bullets
@@ -886,34 +934,36 @@ nodes:
886
934
  "Read-only scope review. No code was changed."
887
935
  Keep it under 15 lines. No emoji walls, no praise.
888
936
 
889
- This is the only step allowed to write, and it may only comment.
937
+ This is the only step that asks for a write, and it may only comment.
890
938
  skills: [github]
891
939
  tools:
892
940
  allow:
893
- - github_add_comment
941
+ - github_get_issue
942
+ outputs:
943
+ - type: comment
944
+ max: 1
945
+ number: { input: pr_number }
894
946
  max_turns: 4
895
947
  output:
896
948
  type: object
897
949
  properties:
898
- posted:
950
+ requested:
899
951
  type: boolean
900
- comment_url:
901
- type: string
902
952
  risk_level:
903
953
  type: string
904
954
  enum: [low, medium, high]
905
- required: [posted, risk_level]
955
+ required: [requested, risk_level]
906
956
  eval:
907
- - name: comment_was_posted
957
+ - name: comment_was_requested
908
958
  kind: function
909
959
  rule:
910
960
  all_tools_called:
911
- - github_add_comment
961
+ - emit_output
912
962
  - name: comment_result
913
963
  kind: value
914
964
  rule:
915
965
  output_matches:
916
- - path: posted
966
+ - path: requested
917
967
  equals: true
918
968
  - path: risk_level
919
969
  in: [low, medium, high]
@@ -974,7 +1024,7 @@ export const PACK_TEMPLATES = [
974
1024
  trigger: DIGEST_TRIGGER,
975
1025
  sample: DIGEST_SAMPLE,
976
1026
  tokens: "15k to 40k per run (estimate)",
977
- writes: { publish: ["github_create_issue", "slack_send_message"] },
1027
+ writes: { publish: ["slack_send_message"] },
978
1028
  },
979
1029
  },
980
1030
  {
@@ -986,7 +1036,7 @@ export const PACK_TEMPLATES = [
986
1036
  trigger: DRIFT_TRIGGER,
987
1037
  sample: DRIFT_SAMPLE,
988
1038
  tokens: "20k to 60k per run (estimate)",
989
- writes: { "file-issue": ["github_create_issue", "github_add_comment"] },
1039
+ writes: {},
990
1040
  },
991
1041
  },
992
1042
  {
@@ -998,7 +1048,7 @@ export const PACK_TEMPLATES = [
998
1048
  trigger: RISK_TRIGGER,
999
1049
  sample: RISK_SAMPLE,
1000
1050
  tokens: "10k to 30k per run (estimate)",
1001
- writes: { "post-comment": ["github_add_comment"] },
1051
+ writes: {},
1002
1052
  },
1003
1053
  },
1004
1054
  ];
package/dist/executor.js CHANGED
@@ -80,6 +80,8 @@ export async function execute(workflow, input, options) {
80
80
  const runEnv = options.env ?? process.env;
81
81
  const usesOutputs = Object.values(workflow.nodes).some((n) => (n.outputs?.length ?? 0) > 0);
82
82
  const actor = usesOutputs ? resolveActor(runEnv, options.actor) : {};
83
+ // The run input, for `number: { input }` issue pins on outputs.
84
+ const runInput = input && typeof input === "object" ? input : undefined;
83
85
  // Build an eval-time alias table from the loaded skills. Each skill owns
84
86
  // its own mapping between skill-tool names and equivalent MCP names. Core
85
87
  // stays vendor-neutral; this call just unions what the skills declare.
@@ -257,7 +259,9 @@ export async function execute(workflow, input, options) {
257
259
  const effectiveRules = assembleCascaded("rules", currentId, workflow.nodes[currentId], inputRules.length, (workflow.rules ?? []).length, resolvedSources);
258
260
  const effectiveContext = assembleCascaded("context", currentId, workflow.nodes[currentId], inputContext.length, (workflow.context ?? []).length, resolvedSources);
259
261
  const baseInstruction = buildNodeInstruction(resolvedInstruction, effectiveRules, effectiveContext, input, skillInstructions);
260
- const nodeInstruction = outputDecls.length > 0 ? `${baseInstruction}\n\n${safeOutputsInstruction(outputDecls)}` : baseInstruction;
262
+ const nodeInstruction = outputDecls.length > 0
263
+ ? `${baseInstruction}\n\n${safeOutputsInstruction(outputDecls, runInput)}`
264
+ : baseInstruction;
261
265
  const instruction = dryRun ? `${dryRunNotice(skippedWrites)}\n\n---\n\n${nodeInstruction}` : nodeInstruction;
262
266
  // Run Claude on this node, with optional eval-failure retry loop.
263
267
  let attempt = 0;
@@ -462,6 +466,7 @@ export async function execute(workflow, input, options) {
462
466
  staged: stageOutputs,
463
467
  state: writeState,
464
468
  logger,
469
+ input: runInput,
465
470
  screen: async (writes) => claude.ask({
466
471
  instruction: SAFE_OUTPUT_SCREEN_INSTRUCTION,
467
472
  context: { writes },
@@ -610,10 +615,17 @@ function warnOnJudgeBudget(workflow, logger) {
610
615
  * including when no evaluator ran (the trusted namespace is then empty).
611
616
  */
612
617
  function buildPriorNodeContext(result) {
613
- const data = (result.data ?? {});
618
+ const { safe_outputs: _forged, ...data } = (result.data ?? {});
614
619
  const evals = result.evals ?? [];
615
620
  const evalsByName = Object.fromEntries(evals.map((e) => [e.name, e]));
616
- return { ...data, evals: evalsByName };
621
+ // Safe-output receipts (#365) are runtime facts, like evals: what sweny
622
+ // actually wrote (type, status, ref, url). A downstream node reads the new
623
+ // issue's identifier here. Agent data can never shadow them.
624
+ return {
625
+ ...data,
626
+ evals: evalsByName,
627
+ ...(result.outputs && result.outputs.length > 0 ? { safe_outputs: result.outputs } : {}),
628
+ };
617
629
  }
618
630
  /**
619
631
  * Build the prior-node entry shown to the LLM route evaluator.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Agent login probes (#339): does the agent a harness drives have a way to
3
+ * authenticate? Each adapter's `preflight()` calls its probe, so `sweny
4
+ * workflow run` and `sweny check` fail before any node runs, with the fix.
5
+ *
6
+ * Presence only. No probe reads a credential's value, and the macOS Keychain
7
+ * probe asks for the entry's attributes, never its secret.
8
+ */
9
+ export type AuthProbeResult = {
10
+ ok: true;
11
+ via: string;
12
+ } | {
13
+ ok: false;
14
+ reason: string;
15
+ };
16
+ /** Test seam: adapters take one in their options instead of reading the host. */
17
+ export type AuthProbe = () => AuthProbeResult;
18
+ /** Same shape as the CLI's other "Missing:" lines; names the agent so the fix is unambiguous. */
19
+ export declare const CLAUDE_CODE_AUTH_HINT: string;
20
+ export declare const CODEX_AUTH_HINT = "Missing: OPENAI_API_KEY or CODEX_API_KEY, or a Codex login (`codex login`), for --agent codex";
21
+ /**
22
+ * A local Claude Code login: `~/.claude/.credentials.json` (or under
23
+ * `CLAUDE_CONFIG_DIR`) and, on macOS, the Keychain entry (attributes only).
24
+ */
25
+ export declare function detectClaudeCodeLogin(opts?: {
26
+ env?: Record<string, string | undefined>;
27
+ home?: string;
28
+ platform?: NodeJS.Platform;
29
+ keychainHasLogin?: () => boolean;
30
+ }): boolean;
31
+ /**
32
+ * Claude Code auth, the way the spawned agent will see it: Bedrock or Vertex
33
+ * routing, an env credential that survives `SWENY_AUTH` (`oauth` keeps only
34
+ * `CLAUDE_CODE_OAUTH_TOKEN`), or a stored Claude Code login.
35
+ */
36
+ export declare function claudeCodeAuth(env?: Record<string, string | undefined>, login?: () => boolean): AuthProbeResult;
37
+ /** Codex auth: an API key or access token in env, or a stored `codex login`. */
38
+ export declare function codexAuth(env?: Record<string, string | undefined>, login?: () => boolean): AuthProbeResult;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Agent login probes (#339): does the agent a harness drives have a way to
3
+ * authenticate? Each adapter's `preflight()` calls its probe, so `sweny
4
+ * workflow run` and `sweny check` fail before any node runs, with the fix.
5
+ *
6
+ * Presence only. No probe reads a credential's value, and the macOS Keychain
7
+ * probe asks for the entry's attributes, never its secret.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { spawnSync } from "node:child_process";
13
+ import { hasCodexLogin } from "../agent-env.js";
14
+ /** Same shape as the CLI's other "Missing:" lines; names the agent so the fix is unambiguous. */
15
+ export const CLAUDE_CODE_AUTH_HINT = "Missing: ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`), " +
16
+ "or a Claude Code login (run `claude`, then /login), for --agent claude";
17
+ export const CODEX_AUTH_HINT = "Missing: OPENAI_API_KEY or CODEX_API_KEY, or a Codex login (`codex login`), for --agent codex";
18
+ function set(v) {
19
+ return typeof v === "string" && v.trim() !== "";
20
+ }
21
+ function truthy(v) {
22
+ if (!set(v))
23
+ return false;
24
+ const s = v.trim().toLowerCase();
25
+ return s !== "0" && s !== "false" && s !== "no" && s !== "off";
26
+ }
27
+ /**
28
+ * A local Claude Code login: `~/.claude/.credentials.json` (or under
29
+ * `CLAUDE_CONFIG_DIR`) and, on macOS, the Keychain entry (attributes only).
30
+ */
31
+ export function detectClaudeCodeLogin(opts = {}) {
32
+ const env = opts.env ?? process.env;
33
+ const home = opts.home ?? os.homedir();
34
+ const platform = opts.platform ?? process.platform;
35
+ const configDir = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
36
+ try {
37
+ if (fs.existsSync(path.join(configDir, ".credentials.json")))
38
+ return true;
39
+ }
40
+ catch {
41
+ // fall through
42
+ }
43
+ if (platform === "darwin") {
44
+ const probe = opts.keychainHasLogin ??
45
+ (() => {
46
+ const r = spawnSync("security", ["find-generic-password", "-s", "Claude Code-credentials"], {
47
+ stdio: "ignore",
48
+ timeout: 3000,
49
+ });
50
+ return r.status === 0;
51
+ });
52
+ try {
53
+ return probe();
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+ /**
62
+ * Claude Code auth, the way the spawned agent will see it: Bedrock or Vertex
63
+ * routing, an env credential that survives `SWENY_AUTH` (`oauth` keeps only
64
+ * `CLAUDE_CODE_OAUTH_TOKEN`), or a stored Claude Code login.
65
+ */
66
+ export function claudeCodeAuth(env = process.env, login = () => detectClaudeCodeLogin({ env })) {
67
+ if (truthy(env.CLAUDE_CODE_USE_BEDROCK))
68
+ return { ok: true, via: "Amazon Bedrock" };
69
+ if (truthy(env.CLAUDE_CODE_USE_VERTEX))
70
+ return { ok: true, via: "Google Vertex AI" };
71
+ const mode = (env.SWENY_AUTH ?? "").trim().toLowerCase();
72
+ if (set(env.CLAUDE_CODE_OAUTH_TOKEN))
73
+ return { ok: true, via: "CLAUDE_CODE_OAUTH_TOKEN" };
74
+ if (mode !== "oauth") {
75
+ if (set(env.ANTHROPIC_API_KEY))
76
+ return { ok: true, via: "ANTHROPIC_API_KEY" };
77
+ if (set(env.ANTHROPIC_AUTH_TOKEN))
78
+ return { ok: true, via: "ANTHROPIC_AUTH_TOKEN" };
79
+ }
80
+ if (login())
81
+ return { ok: true, via: "Claude Code login" };
82
+ return { ok: false, reason: CLAUDE_CODE_AUTH_HINT };
83
+ }
84
+ /** Codex auth: an API key or access token in env, or a stored `codex login`. */
85
+ export function codexAuth(env = process.env, login = () => hasCodexLogin(env)) {
86
+ for (const k of ["CODEX_API_KEY", "OPENAI_API_KEY", "CODEX_ACCESS_TOKEN"]) {
87
+ if (set(env[k]))
88
+ return { ok: true, via: k };
89
+ }
90
+ if (login())
91
+ return { ok: true, via: "codex login" };
92
+ return { ok: false, reason: CODEX_AUTH_HINT };
93
+ }
@@ -15,6 +15,7 @@ import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
15
15
  import { buildEvaluatePrompt } from "./prompts.js";
16
16
  import { parseToolResultContent, summarizeToolError } from "./parse.js";
17
17
  import { makeAbort } from "./abort.js";
18
+ import { type AuthProbe } from "./auth.js";
18
19
  export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
19
20
  export { parseToolResultContent, summarizeToolError, makeAbort };
20
21
  /**
@@ -117,6 +118,8 @@ export interface ClaudeCodeHarnessOptions {
117
118
  sandboxAllowedDomains?: string[];
118
119
  /** Sandbox preflight probe (test seam). Returns a reason when the sandbox cannot run. */
119
120
  sandboxProbe?: () => string | undefined;
121
+ /** Login probe for `preflight()` (test seam). Default: {@link claudeCodeAuth} over `process.env`. */
122
+ authProbe?: AuthProbe;
120
123
  /**
121
124
  * Serve skill tools through the SwenyToolBridge (a stdio MCP shim over a
122
125
  * per-run unix socket, #414) instead of the in-process SDK MCP server.
@@ -149,6 +152,7 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
149
152
  private envScope;
150
153
  private toolBridge;
151
154
  private toolBridgeShim;
155
+ private authProbe;
152
156
  constructor(opts?: ClaudeCodeHarnessOptions);
153
157
  /**
154
158
  * Build env for the Claude Code subprocess (#360). Applies auth precedence
@@ -162,9 +166,13 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
162
166
  private buildEnv;
163
167
  /** Which harness (and SDK version) produced a result. */
164
168
  info(): HarnessInfo;
169
+ /** Checks the agent can authenticate (#339): an env credential, Bedrock/Vertex, or a Claude Code login. */
165
170
  preflight(): Promise<{
166
171
  ok: true;
167
172
  version: string;
173
+ } | {
174
+ ok: false;
175
+ reason: string;
168
176
  }>;
169
177
  /**
170
178
  * Run a node. Runs {@link policyGate} (always `degraded: []` for Claude
@@ -19,6 +19,7 @@ import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } fro
19
19
  import { startToolBridge } from "./tool-bridge/server.js";
20
20
  import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
21
21
  import { makeAbort } from "./abort.js";
22
+ import { claudeCodeAuth } from "./auth.js";
22
23
  export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
23
24
  export { parseToolResultContent, summarizeToolError, makeAbort };
24
25
  let cachedSdkVersion;
@@ -218,6 +219,7 @@ export class ClaudeCodeHarness {
218
219
  envScope;
219
220
  toolBridge;
220
221
  toolBridgeShim;
222
+ authProbe;
221
223
  constructor(opts = {}) {
222
224
  this.model = opts.model;
223
225
  this.maxTurns = opts.maxTurns ?? 20;
@@ -233,6 +235,7 @@ export class ClaudeCodeHarness {
233
235
  this.sandboxProbe = opts.sandboxProbe;
234
236
  this.toolBridge = opts.toolBridge ?? process.env.SWENY_TOOL_BRIDGE === "1";
235
237
  this.toolBridgeShim = opts.toolBridgeShim;
238
+ this.authProbe = opts.authProbe ?? (() => claudeCodeAuth(process.env));
236
239
  }
237
240
  /**
238
241
  * Build env for the Claude Code subprocess (#360). Applies auth precedence
@@ -260,7 +263,11 @@ export class ClaudeCodeHarness {
260
263
  info() {
261
264
  return { id: this.id, version: claudeSdkVersion() };
262
265
  }
266
+ /** Checks the agent can authenticate (#339): an env credential, Bedrock/Vertex, or a Claude Code login. */
263
267
  async preflight() {
268
+ const auth = this.authProbe();
269
+ if (!auth.ok)
270
+ return { ok: false, reason: auth.reason };
264
271
  return { ok: true, version: claudeSdkVersion() };
265
272
  }
266
273
  /**
@@ -301,7 +308,7 @@ export class ClaudeCodeHarness {
301
308
  ...req,
302
309
  readOnly,
303
310
  disallowedTools: disallowedTools.length > 0 ? disallowedTools : undefined,
304
- strictMcp: readOnly || policy.strict,
311
+ strictMcp: readOnly || policy.strict || policy.exclusiveMcp === true,
305
312
  sandboxMode: policy.sandbox,
306
313
  agentAccess: { envVars: req.agentAccess?.envVars ?? [], domains: policy.egress },
307
314
  });
@@ -29,6 +29,7 @@ import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, Harness
29
29
  import { CODEX_CAPABILITIES } from "./capabilities.js";
30
30
  import { type HarnessPolicyMode } from "./policy.js";
31
31
  import { type SandboxWrapper } from "./sandbox-wrapper.js";
32
+ import { type AuthProbe } from "./auth.js";
32
33
  export { CODEX_CAPABILITIES };
33
34
  /** Oldest Codex CLI whose flags this adapter uses (`--ignore-user-config`, `--ignore-rules`). */
34
35
  export declare const MIN_CODEX_VERSION = "0.159.0";
@@ -89,6 +90,8 @@ export interface CodexHarnessOptions {
89
90
  };
90
91
  /** Grace period between SIGTERM and SIGKILL when stopping Codex (default: 2000 ms). */
91
92
  killGraceMs?: number;
93
+ /** Login probe for `preflight()` (test seam). Default: {@link codexAuth} over `process.env`. */
94
+ authProbe?: AuthProbe;
92
95
  }
93
96
  /** A TOML value for `-c key=value`, as the official SDK writes it (sdk/typescript/src/exec.ts). */
94
97
  export declare function toTomlValue(value: unknown): string;
@@ -135,9 +138,13 @@ export declare class CodexHarness implements AgentHarness {
135
138
  private killGraceMs;
136
139
  private preflightResult;
137
140
  private version;
141
+ private authProbe;
138
142
  constructor(opts?: CodexHarnessOptions);
139
143
  info(): HarnessInfo;
140
- /** `codex --version`, checked against {@link MIN_CODEX_VERSION}. Cached per instance. */
144
+ /**
145
+ * Before any node runs (#339): the Codex CLI is installed and new enough,
146
+ * and Codex can authenticate (an API key, an access token, or `codex login`).
147
+ */
141
148
  preflight(): Promise<{
142
149
  ok: true;
143
150
  version: string;
@@ -145,6 +152,8 @@ export declare class CodexHarness implements AgentHarness {
145
152
  ok: false;
146
153
  reason: string;
147
154
  }>;
155
+ /** `codex --version`, checked against {@link MIN_CODEX_VERSION}. Cached per instance. */
156
+ private checkCli;
148
157
  /** Env for the Codex process: scoped like Claude Code's, with Codex's auth vars instead of Anthropic's. */
149
158
  private buildEnv;
150
159
  /** The node policy after `disallowed_tools` names are translated into Codex classes. */