Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/server/src/skill-projection.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
Expand Down Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/skill-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
31 changes: 31 additions & 0 deletions packages/server/src/skill-restore.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
24 changes: 17 additions & 7 deletions packages/server/src/skill-restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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')
Expand Down Expand Up @@ -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}`,
};
}
}
Expand Down