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__/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 d4bf0a755b..182ce89a71 100644 --- a/packages/runtime/src/__tests__/filesystem-worker.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker.test.ts @@ -243,6 +243,73 @@ describe('filesystem worker operations', () => { }); }); + 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'); + + 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' }, + ), + { 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, /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 () => { + // 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'); + 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, + /for a remote Host, that server rather than this computer/, + ); + assert.match(response.error.message, /then retry/); + } + }); + 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__/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 e979230685..6d50ca82f5 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,92 @@ 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/); + assert.match(error.message, /then retry/); + 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 permissions' : 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/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 40f956aeeb..0e2f5a5a95 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -22,6 +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 { + ripgrepMissingMessage, + ripgrepVanishedMessage, + type RipgrepEnvironment, +} from '../ripgrep-guidance.js'; import { sandboxPathApi } from './sandbox-paths.js'; import { sandboxBoundaryExpansionAllowsPath } from '@maka/core/sandbox-boundary'; import { @@ -65,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; @@ -409,18 +416,33 @@ 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', + 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); 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 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, dependencies.ripgrepEnvironment), + ); + throw error; }); if (result.exitCode === 1) return { kind: 'grep', matches: [] }; if (result.exitCode !== 0) { 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 new file mode 100644 index 0000000000..aeea26231a --- /dev/null +++ b/packages/runtime/src/ripgrep-guidance.ts @@ -0,0 +1,116 @@ +/* + * 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. 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'; + +/** 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 on ${installLocation(environment)} with ${command}, or see ${RIPGREP_INSTALL_URL}, 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 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\`), and no usable copy was found. ${installInstructions(environment, platform)}`; +} + +/** 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. */ +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..721c50dc49 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -35,6 +35,11 @@ import { } from './file-stable-write.js'; import { promisify } from 'node:util'; import type { ToolExecutionFacts } from '@maka/core/permission'; +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'; @@ -459,11 +464,25 @@ 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(currentRipgrepEnvironment()), + { 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(); }