Files
gstack/browse/test/path-security-realpath.test.ts
Garry Tanandcapy-ai[bot] fb30ed1047 v1.91.297.0 fix(browse): read Windows process start times in-process so the terminal agent boots without PowerShell (GSTA-139 flaky pass) (#3545)
* fix(browse): read Windows process start times in-process; drop PowerShell from two flaky test paths (GSTA-139 S5)

The terminal agent's identity probe launched powershell.exe Get-CimInstance
with a 2 s timeout on every boot (twice when started standalone). On loaded
Windows runners a launch takes ~0.94 s at p50 and up to 10.8 s, so the agent
missed the watchdog test's 5 s window and a stalled launch returned an empty
identity. Under Bun the start time now comes from GetProcessTimes in the
same /Date(ms)/ form PowerShell's CreationDate serialises to, so records,
the Node daemon (which keeps the PowerShell reader) and earlier releases
agree. The kill-time command-line check still uses PowerShell.

path-security-realpath builds its 8.3 alias with GetShortPathNameW instead
of a PowerShell launch that stalled for 30 s and was reported as '8.3
disabled'. cookie-import-native-job prints the Restart Manager receipt when
its owner assertion fails.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): scope test 5's PowerShell assertion to the owner; identify a win32 agent by generation when readers disagree

Review of d4f0ea3c (specialist + outside voice, confirmed by Windows Free
Tests 40699973746 shard 3): test 5's fake child pid cannot be opened, so its
start time falls back to the PowerShell query; only the owner must skip it.
Adversarial note: the Bun reader and the Node daemon's PowerShell reader can
disagree for a process created in a DST-ambiguous hour; on win32 a live
process carrying the record's --agent-gen is still the agent, so it is
stopped, never treated as gone beside a second agent.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): a win32 start-time mismatch with an unreadable command line is unknown, not gone

Fix-check (adversarial reviewer): when the Bun reader and a Node-written
record disagree and the PowerShell command-line query also fails, the agent
was read as gone and a second agent could start beside it. Base treated a
failed query as unknown; this restores that. CHANGELOG [Unreleased] entry.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): keep agentStatus's zombie check before the generation return (terminal-agent-watchdog source pin)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-28 19:23:18 +00:00

96 lines
4.4 KiB
TypeScript

/**
* #3104: safe directories and target paths go through one realpath function.
* On Windows `fs.realpathSync` keeps an 8.3 short temp dir (C:\Users\BURCIN~1)
* while `fs.realpathSync.native` expands it, so a make-pdf target inside the
* temp dir was rejected. Each case runs the validator in a child process whose
* temp environment is the alias under test, the way the daemon computes its
* safe directories once at startup. On POSIX the fixture lives under /var/tmp
* because /tmp is always a safe directory there.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { dlopen, FFIType, ptr } from 'bun:ffi';
const VALIDATOR = path.resolve(import.meta.dir, '../src/path-security.ts');
const WIN = process.platform === 'win32';
let root = '';
beforeAll(() => {
root = fs.realpathSync.native(fs.mkdtempSync(path.join(WIN ? os.tmpdir() : path.join(path.sep, 'var', 'tmp'), 'gstack-realpath-')));
fs.mkdirSync(path.join(root, 'cwd'));
fs.mkdirSync(path.join(root, 'Long Temp Directory'));
});
afterAll(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
});
function validate(tempDir: string, checks: Array<['output' | 'read', string]>): string[] {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value;
if (WIN) Object.assign(env, { TEMP: tempDir, TMP: tempDir });
else env.TMPDIR = tempDir;
const script = `
const v = require(${JSON.stringify(VALIDATOR)});
for (const [kind, target] of ${JSON.stringify(checks)}) {
try { (kind === 'output' ? v.validateOutputPath : v.validateReadPath)(target); console.log('OK'); }
catch (e) { console.log('REJECT ' + e.message); }
}`;
const result = Bun.spawnSync([process.execPath, '-e', script], { cwd: path.join(root, 'cwd'), env, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
expect(result.stderr.toString()).toBe('');
return result.stdout.toString().trim().split(/\r?\n/);
}
function shortPath(dir: string): string {
const kernel = dlopen('kernel32.dll', { GetShortPathNameW: { args: [FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.u32 } });
try {
const input = Buffer.from(dir + '\0', 'utf16le');
const output = Buffer.alloc(65536);
const length = kernel.symbols.GetShortPathNameW(ptr(input), ptr(output), 32768);
if (length === 0 || length >= 32768) throw new Error(`GetShortPathNameW failed for ${dir} (returned ${length})`);
return output.subarray(0, length * 2).toString('utf16le');
} finally {
kernel.close();
}
}
describe('#3104 one realpath for safe dirs and targets', () => {
test('a temp dir reached through an alias accepts outputs and reads inside it', () => {
const real = path.join(root, 'Long Temp Directory');
let alias = path.join(root, 'alias');
if (WIN) {
alias = shortPath(real);
if (!alias || alias.toLowerCase() === real.toLowerCase()) {
console.log(`[skip] 8.3 short names are disabled on this volume (${real}); the alias case cannot be built`);
return;
}
} else {
fs.symlinkSync(real, alias);
}
const work = path.join(alias, 'gstack-render-browse-probe');
fs.mkdirSync(work);
fs.writeFileSync(path.join(work, 'pdf-0.json'), '{}');
expect(validate(alias, [
['output', path.join(work, 'artifact-0.pdf')],
['output', path.join(work, 'new-dir', 'artifact-1.pdf')],
['read', path.join(work, 'pdf-0.json')],
['read', path.join(work, 'missing-dir', 'payload.json')],
['output', path.join(root, 'outside.pdf')],
])).toEqual(['OK', 'OK', 'OK', 'OK', expect.stringMatching(/^REJECT Path must be within: /)]);
});
test('a missing temp dir matches a target that differs only in case on Windows, and only there', () => {
const missing = path.join(root, 'Missing-Temp');
const [result] = validate(missing, [['output', path.join(root, 'missing-temp', 'artifact.pdf')]]);
if (WIN) expect(result).toBe('OK');
else expect(result).toMatch(/^REJECT Path must be within: /);
});
test('existing components compare exactly after resolution', () => {
const [result] = validate(path.join(root, 'Long Temp Directory'), [['output', path.join(root, 'Long Temp Directory-evil', 'x.pdf')]]);
expect(result).toMatch(/^REJECT Path must be within: /);
});
});