245 lines
9.0 KiB
TypeScript
245 lines
9.0 KiB
TypeScript
import { spawnSync } from 'node:child_process';
|
|
import { copyFile, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { join, resolve } from 'node:path';
|
|
import {
|
|
allHookBasenames,
|
|
backupPathFor,
|
|
hookCommandFor,
|
|
resolvePaths,
|
|
} from '../src/paths.js';
|
|
|
|
describe('resolvePaths (codex)', () => {
|
|
it('places hooks.json + pointer under <home>/.codex/', () => {
|
|
const home = resolve('/fake/home');
|
|
const paths = resolvePaths({ home, hooksDir: resolve('/some/dist/hooks') });
|
|
expect(paths.codexDir).toBe(join(home, '.codex'));
|
|
// Codex targets a STANDALONE hooks.json — NOT config.toml.
|
|
expect(paths.configPath).toBe(join(home, '.codex', 'hooks.json'));
|
|
expect(paths.pointerPath).toBe(join(home, '.codex', 'hive-mind-install.json'));
|
|
});
|
|
|
|
it('hooksDir override wins over moduleUrl', () => {
|
|
const explicit = resolve('/x/y/hooks');
|
|
const paths = resolvePaths({
|
|
home: resolve('/h'),
|
|
hooksDir: explicit,
|
|
moduleUrl: 'file:///irrelevant/dist/install.js',
|
|
});
|
|
expect(paths.hooksDir).toBe(explicit);
|
|
});
|
|
|
|
it('falls back to cwd/dist/hooks when neither moduleUrl nor hooksDir is given', () => {
|
|
const paths = resolvePaths({ home: resolve('/h') });
|
|
expect(paths.hooksDir).toBe(resolve(process.cwd(), 'dist', 'hooks'));
|
|
});
|
|
});
|
|
|
|
describe('hookCommandFor (codex)', () => {
|
|
it('produces a quoted node invocation around the absolute script path', () => {
|
|
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), undefined, { platform: 'linux' });
|
|
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
|
|
});
|
|
|
|
it('appends --cli-path when supplied', () => {
|
|
const cmd = hookCommandFor(
|
|
resolve('/abs/dist/hooks/session-start.js'),
|
|
'/abs/cli/dist/index.js',
|
|
{ platform: 'linux' },
|
|
);
|
|
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
|
|
});
|
|
|
|
it('omits --cli-path when empty string is passed', () => {
|
|
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), '', { platform: 'linux' });
|
|
expect(cmd).not.toContain('--cli-path');
|
|
});
|
|
|
|
it('rejects a Windows root unsafe to embed in either host shell', () => {
|
|
expect(() => hookCommandFor('C:\\hooks\\session-start.js', undefined, {
|
|
platform: 'win32',
|
|
nodePath: 'C:\\runtime\\node.exe',
|
|
systemRoot: 'C:\\Windows & attacker',
|
|
})).toThrow(/SystemRoot/);
|
|
});
|
|
|
|
it.runIf(process.platform === 'win32')(
|
|
'survives Codex Rust cmd.exe /C dispatch with spaces and apostrophes',
|
|
async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "codex hook O'Brien "));
|
|
try {
|
|
const stdinMarker = 'CODEX_STDIN_čćžšđ_漢_🐝';
|
|
const stdoutMarker = 'CODEX_STDOUT_čćžšđ_漢_🐝';
|
|
const stderrMarker = 'CODEX_STDERR_čćžšđ_漢_🐝';
|
|
const fixtureDir = join(root, "fixture dir O'Brien");
|
|
const scriptPath = join(fixtureDir, "hook O'Brien.mjs");
|
|
const cliPath = join(root, "cli dir O'Brien", 'index.js');
|
|
const nodePath = join(root, "node runtime O'Brien.exe");
|
|
const decoyPowerShell = join(root, 'powershell.exe');
|
|
const rustSource = join(root, 'codex-runner.rs');
|
|
const rustExe = join(root, 'codex-runner.exe');
|
|
await mkdir(fixtureDir, { recursive: true });
|
|
await copyFile(process.execPath, nodePath);
|
|
await writeFile(decoyPowerShell, 'DECOY_WORKSPACE_POWERSHELL', 'utf8');
|
|
await writeFile(scriptPath, [
|
|
"let stdin = '';",
|
|
`const stdoutMarker = ${JSON.stringify(stdoutMarker)};`,
|
|
"process.stdin.setEncoding('utf8');",
|
|
"for await (const chunk of process.stdin) stdin += chunk;",
|
|
"process.stdout.write(JSON.stringify({ stdin, argv: process.argv.slice(2), stdoutMarker }));",
|
|
`process.stderr.write(${JSON.stringify(stderrMarker)});`,
|
|
'process.exitCode = 23;',
|
|
'',
|
|
].join('\n'), 'utf8');
|
|
await writeFile(rustSource, String.raw`
|
|
use std::env;
|
|
use std::io::{self, Read, Write};
|
|
use std::process::{Command, Stdio};
|
|
|
|
fn main() {
|
|
let command = env::var("CODEX_TEST_COMMAND").expect("CODEX_TEST_COMMAND");
|
|
let comspec = env::var("ComSpec").unwrap_or_else(|_| "cmd.exe".to_string());
|
|
let mut input = Vec::new();
|
|
io::stdin().read_to_end(&mut input).expect("read stdin");
|
|
let mut child = Command::new(comspec)
|
|
.arg("/C")
|
|
.arg(command)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.expect("spawn cmd.exe");
|
|
child.stdin.take().expect("child stdin").write_all(&input).expect("forward stdin");
|
|
let output = child.wait_with_output().expect("wait for cmd.exe");
|
|
io::stdout().write_all(&output.stdout).expect("forward stdout");
|
|
io::stderr().write_all(&output.stderr).expect("forward stderr");
|
|
std::process::exit(output.status.code().unwrap_or(1));
|
|
}
|
|
`, 'utf8');
|
|
|
|
const compile = spawnSync('rustc', [rustSource, '-o', rustExe], {
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
});
|
|
expect(compile.status, compile.stderr).toBe(0);
|
|
|
|
const command = hookCommandFor(scriptPath, cliPath, {
|
|
platform: 'win32',
|
|
nodePath,
|
|
systemRoot: 'C:\\Windows',
|
|
});
|
|
expect(command).toMatch(/^C:\\Windows\\System32\\WindowsPowerShell\\v1\.0\\powershell\.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand [A-Za-z0-9+/=]+$/);
|
|
expect(command).not.toContain('"');
|
|
const payload = Buffer.from(command.split(' ').at(-1) as string, 'base64').toString('utf16le');
|
|
expect(payload).toContain(`& '${nodePath.replaceAll("'", "''")}'`);
|
|
|
|
const run = spawnSync(rustExe, [], {
|
|
cwd: root,
|
|
env: { ...process.env, CODEX_TEST_COMMAND: command },
|
|
input: stdinMarker,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
});
|
|
expect(run.status).toBe(23);
|
|
expect(JSON.parse(run.stdout)).toEqual({
|
|
stdin: stdinMarker,
|
|
argv: ['--cli-path', cliPath],
|
|
stdoutMarker,
|
|
});
|
|
expect(run.stderr).toBe(stderrMarker);
|
|
|
|
const missingCommand = hookCommandFor(scriptPath, cliPath, {
|
|
platform: 'win32',
|
|
nodePath: join(root, 'missing-node.exe'),
|
|
systemRoot: 'C:\\Windows',
|
|
});
|
|
const missingRun = spawnSync(rustExe, [], {
|
|
cwd: root,
|
|
env: { ...process.env, CODEX_TEST_COMMAND: missingCommand },
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
});
|
|
expect(missingRun.status, missingRun.stderr).not.toBe(0);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
it.runIf(process.platform === 'win32')(
|
|
'survives Codex PowerShell host dispatch',
|
|
async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'codex-powershell-host-'));
|
|
try {
|
|
const scriptPath = join(root, 'session-start.mjs');
|
|
const decoyPowerShell = join(root, 'powershell.exe');
|
|
await writeFile(scriptPath, 'process.stdin.pipe(process.stdout);\n', 'utf8');
|
|
await writeFile(decoyPowerShell, 'DECOY_WORKSPACE_POWERSHELL', 'utf8');
|
|
|
|
const command = hookCommandFor(scriptPath, undefined, {
|
|
platform: 'win32',
|
|
nodePath: process.execPath,
|
|
});
|
|
const hostPowerShell = join(
|
|
process.env.SystemRoot ?? 'C:\\Windows',
|
|
'System32',
|
|
'WindowsPowerShell',
|
|
'v1.0',
|
|
'powershell.exe',
|
|
);
|
|
const run = spawnSync(
|
|
hostPowerShell,
|
|
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command],
|
|
{
|
|
cwd: root,
|
|
input: 'POWERSHELL_HOST_OK',
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
},
|
|
);
|
|
|
|
expect(run.status, run.stderr).toBe(0);
|
|
expect(run.stdout).toBe('POWERSHELL_HOST_OK');
|
|
|
|
const missingCommand = hookCommandFor(scriptPath, undefined, {
|
|
platform: 'win32',
|
|
nodePath: join(root, 'missing-node.exe'),
|
|
});
|
|
const missingRun = spawnSync(
|
|
hostPowerShell,
|
|
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', missingCommand],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
},
|
|
);
|
|
expect(missingRun.status, missingRun.stderr).not.toBe(0);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
15_000,
|
|
);
|
|
});
|
|
|
|
describe('backupPathFor (codex)', () => {
|
|
it('replaces colons and dots in the timestamp for filesystem safety', () => {
|
|
const backup = backupPathFor('/h/.codex/hooks.json', '2026-04-28T10:30:45.123Z');
|
|
expect(backup).toBe('/h/.codex/hooks.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
|
|
});
|
|
});
|
|
|
|
describe('allHookBasenames (codex)', () => {
|
|
it('returns the four canonical basenames (CC clone)', () => {
|
|
expect([...allHookBasenames()].sort()).toEqual([
|
|
'pre-compact',
|
|
'session-start',
|
|
'stop',
|
|
'user-prompt-submit',
|
|
]);
|
|
});
|
|
});
|