Stabilize paired reviewer recovery and owner follow-up

This commit is contained in:
ejclaw
2026-04-06 07:49:26 +09:00
parent 994e957767
commit d75d26c23d
27 changed files with 1474 additions and 166 deletions

View File

@@ -9,7 +9,14 @@ import { Database } from 'bun:sqlite';
import { STORE_DIR } from '../src/config.js';
import { logger } from '../src/logger.js';
import { getPlatform, isHeadless, isWSL } from './platform.js';
import {
canUseLinuxBubblewrapReadonlySandbox,
commandExists,
getAppArmorRestrictUnprivilegedUsernsValue,
getPlatform,
isHeadless,
isWSL,
} from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
@@ -20,6 +27,14 @@ export async function run(_args: string[]): Promise<void> {
const platform = getPlatform();
const wsl = isWSL();
const headless = isHeadless();
const hasBubblewrap = platform === 'linux' && commandExists('bwrap');
const hasSocat = platform === 'linux' && commandExists('socat');
const apparmorRestrictUnprivilegedUserns =
platform === 'linux'
? getAppArmorRestrictUnprivilegedUsernsValue()
: null;
const hasBubblewrapReadonlySandboxCapability =
platform === 'linux' && canUseLinuxBubblewrapReadonlySandbox();
// Check existing config
const hasEnv = fs.existsSync(path.join(projectRoot, '.env'));
@@ -55,6 +70,10 @@ export async function run(_args: string[]): Promise<void> {
hasEnv,
hasAuth,
hasRegisteredGroups,
hasBubblewrap,
hasSocat,
apparmorRestrictUnprivilegedUserns,
hasBubblewrapReadonlySandboxCapability,
},
'Environment check complete',
);
@@ -66,6 +85,12 @@ export async function run(_args: string[]): Promise<void> {
HAS_ENV: hasEnv,
HAS_AUTH: hasAuth,
HAS_REGISTERED_GROUPS: hasRegisteredGroups,
HAS_BWRAP: hasBubblewrap,
HAS_SOCAT: hasSocat,
APPARMOR_RESTRICT_UNPRIVILEGED_USERNS:
apparmorRestrictUnprivilegedUserns ?? 'n/a',
HAS_BWRAP_READONLY_SANDBOX_CAPABILITY:
hasBubblewrapReadonlySandboxCapability,
STATUS: 'success',
LOG: 'logs/setup.log',
});

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from 'vitest';
import {
ensureLinuxReadonlySandboxAppArmorSupport,
getAppArmorRestrictUnprivilegedUsernsValue,
canUseLinuxBubblewrapReadonlySandbox,
getPlatform,
isWSL,
isRoot,
@@ -99,6 +102,218 @@ describe('commandExists', () => {
});
});
describe('canUseLinuxBubblewrapReadonlySandbox', () => {
it('returns false outside linux', () => {
expect(
canUseLinuxBubblewrapReadonlySandbox(
'macos',
() => {
throw new Error('should not probe commands on macOS');
},
),
).toBe(false);
});
it('returns false when bwrap is not installed', () => {
expect(
canUseLinuxBubblewrapReadonlySandbox(
'linux',
() => false,
),
).toBe(false);
});
it('returns true when linux bwrap readonly probe succeeds', () => {
expect(
canUseLinuxBubblewrapReadonlySandbox(
'linux',
() => true,
(() => Buffer.from('')) as typeof import('child_process').execSync,
),
).toBe(true);
});
it('returns false when linux bwrap readonly probe fails', () => {
expect(
canUseLinuxBubblewrapReadonlySandbox(
'linux',
() => true,
(() => {
throw new Error('permission denied');
}) as typeof import('child_process').execSync,
),
).toBe(false);
});
});
describe('getAppArmorRestrictUnprivilegedUsernsValue', () => {
it('returns null when the proc sysctl path is unavailable', () => {
expect(
getAppArmorRestrictUnprivilegedUsernsValue((() => {
throw new Error('missing');
}) as typeof import('fs').readFileSync),
).toBeNull();
});
it('returns the trimmed proc sysctl value', () => {
expect(
getAppArmorRestrictUnprivilegedUsernsValue((() =>
'1\n') as typeof import('fs').readFileSync),
).toBe('1');
});
});
describe('ensureLinuxReadonlySandboxAppArmorSupport', () => {
it('does nothing outside linux', () => {
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'macos',
}),
).toBe('not-linux');
});
it('does nothing when apparmor does not block sandboxing', () => {
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '0',
sandboxCapable: false,
}),
).toBe('not-needed');
});
it('does nothing when sandbox is already capable', () => {
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: true,
}),
).toBe('not-needed');
});
it('requires root when linux apparmor blocks sandboxing', () => {
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: false,
isRootUser: false,
}),
).toBe('requires-root');
});
it('writes and applies the sysctl when running as root', () => {
const writes: string[] = [];
const commands: string[] = [];
const fileContents = new Map<string, string>([
['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'],
]);
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: false,
isRootUser: true,
readFileSyncFn: ((target) => {
const value = fileContents.get(String(target));
if (value == null) throw new Error('missing');
return value;
}) as typeof import('fs').readFileSync,
mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync,
writeFileSyncFn: ((target, contents) => {
const normalizedTarget = String(target);
const normalizedContents = String(contents);
writes.push(`${normalizedTarget}\n${normalizedContents}`);
fileContents.set(normalizedTarget, normalizedContents);
}) as typeof import('fs').writeFileSync,
execFn: ((command) => {
commands.push(String(command));
fileContents.set(
'/proc/sys/kernel/apparmor_restrict_unprivileged_userns',
'0\n',
);
return Buffer.from('');
}) as typeof import('child_process').execSync,
}),
).toBe('configured');
expect(writes[0]).toContain('/etc/sysctl.d/90-ejclaw-sandbox.conf');
expect(writes[0]).toContain(
'kernel.apparmor_restrict_unprivileged_userns=0',
);
expect(commands).toContain(
'sysctl -w kernel.apparmor_restrict_unprivileged_userns=0',
);
});
it('skips rewriting an already-correct sysctl file but still applies and verifies', () => {
const writes: string[] = [];
const commands: string[] = [];
const fileContents = new Map<string, string>([
['/etc/sysctl.d/90-ejclaw-sandbox.conf',
'# Managed by EJClaw setup to allow bubblewrap readonly sandboxing.\n' +
'kernel.apparmor_restrict_unprivileged_userns=0\n'],
['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'],
]);
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: false,
isRootUser: true,
readFileSyncFn: ((target) => {
const value = fileContents.get(String(target));
if (value == null) throw new Error('missing');
return value;
}) as typeof import('fs').readFileSync,
mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync,
writeFileSyncFn: ((target, contents) => {
writes.push(`${String(target)}\n${String(contents)}`);
}) as typeof import('fs').writeFileSync,
execFn: ((command) => {
commands.push(String(command));
fileContents.set(
'/proc/sys/kernel/apparmor_restrict_unprivileged_userns',
'0\n',
);
return Buffer.from('');
}) as typeof import('child_process').execSync,
}),
).toBe('configured');
expect(writes).toHaveLength(0);
expect(commands).toContain(
'sysctl -w kernel.apparmor_restrict_unprivileged_userns=0',
);
});
it('returns failed when the sysctl apply does not take effect', () => {
const fileContents = new Map<string, string>([
['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'],
]);
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: false,
isRootUser: true,
readFileSyncFn: ((target) => {
const value = fileContents.get(String(target));
if (value == null) throw new Error('missing');
return value;
}) as typeof import('fs').readFileSync,
mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync,
writeFileSyncFn: (() => undefined) as typeof import('fs').writeFileSync,
execFn: (() => Buffer.from('')) as typeof import('child_process').execSync,
}),
).toBe('failed');
});
});
// --- getNodeVersion ---
describe('getNodeVersion', () => {

View File

@@ -7,6 +7,12 @@ import os from 'os';
export type Platform = 'macos' | 'linux' | 'unknown';
export type ServiceManager = 'launchd' | 'systemd' | 'none';
export type LinuxReadonlySandboxAppArmorSetupResult =
| 'not-linux'
| 'not-needed'
| 'requires-root'
| 'failed'
| 'configured';
export function getPlatform(): Platform {
const platform = os.platform();
@@ -115,6 +121,110 @@ export function commandExists(name: string): boolean {
}
}
export function canUseLinuxBubblewrapReadonlySandbox(
platform: Platform = getPlatform(),
commandExistsFn: (name: string) => boolean = commandExists,
execFn: typeof execSync = execSync,
): boolean {
if (platform !== 'linux') return false;
if (!commandExistsFn('bwrap')) return false;
try {
execFn('bwrap --ro-bind / / /bin/true', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function readProcSysValue(
procPath: string,
readFileSyncFn: typeof fs.readFileSync = fs.readFileSync,
): string | null {
try {
return readFileSyncFn(procPath, 'utf-8').trim();
} catch {
return null;
}
}
export function getAppArmorRestrictUnprivilegedUsernsValue(
readFileSyncFn: typeof fs.readFileSync = fs.readFileSync,
): string | null {
return readProcSysValue(
'/proc/sys/kernel/apparmor_restrict_unprivileged_userns',
readFileSyncFn,
);
}
export function ensureLinuxReadonlySandboxAppArmorSupport(options?: {
platform?: Platform;
isRootUser?: boolean;
apparmorRestrictUnprivilegedUserns?: string | null;
sandboxCapable?: boolean;
readFileSyncFn?: typeof fs.readFileSync;
mkdirSyncFn?: typeof fs.mkdirSync;
writeFileSyncFn?: typeof fs.writeFileSync;
execFn?: typeof execSync;
}): LinuxReadonlySandboxAppArmorSetupResult {
const platform = options?.platform ?? getPlatform();
if (platform !== 'linux') return 'not-linux';
const readFileSyncFn = options?.readFileSyncFn ?? fs.readFileSync;
const apparmorRestrictUnprivilegedUserns =
options?.apparmorRestrictUnprivilegedUserns ??
getAppArmorRestrictUnprivilegedUsernsValue(readFileSyncFn);
const sandboxCapable =
options?.sandboxCapable ?? canUseLinuxBubblewrapReadonlySandbox(platform);
if (
apparmorRestrictUnprivilegedUserns !== '1' ||
sandboxCapable
) {
return 'not-needed';
}
const isRootUser = options?.isRootUser ?? isRoot();
if (!isRootUser) return 'requires-root';
const mkdirSyncFn = options?.mkdirSyncFn ?? fs.mkdirSync;
const writeFileSyncFn = options?.writeFileSyncFn ?? fs.writeFileSync;
const execFn = options?.execFn ?? execSync;
const sysctlPath = '/etc/sysctl.d/90-ejclaw-sandbox.conf';
const sysctlContents =
'# Managed by EJClaw setup to allow bubblewrap readonly sandboxing.\n' +
'kernel.apparmor_restrict_unprivileged_userns=0\n';
let existingContents: string | null = null;
try {
existingContents = readFileSyncFn(sysctlPath, 'utf-8');
} catch {
existingContents = null;
}
try {
mkdirSyncFn('/etc/sysctl.d', { recursive: true });
if (existingContents !== sysctlContents) {
writeFileSyncFn(sysctlPath, sysctlContents);
}
execFn('sysctl -w kernel.apparmor_restrict_unprivileged_userns=0', {
stdio: 'ignore',
});
const appliedValue = getAppArmorRestrictUnprivilegedUsernsValue(
readFileSyncFn,
);
if (appliedValue !== '0') {
throw new Error(
`kernel.apparmor_restrict_unprivileged_userns remained ${appliedValue ?? 'unavailable'} after sysctl apply`,
);
}
return 'configured';
} catch {
return 'failed';
}
}
export function getNodeVersion(): string | null {
try {
const version = execSync('node --version', { encoding: 'utf-8' }).trim();

View File

@@ -11,7 +11,12 @@ import os from 'os';
import path from 'path';
import { logger } from '../src/logger.js';
import { getPlatform, getNodePath, getServiceManager } from './platform.js';
import {
ensureLinuxReadonlySandboxAppArmorSupport,
getPlatform,
getNodePath,
getServiceManager,
} from './platform.js';
import {
detectLegacyServiceIssues,
formatLegacyServiceFailureMessage,
@@ -33,6 +38,22 @@ export async function run(_args: string[]): Promise<void> {
logger.info({ platform, nodePath, projectRoot }, 'Setting up service');
const readonlySandboxAppArmorSetup =
ensureLinuxReadonlySandboxAppArmorSupport();
if (readonlySandboxAppArmorSetup === 'configured') {
logger.info(
'Configured AppArmor user namespace sysctl for EJClaw readonly sandbox support',
);
} else if (readonlySandboxAppArmorSetup === 'failed') {
logger.warn(
'Failed to apply AppArmor user namespace sysctl for EJClaw readonly sandbox support',
);
} else if (readonlySandboxAppArmorSetup === 'requires-root') {
logger.warn(
'Readonly sandbox strict mode requires root setup to disable AppArmor unprivileged userns restriction',
);
}
const legacyServiceIssues = detectLegacyServiceIssues(
projectRoot,
serviceManager,