feat: add deterministic daily ops SOP and verified dev lifecycle

This commit is contained in:
Codex committed 2026-10-21 16:50:11 +09:00
1 parent dac4af53e0
commit 2aafb6f92f
16 files changed
+935 -5

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import fs from 'node:fs/promises';
import {createReadStream} from 'node:fs';
import path from 'node:path';
import {createHash} from 'node:crypto';
import {run,OpsError,requireSuccess,npmInvocation} from './core.mjs';
import {gitState,git} from './git.mjs';
async function hashFile(file) {const hash=createHash('sha256');for await (const chunk of createReadStream(file))hash.update(chunk);return hash.digest('hex');}
export async function snapshot(directory, filter=()=>true) {
const results={};
async function visit(dir) {
for(const entry of await fs.readdir(dir,{withFileTypes:true}).catch(error=>{if(error.code==='ENOENT')return [];throw error;})) {
const file=path.join(dir,entry.name);
if(entry.isSymbolicLink())continue;
if(entry.isDirectory())await visit(file);
else if(entry.isFile()&&filter(file)) {const stat=await fs.stat(file);const hash=await hashFile(file);results[path.relative(directory,file)]={size:stat.size,mtimeMs:stat.mtimeMs,sha256:hash};}
}
}
await visit(directory);return results;
}
export function freshArtifacts(before, after, startedAt) {
return Object.entries(after).filter(([name,value])=>value.size>0 && value.mtimeMs>=startedAt-2000 && (!before[name] || before[name].sha256!==value.sha256 || before[name].mtimeMs!==value.mtimeMs)).map(([name,value])=>({name,...value}));
}
export async function sourceFingerprint(ctx) {
const listing=await git(ctx,['ls-files','--cached','--others','--exclude-standard','-z'],{maxOutput:16*1024*1024});
requireSuccess(listing,'SOURCE_SCAN_FAILED','Cannot enumerate source files.');
if(listing.outputTruncated)throw new OpsError('SOURCE_SCAN_TOO_LARGE','Source listing exceeds the evidence limit.');
const digest=createHash('sha256');
for(const name of [...new Set(listing.stdout.split('\0').filter(Boolean))].sort()) {
digest.update(name+'\0');
const file=path.join(ctx.root,name);
try{const stat=await fs.lstat(file);if(stat.isSymbolicLink())digest.update(await fs.readlink(file));else if(stat.isFile())digest.update(await hashFile(file));else digest.update('[non-file]');}catch(error){if(error.code==='ENOENT')digest.update('[deleted]');else throw error;}
}
return digest.digest('hex');
}
export async function build(ctx, action) {
if(action==='package' && (process.platform!=='win32'||process.arch!=='x64'))throw new OpsError('WINDOWS_X64_REQUIRED','Installer packaging is supported only on Windows x64.',{},'UNSUPPORTED');
const expected=action==='package'?'npm run runtimes:prepare && npm run build && electron-builder --win nsis --x64':'npm run typecheck && electron-vite build';
const script=action==='package'?'dist:installer':'build';
if(ctx.manifest.scripts?.[script]!==expected)throw new OpsError('SCRIPT_CONTRACT_CHANGED',`${script} script changed; review its safety before using this fixed operation.`);
if(ctx.manifest.scripts?.build!=='npm run typecheck && electron-vite build' || ctx.manifest.scripts?.typecheck!=='tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json' || (action==='package'&&ctx.manifest.scripts?.['runtimes:prepare']!=='node scripts/prepare-runtime-bundle.mjs'))throw new OpsError('SCRIPT_CONTRACT_CHANGED','Nested typecheck/runtime preparation script changed; review before execution.');
const directory=path.join(ctx.root,action==='package'?'release':'out');
const filter=action==='package'?file=>path.basename(file)===`YCode-${ctx.manifest.version}-Setup-x64.exe`:file=>/[/\\](?:main[/\\]index\.mjs|preload[/\\]index\.(?:js|mjs|cjs)|renderer[/\\]index\.html)$/.test(file);
for(const hook of ['prebuild','postbuild','predist:installer','postdist:installer','pretypecheck','posttypecheck','preruntimes:prepare','postruntimes:prepare'])if(ctx.manifest.scripts?.[hook])throw new OpsError('LIFECYCLE_HOOK_BLOCKED',`Unexpected ${hook} lifecycle hook needs review.`);
const before=await snapshot(directory,filter), initial=await gitState(ctx), fingerprint=await sourceFingerprint(ctx), startedAt=Date.now();
const npm=await npmInvocation();
const env={...process.env,CSC_IDENTITY_AUTO_DISCOVERY:'false'};
// This action creates local unsigned artifacts only; it never inherits signing credentials.
for(const key of Object.keys(env))if(/^(?:CSC_|WIN_CSC_|APPLE_|GH_TOKEN$|GITHUB_TOKEN$|AZURE_|YCODE_.*SIGN)/i.test(key)&&key.toUpperCase()!=='CSC_IDENTITY_AUTO_DISCOVERY')delete env[key];
const result=await run(npm.command,[...npm.prefix,'run',script,...(action==='package'?['--','--publish','never']:[])],{cwd:ctx.root,timeoutMs:ctx.timeoutMs,logDir:ctx.logDir,env});
if(result.timedOut)ctx.keepLock=true; // Descendant toolchain processes may remain; never allow an automatic overlapping retry.
requireSuccess(result,'BUILD_FAILED',`${action} failed; no source changes or dependency upgrades were attempted.`);
const after=await snapshot(directory,filter), artifacts=freshArtifacts(before,after,startedAt), current=await gitState(ctx);
const verified=action==='package'?artifacts.length>0:['main/index.mjs','renderer/index.html'].every(name=>artifacts.some(a=>a.name.split(path.sep).join('/')===name)) && artifacts.some(a=>/^preload\/index\.(?:js|mjs|cjs)$/.test(a.name.split(path.sep).join('/')));
if(!verified)throw new OpsError('ARTIFACTS_UNVERIFIED','Command exited successfully but expected fresh artifacts are missing.',{logPath:result.logPath,freshArtifacts:artifacts},'FAIL');
if(await sourceFingerprint(ctx)!==fingerprint)throw new OpsError('SOURCE_CHANGED','Source content changed during the operation; artifacts are not certified.',{before:initial,current,logPath:result.logPath},'FAIL');
if(current.sha!==initial.sha)throw new OpsError('VERSION_CHANGED','HEAD changed during the operation; artifact version cannot be certified.',{before:initial,current},'FAIL');
return {status:'PASS',code:action==='package'?'PACKAGED':'BUILT',summary:action==='package'?'Fresh unsigned Windows x64 installer created locally; not published.':'Build completed with fresh main, preload and renderer entry artifacts.',git:current,evidence:{startedAt:new Date(startedAt).toISOString(),logPath:result.logPath,artifactDirectory:directory,artifacts:artifacts.slice(0,10),sourceFingerprint:fingerprint,sourceDirty:initial.dirty,sourceIdentity:(initial.dirty||current.dirty)?'HEAD plus local modifications; not a pristine commit build':'HEAD'}};
}
+83
View File
@@ -0,0 +1,83 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
export class OpsError extends Error {
constructor(code, message, evidence = {}, status = 'BLOCKED') { super(message); Object.assign(this, {code, evidence, status}); }
}
export function redact(value) {
let text=String(value).replace(/(https?:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi,'$1[REDACTED]@').replace(/((?:authorization|token|password|secret)\s*[:=]\s*)(?:Bearer\s+)?[^\s,;]+/gi,'$1[REDACTED]');
for(const [key,secret] of Object.entries(process.env)) if(/TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY/i.test(key)&&secret?.length>=8)text=text.split(secret).join('[REDACTED]');
return text;
}
export function redactObject(value) {
if(typeof value==='string')return redact(value);
if(Array.isArray(value))return value.map(redactObject);
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([key,item])=>[key,redactObject(item)]));
return value;
}
export function publicResult(value) {
const result=redactObject(value);
for(const key of ['action','code','summary','root'])if(typeof result[key]==='string')result[key]=result[key].slice(0,key==='summary'?4000:8192);
if(Buffer.byteLength(JSON.stringify(result),'utf8')>65536) {
result.evidence={truncated:true,reason:'Evidence exceeds 64 KiB JSON limit; inspect local operation logs.'};
if(result.git)result.git={...result.git,changes:result.git.changes?.slice(0,5).map(s=>s.slice(0,1000)),changesTruncated:true};
delete result.sync;
}
return result;
}
export const actions = new Set(['status','pull','dev','dev-latest','restart-dev','stop-dev','build','package','doctor']);
export function parseArgs(args) {
const options = {action: args[0], json: false, timeoutMs: 600000};
if (!actions.has(options.action)) throw new OpsError('INVALID_ACTION', 'Use status, pull, dev, dev-latest, restart-dev, stop-dev, build, package or doctor.');
const seen = new Set();
for (let i=1;i<args.length;i++) {
const key=args[i];
if (seen.has(key)) throw new OpsError('INVALID_ARGUMENT', `Duplicate option: ${key}`);
seen.add(key);
if(key==='--json') options.json=true;
else if(key==='--root') {
const value=args[++i];
if(!value || !path.isAbsolute(value) || value.includes('\0')) throw new OpsError('INVALID_ROOT','--root requires an absolute directory.');
options.root=value;
} else if(key==='--timeout-ms') {
const value=args[++i];
if(!/^\d+$/.test(value || '') || Number(value)<1000 || Number(value)>3600000) throw new OpsError('INVALID_TIMEOUT','Timeout must be 1000..3600000 milliseconds.');
options.timeoutMs=Number(value);
} else throw new OpsError('INVALID_ARGUMENT', `Unknown option: ${key}`);
}
return options;
}
// Never uses a shell. Logs are private local evidence; JSON contains bounded tails only.
export async function run(command, args, {cwd, timeoutMs=30000, logDir, env=process.env,maxOutput=65536}={}) {
let logPath, handle;
if(logDir) { await fs.mkdir(logDir,{recursive:true}); logPath=path.join(logDir,`${Date.now()}-${randomUUID()}.log`); handle=await fs.open(logPath,'wx',0o600); }
return new Promise(resolve=>{
let stdout='',stderr='',log='',outputTruncated=false,timedOut=false, finished=false;
const child=spawn(command,args,{cwd,env,shell:false,windowsHide:true,stdio:['ignore','pipe','pipe']});
const append=(key,data)=>{ const value=data.toString(); if((key==='stdout'?stdout.length:stderr.length)+value.length>maxOutput)outputTruncated=true; if(key==='stdout') stdout=(stdout+value).slice(-maxOutput); else stderr=(stderr+value).slice(-maxOutput); if(handle) log=(log+value).slice(-8*1024*1024); };
child.stdout.on('data',data=>append('stdout',data)); child.stderr.on('data',data=>append('stderr',data));
const timer=setTimeout(()=>{timedOut=true; child.kill(); setTimeout(()=>{ if(!finished) {child.kill('SIGKILL'); finish(null,'TIMEOUT');} },1500).unref();},timeoutMs);
const finish=async(exitCode,error)=>{ if(finished)return; finished=true;clearTimeout(timer);if(timedOut){child.stdout.destroy();child.stderr.destroy();child.unref();}if(handle){await handle.writeFile(redact(log)).catch(()=>{});await handle.close().catch(()=>{});}resolve({exitCode,stdout,stderr:redact(stderr),timedOut,outputTruncated,logPath,error:error?redact(error):undefined}); };
child.on('error',error=>finish(null,error.message));child.on('close',code=>finish(code));
});
}
export function requireSuccess(result,code,summary) {
if(result.exitCode!==0 || result.timedOut) throw new OpsError(result.timedOut?'TIMEOUT':code,summary,{logPath:result.logPath,exitCode:result.exitCode,stderr:result.stderr.slice(-4000),error:result.error},'FAIL');
return result.stdout.trim();
}
export async function withLock(ctx, action, fn) {
await fs.mkdir(ctx.stateDir,{recursive:true});
const file=path.join(ctx.stateDir,'workspace.lock');
let handle;
try {handle=await fs.open(file,'wx',0o600);} catch(error) {if(error.code!=='EEXIST')throw error; throw new OpsError('WORKSPACE_LOCKED','Another operation owns this workspace. Inspect the lock; never automatically remove it.',{lockPath:file});}
const token=randomUUID();await handle.writeFile(JSON.stringify({pid:process.pid,token,action,root:ctx.root,startedAt:new Date().toISOString()}));await handle.close();
try{return await fn();} finally {try {const owner=JSON.parse(await fs.readFile(file,'utf8'));if(owner.token===token && !ctx.keepLock)await fs.unlink(file);}catch{}}
}
export async function npmInvocation() {
if(process.platform!=='win32') return {command:'npm', prefix:[]};
const candidates=[process.env.npm_execpath,path.join(path.dirname(process.execPath),'node_modules/npm/bin/npm-cli.js')].filter(Boolean);
for(const file of candidates) if(path.basename(file)==='npm-cli.js') {try{await fs.access(file);return {command:process.execPath,prefix:[file]};}catch{}}
throw new OpsError('NPM_NOT_FOUND','Run through npm run ops, or use a Node installation containing npm-cli.js.');
}
+35
View File
@@ -0,0 +1,35 @@
// Detached Windows lifetime owner: no services, registry changes or test profile.
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { redact } from './core.mjs';
const [repoRoot, resultFile] = process.argv.slice(2);
if (!repoRoot || !resultFile || process.platform !== 'win32') process.exit(2);
const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'npm run dev'], {
cwd: repoRoot, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, YCODE_SKIP_KERNEL_DATA_RESET: '1' }
});
// Line buffering avoids leaking credentials split across stream chunks. Oversized
// lines are omitted, rather than persisting partial unredacted secrets.
for (const stream of [child.stdout, child.stderr]) {
stream.setEncoding('utf8');
let pending = '', dropping = false;
stream.on('data', chunk => {
for (const part of chunk.match(/[^\n]*\n|[^\n]+$/g) || []) {
if (!dropping) pending += part;
if (pending.length > 65536) { pending = ''; dropping = true; }
if (part.endsWith('\n')) {
process.stdout.write(dropping ? '[oversized log line omitted]\n' : redact(pending));
pending = ''; dropping = false;
}
}
});
stream.on('end', () => { if (pending && !dropping) process.stdout.write(redact(pending)); });
}
child.on('error', error => {
writeFileSync(resultFile, JSON.stringify({ code: 'SPAWN_FAILED', message: redact(error.message) }));
process.exitCode = 1;
});
child.on('exit', (code, signal) => {
writeFileSync(resultFile, JSON.stringify({ code: 'DEV_EXITED', exitCode: code, signal }));
process.exitCode = code ?? 1;
});
+34
View File
@@ -0,0 +1,34 @@
# Read-only snapshot or identity-checked termination. Input is JSON over stdin;
# repository paths/commands are never evaluated as PowerShell source.
$ErrorActionPreference = 'Stop'
$request = [Console]::In.ReadToEnd() | ConvertFrom-Json
function Identity($p) {
[PSCustomObject]@{ pid = [int]$p.ProcessId; parentPid = [int]$p.ParentProcessId; name = $p.Name; commandLine = [string]$p.CommandLine; created = $(if ($null -ne $p.CreationDate) { $p.CreationDate.ToUniversalTime().ToString('o') } else { '' }) }
}
if ($request.action -eq 'stop') {
$stopped = @()
foreach ($expected in $request.processes) {
$p = Get-CimInstance Win32_Process -Filter ('ProcessId = ' + [int]$expected.pid)
if ($null -eq $p) { continue }
$actual = Identity $p
if (-not $expected.created -or -not $expected.commandLine -or $actual.created -ne $expected.created -or $actual.commandLine -cne $expected.commandLine) { throw ('PROCESS_IDENTITY_CHANGED:' + $expected.pid) }
# Open a handle before the final identity check so termination cannot select a reused PID.
$handle = [System.Diagnostics.Process]::GetProcessById([int]$expected.pid)
$null = $handle.Handle
$again = Get-CimInstance Win32_Process -Filter ('ProcessId = ' + [int]$expected.pid)
if ($null -eq $again) { $handle.Dispose(); continue }
$identity = Identity $again
if ($identity.created -ne $expected.created -or $identity.commandLine -cne $expected.commandLine) { $handle.Dispose(); throw ('PROCESS_IDENTITY_CHANGED:' + $expected.pid) }
$handle.Kill()
$handle.Dispose()
$stopped += [int]$expected.pid
}
@{ stopped = $stopped } | ConvertTo-Json -Compress
exit
}
$processes = @(Get-CimInstance Win32_Process | ForEach-Object { Identity $_ })
$windows = @(Get-Process electron -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object {
@{ pid = $_.Id; title = $_.MainWindowTitle; responding = $_.Responding }
})
$listeners = @(Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { @{ pid = $_.OwningProcess; port = $_.LocalPort; address = $_.LocalAddress } })
@{ processes = $processes; windows = $windows; listeners = $listeners } | ConvertTo-Json -Depth 5 -Compress
+204
View File
@@ -0,0 +1,204 @@
import { spawn, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { createHash, randomUUID } from 'node:crypto';
import { readFile, writeFile, mkdir, access, open, rename } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const exec = promisify(execFile);
const here = path.dirname(fileURLToPath(import.meta.url));
import { redact } from './core.mjs';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const result = (status, code, summary, evidence = {}) => ({ status, code, summary, evidence });
const normalize = value => value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase();
export function sameIdentity(a, b) {
return !!a && !!b && a.pid === b.pid && !!a.created && a.created === b.created && !!a.commandLine && a.commandLine === b.commandLine;
}
export function ownedProcesses(snapshot, owner) {
const root = snapshot.processes.find(p => sameIdentity(p, owner));
if (!root) return [];
const owned = [root];
for (let changed = true; changed;) {
changed = false;
for (const p of snapshot.processes) {
if (!owned.some(x => x.pid === p.pid) && owned.some(x => x.pid === p.parentPid && Date.parse(p.created) >= Date.parse(x.created))) {
owned.push(p); changed = true;
}
}
}
return owned;
}
export function rendererMatches(commandLine, repoRoot) {
const match = commandLine.match(/(?:^|\s)--app-path=(?:"([^"]+)"|([^\s]+))/i);
return /(?:^|\s)--type=renderer(?:\s|$)/i.test(commandLine) && !!match && normalize(match[1] || match[2]) === normalize(repoRoot);
}
export function localUrls(log) {
return [...new Set([...log.replace(/\x1b\[[0-9;]*m/g, '').matchAll(/Local:\s+(http:\/\/(?:localhost|127\.0\.0\.1):\d+\/)/g)].map(m => m[1]))].reverse();
}
export async function moduleMatches(url, source, fetchImpl = fetch) {
const response = await fetchImpl(new URL('/src/components/ChatPane.tsx?raw', url), { signal: AbortSignal.timeout(3500), redirect: 'error' });
if (!response.ok) return false;
const text = await response.text();
const match = text.match(/^\s*export default\s+("(?:[^"\\]|\\.)*")\s*;?/s);
if (!match || JSON.parse(match[1]) !== source) return false;
const module = await fetchImpl(new URL('/src/components/ChatPane.tsx', url), { signal: AbortSignal.timeout(3500), redirect: 'error' });
const body = await module.text();
return module.ok && /(?:javascript|ecmascript)/i.test(module.headers.get('content-type') || '') && !/^\s*<!doctype html/i.test(body);
}
export function verifyAttestation(attestation, state, health, repoRoot, now = Date.now()) {
if (!attestation || !state.runId || !health.healthy) return false;
const checkedAt = attestation.checkedAt;
return attestation.ready === true && attestation.runId === state.runId &&
attestation.pid === health.windowPid && attestation.rendererPid === health.rendererPid &&
Number.isFinite(checkedAt) && checkedAt >= Date.parse(state.startedAt) && checkedAt <= now + 2000 && now - checkedAt <= 5000;
}
async function verifiedHealth(snapshot, state, repoRoot, source, log) {
const health = await inspectHealth(snapshot, state, repoRoot, source, log);
const attestation = state.readyPath ? await readJson(state.readyPath) : null;
const rendererReady = verifyAttestation(attestation, state, health, repoRoot);
return { ...health, processWindowHttpHealthy: health.healthy, healthy: health.healthy && rendererReady,
rendererReady, rendererUI: rendererReady ? 'live-visible-app-shell' : 'unverified',
verificationScope: rendererReady ? 'process-window-http-source-and-visible-renderer' : health.verificationScope };
}
async function powershell(request) {
return new Promise((resolve, reject) => {
const child = spawn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-File', path.join(here, 'dev-processes.ps1')], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
const timer = setTimeout(() => { child.kill(); reject(new Error('PROCESS_QUERY_TIMEOUT')); }, 15000);
child.stdout.on('data', chunk => { stdout += chunk; if (stdout.length > 16_000_000) { child.kill(); reject(new Error('PROCESS_QUERY_TOO_LARGE')); } });
child.stderr.on('data', chunk => { stderr += chunk; });
child.on('error', error => { clearTimeout(timer); reject(error); });
child.on('close', code => { clearTimeout(timer); if (code !== 0) reject(new Error(`PROCESS_QUERY_FAILED: ${stderr.slice(0, 1000)}`)); else { try { resolve(JSON.parse(stdout.replace(/^\uFEFF/, ''))); } catch { reject(new Error('INVALID_PROCESS_SNAPSHOT')); } } });
child.stdin.end(JSON.stringify(request));
});
}
async function gitIdentity(repoRoot) {
const git = async args => (await exec('git', ['-C', repoRoot, ...args], { timeout: 15000, maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' })).stdout;
const sha = (await git(['rev-parse', 'HEAD'])).trim();
const status = await git(['status', '--porcelain=v1', '-z', '--untracked-files=all']);
const hash = createHash('sha256').update(sha).update(status).update(await git(['diff', 'HEAD', '--binary']));
const untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean);
for (const file of untracked) hash.update(file).update(await readFile(path.join(repoRoot, file)));
return { sha, dirty: !!status, fingerprint: hash.digest('hex') };
}
async function readJson(file) {
try { return JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
async function save(file, value) {
const tmp = `${file}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(value, null, 2)); await rename(tmp, file);
}
function targetCandidates(snapshot, repoRoot) {
const root = normalize(repoRoot);
return snapshot.processes.filter(p => {
const cmd = normalize(p.commandLine || '');
return (cmd.includes(root + '\\') || rendererMatches(p.commandLine || '', repoRoot)) && /electron-vite|dev-host\.mjs|--type=renderer/i.test(cmd);
});
}
export async function inspectHealth(snapshot, state, repoRoot, source, log, fetchImpl = fetch) {
const owned = ownedProcesses(snapshot, state.owner);
const renderer = owned.find(p => rendererMatches(p.commandLine, repoRoot));
const window = snapshot.windows.find(w => w.responding && /ycode/i.test(w.title) && owned.some(p => p.pid === w.pid && /^electron\.exe$/i.test(p.name) && !/--type=/.test(p.commandLine)));
const ports = snapshot.listeners.filter(l => owned.some(p => p.pid === l.pid && /^node\.exe$/i.test(p.name) && /electron-vite/i.test(p.commandLine))).map(l => l.port);
const urls = [...new Set([...localUrls(log), ...(state.url && /^http:\/\/(?:localhost|127\.0\.0\.1):\d+\/$/.test(state.url) ? [state.url] : [])])];
for (const url of urls) {
if (!ports.includes(Number(new URL(url).port))) continue;
if (renderer && window && await moduleMatches(url, source, fetchImpl).catch(() => false)) return { healthy: true, ownerAlive: true, url, port: Number(new URL(url).port), windowPid: window.pid, rendererPid: renderer.pid, ownerPid: state.owner.pid, verificationScope: 'process-window-and-http-source', rendererUI: 'unverified' };
}
return { healthy: false, ownerAlive: owned.length > 0, renderer: !!renderer, responsiveWindow: !!window, listeningPorts: ports, discoveredUrls: urls };
}
export async function runDev(action, options = {}) {
const repoRoot = path.resolve(options.repoRoot || options.root || process.cwd());
if (process.platform !== 'win32') return result('UNSUPPORTED', 'WINDOWS_REQUIRED', 'Native dev requires Windows; no GUI readiness is claimed.', { repoRoot, platform: process.platform });
if (!['dev', 'status', 'restart', 'stop'].includes(action)) return result('BLOCKED', 'INVALID_ACTION', 'Use dev, status, restart or stop.');
const stateDir = path.join(repoRoot, '.tmp', 'ops');
const stateFile = path.join(stateDir, 'dev-state.json');
const timeoutMs = Math.min(180000, Math.max(5000, options.timeoutMs || 90000));
let state;
let spawnedPid;
let launchPersisted = false;
try {
const unsafe = [...new Set(['YCODE_USER_DATA_DIR', 'ELECTRON_RUN_AS_NODE', 'ELECTRON_NO_ATTACH_CONSOLE', 'NODE_OPTIONS', ...Object.keys(process.env).filter(key => key.startsWith('YCODE_QA_'))])].filter(key => process.env[key]);
if (unsafe.length && action !== 'stop') return result('BLOCKED', 'UNSAFE_PREVIEW_ENV', 'Inherited test/profile environment must be reviewed before normal user preview.', { variables: unsafe });
const identity = await gitIdentity(repoRoot);
state = await readJson(stateFile);
if (state && (state.schema !== 1 || normalize(state.repoRoot) !== normalize(repoRoot))) return result('BLOCKED', 'INVALID_STATE', 'Dev state belongs to a different repository or schema; inspect it before continuing.', { stateFile });
if (state && (!state.owner || !state.identity || !state.logPath ||
path.dirname(path.resolve(state.logPath)) !== stateDir ||
(state.readyPath && path.resolve(state.readyPath) !== path.join(stateDir, 'renderer-health.json')))) {
return result('BLOCKED', 'INVALID_STATE', 'Dev state has invalid identity or log/health paths; inspect it before continuing.', { stateFile });
}
let snapshot = await powershell({ action: 'snapshot' });
const owned = state ? ownedProcesses(snapshot, state.owner) : [];
const candidates = targetCandidates(snapshot, repoRoot);
const unknown = candidates.filter(p => !owned.some(o => o.pid === p.pid));
if (unknown.length) return result('BLOCKED', 'UNTRACKED_DEV', 'Untracked target dev detected. Its version/ownership cannot be safely adopted; inspect it manually. No process was stopped.', { repoRoot, pids: unknown.map(p => p.pid), identity });
if (action === 'stop') {
if (!owned.length) return result('PASS', 'DEV_STOPPED', 'No tracked target dev is running; no process was stopped.', { identity, stateFile });
await powershell({ action: 'stop', processes: [...owned].reverse() });
await sleep(500);
snapshot = await powershell({ action: 'snapshot' });
const survivors = snapshot.processes.filter(p => owned.some(o => sameIdentity(p, o)));
if (survivors.length || targetCandidates(snapshot, repoRoot).length) return result('BLOCKED', 'STOP_INCOMPLETE', 'Target processes remain after identity-checked stop. Inspect before another mutation.', { pids: survivors.map(p => p.pid), stateFile });
return result('PASS', 'DEV_STOPPED', 'Only the verified target dev process tree was stopped; user data was preserved.', { identity, stoppedPids: owned.map(p => p.pid), stateFile });
}
const source = await readFile(path.join(repoRoot, 'src/renderer/src/components/ChatPane.tsx'), 'utf8');
if (owned.length) {
const log = await readFile(state.logPath, 'utf8').catch(() => '');
const health = await verifiedHealth(snapshot, state, repoRoot, source, log);
const matching = identity.fingerprint === state.identity.fingerprint;
if (action !== 'restart') {
if (!matching) return result('BLOCKED', 'VERSION_CHANGED', 'Running dev was started from different source contents. Use restart-dev to restart only its verified process tree.', { identity, startedIdentity: state.identity, health, stateFile });
if (health.healthy) {
const current = await gitIdentity(repoRoot);
if (current.fingerprint !== identity.fingerprint) return result('BLOCKED', 'VERSION_CHANGED_DURING_CHECK', 'Source changed while checking the existing dev; retry status or restart.', { identity, current, stateFile });
return result('PASS', 'DEV_READY_REUSED', 'Existing target dev is healthy and reused.', { ...health, identity, logPath: state.logPath, stateFile });
}
return result('BLOCKED', 'DEV_NOT_READY', 'Target dev exists but is not fully healthy. Inspect its log or explicitly restart-dev; no duplicate was launched.', { identity, health, logPath: state.logPath, stateFile });
}
// Stop only the captured tree; PowerShell rechecks creation time and full command line per handle.
await powershell({ action: 'stop', processes: [...owned].reverse() });
await sleep(500);
snapshot = await powershell({ action: 'snapshot' });
if (targetCandidates(snapshot, repoRoot).length || ownedProcesses(snapshot, state.owner).length) return result('BLOCKED', 'STOP_INCOMPLETE', 'Target processes remain after identity-checked stop; no new dev was launched.', { stateFile });
} else if (action === 'status') return result('BLOCKED', 'DEV_NOT_RUNNING', 'No tracked target dev is running.', { identity, stateFile });
try { await access(path.join(repoRoot, 'node_modules/.bin/electron-vite.cmd')); await access(path.join(repoRoot, 'node_modules/electron/dist/electron.exe')); }
catch { return result('BLOCKED', 'DEPENDENCIES_MISSING', 'Missing Windows dev dependencies. Run explicit prepare, then retry dev; dependencies are never reinstalled on each launch.', { nextCommand: 'npm ci --legacy-peer-deps --no-audit --no-fund', electronRepair: 'If Electron alone is absent after install: node node_modules/electron/install.js' }); }
const pkg = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8'));
if (pkg.scripts?.dev !== 'electron-vite dev' || pkg.scripts?.predev || pkg.scripts?.postdev) return result('BLOCKED', 'DEV_SCRIPT_CHANGED', 'Expected npm run dev to be exactly electron-vite dev with no predev/postdev hooks; review changed scripts.');
await mkdir(stateDir, { recursive: true });
const stamp = `${Date.now()}-${process.pid}`;
const logPath = path.join(stateDir, `dev-${stamp}.log`);
const exitPath = path.join(stateDir, `dev-${stamp}-exit.json`);
const runId = randomUUID();
const readyPath = path.join(stateDir, 'renderer-health.json');
const log = await open(logPath, 'a');
const host = spawn(process.execPath, [path.join(here, 'dev-host.mjs'), repoRoot, exitPath], { cwd: repoRoot, detached: true, windowsHide: true, stdio: ['ignore', log.fd, log.fd], env: { ...process.env, YCODE_SKIP_KERNEL_DATA_RESET: '1', YCODE_OPS_RUN_ID: runId } });
await new Promise((resolve, reject) => { host.once('spawn', resolve); host.once('error', reject); });
spawnedPid = host.pid;
host.unref(); await log.close();
snapshot = await powershell({ action: 'snapshot' });
const owner = snapshot.processes.find(p => p.pid === host.pid && p.commandLine.includes(path.join(here, 'dev-host.mjs')) && p.commandLine.includes(exitPath));
if (!owner) return result('BLOCKED', 'START_IDENTITY_UNCONFIRMED', 'Launch owner could not be captured. Inspect logs/processes before retry; do not start another instance.', { logPath, exitPath, pid: host.pid, possibleOrphans: true });
state = { schema: 1, repoRoot, identity, owner, logPath, exitPath, runId, readyPath, startedAt: new Date().toISOString() };
await save(stateFile, state);
launchPersisted = true;
const deadline = Date.now() + timeoutMs;
let health;
do {
snapshot = await powershell({ action: 'snapshot' });
health = await verifiedHealth(snapshot, state, repoRoot, source, await readFile(logPath, 'utf8'));
if (health.healthy) {
const current = await gitIdentity(repoRoot);
if (current.fingerprint !== identity.fingerprint) return result('BLOCKED', 'VERSION_CHANGED_DURING_START', 'Source changed during launch; restart to verify the exact version.', { identity, current, logPath, stateFile });
state.url = health.url; state.verifiedAt = new Date().toISOString(); await save(stateFile, state);
return result('PASS', 'DEV_READY', 'Target dev started; owned window responds, renderer shell is visible, and served module matches this repository.', { ...health, identity, logPath, stateFile });
}
const exit = await readJson(exitPath);
if (exit || !health.ownerAlive) return result('FAIL', 'DEV_EXITED', 'Dev exited before full readiness. Inspect the startup log; no automatic reinstall or relaunch was attempted.', { exit, health, identity, logPath, stateFile });
if (Date.now() < deadline) await sleep(1000);
} while (Date.now() < deadline);
return result('BLOCKED', 'DEV_READY_TIMEOUT', 'Readiness deadline reached. The single tracked dev is retained for diagnosis; no GUI success is claimed.', { health, identity, logPath, stateFile });
} catch (error) {
return result('FAIL', 'DEV_CHECK_FAILED', redact(error.message), { repoRoot, stateFile, ...(spawnedPid && !launchPersisted ? { possibleOrphans: true, spawnedPid } : {}), ...(state?.logPath ? { logPath: state.logPath } : {}) });
}
}
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sameIdentity, ownedProcesses, rendererMatches, localUrls, moduleMatches, inspectHealth, verifyAttestation, runDev } from './dev.mjs';
const root = 'C:\\Users\\A Name\\YCode';
const p = (pid, parentPid, name, commandLine, created = '2026-10-21T00:00:01.000Z') => ({ pid, parentPid, name, commandLine, created });
const owner = p(10, 1, 'node.exe', `node "${root}\\scripts\\ops\\dev-host.mjs"`);
const vite = p(11, 10, 'node.exe', `node "${root}\\node_modules\\electron-vite\\bin\\electron-vite.js" dev`);
const main = p(12, 11, 'electron.exe', 'electron.exe .');
const renderer = p(13, 12, 'electron.exe', `electron.exe --type=renderer --app-path="${root}" --other`);
const snapshot = () => ({ processes: [owner, vite, main, renderer], windows: [{ pid: 12, title: 'YCode', responding: true }], listeners: [{ pid: 11, port: 5197 }] });
const state = { owner };
const source = 'export function ChatPane() { return "测试" }\n';
const fakeFetch = async url => new Response(url.search === '?raw' ? `export default ${JSON.stringify(source)};\n//# sourceURL=foo` : 'export function ChatPane() {}', { headers: { 'Content-Type': 'text/javascript' } });
test('identity rejects PID reuse, command changes, opaque timestamps', () => {
assert.equal(sameIdentity(owner, { ...owner }), true);
assert.equal(sameIdentity(owner, { ...owner, created: '2026-10-22T00:00:01.000Z' }), false);
assert.equal(sameIdentity(owner, { ...owner, commandLine: 'node other.js' }), false);
assert.equal(sameIdentity({ ...owner, created: '' }, { ...owner, created: '' }), false);
});
test('ownership excludes unrelated siblings and pre-parent timestamp PID artifacts', () => {
const snap = snapshot();
snap.processes.push(p(88, 1, 'node.exe', 'other repo'), p(89, 10, 'node.exe', 'old', '2026-10-20T00:00:00Z'), p(90, 10, 'system', '', ''));
assert.deepEqual(ownedProcesses(snap, owner).map(p => p.pid), [10, 11, 12, 13]);
assert.deepEqual(ownedProcesses(snap, { ...owner, created: '2025-01-01T00:00:00Z' }), []);
});
test('renderer app path is an exact Windows path, not a substring', () => {
assert.equal(rendererMatches(renderer.commandLine, root.toUpperCase()), true);
assert.equal(rendererMatches(renderer.commandLine.replace('YCode"', 'YCode-other"'), root), false);
assert.equal(rendererMatches(`electron.exe --type=renderer --app-path=C:/repo`, 'C:\\repo'), true);
assert.equal(rendererMatches(`electron.exe --app-path="${root}"`, root), false);
});
test('actual Local URL parses ANSI and variable ports without accepting remote hosts', () => {
assert.deepEqual(localUrls('\x1b[32mLocal: http://localhost:5197/\x1b[0m\nLocal: http://localhost:5198/\nLocal: http://evil:5173/'), ['http://localhost:5198/', 'http://localhost:5197/']);
});
test('HTTP requires exact source and real transformed JS, not generic 200 HTML', async () => {
assert.equal(await moduleMatches('http://localhost:5197/', source, fakeFetch), true);
assert.equal(await moduleMatches('http://localhost:5197/', source + '// changed', fakeFetch), false);
assert.equal(await moduleMatches('http://localhost:5197/', source, async () => new Response('<!doctype html><html>')), false);
assert.equal(await moduleMatches('http://localhost:5197/', source, async url => url.search ? fakeFetch(url) : new Response('<!doctype html>')), false);
});
test('health requires correct port ownership, target renderer, responsive owned main', async () => {
const check = snap => inspectHealth(snap, state, root, source, 'Local: http://localhost:5197/', fakeFetch);
assert.equal((await check(snapshot())).healthy, true);
assert.equal((await check(snapshot())).ownerAlive, true);
for (const change of [s => { s.windows[0].responding = false; }, s => { s.windows[0].pid = 99; }, s => { s.listeners[0].pid = 99; }, s => { s.listeners[0].port = 5173; }, s => { s.processes = s.processes.filter(p => p.pid !== 13); }, s => { s.processes[3] = { ...renderer, commandLine: renderer.commandLine.replace('YCode', 'Other') }; }]) {
const snap = snapshot(); snap.windows = snap.windows.map(x => ({ ...x })); snap.listeners = snap.listeners.map(x => ({ ...x }));
change(snap); assert.equal((await check(snap)).healthy, false);
}
});
test('non-Windows native dev is explicitly unsupported, never GUI PASS', { skip: process.platform === 'win32' }, async () => {
for (const action of ['dev', 'restart', 'status', 'stop']) {
const response = await runDev(action, { repoRoot: '/nonexistent/no-side-effects' });
assert.equal(response.status, 'UNSUPPORTED'); assert.equal(response.code, 'WINDOWS_REQUIRED');
}
});
test('renderer attestation requires fresh UUID-bound visible UI and exact process IDs', () => {
const now = Date.now();
const tracked = { runId: 'nonce', startedAt: new Date(now - 3000).toISOString() };
const health = { healthy: true, windowPid: 12, rendererPid: 13 };
const proof = { runId: 'nonce', ready: true, checkedAt: now - 500, pid: 12, rendererPid: 13 };
assert.equal(verifyAttestation(proof, tracked, health, root, now), true);
for (const change of [{ ready: false }, { runId: 'old-run' }, { pid: 99 }, { rendererPid: 99 }, { checkedAt: now - 6000 }, { checkedAt: now + 5000 }, { checkedAt: NaN }]) {
assert.equal(verifyAttestation({ ...proof, ...change }, tracked, health, root, now), false);
}
assert.equal(verifyAttestation(null, tracked, health, root, now), false);
assert.equal(verifyAttestation(proof, tracked, { ...health, healthy: false }, root, now), false);
});
+35
View File
@@ -0,0 +1,35 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {run,OpsError,requireSuccess} from './core.mjs';
export const EXPECTED_REMOTE='https://gitea.iiiii.run/admin/ycode.git';
export async function git(ctx,args,options={}) {const result=await run('git',['-C',ctx.root,...args],{cwd:ctx.root,timeoutMs:30000,env:{...process.env,GIT_TERMINAL_PROMPT:'0',GIT_OPTIONAL_LOCKS:'0',GCM_INTERACTIVE:'Never',GIT_ASKPASS:'',SSH_ASKPASS:''},...options});if(result.timedOut)ctx.keepLock=true;return result;}
export async function resolveContext(options) {
const requested=await fs.realpath(options.root || process.cwd()).catch(()=>{throw new OpsError('ROOT_NOT_FOUND','Target directory does not exist.');});
const root=await fs.realpath(requireSuccess(await git({root:requested},['rev-parse','--show-toplevel']),'NOT_REPOSITORY','Target is not a Git working tree.'));
if(options.root && root!==requested)throw new OpsError('ROOT_NOT_TOPLEVEL','Explicit --root must be the repository top-level.',{requested,root});
let manifest;try{manifest=JSON.parse(await fs.readFile(path.join(root,'package.json'),'utf8'));}catch{throw new OpsError('WRONG_REPOSITORY','Missing YCode package.json.');}
if(manifest.name!=='ycode')throw new OpsError('WRONG_REPOSITORY','Target package is not ycode.');
const gitDir=requireSuccess(await git({root},['rev-parse','--absolute-git-dir']),'GIT_FAILED','Cannot resolve private Git directory.');
return {...options,root,manifest,stateDir:path.join(gitDir,'ycode-ops'),logDir:path.join(gitDir,'ycode-ops','logs')};
}
export async function gitState(ctx) {
const sha=requireSuccess(await git(ctx,['rev-parse','HEAD']),'GIT_FAILED','Cannot read HEAD.');
const status=requireSuccess(await git(ctx,['status','--porcelain=v1','--untracked-files=normal']),'GIT_FAILED','Cannot read worktree status.');
const branch=(await git(ctx,['symbolic-ref','--quiet','--short','HEAD'])).stdout.trim() || null;
return {sha,dirty:!!status,branch,changes:status.split('\n').filter(Boolean).slice(0,30),changesTruncated:status.split('\n').length>30};
}
export async function pull(ctx) {
const before=await gitState(ctx);
if(before.dirty)throw new OpsError('DIRTY_WORKTREE','Local changes preserved; latest requires a clean checkout.',{git:before});
if(before.branch!=='master')throw new OpsError('WRONG_BRANCH','Pull requires checked-out master; no automatic branch switching.',{git:before});
const remote=requireSuccess(await git(ctx,['remote','get-url','--all','gitea']),'REMOTE_MISSING','Expected gitea remote is missing.');
if(remote!==EXPECTED_REMOTE)throw new OpsError('REMOTE_MISMATCH','gitea URL does not match the approved repository.');
requireSuccess(await git(ctx,['fetch','--no-tags','gitea','refs/heads/master:refs/remotes/gitea/master'],{timeoutMs:ctx.timeoutMs,logDir:ctx.logDir}),'FETCH_FAILED','Fetch failed; cached refs are not latest evidence.');
const target=requireSuccess(await git(ctx,['rev-parse','refs/remotes/gitea/master']),'GIT_FAILED','Cannot resolve fetched master.');
// An ahead-only checkout also fails: latest means exact equality with fetched master.
if((await git(ctx,['merge-base','--is-ancestor',before.sha,target])).exitCode!==0)throw new OpsError('NOT_FAST_FORWARD','Local history is ahead or divergent; no reset, stash or merge commit permitted.',{before:before.sha,target});
const now=await gitState(ctx);if(now.dirty||now.sha!==before.sha)throw new OpsError('WORKTREE_CHANGED','Workspace changed during fetch; synchronization stopped.');
requireSuccess(await git(ctx,['-c','core.hooksPath='+path.join(ctx.stateDir,'disabled-hooks'),'merge','--ff-only','--quiet',target],{logDir:ctx.logDir}),'FAST_FORWARD_FAILED','Fast-forward failed; local state was not forcibly overwritten.');
const after=await gitState(ctx);if(after.sha!==target||after.dirty)throw new OpsError('SYNC_UNVERIFIED','Post-sync state does not match fetched clean master.',{git:after,target},'FAIL');
return {status:'PASS',code:'SYNCED',summary:'Clean master matches this fetch of gitea/master.',git:after,evidence:{before:before.sha,fetchedSha:target,remote:EXPECTED_REMOTE}};
}
+109
View File
@@ -0,0 +1,109 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { EventEmitter } from 'node:events';
import path from 'node:path';
import vm from 'node:vm';
import { transform } from 'esbuild';
// Compile the real producer; Electron, disk, timers and renderer are isolated mocks.
// No GUI, application profile or repository runtime files are touched.
const source = await readFile(new URL('../../src/main/dev-ops-health.ts', import.meta.url), 'utf8');
const compiled = (await transform(source, { loader: 'ts', format: 'cjs', target: 'node20' })).code;
const runId = '12345678-1234-1234-1234-123456789abc';
const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); };
function fixture(options = {}) {
const files = new Map(), writes = [], renames = [], intervals = new Set();
const directory = path.join(path.resolve('/fixture/ycode'), '.tmp', 'ops');
const destination = path.join(directory, 'renderer-health.json');
const window = new EventEmitter(), contents = new EventEmitter();
let sequence = 0, releaseWrite, hold = !!options.holdFirstWrite;
const dom = { complete: true, shell: true, button: true, width: 800, height: 600, display: 'block', visibility: 'visible' };
const fs = {
realpath: async value => options.linkedDirectory ? `${value}-elsewhere` : value,
writeFile: async (file, data, flags) => {
writes.push({ file, data: JSON.parse(data), flags });
if (hold) { hold = false; await new Promise(resolve => { releaseWrite = resolve; }); }
assert.equal(flags.flag, 'wx'); assert.equal(flags.mode, 0o600);
assert.equal(files.has(file), false); files.set(file, data);
},
rename: async (from, to) => { assert.equal(files.has(from), true); files.set(to, files.get(from)); files.delete(from); renames.push(JSON.parse(files.get(to))); },
rm: async file => { files.delete(file); }
};
Object.assign(contents, {
isDestroyed: () => false, getOSProcessId: () => 5678,
isLoadingMainFrame: () => !!options.loading,
executeJavaScript: async code => {
if (options.rejectProbe) throw new Error('renderer unavailable');
const shell = { querySelector: () => dom.button ? {} : null, getBoundingClientRect: () => ({ width: dom.width, height: dom.height }) };
return vm.runInNewContext(code, {
document: { readyState: dom.complete ? 'complete' : 'loading', querySelector: () => dom.shell ? shell : null },
getComputedStyle: () => ({ display: dom.display, visibility: dom.visibility })
});
}
});
Object.assign(window, { webContents: contents, isDestroyed: () => false });
const module = { exports: {} };
vm.runInNewContext(compiled, {
module, exports: module.exports,
require: name => {
if (name === 'electron') return { app: { isPackaged: !!options.packaged } };
if (name === 'node:fs') return { promises: fs };
if (name === 'node:path') return path;
if (name === 'node:crypto') return { randomUUID: () => `mock-${++sequence}` };
throw new Error(`Unexpected dependency: ${name}`);
},
process: { env: { YCODE_OPS_RUN_ID: options.id === undefined ? runId : options.id }, cwd: () => '/fixture/ycode', pid: 1234 },
setInterval: callback => { const timer = { callback, unref() {} }; intervals.add(timer); return timer; },
clearInterval: timer => intervals.delete(timer), Date
});
return { window, contents, dom, files, writes, renames, intervals, destination,
attach: () => module.exports.attachDevOpsHealth(window),
tick: () => { for (const timer of intervals) timer.callback(); },
release: () => { assert.ok(releaseWrite, 'expected delayed write'); releaseWrite(); },
proof: () => files.has(destination) ? JSON.parse(files.get(destination)) : null };
}
test('producer opt-in gates packaged, missing and invalid run IDs without timers or writes', async () => {
for (const options of [{ packaged: true }, { id: '' }, { id: 'not-a-uuid' }]) {
const f = fixture(options); f.attach(); await flush();
assert.equal(f.writes.length, 0); assert.equal(f.intervals.size, 0);
}
});
test('producer writes fresh run and native PID proof only for a complete visible shell with button', async () => {
const f = fixture(); f.attach(); await flush();
const proof = f.proof();
assert.equal(proof.ready, true); assert.equal(proof.runId, runId);
assert.equal(proof.pid, 1234); assert.equal(proof.rendererPid, 5678);
assert.ok(Math.abs(Date.now() - proof.checkedAt) < 5000);
assert.equal(f.files.size, 1); assert.equal(f.files.has(f.destination), true);
for (const [key, value] of [['complete', false], ['shell', false], ['button', false], ['width', 0], ['height', 0], ['display', 'none'], ['visibility', 'hidden']]) {
const prior = f.dom[key]; f.dom[key] = value; f.tick(); await flush();
assert.equal(f.proof().ready, false, `${key} must fail readiness`); f.dom[key] = prior;
}
f.tick(); await flush(); assert.equal(f.proof().ready, true);
});
test('loading, rejected renderer and linked output directory cannot produce ready proof', async () => {
for (const options of [{ loading: true }, { rejectProbe: true }, { linkedDirectory: true }]) {
const f = fixture(options); f.attach(); await flush();
assert.notEqual(f.proof()?.ready, true);
if (options.linkedDirectory) assert.equal(f.writes.length, 0);
}
});
test('invalidation while a true proof write is pending cannot leave a stale true result', async () => {
const f = fixture({ holdFirstWrite: true }); f.attach(); await flush();
assert.equal(f.writes[0].data.ready, true);
f.contents.emit('did-start-loading'); await flush();
f.release(); await flush();
assert.equal(f.proof().ready, false);
// Once invalidation is published, no older queued true may replace it.
const firstFalse = f.renames.findIndex(proof => proof.ready === false);
assert.ok(firstFalse >= 0); assert.ok(f.renames.slice(firstFalse).every(proof => proof.ready === false));
});
test('unresponsive and closed windows invalidate proof; close cancels periodic probes', async () => {
const f = fixture(); f.attach(); await flush();
f.window.emit('unresponsive'); await flush(); assert.equal(f.proof().ready, false);
f.tick(); await flush(); assert.equal(f.proof().ready, false);
f.window.emit('responsive'); await flush(); assert.equal(f.proof().ready, true);
f.window.emit('closed'); await flush(); assert.equal(f.proof().ready, false); assert.equal(f.intervals.size, 0);
});
+91
View File
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {execFileSync} from 'node:child_process';
import {parseArgs,run,withLock,redact,redactObject,publicResult} from './core.mjs';
import {resolveContext,gitState,pull,EXPECTED_REMOTE} from './git.mjs';
import {freshArtifacts,snapshot,build,sourceFingerprint} from './build.mjs';
import {execute} from '../ops.mjs';
async function fixture(t) {
const root=await fs.mkdtemp(path.join(os.tmpdir(),'ycode-ops-fixture-'));
t.after(()=>fs.rm(root,{recursive:true,force:true}));
const git=(...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
git('init','-b','master');git('config','user.name','Ops Fixture');git('config','user.email','ops@example.invalid');
await fs.writeFile(path.join(root,'package.json'),JSON.stringify({name:'ycode'}));git('add','.');git('commit','-m','fixture');
return {root,git,ctx:await resolveContext({root,timeoutMs:10000})};
}
test('strict parser accepts only fixed action and bounded flags',()=>{
assert.equal(parseArgs(['dev','--json']).action,'dev');assert.equal(parseArgs(['stop-dev']).action,'stop-dev');
for(const args of [['dev;touch','/tmp/pwn'],['dev','--','echo'],['dev','--root','relative'],['build','--timeout-ms','1'],['status','--json','--json'],['dev','--root','/tmp','--command','x']])assert.throws(()=>parseArgs(args));
});
test('shell metacharacters remain literal argv, not commands',async t=>{
const root=await fs.mkdtemp(path.join(os.tmpdir(),'ops-argv-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
const value='; touch '+path.join(root,'injected');
const result=await run(process.execPath,['-e','process.stdout.write(process.argv[1])',value],{cwd:root});
assert.equal(result.stdout,value);await assert.rejects(fs.access(path.join(root,'injected')));
});
test('explicit root never silently picks parent or another repository',async t=>{
const {root}=await fixture(t);await fs.mkdir(path.join(root,'nested'));
await assert.rejects(resolveContext({root:path.join(root,'nested')}),e=>e.code==='ROOT_NOT_TOPLEVEL');
await fs.writeFile(path.join(root,'package.json'),'{"name":"other"}');await assert.rejects(resolveContext({root}),e=>e.code==='WRONG_REPOSITORY');
});
test('dirty checkout blocks pull without discarding changes',async t=>{
const {root,ctx}=await fixture(t);await fs.writeFile(path.join(root,'local.txt'),'preserve');
await assert.rejects(pull(ctx),e=>e.code==='DIRTY_WORKTREE');assert.equal(await fs.readFile(path.join(root,'local.txt'),'utf8'),'preserve');assert.equal((await gitState(ctx)).dirty,true);
});
test('pull refuses wrong branch and remote before network access',async t=>{
const {ctx,git}=await fixture(t);git('checkout','-b','other');await assert.rejects(pull(ctx),e=>e.code==='WRONG_BRANCH');
git('checkout','master');git('remote','add','gitea','https://example.invalid/other.git');await assert.rejects(pull(ctx),e=>e.code==='REMOTE_MISMATCH');
});
test('workspace lock excludes concurrent mutations and preserves unknown lock',async t=>{
const {ctx}=await fixture(t);
await withLock(ctx,'test',async()=>{await assert.rejects(withLock(ctx,'test',async()=>{}),e=>e.code==='WORKSPACE_LOCKED');});
await withLock(ctx,'test',async()=>{ctx.keepLock=true;});
await assert.rejects(withLock(ctx,'test',async()=>{}),e=>e.code==='WORKSPACE_LOCKED');
});
test('timeout is bounded and records actual timeout',async()=>{
const result=await run(process.execPath,['-e','setInterval(()=>{},1000)'],{timeoutMs:50});assert.equal(result.timedOut,true);
});
test('artifact freshness rejects stale outputs and accepts rewritten identical bytes',async t=>{
const {root}=await fixture(t);await fs.writeFile(path.join(root,'artifact'),'data');const before=await snapshot(root,f=>f.endsWith('artifact'));
assert.equal(freshArtifacts(before,before,Date.now()).length,0);
const after={artifact:{...before.artifact,mtimeMs:Date.now()+5}};assert.equal(freshArtifacts(before,after,Date.now()).length,1);
});
test('logs redact credentials and JSON errors are machine-readable',async t=>{
assert.equal(redact('https://user:password@example.invalid/token'), 'https://[REDACTED]@example.invalid/token');
const {root}=await fixture(t);const result=await run(process.execPath,['-e','console.error("token=fixture-secret")'],{logDir:path.join(root,'logs')});
assert.doesNotMatch(await fs.readFile(result.logPath,'utf8'),/fixture-secret/);
const failure=await execute(['dev','--command','bad']);assert.equal(failure.exitCode,2);assert.equal(failure.schemaVersion,1);
});
test('package never invokes installer on unsupported platforms',async t=>{
if(process.platform==='win32'&&process.arch==='x64')return t.skip('native packaging covered by Windows smoke');
const {ctx}=await fixture(t);await assert.rejects(build(ctx,'package'),e=>e.code==='WINDOWS_X64_REQUIRED');
});
test('fresh local fetch fast-forwards exactly; ahead/divergent and fetch failure preserve checkout',async t=>{
if(process.platform==='win32')return t.skip('POSIX git shim fixture; Windows covered by native smoke');
const {root,ctx,git}=await fixture(t);
const realGit=execFileSync('which',['git'],{encoding:'utf8'}).trim();
const remote=path.join(root,'.git','fixture-remote.git');execFileSync(realGit,['clone','--bare',root,remote],{stdio:'ignore'});
git('remote','add','gitea',EXPECTED_REMOTE);
// Wrapper substitutes ONLY the fetch transport with a local bare fixture; remote identity remains production contract.
const bin=path.join(root,'.git','fixture-bin');await fs.mkdir(bin);
const shim=path.join(bin,'git');await fs.writeFile(shim,`#!/usr/bin/env node\nconst {spawnSync}=require('node:child_process'); const a=process.argv.slice(2);const i=a.indexOf('fetch');if(i>=0){if(process.env.OPS_FIXTURE_FETCH_FAIL==='1')process.exit(8);a[a.indexOf('gitea',i)]=process.env.OPS_FIXTURE_REMOTE;}const r=spawnSync(process.env.OPS_FIXTURE_GIT,a,{stdio:'inherit'});process.exit(r.status??1);\n`,{mode:0o755});
const saved={PATH:process.env.PATH,OPS_FIXTURE_REMOTE:process.env.OPS_FIXTURE_REMOTE,OPS_FIXTURE_GIT:process.env.OPS_FIXTURE_GIT};
Object.assign(process.env,{PATH:bin+path.delimiter+process.env.PATH,OPS_FIXTURE_REMOTE:remote,OPS_FIXTURE_GIT:realGit});
t.after(()=>{for(const [k,v]of Object.entries(saved))if(v===undefined)delete process.env[k];else process.env[k]=v;delete process.env.OPS_FIXTURE_FETCH_FAIL;});
const old=git('rev-parse','HEAD');await fs.writeFile(path.join(root,'next'),'next');git('add','.');git('commit','-m','remote next');const latest=git('rev-parse','HEAD');git('push',remote,'master');git('checkout','--detach',old);git('branch','-f','master',old);git('checkout','master');
const synced=await pull(ctx);assert.equal(synced.evidence.fetchedSha,latest);assert.equal(git('rev-parse','HEAD'),latest);
await fs.writeFile(path.join(root,'ahead'),'ahead');git('add','.');git('commit','-m','local ahead');const ahead=git('rev-parse','HEAD');
await assert.rejects(pull(ctx),e=>e.code==='NOT_FAST_FORWARD');assert.equal(git('rev-parse','HEAD'),ahead);
process.env.OPS_FIXTURE_FETCH_FAIL='1';await assert.rejects(pull(ctx),e=>e.code==='FETCH_FAILED');assert.equal(git('rev-parse','HEAD'),ahead);
});
test('source fingerprint notices content changes even when HEAD and dirty boolean do not change',async t=>{const {ctx,root}=await fixture(t);const file=path.join(root,'local');await fs.writeFile(file,'one');const before=await sourceFingerprint(ctx);await fs.writeFile(file,'two');assert.notEqual(await sourceFingerprint(ctx),before);});
test('redaction preserves JSON syntax for embedded token error text',()=>{const encoded=JSON.stringify(redactObject({summary:'token=secret-value',next:1}));assert.equal(JSON.parse(encoded).next,1);assert.doesNotMatch(encoded,/secret-value/);});
test('dev-latest is unsupported before any Git mutation on non-Windows',async t=>{if(process.platform==='win32')return t.skip('non-Windows contract');const {root,git}=await fixture(t);const before=git('rev-parse','HEAD');const result=await execute(['dev-latest','--root',root]);assert.equal(result.status,'UNSUPPORTED');assert.equal(result.code,'WINDOWS_REQUIRED');assert.equal(git('rev-parse','HEAD'),before);});
test('oversized evidence is bounded for small-model JSON consumption',()=>{const result=publicResult({status:'FAIL',summary:'bounded',evidence:{data:'x'.repeat(100000)}});assert.equal(result.evidence.truncated,true);assert.ok(JSON.stringify(result).length<65536);});