Files
msd-core/sdk/src/sdk-package-compatibility.test.ts
Tom Boucher 397c34142a Deepen SDK package seam and converge runtime skills policy (#3238)
* Deepen SDK package seam and converge runtime skills policy

* fix(sdk): unified install-root resolution for workflows and agents (CR finding 1)

Use the already-resolved gsdInstallDir constant instead of calling
resolveLegacyInstallDir() again when computing agentsDir, ensuring
workflowsDir and agentsDir share the same install root.

* fix(sdk): tilde shortening requires path-boundary match (CR finding 2)

Both renderGlobalSkillsBaseDisplayPath and renderGlobalSkillDisplayPath
used startsWith(home) which could incorrectly shorten unrelated paths
sharing the same prefix. Now checks for home === base or
base.startsWith(home + sep) to ensure a real directory boundary.

* fix(sdk): validate loadConfig export before invocation (CR finding 3)

After requiring core.cjs, check typeof mod.loadConfig === 'function'
before calling it. Throws a classified GSDError with the module path
if the export is missing, rather than a generic TypeError.

* fix(test): guard root lookup before .path dereference (CR finding 4)

Added assert.ok() guards for claudeRoot and codexRoot after the .find()
calls so that a missing root produces an explicit assertion failure
rather than a TypeError on .path dereference.

* fix(ci): fail-safe on transient API errors in approval dismissal (CR finding 6)

resolveRole() returns 'unknown' for non-404 errors (rate limits, 5xx,
network blips). shouldDismissReviewer() now treats 'unknown' as
unresolvable and skips dismissal, preventing legitimate approvals from
being dismissed due to a transient API failure. Only 'none' (true 404)
is treated as a confirmed non-collaborator.

* changeset: pr=3238 SDK package seam and runtime skills convergence

* fix(sdk): harden resolveGlobalSkillDir against path traversal (CR finding 1)

Use resolve+relative to validate that skillName cannot escape the global
skills base directory. Values like "../../foo" or absolute paths now
return null instead of joining directly. All imports (resolve, relative,
isAbsolute) were already present in helpers.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): split skill-dir-resolution and skill-not-found warnings (CR finding 2)

After resolveGlobalSkillDir's hardening can return null for traversal
attempts, the old single-branch warning "Global skill not found at ..."
was misleading. Split into two distinct cases:
- skillDir === null → "Could not resolve global skill directory for ..."
- skillMd missing → "Global skill not found at ..."

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: lock skill path-traversal rejection in resolveGlobalSkillDir

Regression test verifying that traversal segments (../../foo, ../escape),
empty string, and absolute paths are all rejected (return null), while
a legitimate skill name resolves correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sdk): align display-path contract + traversal coverage for resolveGlobalSkillMarkdownPath (CR nitpicks)

- renderGlobalSkillsBaseDisplayPath now returns a non-null string for
  unsupported runtimes (e.g. cline → "(cline does not use a skills directory)")
  matching the existing renderGlobalSkillDisplayPath contract; callers
  of both helpers no longer need null-checks for unsupported runtimes.
- Remove now-redundant ! non-null assertion on renderGlobalSkillsBaseDisplayPath
  calls in skill-manifest.ts (return type is string, not string | null).
- Extend the path-traversal test block to assert resolveGlobalSkillMarkdownPath
  also propagates null for ../../foo, ../escape, empty, and /abs/path inputs,
  locking the null-propagation contract against future refactors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:06:43 -04:00

96 lines
3.7 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { join } from 'node:path';
import {
BUNDLED_CORE_CJS_PATH,
BUNDLED_GSD_AGENTS_DIR,
BUNDLED_GSD_TEMPLATES_DIR,
BUNDLED_GSD_TOOLS_PATH,
loadLegacyCoreConfig,
probeLegacySdkAsset,
resolveBundledAgentsDir,
resolveBundledTemplatesDir,
resolveGsdToolsPath,
resolveLegacyInstallDir,
resolveLegacyTemplatesDir,
resolveLegacyWorkflowsDir,
} from './sdk-package-compatibility.js';
import { GSDError } from './errors.js';
describe('SDK Package Seam Module', () => {
const projectDir = '/work/project';
const homeDir = '/users/tester';
it('resolves legacy install-relative directories through one seam', () => {
expect(resolveLegacyInstallDir(homeDir)).toBe(join(homeDir, '.claude', 'get-shit-done'));
expect(resolveLegacyTemplatesDir(homeDir)).toBe(join(homeDir, '.claude', 'get-shit-done', 'templates'));
expect(resolveLegacyWorkflowsDir(homeDir)).toBe(join(homeDir, '.claude', 'get-shit-done', 'workflows'));
expect(resolveBundledTemplatesDir()).toBe(BUNDLED_GSD_TEMPLATES_DIR);
expect(resolveBundledAgentsDir()).toBe(BUNDLED_GSD_AGENTS_DIR);
});
it('probes legacy gsd-tools locations in bundled -> project -> home order', () => {
const resolution = probeLegacySdkAsset('gsd-tools', projectDir, {
homeDir,
existsSync: path => path === join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
});
expect(resolution.probes).toEqual([
BUNDLED_GSD_TOOLS_PATH,
join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
join(homeDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
]);
expect(resolution.path).toBe(join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'));
expect(resolution.fallbackPath).toBe(join(homeDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'));
});
it('returns concrete fallback gsd-tools path when no legacy probe exists', () => {
const path = resolveGsdToolsPath(projectDir, {
homeDir,
existsSync: () => false,
});
expect(path).toBe(join(homeDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'));
});
it('loads legacy core.cjs through one compatibility adapter', () => {
const loadConfig = vi.fn((cwd: string) => ({ cwd, source: 'legacy-core' }));
const requireFn = vi.fn(() => ({ loadConfig }));
const createRequire = vi.fn(() => requireFn as unknown as NodeJS.Require);
const result = loadLegacyCoreConfig(projectDir, {
homeDir,
existsSync: path => path === BUNDLED_CORE_CJS_PATH,
createRequire,
});
expect(createRequire).toHaveBeenCalledOnce();
expect(requireFn).toHaveBeenCalledWith(BUNDLED_CORE_CJS_PATH);
expect(loadConfig).toHaveBeenCalledWith(projectDir);
expect(result).toEqual({ cwd: projectDir, source: 'legacy-core' });
});
it('reports checked core.cjs probes when legacy asset missing', () => {
expect(() => loadLegacyCoreConfig(projectDir, {
homeDir,
existsSync: () => false,
})).toThrow(GSDError);
try {
loadLegacyCoreConfig(projectDir, {
homeDir,
existsSync: () => false,
});
expect.fail('expected GSDError');
} catch (error) {
expect(error).toBeInstanceOf(GSDError);
const message = (error as Error).message;
expect(message).toContain('state load: get-shit-done/bin/lib/core.cjs not found.');
expect(message).toContain('Checked:');
expect(message).toContain(BUNDLED_CORE_CJS_PATH);
expect(message).toContain(join(projectDir, '.claude', 'get-shit-done', 'bin', 'lib', 'core.cjs'));
expect(message).toContain(join(homeDir, '.claude', 'get-shit-done', 'bin', 'lib', 'core.cjs'));
}
});
});