@gobing-ai/ts-ai-runner 0.3.21 → 0.4.1
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/agents/auth-shims.d.ts +47 -0
- package/dist/agents/auth-shims.d.ts.map +1 -0
- package/dist/agents/auth-shims.js +120 -0
- package/dist/ai-runner.d.ts +2 -0
- package/dist/ai-runner.d.ts.map +1 -1
- package/dist/ai-runner.js +1 -0
- package/dist/doctor-runner.d.ts +14 -10
- package/dist/doctor-runner.d.ts.map +1 -1
- package/dist/doctor-runner.js +14 -88
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/package.json +4 -4
- package/src/agents/auth-shims.ts +153 -0
- package/src/ai-runner.ts +3 -0
- package/src/doctor-runner.ts +28 -94
- package/src/index.ts +1 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type FileSystem } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import type { AiRunner } from '../ai-runner';
|
|
3
|
+
import { type AgentName, getAgentShim } from './shims';
|
|
4
|
+
/**
|
|
5
|
+
* Authentication state for a coding agent — **tri-state by design**.
|
|
6
|
+
*
|
|
7
|
+
* `unknown` is first-class: several agents have no reliable auth probe
|
|
8
|
+
* (no auth verb, no checkable credential file). Collapsing `unknown` to
|
|
9
|
+
* `unauthenticated` is exactly the false-negative that used to make
|
|
10
|
+
* `antigravity-cli` (no auth verb) and inconclusive probes report
|
|
11
|
+
* `usable: false` despite being runnable.
|
|
12
|
+
*
|
|
13
|
+
* Auth detection is **off the execution critical path**: {@link isAuthenticated}
|
|
14
|
+
* never feeds run-readiness — it is operator information only. A genuinely
|
|
15
|
+
* unauthenticated agent fails at runtime with its own error (auth is the
|
|
16
|
+
* agent's concern).
|
|
17
|
+
*/
|
|
18
|
+
export type AuthState = 'authenticated' | 'unauthenticated' | 'unknown';
|
|
19
|
+
/** Context injected into {@link isAuthenticated}. */
|
|
20
|
+
export interface AuthContext {
|
|
21
|
+
/** Runner used to execute auth probes. */
|
|
22
|
+
runner: AiRunner;
|
|
23
|
+
/** Environment map for env/file auth checks. Defaults to the process env. */
|
|
24
|
+
env?: Record<string, string | undefined>;
|
|
25
|
+
/** Filesystem for auth-file checks. Required — inject a fake in tests. */
|
|
26
|
+
fileSystem: FileSystem;
|
|
27
|
+
/** Auth probe timeout in milliseconds. */
|
|
28
|
+
timeout?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a coding agent's authentication state.
|
|
32
|
+
*
|
|
33
|
+
* **Never throws.** Any probe failure, inconclusive output, or missing probe
|
|
34
|
+
* resolves to `unknown` rather than `unauthenticated` — see {@link AuthState}.
|
|
35
|
+
*
|
|
36
|
+
* Auth sources, in priority order per agent:
|
|
37
|
+
* - **gemini** — credential-like content in `~/.gemini/settings.json`.
|
|
38
|
+
* - **codex** — `codex login status` CLI output, falling back to an
|
|
39
|
+
* `~/.codex/auth{.json,}` credential file.
|
|
40
|
+
* - **pi / omp** — a non-empty `GOOGLE_API_KEY` or `ANTHROPIC_API_KEY` in env
|
|
41
|
+
* (an empty export is not a usable credential), else the CLI auth probe.
|
|
42
|
+
* - **others** — the shim's auth command; matched against {@link AUTH_PATTERNS}.
|
|
43
|
+
* - **no probe** (e.g. `antigravity-cli`) ⇒ `unknown`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isAuthenticated(agent: AgentName, ctx: AuthContext): Promise<AuthState>;
|
|
46
|
+
export { getAgentShim };
|
|
47
|
+
//# sourceMappingURL=auth-shims.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-shims.d.ts","sourceRoot":"","sources":["../../src/agents/auth-shims.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,UAAU,EAA2B,MAAM,uBAAuB,CAAC;AACjF,OAAO,KAAK,EAAmC,QAAQ,EAAE,MAAM,cAAc,CAAC;AAC9E,OAAO,EAAE,KAAK,SAAS,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvD;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,SAAS,GAAG,eAAe,GAAG,iBAAiB,GAAG,SAAS,CAAC;AAExE,qDAAqD;AACrD,MAAM,WAAW,WAAW;IACxB,0CAA0C;IAC1C,MAAM,EAAE,QAAQ,CAAC;IACjB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,0EAA0E;IAC1E,UAAU,EAAE,UAAU,CAAC;IACvB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;CACpB;AA6CD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CAgB5F;AA8CD,OAAO,EAAE,YAAY,EAAE,CAAC"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { getProcessEnv, joinPath } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import { getAgentShim } from './shims.js';
|
|
3
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 5_000;
|
|
4
|
+
/**
|
|
5
|
+
* Per-agent auth-probe output patterns. A positive match ⇒ authenticated;
|
|
6
|
+
* a negative match ⇒ unauthenticated; neither (or no probe) ⇒ unknown.
|
|
7
|
+
*/
|
|
8
|
+
const AUTH_PATTERNS = {
|
|
9
|
+
claude: {
|
|
10
|
+
positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
|
|
11
|
+
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
|
|
12
|
+
},
|
|
13
|
+
codex: {
|
|
14
|
+
positive: /logged[\s_-]*in|authenticated/i,
|
|
15
|
+
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
|
|
16
|
+
},
|
|
17
|
+
opencode: {
|
|
18
|
+
positive: /configured|available/i,
|
|
19
|
+
negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
|
|
20
|
+
},
|
|
21
|
+
openclaw: {
|
|
22
|
+
positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
|
|
23
|
+
negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
|
|
24
|
+
},
|
|
25
|
+
pi: {
|
|
26
|
+
positive: /\S/,
|
|
27
|
+
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
|
|
28
|
+
},
|
|
29
|
+
omp: {
|
|
30
|
+
positive: /\S/,
|
|
31
|
+
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
|
|
32
|
+
},
|
|
33
|
+
hermes: {
|
|
34
|
+
positive: /(^|[^a-z])ok([^a-z]|$)|healthy|configured|ready/i,
|
|
35
|
+
negative: /not[\s_-]*(configured|healthy|ok)|unhealthy|missing[\s_-]+dependenc|error|failed/i,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
/** True when a value is a defined, non-blank string. */
|
|
39
|
+
function isNonEmpty(value) {
|
|
40
|
+
return value !== undefined && value.trim().length > 0;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a coding agent's authentication state.
|
|
44
|
+
*
|
|
45
|
+
* **Never throws.** Any probe failure, inconclusive output, or missing probe
|
|
46
|
+
* resolves to `unknown` rather than `unauthenticated` — see {@link AuthState}.
|
|
47
|
+
*
|
|
48
|
+
* Auth sources, in priority order per agent:
|
|
49
|
+
* - **gemini** — credential-like content in `~/.gemini/settings.json`.
|
|
50
|
+
* - **codex** — `codex login status` CLI output, falling back to an
|
|
51
|
+
* `~/.codex/auth{.json,}` credential file.
|
|
52
|
+
* - **pi / omp** — a non-empty `GOOGLE_API_KEY` or `ANTHROPIC_API_KEY` in env
|
|
53
|
+
* (an empty export is not a usable credential), else the CLI auth probe.
|
|
54
|
+
* - **others** — the shim's auth command; matched against {@link AUTH_PATTERNS}.
|
|
55
|
+
* - **no probe** (e.g. `antigravity-cli`) ⇒ `unknown`.
|
|
56
|
+
*/
|
|
57
|
+
export async function isAuthenticated(agent, ctx) {
|
|
58
|
+
const env = ctx.env ?? getProcessEnv();
|
|
59
|
+
const fs = ctx.fileSystem;
|
|
60
|
+
const timeout = ctx.timeout ?? DEFAULT_AUTH_TIMEOUT_MS;
|
|
61
|
+
const home = env.HOME || env.USERPROFILE || '';
|
|
62
|
+
if (agent === 'gemini')
|
|
63
|
+
return geminiSettingsContainCredentials(fs, home);
|
|
64
|
+
if (agent === 'codex')
|
|
65
|
+
return checkCodexAuth(ctx.runner, fs, home, timeout);
|
|
66
|
+
// pi and omp read provider keys from the environment; require a non-empty
|
|
67
|
+
// value rather than mere presence (an empty export is not a usable credential).
|
|
68
|
+
if ((agent === 'pi' || agent === 'omp') && (isNonEmpty(env.GOOGLE_API_KEY) || isNonEmpty(env.ANTHROPIC_API_KEY))) {
|
|
69
|
+
return 'authenticated';
|
|
70
|
+
}
|
|
71
|
+
return probeAuthOutput(ctx.runner, agent, timeout);
|
|
72
|
+
}
|
|
73
|
+
async function checkCodexAuth(runner, fs, home, timeout) {
|
|
74
|
+
const probeStatus = await probeAuthOutput(runner, 'codex', timeout);
|
|
75
|
+
if (probeStatus !== 'unknown')
|
|
76
|
+
return probeStatus;
|
|
77
|
+
const hasFile = (await hasNonEmptyFile(fs, joinPath(home, '.codex', 'auth.json'))) ||
|
|
78
|
+
(await hasNonEmptyFile(fs, joinPath(home, '.codex', 'auth')));
|
|
79
|
+
return hasFile ? 'authenticated' : 'unknown';
|
|
80
|
+
}
|
|
81
|
+
async function geminiSettingsContainCredentials(fs, home) {
|
|
82
|
+
try {
|
|
83
|
+
const content = await fs.readFile(joinPath(home, '.gemini', 'settings.json'));
|
|
84
|
+
return /auth|token|key/i.test(content) ? 'authenticated' : 'unauthenticated';
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Missing or unreadable settings — auth state genuinely unknown.
|
|
88
|
+
return 'unknown';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function probeAuthOutput(runner, agent, timeout) {
|
|
92
|
+
const options = { timeout };
|
|
93
|
+
const command = runner.runAuthCommand(agent, options);
|
|
94
|
+
const patterns = AUTH_PATTERNS[agent];
|
|
95
|
+
if (command === null || patterns === undefined)
|
|
96
|
+
return 'unknown';
|
|
97
|
+
try {
|
|
98
|
+
const result = await command;
|
|
99
|
+
if (result.exitCode !== 0)
|
|
100
|
+
return 'unauthenticated';
|
|
101
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
102
|
+
if (patterns.negative.test(output))
|
|
103
|
+
return 'unauthenticated';
|
|
104
|
+
if (patterns.positive.test(output))
|
|
105
|
+
return 'authenticated';
|
|
106
|
+
return 'unknown';
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return 'unknown';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** True when the path exists and has a non-zero size. */
|
|
113
|
+
async function hasNonEmptyFile(fs, path) {
|
|
114
|
+
const stat = await fs.stat(path);
|
|
115
|
+
if (stat === null)
|
|
116
|
+
return false;
|
|
117
|
+
return stat.isFile() && stat.size > 0;
|
|
118
|
+
}
|
|
119
|
+
// Re-exported so callers that only need the auth facet don't import getAgentShim directly.
|
|
120
|
+
export { getAgentShim };
|
package/dist/ai-runner.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface AgentRunOptions {
|
|
|
21
21
|
cwd?: string;
|
|
22
22
|
/** Timeout in milliseconds. */
|
|
23
23
|
timeout?: number;
|
|
24
|
+
/** AbortSignal forwarded to ProcessExecutor — aborts the agent subprocess when fired. */
|
|
25
|
+
signal?: AbortSignal;
|
|
24
26
|
}
|
|
25
27
|
/** Constructor options for AiRunner. */
|
|
26
28
|
export interface AiRunnerOptions {
|
package/dist/ai-runner.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-runner.d.ts","sourceRoot":"","sources":["../src/ai-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAa,KAAK,MAAM,EAAc,MAAM,qBAAqB,CAAC;AACxF,OAAO,EAGH,KAAK,eAAe,EAEpB,KAAK,UAAU,EAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,KAAK,SAAS,EAAgB,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AACpG,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAInE,0DAA0D;AAC1D,MAAM,WAAW,cAAc;IAC3B,uEAAuE;IACvE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,uBAAuB;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,uBAAuB;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAC;CACtB;AAED,sCAAsC;AACtC,MAAM,WAAW,eAAe;IAC5B,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"ai-runner.d.ts","sourceRoot":"","sources":["../src/ai-runner.ts"],"names":[],"mappings":"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"}
|
package/dist/ai-runner.js
CHANGED
|
@@ -71,6 +71,7 @@ export class AiRunner {
|
|
|
71
71
|
forceBuffered,
|
|
72
72
|
cwd: options.cwd ?? this.defaultCwd,
|
|
73
73
|
timeout: options.timeout ?? this.defaultTimeout,
|
|
74
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
74
75
|
});
|
|
75
76
|
if (result.exitCode !== 0) {
|
|
76
77
|
this.logger.error('invoke exited non-zero', {
|
package/dist/doctor-runner.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Logger } from '@gobing-ai/ts-infra';
|
|
2
2
|
import { type FileSystem } from '@gobing-ai/ts-runtime';
|
|
3
3
|
import { AgentDetector } from './agent-detector';
|
|
4
|
+
import { type AuthState } from './agents/auth-shims';
|
|
4
5
|
import { type AgentName } from './agents/shims';
|
|
5
6
|
import { AiRunner } from './ai-runner';
|
|
6
7
|
/** Health-check result for one coding agent. */
|
|
@@ -11,9 +12,18 @@ export interface DoctorResult {
|
|
|
11
12
|
installed: boolean;
|
|
12
13
|
/** Human-readable version when installed. */
|
|
13
14
|
version: string | null;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Authentication state — **tri-state** (`authenticated | unauthenticated |
|
|
17
|
+
* unknown`). Informational only; never feeds {@link usable} or any
|
|
18
|
+
* run-readiness gate. A genuinely unauthenticated agent fails at runtime
|
|
19
|
+
* with its own error.
|
|
20
|
+
*/
|
|
21
|
+
authenticated: AuthState;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the agent is **runnable** — liveness only
|
|
24
|
+
* (`installed && version !== null`). Auth is NOT consulted: a logged-out
|
|
25
|
+
* agent is still `usable` (it will fail at runtime with its own auth error).
|
|
26
|
+
*/
|
|
17
27
|
usable: boolean;
|
|
18
28
|
/** Capability tier: 1 = direct CLI, 2 = gateway/TUI constrained. */
|
|
19
29
|
tier: 1 | 2;
|
|
@@ -41,7 +51,7 @@ export interface DoctorRunnerOptions {
|
|
|
41
51
|
/** Filesystem for auth-file checks. Defaults to `createNodeFileSystem()`; inject to test without disk. */
|
|
42
52
|
fileSystem?: FileSystem;
|
|
43
53
|
}
|
|
44
|
-
/** Runs installation and auth health checks for supported coding agents. */
|
|
54
|
+
/** Runs installation, liveness, and auth health checks for supported coding agents. */
|
|
45
55
|
export declare class DoctorRunner {
|
|
46
56
|
private readonly detector;
|
|
47
57
|
private readonly runner;
|
|
@@ -55,11 +65,5 @@ export declare class DoctorRunner {
|
|
|
55
65
|
/** Run a health check on one agent. */
|
|
56
66
|
runOne(agent: string): Promise<DoctorResult>;
|
|
57
67
|
private buildResult;
|
|
58
|
-
private checkAuth;
|
|
59
|
-
private checkCodexAuth;
|
|
60
|
-
private geminiSettingsContainCredentials;
|
|
61
|
-
private probeAuthOutput;
|
|
62
|
-
/** True when the path exists and has a non-zero size. */
|
|
63
|
-
private hasNonEmptyFile;
|
|
64
68
|
}
|
|
65
69
|
//# sourceMappingURL=doctor-runner.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"doctor-runner.d.ts","sourceRoot":"","sources":["../src/doctor-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAwB,KAAK,UAAU,
|
|
1
|
+
{"version":3,"file":"doctor-runner.d.ts","sourceRoot":"","sources":["../src/doctor-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAwB,KAAK,UAAU,EAAiB,MAAM,uBAAuB,CAAC;AAC7F,OAAO,EAAE,aAAa,EAAsB,MAAM,kBAAkB,CAAC;AACrE,OAAO,EAAE,KAAK,SAAS,EAAmB,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,KAAK,SAAS,EAAiD,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC,gDAAgD;AAChD,MAAM,WAAW,YAAY;IACzB,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,SAAS,EAAE,OAAO,CAAC;IACnB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB;;;;;OAKG;IACH,aAAa,EAAE,SAAS,CAAC;IACzB;;;;OAIG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB,oEAAoE;IACpE,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC;IACZ,wDAAwD;IACxD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gEAAgE;IAChE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wEAAwE;IACxE,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB,oCAAoC;IACpC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,4CAA4C;AAC5C,MAAM,WAAW,mBAAmB;IAChC,uBAAuB;IACvB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,qBAAqB;IACrB,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0GAA0G;IAC1G,UAAU,CAAC,EAAE,UAAU,CAAC;CAC3B;AAID,uFAAuF;AACvF,qBAAa,YAAY;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAqC;IACzD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAa;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,OAAO,GAAE,mBAAwB;IAS7C,kDAAkD;IAC5C,MAAM,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;IAkBvC,uCAAuC;IACjC,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;YAIpC,WAAW;CA4B5B"}
|
package/dist/doctor-runner.js
CHANGED
|
@@ -1,44 +1,11 @@
|
|
|
1
1
|
import { getLogger } from '@gobing-ai/ts-infra';
|
|
2
|
-
import { createNodeFileSystem, getProcessEnv
|
|
2
|
+
import { createNodeFileSystem, getProcessEnv } from '@gobing-ai/ts-runtime';
|
|
3
3
|
import { AgentDetector } from './agent-detector.js';
|
|
4
|
+
import { isAuthenticated } from './agents/auth-shims.js';
|
|
4
5
|
import { DISPLAY_ORDER, resolveAgentName, TIER2_AGENTS } from './agents/shims.js';
|
|
5
6
|
import { AiRunner } from './ai-runner.js';
|
|
6
7
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
7
|
-
|
|
8
|
-
claude: {
|
|
9
|
-
positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
|
|
10
|
-
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
|
|
11
|
-
},
|
|
12
|
-
codex: {
|
|
13
|
-
positive: /logged[\s_-]*in|authenticated/i,
|
|
14
|
-
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
|
|
15
|
-
},
|
|
16
|
-
opencode: {
|
|
17
|
-
positive: /configured|available/i,
|
|
18
|
-
negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
|
|
19
|
-
},
|
|
20
|
-
openclaw: {
|
|
21
|
-
positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
|
|
22
|
-
negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
|
|
23
|
-
},
|
|
24
|
-
pi: {
|
|
25
|
-
positive: /\S/,
|
|
26
|
-
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
|
|
27
|
-
},
|
|
28
|
-
omp: {
|
|
29
|
-
positive: /\S/,
|
|
30
|
-
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
|
|
31
|
-
},
|
|
32
|
-
hermes: {
|
|
33
|
-
positive: /(^|[^a-z])ok([^a-z]|$)|healthy|configured|ready/i,
|
|
34
|
-
negative: /not[\s_-]*(configured|healthy|ok)|unhealthy|missing[\s_-]+dependenc|error|failed/i,
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
/** True when a value is a defined, non-blank string. */
|
|
38
|
-
function isNonEmpty(value) {
|
|
39
|
-
return value !== undefined && value.trim().length > 0;
|
|
40
|
-
}
|
|
41
|
-
/** Runs installation and auth health checks for supported coding agents. */
|
|
8
|
+
/** Runs installation, liveness, and auth health checks for supported coding agents. */
|
|
42
9
|
export class DoctorRunner {
|
|
43
10
|
detector;
|
|
44
11
|
runner;
|
|
@@ -74,13 +41,22 @@ export class DoctorRunner {
|
|
|
74
41
|
const canonical = resolveAgentName(detected.name) ?? null;
|
|
75
42
|
const tier = canonical !== null && TIER2_AGENTS.has(canonical) ? 2 : 1;
|
|
76
43
|
this.logger.debug('checking agent', { agent: detected.name, installed: detected.installed, tier });
|
|
77
|
-
const authenticated = detected.installed && canonical !== null
|
|
44
|
+
const authenticated = detected.installed && canonical !== null
|
|
45
|
+
? await isAuthenticated(canonical, {
|
|
46
|
+
runner: this.runner,
|
|
47
|
+
env: this.env,
|
|
48
|
+
fileSystem: this.fs,
|
|
49
|
+
timeout: this.timeout,
|
|
50
|
+
})
|
|
51
|
+
: 'unauthenticated';
|
|
78
52
|
return {
|
|
79
53
|
agent: detected.name,
|
|
80
54
|
installed: detected.installed,
|
|
81
55
|
version: detected.version,
|
|
82
56
|
authenticated,
|
|
83
|
-
|
|
57
|
+
// Liveness only: auth never gates runnability. A logged-out agent
|
|
58
|
+
// is usable (it fails at runtime with its own error).
|
|
59
|
+
usable: detected.installed && detected.version !== null,
|
|
84
60
|
tier,
|
|
85
61
|
channels: detected.channels,
|
|
86
62
|
deprecated: detected.deprecated,
|
|
@@ -88,54 +64,4 @@ export class DoctorRunner {
|
|
|
88
64
|
error: detected.error,
|
|
89
65
|
};
|
|
90
66
|
}
|
|
91
|
-
async checkAuth(agent) {
|
|
92
|
-
const home = this.env.HOME || this.env.USERPROFILE || '';
|
|
93
|
-
if (agent === 'gemini')
|
|
94
|
-
return this.geminiSettingsContainCredentials(home);
|
|
95
|
-
if (agent === 'codex')
|
|
96
|
-
return this.checkCodexAuth(home);
|
|
97
|
-
// pi and omp read provider keys from the environment; require a non-empty
|
|
98
|
-
// value rather than mere presence (an empty export is not a usable credential).
|
|
99
|
-
if ((agent === 'pi' || agent === 'omp') &&
|
|
100
|
-
(isNonEmpty(this.env.GOOGLE_API_KEY) || isNonEmpty(this.env.ANTHROPIC_API_KEY)))
|
|
101
|
-
return true;
|
|
102
|
-
return (await this.probeAuthOutput(agent)) === true;
|
|
103
|
-
}
|
|
104
|
-
async checkCodexAuth(home) {
|
|
105
|
-
const probeStatus = await this.probeAuthOutput('codex');
|
|
106
|
-
if (probeStatus !== null)
|
|
107
|
-
return probeStatus;
|
|
108
|
-
return ((await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth.json'))) ||
|
|
109
|
-
(await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth'))));
|
|
110
|
-
}
|
|
111
|
-
async geminiSettingsContainCredentials(home) {
|
|
112
|
-
try {
|
|
113
|
-
return /auth|token|key/i.test(await this.fs.readFile(joinPath(home, '.gemini', 'settings.json')));
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
async probeAuthOutput(agent) {
|
|
120
|
-
const command = this.runner.runAuthCommand(agent, { timeout: this.timeout });
|
|
121
|
-
const patterns = AUTH_PATTERNS[agent];
|
|
122
|
-
if (command === null || patterns === undefined)
|
|
123
|
-
return null;
|
|
124
|
-
const result = await command;
|
|
125
|
-
if (result.exitCode !== 0)
|
|
126
|
-
return false;
|
|
127
|
-
const output = `${result.stdout}\n${result.stderr}`;
|
|
128
|
-
if (patterns.negative.test(output))
|
|
129
|
-
return false;
|
|
130
|
-
if (patterns.positive.test(output))
|
|
131
|
-
return true;
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
/** True when the path exists and has a non-zero size. */
|
|
135
|
-
async hasNonEmptyFile(path) {
|
|
136
|
-
const stat = await this.fs.stat(path);
|
|
137
|
-
if (stat === null)
|
|
138
|
-
return false;
|
|
139
|
-
return stat.isFile() && stat.size > 0;
|
|
140
|
-
}
|
|
141
67
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC"}
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-ai-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "@gobing-ai/ts-ai-runner — Coding-agent shims, detection, doctor checks, and prompt execution.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-ai-runner-v<version> && git push --tags' && exit 1"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@gobing-ai/ts-db": "^0.
|
|
51
|
-
"@gobing-ai/ts-infra": "^0.
|
|
52
|
-
"@gobing-ai/ts-runtime": "^0.
|
|
50
|
+
"@gobing-ai/ts-db": "^0.4.1",
|
|
51
|
+
"@gobing-ai/ts-infra": "^0.4.1",
|
|
52
|
+
"@gobing-ai/ts-runtime": "^0.4.1"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@types/bun": "1.3.14"
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { type FileSystem, getProcessEnv, joinPath } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import type { AgentRunOptions, AgentRunResult, AiRunner } from '../ai-runner';
|
|
3
|
+
import { type AgentName, getAgentShim } from './shims';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Authentication state for a coding agent — **tri-state by design**.
|
|
7
|
+
*
|
|
8
|
+
* `unknown` is first-class: several agents have no reliable auth probe
|
|
9
|
+
* (no auth verb, no checkable credential file). Collapsing `unknown` to
|
|
10
|
+
* `unauthenticated` is exactly the false-negative that used to make
|
|
11
|
+
* `antigravity-cli` (no auth verb) and inconclusive probes report
|
|
12
|
+
* `usable: false` despite being runnable.
|
|
13
|
+
*
|
|
14
|
+
* Auth detection is **off the execution critical path**: {@link isAuthenticated}
|
|
15
|
+
* never feeds run-readiness — it is operator information only. A genuinely
|
|
16
|
+
* unauthenticated agent fails at runtime with its own error (auth is the
|
|
17
|
+
* agent's concern).
|
|
18
|
+
*/
|
|
19
|
+
export type AuthState = 'authenticated' | 'unauthenticated' | 'unknown';
|
|
20
|
+
|
|
21
|
+
/** Context injected into {@link isAuthenticated}. */
|
|
22
|
+
export interface AuthContext {
|
|
23
|
+
/** Runner used to execute auth probes. */
|
|
24
|
+
runner: AiRunner;
|
|
25
|
+
/** Environment map for env/file auth checks. Defaults to the process env. */
|
|
26
|
+
env?: Record<string, string | undefined>;
|
|
27
|
+
/** Filesystem for auth-file checks. Required — inject a fake in tests. */
|
|
28
|
+
fileSystem: FileSystem;
|
|
29
|
+
/** Auth probe timeout in milliseconds. */
|
|
30
|
+
timeout?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 5_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Per-agent auth-probe output patterns. A positive match ⇒ authenticated;
|
|
37
|
+
* a negative match ⇒ unauthenticated; neither (or no probe) ⇒ unknown.
|
|
38
|
+
*/
|
|
39
|
+
const AUTH_PATTERNS: Partial<Record<AgentName, { positive: RegExp; negative: RegExp }>> = {
|
|
40
|
+
claude: {
|
|
41
|
+
positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
|
|
42
|
+
negative:
|
|
43
|
+
/not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
|
|
44
|
+
},
|
|
45
|
+
codex: {
|
|
46
|
+
positive: /logged[\s_-]*in|authenticated/i,
|
|
47
|
+
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
|
|
48
|
+
},
|
|
49
|
+
opencode: {
|
|
50
|
+
positive: /configured|available/i,
|
|
51
|
+
negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
|
|
52
|
+
},
|
|
53
|
+
openclaw: {
|
|
54
|
+
positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
|
|
55
|
+
negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
|
|
56
|
+
},
|
|
57
|
+
pi: {
|
|
58
|
+
positive: /\S/,
|
|
59
|
+
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
|
|
60
|
+
},
|
|
61
|
+
omp: {
|
|
62
|
+
positive: /\S/,
|
|
63
|
+
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
|
|
64
|
+
},
|
|
65
|
+
hermes: {
|
|
66
|
+
positive: /(^|[^a-z])ok([^a-z]|$)|healthy|configured|ready/i,
|
|
67
|
+
negative: /not[\s_-]*(configured|healthy|ok)|unhealthy|missing[\s_-]+dependenc|error|failed/i,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** True when a value is a defined, non-blank string. */
|
|
72
|
+
function isNonEmpty(value: string | undefined): boolean {
|
|
73
|
+
return value !== undefined && value.trim().length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a coding agent's authentication state.
|
|
78
|
+
*
|
|
79
|
+
* **Never throws.** Any probe failure, inconclusive output, or missing probe
|
|
80
|
+
* resolves to `unknown` rather than `unauthenticated` — see {@link AuthState}.
|
|
81
|
+
*
|
|
82
|
+
* Auth sources, in priority order per agent:
|
|
83
|
+
* - **gemini** — credential-like content in `~/.gemini/settings.json`.
|
|
84
|
+
* - **codex** — `codex login status` CLI output, falling back to an
|
|
85
|
+
* `~/.codex/auth{.json,}` credential file.
|
|
86
|
+
* - **pi / omp** — a non-empty `GOOGLE_API_KEY` or `ANTHROPIC_API_KEY` in env
|
|
87
|
+
* (an empty export is not a usable credential), else the CLI auth probe.
|
|
88
|
+
* - **others** — the shim's auth command; matched against {@link AUTH_PATTERNS}.
|
|
89
|
+
* - **no probe** (e.g. `antigravity-cli`) ⇒ `unknown`.
|
|
90
|
+
*/
|
|
91
|
+
export async function isAuthenticated(agent: AgentName, ctx: AuthContext): Promise<AuthState> {
|
|
92
|
+
const env = ctx.env ?? getProcessEnv();
|
|
93
|
+
const fs = ctx.fileSystem;
|
|
94
|
+
const timeout = ctx.timeout ?? DEFAULT_AUTH_TIMEOUT_MS;
|
|
95
|
+
const home = env.HOME || env.USERPROFILE || '';
|
|
96
|
+
|
|
97
|
+
if (agent === 'gemini') return geminiSettingsContainCredentials(fs, home);
|
|
98
|
+
if (agent === 'codex') return checkCodexAuth(ctx.runner, fs, home, timeout);
|
|
99
|
+
|
|
100
|
+
// pi and omp read provider keys from the environment; require a non-empty
|
|
101
|
+
// value rather than mere presence (an empty export is not a usable credential).
|
|
102
|
+
if ((agent === 'pi' || agent === 'omp') && (isNonEmpty(env.GOOGLE_API_KEY) || isNonEmpty(env.ANTHROPIC_API_KEY))) {
|
|
103
|
+
return 'authenticated';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return probeAuthOutput(ctx.runner, agent, timeout);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function checkCodexAuth(runner: AiRunner, fs: FileSystem, home: string, timeout: number): Promise<AuthState> {
|
|
110
|
+
const probeStatus = await probeAuthOutput(runner, 'codex', timeout);
|
|
111
|
+
if (probeStatus !== 'unknown') return probeStatus;
|
|
112
|
+
const hasFile =
|
|
113
|
+
(await hasNonEmptyFile(fs, joinPath(home, '.codex', 'auth.json'))) ||
|
|
114
|
+
(await hasNonEmptyFile(fs, joinPath(home, '.codex', 'auth')));
|
|
115
|
+
return hasFile ? 'authenticated' : 'unknown';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function geminiSettingsContainCredentials(fs: FileSystem, home: string): Promise<AuthState> {
|
|
119
|
+
try {
|
|
120
|
+
const content = await fs.readFile(joinPath(home, '.gemini', 'settings.json'));
|
|
121
|
+
return /auth|token|key/i.test(content) ? 'authenticated' : 'unauthenticated';
|
|
122
|
+
} catch {
|
|
123
|
+
// Missing or unreadable settings — auth state genuinely unknown.
|
|
124
|
+
return 'unknown';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function probeAuthOutput(runner: AiRunner, agent: AgentName, timeout: number): Promise<AuthState> {
|
|
129
|
+
const options: AgentRunOptions = { timeout };
|
|
130
|
+
const command: Promise<AgentRunResult> | null = runner.runAuthCommand(agent, options);
|
|
131
|
+
const patterns = AUTH_PATTERNS[agent];
|
|
132
|
+
if (command === null || patterns === undefined) return 'unknown';
|
|
133
|
+
try {
|
|
134
|
+
const result = await command;
|
|
135
|
+
if (result.exitCode !== 0) return 'unauthenticated';
|
|
136
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
137
|
+
if (patterns.negative.test(output)) return 'unauthenticated';
|
|
138
|
+
if (patterns.positive.test(output)) return 'authenticated';
|
|
139
|
+
return 'unknown';
|
|
140
|
+
} catch {
|
|
141
|
+
return 'unknown';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True when the path exists and has a non-zero size. */
|
|
146
|
+
async function hasNonEmptyFile(fs: FileSystem, path: string): Promise<boolean> {
|
|
147
|
+
const stat = await fs.stat(path);
|
|
148
|
+
if (stat === null) return false;
|
|
149
|
+
return stat.isFile() && stat.size > 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Re-exported so callers that only need the auth facet don't import getAgentShim directly.
|
|
153
|
+
export { getAgentShim };
|
package/src/ai-runner.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface AgentRunOptions {
|
|
|
31
31
|
cwd?: string;
|
|
32
32
|
/** Timeout in milliseconds. */
|
|
33
33
|
timeout?: number;
|
|
34
|
+
/** AbortSignal forwarded to ProcessExecutor — aborts the agent subprocess when fired. */
|
|
35
|
+
signal?: AbortSignal;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/** Constructor options for AiRunner. */
|
|
@@ -142,6 +144,7 @@ export class AiRunner {
|
|
|
142
144
|
forceBuffered,
|
|
143
145
|
cwd: options.cwd ?? this.defaultCwd,
|
|
144
146
|
timeout: options.timeout ?? this.defaultTimeout,
|
|
147
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
145
148
|
});
|
|
146
149
|
if (result.exitCode !== 0) {
|
|
147
150
|
this.logger.error('invoke exited non-zero', {
|
package/src/doctor-runner.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { getLogger, type Logger } from '@gobing-ai/ts-infra';
|
|
2
|
-
import { createNodeFileSystem, type FileSystem, getProcessEnv
|
|
2
|
+
import { createNodeFileSystem, type FileSystem, getProcessEnv } from '@gobing-ai/ts-runtime';
|
|
3
3
|
import { AgentDetector, type DetectedAgent } from './agent-detector';
|
|
4
|
+
import { type AuthState, isAuthenticated } from './agents/auth-shims';
|
|
4
5
|
import { type AgentName, DISPLAY_ORDER, resolveAgentName, TIER2_AGENTS } from './agents/shims';
|
|
5
|
-
import {
|
|
6
|
+
import { AiRunner } from './ai-runner';
|
|
6
7
|
|
|
7
8
|
/** Health-check result for one coding agent. */
|
|
8
9
|
export interface DoctorResult {
|
|
@@ -12,9 +13,18 @@ export interface DoctorResult {
|
|
|
12
13
|
installed: boolean;
|
|
13
14
|
/** Human-readable version when installed. */
|
|
14
15
|
version: string | null;
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Authentication state — **tri-state** (`authenticated | unauthenticated |
|
|
18
|
+
* unknown`). Informational only; never feeds {@link usable} or any
|
|
19
|
+
* run-readiness gate. A genuinely unauthenticated agent fails at runtime
|
|
20
|
+
* with its own error.
|
|
21
|
+
*/
|
|
22
|
+
authenticated: AuthState;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the agent is **runnable** — liveness only
|
|
25
|
+
* (`installed && version !== null`). Auth is NOT consulted: a logged-out
|
|
26
|
+
* agent is still `usable` (it will fail at runtime with its own auth error).
|
|
27
|
+
*/
|
|
18
28
|
usable: boolean;
|
|
19
29
|
/** Capability tier: 1 = direct CLI, 2 = gateway/TUI constrained. */
|
|
20
30
|
tier: 1 | 2;
|
|
@@ -45,44 +55,8 @@ export interface DoctorRunnerOptions {
|
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
48
|
-
const AUTH_PATTERNS: Partial<Record<AgentName, { positive: RegExp; negative: RegExp }>> = {
|
|
49
|
-
claude: {
|
|
50
|
-
positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
|
|
51
|
-
negative:
|
|
52
|
-
/not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
|
|
53
|
-
},
|
|
54
|
-
codex: {
|
|
55
|
-
positive: /logged[\s_-]*in|authenticated/i,
|
|
56
|
-
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
|
|
57
|
-
},
|
|
58
|
-
opencode: {
|
|
59
|
-
positive: /configured|available/i,
|
|
60
|
-
negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
|
|
61
|
-
},
|
|
62
|
-
openclaw: {
|
|
63
|
-
positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
|
|
64
|
-
negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
|
|
65
|
-
},
|
|
66
|
-
pi: {
|
|
67
|
-
positive: /\S/,
|
|
68
|
-
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
|
|
69
|
-
},
|
|
70
|
-
omp: {
|
|
71
|
-
positive: /\S/,
|
|
72
|
-
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
|
|
73
|
-
},
|
|
74
|
-
hermes: {
|
|
75
|
-
positive: /(^|[^a-z])ok([^a-z]|$)|healthy|configured|ready/i,
|
|
76
|
-
negative: /not[\s_-]*(configured|healthy|ok)|unhealthy|missing[\s_-]+dependenc|error|failed/i,
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
58
|
|
|
80
|
-
/**
|
|
81
|
-
function isNonEmpty(value: string | undefined): boolean {
|
|
82
|
-
return value !== undefined && value.trim().length > 0;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Runs installation and auth health checks for supported coding agents. */
|
|
59
|
+
/** Runs installation, liveness, and auth health checks for supported coding agents. */
|
|
86
60
|
export class DoctorRunner {
|
|
87
61
|
private readonly detector: AgentDetector;
|
|
88
62
|
private readonly runner: AiRunner;
|
|
@@ -128,13 +102,23 @@ export class DoctorRunner {
|
|
|
128
102
|
const canonical = resolveAgentName(detected.name) ?? null;
|
|
129
103
|
const tier = canonical !== null && TIER2_AGENTS.has(canonical) ? 2 : 1;
|
|
130
104
|
this.logger.debug('checking agent', { agent: detected.name, installed: detected.installed, tier });
|
|
131
|
-
const authenticated =
|
|
105
|
+
const authenticated: AuthState =
|
|
106
|
+
detected.installed && canonical !== null
|
|
107
|
+
? await isAuthenticated(canonical, {
|
|
108
|
+
runner: this.runner,
|
|
109
|
+
env: this.env,
|
|
110
|
+
fileSystem: this.fs,
|
|
111
|
+
timeout: this.timeout,
|
|
112
|
+
})
|
|
113
|
+
: 'unauthenticated';
|
|
132
114
|
return {
|
|
133
115
|
agent: detected.name,
|
|
134
116
|
installed: detected.installed,
|
|
135
117
|
version: detected.version,
|
|
136
118
|
authenticated,
|
|
137
|
-
|
|
119
|
+
// Liveness only: auth never gates runnability. A logged-out agent
|
|
120
|
+
// is usable (it fails at runtime with its own error).
|
|
121
|
+
usable: detected.installed && detected.version !== null,
|
|
138
122
|
tier,
|
|
139
123
|
channels: detected.channels,
|
|
140
124
|
deprecated: detected.deprecated,
|
|
@@ -142,54 +126,4 @@ export class DoctorRunner {
|
|
|
142
126
|
error: detected.error,
|
|
143
127
|
};
|
|
144
128
|
}
|
|
145
|
-
|
|
146
|
-
private async checkAuth(agent: AgentName): Promise<boolean> {
|
|
147
|
-
const home = this.env.HOME || this.env.USERPROFILE || '';
|
|
148
|
-
if (agent === 'gemini') return this.geminiSettingsContainCredentials(home);
|
|
149
|
-
if (agent === 'codex') return this.checkCodexAuth(home);
|
|
150
|
-
// pi and omp read provider keys from the environment; require a non-empty
|
|
151
|
-
// value rather than mere presence (an empty export is not a usable credential).
|
|
152
|
-
if (
|
|
153
|
-
(agent === 'pi' || agent === 'omp') &&
|
|
154
|
-
(isNonEmpty(this.env.GOOGLE_API_KEY) || isNonEmpty(this.env.ANTHROPIC_API_KEY))
|
|
155
|
-
)
|
|
156
|
-
return true;
|
|
157
|
-
return (await this.probeAuthOutput(agent)) === true;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
private async checkCodexAuth(home: string): Promise<boolean> {
|
|
161
|
-
const probeStatus = await this.probeAuthOutput('codex');
|
|
162
|
-
if (probeStatus !== null) return probeStatus;
|
|
163
|
-
return (
|
|
164
|
-
(await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth.json'))) ||
|
|
165
|
-
(await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth')))
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private async geminiSettingsContainCredentials(home: string): Promise<boolean> {
|
|
170
|
-
try {
|
|
171
|
-
return /auth|token|key/i.test(await this.fs.readFile(joinPath(home, '.gemini', 'settings.json')));
|
|
172
|
-
} catch {
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
private async probeAuthOutput(agent: AgentName): Promise<boolean | null> {
|
|
178
|
-
const command = this.runner.runAuthCommand(agent, { timeout: this.timeout });
|
|
179
|
-
const patterns = AUTH_PATTERNS[agent];
|
|
180
|
-
if (command === null || patterns === undefined) return null;
|
|
181
|
-
const result: AgentRunResult = await command;
|
|
182
|
-
if (result.exitCode !== 0) return false;
|
|
183
|
-
const output = `${result.stdout}\n${result.stderr}`;
|
|
184
|
-
if (patterns.negative.test(output)) return false;
|
|
185
|
-
if (patterns.positive.test(output)) return true;
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** True when the path exists and has a non-zero size. */
|
|
190
|
-
private async hasNonEmptyFile(path: string): Promise<boolean> {
|
|
191
|
-
const stat = await this.fs.stat(path);
|
|
192
|
-
if (stat === null) return false;
|
|
193
|
-
return stat.isFile() && stat.size > 0;
|
|
194
|
-
}
|
|
195
129
|
}
|