From 4650eb34737103ae8fccfcebfc63a30f8f8c569e Mon Sep 17 00:00:00 2001 From: Totoro Date: Fri, 11 Sep 2026 00:35:01 +0800 Subject: [PATCH 1/3] fix(runtime): name ripgrep when Grep cannot run it Grep failed with a bare `spawn rg ENOENT` on the local path and a generic "Grep is unavailable in this runtime." in the filesystem worker. Both now fail with grep_unavailable and copy that names ripgrep, an install command and the next step (retry locally, restart Maka for the worker). The local path checks that the cwd still exists before blaming ripgrep, because Node reports a missing spawn cwd with the same ENOENT. The worker maps a spawn ENOENT for its startup-resolved executable to grep_unavailable instead of not_found, which read as a missing search path. Fixes #5167 Generated-by: Claude Code Co-Authored-By: Claude Opus 5 --- .../src/__tests__/filesystem-worker.test.ts | 61 +++++++++++++ .../src/__tests__/workspace-executor.test.ts | 89 ++++++++++++++++++- .../src/filesystem-worker/operations.ts | 15 +++- packages/runtime/src/ripgrep-guidance.ts | 66 ++++++++++++++ packages/runtime/src/workspace-executor.ts | 12 +++ 5 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 packages/runtime/src/ripgrep-guidance.ts diff --git a/packages/runtime/src/__tests__/filesystem-worker.test.ts b/packages/runtime/src/__tests__/filesystem-worker.test.ts index d4bf0a755b..c9d2073127 100644 --- a/packages/runtime/src/__tests__/filesystem-worker.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker.test.ts @@ -243,6 +243,67 @@ describe('filesystem worker operations', () => { }); }); + test('names ripgrep and the restart when the runtime started without it (#5167)', async () => { + const root = await temporaryDirectory('maka-worker-grep-missing-'); + const target = join(root, 'file.ts'); + await writeFile(target, 'const healthSignal = true;', 'utf8'); + + const response = await executeFilesystemWorkerRequest( + await requestFor( + { + kind: 'grep', + cwd: root, + path: target, + pattern: 'healthSignal', + maxCountPerFile: 50, + limit: 200, + timeoutMs: 1_000, + }, + { enforcementPath: target, access: 'read', scope: 'exact', targetType: 'file' }, + ), + {}, + ); + + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.code, 'grep_unavailable'); + assert.match(response.error.message, /ripgrep/); + assert.match(response.error.message, /restart Maka/); + } + }); + + test('reports a ripgrep that vanished after startup as unavailable, not as a missing search path', async () => { + // Launch-time resolution pins the realpath (e.g. a versioned Homebrew + // keg); an upgrade can delete it while the runtime keeps running. + const root = await temporaryDirectory('maka-worker-grep-vanished-'); + const target = join(root, 'file.ts'); + const vanished = join(root, 'uninstalled', 'rg'); + await writeFile(target, 'const healthSignal = true;', 'utf8'); + + const response = await executeFilesystemWorkerRequest( + await requestFor( + { + kind: 'grep', + cwd: root, + path: target, + pattern: 'healthSignal', + maxCountPerFile: 50, + limit: 200, + timeoutMs: 1_000, + }, + { enforcementPath: target, access: 'read', scope: 'exact', targetType: 'file' }, + ), + { grepExecutable: vanished }, + ); + + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.code, 'grep_unavailable'); + assert.ok(response.error.message.includes(vanished)); + assert.match(response.error.message, /restart Maka/); + } + }); + test('passes option-like Grep patterns after a `--` separator', async () => { const root = await temporaryDirectory('maka-worker-grep-option-like-'); const target = join(root, 'file.ts'); diff --git a/packages/runtime/src/__tests__/workspace-executor.test.ts b/packages/runtime/src/__tests__/workspace-executor.test.ts index e979230685..459d687fc2 100644 --- a/packages/runtime/src/__tests__/workspace-executor.test.ts +++ b/packages/runtime/src/__tests__/workspace-executor.test.ts @@ -19,7 +19,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, truncate, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, truncate, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { LocalWorkspaceExecutor } from '../workspace-executor.js'; @@ -274,4 +274,91 @@ describe('LocalWorkspaceExecutor file operations', () => { `${join(cwd, 'src', 'main.ts')}:1:export const token = 1; // --flag`, ]); }); + + test('reports a missing ripgrep as grep_unavailable with an install hint (#5167)', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'maka-workspace-grep-no-rg-')); + const emptyBin = await mkdtemp(join(tmpdir(), 'maka-workspace-grep-empty-path-')); + const executor = new LocalWorkspaceExecutor(); + + await withPath(emptyBin, () => + assert.rejects( + executor.grepFiles({ + cwd, + pattern: 'token', + path: cwd, + maxCountPerFile: 50, + limit: 200, + timeoutMs: 5_000, + }), + (error: NodeJS.ErrnoException) => { + assert.equal(error.code, 'grep_unavailable'); + assert.match(error.message, /ripgrep/); + assert.match(error.message, /BurntSushi\/ripgrep/); + return true; + }, + ), + ); + }); + + test('keeps a missing working directory distinct from a missing ripgrep', async () => { + // Node reports a missing spawn cwd exactly like a missing executable + // (`spawn rg ENOENT`), so the command name alone cannot tell them apart. + const parent = await mkdtemp(join(tmpdir(), 'maka-workspace-grep-gone-cwd-')); + const cwd = join(parent, 'deleted'); + await mkdir(cwd); + await writeFile(join(parent, 'kept.ts'), 'token', 'utf8'); + await rm(cwd, { recursive: true }); + const executor = new LocalWorkspaceExecutor(); + + await assert.rejects( + executor.grepFiles({ + cwd, + pattern: 'token', + path: parent, + maxCountPerFile: 50, + limit: 200, + timeoutMs: 5_000, + }), + (error: NodeJS.ErrnoException) => { + assert.equal(error.code, 'ENOENT'); + assert.doesNotMatch(error.message, /ripgrep/); + return true; + }, + ); + }); + + test('leaves other spawn failures, such as a non-executable rg, untouched', { + skip: process.platform === 'win32' ? 'POSIX execute bit' : false, + }, async () => { + const cwd = await mkdtemp(join(tmpdir(), 'maka-workspace-grep-eacces-')); + const bin = await mkdtemp(join(tmpdir(), 'maka-workspace-grep-noexec-bin-')); + await writeFile(join(bin, 'rg'), '#!/bin/sh\n', 'utf8'); + await chmod(join(bin, 'rg'), 0o644); + const executor = new LocalWorkspaceExecutor(); + + await withPath(bin, () => + assert.rejects( + executor.grepFiles({ + cwd, + pattern: 'token', + path: cwd, + maxCountPerFile: 50, + limit: 200, + timeoutMs: 5_000, + }), + { code: 'EACCES' }, + ), + ); + }); }); + +async function withPath(path: string, run: () => Promise): Promise { + const original = process.env.PATH; + process.env.PATH = path; + try { + return await run(); + } finally { + if (original === undefined) delete process.env.PATH; + else process.env.PATH = original; + } +} diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 40f956aeeb..05b149eedd 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -22,6 +22,7 @@ import { promises as fs } from 'node:fs'; import { glob as nodeGlob } from 'node:fs/promises'; import { dirname, isAbsolute, parse, resolve } from 'node:path'; import { isPathInside } from '../path-containment.js'; +import { ripgrepMissingAtStartupMessage, ripgrepVanishedMessage } from '../ripgrep-guidance.js'; import { sandboxPathApi } from './sandbox-paths.js'; import { sandboxBoundaryExpansionAllowsPath } from '@maka/core/sandbox-boundary'; import { @@ -409,18 +410,26 @@ export async function executeFilesystemOperation( 'Grep is not available inside the Windows sandbox preview; use Glob and Read instead.', ); } - if (!dependencies.grepExecutable) - throw operationError('grep_unavailable', 'Grep is unavailable in this runtime.'); + const grepExecutable = dependencies.grepExecutable; + if (!grepExecutable) + throw operationError('grep_unavailable', ripgrepMissingAtStartupMessage()); const args = ['-n', '--no-heading', `--max-count=${operation.maxCountPerFile}`]; if (operation.glob) args.push('--glob', operation.glob); args.push('--', operation.pattern, path); const result = await (dependencies.runGrep ?? runRipgrep)({ - executable: dependencies.grepExecutable, + executable: grepExecutable, args, // The target is canonical and absolute. Running from its filesystem root avoids // requiring operation-scoped workers to read the broader session workspace. cwd: parse(path).root, timeoutMs: operation.timeoutMs, + }).catch((error: unknown) => { + // The cwd is a filesystem root, which always exists, so a spawn ENOENT + // means the executable resolved at startup is gone. Left alone it would + // be normalized to `not_found` and read as a missing search path. + if (nodeErrorCode(error) === 'ENOENT') + throw operationError('grep_unavailable', ripgrepVanishedMessage(grepExecutable)); + throw error; }); if (result.exitCode === 1) return { kind: 'grep', matches: [] }; if (result.exitCode !== 0) { diff --git a/packages/runtime/src/ripgrep-guidance.ts b/packages/runtime/src/ripgrep-guidance.ts new file mode 100644 index 0000000000..d6678f9a0e --- /dev/null +++ b/packages/runtime/src/ripgrep-guidance.ts @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Grep's only engine is ripgrep, and nothing ships it (#5167). These builders + * keep the missing-ripgrep copy identical on the local executor and the + * filesystem worker, so the model and the user learn the same fix whichever + * path ran the search. The Windows sandbox refusal deliberately stays in the + * worker: that is a capability limit of the AppContainer, not a missing + * dependency. + */ + +const RIPGREP_INSTALL_URL = 'https://github.com/BurntSushi/ripgrep#installation'; + +function ripgrepInstallHint(platform: NodeJS.Platform): string { + const command = + platform === 'darwin' + ? '`brew install ripgrep`' + : platform === 'win32' + ? '`winget install BurntSushi.ripgrep.MSVC`' + : 'your package manager (for example `apt install ripgrep`)'; + return `Install it with ${command}, or see ${RIPGREP_INSTALL_URL}.`; +} + +/** The local executor looks `rg` up on PATH at every call, so a retry suffices. */ +export function ripgrepMissingOnPathMessage(platform: NodeJS.Platform = process.platform): string { + return `Grep requires ripgrep (\`rg\`), which was not found on PATH. ${ripgrepInstallHint(platform)} Then retry.`; +} + +/** The worker resolves ripgrep once, when the runtime starts. */ +export function ripgrepMissingAtStartupMessage( + platform: NodeJS.Platform = process.platform, +): string { + return `Grep requires ripgrep (\`rg\`), but no usable copy was found when Maka started. ${ripgrepInstallHint(platform)} Then restart Maka.`; +} + +/** The executable resolved at startup is gone (e.g. a package upgrade removed it). */ +export function ripgrepVanishedMessage(executable: string): string { + return `Grep could not start ripgrep at ${executable}; it was moved or removed after Maka started (for example by a package upgrade). Reinstall ripgrep if needed, then restart Maka.`; +} + +/** Local-executor twin of the worker protocol's `grep_unavailable` error. */ +export class RipgrepUnavailableError extends Error { + readonly code = 'grep_unavailable'; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'RipgrepUnavailableError'; + } +} diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index f6fb22c7c9..4ba10bd6e5 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -35,6 +35,7 @@ import { } from './file-stable-write.js'; import { promisify } from 'node:util'; import type { ToolExecutionFacts } from '@maka/core/permission'; +import { RipgrepUnavailableError, ripgrepMissingOnPathMessage } from './ripgrep-guidance.js'; import { runProcessWithBoundedTail, runShellWithBoundedTail } from './shell-exec.js'; import type { ChildFdInput } from './child-fd-input.js'; import type { ShellPlan } from './shell-detect.js'; @@ -459,11 +460,22 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { return { matches: stdout.split('\n').filter(Boolean).slice(0, input.limit) }; } catch (error: any) { if (error?.code === 1) return { matches: [] }; + // Node reports a missing spawn cwd exactly like a missing executable + // (both `spawn rg ENOENT`), so only blame ripgrep once the cwd exists. + if (error?.code === 'ENOENT' && (await isDirectory(input.cwd))) + throw new RipgrepUnavailableError(ripgrepMissingOnPathMessage(), { cause: error }); throw error; } } } +async function isDirectory(path: string): Promise { + return await fs.stat(path).then( + (stat) => stat.isDirectory(), + () => false, + ); +} + export function createLocalWorkspaceExecutor(): WorkspaceExecutor { return new LocalWorkspaceExecutor(); } From 1854c1342a29e12452a49ee731415556610d3bdf Mon Sep 17 00:00:00 2001 From: Totoro Date: Fri, 11 Sep 2026 01:00:23 +0800 Subject: [PATCH 2/3] ci: track ripgrep-guidance in the Windows gates The shared ripgrep copy is imported by the filesystem worker and the builtin tools, so it joins the Windows package closure and the recovery filter. Register the new POSIX-only EACCES test in the Windows skip inventory as a platform contract. Generated-by: Claude Code Co-Authored-By: Claude Opus 5 --- .github/workflows/release-windows-check.yml | 1 + .github/workflows/windows-recovery.yml | 1 + docs/windows-test-inventory.md | 5 +++-- packages/runtime/src/__tests__/workspace-executor.test.ts | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index 6bc9bef82e..17137aa98b 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -73,6 +73,7 @@ on: - 'packages/runtime/src/filesystem-worker/**' - 'packages/runtime/src/sandbox/**' - 'packages/runtime/src/path-containment.ts' + - 'packages/runtime/src/ripgrep-guidance.ts' - 'packages/runtime/src/sandbox-boundary-path.ts' - 'packages/runtime/src/apply-patch-file.ts' - 'packages/runtime/src/child-fd-input.ts' diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 886404f416..05b6de0982 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -104,6 +104,7 @@ on: - 'packages/runtime/src/pipe-process-driver.ts' - 'packages/runtime/src/process-tree-terminator.ts' - 'packages/runtime/src/pty-process-driver.ts' + - 'packages/runtime/src/ripgrep-guidance.ts' - 'packages/runtime/src/sandbox-boundary-declaration.ts' - 'packages/runtime/src/sandbox/default-sandbox-manager.ts' - 'packages/runtime/src/sandbox/sandbox-manager.ts' diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 79974a1a54..09bcef9d4e 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t |---|---:| | windows-backend-gap | 27 | | portable-candidate | 31 | -| platform-contract | 31 | +| platform-contract | 32 | -Total Windows-excluded declarations: **89** +Total Windows-excluded declarations: **90** ## Inventory @@ -82,6 +82,7 @@ Total Windows-excluded declarations: **89** | platform-contract | `packages/runtime/src/__tests__/shell-run-manager.test.ts` settles after root exit when a detached descendant retains inherited stdout | `process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false` | | platform-contract | `packages/runtime/src/__tests__/shell-run-manager.test.ts` keeps the first committed lifecycle cause across Stop and timeout races | `process.platform === 'win32' ? 'Windows tree termination has no graceful SIGTERM phase' : false` | | platform-contract | `packages/runtime/src/__tests__/shell-run-manager.test.ts` keeps SIGTERM final output and escalates an ignored SIGTERM without leaking slots | `process.platform === 'win32' ? 'Windows tree termination has no graceful SIGTERM phase' : false` | +| platform-contract | `packages/runtime/src/__tests__/workspace-executor.test.ts` leaves other spawn failures, such as a non-executable rg, untouched | `process.platform === 'win32' ? 'POSIX execute permissions' : false` | | portable-candidate | `packages/storage/src/__tests__/atomic-file-write.test.ts` removes its temp file and rethrows after a chmod failure | `process.platform === 'win32'` | | portable-candidate | `packages/storage/src/__tests__/atomic-file-write.test.ts` creates the target 0600 on POSIX | `process.platform === 'win32'` | | portable-candidate | `packages/storage/src/__tests__/atomic-file-write.test.ts` re-chmods a pre-existing world-readable target to 0600 on the next write | `process.platform === 'win32'` | diff --git a/packages/runtime/src/__tests__/workspace-executor.test.ts b/packages/runtime/src/__tests__/workspace-executor.test.ts index 459d687fc2..4a091f279d 100644 --- a/packages/runtime/src/__tests__/workspace-executor.test.ts +++ b/packages/runtime/src/__tests__/workspace-executor.test.ts @@ -328,7 +328,7 @@ describe('LocalWorkspaceExecutor file operations', () => { }); test('leaves other spawn failures, such as a non-executable rg, untouched', { - skip: process.platform === 'win32' ? 'POSIX execute bit' : false, + skip: process.platform === 'win32' ? 'POSIX execute permissions' : false, }, async () => { const cwd = await mkdtemp(join(tmpdir(), 'maka-workspace-grep-eacces-')); const bin = await mkdtemp(join(tmpdir(), 'maka-workspace-grep-noexec-bin-')); From ebc6df0d93bc5ab4ac944023b69c0029ecf7e87b Mon Sep 17 00:00:00 2001 From: Totoro Date: Sat, 12 Sep 2026 13:43:59 +0800 Subject: [PATCH 3/3] fix(runtime): let Grep recover from a missing ripgrep with a retry The filesystem worker's launch configuration cached its first ripgrep lookup for the life of the Host, so installing ripgrep, or an upgrade that moved it, needed a Host restart that quitting Desktop does not always perform. Keep a resolved copy while the same file is still there, and look again on the next launch when it was missing, has disappeared or was replaced in place, with the same executable and dependency-root checks, so the sandbox only grants the copy found. Mach-O inspection results are kept per file identity, so an unchanged binary never runs otool twice. The guidance now says where to install ripgrep and to retry. It names the WSL distribution when there is one and otherwise the machine the Host runs on, without the hostname: the text reaches the model as tool output. On Windows the winget Links directory is also a candidate, since a running Host keeps the PATH it started with. Refs #5167 Generated-by: Claude Code Co-Authored-By: Claude Opus 5 --- .../filesystem-worker-launch-spec.test.ts | 218 +++++++++++++++++- .../src/__tests__/filesystem-worker.test.ts | 18 +- .../src/__tests__/ripgrep-guidance.test.ts | 87 +++++++ .../src/__tests__/workspace-executor.test.ts | 1 + .../src/filesystem-worker/launch-spec.ts | 208 ++++++++++++----- .../src/filesystem-worker/operations.ts | 23 +- .../src/filesystem-worker/worker-entry.ts | 2 + packages/runtime/src/ripgrep-guidance.ts | 78 +++++-- packages/runtime/src/workspace-executor.ts | 11 +- 9 files changed, 565 insertions(+), 81 deletions(-) create mode 100644 packages/runtime/src/__tests__/ripgrep-guidance.test.ts diff --git a/packages/runtime/src/__tests__/filesystem-worker-launch-spec.test.ts b/packages/runtime/src/__tests__/filesystem-worker-launch-spec.test.ts index 5166cda7d8..2900bae7ed 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-launch-spec.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-launch-spec.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { copyFile, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { chmod, copyFile, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { test } from 'node:test'; @@ -136,3 +136,219 @@ test('a node runtime worker never receives the Electron-only stdio switch', asyn assert.equal(result.ok, true); if (result.ok) assert.equal(result.spec.args.includes('--no-stdio-init'), false); }); + +test('a ripgrep installed after the first launch is found by the next one (#5169)', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-launch-spec-rg-installed-')); + try { + const candidate = join(root, 'bin', 'rg'); + const getLaunchSpec = createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + platform: 'linux', + executable: process.execPath, + resourceLocation: { kind: 'runtime' }, + rgCandidates: [candidate], + }); + const before = await getLaunchSpec(); + assert.equal(before.ok, true); + if (!before.ok) return; + assert.equal(before.spec.args.includes('--grep-executable'), false); + + await installExecutable(candidate); + const after = await getLaunchSpec(); + + assert.equal(after.ok, true); + if (!after.ok) return; + const installed = await realpath(candidate); + assert.deepEqual(after.spec.args.slice(-2), ['--grep-executable', installed]); + assert.ok(after.spec.executableRoots.includes(installed)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a ripgrep that disappears is replaced by the next launch, and only the replacement is granted (#5169)', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-launch-spec-rg-replaced-')); + try { + const first = join(root, 'keg-14.1.0', 'rg'); + const second = join(root, 'keg-14.1.1', 'rg'); + await installExecutable(first); + const firstReal = await realpath(first); + const getLaunchSpec = createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + platform: 'linux', + executable: process.execPath, + resourceLocation: { kind: 'runtime' }, + rgCandidates: [first, second], + }); + const before = await getLaunchSpec(); + assert.equal(before.ok, true); + if (!before.ok) return; + assert.deepEqual(before.spec.args.slice(-2), ['--grep-executable', firstReal]); + + // A package upgrade removes the old keg and installs the new one. + await rm(dirname(first), { recursive: true, force: true }); + await installExecutable(second); + const after = await getLaunchSpec(); + + assert.equal(after.ok, true); + if (!after.ok) return; + const secondReal = await realpath(second); + assert.deepEqual(after.spec.args.slice(-2), ['--grep-executable', secondReal]); + assert.ok(after.spec.executableRoots.includes(secondReal)); + assert.ok(!after.spec.executableRoots.includes(firstReal)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a resolved ripgrep is inspected once while it is still there (#5169)', async () => { + const executable = await realpath(process.execPath); + let inspections = 0; + const getLaunchSpec = createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + platform: 'darwin', + executable, + resourceLocation: { kind: 'runtime' }, + rgCandidates: [executable], + inspectMacosExecutableDependencies: async () => { + inspections += 1; + return { ok: true, dependencyCount: 0, runtimeReadableRoots: [], executableRoots: [] }; + }, + }); + + await getLaunchSpec(); + await getLaunchSpec(); + + assert.equal(inspections, 1); +}); + +test('the worker is told where it runs so Grep can say where to install ripgrep (#5169)', async () => { + const getLaunchSpec = createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + platform: 'linux', + executable: process.execPath, + resourceLocation: { kind: 'runtime' }, + rgCandidates: [], + hostEnv: { WSL_DISTRO_NAME: 'Ubuntu-24.04' }, + }); + + const result = await getLaunchSpec(); + + assert.equal(result.ok, true); + if (!result.ok) return; + const index = result.spec.args.indexOf('--ripgrep-environment'); + assert.notEqual(index, -1); + assert.equal(result.spec.args[index + 1], 'wsl:Ubuntu-24.04'); +}); + +test('outside WSL the worker is not told a machine name (#5169)', async () => { + const getLaunchSpec = createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + platform: 'linux', + executable: process.execPath, + resourceLocation: { kind: 'runtime' }, + rgCandidates: [], + hostEnv: {}, + }); + + const result = await getLaunchSpec(); + + assert.equal(result.ok, true); + if (result.ok) assert.equal(result.spec.args.includes('--ripgrep-environment'), false); +}); + +test('a ripgrep whose libraries cannot be granted is not inspected again on every launch (#5169)', async () => { + const executable = await realpath(process.execPath); + let inspections = 0; + const getLaunchSpec = createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + platform: 'darwin', + executable, + resourceLocation: { kind: 'runtime' }, + rgCandidates: [executable], + inspectMacosExecutableDependencies: async () => { + inspections += 1; + return { ok: false, reason: 'dependency_unresolved', message: 'fixture failure' }; + }, + }); + + for (let launch = 0; launch < 3; launch += 1) { + const result = await getLaunchSpec(); + assert.equal(result.ok, true); + if (result.ok) assert.equal(result.spec.args.includes('--grep-executable'), false); + } + + assert.equal(inspections, 1); +}); + +test('a ripgrep reinstalled in place is inspected again and granted its new libraries (#5169)', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-launch-spec-rg-in-place-')); + try { + const candidate = join(root, 'bin', 'rg'); + await installExecutable(candidate); + const libraries = ['/opt/ripgrep-14.1.0/lib', '/opt/ripgrep-14.1.1/lib']; + let inspections = 0; + const getLaunchSpec = createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + platform: 'darwin', + executable: process.execPath, + resourceLocation: { kind: 'runtime' }, + rgCandidates: [candidate], + inspectMacosExecutableDependencies: async () => { + const library = libraries[Math.min(inspections, 1)]!; + inspections += 1; + return { + ok: true, + dependencyCount: 1, + runtimeReadableRoots: [library], + executableRoots: [library], + }; + }, + }); + const before = await getLaunchSpec(); + assert.equal(before.ok, true); + if (!before.ok) return; + assert.ok(before.spec.executableRoots.includes(libraries[0]!)); + + // Same path, new binary: a reinstall rewrites the file in place. + await writeFile(candidate, '#!/bin/sh\n# 14.1.1\n', 'utf8'); + const after = await getLaunchSpec(); + + assert.equal(after.ok, true); + if (!after.ok) return; + assert.equal(inspections, 2); + assert.ok(after.spec.executableRoots.includes(libraries[1]!)); + assert.ok(!after.spec.executableRoots.includes(libraries[0]!)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('on Windows the winget links directory is searched even when PATH predates the install (#5169)', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-launch-spec-winget-')); + try { + const linked = join(root, 'Microsoft', 'WinGet', 'Links', 'rg.exe'); + await installExecutable(linked); + const getLaunchSpec = createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + platform: 'win32', + executable: process.execPath, + resourceLocation: { kind: 'runtime' }, + hostEnv: { PATH: '', LOCALAPPDATA: root }, + }); + + const result = await getLaunchSpec(); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.deepEqual(result.spec.args.slice(-2), ['--grep-executable', await realpath(linked)]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function installExecutable(path: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, '#!/bin/sh\n', 'utf8'); + await chmod(path, 0o755); +} diff --git a/packages/runtime/src/__tests__/filesystem-worker.test.ts b/packages/runtime/src/__tests__/filesystem-worker.test.ts index c9d2073127..182ce89a71 100644 --- a/packages/runtime/src/__tests__/filesystem-worker.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker.test.ts @@ -243,7 +243,7 @@ describe('filesystem worker operations', () => { }); }); - test('names ripgrep and the restart when the runtime started without it (#5167)', async () => { + test('names ripgrep, where to install it, and a retry when no usable copy was found (#5167)', async () => { const root = await temporaryDirectory('maka-worker-grep-missing-'); const target = join(root, 'file.ts'); await writeFile(target, 'const healthSignal = true;', 'utf8'); @@ -261,20 +261,22 @@ describe('filesystem worker operations', () => { }, { enforcementPath: target, access: 'read', scope: 'exact', targetType: 'file' }, ), - {}, + { ripgrepEnvironment: { kind: 'wsl', name: 'Ubuntu-24.04' } }, ); assert.equal(response.ok, false); if (!response.ok) { assert.equal(response.error.code, 'grep_unavailable'); assert.match(response.error.message, /ripgrep/); - assert.match(response.error.message, /restart Maka/); + assert.match(response.error.message, /the WSL distribution "Ubuntu-24\.04"/); + assert.match(response.error.message, /then retry/); + assert.doesNotMatch(response.error.message, /restart/i); } }); test('reports a ripgrep that vanished after startup as unavailable, not as a missing search path', async () => { - // Launch-time resolution pins the realpath (e.g. a versioned Homebrew - // keg); an upgrade can delete it while the runtime keeps running. + // The launch configuration checks the executable before every launch, so + // this is the narrow window where it disappears after that check. const root = await temporaryDirectory('maka-worker-grep-vanished-'); const target = join(root, 'file.ts'); const vanished = join(root, 'uninstalled', 'rg'); @@ -300,7 +302,11 @@ describe('filesystem worker operations', () => { if (!response.ok) { assert.equal(response.error.code, 'grep_unavailable'); assert.ok(response.error.message.includes(vanished)); - assert.match(response.error.message, /restart Maka/); + assert.match( + response.error.message, + /for a remote Host, that server rather than this computer/, + ); + assert.match(response.error.message, /then retry/); } }); diff --git a/packages/runtime/src/__tests__/ripgrep-guidance.test.ts b/packages/runtime/src/__tests__/ripgrep-guidance.test.ts new file mode 100644 index 0000000000..f920d840df --- /dev/null +++ b/packages/runtime/src/__tests__/ripgrep-guidance.test.ts @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { hostname } from 'node:os'; +import { test } from 'node:test'; + +import { + currentRipgrepEnvironment, + formatRipgrepEnvironmentArg, + parseRipgrepEnvironmentArg, + ripgrepMissingMessage, + ripgrepMissingOnPathMessage, + ripgrepVanishedMessage, +} from '../ripgrep-guidance.js'; + +test('only a WSL distribution names where the Host runs', () => { + assert.deepEqual(currentRipgrepEnvironment({ WSL_DISTRO_NAME: 'Ubuntu-24.04' }), { + kind: 'wsl', + name: 'Ubuntu-24.04', + }); + assert.equal(currentRipgrepEnvironment({}), undefined); + assert.equal(currentRipgrepEnvironment({ WSL_DISTRO_NAME: ' ' }), undefined); +}); + +test('the environment survives the worker launch argument', () => { + const environment = { kind: 'wsl', name: 'Ubuntu-24.04' } as const; + assert.deepEqual( + parseRipgrepEnvironmentArg(formatRipgrepEnvironmentArg(environment)), + environment, + ); + assert.equal(parseRipgrepEnvironmentArg(undefined), undefined); + assert.equal(parseRipgrepEnvironmentArg('machine:build-host'), undefined); +}); + +test('every missing-ripgrep message says where to install it and to retry, never to restart', () => { + const wsl = { kind: 'wsl', name: 'Ubuntu-24.04' } as const; + for (const message of [ + ripgrepMissingOnPathMessage(wsl, 'linux'), + ripgrepMissingMessage(wsl, 'linux'), + ripgrepVanishedMessage('/usr/local/Cellar/ripgrep/14.1.0/bin/rg', wsl), + ]) { + assert.match(message, /the WSL distribution "Ubuntu-24\.04"/); + assert.match(message, /then retry\.$/); + assert.doesNotMatch(message, /restart/i); + } + for (const message of [ + ripgrepMissingOnPathMessage(undefined, 'darwin'), + ripgrepMissingMessage(undefined, 'darwin'), + ripgrepVanishedMessage('/opt/homebrew/Cellar/ripgrep/14.1.0/bin/rg', undefined), + ]) { + assert.match( + message, + /the machine this Maka Host runs on \(for a remote Host, that server rather than this computer\)/, + ); + assert.match(message, /then retry\.$/); + assert.doesNotMatch(message, /restart/i); + } +}); + +test('the guidance never carries the machine name to the model', () => { + const machine = hostname().trim(); + if (machine.length < 4) return; + for (const message of [ + ripgrepMissingOnPathMessage(currentRipgrepEnvironment({})), + ripgrepMissingMessage(currentRipgrepEnvironment({})), + ripgrepVanishedMessage('/usr/bin/rg', currentRipgrepEnvironment({})), + ]) { + assert.equal(message.includes(machine), false); + } +}); diff --git a/packages/runtime/src/__tests__/workspace-executor.test.ts b/packages/runtime/src/__tests__/workspace-executor.test.ts index 4a091f279d..6d50ca82f5 100644 --- a/packages/runtime/src/__tests__/workspace-executor.test.ts +++ b/packages/runtime/src/__tests__/workspace-executor.test.ts @@ -294,6 +294,7 @@ describe('LocalWorkspaceExecutor file operations', () => { assert.equal(error.code, 'grep_unavailable'); assert.match(error.message, /ripgrep/); assert.match(error.message, /BurntSushi\/ripgrep/); + assert.match(error.message, /then retry/); return true; }, ), diff --git a/packages/runtime/src/filesystem-worker/launch-spec.ts b/packages/runtime/src/filesystem-worker/launch-spec.ts index e64383ca92..b5ef3dca9b 100644 --- a/packages/runtime/src/filesystem-worker/launch-spec.ts +++ b/packages/runtime/src/filesystem-worker/launch-spec.ts @@ -18,7 +18,7 @@ */ import { constants } from 'node:fs'; -import { access, realpath } from 'node:fs/promises'; +import { access, realpath, stat } from 'node:fs/promises'; import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'; import { @@ -29,6 +29,11 @@ import { resolveMacosExecutableDependencies, type MacosExecutableDependencyResolution, } from './macos-executable-dependencies.js'; +import { + currentRipgrepEnvironment, + formatRipgrepEnvironmentArg, + type RipgrepEnvironment, +} from '../ripgrep-guidance.js'; export interface FilesystemWorkerLaunchSpec { program: string; @@ -65,8 +70,41 @@ export interface CreateFilesystemWorkerLaunchSpecProviderInput { export function createFilesystemWorkerLaunchSpecProvider( input: CreateFilesystemWorkerLaunchSpecProviderInput, ): FilesystemWorkerLaunchSpecProvider { - let cached: Promise | undefined; - return () => (cached ??= resolveLaunchSpec(input)); + const platform = input.platform ?? process.platform; + let base: Promise | undefined; + let ripgrep: Promise | undefined; + // Mach-O inspection runs otool, so its result is kept per executable and + // file identity: an unchanged binary is inspected once, whether it was + // granted or refused, however often launches look for ripgrep again. + const inspections = new Map(); + const resolveRipgrep = () => + resolveRipgrepExecutable( + input.rgCandidates ?? defaultRipgrepCandidates(input.hostEnv ?? process.env, platform), + platform, + input.inspectMacosExecutableDependencies ?? resolveMacosExecutableDependencies, + inspections, + ); + // A resolved ripgrep stays cached while the same file is still there. A + // missing one, or one whose file has since disappeared or changed (a package + // upgrade removes the old keg; a reinstall in place can change its + // libraries), is looked up again on the next launch, with the same + // executable and dependency-root validation as the first lookup: installing + // ripgrep where the Host runs and retrying recovers without restarting the + // Host, and the sandbox only ever grants the copy found. + const currentRipgrep = async (): Promise => { + const known = (ripgrep ??= resolveRipgrep()); + const resolved = await known; + if (resolved && (await fileIdentity(resolved.executable)) === resolved.identity) + return resolved; + // Concurrent launches share one fresh lookup. + if (ripgrep === known) ripgrep = resolveRipgrep(); + return await ripgrep; + }; + return async () => { + const resolved = await (base ??= resolveLaunchBase(input, platform)); + if (!resolved.ok) return resolved; + return { ok: true, spec: composeLaunchSpec(resolved.base, await currentRipgrep()) }; + }; } export function buildFilesystemWorkerEnv( @@ -94,9 +132,40 @@ export function buildFilesystemWorkerEnv( return env; } -async function resolveLaunchSpec( +interface RipgrepResolution { + readonly executable: string; + /** The file that was validated; a different file at the same path is looked up again. */ + readonly identity: string; + readonly runtimeReadableRoots: readonly string[]; + readonly executableRoots: readonly string[]; +} + +interface MacosInspectionRecord { + readonly identity: string; + readonly result: MacosExecutableDependencyResolution; +} + +/** Everything in a launch that does not depend on ripgrep; resolved once. */ +interface LaunchBase { + readonly runtime: 'node' | 'electron'; + readonly platform: NodeJS.Platform; + readonly program: string; + readonly bundlePath: string; + readonly runtimeRoot: string; + readonly dependencyRoots: readonly string[]; + readonly electronFrameworks?: string; + readonly env: Readonly>; + readonly environment?: RipgrepEnvironment; +} + +type LaunchBaseResult = + | { ok: true; base: LaunchBase } + | Extract; + +async function resolveLaunchBase( input: CreateFilesystemWorkerLaunchSpecProviderInput, -): Promise { + platform: NodeJS.Platform, +): Promise { const bundle = await resolveFilesystemWorkerBundle(input.resourceLocation); if (!bundle.ok) { return { @@ -113,7 +182,6 @@ async function resolveLaunchSpec( message: 'Filesystem worker runtime is unavailable.', }; } - const platform = input.platform ?? process.platform; // A packaged Windows executable lives directly inside the product-owned app // directory. Granting its parent would widen a normal install from // `...\Programs\Maka` to every application under `...\Programs` (or from @@ -131,11 +199,6 @@ async function resolveLaunchSpec( }; } const dependencyRoots = await resolveRuntimeDependencyRoots(program); - const grep = await resolveRipgrepExecutable( - input.rgCandidates ?? defaultRipgrepCandidates(input.hostEnv ?? process.env, platform), - platform, - input.inspectMacosExecutableDependencies ?? resolveMacosExecutableDependencies, - ); const electronFrameworks = input.runtime === 'electron' && platform === 'darwin' ? await resolveReadableRoot(resolve(dirname(program), '..', 'Frameworks')) @@ -147,72 +210,96 @@ async function resolveLaunchSpec( message: 'Electron framework roots are unavailable.', }; } + const environment = currentRipgrepEnvironment(input.hostEnv ?? process.env); return { ok: true, - spec: { + base: { + runtime: input.runtime, + platform, program, - // --preserve-symlinks-main skips the module loader's realpath of the - // bundle path. Inside the Windows AppContainer that realpath would - // lstat every ancestor directory (up to the volume root), which the - // sandbox grants deliberately do not allow. - // - // --no-stdio-init: Electron's run-as-node entry opens the NUL device to - // backfill missing standard handles before Node starts, and the - // AppContainer denies that device open, which aborts startup (FATAL - // node_main.cc "Unable to open nul device"). The broker always relays - // three valid standard handles into the child, so the backfill is - // unnecessary; the switch skips it and is consumed before Node's own - // option parsing. - args: [ - ...(input.runtime === 'electron' && platform === 'win32' ? ['--no-stdio-init'] : []), - ...(platform === 'win32' ? ['--preserve-symlinks-main'] : []), - bundle.path, - ...(grep ? ['--grep-executable', grep.executable] : []), - ], + bundlePath: bundle.path, + runtimeRoot, + dependencyRoots, + ...(electronFrameworks ? { electronFrameworks } : {}), env: buildFilesystemWorkerEnv(input.runtime, input.hostEnv, input.tmpdir, platform), - runtimeReadableRoots: unique([ - bundle.path, - runtimeRoot, - ...dependencyRoots, - ...(grep?.runtimeReadableRoots ?? []), - ]), - executableRoots: unique([ - program, - runtimeRoot, - ...(electronFrameworks ? [electronFrameworks] : []), - ...dependencyRoots, - ...(grep ? [grep.executable, ...grep.executableRoots] : []), - ]), + ...(environment ? { environment } : {}), }, }; } +function composeLaunchSpec( + base: LaunchBase, + grep: RipgrepResolution | undefined, +): FilesystemWorkerLaunchSpec { + return { + program: base.program, + // --preserve-symlinks-main skips the module loader's realpath of the + // bundle path. Inside the Windows AppContainer that realpath would + // lstat every ancestor directory (up to the volume root), which the + // sandbox grants deliberately do not allow. + // + // --no-stdio-init: Electron's run-as-node entry opens the NUL device to + // backfill missing standard handles before Node starts, and the + // AppContainer denies that device open, which aborts startup (FATAL + // node_main.cc "Unable to open nul device"). The broker always relays + // three valid standard handles into the child, so the backfill is + // unnecessary; the switch skips it and is consumed before Node's own + // option parsing. + args: [ + ...(base.runtime === 'electron' && base.platform === 'win32' ? ['--no-stdio-init'] : []), + ...(base.platform === 'win32' ? ['--preserve-symlinks-main'] : []), + base.bundlePath, + ...(base.environment + ? ['--ripgrep-environment', formatRipgrepEnvironmentArg(base.environment)] + : []), + ...(grep ? ['--grep-executable', grep.executable] : []), + ], + env: base.env, + runtimeReadableRoots: unique([ + base.bundlePath, + base.runtimeRoot, + ...base.dependencyRoots, + ...(grep?.runtimeReadableRoots ?? []), + ]), + executableRoots: unique([ + base.program, + base.runtimeRoot, + ...(base.electronFrameworks ? [base.electronFrameworks] : []), + ...base.dependencyRoots, + ...(grep ? [grep.executable, ...grep.executableRoots] : []), + ]), + }; +} + async function resolveRipgrepExecutable( candidates: readonly string[], platform: NodeJS.Platform, inspectMacosExecutableDependencies: ( executable: string, ) => Promise, -): Promise< - | { - executable: string; - runtimeReadableRoots: readonly string[]; - executableRoots: readonly string[]; - } - | undefined -> { + inspections: Map, +): Promise { const inspected = new Set(); for (const candidate of candidates) { const executable = await resolveExecutable(candidate); if (!executable || inspected.has(executable)) continue; inspected.add(executable); + const identity = await fileIdentity(executable); + if (!identity) continue; if (platform !== 'darwin') { - return { executable, runtimeReadableRoots: [], executableRoots: [] }; + return { executable, identity, runtimeReadableRoots: [], executableRoots: [] }; } - const dependencies = await inspectMacosExecutableDependencies(executable); + const known = inspections.get(executable); + const inspection = + known && known.identity === identity + ? known + : { identity, result: await inspectMacosExecutableDependencies(executable) }; + inspections.set(executable, inspection); + const dependencies = inspection.result; if (!dependencies.ok) continue; return { executable, + identity, runtimeReadableRoots: dependencies.runtimeReadableRoots, executableRoots: dependencies.executableRoots, }; @@ -230,6 +317,15 @@ async function resolveExecutable(candidate: string): Promise } } +async function fileIdentity(path: string): Promise { + try { + const info = await stat(path); + return `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}:${info.mode}`; + } catch { + return undefined; + } +} + async function resolveReadableRoot(candidate: string): Promise { try { return await realpath(candidate); @@ -249,6 +345,12 @@ function defaultRipgrepCandidates( .filter(Boolean) .map((directory) => join(directory, executableName)), ...(platform === 'win32' ? [] : ['/opt/homebrew/bin/rg', '/usr/local/bin/rg', '/usr/bin/rg']), + // winget links portable packages here and adds the directory to PATH for + // processes started afterwards; a running Host still has the old PATH, so + // look here too or "install, then retry" would not find the install. + ...(platform === 'win32' && env.LOCALAPPDATA + ? [join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', executableName)] + : []), ]; } diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 05b149eedd..0e2f5a5a95 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -22,7 +22,11 @@ import { promises as fs } from 'node:fs'; import { glob as nodeGlob } from 'node:fs/promises'; import { dirname, isAbsolute, parse, resolve } from 'node:path'; import { isPathInside } from '../path-containment.js'; -import { ripgrepMissingAtStartupMessage, ripgrepVanishedMessage } from '../ripgrep-guidance.js'; +import { + ripgrepMissingMessage, + ripgrepVanishedMessage, + type RipgrepEnvironment, +} from '../ripgrep-guidance.js'; import { sandboxPathApi } from './sandbox-paths.js'; import { sandboxBoundaryExpansionAllowsPath } from '@maka/core/sandbox-boundary'; import { @@ -66,6 +70,8 @@ const MAX_GREP_STDERR_BYTES = 16 * 1024; export interface FilesystemWorkerOperationDependencies { grepExecutable?: string; + /** Where this worker runs, as the Host observed it; names the install location in Grep's guidance. */ + ripgrepEnvironment?: RipgrepEnvironment; runGrep?: FilesystemWorkerGrepRunner; /** Set when the worker runs inside the Windows AppContainer sandbox. */ windowsSandboxed?: boolean; @@ -412,7 +418,10 @@ export async function executeFilesystemOperation( } const grepExecutable = dependencies.grepExecutable; if (!grepExecutable) - throw operationError('grep_unavailable', ripgrepMissingAtStartupMessage()); + throw operationError( + 'grep_unavailable', + ripgrepMissingMessage(dependencies.ripgrepEnvironment), + ); const args = ['-n', '--no-heading', `--max-count=${operation.maxCountPerFile}`]; if (operation.glob) args.push('--glob', operation.glob); args.push('--', operation.pattern, path); @@ -425,10 +434,14 @@ export async function executeFilesystemOperation( timeoutMs: operation.timeoutMs, }).catch((error: unknown) => { // The cwd is a filesystem root, which always exists, so a spawn ENOENT - // means the executable resolved at startup is gone. Left alone it would - // be normalized to `not_found` and read as a missing search path. + // means the executable this worker was launched with is gone — removed + // after the launch configuration checked it. Left alone it would be + // normalized to `not_found` and read as a missing search path. if (nodeErrorCode(error) === 'ENOENT') - throw operationError('grep_unavailable', ripgrepVanishedMessage(grepExecutable)); + throw operationError( + 'grep_unavailable', + ripgrepVanishedMessage(grepExecutable, dependencies.ripgrepEnvironment), + ); throw error; }); if (result.exitCode === 1) return { kind: 'grep', matches: [] }; diff --git a/packages/runtime/src/filesystem-worker/worker-entry.ts b/packages/runtime/src/filesystem-worker/worker-entry.ts index aa4f7b2f67..fcf4d4331c 100644 --- a/packages/runtime/src/filesystem-worker/worker-entry.ts +++ b/packages/runtime/src/filesystem-worker/worker-entry.ts @@ -20,6 +20,7 @@ import { stdin, stdout } from 'node:process'; import { executeFilesystemWorkerRequest } from './operations.js'; +import { parseRipgrepEnvironmentArg } from '../ripgrep-guidance.js'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION, FilesystemWorkerRequestSchema, @@ -68,6 +69,7 @@ async function main(): Promise { writeResponse( await executeFilesystemWorkerRequest(parsed.data, { grepExecutable: readOption('--grep-executable'), + ripgrepEnvironment: parseRipgrepEnvironmentArg(readOption('--ripgrep-environment')), windowsSandboxed: process.env.MAKA_WINDOWS_SANDBOX === '1', }), ); diff --git a/packages/runtime/src/ripgrep-guidance.ts b/packages/runtime/src/ripgrep-guidance.ts index d6678f9a0e..aeea26231a 100644 --- a/packages/runtime/src/ripgrep-guidance.ts +++ b/packages/runtime/src/ripgrep-guidance.ts @@ -21,38 +21,88 @@ * Grep's only engine is ripgrep, and nothing ships it (#5167). These builders * keep the missing-ripgrep copy identical on the local executor and the * filesystem worker, so the model and the user learn the same fix whichever - * path ran the search. The Windows sandbox refusal deliberately stays in the - * worker: that is a capability limit of the AppContainer, not a missing - * dependency. + * path ran the search. Both paths look ripgrep up again on the next attempt — + * the local executor on every call, the worker whenever the copy it knew is + * missing, gone or replaced — so the recovery is always "install it where the + * Host runs, then retry", never a restart. The Windows sandbox refusal + * deliberately stays in the worker: that is a capability limit of the + * AppContainer, not a missing dependency. + * + * The copy reaches the model as tool output, so it names a place without + * naming the machine. A WSL distribution is an identifier Maka already keeps + * for its Hosts; a hostname is personal (a default macOS name carries its + * owner's name) and Maka otherwise shows it only as a device name in Desktop, + * which already says which Host is active. */ const RIPGREP_INSTALL_URL = 'https://github.com/BurntSushi/ripgrep#installation'; -function ripgrepInstallHint(platform: NodeJS.Platform): string { +/** The one place a Host can name about itself without naming the machine. */ +export interface RipgrepEnvironment { + readonly kind: 'wsl'; + readonly name: string; +} + +export function currentRipgrepEnvironment( + env: NodeJS.ProcessEnv = process.env, +): RipgrepEnvironment | undefined { + const distribution = env.WSL_DISTRO_NAME?.trim(); + return distribution ? { kind: 'wsl', name: distribution } : undefined; +} + +/** The worker receives its environment as a launch argument: its own environment block is scrubbed. */ +export function formatRipgrepEnvironmentArg(environment: RipgrepEnvironment): string { + return `${environment.kind}:${environment.name}`; +} + +export function parseRipgrepEnvironmentArg( + value: string | undefined, +): RipgrepEnvironment | undefined { + const match = value ? /^wsl:(.+)$/u.exec(value) : null; + return match ? { kind: 'wsl', name: match[1]! } : undefined; +} + +function installLocation(environment: RipgrepEnvironment | undefined): string { + return environment + ? `the WSL distribution "${environment.name}"` + : 'the machine this Maka Host runs on (for a remote Host, that server rather than this computer)'; +} + +function installInstructions( + environment: RipgrepEnvironment | undefined, + platform: NodeJS.Platform, +): string { const command = platform === 'darwin' ? '`brew install ripgrep`' : platform === 'win32' ? '`winget install BurntSushi.ripgrep.MSVC`' : 'your package manager (for example `apt install ripgrep`)'; - return `Install it with ${command}, or see ${RIPGREP_INSTALL_URL}.`; + return `Install it on ${installLocation(environment)} with ${command}, or see ${RIPGREP_INSTALL_URL}, then retry.`; } -/** The local executor looks `rg` up on PATH at every call, so a retry suffices. */ -export function ripgrepMissingOnPathMessage(platform: NodeJS.Platform = process.platform): string { - return `Grep requires ripgrep (\`rg\`), which was not found on PATH. ${ripgrepInstallHint(platform)} Then retry.`; +/** The local executor looks `rg` up on PATH at every call. */ +export function ripgrepMissingOnPathMessage( + environment: RipgrepEnvironment | undefined, + platform: NodeJS.Platform = process.platform, +): string { + return `Grep requires ripgrep (\`rg\`), and it was not found on PATH. ${installInstructions(environment, platform)}`; } -/** The worker resolves ripgrep once, when the runtime starts. */ -export function ripgrepMissingAtStartupMessage( +/** The worker was launched without a usable ripgrep; its next launch looks again. */ +export function ripgrepMissingMessage( + environment: RipgrepEnvironment | undefined, platform: NodeJS.Platform = process.platform, ): string { - return `Grep requires ripgrep (\`rg\`), but no usable copy was found when Maka started. ${ripgrepInstallHint(platform)} Then restart Maka.`; + return `Grep requires ripgrep (\`rg\`), and no usable copy was found. ${installInstructions(environment, platform)}`; } -/** The executable resolved at startup is gone (e.g. a package upgrade removed it). */ -export function ripgrepVanishedMessage(executable: string): string { - return `Grep could not start ripgrep at ${executable}; it was moved or removed after Maka started (for example by a package upgrade). Reinstall ripgrep if needed, then restart Maka.`; +/** The executable the worker was launched with is gone (e.g. a package upgrade removed it). */ +export function ripgrepVanishedMessage( + executable: string, + environment: RipgrepEnvironment | undefined, +): string { + return `Grep could not start ripgrep at ${executable}; it was moved or removed, for example by a package upgrade. Reinstall it on ${installLocation(environment)} if needed, then retry.`; } /** Local-executor twin of the worker protocol's `grep_unavailable` error. */ diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index 4ba10bd6e5..721c50dc49 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -35,7 +35,11 @@ import { } from './file-stable-write.js'; import { promisify } from 'node:util'; import type { ToolExecutionFacts } from '@maka/core/permission'; -import { RipgrepUnavailableError, ripgrepMissingOnPathMessage } from './ripgrep-guidance.js'; +import { + currentRipgrepEnvironment, + RipgrepUnavailableError, + ripgrepMissingOnPathMessage, +} from './ripgrep-guidance.js'; import { runProcessWithBoundedTail, runShellWithBoundedTail } from './shell-exec.js'; import type { ChildFdInput } from './child-fd-input.js'; import type { ShellPlan } from './shell-detect.js'; @@ -463,7 +467,10 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { // Node reports a missing spawn cwd exactly like a missing executable // (both `spawn rg ENOENT`), so only blame ripgrep once the cwd exists. if (error?.code === 'ENOENT' && (await isDirectory(input.cwd))) - throw new RipgrepUnavailableError(ripgrepMissingOnPathMessage(), { cause: error }); + throw new RipgrepUnavailableError( + ripgrepMissingOnPathMessage(currentRipgrepEnvironment()), + { cause: error }, + ); throw error; } }