diff --git a/packages/server/src/skill-projection.test.ts b/packages/server/src/skill-projection.test.ts index 1f4bf5266..43208846a 100644 --- a/packages/server/src/skill-projection.test.ts +++ b/packages/server/src/skill-projection.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -172,6 +173,20 @@ describe('readSkillBundledFiles', () => { test('absent skill dir returns empty', () => { expect(readSkillBundledFiles(join(root, 'nope'))).toEqual([]); }); + + const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + test.skipIf(isRoot)('a genuine IO error THROWS rather than masquerading as binary', () => { + const dir = makeSkill('locked', '# Body'); + mkdirSync(join(dir, 'reference'), { recursive: true }); + const secret = join(dir, 'reference', 'secret.md'); + writeFileSync(secret, '# Secret', 'utf-8'); + chmodSync(secret, 0o000); + try { + expect(() => readSkillBundledFiles(dir)).toThrow(); + } finally { + chmodSync(secret, 0o644); // restore so afterEach can clean up + } + }); }); describe('hostSkillsRootEscapes', () => { diff --git a/packages/server/src/skill-projection.ts b/packages/server/src/skill-projection.ts index 93e6901ac..1dc90a2ed 100644 --- a/packages/server/src/skill-projection.ts +++ b/packages/server/src/skill-projection.ts @@ -233,7 +233,8 @@ export function readSkillBundledFiles( if (buf.length <= MAX_BUNDLED_FILE_BYTES && !buf.includes(0)) { text = buf.toString('utf-8'); } - } catch { + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; text = null; } out.push({ path: rel, text }); diff --git a/packages/server/src/skill-restore.test.ts b/packages/server/src/skill-restore.test.ts new file mode 100644 index 000000000..4f0a79a3d --- /dev/null +++ b/packages/server/src/skill-restore.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test'; +import { isGitObjectNotFound } from './skill-restore.ts'; + +describe('isGitObjectNotFound', () => { + test('classifies a genuinely-missing object/revision as not-found (→ 404)', () => { + for (const msg of [ + "fatal: not a valid object name 'abc123'", + 'fatal: bad revision', + 'unknown revision or path not in the working tree', + 'fatal: Not a valid object name HEAD~5', + 'fatal: invalid object name deadbeef', + 'fatal: not a tree object', + ]) { + expect(isGitObjectNotFound(msg)).toBe(true); + } + }); + + test('does NOT match genuine git I/O / server faults (→ 500)', () => { + for (const msg of [ + 'fatal: unable to read source tree', + 'git binary not found', + 'fatal: object file is empty', + 'repository is corrupt', + 'fatal: unable to read tree', + 'EACCES: permission denied', + 'spawn git ENOENT', + ]) { + expect(isGitObjectNotFound(msg)).toBe(false); + } + }); +}); diff --git a/packages/server/src/skill-restore.ts b/packages/server/src/skill-restore.ts index 4d9e3336e..a0cfc2560 100644 --- a/packages/server/src/skill-restore.ts +++ b/packages/server/src/skill-restore.ts @@ -16,6 +16,12 @@ export type RestoreSkillResult = error: string; }; +export function isGitObjectNotFound(message: string): boolean { + return /not a valid object name|not a tree object|bad revision|unknown revision|invalid object name/i.test( + message, + ); +} + export async function restoreSkillVersion(opts: { shadow: ShadowHandle; contentDir: string; @@ -33,12 +39,15 @@ export async function restoreSkillVersion(opts: { let fileList: string; try { fileList = await sg.raw('ls-tree', '-r', '--name-only', version, '--', shadowPath); - } catch { - return { - ok: false, - code: 'version-not-found', - error: `Version ${version.slice(0, 8)} not found.`, - }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return isGitObjectNotFound(msg) + ? { ok: false, code: 'version-not-found', error: `Version ${version.slice(0, 8)} not found.` } + : { + ok: false, + code: 'io-error', + error: `Failed to read version ${version.slice(0, 8)}: ${msg}`, + }; } const files = fileList .split('\n') @@ -68,10 +77,11 @@ export async function restoreSkillVersion(opts: { try { staged.push({ rel, destAbs, content: await sg.raw('show', `${version}:${shadowFile}`) }); } catch (e) { + const msg = e instanceof Error ? e.message : String(e); return { ok: false, code: 'io-error', - error: `Failed reading ${rel} at ${version.slice(0, 8)}: ${(e as Error).message}`, + error: `Failed reading ${rel} at ${version.slice(0, 8)}: ${msg}`, }; } }