Skip to content

Commit a180052

Browse files
elberrdclaude
andauthored
fix: instalação no Windows não morre mais nos symlinks do harness (#31)
* fix: instalação no Windows não morre mais nos symlinks do harness (extrator próprio com materialização) O tar.exe do Windows (bsdtar) não cria symlink sem privilégio (admin/Developer Mode) — os 55 espelhos do harness (.agents/* e .cursor/agents/*) falhavam com "Invalid argument", o tar saía 1 e o instalador abortava com um falso "corrupted download" que repetir nunca resolvia. - src/lib/tar-extract.js (novo): extrator .tar.gz em JS puro (formato pax do git archive, zero dependência) — tenta symlink real; na primeira recusa materializa todos os links como cópia do alvo resolvido (confinado ao destino, com passes para cadeias de link e suporte a pax x/g, GNU L/K e hardlink). - template-fetch: tenta o tar do sistema; se faltar ou falhar, re-extrai do zero com o extrator próprio. tar_missing deixou de existir (não é mais beco sem saída) e extract_failed agora só dispara quando os DOIS extratores recusam os bytes (corrupção real). - imp fix: restore de entradas link: do manifest ganha o mesmo fallback de cópia quando symlink() é negado. - Skills CLI (vercel-labs) auditado e já é seguro no win32 (junction + cópia) — sem mudança. - Validação: git archive real do harness (362 arquivos + 55 links) com paridade byte a byte contra o tar do sistema; testes novos constroem o tar à mão e rodam em qualquer OS sem privilégio de symlink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: manifest do harness portável — target de symlink normalizado para forward slash No Windows (com privilégio de symlink) o readlink devolve o target com backslashes e o manifest gravava link:..\..\... — não-portável entre OSes e o único teste vermelho do CI Windows na main. collectHarnessManifest normaliza o target para / e classifyHarnessState normaliza o lado do disco antes de comparar, então link carimbado num OS continua pristine no outro. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c43e062 commit a180052

7 files changed

Lines changed: 528 additions & 40 deletions

File tree

DOCS.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,23 @@ runs `git init` before the best-effort commit. The `--no-harness`/
859859
`--skip-harness` flags only apply in `full` mode (template WITHOUT the
860860
harness); in `harness` mode they are ignored with a warning.
861861

862+
**Extraction and the Windows symlink fallback.** Every gated download
863+
(harness AND templates, `src/lib/template-fetch.js`) unpacks with the system
864+
`tar` first; when it is missing or fails, a built-in pure-JS extractor
865+
(`src/lib/tar-extract.js`) re-extracts from scratch. The case that motivates
866+
it: the harness ships 50+ mirror symlinks (`.agents/*` and `.cursor/agents/*`
867+
pointing into `.claude/` and `.cursor/`), and Windows' bundled tar cannot
868+
CREATE symlinks without a privilege students don't have (admin shell or
869+
Developer Mode) — every link died with "Invalid argument" and the install
870+
aborted as a false "corrupted download". The built-in extractor tries a real
871+
link first and, on the first failure, MATERIALIZES every link as a copy of
872+
its resolved target instead — each engine still finds real content at its
873+
mirrored path, and the machines that do allow symlinks keep them. Only when
874+
both extractors refuse the bytes does the run fail (`extract_failed` — a
875+
genuinely corrupted download). The same degradation exists in `imp fix`
876+
(§14.5): restoring a `link:` manifest entry falls back to copying the
877+
target when `symlink()` is denied.
878+
862879
The harness also ships `.claude/settings.json` with
863880
`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` (required by `/team`) and the two
864881
fda-lock hooks (SessionStart warn + PreToolUse gate — the read-only guard
@@ -877,7 +894,10 @@ doctor` warns about any npx MCP server missing the flag and `imp fix` adds it
877894
marker merge, template-owned paths discarded), so its keys are exactly the
878895
project-relative paths the harness shipped; each value is the sha1 of the
879896
harness content, or `link:<target>` for a symlink (the merge copies links
880-
verbatim, so the recorded target is what a healthy disk must show).
897+
verbatim, so the recorded target is what a healthy disk must show). On a
898+
Windows machine where the extractor materialized the links as copies, the
899+
clone has real files at those paths — the manifest then records content
900+
sha1s, which matches that disk just the same.
881901

882902
That baseline is what makes a missing/pristine/modified classification
883903
possible for harness files: `imp doctor` reports it and `imp fix` restores
@@ -950,9 +970,11 @@ without the FIA runtime.
950970
| `ui-component-researcher` | Researches/documents a single UI component into `ai-docs/components/<lib>/<name>.md`. |
951971
| `api-docs-researcher` | Researches an external API/technology and writes the project-tailored doc into `ai-docs/apis/` (also logs the four research dimensions). |
952972

953-
`.cursor/agents/` are symlinks to `.claude/agents/` (canonical). Cursor
954-
additionally ships router skills (`project-workflow` + `workflow-*` wrappers
955-
for the original 8 pipelines) because Cursor routes by skill.
973+
`.cursor/agents/` are symlinks to `.claude/agents/` (canonical) — real
974+
copies on a Windows machine without the symlink privilege (§8, extraction
975+
fallback). Cursor additionally ships router skills (`project-workflow` +
976+
`workflow-*` wrappers for the original 8 pipelines) because Cursor routes by
977+
skill.
956978

957979
### 8.3 Skills shipped
958980

@@ -2024,7 +2046,7 @@ What it knows how to repair:
20242046
| `skills-missing` | project | Restores agent skills that `skills-lock.json` records but `.agents/skills/` lost. |
20252047
| `pi-skill-dupes` | project | Deletes skill copies duplicated into `.pi/skills/` (the "Skill conflicts" panel at every Pi launch — §6.2). |
20262048
| `runtime-missing` | project | Restores FIA runtime files the stamp manifest recorded and the disk no longer has. |
2027-
| `harness-missing` | project | Re-downloads the harness from the community API and copies back ONLY the paths `imp/.harness-manifest.json` lists as missing. Dangling symlinks are re-pointed at the stamped target; a path the current harness no longer ships is reported, not invented. |
2049+
| `harness-missing` | project | Re-downloads the harness from the community API and copies back ONLY the paths `imp/.harness-manifest.json` lists as missing. Dangling symlinks are re-pointed at the stamped target (or materialized as a copy where the OS denies links — §8); a path the current harness no longer ships is reported, not invented. |
20282050
| `agents-md-block` | project | Re-appends the harness block to `AGENTS.md` (or recreates the file) via the same idempotent marker merge the installer uses — your own content is kept. |
20292051

20302052
```bash

src/lib/harness-manifest.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export function sha1(content) {
3030
* target's content.
3131
* @returns {Promise<Record<string, string>>} rel path → sha1 | `link:<target>`
3232
*/
33+
// readlink on Windows reports the stored target with backslashes — normalize
34+
// to forward slashes so manifests are portable across OSes and a link stamped
35+
// on one machine still classifies as pristine on another.
36+
const linkTarget = (target) => String(target).replaceAll('\\', '/');
37+
3338
export async function collectHarnessManifest(cloneDir) {
3439
const files = {};
3540
async function walk(rel) {
@@ -38,7 +43,7 @@ export async function collectHarnessManifest(cloneDir) {
3843
const entryRel = rel ? `${rel}/${entry.name}` : entry.name;
3944
const full = join(cloneDir, entryRel);
4045
if (entry.isSymbolicLink()) {
41-
files[entryRel] = `link:${await readlink(full)}`;
46+
files[entryRel] = `link:${linkTarget(await readlink(full))}`;
4247
} else if (entry.isDirectory()) {
4348
await walk(entryRel);
4449
} else {
@@ -87,7 +92,7 @@ export async function classifyHarnessState(manifest, dir) {
8792
}
8893
if (String(expected).startsWith('link:')) {
8994
const target = st.isSymbolicLink() ? await readlink(dest).catch(() => null) : null;
90-
if (target === String(expected).slice(5)) pristine++;
95+
if (target != null && linkTarget(target) === String(expected).slice(5)) pristine++;
9196
else modified.push(rel);
9297
continue;
9398
}

src/lib/tar-extract.js

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Pure-JS .tar.gz extractor — the fallback when the system `tar` cannot unpack
2+
// a downloaded template/harness tarball. The case that motivates it: Windows'
3+
// bundled tar (bsdtar) refuses to CREATE SYMLINKS unless the process holds the
4+
// symlink privilege (admin shell or Developer Mode) — on a typical student
5+
// machine every one of the harness' 50+ mirror links (.agents/* and
6+
// .cursor/agents/* pointing into .claude/ and .cursor/) died with
7+
// "Can't create '…': Invalid argument", tar exited 1 and the installer
8+
// mislabeled a perfectly good download as corrupted. Here a symlink that
9+
// cannot be created is MATERIALIZED instead: the resolved target is copied in
10+
// its place, so every engine still finds real content at the mirrored path.
11+
//
12+
// Scope: the GitHub codeload tarballs the community API serves (`git archive`
13+
// pax format). Supported entries: regular files, directories, symlinks,
14+
// hardlinks, pax extended headers (x/g) and GNU long name/link (L/K).
15+
// Anything else (fifo, devices) is skipped. Header checksums are not
16+
// validated — gzip's own CRC already covers download integrity.
17+
18+
import { cpSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
19+
import { gunzipSync } from 'node:zlib';
20+
import { dirname, resolve, sep } from 'node:path';
21+
22+
const BLOCK = 512;
23+
24+
/** Numeric tar field: octal text, or GNU base-256 when the high bit is set. */
25+
function parseNumeric(buf) {
26+
if (buf[0] & 0x80) {
27+
let v = buf[0] & 0x7f;
28+
for (let i = 1; i < buf.length; i++) v = v * 256 + buf[i];
29+
return v;
30+
}
31+
const s = buf.toString('ascii').replace(/\0/g, '').trim();
32+
return s ? parseInt(s, 8) : 0;
33+
}
34+
35+
/** NUL-terminated string field. */
36+
function stringField(block, start, len) {
37+
const raw = block.subarray(start, start + len);
38+
const nul = raw.indexOf(0);
39+
return raw.subarray(0, nul === -1 ? raw.length : nul).toString('utf8');
40+
}
41+
42+
/** pax extended-header body: a sequence of "<len> <key>=<value>\n" records. */
43+
function parsePaxRecords(body) {
44+
const out = {};
45+
let i = 0;
46+
while (i < body.length) {
47+
const sp = body.indexOf(0x20, i);
48+
if (sp === -1) break;
49+
const len = parseInt(body.subarray(i, sp).toString('ascii'), 10);
50+
if (!Number.isFinite(len) || len <= 0 || i + len > body.length) break;
51+
const record = body.subarray(sp + 1, i + len - 1).toString('utf8'); // drops the trailing \n
52+
const eq = record.indexOf('=');
53+
if (eq !== -1) out[record.slice(0, eq)] = record.slice(eq + 1);
54+
i += len;
55+
}
56+
return out;
57+
}
58+
59+
/**
60+
* Iterate the entries of an (uncompressed) tar buffer. Metadata entries
61+
* (pax x/g, GNU L/K) are folded into the entry they describe and never
62+
* yielded themselves.
63+
* @param {Buffer} tar
64+
* @yields {{name: string, type: string, linkname: string, mode: number, body: Buffer}}
65+
*/
66+
export function* tarEntries(tar) {
67+
let offset = 0;
68+
let overrides = null; // accumulated pax/GNU metadata for the NEXT real entry
69+
while (offset + BLOCK <= tar.length) {
70+
const block = tar.subarray(offset, offset + BLOCK);
71+
if (block.every((b) => b === 0)) break; // end-of-archive marker
72+
const size = parseNumeric(block.subarray(124, 136));
73+
const body = tar.subarray(offset + BLOCK, offset + BLOCK + size);
74+
offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
75+
76+
const type = block[156] === 0 ? '0' : String.fromCharCode(block[156]);
77+
if (type === 'x') {
78+
overrides = { ...overrides, ...parsePaxRecords(body) };
79+
continue;
80+
}
81+
if (type === 'g') continue; // global pax header (git archive's commit comment)
82+
if (type === 'L' || type === 'K') {
83+
const text = body.subarray(0, body.indexOf(0) === -1 ? body.length : body.indexOf(0)).toString('utf8');
84+
overrides = { ...overrides, [type === 'L' ? 'path' : 'linkpath']: text };
85+
continue;
86+
}
87+
88+
let name = stringField(block, 0, 100);
89+
// The prefix field only exists in ustar-family headers.
90+
if (stringField(block, 257, 6).startsWith('ustar')) {
91+
const prefix = stringField(block, 345, 155);
92+
if (prefix) name = `${prefix}/${name}`;
93+
}
94+
let linkname = stringField(block, 157, 100);
95+
if (overrides?.path) name = overrides.path;
96+
if (overrides?.linkpath) linkname = overrides.linkpath;
97+
overrides = null;
98+
yield { name, type, linkname, mode: parseNumeric(block.subarray(100, 108)), body };
99+
}
100+
}
101+
102+
/**
103+
* Extract `tgzPath` into `destDir`, dropping the first `strip` path components
104+
* (the GitHub tarball root `<owner>-<repo>-<sha>/`), like
105+
* `tar -xzf … --strip-components=1`. Existing files are overwritten (the
106+
* caller may be retrying after a partial system-tar run).
107+
*
108+
* Symlinks: a real link is attempted first (with the right dir/file type for
109+
* Windows). The FIRST failure flips the whole run to materialization — every
110+
* remaining link becomes a deep copy of its resolved target — because the
111+
* failure means the machine cannot create symlinks at all, and mixing links
112+
* with copies would leave the tree half-mirrored. Targets are resolved inside
113+
* the archive only; a link pointing outside `destDir` is created verbatim when
114+
* possible and skipped (reported) otherwise — never dereferenced.
115+
*
116+
* @param {string} tgzPath
117+
* @param {string} destDir
118+
* @param {{strip?: number, makeSymlink?: typeof symlinkSync}} [opts]
119+
* `makeSymlink` is a test seam to simulate a symlink-incapable machine.
120+
* @returns {{files: number, links: number, materialized: boolean, skipped: string[]}}
121+
*/
122+
export function extractTarGz(tgzPath, destDir, opts = {}) {
123+
const { strip = 1, makeSymlink = symlinkSync } = opts;
124+
const tar = gunzipSync(readFileSync(tgzPath));
125+
const dest = resolve(destDir);
126+
const inside = (abs) => abs === dest || abs.startsWith(dest + sep);
127+
128+
// Archive path → absolute destination (or null for entries the strip eats).
129+
const destPathOf = (rawName) => {
130+
const parts = String(rawName)
131+
.split('/')
132+
.filter((p) => p && p !== '.');
133+
if (parts.some((p) => p === '..')) throw new Error(`unsafe path in archive: ${rawName}`);
134+
if (parts.length <= strip) return null;
135+
const abs = resolve(dest, parts.slice(strip).join('/'));
136+
if (!inside(abs)) throw new Error(`unsafe path in archive: ${rawName}`);
137+
return abs;
138+
};
139+
140+
const links = [];
141+
let files = 0;
142+
for (const entry of tarEntries(tar)) {
143+
const target = destPathOf(entry.name);
144+
if (target === null) continue;
145+
if (entry.type === '5' || entry.name.endsWith('/')) {
146+
mkdirSync(target, { recursive: true });
147+
} else if (entry.type === '2' || entry.type === '1') {
148+
links.push({ dest: target, linkname: entry.linkname, hard: entry.type === '1' });
149+
} else if (entry.type === '0') {
150+
mkdirSync(dirname(target), { recursive: true });
151+
rmSync(target, { recursive: true, force: true });
152+
writeFileSync(target, entry.body, { mode: entry.mode || 0o644 });
153+
files++;
154+
}
155+
// Anything else (fifo/devices) never appears in git archives — skipped.
156+
}
157+
158+
// Links go LAST (their targets must exist), in passes: a link whose target
159+
// is another still-pending link is deferred to the next round.
160+
let cannotLink = false;
161+
const skipped = [];
162+
let pending = links;
163+
let progress = true;
164+
while (pending.length && progress) {
165+
progress = false;
166+
const next = [];
167+
for (const link of pending) {
168+
// Hardlink names are archive paths; symlink names are relative to the link.
169+
const target = link.hard ? destPathOf(link.linkname) : resolve(dirname(link.dest), link.linkname);
170+
const confined = target !== null && inside(target);
171+
let stat = null;
172+
if (confined) {
173+
try {
174+
stat = statSync(target);
175+
} catch {
176+
stat = null; // target not materialized yet (or dangling) — retry later
177+
}
178+
}
179+
if (confined && !stat) {
180+
next.push(link);
181+
continue;
182+
}
183+
mkdirSync(dirname(link.dest), { recursive: true });
184+
rmSync(link.dest, { recursive: true, force: true });
185+
if (!link.hard && !cannotLink) {
186+
try {
187+
makeSymlink(link.linkname, link.dest, stat?.isDirectory() ? 'dir' : 'file');
188+
files++;
189+
progress = true;
190+
continue;
191+
} catch {
192+
cannotLink = true; // this machine cannot create symlinks — materialize all
193+
}
194+
}
195+
if (!confined || !stat) {
196+
skipped.push(link.dest); // points outside the archive and cannot be linked
197+
progress = true;
198+
continue;
199+
}
200+
cpSync(target, link.dest, { recursive: true, dereference: true });
201+
files++;
202+
progress = true;
203+
}
204+
pending = next;
205+
}
206+
// Whatever is left points at a target the archive never shipped (dangling).
207+
for (const link of pending) {
208+
try {
209+
makeSymlink(link.linkname, link.dest, 'file');
210+
files++;
211+
} catch {
212+
skipped.push(link.dest);
213+
}
214+
}
215+
216+
return { files, links: links.length, materialized: cannotLink, skipped };
217+
}

src/lib/template-fetch.js

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,39 @@ import { mkdir, rm } from 'node:fs/promises';
99
import { tmpdir } from 'node:os';
1010
import { join } from 'node:path';
1111
import { has, run } from './proc.js';
12+
import { extractTarGz } from './tar-extract.js';
1213
import { downloadTemplate } from './auth-client.js';
1314

15+
/**
16+
* Unpack a downloaded tarball into `destDir`. The system `tar` goes first
17+
* (fast, battle-tested, preserves symlinks where the OS allows them); when it
18+
* is missing OR fails, the built-in extractor takes over — the one failure
19+
* that matters in practice is Windows' bundled tar dying on the harness'
20+
* symlink entries ("Invalid argument") because creating links needs a
21+
* privilege students don't have, and the built-in extractor materializes
22+
* those links as copies instead. `deps` is a test seam ({ run, has }).
23+
* @returns {Promise<{ok: boolean, reason?: string}>}
24+
*/
25+
export async function extractDownloadedTarball(tgz, destDir, deps = {}) {
26+
const exec = deps.run ?? run;
27+
const hasCmd = deps.has ?? has;
28+
await mkdir(destDir, { recursive: true });
29+
if (await hasCmd('tar')) {
30+
const r = await exec('tar', ['-xzf', tgz, '-C', destDir, '--strip-components=1']);
31+
if (r.ok) return { ok: true };
32+
}
33+
try {
34+
// Start clean: a failed system-tar run leaves a partial tree behind.
35+
await rm(destDir, { recursive: true, force: true });
36+
await mkdir(destDir, { recursive: true });
37+
extractTarGz(tgz, destDir, { strip: 1 });
38+
return { ok: true };
39+
} catch {
40+
// Both extractors refused the bytes — the download really is corrupted.
41+
return { ok: false, reason: 'extract_failed' };
42+
}
43+
}
44+
1445
/**
1546
* Downloads the template `name` (live1 | live2 | harness) and extracts it into
1647
* `destDir`. The GitHub tarball ships a root directory `<owner>-<repo>-<sha>/`,
@@ -23,16 +54,7 @@ export async function fetchTemplateToDir(apiBase, token, name, destDir, ref) {
2354
try {
2455
const dl = await downloadTemplate(apiBase, token, name, tgz, ref);
2556
if (!dl.ok) return { ok: false, reason: dl.reason };
26-
27-
await mkdir(destDir, { recursive: true });
28-
const r = await run('tar', ['-xzf', tgz, '-C', destDir, '--strip-components=1']);
29-
if (!r.ok) {
30-
// A missing `tar` binary is NOT transient — distinguish it from a
31-
// corrupted download so the message tells the right recovery step.
32-
if (!(await has('tar'))) return { ok: false, reason: 'tar_missing' };
33-
return { ok: false, reason: 'extract_failed' };
34-
}
35-
return { ok: true };
57+
return await extractDownloadedTarball(tgz, destDir);
3658
} finally {
3759
await rm(tmpRoot, { recursive: true, force: true });
3860
}
@@ -50,9 +72,8 @@ export async function fetchTemplateToDir(apiBase, token, name, destDir, ref) {
5072
* GitHub token that fetches the private repos). Saying "try again in a
5173
* moment" here makes the student retry forever an error only the
5274
* maintainer can fix;
53-
* tar_missing → the `tar` program is absent on the machine — retrying
54-
* won't help either; the fix is installing it;
55-
* extract_failed → the download arrived corrupted — one re-download is
75+
* extract_failed → neither the system tar nor the built-in extractor could
76+
* unpack the bytes — the download arrived corrupted; one re-download is
5677
* worth trying, then support;
5778
* download_timeout → the connection dropped mid-download;
5879
* network_error → the server could not be reached at all (DNS, refused,
@@ -80,15 +101,6 @@ export function downloadErrorMessage(what, reason) {
80101
'contact the community support and try again later.',
81102
].join('\n');
82103
}
83-
if (reason === 'tar_missing') {
84-
return [
85-
`The "tar" program was not found on this computer — it is needed to unpack the ${what}.`,
86-
'macOS: run xcode-select --install to restore the command line tools.',
87-
'Windows: tar ships with Windows 10 and newer — update Windows.',
88-
'Linux: install it with your package manager (e.g. sudo apt install tar ).',
89-
'Then run the same command again.',
90-
].join('\n');
91-
}
92104
if (reason === 'extract_failed') {
93105
return [
94106
`The downloaded ${what} file arrived corrupted and could not be unpacked.`,

0 commit comments

Comments
 (0)