Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,30 @@ jobs:
LIBRECHAT_CODE_LIVE_SRT_TESTS: '1'
run: node --test dist/environment-live.test.js

linux-native-sandbox-tests:
name: Linux Native Sandbox Tests
runs-on: ubuntu-24.04
defaults:
run:
working-directory: packages/code
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24.16.0
- name: Install bubblewrap
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq bubblewrap socat ripgrep
# Ubuntu 24.04 blocks unprivileged user namespaces through AppArmor; bwrap needs them.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- run: npm ci
- run: npm run build
- name: Linked worktree lane containment
env:
LIBRECHAT_CODE_LIVE_SRT_TESTS: '1'
run: node --test dist/linked-worktrees-live.test.js

lambda-microvm-provisioning:
name: Lambda MicroVM Provisioning
runs-on: ubuntu-latest
Expand Down
108 changes: 108 additions & 0 deletions packages/code/src/linked-worktrees-live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import test from 'node:test';

import { verifyLinkedWorktree } from './linked-worktrees.js';
import { NativeSrtWorkspaceCommandSandbox } from './native-sandbox.js';
import { resolveNativeSrtCommandPolicy } from './native-policy.js';

const execFileAsync = promisify(execFile);
const IDENTITY = ['-c', 'user.name=lane', '-c', 'user.email=lane@example.com', '-c', 'commit.gpgsign=false'];

async function git(cwd: string, ...args: string[]): Promise<string> {
return (await execFileAsync('git', [...IDENTITY, ...args], { cwd })).stdout.trim();
}

async function snapshot(paths: string[]): Promise<Record<string, string | null>> {
const entries = await Promise.all(
paths.map(async (path) => [path, await readFile(path, 'utf8').catch(() => null)] as const),
);
return Object.fromEntries(entries);
}

/**
* The worker's home is read-denied, so a checkout beneath it exercises the
* sandbox's tmpfs re-binding; one beneath the system temporary directory does not.
*/
for (const [location, parent] of [
['beneath the worker home', homedir()],
['outside the worker home', tmpdir()],
] as const) {
test(`real SRT lets a linked worktree lane commit while checkout and sibling Git metadata stay intact (${location})`, {
skip: process.env.LIBRECHAT_CODE_LIVE_SRT_TESTS !== '1',
timeout: 60_000,
}, async (t) => {
const base = await realpath(await mkdtemp(join(parent, 'lane-live-')));
t.after(() => rm(base, { recursive: true, force: true }));
const root = join(base, 'repo');
await mkdir(root);
await git(root, 'init', '-q', '-b', 'main');
await writeFile(join(root, 'tracked.txt'), 'checkout\n');
await git(root, 'add', 'tracked.txt');
await git(root, 'commit', '-qm', 'init');
await mkdir(join(root, '.worktrees'));
await git(root, 'worktree', 'add', '-q', '-b', 'task-a', '.worktrees/task-a');
await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b');

const commonGitDir = join(root, '.git');
const protectedFiles = [
join(commonGitDir, 'HEAD'),
join(commonGitDir, 'index'),
join(commonGitDir, 'config'),
join(commonGitDir, 'MERGE_HEAD'),
join(commonGitDir, 'packed-refs'),
join(commonGitDir, 'hooks', 'pre-commit'),
join(commonGitDir, 'worktrees', 'task-b', 'HEAD'),
join(root, 'tracked.txt'),
];
const before = await snapshot(protectedFiles);
const lane = await verifyLinkedWorktree(root, 'task-a');
const sandbox = new NativeSrtWorkspaceCommandSandbox({
workspaceRoot: lane.root,
workspaceIdentity: lane.identity,
linkedWorktree: {
checkoutRoot: lane.checkoutRoot,
commonGitDir: lane.commonGitDir,
writableGitPaths: lane.writableGitPaths,
},
commandPolicy: resolveNativeSrtCommandPolicy('trusted-vm'),
environment: { PATH: process.env.PATH, LANG: 'C.UTF-8' },
});
t.after(() => sandbox.close());
const run = (command: string) =>
sandbox.execute({
protocolVersion: 1,
operation: 'execute_command',
workspaceId: 'lane',
command,
timeoutMs: 30_000,
maxOutputBytes: 16_384,
});
const gitInLane = `git ${IDENTITY.join(' ')}`;

const committed = await run(
`printf lane > lane.txt && ${gitInLane} add lane.txt && ${gitInLane} commit -qm lane && ${gitInLane} branch lane-extra`,
);
assert.equal(committed.exitCode, 0, committed.stderr);

for (const path of protectedFiles) {
await run(`printf tampered > '${path}'`);
}
// Packing must not move refs into storage that vanishes with the sandbox.
await run(`${gitInLane} pack-refs --all`);
await sandbox.close();

for (const branch of ['main', 'task-a', 'task-b', 'lane-extra']) {
assert.ok(await git(root, 'rev-parse', '--verify', '-q', `refs/heads/${branch}`), branch);
}

assert.equal(await git(root, 'log', '-1', '--format=%s', 'task-a'), 'lane');
assert.deepEqual(await snapshot(protectedFiles), before);
assert.equal(await git(root, 'status', '--porcelain', '--untracked-files=no'), '');
await assert.rejects(stat(join(commonGitDir, 'MERGE_HEAD')), { code: 'ENOENT' });
});
}
13 changes: 12 additions & 1 deletion packages/code/src/native-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1612,13 +1612,14 @@ test('a linked worktree lane may write only shared Git storage and its own metad
await Promise.all(
[lane, ...writableGitPaths].map(path => mkdir(path, { recursive: true })),
);
const prepare = async (paths: string[]) => {
const prepare = async (paths: string[], platform: NodeJS.Platform = 'linux') => {
const fake = fakeManager();
const sandbox = new NativeSrtWorkspaceCommandSandbox({
workspaceRoot: lane,
linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: paths },
environment: { PATH: '/usr/bin' },
manager: fake.manager,
platform,
});
t.after(() => sandbox.close());
await sandbox.prepare();
Expand All @@ -1629,6 +1630,16 @@ test('a linked worktree lane may write only shared Git storage and its own metad
assert.deepEqual(config.filesystem.allowWrite.slice(0, 4), [lane, ...writableGitPaths]);
assert.ok(!config.filesystem.allowWrite.includes(commonGitDir));
assert.ok(config.filesystem.allowRead?.includes(commonGitDir));
// Deeper read denies make SRT re-bind each writable Git directory after the
// read-only bind of the common directory (Linux tmpfs re-binding order).
for (const path of writableGitPaths) {
assert.ok(config.filesystem.denyRead.includes(path), path);
}
assert.ok(!config.filesystem.denyRead.includes(join(commonGitDir, 'lfs')));
const darwin = await prepare(writableGitPaths, 'darwin');
for (const path of writableGitPaths) {
assert.ok(!darwin.filesystem.denyRead.includes(path), path);
}
const gitGuard = config.filesystem.allowRead?.find(path => path.includes('librechat-code-git-'));
assert.ok(gitGuard, 'lane Git guard must be readable');
assert.ok(!config.filesystem.allowWrite.includes(gitGuard));
Expand Down
20 changes: 20 additions & 0 deletions packages/code/src/native-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,25 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox
);
}
const laneGitPaths = commonGitDir ? [commonGitDir] : [];
/**
* On Linux, SRT hides a read-denied directory (such as the worker home) under a
* tmpfs and then re-binds writes before reads, so the read-only bind of the whole
* common Git directory would mask its writable descendants. SRT processes read
* denies shallow-first, re-binding each one's writes on top, so listing every
* existing writable Git directory as a deeper deny restores its write bind after
* the ancestor read bind. The common directory stays a live, read-only host
* directory: nothing can be created at its top level.
*/
const laneWriteRebinds =
this.platform === 'linux'
? (
await Promise.all(
writableGitPaths.map(async path =>
(await stat(path).catch(() => undefined))?.isDirectory() ? path : undefined,
),
)
).filter((path): path is string => path != null)
: [];
const canonicalScratchDirectory =
await this.createScratchDirectory(sharedScratchPaths);
if (
Expand Down Expand Up @@ -519,6 +538,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox
...sharedScratchPaths.filter(path =>
deniedInheritedWritablePaths.includes(path),
),
...laneWriteRebinds,
],
allowRead: [
root,
Expand Down
Loading