Merge branch 'pr-18' into pr-delivery-74-94-18-39-41
# Conflicts: # .gitignore # src/agent/local-agents.ts
This commit is contained in:
6 files changed
+123
-51
No files matched your search
@@ -63,3 +63,5 @@ scripts/.export-denylist.local
|
|||||||
.aiwg/*
|
.aiwg/*
|
||||||
!.aiwg/addons/
|
!.aiwg/addons/
|
||||||
!.aiwg/addons/**
|
!.aiwg/addons/**
|
||||||
|
|
||||||
|
server.pid
|
||||||
@@ -164,7 +164,7 @@ The framework is an 8-operator kill chain, and this table won't blow smoke about
|
|||||||
| Re-derivable measurement (`verify-claims`) | ✅ Stable | every headline recomputes from committed artifacts |
|
| Re-derivable measurement (`verify-claims`) | ✅ Stable | every headline recomputes from committed artifacts |
|
||||||
| Recon engine | ✅ Stable | drives nmap / DNS / HTTP / fingerprinting; every finding traces to real tool output |
|
| Recon engine | ✅ Stable | drives nmap / DNS / HTTP / fingerprinting; every finding traces to real tool output |
|
||||||
| Mission engine + War Room + Op Admiral | ✅ Stable | keyless through a connected local agent |
|
| Mission engine + War Room + Op Admiral | ✅ Stable | keyless through a connected local agent |
|
||||||
| Arsenal, MCP server, HTTP API | ✅ Stable | 35 built-in tools by default; 83 with the opt-in `T3MP3ST_FULL_ARSENAL` (+48 adapters, with the dangerous post-ex drivers — metasploit, hydra — behind a human-approval gate) — both counts re-derive via `verify-claims`. `security_recon` over MCP |
|
| Arsenal, MCP server, HTTP API | ✅ Stable | 35 built-in tools by default; 102 with the opt-in `T3MP3ST_FULL_ARSENAL` (+67 adapters, with dangerous/catalog-only drivers — metasploit, hydra, pacu, frida — behind narrow approved paths rather than generic execution) — both counts re-derive via `verify-claims`. `security_recon` over MCP |
|
||||||
| Egress-scope containment | ✅ Stable (on by default) | once a mission target is set, built-in networked tools refuse off-scope public hosts — not the target/subdomains, not loopback/private (`SCOPE DENIED`) — a tightened default, not a bare tool runner |
|
| Egress-scope containment | ✅ Stable (on by default) | once a mission target is set, built-in networked tools refuse off-scope public hosts — not the target/subdomains, not loopback/private (`SCOPE DENIED`) — a tightened default, not a bare tool runner |
|
||||||
| Coordinated-disclosure pipeline | ✅ Stable | OSV novelty + live PoC + refuter panel + CVSS; drafts only, a human sends |
|
| Coordinated-disclosure pipeline | ✅ Stable | OSV novelty + live PoC + refuter panel + CVSS; drafts only, a human sends |
|
||||||
| White-box source analysis | ⚠️ Experimental | Python-only regex ingest; multi-model decomposition costs more tokens, not fewer |
|
| White-box source analysis | ⚠️ Experimental | Python-only regex ingest; multi-model decomposition costs more tokens, not fewer |
|
||||||
|
|||||||
@@ -159,9 +159,9 @@ check('VERIFY gate implemented', /VERIFY gate|never appeared in tool output/.tes
|
|||||||
check('REFLECT gate implemented', /REFLECT gate/.test(bench), 'forced mid-run pivot');
|
check('REFLECT gate implemented', /REFLECT gate/.test(bench), 'forced mid-run pivot');
|
||||||
|
|
||||||
// ── CLAIM 4: capability breadth ─────────────────────────────────────────────
|
// ── CLAIM 4: capability breadth ─────────────────────────────────────────────
|
||||||
console.log('\nCLAIM 4 — capability: 83 tools, 8-operator kill-chain');
|
console.log('\nCLAIM 4 — capability: 102 tools, 8-operator kill-chain');
|
||||||
// DISTINCT tools = external-binary adapters (catalog.ts TOOL_ADAPTERS id:) + custom
|
// DISTINCT tools = external-binary adapters (catalog.ts TOOL_ADAPTERS id:) + custom
|
||||||
// built-in tools (index.ts BUILTIN_TOOLS top-level name:). NOT a name:/id: regex count
|
// built-in/external tools (index.ts top-level name:). NOT a name:/id: regex count
|
||||||
// (that double-counts each tool's id+name AND every parameter name).
|
// (that double-counts each tool's id+name AND every parameter name).
|
||||||
const catalogSrc = fs.existsSync(R('src/arsenal/catalog.ts')) ? fs.readFileSync(R('src/arsenal/catalog.ts'), 'utf8') : '';
|
const catalogSrc = fs.existsSync(R('src/arsenal/catalog.ts')) ? fs.readFileSync(R('src/arsenal/catalog.ts'), 'utf8') : '';
|
||||||
const indexSrc = fs.existsSync(R('src/arsenal/index.ts')) ? fs.readFileSync(R('src/arsenal/index.ts'), 'utf8') : '';
|
const indexSrc = fs.existsSync(R('src/arsenal/index.ts')) ? fs.readFileSync(R('src/arsenal/index.ts'), 'utf8') : '';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// ARSENAL COUNT HONESTY — the "advertised = real, and nothing is silently dead".
|
// ARSENAL COUNT HONESTY — the "advertised = real, and nothing is silently dead".
|
||||||
//
|
//
|
||||||
// The arsenal advertises "83 tools". verify-claims defends that number by counting
|
// The arsenal advertises "102 tools". verify-claims defends that number by counting
|
||||||
// `id:`/`name:` source lines — a SOURCE-LINE count, which would keep passing even if
|
// `id:`/`name:` source lines — a SOURCE-LINE count, which would keep passing even if
|
||||||
// an entry became uncallable. This test locks the number to the REAL registered arrays
|
// an entry became uncallable. This test locks the number to the REAL registered arrays
|
||||||
// and, more importantly, pins the invariant that makes the count honest:
|
// and, more importantly, pins the invariant that makes the count honest:
|
||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
// is EXPLICITLY gated by its execution mode (catalog_only / import_only).
|
// is EXPLICITLY gated by its execution mode (catalog_only / import_only).
|
||||||
//
|
//
|
||||||
// i.e. a tool can be counted-but-not-generically-callable ONLY on purpose (metasploit /
|
// i.e. a tool can be counted-but-not-generically-callable ONLY on purpose (metasploit /
|
||||||
// hydra reachable solely via the approval-gated post-ex drivers; bloodhound import-only),
|
// hydra reachable solely via the approval-gated post-ex drivers; bloodhound import-only;
|
||||||
|
// pacu/frida catalog-only until narrow approved runners exist),
|
||||||
// never by a silent DEFAULT/mint slip. Sibling of no-phantom-tools / stub-honesty.
|
// never by a silent DEFAULT/mint slip. Sibling of no-phantom-tools / stub-honesty.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -33,16 +34,16 @@ const isGated = (a: ToolAdapter) =>
|
|||||||
a.execution === 'catalog_only' || a.execution === 'import_only';
|
a.execution === 'catalog_only' || a.execution === 'import_only';
|
||||||
|
|
||||||
describe('arsenal count honesty (advertised = real registered surface)', () => {
|
describe('arsenal count honesty (advertised = real registered surface)', () => {
|
||||||
it('the advertised "83 tools" is the real registered surface, not a source-line count', () => {
|
it('the advertised "102 tools" is the real registered surface, not a source-line count', () => {
|
||||||
const total = TOOL_ADAPTERS.length + BUILTIN_TOOLS.length + EXTERNAL_TOOLS.length;
|
const total = TOOL_ADAPTERS.length + BUILTIN_TOOLS.length + EXTERNAL_TOOLS.length;
|
||||||
// Locks the headline to code. If the arsenal grows/shrinks, update this AND the
|
// Locks the headline to code. If the arsenal grows/shrinks, update this AND the
|
||||||
// README / verify-claims headline together — that is the point of the lock.
|
// README / verify-claims headline together — that is the point of the lock.
|
||||||
expect(
|
expect(
|
||||||
total,
|
total,
|
||||||
`arsenal size drifted from the advertised 83 (adapters=${TOOL_ADAPTERS.length}, ` +
|
`arsenal size drifted from the advertised 102 (adapters=${TOOL_ADAPTERS.length}, ` +
|
||||||
`built-ins=${BUILTIN_TOOLS.length}, externals=${EXTERNAL_TOOLS.length}) — ` +
|
`built-ins=${BUILTIN_TOOLS.length}, externals=${EXTERNAL_TOOLS.length}) — ` +
|
||||||
'update the README / verify-claims headline to match',
|
'update the README / verify-claims headline to match',
|
||||||
).toBe(83);
|
).toBe(102);
|
||||||
expect(total).toBeGreaterThanOrEqual(80); // stays consistent with verify-claims' `>= 80` gate
|
expect(total).toBeGreaterThanOrEqual(80); // stays consistent with verify-claims' `>= 80` gate
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,11 +61,12 @@ describe('arsenal count honesty (advertised = real registered surface)', () => {
|
|||||||
expect(wronglyGated, `gated adapters that are still generically callable: ${wronglyGated.join(', ')}`).toEqual([]);
|
expect(wronglyGated, `gated adapters that are still generically callable: ${wronglyGated.join(', ')}`).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the gated set is exactly the documented post-ex / import tools (metasploit, hydra, bloodhound)', () => {
|
it('the gated set is exactly the documented post-ex / import tools', () => {
|
||||||
const gated = TOOL_ADAPTERS.filter(isGated);
|
const gated = TOOL_ADAPTERS.filter(isGated);
|
||||||
// metasploit + hydra are catalog_only (callable only via the approval-gated hand-written
|
// metasploit + hydra are catalog_only (callable only via the approval-gated hand-written
|
||||||
// post-ex drivers); bloodhound is import_only (collector output is imported, never run here).
|
// post-ex drivers); pacu/frida are catalog_only pending narrow approved runners; bloodhound is
|
||||||
expect(gated.map((a) => a.id).sort()).toEqual(['bloodhound', 'hydra', 'metasploit']);
|
// import_only (collector output is imported, never run here).
|
||||||
|
expect(gated.map((a) => a.id).sort()).toEqual(['bloodhound', 'frida', 'hydra', 'metasploit', 'pacu']);
|
||||||
expect(TOOL_ADAPTERS.find((a) => a.id === 'bloodhound')?.execution).toBe('import_only');
|
expect(TOOL_ADAPTERS.find((a) => a.id === 'bloodhound')?.execution).toBe('import_only');
|
||||||
// Every gated adapter is genuinely off the generic surface.
|
// Every gated adapter is genuinely off the generic surface.
|
||||||
for (const a of gated) {
|
for (const a of gated) {
|
||||||
|
|||||||
@@ -11,9 +11,8 @@
|
|||||||
* These tests pin the resolver in isolation, the detector wiring, AND the two spawn call-sites
|
* These tests pin the resolver in isolation, the detector wiring, AND the two spawn call-sites
|
||||||
* (runLocalAgent / localAgentChat) the fix rewired — all through the public API.
|
* (runLocalAgent / localAgentChat) the fix rewired — all through the public API.
|
||||||
*
|
*
|
||||||
* POSIX-only by design: `resolveBin` short-circuits on win32 (Windows resolution — where.exe /
|
* POSIX-focused, with a small Windows boundary assertion: unresolved Windows shims fail closed
|
||||||
* .cmd shims — is PR #18's domain, deliberately not duplicated here). The win32 case is asserted
|
* instead of returning a bare name that would bypass the safe .cmd/.bat launch path.
|
||||||
* via a platform stub so the boundary is documented and locked.
|
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs';
|
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs';
|
||||||
@@ -155,7 +154,7 @@ describe('resolveBin — macOS/POSIX well-known-dir resolution (issue #78)', ()
|
|||||||
expect(resolveBin('definitely-not-a-real-cli-xyz')).toBe('definitely-not-a-real-cli-xyz');
|
expect(resolveBin('definitely-not-a-real-cli-xyz')).toBe('definitely-not-a-real-cli-xyz');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT scan on win32 — returns the bare name (Windows resolution is PR #18\'s domain)', () => {
|
it('does NOT scan POSIX dirs on win32 — unresolved shims fail closed', () => {
|
||||||
const home = scratch();
|
const home = scratch();
|
||||||
putExe(join(home, '.local', 'bin'), 'faketool');
|
putExe(join(home, '.local', 'bin'), 'faketool');
|
||||||
process.env.T3MP3ST_AGENT_HOME = home;
|
process.env.T3MP3ST_AGENT_HOME = home;
|
||||||
@@ -163,7 +162,7 @@ describe('resolveBin — macOS/POSIX well-known-dir resolution (issue #78)', ()
|
|||||||
const orig = Object.getOwnPropertyDescriptor(process, 'platform') || { value: process.platform, configurable: true };
|
const orig = Object.getOwnPropertyDescriptor(process, 'platform') || { value: process.platform, configurable: true };
|
||||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
try {
|
try {
|
||||||
expect(resolveBin('faketool')).toBe('faketool');
|
expect(resolveBin('faketool')).toBeUndefined();
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(process, 'platform', orig);
|
Object.defineProperty(process, 'platform', orig);
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-35
@@ -53,6 +53,16 @@ export function agentHome(): string {
|
|||||||
}
|
}
|
||||||
const expand = (p: string): string => (p.startsWith('~') ? agentHome() + p.slice(1) : p);
|
const expand = (p: string): string => (p.startsWith('~') ? agentHome() + p.slice(1) : p);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a path under the OS-native per-user local app-data root, cross-platform:
|
||||||
|
* - Windows: %LOCALAPPDATA% (e.g. C:\Users\<u>\AppData\Local), where the Hermes desktop app
|
||||||
|
* stores its runtime auth under %LOCALAPPDATA%\hermes\ rather than ~/.hermes/.
|
||||||
|
* - POSIX: <agentHome>/AppData/Local fallback (harmless: the artifact simply won't exist there).
|
||||||
|
* PRESENCE-ONLY: callers pass the result to existsSync(); contents are never read.
|
||||||
|
*/
|
||||||
|
const localAppData = (rel: string): string =>
|
||||||
|
join(process.env.LOCALAPPDATA || join(agentHome(), 'AppData', 'Local'), ...rel.split('/'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Env for a spawned agent CLI. Two adjustments to our own env:
|
* Env for a spawned agent CLI. Two adjustments to our own env:
|
||||||
* - strip the injected provider keys so the CLI falls back to its OWN native login (not t3mp3st's).
|
* - strip the injected provider keys so the CLI falls back to its OWN native login (not t3mp3st's).
|
||||||
@@ -147,7 +157,10 @@ const SPECS: AgentSpec[] = [
|
|||||||
invokeHint: 'hermes -z "<prompt>" (--yolo only if T3MP3ST_HERMES_YOLO=1)',
|
invokeHint: 'hermes -z "<prompt>" (--yolo only if T3MP3ST_HERMES_YOLO=1)',
|
||||||
versionArgs: ['--version'],
|
versionArgs: ['--version'],
|
||||||
parseVersion: (o) => (o.match(/v([\d]+\.[\d]+(\.[\d]+)?)/)?.[1]) || (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
|
parseVersion: (o) => (o.match(/v([\d]+\.[\d]+(\.[\d]+)?)/)?.[1]) || (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
|
||||||
authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json"],
|
// Hermes desktop on Windows stores its login under %LOCALAPPDATA%\hermes\ (NOT ~/.hermes/), so
|
||||||
|
// checking only ~/.hermes/ made an authed Windows install read as NOT AUTHED. Presence-only check;
|
||||||
|
// ~/.hermes/auth.json is upstream's POSIX/mac auth artifact, kept alongside the Windows paths.
|
||||||
|
authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json", localAppData('hermes/.env'), localAppData('hermes/auth.json')],
|
||||||
oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])],
|
oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -182,6 +195,10 @@ export interface AgentDetection {
|
|||||||
ready: boolean; // installed && authed
|
ready: boolean; // installed && authed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWin32 = (): boolean => process.platform === 'win32';
|
||||||
|
// Kept as a constructed RegExp so the source never embeds a raw CRLF inside a regex literal.
|
||||||
|
const NEWLINE_RE = new RegExp('\\r?\\n');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated POSIX install dirs an agent CLI commonly lands in, under the REAL agent home (issue #78).
|
* Curated POSIX install dirs an agent CLI commonly lands in, under the REAL agent home (issue #78).
|
||||||
*
|
*
|
||||||
@@ -221,16 +238,28 @@ function wellKnownBinDirs(home: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Absolute path to `bin`, resolved WITHOUT a shell against PATH plus the well-known install dirs
|
* Absolute path to `bin`, resolved WITHOUT an operator-controlled shell:
|
||||||
* (issue #78). Fail-open: an unresolved name is returned unchanged so `execFile`/`spawn` still throw
|
* - Windows: where.exe honors PATHEXT, preferring .exe > .cmd > .bat > first hit.
|
||||||
* the same ENOENT (→ `installed:false`) they did before — no behavior change when a CLI is truly
|
* - POSIX: PATH plus well-known install dirs under agentHome() (issue #78).
|
||||||
* absent. Pure-fs (no shell) so it can't hang or be injected, and it uses an X_OK access check to
|
*
|
||||||
* match `execFile`'s own executable-file semantics (a non-executable name-collision is skipped, not
|
* POSIX fail-open: an unresolved name is returned unchanged so `execFile`/`spawn` still throw the
|
||||||
* returned). win32 is intentionally NOT handled here (where.exe / .cmd-shim resolution is PR #18's
|
* same ENOENT (→ installed:false) they did before. Windows fail-closed: an unresolved npm shim must
|
||||||
* domain) — the bare name is returned so Node's own %PATH%/PATHEXT lookup stays in effect.
|
* not be spawned as a bare name because .cmd/.bat launches need explicit shell handling.
|
||||||
*/
|
*/
|
||||||
export function resolveBin(bin: string): string {
|
export function resolveBin(bin: string): string | undefined {
|
||||||
if (process.platform === 'win32' || bin.includes('/')) return bin;
|
if (isWin32()) {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('where.exe', [bin], { encoding: 'utf8', timeout: 5000 });
|
||||||
|
const hits = out.split(NEWLINE_RE).map((h) => h.trim()).filter(Boolean);
|
||||||
|
return (
|
||||||
|
hits.find((h) => /\.exe$/i.test(h)) ??
|
||||||
|
hits.find((h) => /\.cmd$/i.test(h)) ??
|
||||||
|
hits.find((h) => /\.bat$/i.test(h)) ??
|
||||||
|
hits[0]
|
||||||
|
);
|
||||||
|
} catch { return undefined; }
|
||||||
|
}
|
||||||
|
if (bin.includes('/')) return bin;
|
||||||
const path = process.env.PATH || '';
|
const path = process.env.PATH || '';
|
||||||
for (const dir of [...path.split(':'), ...wellKnownBinDirs(agentHome())]) {
|
for (const dir of [...path.split(':'), ...wellKnownBinDirs(agentHome())]) {
|
||||||
// Absolute dirs only: skips blank PATH segments (whose POSIX "= cwd" meaning is a known
|
// Absolute dirs only: skips blank PATH segments (whose POSIX "= cwd" meaning is a known
|
||||||
@@ -248,6 +277,23 @@ export function resolveBin(bin: string): string {
|
|||||||
return bin;
|
return bin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows-safe launcher for a resolved agent binary.
|
||||||
|
*
|
||||||
|
* A `.cmd`/`.bat` shim can't be spawned with shell:false on Windows. It must run through cmd.exe, but
|
||||||
|
* hand-rolling `spawn('cmd.exe', ['/d','/s','/c', shim, ...args])` is unsafe because cmd.exe re-parses
|
||||||
|
* the command tail with its own quoting rules. Letting Node launch the resolved shim with shell:true
|
||||||
|
* uses Node's patched argument escaping path, so adversarial prompt text stays a literal argument.
|
||||||
|
*/
|
||||||
|
function spawnAgent(resolvedBin: string, args: string[], options: import('child_process').SpawnOptions): import('child_process').ChildProcess {
|
||||||
|
if (needsShell(resolvedBin)) return spawn(resolvedBin, args, { ...options, shell: true });
|
||||||
|
return spawn(resolvedBin, args, { ...options, shell: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsShell(resolvedBin: string): boolean {
|
||||||
|
return isWin32() && /\.(cmd|bat)$/i.test(resolvedBin);
|
||||||
|
}
|
||||||
|
|
||||||
function authState(spec: AgentSpec): { authed: boolean; method?: string } {
|
function authState(spec: AgentSpec): { authed: boolean; method?: string } {
|
||||||
for (const a of spec.authArtifacts) {
|
for (const a of spec.authArtifacts) {
|
||||||
if (existsSync(expand(a))) return { authed: true, method: 'file' };
|
if (existsSync(expand(a))) return { authed: true, method: 'file' };
|
||||||
@@ -269,29 +315,45 @@ function detectOne(spec: AgentSpec): Promise<AgentDetection> {
|
|||||||
id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin,
|
id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin,
|
||||||
blurb: spec.blurb, invokeHint: spec.invokeHint,
|
blurb: spec.blurb, invokeHint: spec.invokeHint,
|
||||||
};
|
};
|
||||||
// Resolve against PATH + well-known install dirs so a Finder/desktop launch's minimal PATH
|
// POSIX falls back to the bare name when unresolved; Windows keeps `resolved` authoritative because
|
||||||
// doesn't hide an installed CLI (issue #78). Absolute when found; the bare name otherwise
|
// bare npm .cmd/.bat shims require explicit shell handling after where.exe resolution.
|
||||||
// (→ execFile throws the same ENOENT → installed:false, preserving the true-absence path).
|
const exe = resolveBin(spec.bin) || (isWin32() ? undefined : spec.bin);
|
||||||
const exe = resolveBin(spec.bin);
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
execFile(exe, spec.versionArgs, { timeout: 8000 }, (err, stdout) => {
|
if (!exe) {
|
||||||
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
resolve({ ...base, installed: false, authed: false, ready: false });
|
||||||
resolve({ ...base, installed: false, authed: false, ready: false });
|
return;
|
||||||
return;
|
}
|
||||||
}
|
const installedNoVersion = () => {
|
||||||
// even a non-zero exit but no ENOENT means the binary exists
|
|
||||||
const version = spec.parseVersion(String(stdout || ''));
|
|
||||||
const auth = authState(spec);
|
const auth = authState(spec);
|
||||||
resolve({
|
resolve({
|
||||||
...base,
|
...base, installed: true, path: exe !== spec.bin ? exe : undefined, version: '?',
|
||||||
installed: true,
|
authed: auth.authed, authMethod: auth.method, ready: auth.authed,
|
||||||
path: exe !== spec.bin ? exe : undefined,
|
|
||||||
version,
|
|
||||||
authed: auth.authed,
|
|
||||||
authMethod: auth.method,
|
|
||||||
ready: auth.authed,
|
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
try {
|
||||||
|
execFile(exe, spec.versionArgs, { timeout: 8000, shell: needsShell(exe) }, (err, stdout) => {
|
||||||
|
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
resolve({ ...base, installed: false, authed: false, ready: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// even a non-zero exit but no ENOENT means the binary exists
|
||||||
|
const version = spec.parseVersion(String(stdout || ''));
|
||||||
|
const auth = authState(spec);
|
||||||
|
resolve({
|
||||||
|
...base,
|
||||||
|
installed: true,
|
||||||
|
path: exe !== spec.bin ? exe : undefined,
|
||||||
|
version,
|
||||||
|
authed: auth.authed,
|
||||||
|
authMethod: auth.method,
|
||||||
|
ready: auth.authed,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Synchronous spawn failures (for example restricted child-process creation) should not reject
|
||||||
|
// the whole detection batch; the binary exists, but its version could not be probed.
|
||||||
|
installedNoVersion();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +363,15 @@ export async function detectLocalAgents(): Promise<AgentDetection[]> {
|
|||||||
// local-agent auto-detection) so key-required / fail-closed paths can be exercised
|
// local-agent auto-detection) so key-required / fail-closed paths can be exercised
|
||||||
// deterministically — used by scripts/arsenal-smoke.mjs for a reproducible run.
|
// deterministically — used by scripts/arsenal-smoke.mjs for a reproducible run.
|
||||||
if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_DISABLE_LOCAL_AGENTS || '').trim())) return [];
|
if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_DISABLE_LOCAL_AGENTS || '').trim())) return [];
|
||||||
return Promise.all(SPECS.map(detectOne));
|
const settled = await Promise.allSettled(SPECS.map(detectOne));
|
||||||
|
return settled.map((r, i) => {
|
||||||
|
if (r.status === 'fulfilled') return r.value;
|
||||||
|
const s = SPECS[i];
|
||||||
|
return {
|
||||||
|
id: s.id, label: s.label, vendor: s.vendor, bin: s.bin, blurb: s.blurb,
|
||||||
|
invokeHint: s.invokeHint, installed: false, authed: false, ready: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentRunResult {
|
export interface AgentRunResult {
|
||||||
@@ -340,11 +410,10 @@ export function runLocalAgent(
|
|||||||
const maxChars = opts.maxChars ?? 4000;
|
const maxChars = opts.maxChars ?? 4000;
|
||||||
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
|
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
|
||||||
const env = childEnv();
|
const env = childEnv();
|
||||||
|
const resolvedBin = resolveBin(spec.bin) || spec.bin;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait).
|
// stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait).
|
||||||
// resolveBin: same PATH-robust resolution the detector uses, so a CLI detected under a minimal
|
const child = spawnAgent(resolvedBin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
// launchd PATH also SPAWNS (detected-but-unspawnable would be worse than undetected) — issue #78.
|
|
||||||
const child = spawn(resolveBin(spec.bin), args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
||||||
let out = '';
|
let out = '';
|
||||||
let errOut = '';
|
let errOut = '';
|
||||||
let done = false;
|
let done = false;
|
||||||
@@ -406,9 +475,9 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } };
|
const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } };
|
||||||
|
const resolvedBin = resolveBin(spec.bin) || spec.bin;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// resolveBin: PATH-robust resolution (issue #78) — see runLocalAgent.
|
const child = spawnAgent(resolvedBin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
||||||
const child = spawn(resolveBin(spec.bin), args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
||||||
let out = '';
|
let out = '';
|
||||||
let errOut = '';
|
let errOut = '';
|
||||||
let done = false;
|
let done = false;
|
||||||
|
|||||||
Reference in new issue
Block a user