@kolisachint/hoocode-agent 0.4.83 → 0.4.85

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/cli/args.d.ts +4 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +18 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +8 -1
  8. package/dist/cli.js.map +1 -1
  9. package/dist/core/tools/webfetch.d.ts +4 -2
  10. package/dist/core/tools/webfetch.d.ts.map +1 -1
  11. package/dist/core/tools/webfetch.js +17 -12
  12. package/dist/core/tools/webfetch.js.map +1 -1
  13. package/dist/core/tools/websearch.d.ts +2 -2
  14. package/dist/core/tools/websearch.d.ts.map +1 -1
  15. package/dist/core/tools/websearch.js +8 -11
  16. package/dist/core/tools/websearch.js.map +1 -1
  17. package/dist/core/tools/webtools-shared.d.ts +29 -1
  18. package/dist/core/tools/webtools-shared.d.ts.map +1 -1
  19. package/dist/core/tools/webtools-shared.js +141 -7
  20. package/dist/core/tools/webtools-shared.js.map +1 -1
  21. package/dist/utils/tls-ca.d.ts +18 -0
  22. package/dist/utils/tls-ca.d.ts.map +1 -0
  23. package/dist/utils/tls-ca.js +163 -0
  24. package/dist/utils/tls-ca.js.map +1 -0
  25. package/dist/utils/tools-manager.d.ts +1 -0
  26. package/dist/utils/tools-manager.d.ts.map +1 -1
  27. package/dist/utils/tools-manager.js +90 -16
  28. package/dist/utils/tools-manager.js.map +1 -1
  29. package/docs/providers.md +68 -0
  30. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  31. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  32. package/examples/extensions/sandbox/package.json +1 -1
  33. package/examples/extensions/with-deps/package.json +1 -1
  34. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"webtools-shared.js","sourceRoot":"","sources":["../../../src/core/tools/webtools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `webfetch` and `websearch` tools.\n *\n * Both tools shell out to the `webtools` binary (fetch / search subcommands,\n * resolved/downloaded via {@link ensureTool}) and parse its `--json` output.\n * This module owns:\n * - the spawn-and-parse runner,\n * - the locked JSON result types,\n * - a short-lived in-process result cache, and\n * - the `.webtoolsignore` policy matcher (gitignore semantics) used to block\n * hosts both before a fetch and when filtering search result links.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport ignore from \"ignore\";\nimport { getAgentDir } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\n/** Default request timeout (seconds) passed to the binary. */\nexport const WEBTOOLS_DEFAULT_TIMEOUT_SECS = 15;\n\n/** How long a successful result stays cached, mirroring the documented 15-min TTL. */\nconst CACHE_TTL_MS = 15 * 60 * 1000;\n\n// ============================================================================\n// Result types (locked against `webtools <cmd> --json`)\n// ============================================================================\n\nexport interface WebFetchReference {\n\tindex: number;\n\turl: string;\n\ttext?: string;\n}\n\nexport interface WebFetchMetadata {\n\tdescription?: string;\n\tauthor?: string;\n\tpublished?: string;\n\tlang?: string;\n\tsite_name?: string;\n}\n\nexport interface WebFetchResult {\n\ttitle?: string;\n\tfinal_url: string;\n\tcontent: string;\n\tcontent_type: string;\n\tmedia: string;\n\ttoken_estimate: number;\n\treferences: WebFetchReference[];\n\tmetadata?: WebFetchMetadata;\n\tsource: string;\n}\n\nexport interface WebSearchResultItem {\n\ttitle: string;\n\tsnippet: string;\n\turl: string;\n\tref_index: number;\n}\n\nexport interface WebSearchReference {\n\tindex: number;\n\turl: string;\n}\n\nexport interface WebSearchOutput {\n\tquery: string;\n\tresults: WebSearchResultItem[];\n\treferences: WebSearchReference[];\n\ttoken_estimate: number;\n\tresult_count: number;\n}\n\n// ============================================================================\n// Binary runner\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"webtools binary unavailable and could not be downloaded — web tools require the `webtools` CLI on PATH or a published release for this platform\";\n\n/**\n * Run a `webtools` subcommand with `--json` and return parsed stdout.\n *\n * Throws on missing binary, non-zero exit (surfacing the binary's stderr), or\n * unparseable output. Callers convert thrown errors into tool error results.\n */\nexport async function runWebtools<T>(\n\tsubcommand: \"fetch\" | \"search\",\n\targs: string[],\n\tcwd: string,\n\tsignal?: AbortSignal,\n\ttimeoutSecs: number = WEBTOOLS_DEFAULT_TIMEOUT_SECS,\n): Promise<T> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\tconst binaryPath = await ensureTool(\"webtools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\n\t// Give the spawn a little headroom over the binary's own request timeout so\n\t// the binary reports the timeout itself rather than being killed mid-flight.\n\tconst spawnTimeoutMs = (timeoutSecs + 5) * 1000;\n\tconst result = await execCommand(binaryPath, [subcommand, ...args, \"--json\"], cwd, {\n\t\tsignal,\n\t\ttimeout: spawnTimeoutMs,\n\t});\n\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`webtools ${subcommand} timed out after ${timeoutSecs}s`);\n\tif (result.code !== 0) {\n\t\tconst stderr = result.stderr.trim();\n\t\tthrow new Error(stderr || `webtools ${subcommand} exited with code ${result.code}`);\n\t}\n\n\ttry {\n\t\treturn JSON.parse(result.stdout) as T;\n\t} catch {\n\t\tthrow new Error(`webtools ${subcommand} returned malformed JSON`);\n\t}\n}\n\n// ============================================================================\n// Result cache (per-process, short TTL)\n// ============================================================================\n\ninterface CacheEntry<T> {\n\tvalue: T;\n\texpiresAt: number;\n}\n\nexport class WebToolsCache<T> {\n\tprivate readonly entries = new Map<string, CacheEntry<T>>();\n\n\tget(key: string): T | undefined {\n\t\tconst entry = this.entries.get(key);\n\t\tif (!entry) return undefined;\n\t\tif (Date.now() >= entry.expiresAt) {\n\t\t\tthis.entries.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: T): void {\n\t\tthis.entries.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });\n\t}\n}\n\n// ============================================================================\n// .webtoolsignore policy matcher\n// ============================================================================\n\n/**\n * Build an {@link Ignore} matcher from `.webtoolsignore` policy files.\n *\n * Precedence is project-after-user so a project file can re-allow (`!host`)\n * something the user blocked, matching gitignore layering. Returns undefined\n * when no policy files exist (the common case: everything allowed).\n *\n * Hosts are matched as single path components, so subdomains need an explicit\n * wildcard (`*.example.com`), exactly like gitignore directory matching.\n */\nexport function loadWebtoolsIgnore(cwd: string): IgnoreMatcher | undefined {\n\tconst files = [\n\t\tjoin(getAgentDir(), \"webtoolsignore\"),\n\t\tjoin(homedir(), \".webtoolsignore\"),\n\t\tjoin(cwd, \".webtoolsignore\"),\n\t];\n\tlet found = false;\n\tconst ig = ignore();\n\tfor (const file of files) {\n\t\tif (!existsSync(file)) continue;\n\t\ttry {\n\t\t\tig.add(readFileSync(file, \"utf8\"));\n\t\t\tfound = true;\n\t\t} catch {\n\t\t\t// Unreadable policy file: ignore it rather than failing the tool call.\n\t\t}\n\t}\n\treturn found ? ig : undefined;\n}\n\n/** Extract the lowercased hostname from a URL, or undefined if it cannot be parsed. */\nexport function hostnameOf(url: string): string | undefined {\n\ttry {\n\t\tconst host = new URL(url).hostname.toLowerCase();\n\t\treturn host || undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Whether a host is blocked by policy. A matcher is required; with no policy\n * files present callers treat every host as allowed.\n */\nexport function isHostBlocked(matcher: IgnoreMatcher, host: string): boolean {\n\tif (!host) return false;\n\treturn matcher.ignores(host);\n}\n\n/**\n * Convenience used by the permission gate: returns the blocked host for a URL,\n * or undefined when the URL is allowed (or there is no policy / unparseable URL).\n */\nexport function blockedHostForUrl(cwd: string, url: string): string | undefined {\n\tconst matcher = loadWebtoolsIgnore(cwd);\n\tif (!matcher) return undefined;\n\tconst host = hostnameOf(url);\n\tif (!host) return undefined;\n\treturn isHostBlocked(matcher, host) ? host : undefined;\n}\n"]}
1
+ {"version":3,"file":"webtools-shared.js","sourceRoot":"","sources":["../../../src/core/tools/webtools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `webfetch` and `websearch` tools.\n *\n * Both tools shell out to the `webtools` binary (fetch / search subcommands,\n * resolved/downloaded via {@link ensureTool}) and parse its `--json` output.\n * This module owns:\n * - the spawn-and-parse runner,\n * - the locked JSON result types,\n * - a short-lived in-process result cache, and\n * - the `.webtoolsignore` policy matcher (gitignore semantics) used to block\n * hosts both before a fetch and when filtering search result links.\n */\n\nimport { accessSync, constants, existsSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport chalk from \"chalk\";\nimport ignore from \"ignore\";\nimport { getAgentDir } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\n/** Default request timeout (seconds) passed to the binary. */\nexport const WEBTOOLS_DEFAULT_TIMEOUT_SECS = 15;\n\n/** How long a successful result stays cached, mirroring the documented 15-min TTL. */\nconst CACHE_TTL_MS = 15 * 60 * 1000;\n\n// ============================================================================\n// Result types (locked against `webtools <cmd> --json`)\n// ============================================================================\n\nexport interface WebFetchReference {\n\tindex: number;\n\turl: string;\n\ttext?: string;\n}\n\nexport interface WebFetchMetadata {\n\tdescription?: string;\n\tauthor?: string;\n\tpublished?: string;\n\tlang?: string;\n\tsite_name?: string;\n}\n\nexport interface WebFetchResult {\n\ttitle?: string;\n\tfinal_url: string;\n\tcontent: string;\n\tcontent_type: string;\n\tmedia: string;\n\ttoken_estimate: number;\n\treferences: WebFetchReference[];\n\tmetadata?: WebFetchMetadata;\n\tsource: string;\n}\n\nexport interface WebSearchResultItem {\n\ttitle: string;\n\tsnippet: string;\n\turl: string;\n\tref_index: number;\n}\n\nexport interface WebSearchReference {\n\tindex: number;\n\turl: string;\n}\n\nexport interface WebSearchOutput {\n\tquery: string;\n\tresults: WebSearchResultItem[];\n\treferences: WebSearchReference[];\n\ttoken_estimate: number;\n\tresult_count: number;\n}\n\n// ============================================================================\n// Binary runner\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"webtools binary unavailable and could not be downloaded — web tools require the `webtools` CLI on PATH or a published release for this platform\";\n\n/**\n * TLS plumbing forwarded to the `webtools` binary for `webfetch`/`websearch`.\n * Kept separate from hoocode's own app-level TLS trust (utils/tls-ca.ts): the\n * binary has its own TLS stack, so it needs the CA / insecure flag passed in.\n */\nexport interface WebtoolsTLSConfig {\n\t/** Path to a PEM CA bundle forwarded as `--ca-cert <path>` (validated readable). */\n\tcaCertPath?: string;\n\t/** Forward `--insecure` (disables TLS verification in the binary). Strictly opt-in. */\n\tinsecure?: boolean;\n}\n\nfunction isTruthyEnv(value: string | undefined): boolean {\n\tif (!value) return false;\n\tconst normalized = value.trim().toLowerCase();\n\treturn normalized === \"1\" || normalized === \"true\" || normalized === \"yes\";\n}\n\n/**\n * Resolve the webtools TLS config from explicit overrides (e.g. settings.json\n * passed down from the tool factories) falling back to the environment\n * (`HOOCODE_WEBTOOLS_CA_CERT`, `HOOCODE_WEBTOOLS_INSECURE`). Never hardcoded.\n */\nexport function resolveWebtoolsTLSConfig(overrides?: WebtoolsTLSConfig): WebtoolsTLSConfig {\n\tconst envCaCert = process.env.HOOCODE_WEBTOOLS_CA_CERT?.trim();\n\tconst caCertPath = overrides?.caCertPath ?? (envCaCert && envCaCert.length > 0 ? envCaCert : undefined);\n\tconst insecure = overrides?.insecure ?? isTruthyEnv(process.env.HOOCODE_WEBTOOLS_INSECURE);\n\treturn { caCertPath, insecure };\n}\n\n// Warn at most once per distinct message for the life of the process.\nconst warnedWebtoolsMessages = new Set<string>();\nfunction warnOnce(message: string): void {\n\tif (warnedWebtoolsMessages.has(message)) return;\n\twarnedWebtoolsMessages.add(message);\n\tconsole.warn(chalk.yellow(`[webtools] ${message}`));\n}\n\n/** True only when `path` is a readable regular file; warns once and returns false otherwise. */\nfunction isReadableFile(path: string): boolean {\n\ttry {\n\t\tif (!statSync(path).isFile()) {\n\t\t\twarnOnce(`--ca-cert path is not a regular file, ignoring: ${path}`);\n\t\t\treturn false;\n\t\t}\n\t\taccessSync(path, constants.R_OK);\n\t\treturn true;\n\t} catch (error) {\n\t\tconst reason = error instanceof Error ? error.message : String(error);\n\t\twarnOnce(`--ca-cert path is not readable, ignoring: ${path} (${reason})`);\n\t\treturn false;\n\t}\n}\n\n/** Build the TLS-related argv flags forwarded to the binary (argv array, no shell). */\nfunction buildTLSArgs(config: WebtoolsTLSConfig | undefined): string[] {\n\tconst flags: string[] = [];\n\tif (!config) return flags;\n\tif (config.caCertPath && isReadableFile(config.caCertPath)) {\n\t\tflags.push(\"--ca-cert\", config.caCertPath);\n\t}\n\tif (config.insecure) {\n\t\twarnOnce(\n\t\t\t\"webtools running with --insecure: TLS verification is DISABLED for webfetch/websearch. \" +\n\t\t\t\t\"Prefer HOOCODE_WEBTOOLS_CA_CERT to trust your proxy's CA with verification kept on.\",\n\t\t);\n\t\tflags.push(\"--insecure\");\n\t}\n\treturn flags;\n}\n\n/**\n * Run a `webtools` subcommand with `--json` and return parsed stdout.\n *\n * Throws on missing binary, non-zero exit (surfacing the binary's stderr), or\n * unparseable output. Callers convert thrown errors into tool error results.\n */\nexport async function runWebtools<T>(\n\tsubcommand: \"fetch\" | \"search\",\n\targs: string[],\n\tcwd: string,\n\tsignal?: AbortSignal,\n\ttimeoutSecs: number = WEBTOOLS_DEFAULT_TIMEOUT_SECS,\n\ttlsConfig?: WebtoolsTLSConfig,\n): Promise<T> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\tconst binaryPath = await ensureTool(\"webtools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\n\t// Give the spawn a little headroom over the binary's own request timeout so\n\t// the binary reports the timeout itself rather than being killed mid-flight.\n\tconst spawnTimeoutMs = (timeoutSecs + 5) * 1000;\n\tconst tlsArgs = buildTLSArgs(tlsConfig);\n\tconst result = await execCommand(binaryPath, [subcommand, ...args, ...tlsArgs, \"--json\"], cwd, {\n\t\tsignal,\n\t\ttimeout: spawnTimeoutMs,\n\t});\n\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`webtools ${subcommand} timed out after ${timeoutSecs}s`);\n\tif (result.code !== 0) {\n\t\tconst stderr = result.stderr.trim();\n\t\tthrow new Error(stderr || `webtools ${subcommand} exited with code ${result.code}`);\n\t}\n\n\ttry {\n\t\treturn JSON.parse(result.stdout) as T;\n\t} catch {\n\t\tthrow new Error(`webtools ${subcommand} returned malformed JSON`);\n\t}\n}\n\n// ============================================================================\n// Result cache (per-process, short TTL)\n// ============================================================================\n\ninterface CacheEntry<T> {\n\tvalue: T;\n\texpiresAt: number;\n}\n\n/**\n * A computation shared by every caller that requested the same key while it was\n * still running. The subprocess is only aborted once *all* joined callers have\n * aborted, tracked by {@link refCount} against a shared {@link controller}.\n */\ninterface InFlightEntry<T> {\n\tpromise: Promise<T>;\n\tcontroller: AbortController;\n\trefCount: number;\n}\n\nexport class WebToolsCache<T> {\n\tprivate readonly entries = new Map<string, CacheEntry<T>>();\n\tprivate readonly inflight = new Map<string, InFlightEntry<T>>();\n\n\tget(key: string): T | undefined {\n\t\tconst entry = this.entries.get(key);\n\t\tif (!entry) return undefined;\n\t\tif (Date.now() >= entry.expiresAt) {\n\t\t\tthis.entries.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: T): void {\n\t\tthis.entries.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });\n\t}\n\n\t/**\n\t * Return a cached value, join an identical in-flight computation, or start a\n\t * new one — collapsing concurrent duplicate fetch/search calls onto a single\n\t * subprocess. Successful results are cached; failures are not.\n\t *\n\t * Cancellation is shared safely: a caller whose own `signal` aborts rejects\n\t * promptly and releases its reference, but the underlying work keeps running\n\t * for the remaining callers and is only cancelled once none are left.\n\t */\n\tasync getOrCompute(\n\t\tkey: string,\n\t\tsignal: AbortSignal | undefined,\n\t\tcompute: (signal: AbortSignal) => Promise<T>,\n\t): Promise<T> {\n\t\tconst cached = this.get(key);\n\t\tif (cached !== undefined) return cached;\n\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\tlet entry = this.inflight.get(key);\n\t\tif (!entry) {\n\t\t\tconst controller = new AbortController();\n\t\t\tconst promise = (async () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst value = await compute(controller.signal);\n\t\t\t\t\tthis.set(key, value);\n\t\t\t\t\treturn value;\n\t\t\t\t} finally {\n\t\t\t\t\tthis.inflight.delete(key);\n\t\t\t\t}\n\t\t\t})();\n\t\t\tentry = { promise, controller, refCount: 0 };\n\t\t\tthis.inflight.set(key, entry);\n\t\t}\n\n\t\tconst joined = entry;\n\t\t// Every joined caller (signalled or not) holds a reference; the shared work\n\t\t// is cancelled only when an abort drops the count back to zero.\n\t\tjoined.refCount++;\n\n\t\tif (!signal) {\n\t\t\treturn joined.promise;\n\t\t}\n\n\t\tconst onAbort = () => {\n\t\t\tif (joined.refCount > 0) joined.refCount--;\n\t\t\tif (joined.refCount === 0) joined.controller.abort();\n\t\t};\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t\ttry {\n\t\t\treturn await Promise.race([\n\t\t\t\tjoined.promise,\n\t\t\t\tnew Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Operation aborted\")), { once: true });\n\t\t\t\t}),\n\t\t\t]);\n\t\t} finally {\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t}\n\t}\n}\n\n// ============================================================================\n// .webtoolsignore policy matcher\n// ============================================================================\n\n/**\n * Memoize the parsed matcher per cwd. The policy is consulted on every webfetch\n * (twice: permission gate + tool execute) and every websearch, so re-reading and\n * re-parsing three files each time is wasted sync I/O on the hot path. The cache\n * is invalidated by a cheap stat signature (existence + mtime + size) so an\n * edited `.webtoolsignore` still takes effect immediately — correctness matters\n * here because this gate enforces host policy.\n */\ninterface IgnoreCacheEntry {\n\tsignature: string;\n\tmatcher: IgnoreMatcher | undefined;\n}\nconst ignoreCacheByCwd = new Map<string, IgnoreCacheEntry>();\n\nfunction ignoreSignature(files: string[]): string {\n\treturn files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\tconst st = statSync(file);\n\t\t\t\treturn `${file}:${st.mtimeMs}:${st.size}`;\n\t\t\t} catch {\n\t\t\t\treturn `${file}:absent`;\n\t\t\t}\n\t\t})\n\t\t.join(\"|\");\n}\n\n/**\n * Build an {@link Ignore} matcher from `.webtoolsignore` policy files.\n *\n * Precedence is project-after-user so a project file can re-allow (`!host`)\n * something the user blocked, matching gitignore layering. Returns undefined\n * when no policy files exist (the common case: everything allowed).\n *\n * Hosts are matched as single path components, so subdomains need an explicit\n * wildcard (`*.example.com`), exactly like gitignore directory matching.\n */\nexport function loadWebtoolsIgnore(cwd: string): IgnoreMatcher | undefined {\n\tconst files = [\n\t\tjoin(getAgentDir(), \"webtoolsignore\"),\n\t\tjoin(homedir(), \".webtoolsignore\"),\n\t\tjoin(cwd, \".webtoolsignore\"),\n\t];\n\n\tconst signature = ignoreSignature(files);\n\tconst cached = ignoreCacheByCwd.get(cwd);\n\tif (cached && cached.signature === signature) {\n\t\treturn cached.matcher;\n\t}\n\n\tlet found = false;\n\tconst ig = ignore();\n\tfor (const file of files) {\n\t\tif (!existsSync(file)) continue;\n\t\ttry {\n\t\t\tig.add(readFileSync(file, \"utf8\"));\n\t\t\tfound = true;\n\t\t} catch {\n\t\t\t// Unreadable policy file: ignore it rather than failing the tool call.\n\t\t}\n\t}\n\tconst matcher = found ? ig : undefined;\n\tignoreCacheByCwd.set(cwd, { signature, matcher });\n\treturn matcher;\n}\n\n/** Extract the lowercased hostname from a URL, or undefined if it cannot be parsed. */\nexport function hostnameOf(url: string): string | undefined {\n\ttry {\n\t\tconst host = new URL(url).hostname.toLowerCase();\n\t\treturn host || undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Whether a host is blocked by policy. A matcher is required; with no policy\n * files present callers treat every host as allowed.\n */\nexport function isHostBlocked(matcher: IgnoreMatcher, host: string): boolean {\n\tif (!host) return false;\n\treturn matcher.ignores(host);\n}\n\n/**\n * Convenience used by the permission gate: returns the blocked host for a URL,\n * or undefined when the URL is allowed (or there is no policy / unparseable URL).\n */\nexport function blockedHostForUrl(cwd: string, url: string): string | undefined {\n\tconst matcher = loadWebtoolsIgnore(cwd);\n\tif (!matcher) return undefined;\n\tconst host = hostnameOf(url);\n\tif (!host) return undefined;\n\treturn isHostBlocked(matcher, host) ? host : undefined;\n}\n"]}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Build the additive set of trusted CA certificates:
3
+ * (a) Node's bundled root certificates (always),
4
+ * (b) an explicit PEM bundle from the first configured source (always, when set),
5
+ * (c) the OS trust store, ONLY when explicitly opted in.
6
+ *
7
+ * Never throws: every source that fails is warned-once and skipped, and the
8
+ * bundled defaults are always retained.
9
+ */
10
+ export declare function resolveTrustedCAs(): string[];
11
+ /**
12
+ * Install the resolved CA set on the global HTTPS agent and return it so the
13
+ * caller can thread the same trust set into other dispatchers (e.g. undici).
14
+ * Warns once if `NODE_TLS_REJECT_UNAUTHORIZED=0` is set, since that disables
15
+ * verification globally and defeats the purpose of trusting a specific CA.
16
+ */
17
+ export declare function configureGlobalTLS(): string[];
18
+ //# sourceMappingURL=tls-ca.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tls-ca.d.ts","sourceRoot":"","sources":["../../src/utils/tls-ca.ts"],"names":[],"mappings":"AAgHA;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,EAAE,CA+B5C;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,EAAE,CAY7C","sourcesContent":["/**\n * App-level TLS CA trust for hoocode's own outbound traffic (provider calls,\n * GitHub API, tool downloads). This lets hoocode work behind corporate\n * TLS-intercepting proxies WITH certificate validation kept ON, replacing the\n * insecure `NODE_TLS_REJECT_UNAUTHORIZED=0` workaround.\n *\n * Invariants:\n * - Verification is NEVER disabled (we never set `rejectUnauthorized: false`).\n * - Trust is ADDITIVE to Node's bundled roots — a custom CA extends the trust\n * set, it does not replace it.\n * - Fail closed: a missing/invalid CA source warns once and is skipped; we never\n * fall back to trusting everything, and there is no trust-on-first-use.\n *\n * This does NOT cover the `webfetch`/`websearch` tools — those shell out to a\n * separate `webtools` binary with its own TLS stack.\n */\nimport chalk from \"chalk\";\nimport { readFileSync, statSync } from \"fs\";\nimport { globalAgent } from \"https\";\nimport { getCACertificates, rootCertificates } from \"tls\";\n\n// Warnings are deduplicated by message so a given problem is reported at most\n// once for the life of the process (\"warn once\").\nconst warnedMessages = new Set<string>();\nfunction warnOnce(message: string): void {\n\tif (warnedMessages.has(message)) return;\n\twarnedMessages.add(message);\n\tconsole.warn(chalk.yellow(`[tls] ${message}`));\n}\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\n/** Scan `process.argv` for `--flag value` or `--flag=value`, returning the value. */\nfunction readArgValue(flag: string): string | undefined {\n\tconst argv = process.argv;\n\tfor (let i = 0; i < argv.length; i++) {\n\t\tconst current = argv[i];\n\t\tif (current === flag) {\n\t\t\tconst next = argv[i + 1];\n\t\t\tif (next !== undefined && !next.startsWith(\"-\")) {\n\t\t\t\tconst trimmed = next.trim();\n\t\t\t\treturn trimmed.length > 0 ? trimmed : undefined;\n\t\t\t}\n\t\t\treturn undefined;\n\t\t}\n\t\tif (current.startsWith(`${flag}=`)) {\n\t\t\tconst trimmed = current.slice(flag.length + 1).trim();\n\t\t\treturn trimmed.length > 0 ? trimmed : undefined;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/** Scan `process.argv` for a boolean `--flag`. */\nfunction hasArgFlag(flag: string): boolean {\n\treturn process.argv.includes(flag);\n}\n\n/**\n * Resolve the path to an explicit PEM CA bundle from the first configured\n * source, in precedence order: `--ca-cert <path>` > `HOOCODE_CA_CERT` >\n * `NODE_EXTRA_CA_CERTS`.\n */\nfunction resolveExplicitCAPath(): string | undefined {\n\tconst fromFlag = readArgValue(\"--ca-cert\");\n\tif (fromFlag) return fromFlag;\n\n\tconst fromHoocodeEnv = process.env.HOOCODE_CA_CERT?.trim();\n\tif (fromHoocodeEnv) return fromHoocodeEnv;\n\n\tconst fromNodeEnv = process.env.NODE_EXTRA_CA_CERTS?.trim();\n\tif (fromNodeEnv) return fromNodeEnv;\n\n\treturn undefined;\n}\n\n/** Read a PEM bundle from a readable regular file, warning and skipping on failure. */\nfunction readCABundle(path: string): string | undefined {\n\ttry {\n\t\tif (!statSync(path).isFile()) {\n\t\t\twarnOnce(`CA certificate path is not a regular file, skipping: ${path}`);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn readFileSync(path, \"utf8\");\n\t} catch (error) {\n\t\twarnOnce(`Could not read CA certificate file, skipping: ${path} (${errorMessage(error)})`);\n\t\treturn undefined;\n\t}\n}\n\n/** True when the OS trust store has been explicitly opted into. */\nfunction isSystemStoreOptedIn(): boolean {\n\tif (hasArgFlag(\"--use-system-ca\")) return true;\n\tconst value = process.env.HOOCODE_USE_SYSTEM_CA?.trim().toLowerCase();\n\treturn value === \"1\" || value === \"true\" || value === \"yes\";\n}\n\n/** De-duplicate certificate strings while preserving insertion order. */\nfunction dedupe(certs: string[]): string[] {\n\tconst seen = new Set<string>();\n\tconst result: string[] = [];\n\tfor (const cert of certs) {\n\t\tconst key = cert.trim();\n\t\tif (key.length === 0 || seen.has(key)) continue;\n\t\tseen.add(key);\n\t\tresult.push(cert);\n\t}\n\treturn result;\n}\n\n/**\n * Build the additive set of trusted CA certificates:\n * (a) Node's bundled root certificates (always),\n * (b) an explicit PEM bundle from the first configured source (always, when set),\n * (c) the OS trust store, ONLY when explicitly opted in.\n *\n * Never throws: every source that fails is warned-once and skipped, and the\n * bundled defaults are always retained.\n */\nexport function resolveTrustedCAs(): string[] {\n\tconst certs: string[] = [];\n\n\t// (a) Bundled defaults — always present so trust stays additive.\n\tfor (const cert of rootCertificates) {\n\t\tcerts.push(cert);\n\t}\n\n\t// (b) Explicit PEM bundle (first configured source wins).\n\tconst explicitPath = resolveExplicitCAPath();\n\tif (explicitPath) {\n\t\tconst bundle = readCABundle(explicitPath);\n\t\tif (bundle) certs.push(bundle);\n\t}\n\n\t// (c) OS trust store — opt-in only, and only if the runtime supports it.\n\tif (isSystemStoreOptedIn()) {\n\t\tif (typeof getCACertificates === \"function\") {\n\t\t\ttry {\n\t\t\t\tfor (const cert of getCACertificates(\"system\")) {\n\t\t\t\t\tcerts.push(cert);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\twarnOnce(`Could not read the system CA store, skipping: ${errorMessage(error)}`);\n\t\t\t}\n\t\t} else {\n\t\t\twarnOnce(\"System CA store requested, but this Node runtime does not support tls.getCACertificates().\");\n\t\t}\n\t}\n\n\treturn dedupe(certs);\n}\n\n/**\n * Install the resolved CA set on the global HTTPS agent and return it so the\n * caller can thread the same trust set into other dispatchers (e.g. undici).\n * Warns once if `NODE_TLS_REJECT_UNAUTHORIZED=0` is set, since that disables\n * verification globally and defeats the purpose of trusting a specific CA.\n */\nexport function configureGlobalTLS(): string[] {\n\tconst ca = resolveTrustedCAs();\n\tglobalAgent.options.ca = ca;\n\n\tif (process.env.NODE_TLS_REJECT_UNAUTHORIZED === \"0\") {\n\t\twarnOnce(\n\t\t\t\"NODE_TLS_REJECT_UNAUTHORIZED=0 disables all TLS certificate verification and is insecure. \" +\n\t\t\t\t\"Prefer --ca-cert <path> (or --use-system-ca) to trust your proxy's CA with verification kept on.\",\n\t\t);\n\t}\n\n\treturn ca;\n}\n"]}
@@ -0,0 +1,163 @@
1
+ /**
2
+ * App-level TLS CA trust for hoocode's own outbound traffic (provider calls,
3
+ * GitHub API, tool downloads). This lets hoocode work behind corporate
4
+ * TLS-intercepting proxies WITH certificate validation kept ON, replacing the
5
+ * insecure `NODE_TLS_REJECT_UNAUTHORIZED=0` workaround.
6
+ *
7
+ * Invariants:
8
+ * - Verification is NEVER disabled (we never set `rejectUnauthorized: false`).
9
+ * - Trust is ADDITIVE to Node's bundled roots — a custom CA extends the trust
10
+ * set, it does not replace it.
11
+ * - Fail closed: a missing/invalid CA source warns once and is skipped; we never
12
+ * fall back to trusting everything, and there is no trust-on-first-use.
13
+ *
14
+ * This does NOT cover the `webfetch`/`websearch` tools — those shell out to a
15
+ * separate `webtools` binary with its own TLS stack.
16
+ */
17
+ import chalk from "chalk";
18
+ import { readFileSync, statSync } from "fs";
19
+ import { globalAgent } from "https";
20
+ import { getCACertificates, rootCertificates } from "tls";
21
+ // Warnings are deduplicated by message so a given problem is reported at most
22
+ // once for the life of the process ("warn once").
23
+ const warnedMessages = new Set();
24
+ function warnOnce(message) {
25
+ if (warnedMessages.has(message))
26
+ return;
27
+ warnedMessages.add(message);
28
+ console.warn(chalk.yellow(`[tls] ${message}`));
29
+ }
30
+ function errorMessage(error) {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+ /** Scan `process.argv` for `--flag value` or `--flag=value`, returning the value. */
34
+ function readArgValue(flag) {
35
+ const argv = process.argv;
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const current = argv[i];
38
+ if (current === flag) {
39
+ const next = argv[i + 1];
40
+ if (next !== undefined && !next.startsWith("-")) {
41
+ const trimmed = next.trim();
42
+ return trimmed.length > 0 ? trimmed : undefined;
43
+ }
44
+ return undefined;
45
+ }
46
+ if (current.startsWith(`${flag}=`)) {
47
+ const trimmed = current.slice(flag.length + 1).trim();
48
+ return trimmed.length > 0 ? trimmed : undefined;
49
+ }
50
+ }
51
+ return undefined;
52
+ }
53
+ /** Scan `process.argv` for a boolean `--flag`. */
54
+ function hasArgFlag(flag) {
55
+ return process.argv.includes(flag);
56
+ }
57
+ /**
58
+ * Resolve the path to an explicit PEM CA bundle from the first configured
59
+ * source, in precedence order: `--ca-cert <path>` > `HOOCODE_CA_CERT` >
60
+ * `NODE_EXTRA_CA_CERTS`.
61
+ */
62
+ function resolveExplicitCAPath() {
63
+ const fromFlag = readArgValue("--ca-cert");
64
+ if (fromFlag)
65
+ return fromFlag;
66
+ const fromHoocodeEnv = process.env.HOOCODE_CA_CERT?.trim();
67
+ if (fromHoocodeEnv)
68
+ return fromHoocodeEnv;
69
+ const fromNodeEnv = process.env.NODE_EXTRA_CA_CERTS?.trim();
70
+ if (fromNodeEnv)
71
+ return fromNodeEnv;
72
+ return undefined;
73
+ }
74
+ /** Read a PEM bundle from a readable regular file, warning and skipping on failure. */
75
+ function readCABundle(path) {
76
+ try {
77
+ if (!statSync(path).isFile()) {
78
+ warnOnce(`CA certificate path is not a regular file, skipping: ${path}`);
79
+ return undefined;
80
+ }
81
+ return readFileSync(path, "utf8");
82
+ }
83
+ catch (error) {
84
+ warnOnce(`Could not read CA certificate file, skipping: ${path} (${errorMessage(error)})`);
85
+ return undefined;
86
+ }
87
+ }
88
+ /** True when the OS trust store has been explicitly opted into. */
89
+ function isSystemStoreOptedIn() {
90
+ if (hasArgFlag("--use-system-ca"))
91
+ return true;
92
+ const value = process.env.HOOCODE_USE_SYSTEM_CA?.trim().toLowerCase();
93
+ return value === "1" || value === "true" || value === "yes";
94
+ }
95
+ /** De-duplicate certificate strings while preserving insertion order. */
96
+ function dedupe(certs) {
97
+ const seen = new Set();
98
+ const result = [];
99
+ for (const cert of certs) {
100
+ const key = cert.trim();
101
+ if (key.length === 0 || seen.has(key))
102
+ continue;
103
+ seen.add(key);
104
+ result.push(cert);
105
+ }
106
+ return result;
107
+ }
108
+ /**
109
+ * Build the additive set of trusted CA certificates:
110
+ * (a) Node's bundled root certificates (always),
111
+ * (b) an explicit PEM bundle from the first configured source (always, when set),
112
+ * (c) the OS trust store, ONLY when explicitly opted in.
113
+ *
114
+ * Never throws: every source that fails is warned-once and skipped, and the
115
+ * bundled defaults are always retained.
116
+ */
117
+ export function resolveTrustedCAs() {
118
+ const certs = [];
119
+ // (a) Bundled defaults — always present so trust stays additive.
120
+ for (const cert of rootCertificates) {
121
+ certs.push(cert);
122
+ }
123
+ // (b) Explicit PEM bundle (first configured source wins).
124
+ const explicitPath = resolveExplicitCAPath();
125
+ if (explicitPath) {
126
+ const bundle = readCABundle(explicitPath);
127
+ if (bundle)
128
+ certs.push(bundle);
129
+ }
130
+ // (c) OS trust store — opt-in only, and only if the runtime supports it.
131
+ if (isSystemStoreOptedIn()) {
132
+ if (typeof getCACertificates === "function") {
133
+ try {
134
+ for (const cert of getCACertificates("system")) {
135
+ certs.push(cert);
136
+ }
137
+ }
138
+ catch (error) {
139
+ warnOnce(`Could not read the system CA store, skipping: ${errorMessage(error)}`);
140
+ }
141
+ }
142
+ else {
143
+ warnOnce("System CA store requested, but this Node runtime does not support tls.getCACertificates().");
144
+ }
145
+ }
146
+ return dedupe(certs);
147
+ }
148
+ /**
149
+ * Install the resolved CA set on the global HTTPS agent and return it so the
150
+ * caller can thread the same trust set into other dispatchers (e.g. undici).
151
+ * Warns once if `NODE_TLS_REJECT_UNAUTHORIZED=0` is set, since that disables
152
+ * verification globally and defeats the purpose of trusting a specific CA.
153
+ */
154
+ export function configureGlobalTLS() {
155
+ const ca = resolveTrustedCAs();
156
+ globalAgent.options.ca = ca;
157
+ if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0") {
158
+ warnOnce("NODE_TLS_REJECT_UNAUTHORIZED=0 disables all TLS certificate verification and is insecure. " +
159
+ "Prefer --ca-cert <path> (or --use-system-ca) to trust your proxy's CA with verification kept on.");
160
+ }
161
+ return ca;
162
+ }
163
+ //# sourceMappingURL=tls-ca.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tls-ca.js","sourceRoot":"","sources":["../../src/utils/tls-ca.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * App-level TLS CA trust for hoocode's own outbound traffic (provider calls,\n * GitHub API, tool downloads). This lets hoocode work behind corporate\n * TLS-intercepting proxies WITH certificate validation kept ON, replacing the\n * insecure `NODE_TLS_REJECT_UNAUTHORIZED=0` workaround.\n *\n * Invariants:\n * - Verification is NEVER disabled (we never set `rejectUnauthorized: false`).\n * - Trust is ADDITIVE to Node's bundled roots — a custom CA extends the trust\n * set, it does not replace it.\n * - Fail closed: a missing/invalid CA source warns once and is skipped; we never\n * fall back to trusting everything, and there is no trust-on-first-use.\n *\n * This does NOT cover the `webfetch`/`websearch` tools — those shell out to a\n * separate `webtools` binary with its own TLS stack.\n */\nimport chalk from \"chalk\";\nimport { readFileSync, statSync } from \"fs\";\nimport { globalAgent } from \"https\";\nimport { getCACertificates, rootCertificates } from \"tls\";\n\n// Warnings are deduplicated by message so a given problem is reported at most\n// once for the life of the process (\"warn once\").\nconst warnedMessages = new Set<string>();\nfunction warnOnce(message: string): void {\n\tif (warnedMessages.has(message)) return;\n\twarnedMessages.add(message);\n\tconsole.warn(chalk.yellow(`[tls] ${message}`));\n}\n\nfunction errorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\n/** Scan `process.argv` for `--flag value` or `--flag=value`, returning the value. */\nfunction readArgValue(flag: string): string | undefined {\n\tconst argv = process.argv;\n\tfor (let i = 0; i < argv.length; i++) {\n\t\tconst current = argv[i];\n\t\tif (current === flag) {\n\t\t\tconst next = argv[i + 1];\n\t\t\tif (next !== undefined && !next.startsWith(\"-\")) {\n\t\t\t\tconst trimmed = next.trim();\n\t\t\t\treturn trimmed.length > 0 ? trimmed : undefined;\n\t\t\t}\n\t\t\treturn undefined;\n\t\t}\n\t\tif (current.startsWith(`${flag}=`)) {\n\t\t\tconst trimmed = current.slice(flag.length + 1).trim();\n\t\t\treturn trimmed.length > 0 ? trimmed : undefined;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/** Scan `process.argv` for a boolean `--flag`. */\nfunction hasArgFlag(flag: string): boolean {\n\treturn process.argv.includes(flag);\n}\n\n/**\n * Resolve the path to an explicit PEM CA bundle from the first configured\n * source, in precedence order: `--ca-cert <path>` > `HOOCODE_CA_CERT` >\n * `NODE_EXTRA_CA_CERTS`.\n */\nfunction resolveExplicitCAPath(): string | undefined {\n\tconst fromFlag = readArgValue(\"--ca-cert\");\n\tif (fromFlag) return fromFlag;\n\n\tconst fromHoocodeEnv = process.env.HOOCODE_CA_CERT?.trim();\n\tif (fromHoocodeEnv) return fromHoocodeEnv;\n\n\tconst fromNodeEnv = process.env.NODE_EXTRA_CA_CERTS?.trim();\n\tif (fromNodeEnv) return fromNodeEnv;\n\n\treturn undefined;\n}\n\n/** Read a PEM bundle from a readable regular file, warning and skipping on failure. */\nfunction readCABundle(path: string): string | undefined {\n\ttry {\n\t\tif (!statSync(path).isFile()) {\n\t\t\twarnOnce(`CA certificate path is not a regular file, skipping: ${path}`);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn readFileSync(path, \"utf8\");\n\t} catch (error) {\n\t\twarnOnce(`Could not read CA certificate file, skipping: ${path} (${errorMessage(error)})`);\n\t\treturn undefined;\n\t}\n}\n\n/** True when the OS trust store has been explicitly opted into. */\nfunction isSystemStoreOptedIn(): boolean {\n\tif (hasArgFlag(\"--use-system-ca\")) return true;\n\tconst value = process.env.HOOCODE_USE_SYSTEM_CA?.trim().toLowerCase();\n\treturn value === \"1\" || value === \"true\" || value === \"yes\";\n}\n\n/** De-duplicate certificate strings while preserving insertion order. */\nfunction dedupe(certs: string[]): string[] {\n\tconst seen = new Set<string>();\n\tconst result: string[] = [];\n\tfor (const cert of certs) {\n\t\tconst key = cert.trim();\n\t\tif (key.length === 0 || seen.has(key)) continue;\n\t\tseen.add(key);\n\t\tresult.push(cert);\n\t}\n\treturn result;\n}\n\n/**\n * Build the additive set of trusted CA certificates:\n * (a) Node's bundled root certificates (always),\n * (b) an explicit PEM bundle from the first configured source (always, when set),\n * (c) the OS trust store, ONLY when explicitly opted in.\n *\n * Never throws: every source that fails is warned-once and skipped, and the\n * bundled defaults are always retained.\n */\nexport function resolveTrustedCAs(): string[] {\n\tconst certs: string[] = [];\n\n\t// (a) Bundled defaults — always present so trust stays additive.\n\tfor (const cert of rootCertificates) {\n\t\tcerts.push(cert);\n\t}\n\n\t// (b) Explicit PEM bundle (first configured source wins).\n\tconst explicitPath = resolveExplicitCAPath();\n\tif (explicitPath) {\n\t\tconst bundle = readCABundle(explicitPath);\n\t\tif (bundle) certs.push(bundle);\n\t}\n\n\t// (c) OS trust store — opt-in only, and only if the runtime supports it.\n\tif (isSystemStoreOptedIn()) {\n\t\tif (typeof getCACertificates === \"function\") {\n\t\t\ttry {\n\t\t\t\tfor (const cert of getCACertificates(\"system\")) {\n\t\t\t\t\tcerts.push(cert);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\twarnOnce(`Could not read the system CA store, skipping: ${errorMessage(error)}`);\n\t\t\t}\n\t\t} else {\n\t\t\twarnOnce(\"System CA store requested, but this Node runtime does not support tls.getCACertificates().\");\n\t\t}\n\t}\n\n\treturn dedupe(certs);\n}\n\n/**\n * Install the resolved CA set on the global HTTPS agent and return it so the\n * caller can thread the same trust set into other dispatchers (e.g. undici).\n * Warns once if `NODE_TLS_REJECT_UNAUTHORIZED=0` is set, since that disables\n * verification globally and defeats the purpose of trusting a specific CA.\n */\nexport function configureGlobalTLS(): string[] {\n\tconst ca = resolveTrustedCAs();\n\tglobalAgent.options.ca = ca;\n\n\tif (process.env.NODE_TLS_REJECT_UNAUTHORIZED === \"0\") {\n\t\twarnOnce(\n\t\t\t\"NODE_TLS_REJECT_UNAUTHORIZED=0 disables all TLS certificate verification and is insecure. \" +\n\t\t\t\t\"Prefer --ca-cert <path> (or --use-system-ca) to trust your proxy's CA with verification kept on.\",\n\t\t);\n\t}\n\n\treturn ca;\n}\n"]}
@@ -1,5 +1,6 @@
1
1
  /** Tools whose binaries hoocode can resolve from PATH or download on demand. */
2
2
  export type ManagedTool = "fd" | "rg" | "webtools";
3
3
  export declare function getToolPath(tool: ManagedTool): string | null;
4
+ export declare function downloadFile(url: string, dest: string): Promise<void>;
4
5
  export declare function ensureTool(tool: ManagedTool, silent?: boolean): Promise<string | undefined>;
5
6
  //# sourceMappingURL=tools-manager.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tools-manager.d.ts","sourceRoot":"","sources":["../../src/utils/tools-manager.ts"],"names":[],"mappings":"AAoBA,gFAAgF;AAChF,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,IAAI,GAAG,UAAU,CAAC;AA4FnD,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAgC5D;AAiJD,wBAAsB,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,GAAE,OAAe,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA2CxG","sourcesContent":["import chalk from \"chalk\";\nimport { spawnSync } from \"child_process\";\nimport extractZip from \"extract-zip\";\nimport { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from \"fs\";\nimport { arch, platform } from \"os\";\nimport { join } from \"path\";\nimport { Readable } from \"stream\";\nimport { pipeline } from \"stream/promises\";\nimport { APP_NAME, getBinDir } from \"../config.js\";\n\nconst TOOLS_DIR = getBinDir();\nconst NETWORK_TIMEOUT_MS = 10_000;\nconst DOWNLOAD_TIMEOUT_MS = 120_000;\n\nfunction isOfflineModeEnabled(): boolean {\n\tconst value = process.env.HOOCODE_OFFLINE ?? process.env.PI_OFFLINE;\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\n/** Tools whose binaries hoocode can resolve from PATH or download on demand. */\nexport type ManagedTool = \"fd\" | \"rg\" | \"webtools\";\n\ninterface ToolConfig {\n\tname: string;\n\trepo: string; // GitHub repo (e.g., \"sharkdp/fd\")\n\tbinaryName: string; // Name of the binary inside the archive\n\tsystemBinaryNames?: string[]; // Alternative system command names to try before downloading\n\ttagPrefix: string; // Prefix for tags (e.g., \"v\" for v1.0.0, \"\" for 1.0.0)\n\tgetAssetName: (version: string, plat: string, architecture: string) => string | null;\n}\n\nconst TOOLS: Record<string, ToolConfig> = {\n\tfd: {\n\t\tname: \"fd\",\n\t\trepo: \"sharkdp/fd\",\n\t\tbinaryName: \"fd\",\n\t\tsystemBinaryNames: [\"fd\", \"fdfind\"],\n\t\ttagPrefix: \"v\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\trg: {\n\t\tname: \"ripgrep\",\n\t\trepo: \"BurntSushi/ripgrep\",\n\t\tbinaryName: \"rg\",\n\t\ttagPrefix: \"\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tif (architecture === \"arm64\") {\n\t\t\t\t\treturn `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;\n\t\t\t\t}\n\t\t\t\treturn `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\twebtools: {\n\t\tname: \"webtools\",\n\t\trepo: \"kolisachint/webtools\",\n\t\tbinaryName: \"webtools\",\n\t\ttagPrefix: \"v\",\n\t\t// Release assets follow Rust target triples: webtools-<arch>-<target>.<ext>.\n\t\t// Some platforms may not be published yet; a missing asset 404s and ensureTool\n\t\t// degrades gracefully (returns undefined, tools fall back to an error message).\n\t\tgetAssetName: (_version, plat, architecture) => {\n\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\treturn `webtools-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\treturn `webtools-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\treturn `webtools-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n};\n\n// Check if a command exists in PATH by trying to run it\nfunction commandExists(cmd: string): boolean {\n\ttry {\n\t\tconst result = spawnSync(cmd, [\"--version\"], { stdio: \"pipe\" });\n\t\t// Check for ENOENT error (command not found)\n\t\treturn result.error === undefined || result.error === null;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n// Resolved tool paths are stable for the life of the process. Cache the first\n// successful resolution so we never re-run the synchronous spawnSync probe in\n// commandExists() on every grep/find/glob invocation, which blocks the event loop.\nconst resolvedToolPathCache = new Map<ManagedTool, string>();\n\n// Get the path to a tool (system-wide or in our tools dir)\nexport function getToolPath(tool: ManagedTool): string | null {\n\tconst config = TOOLS[tool];\n\tif (!config) return null;\n\n\t// Reuse a previously resolved path. A bare command name resolves via PATH;\n\t// an absolute path must still exist (revalidate cheaply with existsSync).\n\tconst cached = resolvedToolPathCache.get(tool);\n\tif (cached !== undefined) {\n\t\tconst isAbsolutePath = cached.includes(\"/\") || cached.includes(\"\\\\\");\n\t\tif (!isAbsolutePath || existsSync(cached)) {\n\t\t\treturn cached;\n\t\t}\n\t\tresolvedToolPathCache.delete(tool);\n\t}\n\n\t// Check our tools directory first\n\tconst localPath = join(TOOLS_DIR, config.binaryName + (platform() === \"win32\" ? \".exe\" : \"\"));\n\tif (existsSync(localPath)) {\n\t\tresolvedToolPathCache.set(tool, localPath);\n\t\treturn localPath;\n\t}\n\n\t// Check system PATH - if found, just return the command name (it's in PATH)\n\tconst systemBinaryNames = config.systemBinaryNames ?? [config.binaryName];\n\tfor (const systemBinaryName of systemBinaryNames) {\n\t\tif (commandExists(systemBinaryName)) {\n\t\t\tresolvedToolPathCache.set(tool, systemBinaryName);\n\t\t\treturn systemBinaryName;\n\t\t}\n\t}\n\n\treturn null;\n}\n\n// Fetch latest release version from GitHub\nasync function getLatestVersion(repo: string): Promise<string> {\n\tconst response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {\n\t\theaders: { \"User-Agent\": `${APP_NAME}-coding-agent` },\n\t\tsignal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`GitHub API error: ${response.status}`);\n\t}\n\n\tconst data = (await response.json()) as { tag_name: string };\n\treturn data.tag_name.replace(/^v/, \"\");\n}\n\n// Download a file from URL\nasync function downloadFile(url: string, dest: string): Promise<void> {\n\tconst response = await fetch(url, {\n\t\tsignal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`Failed to download: ${response.status}`);\n\t}\n\n\tif (!response.body) {\n\t\tthrow new Error(\"No response body\");\n\t}\n\n\tconst fileStream = createWriteStream(dest);\n\tawait pipeline(Readable.fromWeb(response.body as any), fileStream);\n}\n\nfunction findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {\n\tconst stack: string[] = [rootDir];\n\n\twhile (stack.length > 0) {\n\t\tconst currentDir = stack.pop();\n\t\tif (!currentDir) continue;\n\n\t\tconst entries = readdirSync(currentDir, { withFileTypes: true });\n\t\tfor (const entry of entries) {\n\t\t\tconst fullPath = join(currentDir, entry.name);\n\t\t\tif (entry.isFile() && entry.name === binaryFileName) {\n\t\t\t\treturn fullPath;\n\t\t\t}\n\t\t\tif (entry.isDirectory()) {\n\t\t\t\tstack.push(fullPath);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn null;\n}\n\n// Download and install a tool\nasync function downloadTool(tool: ManagedTool): Promise<string> {\n\tconst config = TOOLS[tool];\n\tif (!config) throw new Error(`Unknown tool: ${tool}`);\n\n\tconst plat = platform();\n\tconst architecture = arch();\n\n\t// Get latest version\n\tconst version = await getLatestVersion(config.repo);\n\n\t// Get asset name for this platform\n\tconst assetName = config.getAssetName(version, plat, architecture);\n\tif (!assetName) {\n\t\tthrow new Error(`Unsupported platform: ${plat}/${architecture}`);\n\t}\n\n\t// Create tools directory\n\tmkdirSync(TOOLS_DIR, { recursive: true });\n\n\tconst downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;\n\tconst archivePath = join(TOOLS_DIR, assetName);\n\tconst binaryExt = plat === \"win32\" ? \".exe\" : \"\";\n\tconst binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);\n\n\t// Download\n\tawait downloadFile(downloadUrl, archivePath);\n\n\t// Extract into a unique temp directory. fd and rg downloads can run concurrently\n\t// during startup, so sharing a fixed directory causes races.\n\tconst extractDir = join(\n\t\tTOOLS_DIR,\n\t\t`extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,\n\t);\n\tmkdirSync(extractDir, { recursive: true });\n\n\ttry {\n\t\tif (assetName.endsWith(\".tar.gz\")) {\n\t\t\tconst extractResult = spawnSync(\"tar\", [\"xzf\", archivePath, \"-C\", extractDir], { stdio: \"pipe\" });\n\t\t\tif (extractResult.error || extractResult.status !== 0) {\n\t\t\t\tconst errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? \"unknown error\";\n\t\t\t\tthrow new Error(`Failed to extract ${assetName}: ${errMsg}`);\n\t\t\t}\n\t\t} else if (assetName.endsWith(\".zip\")) {\n\t\t\tawait extractZip(archivePath, { dir: extractDir });\n\t\t} else {\n\t\t\tthrow new Error(`Unsupported archive format: ${assetName}`);\n\t\t}\n\n\t\t// Find the binary in extracted files. Some archives contain files directly\n\t\t// at root, others nest under a versioned subdirectory.\n\t\tconst binaryFileName = config.binaryName + binaryExt;\n\t\tconst extractedDir = join(extractDir, assetName.replace(/\\.(tar\\.gz|zip)$/, \"\"));\n\t\tconst extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)];\n\t\tlet extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate));\n\n\t\tif (!extractedBinary) {\n\t\t\textractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined;\n\t\t}\n\n\t\tif (extractedBinary) {\n\t\t\trenameSync(extractedBinary, binaryPath);\n\t\t} else {\n\t\t\tthrow new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`);\n\t\t}\n\n\t\t// Make executable (Unix only)\n\t\tif (plat !== \"win32\") {\n\t\t\tchmodSync(binaryPath, 0o755);\n\t\t}\n\t} finally {\n\t\t// Cleanup\n\t\trmSync(archivePath, { force: true });\n\t\trmSync(extractDir, { recursive: true, force: true });\n\t}\n\n\treturn binaryPath;\n}\n\n// Termux package names for tools\nconst TERMUX_PACKAGES: Record<string, string> = {\n\tfd: \"fd\",\n\trg: \"ripgrep\",\n\twebtools: \"webtools\",\n};\n\n// Ensure a tool is available, downloading if necessary\n// Returns the path to the tool, or null if unavailable\nexport async function ensureTool(tool: ManagedTool, silent: boolean = false): Promise<string | undefined> {\n\tconst existingPath = getToolPath(tool);\n\tif (existingPath) {\n\t\treturn existingPath;\n\t}\n\n\tconst config = TOOLS[tool];\n\tif (!config) return undefined;\n\n\tif (isOfflineModeEnabled()) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.\n\t// Users must install via pkg.\n\tif (platform() === \"android\") {\n\t\tconst pkgName = TERMUX_PACKAGES[tool] ?? tool;\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// Tool not found - download it\n\tif (!silent) {\n\t\tconsole.log(chalk.dim(`${config.name} not found. Downloading...`));\n\t}\n\n\ttry {\n\t\tconst path = await downloadTool(tool);\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.dim(`${config.name} installed to ${path}`));\n\t\t}\n\t\treturn path;\n\t} catch (e) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));\n\t\t}\n\t\treturn undefined;\n\t}\n}\n"]}
1
+ {"version":3,"file":"tools-manager.d.ts","sourceRoot":"","sources":["../../src/utils/tools-manager.ts"],"names":[],"mappings":"AA+BA,gFAAgF;AAChF,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,IAAI,GAAG,UAAU,CAAC;AA4FnD,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAgC5D;AAqDD,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoC3E;AA0ID,wBAAsB,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,GAAE,OAAe,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA2CxG","sourcesContent":["import chalk from \"chalk\";\nimport { spawnSync } from \"child_process\";\nimport { createHash, randomBytes } from \"crypto\";\nimport extractZip from \"extract-zip\";\nimport {\n\tchmodSync,\n\tcreateWriteStream,\n\texistsSync,\n\tmkdirSync,\n\treaddirSync,\n\treadFileSync,\n\trenameSync,\n\trmSync,\n\tstatSync,\n} from \"fs\";\nimport { arch, platform } from \"os\";\nimport { join } from \"path\";\nimport { Readable } from \"stream\";\nimport { pipeline } from \"stream/promises\";\nimport { APP_NAME, getBinDir } from \"../config.js\";\n\nconst TOOLS_DIR = getBinDir();\nconst NETWORK_TIMEOUT_MS = 10_000;\nconst DOWNLOAD_TIMEOUT_MS = 120_000;\n\nfunction isOfflineModeEnabled(): boolean {\n\tconst value = process.env.HOOCODE_OFFLINE ?? process.env.PI_OFFLINE;\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\n/** Tools whose binaries hoocode can resolve from PATH or download on demand. */\nexport type ManagedTool = \"fd\" | \"rg\" | \"webtools\";\n\ninterface ToolConfig {\n\tname: string;\n\trepo: string; // GitHub repo (e.g., \"sharkdp/fd\")\n\tbinaryName: string; // Name of the binary inside the archive\n\tsystemBinaryNames?: string[]; // Alternative system command names to try before downloading\n\ttagPrefix: string; // Prefix for tags (e.g., \"v\" for v1.0.0, \"\" for 1.0.0)\n\tgetAssetName: (version: string, plat: string, architecture: string) => string | null;\n}\n\nconst TOOLS: Record<string, ToolConfig> = {\n\tfd: {\n\t\tname: \"fd\",\n\t\trepo: \"sharkdp/fd\",\n\t\tbinaryName: \"fd\",\n\t\tsystemBinaryNames: [\"fd\", \"fdfind\"],\n\t\ttagPrefix: \"v\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `fd-v${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\trg: {\n\t\tname: \"ripgrep\",\n\t\trepo: \"BurntSushi/ripgrep\",\n\t\tbinaryName: \"rg\",\n\t\ttagPrefix: \"\",\n\t\tgetAssetName: (version, plat, architecture) => {\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\tif (architecture === \"arm64\") {\n\t\t\t\t\treturn `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;\n\t\t\t\t}\n\t\t\t\treturn `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\t\treturn `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n\twebtools: {\n\t\tname: \"webtools\",\n\t\trepo: \"kolisachint/webtools\",\n\t\tbinaryName: \"webtools\",\n\t\ttagPrefix: \"v\",\n\t\t// Release assets follow Rust target triples: webtools-<arch>-<target>.<ext>.\n\t\t// Some platforms may not be published yet; a missing asset 404s and ensureTool\n\t\t// degrades gracefully (returns undefined, tools fall back to an error message).\n\t\tgetAssetName: (_version, plat, architecture) => {\n\t\t\tconst archStr = architecture === \"arm64\" ? \"aarch64\" : \"x86_64\";\n\t\t\tif (plat === \"darwin\") {\n\t\t\t\treturn `webtools-${archStr}-apple-darwin.tar.gz`;\n\t\t\t} else if (plat === \"linux\") {\n\t\t\t\treturn `webtools-${archStr}-unknown-linux-gnu.tar.gz`;\n\t\t\t} else if (plat === \"win32\") {\n\t\t\t\treturn `webtools-${archStr}-pc-windows-msvc.zip`;\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t},\n};\n\n// Check if a command exists in PATH by trying to run it\nfunction commandExists(cmd: string): boolean {\n\ttry {\n\t\tconst result = spawnSync(cmd, [\"--version\"], { stdio: \"pipe\" });\n\t\t// Check for ENOENT error (command not found)\n\t\treturn result.error === undefined || result.error === null;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n// Resolved tool paths are stable for the life of the process. Cache the first\n// successful resolution so we never re-run the synchronous spawnSync probe in\n// commandExists() on every grep/find/glob invocation, which blocks the event loop.\nconst resolvedToolPathCache = new Map<ManagedTool, string>();\n\n// Get the path to a tool (system-wide or in our tools dir)\nexport function getToolPath(tool: ManagedTool): string | null {\n\tconst config = TOOLS[tool];\n\tif (!config) return null;\n\n\t// Reuse a previously resolved path. A bare command name resolves via PATH;\n\t// an absolute path must still exist (revalidate cheaply with existsSync).\n\tconst cached = resolvedToolPathCache.get(tool);\n\tif (cached !== undefined) {\n\t\tconst isAbsolutePath = cached.includes(\"/\") || cached.includes(\"\\\\\");\n\t\tif (!isAbsolutePath || existsSync(cached)) {\n\t\t\treturn cached;\n\t\t}\n\t\tresolvedToolPathCache.delete(tool);\n\t}\n\n\t// Check our tools directory first\n\tconst localPath = join(TOOLS_DIR, config.binaryName + (platform() === \"win32\" ? \".exe\" : \"\"));\n\tif (existsSync(localPath)) {\n\t\tresolvedToolPathCache.set(tool, localPath);\n\t\treturn localPath;\n\t}\n\n\t// Check system PATH - if found, just return the command name (it's in PATH)\n\tconst systemBinaryNames = config.systemBinaryNames ?? [config.binaryName];\n\tfor (const systemBinaryName of systemBinaryNames) {\n\t\tif (commandExists(systemBinaryName)) {\n\t\t\tresolvedToolPathCache.set(tool, systemBinaryName);\n\t\t\treturn systemBinaryName;\n\t\t}\n\t}\n\n\treturn null;\n}\n\n// Fetch latest release version from GitHub\nasync function getLatestVersion(repo: string): Promise<string> {\n\tconst response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {\n\t\theaders: { \"User-Agent\": `${APP_NAME}-coding-agent` },\n\t\tsignal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`GitHub API error: ${response.status}`);\n\t}\n\n\tconst data = (await response.json()) as { tag_name: string };\n\treturn data.tag_name.replace(/^v/, \"\");\n}\n\n// Best-effort SHA-256 verification: fetch \"<downloadUrl>.sha256\" and, when it is\n// served (HTTP 200), verify the downloaded file against it. A 404 (or any other\n// non-200 / network error) means no published checksum, so verification is\n// skipped rather than treated as a failure. A genuine mismatch throws.\nasync function verifyChecksum(downloadUrl: string, filePath: string): Promise<void> {\n\tlet checksumResponse: Awaited<ReturnType<typeof fetch>>;\n\ttry {\n\t\tchecksumResponse = await fetch(`${downloadUrl}.sha256`, {\n\t\t\tsignal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),\n\t\t});\n\t} catch {\n\t\t// Network error fetching the checksum is non-fatal for best-effort verification.\n\t\treturn;\n\t}\n\n\tif (checksumResponse.status !== 200) {\n\t\treturn;\n\t}\n\n\t// sha256 files are commonly \"<hex> <filename>\"; take the leading token.\n\tconst expectedHash = (await checksumResponse.text()).trim().split(/\\s+/)[0]?.toLowerCase();\n\tif (!expectedHash || !/^[0-9a-f]{64}$/.test(expectedHash)) {\n\t\t// Unusable checksum body: skip rather than fail (still best-effort).\n\t\treturn;\n\t}\n\n\tconst actualHash = createHash(\"sha256\").update(readFileSync(filePath)).digest(\"hex\");\n\tif (actualHash !== expectedHash) {\n\t\tthrow new Error(`Checksum mismatch for ${downloadUrl}: expected ${expectedHash}, got ${actualHash}`);\n\t}\n}\n\n// Download a file from URL into `dest`, validating integrity. Throws (and removes\n// the partial file) on a truncated transfer (bytes written != Content-Length when\n// the header is present) or a SHA-256 mismatch, so a corrupt artifact is never\n// left behind. Exported for tests.\nexport async function downloadFile(url: string, dest: string): Promise<void> {\n\ttry {\n\t\tconst response = await fetch(url, {\n\t\t\tsignal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to download: ${response.status}`);\n\t\t}\n\n\t\tif (!response.body) {\n\t\t\tthrow new Error(\"No response body\");\n\t\t}\n\n\t\tconst contentLengthHeader = response.headers.get(\"content-length\");\n\t\tconst expectedBytes =\n\t\t\tcontentLengthHeader !== null && contentLengthHeader.trim() !== \"\" ? Number(contentLengthHeader) : null;\n\n\t\tconst fileStream = createWriteStream(dest);\n\t\tawait pipeline(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]), fileStream);\n\n\t\tif (expectedBytes !== null && Number.isFinite(expectedBytes)) {\n\t\t\tconst bytesWritten = statSync(dest).size;\n\t\t\tif (bytesWritten !== expectedBytes) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Truncated download from ${url}: expected ${expectedBytes} bytes, received ${bytesWritten}`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tawait verifyChecksum(url, dest);\n\t} catch (e) {\n\t\t// Never leave a partial/corrupt file behind on any failure.\n\t\trmSync(dest, { force: true });\n\t\tthrow e;\n\t}\n}\n\nfunction findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {\n\tconst stack: string[] = [rootDir];\n\n\twhile (stack.length > 0) {\n\t\tconst currentDir = stack.pop();\n\t\tif (!currentDir) continue;\n\n\t\tconst entries = readdirSync(currentDir, { withFileTypes: true });\n\t\tfor (const entry of entries) {\n\t\t\tconst fullPath = join(currentDir, entry.name);\n\t\t\tif (entry.isFile() && entry.name === binaryFileName) {\n\t\t\t\treturn fullPath;\n\t\t\t}\n\t\t\tif (entry.isDirectory()) {\n\t\t\t\tstack.push(fullPath);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn null;\n}\n\n// Download and install a tool\nasync function downloadTool(tool: ManagedTool): Promise<string> {\n\tconst config = TOOLS[tool];\n\tif (!config) throw new Error(`Unknown tool: ${tool}`);\n\n\tconst plat = platform();\n\tconst architecture = arch();\n\n\t// Get latest version\n\tconst version = await getLatestVersion(config.repo);\n\n\t// Get asset name for this platform\n\tconst assetName = config.getAssetName(version, plat, architecture);\n\tif (!assetName) {\n\t\tthrow new Error(`Unsupported platform: ${plat}/${architecture}`);\n\t}\n\n\t// Create tools directory\n\tmkdirSync(TOOLS_DIR, { recursive: true });\n\n\tconst downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;\n\tconst archivePath = join(TOOLS_DIR, assetName);\n\tconst binaryExt = plat === \"win32\" ? \".exe\" : \"\";\n\tconst binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);\n\n\t// Download to a unique temp path, validate, then atomically rename into place.\n\t// Writing the shared archive path directly would leave a corrupt partial behind\n\t// if the transfer fails or is truncated. fd/rg/webtools can also download\n\t// concurrently at startup, so the per-attempt temp name must be unique.\n\tconst tempArchivePath = `${archivePath}.${process.pid}.${randomBytes(6).toString(\"hex\")}.part`;\n\n\t// Extract into a unique temp directory. fd and rg downloads can run concurrently\n\t// during startup, so sharing a fixed directory causes races.\n\tconst extractDir = join(\n\t\tTOOLS_DIR,\n\t\t`extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,\n\t);\n\n\ttry {\n\t\t// One retry (2 attempts total) around download + integrity verification.\n\t\t// downloadFile removes its own partial on failure, so each attempt is clean.\n\t\tlet lastError: unknown;\n\t\tlet downloaded = false;\n\t\tfor (let attempt = 1; attempt <= 2 && !downloaded; attempt++) {\n\t\t\ttry {\n\t\t\t\tawait downloadFile(downloadUrl, tempArchivePath);\n\t\t\t\tdownloaded = true;\n\t\t\t} catch (e) {\n\t\t\t\tlastError = e;\n\t\t\t\trmSync(tempArchivePath, { force: true });\n\t\t\t}\n\t\t}\n\t\tif (!downloaded) {\n\t\t\tthrow lastError instanceof Error ? lastError : new Error(String(lastError));\n\t\t}\n\n\t\t// Atomic publish of the verified archive, then extract.\n\t\trenameSync(tempArchivePath, archivePath);\n\t\tmkdirSync(extractDir, { recursive: true });\n\n\t\tif (assetName.endsWith(\".tar.gz\")) {\n\t\t\tconst extractResult = spawnSync(\"tar\", [\"xzf\", archivePath, \"-C\", extractDir], { stdio: \"pipe\" });\n\t\t\tif (extractResult.error || extractResult.status !== 0) {\n\t\t\t\tconst errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? \"unknown error\";\n\t\t\t\tthrow new Error(`Failed to extract ${assetName}: ${errMsg}`);\n\t\t\t}\n\t\t} else if (assetName.endsWith(\".zip\")) {\n\t\t\tawait extractZip(archivePath, { dir: extractDir });\n\t\t} else {\n\t\t\tthrow new Error(`Unsupported archive format: ${assetName}`);\n\t\t}\n\n\t\t// Find the binary in extracted files. Some archives contain files directly\n\t\t// at root, others nest under a versioned subdirectory.\n\t\tconst binaryFileName = config.binaryName + binaryExt;\n\t\tconst extractedDir = join(extractDir, assetName.replace(/\\.(tar\\.gz|zip)$/, \"\"));\n\t\tconst extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)];\n\t\tlet extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate));\n\n\t\tif (!extractedBinary) {\n\t\t\textractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined;\n\t\t}\n\n\t\tif (extractedBinary) {\n\t\t\trenameSync(extractedBinary, binaryPath);\n\t\t} else {\n\t\t\tthrow new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`);\n\t\t}\n\n\t\t// Make executable (Unix only)\n\t\tif (plat !== \"win32\") {\n\t\t\tchmodSync(binaryPath, 0o755);\n\t\t}\n\t} finally {\n\t\t// Guaranteed cleanup of every transient artifact on ANY outcome: the temp\n\t\t// download (if a failure left it before the rename), the published archive,\n\t\t// and the temp extract dir.\n\t\trmSync(tempArchivePath, { force: true });\n\t\trmSync(archivePath, { force: true });\n\t\trmSync(extractDir, { recursive: true, force: true });\n\t}\n\n\treturn binaryPath;\n}\n\n// Termux package names for tools\nconst TERMUX_PACKAGES: Record<string, string> = {\n\tfd: \"fd\",\n\trg: \"ripgrep\",\n\twebtools: \"webtools\",\n};\n\n// Ensure a tool is available, downloading if necessary\n// Returns the path to the tool, or null if unavailable\nexport async function ensureTool(tool: ManagedTool, silent: boolean = false): Promise<string | undefined> {\n\tconst existingPath = getToolPath(tool);\n\tif (existingPath) {\n\t\treturn existingPath;\n\t}\n\n\tconst config = TOOLS[tool];\n\tif (!config) return undefined;\n\n\tif (isOfflineModeEnabled()) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.\n\t// Users must install via pkg.\n\tif (platform() === \"android\") {\n\t\tconst pkgName = TERMUX_PACKAGES[tool] ?? tool;\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t// Tool not found - download it\n\tif (!silent) {\n\t\tconsole.log(chalk.dim(`${config.name} not found. Downloading...`));\n\t}\n\n\ttry {\n\t\tconst path = await downloadTool(tool);\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.dim(`${config.name} installed to ${path}`));\n\t\t}\n\t\treturn path;\n\t} catch (e) {\n\t\tif (!silent) {\n\t\t\tconsole.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));\n\t\t}\n\t\treturn undefined;\n\t}\n}\n"]}
@@ -1,7 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { spawnSync } from "child_process";
3
+ import { createHash, randomBytes } from "crypto";
3
4
  import extractZip from "extract-zip";
4
- import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs";
5
+ import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, } from "fs";
5
6
  import { arch, platform } from "os";
6
7
  import { join } from "path";
7
8
  import { Readable } from "stream";
@@ -143,19 +144,67 @@ async function getLatestVersion(repo) {
143
144
  const data = (await response.json());
144
145
  return data.tag_name.replace(/^v/, "");
145
146
  }
146
- // Download a file from URL
147
- async function downloadFile(url, dest) {
148
- const response = await fetch(url, {
149
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
150
- });
151
- if (!response.ok) {
152
- throw new Error(`Failed to download: ${response.status}`);
147
+ // Best-effort SHA-256 verification: fetch "<downloadUrl>.sha256" and, when it is
148
+ // served (HTTP 200), verify the downloaded file against it. A 404 (or any other
149
+ // non-200 / network error) means no published checksum, so verification is
150
+ // skipped rather than treated as a failure. A genuine mismatch throws.
151
+ async function verifyChecksum(downloadUrl, filePath) {
152
+ let checksumResponse;
153
+ try {
154
+ checksumResponse = await fetch(`${downloadUrl}.sha256`, {
155
+ signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),
156
+ });
157
+ }
158
+ catch {
159
+ // Network error fetching the checksum is non-fatal for best-effort verification.
160
+ return;
161
+ }
162
+ if (checksumResponse.status !== 200) {
163
+ return;
164
+ }
165
+ // sha256 files are commonly "<hex> <filename>"; take the leading token.
166
+ const expectedHash = (await checksumResponse.text()).trim().split(/\s+/)[0]?.toLowerCase();
167
+ if (!expectedHash || !/^[0-9a-f]{64}$/.test(expectedHash)) {
168
+ // Unusable checksum body: skip rather than fail (still best-effort).
169
+ return;
170
+ }
171
+ const actualHash = createHash("sha256").update(readFileSync(filePath)).digest("hex");
172
+ if (actualHash !== expectedHash) {
173
+ throw new Error(`Checksum mismatch for ${downloadUrl}: expected ${expectedHash}, got ${actualHash}`);
174
+ }
175
+ }
176
+ // Download a file from URL into `dest`, validating integrity. Throws (and removes
177
+ // the partial file) on a truncated transfer (bytes written != Content-Length when
178
+ // the header is present) or a SHA-256 mismatch, so a corrupt artifact is never
179
+ // left behind. Exported for tests.
180
+ export async function downloadFile(url, dest) {
181
+ try {
182
+ const response = await fetch(url, {
183
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
184
+ });
185
+ if (!response.ok) {
186
+ throw new Error(`Failed to download: ${response.status}`);
187
+ }
188
+ if (!response.body) {
189
+ throw new Error("No response body");
190
+ }
191
+ const contentLengthHeader = response.headers.get("content-length");
192
+ const expectedBytes = contentLengthHeader !== null && contentLengthHeader.trim() !== "" ? Number(contentLengthHeader) : null;
193
+ const fileStream = createWriteStream(dest);
194
+ await pipeline(Readable.fromWeb(response.body), fileStream);
195
+ if (expectedBytes !== null && Number.isFinite(expectedBytes)) {
196
+ const bytesWritten = statSync(dest).size;
197
+ if (bytesWritten !== expectedBytes) {
198
+ throw new Error(`Truncated download from ${url}: expected ${expectedBytes} bytes, received ${bytesWritten}`);
199
+ }
200
+ }
201
+ await verifyChecksum(url, dest);
153
202
  }
154
- if (!response.body) {
155
- throw new Error("No response body");
203
+ catch (e) {
204
+ // Never leave a partial/corrupt file behind on any failure.
205
+ rmSync(dest, { force: true });
206
+ throw e;
156
207
  }
157
- const fileStream = createWriteStream(dest);
158
- await pipeline(Readable.fromWeb(response.body), fileStream);
159
208
  }
160
209
  function findBinaryRecursively(rootDir, binaryFileName) {
161
210
  const stack = [rootDir];
@@ -196,13 +245,35 @@ async function downloadTool(tool) {
196
245
  const archivePath = join(TOOLS_DIR, assetName);
197
246
  const binaryExt = plat === "win32" ? ".exe" : "";
198
247
  const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);
199
- // Download
200
- await downloadFile(downloadUrl, archivePath);
248
+ // Download to a unique temp path, validate, then atomically rename into place.
249
+ // Writing the shared archive path directly would leave a corrupt partial behind
250
+ // if the transfer fails or is truncated. fd/rg/webtools can also download
251
+ // concurrently at startup, so the per-attempt temp name must be unique.
252
+ const tempArchivePath = `${archivePath}.${process.pid}.${randomBytes(6).toString("hex")}.part`;
201
253
  // Extract into a unique temp directory. fd and rg downloads can run concurrently
202
254
  // during startup, so sharing a fixed directory causes races.
203
255
  const extractDir = join(TOOLS_DIR, `extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
204
- mkdirSync(extractDir, { recursive: true });
205
256
  try {
257
+ // One retry (2 attempts total) around download + integrity verification.
258
+ // downloadFile removes its own partial on failure, so each attempt is clean.
259
+ let lastError;
260
+ let downloaded = false;
261
+ for (let attempt = 1; attempt <= 2 && !downloaded; attempt++) {
262
+ try {
263
+ await downloadFile(downloadUrl, tempArchivePath);
264
+ downloaded = true;
265
+ }
266
+ catch (e) {
267
+ lastError = e;
268
+ rmSync(tempArchivePath, { force: true });
269
+ }
270
+ }
271
+ if (!downloaded) {
272
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
273
+ }
274
+ // Atomic publish of the verified archive, then extract.
275
+ renameSync(tempArchivePath, archivePath);
276
+ mkdirSync(extractDir, { recursive: true });
206
277
  if (assetName.endsWith(".tar.gz")) {
207
278
  const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" });
208
279
  if (extractResult.error || extractResult.status !== 0) {
@@ -237,7 +308,10 @@ async function downloadTool(tool) {
237
308
  }
238
309
  }
239
310
  finally {
240
- // Cleanup
311
+ // Guaranteed cleanup of every transient artifact on ANY outcome: the temp
312
+ // download (if a failure left it before the rename), the published archive,
313
+ // and the temp extract dir.
314
+ rmSync(tempArchivePath, { force: true });
241
315
  rmSync(archivePath, { force: true });
242
316
  rmSync(extractDir, { recursive: true, force: true });
243
317
  }