Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
157 changes: 89 additions & 68 deletions src/commands/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdtempSync, readFileSync, symlinkSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
Expand All @@ -17,6 +17,21 @@ import {
import type { AgentDeps, AgentFs, InstallResult, ListResult } from './agent.js';
import { AGENTS_MD_CODEX_BUDGET_BYTES, createAgentCommand, runInstall, runList } from './agent.js';

/** Windows requires Developer Mode or elevation to create symlinks. */
function canCreateSymlinks(): boolean {
const probeRoot = mkdtempSync(path.join(tmpdir(), 'agent-symlink-probe-'));
const target = path.join(probeRoot, 'target.txt');
writeFileSync(target, 'probe');
try {
symlinkSync(target, path.join(probeRoot, 'link.txt'), 'file');
return true;
} catch {
return false;
}
}

const symlinkCapable = canCreateSymlinks();

// ---------------------------------------------------------------------------
// In-memory AgentFs backed by a Map
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1085,75 +1100,81 @@ describe('runInstall — default AgentFs (real disk)', () => {
expect(readFileSync(abs, 'utf8')).toBe(content);
});

it('refuses to write through a symlinked parent dir (real disk) — exit 5', async () => {
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-parent-'));
const outside = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-'));
// `.claude` is a real symlink to a directory outside the project root.
symlinkSync(outside, path.join(tmpRoot, '.claude'), 'dir');
const { deps } = makeCapture();

let thrown: unknown;
try {
await runInstall(
{
profile: 'default',
output: 'text',
debug: false,
dryRun: false,
target: ['claude'],
skills: ['testsprite-verify'],
force: false,
dir: tmpRoot,
},
{ ...deps },
);
} catch (err) {
thrown = err;
}

expect(thrown).toBeInstanceOf(CLIError);
expect((thrown as CLIError).exitCode).toBe(5);
// Nothing was created through the symlink, outside --dir.
expect(existsSync(path.join(outside, 'skills'))).toBe(false);
});

it('refuses to overwrite a symlinked target file (real disk) with --force — exit 5', async () => {
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-target-'));
const outsideDir = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-target-'));
const { path: relPath } = renderForTarget('claude', 'testsprite-verify');
const abs = path.resolve(tmpRoot, relPath);
const nodeFs = await import('node:fs/promises');
await nodeFs.mkdir(path.dirname(abs), { recursive: true });
// SKILL.md is a real symlink to a file outside the project root.
const outsideFile = path.join(outsideDir, 'secret.txt');
await nodeFs.writeFile(outsideFile, 'SECRET', 'utf8');
symlinkSync(outsideFile, abs, 'file');
const { deps } = makeCapture();
it.skipIf(!symlinkCapable)(
'refuses to write through a symlinked parent dir (real disk) — exit 5',
async () => {
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-parent-'));
const outside = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-'));
// `.claude` is a real symlink to a directory outside the project root.
symlinkSync(outside, path.join(tmpRoot, '.claude'), 'dir');
const { deps } = makeCapture();

let thrown: unknown;
try {
await runInstall(
{
profile: 'default',
output: 'text',
debug: false,
dryRun: false,
target: ['claude'],
skills: ['testsprite-verify'],
force: false,
dir: tmpRoot,
},
{ ...deps },
);
} catch (err) {
thrown = err;
}

let thrown: unknown;
try {
await runInstall(
{
profile: 'default',
output: 'text',
debug: false,
dryRun: false,
target: ['claude'],
skills: ['testsprite-verify'],
force: true,
dir: tmpRoot,
},
{ ...deps },
);
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(CLIError);
expect((thrown as CLIError).exitCode).toBe(5);
// Nothing was created through the symlink, outside --dir.
expect(existsSync(path.join(outside, 'skills'))).toBe(false);
},
);

it.skipIf(!symlinkCapable)(
'refuses to overwrite a symlinked target file (real disk) with --force — exit 5',
async () => {
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-target-'));
const outsideDir = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-target-'));
const { path: relPath } = renderForTarget('claude', 'testsprite-verify');
const abs = path.resolve(tmpRoot, relPath);
const nodeFs = await import('node:fs/promises');
await nodeFs.mkdir(path.dirname(abs), { recursive: true });
// SKILL.md is a real symlink to a file outside the project root.
const outsideFile = path.join(outsideDir, 'secret.txt');
await nodeFs.writeFile(outsideFile, 'SECRET', 'utf8');
symlinkSync(outsideFile, abs, 'file');
const { deps } = makeCapture();

let thrown: unknown;
try {
await runInstall(
{
profile: 'default',
output: 'text',
debug: false,
dryRun: false,
target: ['claude'],
skills: ['testsprite-verify'],
force: true,
dir: tmpRoot,
},
{ ...deps },
);
} catch (err) {
thrown = err;
}

expect(thrown).toBeInstanceOf(CLIError);
expect((thrown as CLIError).exitCode).toBe(5);
// The outside file was NOT overwritten (nor clobbered via the .bak path).
expect(readFileSync(outsideFile, 'utf8')).toBe('SECRET');
});
expect(thrown).toBeInstanceOf(CLIError);
expect((thrown as CLIError).exitCode).toBe(5);
// The outside file was NOT overwritten (nor clobbered via the .bak path).
expect(readFileSync(outsideFile, 'utf8')).toBe('SECRET');
},
);
});

// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/lib/agent-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function parseFrontmatterDescription(content: string): string | undefined {
}
}
if (inFrontmatter && line.startsWith('description: ')) {
return line.slice('description: '.length);
return line.slice('description: '.length).replace(/\r$/, '');
}
}
return undefined;
Expand Down
10 changes: 5 additions & 5 deletions src/lib/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import { existsSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
applyFailedOnly,
Expand Down Expand Up @@ -592,13 +592,13 @@ describe('resolveBundleDir', () => {

it('resolves a relative path against cwd', () => {
const out = resolveBundleDir('./tmp/x');
expect(out.endsWith('/tmp/x')).toBe(true);
expect(out.startsWith('/')).toBe(true);
expect(out).toBe(resolve(process.cwd(), 'tmp', 'x'));
});

it('strips a trailing slash', () => {
const out = resolveBundleDir('/tmp/x/');
expect(out).toBe('/tmp/x');
const base = resolve(process.cwd(), 'tmp', 'x');
const trailing = process.platform === 'win32' ? `${base}\\` : `${base}/`;
expect(resolveBundleDir(trailing)).toBe(base);
});
});

Expand Down
13 changes: 12 additions & 1 deletion src/lib/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,17 @@ export function applyFailedOnly(ctx: CliFailureContext): CliFailureContext {
* its `.tmp` child — `writeBundle` mkdir's after the integrity check
* passes so a forged response never modifies the operator's filesystem.
*/
function stripTrailingSeparators(rawPath: string): string {
if (rawPath.length <= 1) return rawPath;
let end = rawPath.length;
while (end > 1 && (rawPath[end - 1] === '/' || rawPath[end - 1] === '\\')) {
// Preserve Windows drive roots (e.g. `C:\`).
if (end === 3 && rawPath[1] === ':' && /[A-Za-z]/.test(rawPath[0]!)) break;
end--;
}
return rawPath.slice(0, end);
}

export function resolveBundleDir(rawPath: string): string {
if (typeof rawPath !== 'string' || rawPath.length === 0) {
throw ApiError.fromEnvelope({
Expand All @@ -325,7 +336,7 @@ export function resolveBundleDir(rawPath: string): string {
},
});
}
const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath;
const trimmed = stripTrailingSeparators(rawPath);
return isAbsolute(trimmed) ? trimmed : resolve(process.cwd(), trimmed);
}

Expand Down
14 changes: 9 additions & 5 deletions src/lib/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdtempSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
Expand Down Expand Up @@ -135,12 +135,16 @@ describe('readCredentialsFile / readProfile', () => {
});
});

const isWin = process.platform === 'win32';

describe('writeProfile', () => {
it('creates the file with mode 0600 and writes the profile', () => {
writeProfile(DEFAULT_PROFILE, { apiKey: 'sk-new' }, { path: credentialsPath });
expect(existsSync(credentialsPath)).toBe(true);
const mode = statSync(credentialsPath).mode & 0o777;
expect(mode).toBe(0o600);
if (!isWin) {
const mode = statSync(credentialsPath).mode & 0o777;
expect(mode).toBe(0o600);
}
expect(readProfile(DEFAULT_PROFILE, { path: credentialsPath })).toEqual({ apiKey: 'sk-new' });
});

Expand Down Expand Up @@ -188,7 +192,7 @@ describe('ensureRestrictiveMode', () => {
expect(() => ensureRestrictiveMode(credentialsPath)).not.toThrow();
});

it('downgrades over-permissive modes', () => {
it.skipIf(isWin)('downgrades over-permissive modes', () => {
mkdirSync(tmpRoot, { recursive: true });
writeFileSync(credentialsPath, 'data', { mode: 0o644 });
ensureRestrictiveMode(credentialsPath);
Expand All @@ -199,7 +203,7 @@ describe('ensureRestrictiveMode', () => {

describe('defaultCredentialsPath', () => {
it('points at ~/.testsprite/credentials', () => {
expect(defaultCredentialsPath().endsWith('/.testsprite/credentials')).toBe(true);
expect(defaultCredentialsPath()).toBe(join(homedir(), '.testsprite', 'credentials'));
});
});

Expand Down
Loading
Loading