@sweny-ai/core 0.1.202 → 0.1.204

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.
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import type { Logger, McpServerConfig, ToolContext } from "../types.js";
27
27
  import { type HarnessEnvOptions, type SandboxMode } from "../agent-env.js";
28
- import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
28
+ import type { AgentHarness, HarnessCapabilities, HarnessCheck, HarnessCompleteRequest, HarnessDiagnoseOptions, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
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";
@@ -162,9 +162,10 @@ export declare class CodexHarness implements AgentHarness {
162
162
  reason: string;
163
163
  }>;
164
164
  /**
165
- * `process.env` with the caller's `env` / `envRemove` applied, unscoped: where
166
- * sweny reads Codex's own settings (`OPENAI_BASE_URL`) and the login probe.
167
- * The scoped agent env would drop an inherited base URL.
165
+ * `process.env` (or `base`, for `sweny check`) with the caller's `env` /
166
+ * `envRemove` applied, unscoped: where sweny reads Codex's own settings
167
+ * (`OPENAI_BASE_URL`) and the login probe. The scoped agent env would drop
168
+ * an inherited base URL.
168
169
  */
169
170
  private hostEnv;
170
171
  /**
@@ -177,6 +178,23 @@ export declare class CodexHarness implements AgentHarness {
177
178
  private cliPath;
178
179
  /** `codex --version`, checked against {@link MIN_CODEX_VERSION}. Cached per instance. */
179
180
  private checkCli;
181
+ /** Run `<resolved> --version` with `hostEnv` and parse it. No caching, no side effects. */
182
+ private probeVersion;
183
+ /**
184
+ * `sweny check` (#532): the CLI and its version (resolved and parsed the way
185
+ * {@link preflight} does), a credential ({@link codexAuth}), the CLI's own
186
+ * `codex login status`, and an upstream probe when an API key is set
187
+ * (`GET /models`: read-only, no model call, no tokens). A ChatGPT login or
188
+ * an access token has no cheap upstream probe, so that line is skipped.
189
+ * Nothing is cached and no credential value is printed.
190
+ */
191
+ diagnose(opts?: HarnessDiagnoseOptions): Promise<HarnessCheck[]>;
192
+ /**
193
+ * `codex login status`: exit 0 when a login is stored, non-zero when not
194
+ * (codex-rs/cli/src/login.rs). Its output can carry a masked key, so it is
195
+ * classified, never printed.
196
+ */
197
+ private loginStatus;
180
198
  /**
181
199
  * Env for the Codex process: scoped like Claude Code's, with Codex's auth
182
200
  * vars instead of Anthropic's, then the caller's `envRemove` / `env`
@@ -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, agentEnvOverlay, applyEnvOverlay, checkSandboxSupport, finishAgentEnv, resolveExecutable, overlaidProcessEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
31
+ import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, agentEnvOverlay, applyEnvOverlay, checkSandboxSupport, finishAgentEnv, resolveExecutable, heldCredentials, hasCodexLogin, 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";
@@ -39,6 +39,7 @@ import { startToolBridge } from "./tool-bridge/server.js";
39
39
  import { defaultSandboxWrapper, prepareAgentSpawn, wrappersFrom, } from "./sandbox-wrapper.js";
40
40
  import { TOKEN_ENV } from "./tool-bridge/protocol.js";
41
41
  import { codexAuth } from "./auth.js";
42
+ import { CHECK_TIMEOUT_MS, checkOpenAI, cliCheck, diagnoseEnv, runCli } from "./diagnose.js";
42
43
  export { CODEX_CAPABILITIES };
43
44
  /**
44
45
  * The credentials an `env` overlay replaces as a set ({@link applyEnvOverlay}):
@@ -342,20 +343,21 @@ export class CodexHarness {
342
343
  return auth.ok ? cli : { ok: false, reason: auth.reason };
343
344
  }
344
345
  /**
345
- * `process.env` with the caller's `env` / `envRemove` applied, unscoped: where
346
- * sweny reads Codex's own settings (`OPENAI_BASE_URL`) and the login probe.
347
- * The scoped agent env would drop an inherited base URL.
346
+ * `process.env` (or `base`, for `sweny check`) with the caller's `env` /
347
+ * `envRemove` applied, unscoped: where sweny reads Codex's own settings
348
+ * (`OPENAI_BASE_URL`) and the login probe. The scoped agent env would drop
349
+ * an inherited base URL.
348
350
  */
349
- hostEnv() {
350
- return overlaidProcessEnv(this.envOverlay, CODEX_CREDENTIAL_VARS);
351
+ hostEnv(base) {
352
+ return diagnoseEnv(base, this.envOverlay, CODEX_CREDENTIAL_VARS);
351
353
  }
352
354
  /**
353
355
  * {@link hostEnv} as the login probe should see it: an inherited
354
356
  * OPENAI_API_KEY does not count once `envRemove` names CODEX_API_KEY, since
355
357
  * the agent env no longer derives CODEX_API_KEY from it.
356
358
  */
357
- loginEnv() {
358
- const env = this.hostEnv();
359
+ loginEnv(base) {
360
+ const env = this.hostEnv(base);
359
361
  const overlay = this.envOverlay;
360
362
  if (overlay?.remove.includes("CODEX_API_KEY") && !Object.hasOwn(overlay.env, "OPENAI_API_KEY")) {
361
363
  delete env.OPENAI_API_KEY;
@@ -368,13 +370,32 @@ export class CodexHarness {
368
370
  }
369
371
  /** `codex --version`, checked against {@link MIN_CODEX_VERSION}. Cached per instance. */
370
372
  checkCli() {
371
- this.preflightResult ??= new Promise((resolve) => {
372
- const { command, args } = this.codexCommand;
373
+ this.preflightResult ??= (() => {
373
374
  // Probe the file the spawn will run: resolved on the run's PATH and cwd, then reused at spawn.
374
375
  const hostEnv = this.hostEnv();
375
- this.resolvedCommand = resolveExecutable(command, hostEnv, this.cwd);
376
+ this.resolvedCommand = resolveExecutable(this.codexCommand.command, hostEnv, this.cwd);
377
+ return this.probeVersion(this.resolvedCommand, hostEnv).then((res) => {
378
+ if (!res.ok)
379
+ return res;
380
+ this.version = res.version;
381
+ // Newer than the tested version: the declared capabilities may no longer hold. Warn once
382
+ // (the result is cached per instance). Not `degraded`: that is a per-node policy report and
383
+ // there is no node yet; not a refusal: it would break anyone who chose `latest` on purpose.
384
+ if (compareVersions(res.version, TESTED_CODEX_VERSION) > 0) {
385
+ this.logger.warn(`codex ${res.version} is newer than ${TESTED_CODEX_VERSION}, the version sweny's Codex capabilities were tested against; ` +
386
+ `flags and event shapes may differ. Pin @openai/codex@${TESTED_CODEX_VERSION} (action input codex-version) for the tested behavior.`);
387
+ }
388
+ return res;
389
+ });
390
+ })();
391
+ return this.preflightResult;
392
+ }
393
+ /** Run `<resolved> --version` with `hostEnv` and parse it. No caching, no side effects. */
394
+ probeVersion(resolved, hostEnv) {
395
+ const { command, args } = this.codexCommand;
396
+ return new Promise((resolve) => {
376
397
  const opts = { timeout: 30_000, cwd: this.cwd, env: hostEnv };
377
- execFile(this.resolvedCommand ?? command, [...args, "--version"], opts, (err, stdout) => {
398
+ execFile(resolved ?? command, [...args, "--version"], opts, (err, stdout) => {
378
399
  if (err) {
379
400
  const code = err.code;
380
401
  resolve({
@@ -394,18 +415,72 @@ export class CodexHarness {
394
415
  resolve({ ok: false, reason: `codex ${m[1]} is too old; sweny needs >= ${MIN_CODEX_VERSION}` });
395
416
  return;
396
417
  }
397
- this.version = m[1];
398
- // Newer than the tested version: the declared capabilities may no longer hold. Warn once
399
- // (the result is cached per instance). Not `degraded`: that is a per-node policy report and
400
- // there is no node yet; not a refusal: it would break anyone who chose `latest` on purpose.
401
- if (compareVersions(m[1], TESTED_CODEX_VERSION) > 0) {
402
- this.logger.warn(`codex ${m[1]} is newer than ${TESTED_CODEX_VERSION}, the version sweny's Codex capabilities were tested against; ` +
403
- `flags and event shapes may differ. Pin @openai/codex@${TESTED_CODEX_VERSION} (action input codex-version) for the tested behavior.`);
404
- }
405
418
  resolve({ ok: true, version: m[1] });
406
419
  });
407
420
  });
408
- return this.preflightResult;
421
+ }
422
+ /**
423
+ * `sweny check` (#532): the CLI and its version (resolved and parsed the way
424
+ * {@link preflight} does), a credential ({@link codexAuth}), the CLI's own
425
+ * `codex login status`, and an upstream probe when an API key is set
426
+ * (`GET /models`: read-only, no model call, no tokens). A ChatGPT login or
427
+ * an access token has no cheap upstream probe, so that line is skipped.
428
+ * Nothing is cached and no credential value is printed.
429
+ */
430
+ async diagnose(opts = {}) {
431
+ const hostEnv = this.hostEnv(opts.env);
432
+ const env = this.loginEnv(opts.env);
433
+ const timeoutMs = opts.timeoutMs ?? CHECK_TIMEOUT_MS;
434
+ const resolved = resolveExecutable(this.codexCommand.command, hostEnv, this.cwd);
435
+ const pre = await this.probeVersion(resolved, hostEnv);
436
+ const newer = pre.ok && compareVersions(pre.version, TESTED_CODEX_VERSION) > 0
437
+ ? `(newer than ${TESTED_CODEX_VERSION}, the version sweny is tested against)`
438
+ : undefined;
439
+ const checks = [cliCheck("Agent CLI (codex)", resolved, pre, newer)];
440
+ const auth = codexAuth(env, opts.hasLogin ?? (() => hasCodexLogin(env)));
441
+ checks.push(auth.ok
442
+ ? { name: "Agent auth (codex)", status: "ok", detail: `via ${auth.via}` }
443
+ : { name: "Agent auth (codex)", status: "fail", detail: auth.reason });
444
+ // No credential at all: the auth line already names `codex login`; asking the CLI adds nothing.
445
+ if (!auth.ok)
446
+ return checks;
447
+ if (pre.ok)
448
+ checks.push(await this.loginStatus(resolved, hostEnv, timeoutMs, auth.via));
449
+ const keyVar = ["CODEX_API_KEY", "OPENAI_API_KEY"].find((k) => env[k]?.trim());
450
+ checks.push(keyVar
451
+ ? await checkOpenAI(env[keyVar].trim(), keyVar, hostEnv.OPENAI_BASE_URL || undefined, timeoutMs)
452
+ : {
453
+ name: "OpenAI API (codex agent)",
454
+ status: "skip",
455
+ detail: `auth via ${auth.via}, skipping upstream API check`,
456
+ });
457
+ return checks;
458
+ }
459
+ /**
460
+ * `codex login status`: exit 0 when a login is stored, non-zero when not
461
+ * (codex-rs/cli/src/login.rs). Its output can carry a masked key, so it is
462
+ * classified, never printed.
463
+ */
464
+ async loginStatus(resolved, env, timeoutMs, via) {
465
+ const name = "Agent login (codex)";
466
+ const { command, args } = this.codexCommand;
467
+ const r = await runCli(resolved ?? command, [...args, "login", "status"], { env, cwd: this.cwd, timeoutMs });
468
+ if (r.code === 0) {
469
+ const out = `${r.stdout}\n${r.stderr}`;
470
+ const how = /ChatGPT/i.test(out) ? " with ChatGPT" : /API key/i.test(out) ? " with an API key" : "";
471
+ return { name, status: "ok", detail: `logged in${how} (codex login status)` };
472
+ }
473
+ if (r.code === null)
474
+ return { name, status: "fail", detail: `codex login status did not finish: ${r.error}` };
475
+ // Not logged in. Fine when an env credential carries auth instead.
476
+ if (via !== "codex login") {
477
+ return { name, status: "skip", detail: `no stored login (codex login status); Codex authenticates with ${via}` };
478
+ }
479
+ return {
480
+ name,
481
+ status: "fail",
482
+ detail: "codex login status: not logged in, though auth.json exists. Run `codex login` again",
483
+ };
409
484
  }
410
485
  /**
411
486
  * Env for the Codex process: scoped like Claude Code's, with Codex's auth
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Probes behind `AgentHarness.diagnose()` (#532), the agent lines of `sweny
3
+ * check`. Read-only and cheap: env and file presence, a CLI's own version or
4
+ * status command, and HTTP requests that list models (no model call, no
5
+ * tokens). Nothing here prints a credential value.
6
+ */
7
+ import { type AgentEnvOverlay } from "../agent-env.js";
8
+ import type { HarnessCheck } from "./types.js";
9
+ /** Per-probe timeout: a hung connect or CLI must not make `sweny check` hang. */
10
+ export declare const CHECK_TIMEOUT_MS = 5000;
11
+ /**
12
+ * The env a harness's checks read: `base` (default `process.env`) with the
13
+ * harness's `env` / `envRemove` overlay applied the way `preflight()` applies
14
+ * it (`overlaidProcessEnv` in agent-env.ts, which this matches for
15
+ * `process.env`), so `sweny check` sees the same PATH, HOME and credentials a
16
+ * run would. `SWENY_*` overlay names stay out, as there.
17
+ */
18
+ export declare function diagnoseEnv(base: Record<string, string | undefined> | undefined, overlay: AgentEnvOverlay | undefined, credentials: readonly string[]): Record<string, string>;
19
+ /** Redact a URL to scheme + host so userinfo / query (which can carry a key) never logs. */
20
+ export declare function redactUrl(raw: string): string;
21
+ export declare function networkErrorMessage(err: unknown, timeoutMs?: number): string;
22
+ /**
23
+ * The CLI line: the adapter's own version check (the same resolution and
24
+ * parse a run's `preflight()` does) plus the file it resolved to.
25
+ */
26
+ export declare function cliCheck(name: string, resolved: string | undefined, pre: {
27
+ ok: true;
28
+ version: string;
29
+ } | {
30
+ ok: false;
31
+ reason: string;
32
+ }, note?: string): HarnessCheck;
33
+ export interface CliRun {
34
+ /** Exit code; null when the process could not start or was killed. */
35
+ code: number | null;
36
+ stdout: string;
37
+ stderr: string;
38
+ /** Set when the process could not start (ENOENT) or timed out. */
39
+ error?: string;
40
+ }
41
+ /** Run a CLI subcommand with a timeout. Never throws; output is for parsing, not printing. */
42
+ export declare function runCli(command: string, args: string[], opts?: {
43
+ env?: Record<string, string | undefined>;
44
+ cwd?: string;
45
+ timeoutMs?: number;
46
+ }): Promise<CliRun>;
47
+ /** `GET <base>/models` with an OpenAI key (Codex). */
48
+ export declare function checkOpenAI(apiKey: string, envName: string, baseUrl?: string, timeoutMs?: number): Promise<HarnessCheck>;
49
+ /** Which credential `sweny check` should validate for Claude, mirroring resolveAuthEnv intent. */
50
+ export type CheckAuthMode = "oauth" | "api-key" | "auth-token" | "none";
51
+ export interface ClaudeCheckAuth {
52
+ anthropicApiKey: string;
53
+ anthropicAuthToken: string;
54
+ claudeOauthToken: string;
55
+ swenyAuth: "auto" | "api-key" | "oauth";
56
+ }
57
+ export declare function resolveCheckAuthMode(config: ClaudeCheckAuth): CheckAuthMode;
58
+ /** `GET https://api.anthropic.com/v1/models` with an API key. */
59
+ export declare function checkAnthropic(apiKey: string, timeoutMs?: number): Promise<HarnessCheck>;
60
+ /** Probe an Anthropic-compatible gateway, never real Anthropic with a gateway-bound key. */
61
+ export declare function checkAnthropicGateway(base: string, config: Pick<ClaudeCheckAuth, "anthropicApiKey" | "anthropicAuthToken" | "claudeOauthToken">, mode: CheckAuthMode, timeoutMs?: number): Promise<HarnessCheck>;
62
+ /**
63
+ * Claude Code's `sweny check` line, unchanged from before #532: a stored
64
+ * login, an upstream probe for an API key or a gateway, or a skip that names
65
+ * the auth mode.
66
+ */
67
+ export declare function diagnoseClaudeCode(opts: {
68
+ env: Record<string, string | undefined>;
69
+ hasLogin: () => boolean;
70
+ timeoutMs?: number;
71
+ }): Promise<HarnessCheck[]>;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Probes behind `AgentHarness.diagnose()` (#532), the agent lines of `sweny
3
+ * check`. Read-only and cheap: env and file presence, a CLI's own version or
4
+ * status command, and HTTP requests that list models (no model call, no
5
+ * tokens). Nothing here prints a credential value.
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import { applyEnvOverlay } from "../agent-env.js";
9
+ /** Per-probe timeout: a hung connect or CLI must not make `sweny check` hang. */
10
+ export const CHECK_TIMEOUT_MS = 5000;
11
+ /**
12
+ * The env a harness's checks read: `base` (default `process.env`) with the
13
+ * harness's `env` / `envRemove` overlay applied the way `preflight()` applies
14
+ * it (`overlaidProcessEnv` in agent-env.ts, which this matches for
15
+ * `process.env`), so `sweny check` sees the same PATH, HOME and credentials a
16
+ * run would. `SWENY_*` overlay names stay out, as there.
17
+ */
18
+ export function diagnoseEnv(base, overlay, credentials) {
19
+ const full = Object.fromEntries(Object.entries(base ?? process.env).filter((e) => e[1] != null));
20
+ if (!overlay)
21
+ return full;
22
+ const own = (n) => !n.startsWith("SWENY_");
23
+ return applyEnvOverlay(full, {
24
+ env: Object.fromEntries(Object.entries(overlay.env).filter(([k]) => own(k))),
25
+ remove: overlay.remove.filter(own),
26
+ }, credentials);
27
+ }
28
+ /** Redact a URL to scheme + host so userinfo / query (which can carry a key) never logs. */
29
+ export function redactUrl(raw) {
30
+ try {
31
+ const u = new URL(raw);
32
+ return `${u.protocol}//${u.host}`;
33
+ }
34
+ catch {
35
+ return "(invalid URL)";
36
+ }
37
+ }
38
+ export function networkErrorMessage(err, timeoutMs = CHECK_TIMEOUT_MS) {
39
+ // AbortSignal.timeout fires a DOMException with name "TimeoutError"; an
40
+ // explicit abort surfaces as "AbortError". Either means we gave up waiting.
41
+ const errName = err instanceof Error ? err.name : "";
42
+ if (errName === "TimeoutError" || errName === "AbortError") {
43
+ return `Connection timed out after ${timeoutMs}ms - host unreachable or too slow`;
44
+ }
45
+ const msg = err instanceof Error ? err.message : String(err);
46
+ if (/ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test(msg)) {
47
+ return `Network error - check your internet connection (${msg})`;
48
+ }
49
+ return msg;
50
+ }
51
+ /**
52
+ * The CLI line: the adapter's own version check (the same resolution and
53
+ * parse a run's `preflight()` does) plus the file it resolved to.
54
+ */
55
+ export function cliCheck(name, resolved, pre, note) {
56
+ if (!pre.ok)
57
+ return { name, status: "fail", detail: pre.reason };
58
+ return { name, status: "ok", detail: [pre.version, resolved && `(${resolved})`, note].filter(Boolean).join(" ") };
59
+ }
60
+ /** Run a CLI subcommand with a timeout. Never throws; output is for parsing, not printing. */
61
+ export function runCli(command, args, opts = {}) {
62
+ const env = Object.fromEntries(Object.entries(opts.env ?? process.env).filter((e) => e[1] != null));
63
+ return new Promise((resolve) => {
64
+ const execOpts = { env, cwd: opts.cwd, timeout: opts.timeoutMs ?? CHECK_TIMEOUT_MS };
65
+ execFile(command, args, execOpts, (err, stdout, stderr) => {
66
+ const out = { stdout: String(stdout ?? ""), stderr: String(stderr ?? "") };
67
+ if (!err)
68
+ return resolve({ code: 0, ...out });
69
+ const e = err;
70
+ if (typeof e.code === "number")
71
+ return resolve({ code: e.code, ...out });
72
+ resolve({
73
+ code: null,
74
+ ...out,
75
+ error: e.killed ? `timed out after ${opts.timeoutMs ?? CHECK_TIMEOUT_MS}ms` : e.message,
76
+ });
77
+ });
78
+ });
79
+ }
80
+ // ─── Upstream probes (list models: no model call, no tokens) ─────
81
+ /** `GET <base>/models` with an OpenAI key (Codex). */
82
+ export async function checkOpenAI(apiKey, envName, baseUrl = "https://api.openai.com/v1", timeoutMs = CHECK_TIMEOUT_MS) {
83
+ const name = "OpenAI API (codex agent)";
84
+ const safeBase = redactUrl(baseUrl);
85
+ try {
86
+ const res = await fetch(baseUrl.replace(/\/+$/, "") + "/models", {
87
+ headers: { Authorization: `Bearer ${apiKey}` },
88
+ signal: AbortSignal.timeout(timeoutMs),
89
+ });
90
+ if (res.status === 200)
91
+ return { name, status: "ok", detail: `${envName} is valid (${safeBase})` };
92
+ if (res.status === 401 || res.status === 403) {
93
+ return {
94
+ name,
95
+ status: "fail",
96
+ detail: `${res.status} from ${safeBase} - check ${envName} at https://platform.openai.com/api-keys`,
97
+ };
98
+ }
99
+ return { name, status: "fail", detail: `Unexpected HTTP ${res.status} from ${safeBase}` };
100
+ }
101
+ catch (err) {
102
+ return { name, status: "fail", detail: networkErrorMessage(err, timeoutMs) };
103
+ }
104
+ }
105
+ export function resolveCheckAuthMode(config) {
106
+ const hasKey = !!config.anthropicApiKey;
107
+ const hasBearer = !!config.anthropicAuthToken;
108
+ const hasOauth = !!config.claudeOauthToken;
109
+ if (config.swenyAuth === "oauth")
110
+ return hasOauth ? "oauth" : "none";
111
+ if (config.swenyAuth === "api-key")
112
+ return hasKey ? "api-key" : hasBearer ? "auth-token" : "none";
113
+ // auto: OAuth wins when present (today's protective behavior)
114
+ if (hasOauth)
115
+ return "oauth";
116
+ if (hasKey)
117
+ return "api-key";
118
+ if (hasBearer)
119
+ return "auth-token";
120
+ return "none";
121
+ }
122
+ /** `GET https://api.anthropic.com/v1/models` with an API key. */
123
+ export async function checkAnthropic(apiKey, timeoutMs = CHECK_TIMEOUT_MS) {
124
+ const name = "Anthropic (claude agent)";
125
+ try {
126
+ const res = await fetch("https://api.anthropic.com/v1/models", {
127
+ headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
128
+ signal: AbortSignal.timeout(timeoutMs),
129
+ });
130
+ if (res.status === 200) {
131
+ return { name, status: "ok", detail: "API key is valid" };
132
+ }
133
+ if (res.status === 401) {
134
+ return {
135
+ name,
136
+ status: "fail",
137
+ detail: "401 Unauthorized - check ANTHROPIC_API_KEY at https://console.anthropic.com",
138
+ };
139
+ }
140
+ return { name, status: "fail", detail: `Unexpected HTTP ${res.status}` };
141
+ }
142
+ catch (err) {
143
+ return { name, status: "fail", detail: networkErrorMessage(err, timeoutMs) };
144
+ }
145
+ }
146
+ /** Probe an Anthropic-compatible gateway, never real Anthropic with a gateway-bound key. */
147
+ export async function checkAnthropicGateway(base, config, mode, timeoutMs = CHECK_TIMEOUT_MS) {
148
+ const name = "Anthropic (gateway)";
149
+ const safeBase = redactUrl(base);
150
+ const headers = { "anthropic-version": "2023-06-01" };
151
+ if (mode === "api-key") {
152
+ headers["x-api-key"] = config.anthropicApiKey;
153
+ }
154
+ else {
155
+ // auth-token or oauth against the gateway
156
+ headers.Authorization = `Bearer ${config.anthropicAuthToken || config.claudeOauthToken}`;
157
+ }
158
+ try {
159
+ const url = base.replace(/\/+$/, "") + "/v1/models";
160
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
161
+ // Gateways vary on whether /v1/models exists; reachable + non-auth-error is good enough.
162
+ if (res.ok || res.status === 404) {
163
+ return { name, status: "ok", detail: `gateway reachable (${safeBase}), auth mode: ${mode}` };
164
+ }
165
+ if (res.status === 401 || res.status === 403) {
166
+ return { name, status: "fail", detail: `${res.status} from gateway ${safeBase}, check gateway credentials` };
167
+ }
168
+ return { name, status: "fail", detail: `Unexpected HTTP ${res.status} from gateway ${safeBase}` };
169
+ }
170
+ catch (err) {
171
+ return { name, status: "fail", detail: networkErrorMessage(err, timeoutMs) };
172
+ }
173
+ }
174
+ /**
175
+ * Claude Code's `sweny check` line, unchanged from before #532: a stored
176
+ * login, an upstream probe for an API key or a gateway, or a skip that names
177
+ * the auth mode.
178
+ */
179
+ export async function diagnoseClaudeCode(opts) {
180
+ const { env } = opts;
181
+ const raw = (env.SWENY_AUTH ?? "").trim().toLowerCase();
182
+ const auth = {
183
+ anthropicApiKey: env.ANTHROPIC_API_KEY || "",
184
+ anthropicAuthToken: env.ANTHROPIC_AUTH_TOKEN || "",
185
+ claudeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN || "",
186
+ swenyAuth: raw === "api-key" || raw === "oauth" ? raw : "auto",
187
+ };
188
+ const mode = resolveCheckAuthMode(auth);
189
+ const base = env.ANTHROPIC_BASE_URL || "";
190
+ const name = "Anthropic (claude agent)";
191
+ if (mode === "none" && opts.hasLogin())
192
+ return [{ name, status: "ok", detail: "Claude Code login detected" }];
193
+ if (mode === "none")
194
+ return [{ name, status: "skip", detail: "No credential configured" }];
195
+ if (base)
196
+ return [await checkAnthropicGateway(base, auth, mode, opts.timeoutMs)];
197
+ if (mode === "api-key")
198
+ return [await checkAnthropic(auth.anthropicApiKey, opts.timeoutMs)];
199
+ // oauth or auth-token, no base URL: nothing cheap to probe, report the mode.
200
+ return [{ name, status: "skip", detail: `auth mode: ${mode}, skipping upstream API check` }];
201
+ }
@@ -7,7 +7,7 @@ import { CodexHarness, type CodexHarnessOptions } from "./codex.js";
7
7
  import { AcpHarness, type AcpHarnessOptions } from "./acp.js";
8
8
  import { PiHarness, type PiHarnessOptions } from "./pi.js";
9
9
  export { claudeCompat, asClaude } from "./compat.js";
10
- export type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, } from "./types.js";
10
+ export type { AgentHarness, HarnessCapabilities, HarnessCheck, HarnessCompleteRequest, HarnessDiagnoseOptions, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, } from "./types.js";
11
11
  export { policyGate, budgetGate, nativeDenyClasses, resolveHarnessPolicy, isToolClass, TOOL_CLASSES, } from "./policy.js";
12
12
  export type { HarnessPolicyMode } from "./policy.js";
13
13
  export { ask, evaluate, buildAskPrompt, buildEvaluatePrompt, reportCompletionUsage } from "./prompts.js";
@@ -30,7 +30,7 @@
30
30
  */
31
31
  import type { Logger, McpServerConfig, NodeUsage, ToolContext, Tool } from "../types.js";
32
32
  import { type HarnessEnvOptions, type SandboxMode } from "../agent-env.js";
33
- import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
33
+ import type { AgentHarness, HarnessCapabilities, HarnessCheck, HarnessCompleteRequest, HarnessDiagnoseOptions, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
34
34
  import { PI_CAPABILITIES } from "./capabilities.js";
35
35
  import { type HarnessPolicyMode } from "./policy.js";
36
36
  import { type SandboxWrapper } from "./sandbox-wrapper.js";
@@ -186,6 +186,18 @@ export declare class PiHarness implements AgentHarness {
186
186
  ok: false;
187
187
  reason: string;
188
188
  }>;
189
+ /** Run `<resolved> --version` with `hostEnv` and parse it. No caching, no side effects. */
190
+ private probeVersion;
191
+ /**
192
+ * `sweny check` (#532): the CLI and its version (resolved and parsed the way
193
+ * {@link preflight} does), and the provider credential a run would hand pi
194
+ * ({@link resolvePiProvider}), or the custom `models.json`. pi runs without
195
+ * its stored `auth.json`, so env is the only source. No upstream probe: pi
196
+ * talks to any provider, and none has one cheap, provider-neutral check.
197
+ * Nothing is cached and no credential value is printed.
198
+ */
199
+ diagnose(opts?: HarnessDiagnoseOptions): Promise<HarnessCheck[]>;
200
+ private authCheck;
189
201
  /** Which provider's credential this call gets ({@link resolvePiProvider}), over the env with the overlay applied. */
190
202
  private resolveProvider;
191
203
  /**
@@ -41,6 +41,7 @@ import { gitCredentialPolicy } from "../git-credentials.js";
41
41
  import { buildNodePrompt, reportCompletionUsage } from "./prompts.js";
42
42
  import { makeAbort } from "./abort.js";
43
43
  import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
44
+ import { cliCheck, diagnoseEnv } from "./diagnose.js";
44
45
  import { startToolBridge } from "./tool-bridge/server.js";
45
46
  import { defaultSandboxWrapper, prepareAgentSpawn, wrappersFrom, } from "./sandbox-wrapper.js";
46
47
  import { TOKEN_ENV } from "./tool-bridge/protocol.js";
@@ -370,13 +371,24 @@ export class PiHarness {
370
371
  }
371
372
  /** `pi --version`, checked against {@link MIN_PI_VERSION}. Cached per instance. */
372
373
  preflight() {
373
- this.preflightResult ??= new Promise((resolve) => {
374
- const { command, args } = this.piCommand;
374
+ this.preflightResult ??= (() => {
375
375
  // Probe the file the spawn will run: resolved on the run's PATH and cwd, then reused at spawn.
376
376
  const hostEnv = overlaidProcessEnv(this.envOverlay, PI_CREDENTIAL_VARS);
377
- this.resolvedCommand = resolveExecutable(command, hostEnv, this.cwd);
377
+ this.resolvedCommand = resolveExecutable(this.piCommand.command, hostEnv, this.cwd);
378
+ return this.probeVersion(this.resolvedCommand, hostEnv).then((res) => {
379
+ if (res.ok)
380
+ this.version = res.version;
381
+ return res;
382
+ });
383
+ })();
384
+ return this.preflightResult;
385
+ }
386
+ /** Run `<resolved> --version` with `hostEnv` and parse it. No caching, no side effects. */
387
+ probeVersion(resolved, hostEnv) {
388
+ const { command, args } = this.piCommand;
389
+ return new Promise((resolve) => {
378
390
  const opts = { timeout: 30_000, cwd: this.cwd, env: hostEnv };
379
- execFile(this.resolvedCommand ?? command, [...args, "--version"], opts, (err, stdout, stderr) => {
391
+ execFile(resolved ?? command, [...args, "--version"], opts, (err, stdout, stderr) => {
380
392
  if (err) {
381
393
  const code = err.code;
382
394
  resolve({
@@ -397,11 +409,54 @@ export class PiHarness {
397
409
  resolve({ ok: false, reason: `pi ${m[1]} is too old; sweny needs >= ${MIN_PI_VERSION}` });
398
410
  return;
399
411
  }
400
- this.version = m[1];
401
412
  resolve({ ok: true, version: m[1] });
402
413
  });
403
414
  });
404
- return this.preflightResult;
415
+ }
416
+ /**
417
+ * `sweny check` (#532): the CLI and its version (resolved and parsed the way
418
+ * {@link preflight} does), and the provider credential a run would hand pi
419
+ * ({@link resolvePiProvider}), or the custom `models.json`. pi runs without
420
+ * its stored `auth.json`, so env is the only source. No upstream probe: pi
421
+ * talks to any provider, and none has one cheap, provider-neutral check.
422
+ * Nothing is cached and no credential value is printed.
423
+ */
424
+ async diagnose(opts = {}) {
425
+ const env = diagnoseEnv(opts.env, this.envOverlay, PI_CREDENTIAL_VARS);
426
+ const resolved = resolveExecutable(this.piCommand.command, env, this.cwd);
427
+ const cli = cliCheck("Agent CLI (pi)", resolved, await this.probeVersion(resolved, env));
428
+ return [cli, this.authCheck(env)];
429
+ }
430
+ authCheck(env) {
431
+ const name = "Agent auth (pi)";
432
+ const res = resolvePiProvider(env, this.model, this.provider);
433
+ if (res.error)
434
+ return { name, status: "fail", detail: res.error };
435
+ const set = res.vars.filter((v) => env[v]?.trim());
436
+ const provider = res.provider ? `provider ${res.provider}` : "provider";
437
+ if (set.length > 0)
438
+ return { name, status: "ok", detail: `${provider} via ${set.join(", ")}` };
439
+ if (this.modelsFile) {
440
+ return fs.existsSync(this.modelsFile)
441
+ ? { name, status: "ok", detail: `custom models from ${this.modelsFile} (SWENY_PI_MODELS_JSON)` }
442
+ : { name, status: "fail", detail: `SWENY_PI_MODELS_JSON points to a missing file: ${this.modelsFile}` };
443
+ }
444
+ if (res.provider && res.vars.length > 0) {
445
+ return { name, status: "fail", detail: `Missing: ${res.vars.join(" or ")} for pi ${provider}` };
446
+ }
447
+ if (res.provider) {
448
+ return {
449
+ name,
450
+ status: "skip",
451
+ detail: `${provider}: sweny does not know its credential; pi reports a missing one`,
452
+ };
453
+ }
454
+ return {
455
+ name,
456
+ status: "fail",
457
+ detail: "Missing: a model provider credential for pi, such as ANTHROPIC_API_KEY or OPENAI_API_KEY " +
458
+ "(pi runs without its stored auth.json), or SWENY_PI_MODELS_JSON for a custom provider",
459
+ };
405
460
  }
406
461
  /** Which provider's credential this call gets ({@link resolvePiProvider}), over the env with the overlay applied. */
407
462
  resolveProvider(model) {
@@ -172,6 +172,24 @@ export interface HarnessCompleteRequest {
172
172
  */
173
173
  onUsage?: (usage: NodeUsage) => void;
174
174
  }
175
+ /**
176
+ * One line of `sweny check` for the agent (#532): the CLI and its version, a
177
+ * credential, a stored login, an upstream probe. `detail` names credentials,
178
+ * never their values.
179
+ */
180
+ export interface HarnessCheck {
181
+ name: string;
182
+ status: "ok" | "fail" | "skip";
183
+ detail: string;
184
+ }
185
+ export interface HarnessDiagnoseOptions {
186
+ /** Where credentials are read from (default: `process.env`). */
187
+ env?: Record<string, string | undefined>;
188
+ /** Timeout for each probe (default: 5000 ms). */
189
+ timeoutMs?: number;
190
+ /** Stored-login probe override (Claude Code, Codex). Default: the adapter's own file probe. */
191
+ hasLogin?: () => boolean;
192
+ }
175
193
  export interface AgentHarness {
176
194
  readonly id: HarnessId;
177
195
  readonly capabilities: HarnessCapabilities;
@@ -186,6 +204,13 @@ export interface AgentHarness {
186
204
  ok: false;
187
205
  reason: string;
188
206
  }>;
207
+ /**
208
+ * `sweny check` (#532): each thing this agent needs, checked and reported
209
+ * line by line. Read-only and cheap: CLI version and status commands, env
210
+ * and file presence, and an upstream request only where one costs no model
211
+ * tokens. Never a model call. Optional; `sweny check` reports its absence.
212
+ */
213
+ diagnose?(opts?: HarnessDiagnoseOptions): Promise<HarnessCheck[]>;
189
214
  run(req: HarnessRunRequest): Promise<HarnessRunResult>;
190
215
  /** One completion, no tools, no MCP. null = failed (callers fail closed). */
191
216
  complete(req: HarnessCompleteRequest): Promise<string | null>;