Stabilize paired reviewer recovery and owner follow-up
This commit is contained in:
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user