mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
535 lines
23 KiB
TypeScript
535 lines
23 KiB
TypeScript
/**
|
||
* Browser-skills Phase 2a — gate-tier E2E for /scrape and /skillify.
|
||
*
|
||
* Five scenarios cover the productivity loop and the contracts locked
|
||
* during the v1.19.0.0 plan review:
|
||
*
|
||
* D1 — /skillify provenance guard (scenario 4)
|
||
* D2 — synthesis input slice (covered indirectly by scenario 3 — the
|
||
* committed SKILL.md must not contain conversation prose)
|
||
* D3 — atomic write discipline (scenarios 3 and 5)
|
||
*
|
||
* 1. scrape-match-path — /scrape with intent matching bundled
|
||
* hackernews-frontpage routes via $B skill run, no prototype.
|
||
* 2. scrape-prototype-path — /scrape against a local file:// fixture
|
||
* (no matching skill) drives $B primitives, returns JSON, suggests
|
||
* /skillify.
|
||
* 3. skillify-happy-path — /scrape then /skillify in one session.
|
||
* Skill written to the global tier (<state root>/browser-skills/<name>/)
|
||
* with full file tree, $B skill test passes.
|
||
* 4. skillify-provenance-refusal — cold /skillify with no prior
|
||
* /scrape refuses with the D1 message; nothing on disk.
|
||
* 5. skillify-approval-reject — /scrape then /skillify but reject in
|
||
* the approval gate; temp dir is removed, nothing at final path.
|
||
*
|
||
* The three skillify keys run gate-tier; the two scrape keys are periodic
|
||
* (/scrape is Aside-first and its fallback no longer prescribes the match +
|
||
* prototype flow they assert — see E2E_TIERS). ~$0.50–$1.50 each.
|
||
* Set EVALS=1 to enable. Set EVALS_MODEL to override (default frontier Claude).
|
||
*/
|
||
|
||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||
import { JUDGE_MS, CAPTURE_MS, CAPTURE_LONG_MS } from './helpers/eval-budgets';
|
||
import { runSkillTest } from './helpers/session-runner';
|
||
import {
|
||
ROOT, browseBin, runId,
|
||
describeIfSelected, testConcurrentIfSelected,
|
||
setupBrowseShims, copyDirSync, logCost, recordE2E,
|
||
createEvalCollector, finalizeEvalCollector,
|
||
} from './helpers/e2e-helpers';
|
||
import { extractSkillBody } from './helpers/skill-fixture';
|
||
import { resolveStateRoot } from '../lib/state-root';
|
||
import { spawnSync } from 'child_process';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import * as os from 'os';
|
||
import { readShippedSkillRouting } from './helpers/shipped-skill-routing';
|
||
import { hasNarrationLeak } from './helpers/skill-body-narration';
|
||
|
||
const evalCollector = createEvalCollector('e2e-skillify');
|
||
|
||
// ─── Shared workdir setup ───────────────────────────────────────
|
||
|
||
interface Workdir {
|
||
workDir: string;
|
||
gstackHome: string;
|
||
skillsDir: string;
|
||
}
|
||
|
||
/**
|
||
* Build a working directory that has:
|
||
* - The /scrape and /skillify skills installed under .claude/skills/
|
||
* - The browse binary symlinked + find-browse shim (via setupBrowseShims)
|
||
* - bin/ scripts referenced by the preamble
|
||
* - A scoped GSTACK_HOME under the workdir so on-disk artifacts are
|
||
* contained and assertable
|
||
* - A CLAUDE.md with the ## Skill routing section gstack ships
|
||
*
|
||
* `installSkills` lets each test pick the minimum surface (e.g., the
|
||
* provenance-refusal scenario doesn't need /scrape).
|
||
*/
|
||
function setupSkillifyWorkdir(suffix: string, installSkills: string[] = ['scrape', 'skillify']): Workdir {
|
||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), `skill-e2e-skillify-${suffix}-`));
|
||
const gstackHome = path.join(workDir, '.gstack-home');
|
||
fs.mkdirSync(gstackHome, { recursive: true });
|
||
|
||
const run = (cmd: string, args: string[]) =>
|
||
spawnSync(cmd, args, { cwd: workDir, stdio: 'pipe', timeout: 5000 });
|
||
run('git', ['init', '-b', 'main']);
|
||
run('git', ['config', 'user.email', 'test@test.com']);
|
||
run('git', ['config', 'user.name', 'Test']);
|
||
fs.writeFileSync(path.join(workDir, 'README.md'), '# test\n');
|
||
run('git', ['add', '.']);
|
||
run('git', ['commit', '-m', 'initial']);
|
||
|
||
setupBrowseShims(workDir);
|
||
|
||
// Install requested skills. The tests exercise the full /scrape + /skillify
|
||
// flows (all 11 skillify steps, D1-D3 contracts), so keep the whole
|
||
// skill-specific body — but drop the ~780-line shared preamble the tests
|
||
// never touch (CLAUDE.md: "E2E test fixtures: extract, don't copy").
|
||
const skillsDir = path.join(workDir, '.claude', 'skills');
|
||
for (const skill of installSkills) {
|
||
const destDir = path.join(skillsDir, skill);
|
||
fs.mkdirSync(destDir, { recursive: true });
|
||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), extractSkillBody(path.join(ROOT, skill)));
|
||
}
|
||
|
||
// bin/ scripts — preamble references several of these.
|
||
const binDir = path.join(workDir, 'bin');
|
||
fs.mkdirSync(binDir, { recursive: true });
|
||
for (const script of [
|
||
'gstack-timeline-log', 'gstack-slug', 'gstack-config', 'gstack-state-root.sh', 'gstack-remote-identity.sh',
|
||
'gstack-update-check', 'gstack-repo-mode',
|
||
'gstack-learnings-log', 'gstack-learnings-search',
|
||
]) {
|
||
const src = path.join(ROOT, 'bin', script);
|
||
if (fs.existsSync(src)) {
|
||
fs.copyFileSync(src, path.join(binDir, script));
|
||
fs.chmodSync(path.join(binDir, script), 0o755);
|
||
}
|
||
}
|
||
|
||
fs.writeFileSync(path.join(workDir, 'CLAUDE.md'), `# Project Instructions
|
||
|
||
${readShippedSkillRouting().section}
|
||
|
||
## Test environment
|
||
|
||
- GSTACK_HOME="${gstackHome}" for all gstack bin scripts.
|
||
- bin scripts are at ./bin/ relative to this directory.
|
||
- Browse binary is at ${browseBin} — assign to $B (e.g., \`B=${browseBin}\`).
|
||
`);
|
||
|
||
return { workDir, gstackHome, skillsDir };
|
||
}
|
||
|
||
/**
|
||
* Install the bundled hackernews-frontpage browser-skill into the workdir's
|
||
* project-tier (so $B skill list finds it for match-path tests). The skill
|
||
* has to live under <workdir>/.gstack/browser-skills/ for the project-tier
|
||
* lookup to find it (gstack's bundled tier resolves from the install dir,
|
||
* which the test workdir doesn't have).
|
||
*/
|
||
function installBundledHackernewsSkill(workDir: string) {
|
||
const src = path.join(ROOT, 'browser-skills', 'hackernews-frontpage');
|
||
const dst = path.join(workDir, '.gstack', 'browser-skills', 'hackernews-frontpage');
|
||
copyDirSync(src, dst);
|
||
}
|
||
|
||
/**
|
||
* Where the child's skill writes resolve: the state root its env names (the
|
||
* global tier and /skillify's .tmp staging, per lib/state-root.ts — GSTACK_HOME
|
||
* outranks HOME) and the cwd's project tier. Since the global tier moved to
|
||
* the state root (v1.91.11.0), sweeping only $HOME/.gstack failed the agent
|
||
* that used commitSkill as written and passed agents that unset GSTACK_HOME
|
||
* to land a skill $B skill list cannot see.
|
||
*/
|
||
function skillWriteRoots(workDir: string, childEnv: { GSTACK_HOME: string; HOME: string }) {
|
||
return { global: resolveStateRoot(childEnv), project: path.join(workDir, '.gstack') };
|
||
}
|
||
|
||
/** Negative sweeps also cover $HOME/.gstack, where an agent that bypassed the state root would write. */
|
||
function everyWriteRoot(workDir: string, childEnv: { GSTACK_HOME: string; HOME: string }): string[] {
|
||
const { global, project } = skillWriteRoots(workDir, childEnv);
|
||
return [...new Set([global, project, path.join(childEnv.HOME, '.gstack')])];
|
||
}
|
||
|
||
/** Helper: every Bash invocation's command string from the agent. */
|
||
function bashCommands(result: { toolCalls: Array<{ tool: string; input: any }> }): string[] {
|
||
return result.toolCalls
|
||
.filter((tc) => tc.tool === 'Bash')
|
||
.map((tc) => String(tc.input?.command ?? ''))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
/** Helper: the union of agent text + every tool input/output for matching. */
|
||
function fullSurface(result: any): string {
|
||
const parts: string[] = [];
|
||
if (result.output) parts.push(String(result.output));
|
||
for (const tc of result.toolCalls || []) {
|
||
parts.push(JSON.stringify(tc.input || {}));
|
||
if (tc.output) parts.push(String(tc.output));
|
||
}
|
||
for (const entry of result.transcript || []) {
|
||
try { parts.push(JSON.stringify(entry)); } catch { /* skip */ }
|
||
}
|
||
return parts.join('\n');
|
||
}
|
||
|
||
// ─── Test fixtures ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* Tiny HTML fixture for the prototype-path test. Stable structure with three
|
||
* "items" the agent should be able to extract via $B html + parse.
|
||
*/
|
||
const PROTOTYPE_FIXTURE_HTML = `<!doctype html>
|
||
<html><body>
|
||
<h1>Test Items</h1>
|
||
<ul id="items">
|
||
<li class="item"><a href="/a">First Title</a><span class="score">42</span></li>
|
||
<li class="item"><a href="/b">Second Title</a><span class="score">17</span></li>
|
||
<li class="item"><a href="/c">Third Title</a><span class="score">8</span></li>
|
||
</ul>
|
||
</body></html>
|
||
`;
|
||
|
||
// ─── Live-fire suite ────────────────────────────────────────────
|
||
|
||
describeIfSelected('Browser-skills Phase 2a E2E (/scrape + /skillify)', [
|
||
'scrape-match-path',
|
||
'scrape-prototype-path',
|
||
'skillify-happy-path',
|
||
'skillify-provenance-refusal',
|
||
'skillify-approval-reject',
|
||
], () => {
|
||
afterAll(() => { finalizeEvalCollector(evalCollector); });
|
||
|
||
// ── 1. /scrape match path: bundled hackernews-frontpage matches ──────
|
||
testConcurrentIfSelected('scrape-match-path', async () => {
|
||
const { workDir, gstackHome } = setupSkillifyWorkdir('match', ['scrape']);
|
||
installBundledHackernewsSkill(workDir);
|
||
|
||
const result = await runSkillTest({
|
||
prompt: `Run /scrape latest hacker news stories.
|
||
This run is non-interactive; AskUserQuestion is unavailable.`,
|
||
workingDirectory: workDir,
|
||
env: { GSTACK_HOME: gstackHome },
|
||
maxTurns: 12,
|
||
allowedTools: ['Skill', 'Bash', 'Read'],
|
||
timeout: JUDGE_MS,
|
||
testName: 'scrape-match-path',
|
||
runId,
|
||
});
|
||
|
||
logCost('scrape-match-path', result);
|
||
|
||
// Outcome: the matcher ran the bundled skill instead of prototyping, and
|
||
// nothing new was written. Listing first is one valid way to match.
|
||
const cmds = bashCommands(result);
|
||
const ranBundledSkill = cmds.some(c => /\bskill\s+run\s+hackernews-frontpage\b/.test(c));
|
||
const prototyped = cmds.some(c => /\$B\s+goto\b|\/browse\s+goto\b/.test(c));
|
||
const newSkills = [workDir, gstackHome].flatMap((root) => {
|
||
const skillsRoot = path.join(root, '.gstack', 'browser-skills');
|
||
return fs.existsSync(skillsRoot) ? fs.readdirSync(skillsRoot).filter(d => !d.startsWith('.') && d !== 'hackernews-frontpage') : [];
|
||
});
|
||
const staged = [workDir, gstackHome].some((root) => {
|
||
const stagingRoot = path.join(root, '.gstack', '.tmp');
|
||
return fs.existsSync(stagingRoot) && fs.readdirSync(stagingRoot).some(d => d.startsWith('skillify-'));
|
||
});
|
||
console.log(`scrape-match-path: listed skills first = ${cmds.some(c => /\bskill\s+list\b/.test(c))}`);
|
||
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
||
|
||
recordE2E(evalCollector, 'scrape match-path routes to bundled skill', 'Phase 2a E2E', result, {
|
||
passed: exitOk && ranBundledSkill && !prototyped && newSkills.length === 0 && !staged,
|
||
});
|
||
|
||
expect(exitOk).toBe(true);
|
||
expect(ranBundledSkill).toBe(true);
|
||
expect(prototyped, 'matched skill must run without prototyping').toBe(false);
|
||
expect(newSkills).toEqual([]);
|
||
expect(staged).toBe(false);
|
||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||
}, CAPTURE_MS);
|
||
|
||
// ── 2. /scrape prototype path: drive $B primitives against fixture ────
|
||
testConcurrentIfSelected('scrape-prototype-path', async () => {
|
||
const { workDir, gstackHome } = setupSkillifyWorkdir('prototype', ['scrape']);
|
||
|
||
// Stage a local HTML fixture the agent can goto via file://
|
||
const fixturePath = path.join(workDir, 'fixture.html');
|
||
fs.writeFileSync(fixturePath, PROTOTYPE_FIXTURE_HTML);
|
||
const fileUrl = `file://${fixturePath}`;
|
||
|
||
const result = await runSkillTest({
|
||
prompt: `Run /scrape titles and scores from ${fileUrl}.
|
||
Return JSON of the form {"items": [{"title": "...", "score": N}, ...], "count": N}.
|
||
This run is non-interactive; AskUserQuestion is unavailable.`,
|
||
workingDirectory: workDir,
|
||
env: { GSTACK_HOME: gstackHome },
|
||
maxTurns: 18,
|
||
allowedTools: ['Skill', 'Bash', 'Read'],
|
||
timeout: CAPTURE_MS,
|
||
testName: 'scrape-prototype-path',
|
||
runId,
|
||
});
|
||
|
||
logCost('scrape-prototype-path', result);
|
||
|
||
const cmds = bashCommands(result);
|
||
const wentToFixture = cmds.some(c => c.includes(fileUrl));
|
||
const fetchedHtml = cmds.some(c => /\bgoto\b|\bhtml\b|\btext\b/.test(c));
|
||
const surface = fullSurface(result);
|
||
const mentionsSkillify = /skillify/i.test(surface);
|
||
// Accept JSON shape variants — the prompt asks for `"items": [...]` but
|
||
// the model sometimes emits equivalent containers (`"results"`, `"data"`,
|
||
// `"hits"`) or skips the wrapper entirely and emits a bare array of
|
||
// objects with title+score keys. All of these satisfy the underlying
|
||
// intent: "the agent produced parseable structured output naming the
|
||
// scraped items". We assert the shape, not a literal key name.
|
||
const hasJsonItems =
|
||
/"(items|results|data|hits|entries)"\s*:\s*\[/i.test(surface) ||
|
||
/'(items|results|data|hits|entries)'\s*:/i.test(surface) ||
|
||
// Bare array of {title, score} objects (no outer wrapper key)
|
||
/\[\s*\{[^}]*\btitle\b[^}]*\bscore\b/.test(surface);
|
||
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
||
|
||
recordE2E(evalCollector, 'scrape prototype-path drives $B + emits JSON + nudges skillify', 'Phase 2a E2E', result, {
|
||
passed: exitOk && wentToFixture && fetchedHtml && hasJsonItems && mentionsSkillify,
|
||
});
|
||
|
||
expect(exitOk).toBe(true);
|
||
expect(wentToFixture).toBe(true);
|
||
expect(fetchedHtml).toBe(true);
|
||
expect(hasJsonItems).toBe(true);
|
||
expect(mentionsSkillify).toBe(true);
|
||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||
}, CAPTURE_MS);
|
||
|
||
// ── 3. /skillify happy path: scrape then skillify in one session ─────
|
||
testConcurrentIfSelected('skillify-happy-path', async () => {
|
||
const { workDir, gstackHome } = setupSkillifyWorkdir('happy', ['scrape', 'skillify']);
|
||
const fixturePath = path.join(workDir, 'fixture.html');
|
||
fs.writeFileSync(fixturePath, PROTOTYPE_FIXTURE_HTML);
|
||
const fileUrl = `file://${fixturePath}`;
|
||
|
||
const childHome = path.join(workDir, 'home');
|
||
fs.mkdirSync(childHome, { recursive: true });
|
||
const childEnv = {
|
||
GSTACK_HOME: gstackHome,
|
||
// Fresh subdir, NEVER the cwd: with HOME == cwd, claude resolves
|
||
// <cwd>/.claude/skills as the PERSONAL skills dir and the seeded
|
||
// project-tier skills stop registering — this test's Skill() calls
|
||
// silently errored ("Unknown skill") and only passed via the agent
|
||
// self-recovering by Reading SKILL.md manually. Same fix as the
|
||
// provenance-refusal test below.
|
||
HOME: childHome,
|
||
};
|
||
|
||
const result = await runSkillTest({
|
||
prompt: `Two steps in this session:
|
||
|
||
1. Run /scrape titles and scores from ${fileUrl} via the Skill tool.
|
||
Drive the prototype path; return JSON with items[].
|
||
|
||
2. Run /skillify via the Skill tool. Follow ALL 11 steps including:
|
||
- D1 provenance guard (you have a recent /scrape, proceed)
|
||
- D2 synthesis: include ONLY the final-attempt $B calls (goto + html)
|
||
- D3 atomic write: stage to temp dir, run test, then commit on approval
|
||
- When AskUserQuestion fires, choose the recommended option (A)
|
||
for both the name/tier question AND the approval gate.
|
||
|
||
The environment already points gstack's state root (GSTACK_HOME) at a
|
||
test sandbox; keep it as set so the write helpers and $B agree.
|
||
|
||
Do NOT halt for clarification.`,
|
||
workingDirectory: workDir,
|
||
env: childEnv,
|
||
maxTurns: 40,
|
||
allowedTools: ['Skill', 'Bash', 'Read', 'Write'],
|
||
timeout: CAPTURE_LONG_MS,
|
||
testName: 'skillify-happy-path',
|
||
runId,
|
||
});
|
||
|
||
logCost('skillify-happy-path', result);
|
||
|
||
// The skill must land in a tier $B resolves: global (the state root) or project.
|
||
const tiers = skillWriteRoots(workDir, childEnv);
|
||
const skillRoots = [tiers.global, tiers.project].map((r) => path.join(r, 'browser-skills'));
|
||
const writtenSkills = skillRoots.flatMap((root) => (fs.existsSync(root)
|
||
? fs.readdirSync(root)
|
||
.filter(d => !d.startsWith('.') && d !== 'hackernews-frontpage')
|
||
.map((d) => path.join(root, d))
|
||
: []));
|
||
const skillDir = writtenSkills[0] ?? '';
|
||
const hasAllFiles = !!skillDir
|
||
&& fs.existsSync(path.join(skillDir, 'SKILL.md'))
|
||
&& fs.existsSync(path.join(skillDir, 'script.ts'))
|
||
&& fs.existsSync(path.join(skillDir, 'script.test.ts'))
|
||
&& fs.existsSync(path.join(skillDir, '_lib', 'browse-client.ts'))
|
||
&& fs.existsSync(path.join(skillDir, 'fixtures'));
|
||
|
||
// D2 enforcement: the SKILL.md prose body must not contain conversation
|
||
// fragments (stored good/bad bodies: skill-fixture.test.ts).
|
||
let prosesClean = false;
|
||
if (hasAllFiles) {
|
||
const skillMd = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
|
||
const body = skillMd.split(/\n---\n/)[1] || '';
|
||
prosesClean = !hasNarrationLeak(body);
|
||
}
|
||
|
||
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
||
|
||
recordE2E(evalCollector, 'skillify happy path writes well-formed skill on disk', 'Phase 2a E2E', result, {
|
||
passed: exitOk && hasAllFiles && prosesClean,
|
||
});
|
||
|
||
expect(exitOk).toBe(true);
|
||
expect(writtenSkills.length).toBeGreaterThan(0);
|
||
expect(hasAllFiles).toBe(true);
|
||
expect(prosesClean).toBe(true);
|
||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||
}, CAPTURE_LONG_MS);
|
||
|
||
// ── 4. /skillify provenance refusal: D1 contract ─────────────────────
|
||
testConcurrentIfSelected('skillify-provenance-refusal', async () => {
|
||
const { workDir, gstackHome } = setupSkillifyWorkdir('refusal', ['skillify']);
|
||
// Child HOME must be a FRESH dir, never workDir itself: with HOME == cwd,
|
||
// claude resolves <cwd>/.claude/skills as the PERSONAL skills dir and the
|
||
// project-tier skills seeded there never register — the Skill tool then
|
||
// errors "Unknown skill: skillify" (observed on claude 2.1.237). A
|
||
// sibling home/ dir keeps the override's intent (any ~/.gstack write from
|
||
// the child lands inside the assertable sandbox, not the operator's real
|
||
// home) without colliding with project-skill discovery.
|
||
const childHome = path.join(workDir, 'home');
|
||
fs.mkdirSync(childHome, { recursive: true });
|
||
const childEnv = { GSTACK_HOME: gstackHome, HOME: childHome };
|
||
|
||
const result = await runSkillTest({
|
||
prompt: `Run /skillify.
|
||
This run is non-interactive; AskUserQuestion is unavailable.`,
|
||
workingDirectory: workDir,
|
||
env: childEnv,
|
||
maxTurns: 8,
|
||
allowedTools: ['Skill', 'Bash', 'Read'],
|
||
timeout: JUDGE_MS,
|
||
testName: 'skillify-provenance-refusal',
|
||
runId,
|
||
});
|
||
|
||
logCost('skillify-provenance-refusal', result);
|
||
|
||
// Tripwire: the Skill tool must actually LOAD skillify. A not-loaded
|
||
// skill (tool error "Unknown skill: skillify", or the agent narrating
|
||
// "not registered" and improvising a refusal) must never pass as a D1
|
||
// refusal. Neither phrase appears in the skillify fixture or the prompt,
|
||
// so a hit can only come from a real load failure.
|
||
const surface = fullSurface(result);
|
||
const skillLoadFailed = /unknown skill|not registered/i.test(surface);
|
||
|
||
// The refusal must be in the AGENT'S OWN words. When the Skill tool
|
||
// loads skillify, the SKILL.md body — which contains the exact refusal
|
||
// message — is injected into the transcript as a user message, so
|
||
// matching the full surface would pass vacuously. Match only assistant
|
||
// text blocks + the final result.
|
||
const agentText = [
|
||
result.output,
|
||
...result.transcript
|
||
.filter((e: any) => e?.type === 'assistant')
|
||
.flatMap((e: any) => ((e.message?.content ?? []) as any[])
|
||
.filter((c: any) => c?.type === 'text')
|
||
.map((c: any) => String(c.text ?? ''))),
|
||
].join('\n');
|
||
const refusalText = /no recent \/?scrape result|run \/scrape.*first|no prior \/?scrape/i.test(agentText);
|
||
|
||
// Critical: nothing on disk. No staged dir, no committed skill, in any
|
||
// root a write could resolve to.
|
||
const diskRoots = everyWriteRoot(workDir, childEnv);
|
||
const noSkillsWritten = diskRoots.every((root) => {
|
||
const skillsRoot = path.join(root, 'browser-skills');
|
||
return !fs.existsSync(skillsRoot)
|
||
|| fs.readdirSync(skillsRoot).filter(d => !d.startsWith('.')).length === 0;
|
||
});
|
||
const noStaging = diskRoots.every((root) => {
|
||
const stagingRoot = path.join(root, '.tmp');
|
||
return !fs.existsSync(stagingRoot)
|
||
|| fs.readdirSync(stagingRoot).filter(d => d.startsWith('skillify-')).length === 0;
|
||
});
|
||
|
||
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
||
|
||
recordE2E(evalCollector, 'skillify D1 refusal — no on-disk write', 'Phase 2a E2E', result, {
|
||
passed: exitOk && !skillLoadFailed && refusalText && noSkillsWritten && noStaging,
|
||
});
|
||
|
||
expect(exitOk).toBe(true);
|
||
expect(skillLoadFailed).toBe(false);
|
||
expect(refusalText).toBe(true);
|
||
expect(noSkillsWritten).toBe(true);
|
||
expect(noStaging).toBe(true);
|
||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||
}, JUDGE_MS);
|
||
|
||
// ── 5. /skillify approval-gate reject: D3 cleanup ────────────────────
|
||
testConcurrentIfSelected('skillify-approval-reject', async () => {
|
||
const { workDir, gstackHome } = setupSkillifyWorkdir('reject', ['scrape', 'skillify']);
|
||
const fixturePath = path.join(workDir, 'fixture.html');
|
||
fs.writeFileSync(fixturePath, PROTOTYPE_FIXTURE_HTML);
|
||
const fileUrl = `file://${fixturePath}`;
|
||
|
||
const childHome = path.join(workDir, 'home');
|
||
fs.mkdirSync(childHome, { recursive: true });
|
||
// Fresh subdir, never the cwd — see the happy-path comment.
|
||
const childEnv = { GSTACK_HOME: gstackHome, HOME: childHome };
|
||
|
||
const result = await runSkillTest({
|
||
prompt: `Two steps:
|
||
|
||
1. Run /scrape titles and scores from ${fileUrl} via the Skill tool.
|
||
|
||
2. Run /skillify via the Skill tool. Follow steps 1-9. When the approval
|
||
gate AskUserQuestion fires (Step 9), choose option C (Discard) instead
|
||
of A (Commit). The D3 contract says the temp dir must be removed and
|
||
nothing should land at the final tier path.
|
||
|
||
Do NOT commit the skill.`,
|
||
workingDirectory: workDir,
|
||
env: childEnv,
|
||
maxTurns: 35,
|
||
allowedTools: ['Skill', 'Bash', 'Read', 'Write'],
|
||
timeout: CAPTURE_LONG_MS,
|
||
testName: 'skillify-approval-reject',
|
||
runId,
|
||
});
|
||
|
||
logCost('skillify-approval-reject', result);
|
||
|
||
// D3 contract: nothing at the final tier path; staging dir is gone.
|
||
const negativeRoots = everyWriteRoot(workDir, childEnv);
|
||
const writtenSkills = negativeRoots.flatMap((root) => {
|
||
const skillsRoot = path.join(root, 'browser-skills');
|
||
return fs.existsSync(skillsRoot)
|
||
? fs.readdirSync(skillsRoot).filter(d => !d.startsWith('.'))
|
||
: [];
|
||
});
|
||
const stagingLeftovers = negativeRoots.flatMap((root) => {
|
||
const stagingRoot = path.join(root, '.tmp');
|
||
return fs.existsSync(stagingRoot)
|
||
? fs.readdirSync(stagingRoot).filter(d => d.startsWith('skillify-'))
|
||
: [];
|
||
});
|
||
|
||
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
||
|
||
recordE2E(evalCollector, 'skillify approval-reject leaves no on-disk artifact', 'Phase 2a E2E', result, {
|
||
passed: exitOk && writtenSkills.length === 0 && stagingLeftovers.length === 0,
|
||
});
|
||
|
||
expect(exitOk).toBe(true);
|
||
expect(writtenSkills.length).toBe(0);
|
||
expect(stagingLeftovers.length).toBe(0);
|
||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||
}, CAPTURE_LONG_MS);
|
||
});
|