mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
96 lines
4.4 KiB
TypeScript
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: /);
|
|
});
|
|
});
|