Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release-windows-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/windows-recovery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 3 additions & 2 deletions docs/windows-test-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'` |
Expand Down
61 changes: 61 additions & 0 deletions packages/runtime/src/__tests__/filesystem-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
89 changes: 88 additions & 1 deletion packages/runtime/src/__tests__/workspace-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 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<T>(path: string, run: () => Promise<T>): Promise<T> {
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;
}
}
15 changes: 12 additions & 3 deletions packages/runtime/src/filesystem-worker/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
66 changes: 66 additions & 0 deletions packages/runtime/src/ripgrep-guidance.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
12 changes: 12 additions & 0 deletions packages/runtime/src/workspace-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<boolean> {
return await fs.stat(path).then(
(stat) => stat.isDirectory(),
() => false,
);
}

export function createLocalWorkspaceExecutor(): WorkspaceExecutor {
return new LocalWorkspaceExecutor();
}
Expand Down