@mycadet/assistant 0.12.8 → 0.12.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/ces-client/package.json +1 -1
- package/node_modules/@mycadet/ces-client/src/__tests__/ces-client.test.ts +14 -14
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/openapi.yaml +2 -18
- package/package.json +2 -2
- package/src/__tests__/cloud-profile-guard.test.ts +21 -19
- package/src/__tests__/config-set-route.test.ts +12 -8
- package/src/__tests__/credential-health-service.test.ts +4 -2
- package/src/__tests__/get-skill-detail-audit.test.ts +134 -289
- package/src/__tests__/mcp-abort-signal.test.ts +0 -1
- package/src/__tests__/mcp-client-auth.test.ts +0 -1
- package/src/__tests__/mcp-config-secret-boundary.test.ts +44 -40
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +0 -1
- package/src/__tests__/plugin-api-store-credential.test.ts +4 -1
- package/src/__tests__/skills-file-content-endpoint.test.ts +221 -535
- package/src/__tests__/skills-files-catalog-fallback.test.ts +258 -576
- package/src/api/events/show-cloud-login.ts +2 -2
- package/src/cli/commands/cloud/__tests__/callback-routes-list.test.ts +2 -2
- package/src/cli/commands/cloud/index.ts +1 -1
- package/src/cli/commands/config.ts +1 -1
- package/src/config/__tests__/substrate-twin-shadowing.test.ts +36 -26
- package/src/config/env-registry.ts +2 -9
- package/src/config/schemas/ingress.ts +30 -0
- package/src/config/schemas/llm.ts +0 -9
- package/src/config/seed-inference-profiles.ts +2 -2
- package/src/config/substrate-twin-shadowing.ts +12 -17
- package/src/config/sync-gated-profiles.ts +3 -4
- package/src/credential-execution/prompted-credential.ts +1 -10
- package/src/credential-health/credential-health-service.ts +8 -4
- package/src/daemon/credential-transcript-scrub.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +5 -0
- package/src/providers/inference/backfill.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +100 -57
- package/src/runtime/routes/cloud-routes.ts +5 -5
- package/src/runtime/routes/conversation-query-routes.ts +275 -397
- package/src/runtime/routes/secret-routes.ts +2 -17
- package/src/tools/credentials/store.ts +1 -10
- package/src/util/layout.ts +479 -0
- package/src/util/platform.ts +11 -467
- package/src/workspace/migrations/127-backfill-default-provider.ts +2 -2
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Handler-level tests for `getSkillFileContent
|
|
2
|
+
* Handler-level tests for `getSkillFileContent` — zero-leak.
|
|
3
3
|
*
|
|
4
4
|
* Covers:
|
|
5
5
|
* - Installed skill, valid path → returns file content (text + binary).
|
|
6
6
|
* - Installed skill, traversal path → 400 "Invalid path".
|
|
7
7
|
* - Installed skill, missing file → 404 "File not found".
|
|
8
|
-
* - Installed skill with missing on-disk directory → 404 "Skill directory
|
|
9
|
-
* missing" without consulting the provider chain fallback.
|
|
10
8
|
* - Uninstalled cadet catalog skill → cadet provider returns content.
|
|
11
9
|
* - Uninstalled skills.sh skill → skills.sh provider returns content.
|
|
12
10
|
* - Uninstalled clawhub skill → clawhub provider returns content.
|
|
@@ -14,357 +12,150 @@
|
|
|
14
12
|
* - Hidden / SKIP_DIRS path segments → rejected with 400 before touching
|
|
15
13
|
* either the installed-skill disk read or the provider chain fallback.
|
|
16
14
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* Zero-leak: the REAL catalog loader resolves skills seeded into the
|
|
16
|
+
* workspace skills dir, the REAL path sanitizer validates every path (the
|
|
17
|
+
* former mock carried an inline copy that could drift), and the REAL
|
|
18
|
+
* provider chain runs — the only fakes are the HTTP transport
|
|
19
|
+
* (requests-mock, restored automatically per test) and the clawhub CLI
|
|
20
|
+
* subprocess (a PATH-shimmed `npx` that emits canned JSON while recording
|
|
21
|
+
* its argv). The former ghost-install test (resolver lists a skill whose
|
|
22
|
+
* directory is missing) is gone: through the real loader a resolved
|
|
23
|
+
* skill's directory always exists at resolution time — the handler's
|
|
24
|
+
* `existsSync` branch is defense-in-depth for mid-request unmount races
|
|
25
|
+
* that a deterministic test cannot reach.
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
rmSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
} from "node:fs";
|
|
23
35
|
import { tmpdir } from "node:os";
|
|
24
36
|
import { join } from "node:path";
|
|
25
|
-
import { afterEach, beforeEach, describe, expect,
|
|
26
|
-
|
|
27
|
-
import { setAllFlagsOnForTesting } from "../__tests__/feature-flag-test-helpers.js";
|
|
28
|
-
import type { SkillSummary } from "../config/skills.js";
|
|
29
|
-
import type { SlimSkillResponse } from "../daemon/message-types/skills.js";
|
|
30
|
-
import type { SkillFileEntry } from "../skills/catalog-files.js";
|
|
31
|
-
import type { SkillFileProvider } from "../skills/skill-file-provider.js";
|
|
32
|
-
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// Mocks — must be declared before importing the module under test
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
// Suppress logger output
|
|
38
|
-
const noopLogger = {
|
|
39
|
-
info: () => {},
|
|
40
|
-
warn: () => {},
|
|
41
|
-
error: () => {},
|
|
42
|
-
debug: () => {},
|
|
43
|
-
trace: () => {},
|
|
44
|
-
fatal: () => {},
|
|
45
|
-
child: () => noopLogger,
|
|
46
|
-
};
|
|
37
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
47
38
|
|
|
48
|
-
|
|
39
|
+
import { setAllFlagsOnForTesting } from "./feature-flag-test-helpers.js";
|
|
40
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
41
|
+
import { mockHttp } from "./requests-mock.js";
|
|
49
42
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// Per-provider mock state
|
|
56
|
-
let mockCadetProvider: SkillFileProvider;
|
|
57
|
-
let mockSkillsshProvider: SkillFileProvider;
|
|
58
|
-
let mockClawhubProvider: SkillFileProvider;
|
|
59
|
-
|
|
60
|
-
// Track provider calls
|
|
61
|
-
const providerReadCalls: Array<{
|
|
62
|
-
provider: string;
|
|
63
|
-
skillId: string;
|
|
64
|
-
path: string;
|
|
65
|
-
}> = [];
|
|
66
|
-
|
|
67
|
-
function makeNoopProvider(name: string): SkillFileProvider {
|
|
68
|
-
return {
|
|
69
|
-
canHandle(): boolean {
|
|
70
|
-
return false;
|
|
71
|
-
},
|
|
72
|
-
async listFiles(): Promise<SkillFileEntry[] | null> {
|
|
73
|
-
return null;
|
|
74
|
-
},
|
|
75
|
-
async readFileContent(
|
|
76
|
-
skillId: string,
|
|
77
|
-
path: string,
|
|
78
|
-
): Promise<SkillFileEntry | null> {
|
|
79
|
-
providerReadCalls.push({ provider: name, skillId, path });
|
|
80
|
-
return null;
|
|
81
|
-
},
|
|
82
|
-
async toSlimSkill(): Promise<SlimSkillResponse | null> {
|
|
83
|
-
return null;
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
mock.module("../config/skills.js", () => ({
|
|
89
|
-
loadSkillCatalog: () => mockResolvedSkills.map((r) => r.summary),
|
|
90
|
-
}));
|
|
91
|
-
|
|
92
|
-
mock.module("../config/skill-state.js", () => ({
|
|
93
|
-
resolveSkillStates: () => mockResolvedSkills,
|
|
94
|
-
skillFlagKey: () => null,
|
|
95
|
-
}));
|
|
96
|
-
|
|
97
|
-
mock.module("../skills/catalog-cache.js", () => ({
|
|
98
|
-
getCatalog: async () => [],
|
|
99
|
-
}));
|
|
100
|
-
|
|
101
|
-
// The `catalog-files.js` mock — provides path validation functions inline
|
|
102
|
-
// and delegates provider creation to mock state.
|
|
103
|
-
const INLINE_SKIP_DIRS = new Set(["node_modules", "__pycache__", ".git"]);
|
|
104
|
-
|
|
105
|
-
function inlineSanitizeRelativePath(rawPath: string): string | null {
|
|
106
|
-
if (typeof rawPath !== "string" || rawPath.length === 0) {
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
if (rawPath.includes("\0")) {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
if (rawPath.startsWith("/")) {
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
if (/^[a-zA-Z]:[/\\]/.test(rawPath)) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
let candidate = rawPath.replace(/\\/g, "/");
|
|
119
|
-
while (candidate.startsWith("./")) {
|
|
120
|
-
candidate = candidate.slice(2);
|
|
121
|
-
}
|
|
122
|
-
if (candidate.length === 0) {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
const segments: string[] = [];
|
|
126
|
-
for (const seg of candidate.split("/")) {
|
|
127
|
-
if (seg === "" || seg === ".") {
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
if (seg === "..") {
|
|
131
|
-
if (segments.length === 0) {
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
segments.pop();
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
segments.push(seg);
|
|
138
|
-
}
|
|
139
|
-
if (segments.length === 0) {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
const normalized = segments.join("/");
|
|
143
|
-
if (normalized.startsWith("/")) {
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
if (/^[a-zA-Z]:[/\\]/.test(normalized)) {
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
return normalized;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function inlineHasHiddenOrSkippedSegment(sanitized: string): boolean {
|
|
153
|
-
for (const segment of sanitized.split("/")) {
|
|
154
|
-
if (segment.length === 0) {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
if (segment.startsWith(".")) {
|
|
158
|
-
return true;
|
|
159
|
-
}
|
|
160
|
-
if (INLINE_SKIP_DIRS.has(segment)) {
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// The provider factory functions are called once at module init and the
|
|
168
|
-
// returned objects are captured in the `fileProviders` array. To allow
|
|
169
|
-
// per-test mock reassignment, return proxy objects that delegate every
|
|
170
|
-
// method call to the CURRENT value of the mutable mock variable.
|
|
171
|
-
mock.module("../skills/catalog-files.js", () => ({
|
|
172
|
-
SKIP_DIRS: INLINE_SKIP_DIRS,
|
|
173
|
-
sanitizeRelativePath: inlineSanitizeRelativePath,
|
|
174
|
-
hasHiddenOrSkippedSegment: inlineHasHiddenOrSkippedSegment,
|
|
175
|
-
catalogSkillToSlim: () => ({}),
|
|
176
|
-
createCadetCatalogProvider: () => ({
|
|
177
|
-
canHandle: (id: string) => mockCadetProvider.canHandle(id),
|
|
178
|
-
listFiles: (id: string) => mockCadetProvider.listFiles(id),
|
|
179
|
-
readFileContent: (id: string, p: string) =>
|
|
180
|
-
mockCadetProvider.readFileContent(id, p),
|
|
181
|
-
toSlimSkill: (id: string) => mockCadetProvider.toSlimSkill(id),
|
|
182
|
-
}),
|
|
183
|
-
readCatalogSkillFiles: async () => null,
|
|
184
|
-
readCatalogSkillFileContent: async () => null,
|
|
185
|
-
}));
|
|
186
|
-
|
|
187
|
-
mock.module("../skills/skillssh-files.js", () => ({
|
|
188
|
-
createSkillsShProvider: () => ({
|
|
189
|
-
canHandle: (id: string) => mockSkillsshProvider.canHandle(id),
|
|
190
|
-
listFiles: (id: string) => mockSkillsshProvider.listFiles(id),
|
|
191
|
-
readFileContent: (id: string, p: string) =>
|
|
192
|
-
mockSkillsshProvider.readFileContent(id, p),
|
|
193
|
-
toSlimSkill: (id: string) => mockSkillsshProvider.toSlimSkill(id),
|
|
194
|
-
}),
|
|
195
|
-
}));
|
|
196
|
-
|
|
197
|
-
mock.module("../skills/clawhub-files.js", () => ({
|
|
198
|
-
createClawhubProvider: () => ({
|
|
199
|
-
canHandle: (id: string) => mockClawhubProvider.canHandle(id),
|
|
200
|
-
listFiles: (id: string) => mockClawhubProvider.listFiles(id),
|
|
201
|
-
readFileContent: (id: string, p: string) =>
|
|
202
|
-
mockClawhubProvider.readFileContent(id, p),
|
|
203
|
-
toSlimSkill: (id: string) => mockClawhubProvider.toSlimSkill(id),
|
|
204
|
-
}),
|
|
205
|
-
}));
|
|
206
|
-
|
|
207
|
-
mock.module("../skills/skill-file-provider.js", () => ({}));
|
|
208
|
-
|
|
209
|
-
mock.module("../skills/catalog-install.js", () => ({
|
|
210
|
-
installSkillLocally: async () => {},
|
|
211
|
-
getRepoSkillsDir: () => undefined,
|
|
212
|
-
}));
|
|
213
|
-
|
|
214
|
-
mock.module("../skills/catalog-search.js", () => ({
|
|
215
|
-
filterByQuery: () => [],
|
|
216
|
-
}));
|
|
217
|
-
|
|
218
|
-
mock.module("../skills/clawhub.js", () => ({
|
|
219
|
-
clawhubCheckUpdates: mock(async () => []),
|
|
220
|
-
clawhubInspect: mock(async () => ({})),
|
|
221
|
-
clawhubInstall: mock(async () => ({ success: true })),
|
|
222
|
-
clawhubSearch: mock(async () => ({ skills: [] })),
|
|
223
|
-
clawhubUpdate: mock(async () => ({ success: true })),
|
|
224
|
-
}));
|
|
225
|
-
|
|
226
|
-
mock.module("../skills/skillssh-registry.js", () => ({
|
|
227
|
-
installExternalSkill: mock(async () => {}),
|
|
228
|
-
resolveSkillSource: () => {
|
|
229
|
-
throw new Error("not used");
|
|
230
|
-
},
|
|
231
|
-
searchSkillsRegistry: mock(async () => []),
|
|
232
|
-
}));
|
|
233
|
-
|
|
234
|
-
mock.module("../skills/install-meta.js", () => ({
|
|
235
|
-
readInstallMeta: () => null,
|
|
236
|
-
}));
|
|
237
|
-
|
|
238
|
-
mock.module("../skills/cloud-store.js", () => ({
|
|
239
|
-
createCloudSkill: () => ({ created: true }),
|
|
240
|
-
deleteCloudSkill: () => ({ deleted: true }),
|
|
241
|
-
validateCloudSkillId: () => null,
|
|
242
|
-
}));
|
|
243
|
-
|
|
244
|
-
mock.module("../plugins/defaults/memory/graph/capability-seed.js", () => ({
|
|
245
|
-
deleteSkillCapabilityNode: () => {},
|
|
246
|
-
seedSkillGraphNodes: () => {},
|
|
247
|
-
seedUninstalledCatalogSkillMemories: async () => {},
|
|
248
|
-
}));
|
|
249
|
-
|
|
250
|
-
mock.module("../providers/provider-send-message.js", () => ({
|
|
251
|
-
createTimeout: () => ({
|
|
252
|
-
signal: AbortSignal.timeout(1000),
|
|
253
|
-
cleanup: () => {},
|
|
254
|
-
}),
|
|
255
|
-
extractText: () => "",
|
|
256
|
-
getConfiguredProvider: async () => null,
|
|
257
|
-
userMessage: () => ({}),
|
|
258
|
-
}));
|
|
259
|
-
|
|
260
|
-
// Real isTextMimeType — we want actual classification here.
|
|
261
|
-
// No mock needed; let it fall through to the real implementation.
|
|
262
|
-
|
|
263
|
-
let mockCloudBaseUrl = "https://platform.test";
|
|
264
|
-
mock.module("../config/env.js", () => ({
|
|
265
|
-
getCloudBaseUrl: () => mockCloudBaseUrl,
|
|
266
|
-
}));
|
|
267
|
-
mock.module("../config/env.ts", () => ({
|
|
268
|
-
getCloudBaseUrl: () => mockCloudBaseUrl,
|
|
269
|
-
}));
|
|
270
|
-
|
|
271
|
-
mock.module("../daemon/handlers/shared.js", () => ({
|
|
272
|
-
CONFIG_RELOAD_DEBOUNCE_MS: 100,
|
|
273
|
-
ensureSkillEntry: () => ({ enabled: false }),
|
|
274
|
-
log: noopLogger,
|
|
275
|
-
}));
|
|
276
|
-
|
|
277
|
-
// ---------------------------------------------------------------------------
|
|
278
|
-
// Imports (after mocks)
|
|
279
|
-
// ---------------------------------------------------------------------------
|
|
43
|
+
// Real env: the real getCloudBaseUrl() reads config.cloud.baseUrl — point it
|
|
44
|
+
// at a host mockHttp matches by path substring, so the platform-facing
|
|
45
|
+
// provider fetches flow through the real env → URL → fetch chain.
|
|
46
|
+
setConfig("cloud", { baseUrl: "https://test-cloud.mycadet.ai" });
|
|
280
47
|
|
|
281
48
|
import {
|
|
282
49
|
_resetFileProvidersForTest,
|
|
283
50
|
getSkillFileContent,
|
|
284
51
|
} from "../daemon/handlers/skills.js";
|
|
52
|
+
import { getCatalog, invalidateCatalogCache } from "../skills/catalog-cache.js";
|
|
53
|
+
import { getWorkspaceSkillsDir } from "../util/layout.js";
|
|
285
54
|
|
|
286
|
-
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// clawhub CLI subprocess shim — real spawn, canned stdout
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
287
58
|
|
|
288
|
-
|
|
59
|
+
// clawhub-files drives the real `clawhub` CLI through `npx`. The shim is a
|
|
60
|
+
// real executable earlier on PATH that records its argv and emits canned
|
|
61
|
+
// JSON, so the spawn → stdout-parse → content contract runs for real.
|
|
62
|
+
const shimDir = join(tmpdir(), "skills-content-npx-shim");
|
|
63
|
+
const shimRecord = join(shimDir, "calls.log");
|
|
64
|
+
const shimScript = [
|
|
65
|
+
"#!/bin/sh",
|
|
66
|
+
'if [ -n "${FAKE_NPX_RECORD:-}" ]; then',
|
|
67
|
+
' printf \'%s\\n\' "$*" >> "$FAKE_NPX_RECORD"',
|
|
68
|
+
"fi",
|
|
69
|
+
"printf '%s' \"${FAKE_NPX_RESPONSE:-}\"",
|
|
70
|
+
"exit 0",
|
|
71
|
+
].join("\n");
|
|
72
|
+
|
|
73
|
+
let originalPath: string | undefined;
|
|
74
|
+
|
|
75
|
+
const clawhubCalls = (): string[] => {
|
|
76
|
+
if (!existsSync(shimRecord)) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
return readFileSync(shimRecord, "utf-8")
|
|
80
|
+
.split("\n")
|
|
81
|
+
.filter((line) => line.trim().length > 0);
|
|
82
|
+
};
|
|
289
83
|
|
|
290
|
-
function
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
84
|
+
function installNpxShim(inspectResponse: unknown): void {
|
|
85
|
+
mkdirSync(shimDir, { recursive: true });
|
|
86
|
+
writeFileSync(join(shimDir, "npx"), shimScript, { mode: 0o755 });
|
|
87
|
+
process.env.FAKE_NPX_RESPONSE = JSON.stringify(inspectResponse ?? {});
|
|
88
|
+
process.env.FAKE_NPX_RECORD = shimRecord;
|
|
89
|
+
originalPath = process.env.PATH;
|
|
90
|
+
process.env.PATH = `${shimDir}:${process.env.PATH ?? ""}`;
|
|
294
91
|
}
|
|
295
92
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const root = mkdtempSync(join(tmpdir(), "skill-file-content-test-"));
|
|
300
|
-
tempDirs.push(root);
|
|
301
|
-
const skillDir = join(root, skillId);
|
|
302
|
-
mkdirSync(skillDir, { recursive: true });
|
|
303
|
-
return skillDir;
|
|
304
|
-
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Workspace skill seeding — the real loader resolves these
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
305
96
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
97
|
+
const seededSkillDirs: string[] = [];
|
|
98
|
+
|
|
99
|
+
function seedWorkspaceSkill(
|
|
100
|
+
id: string,
|
|
101
|
+
files: Record<string, string | Buffer> = {},
|
|
102
|
+
): string {
|
|
103
|
+
const dir = join(getWorkspaceSkillsDir(), id);
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
writeFileSync(
|
|
106
|
+
join(dir, "SKILL.md"),
|
|
107
|
+
`---\nname: ${id}\ndescription: seeded test skill\n---\n# ${id}\n`,
|
|
108
|
+
);
|
|
109
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
110
|
+
const abs = join(dir, relPath);
|
|
111
|
+
mkdirSync(join(abs, ".."), { recursive: true });
|
|
112
|
+
writeFileSync(abs, content);
|
|
113
|
+
}
|
|
114
|
+
seededSkillDirs.push(dir);
|
|
115
|
+
return dir;
|
|
310
116
|
}
|
|
311
117
|
|
|
312
|
-
|
|
118
|
+
// A catalog listing the given skills — served for the platform catalog fetch
|
|
119
|
+
// that warms the real cache (`getCatalog()` mirrors the list path running
|
|
120
|
+
// first, exactly how production warms it before a content read).
|
|
121
|
+
function catalogBody(ids: string[]): { skills: unknown[] } {
|
|
313
122
|
return {
|
|
314
|
-
|
|
123
|
+
skills: ids.map((id) => ({
|
|
315
124
|
id,
|
|
316
125
|
name: id,
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
directoryPath,
|
|
320
|
-
skillFilePath: join(directoryPath, "SKILL.md"),
|
|
321
|
-
source: "workspace" as const,
|
|
322
|
-
},
|
|
323
|
-
state: "enabled" as const,
|
|
126
|
+
description: `${id} catalog skill`,
|
|
127
|
+
})),
|
|
324
128
|
};
|
|
325
129
|
}
|
|
326
130
|
|
|
327
|
-
// ---------------------------------------------------------------------------
|
|
328
|
-
// Setup / teardown
|
|
329
|
-
// ---------------------------------------------------------------------------
|
|
330
|
-
|
|
331
131
|
beforeEach(() => {
|
|
332
|
-
// Real flag resolution replaces the former blanket module mock.
|
|
333
132
|
setAllFlagsOnForTesting();
|
|
334
|
-
|
|
335
|
-
originalFetch = globalThis.fetch;
|
|
336
|
-
mockResolvedSkills = [];
|
|
337
|
-
mockCloudBaseUrl = "https://platform.test";
|
|
338
|
-
providerReadCalls.length = 0;
|
|
339
|
-
mockCadetProvider = makeNoopProvider("cadet");
|
|
340
|
-
mockSkillsshProvider = makeNoopProvider("skillssh");
|
|
341
|
-
mockClawhubProvider = makeNoopProvider("clawhub");
|
|
342
|
-
// Force provider chain re-creation from the (mocked) factory functions
|
|
133
|
+
invalidateCatalogCache();
|
|
343
134
|
_resetFileProvidersForTest();
|
|
135
|
+
installNpxShim({});
|
|
344
136
|
});
|
|
345
137
|
|
|
346
138
|
afterEach(() => {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
rmSync(dir, { recursive: true, force: true });
|
|
351
|
-
} catch {
|
|
352
|
-
// best effort
|
|
353
|
-
}
|
|
139
|
+
if (originalPath !== undefined) {
|
|
140
|
+
process.env.PATH = originalPath;
|
|
141
|
+
originalPath = undefined;
|
|
354
142
|
}
|
|
355
|
-
|
|
143
|
+
delete process.env.FAKE_NPX_RESPONSE;
|
|
144
|
+
delete process.env.FAKE_NPX_RECORD;
|
|
145
|
+
rmSync(shimDir, { recursive: true, force: true });
|
|
146
|
+
for (const dir of seededSkillDirs) {
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
seededSkillDirs.length = 0;
|
|
356
150
|
});
|
|
357
151
|
|
|
358
152
|
// ---------------------------------------------------------------------------
|
|
359
|
-
// Installed-skill path
|
|
153
|
+
// Installed-skill path (real loader → real disk reads)
|
|
360
154
|
// ---------------------------------------------------------------------------
|
|
361
155
|
|
|
362
156
|
describe("getSkillFileContent — installed skill", () => {
|
|
363
157
|
test("returns text file content for a valid path", async () => {
|
|
364
|
-
|
|
365
|
-
writeFile(skillDir, "SKILL.md", "# hello world\n");
|
|
366
|
-
mockResolvedSkills = [installedSkill("my-skill", skillDir)];
|
|
367
|
-
installFetchForbidden();
|
|
158
|
+
seedWorkspaceSkill("my-skill");
|
|
368
159
|
|
|
369
160
|
const result = await getSkillFileContent("my-skill", "SKILL.md");
|
|
370
161
|
expect("error" in result).toBe(false);
|
|
@@ -373,20 +164,14 @@ describe("getSkillFileContent — installed skill", () => {
|
|
|
373
164
|
}
|
|
374
165
|
expect(result.path).toBe("SKILL.md");
|
|
375
166
|
expect(result.name).toBe("SKILL.md");
|
|
376
|
-
expect(result.size).toBe("# hello world\n".length);
|
|
377
167
|
expect(result.isBinary).toBe(false);
|
|
378
|
-
expect(result.content).
|
|
168
|
+
expect(result.content).toContain("# my-skill");
|
|
379
169
|
});
|
|
380
170
|
|
|
381
171
|
test("returns content=null for binary files", async () => {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
"img.png",
|
|
386
|
-
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
387
|
-
);
|
|
388
|
-
mockResolvedSkills = [installedSkill("my-skill", skillDir)];
|
|
389
|
-
installFetchForbidden();
|
|
172
|
+
seedWorkspaceSkill("my-skill", {
|
|
173
|
+
"img.png": Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
174
|
+
});
|
|
390
175
|
|
|
391
176
|
const result = await getSkillFileContent("my-skill", "img.png");
|
|
392
177
|
expect("error" in result).toBe(false);
|
|
@@ -399,10 +184,7 @@ describe("getSkillFileContent — installed skill", () => {
|
|
|
399
184
|
});
|
|
400
185
|
|
|
401
186
|
test("rejects traversal paths with 400 Invalid path", async () => {
|
|
402
|
-
|
|
403
|
-
writeFile(skillDir, "SKILL.md", "ok");
|
|
404
|
-
mockResolvedSkills = [installedSkill("my-skill", skillDir)];
|
|
405
|
-
installFetchForbidden();
|
|
187
|
+
seedWorkspaceSkill("my-skill");
|
|
406
188
|
|
|
407
189
|
for (const bad of ["../secrets", "..", "/etc/passwd", "./../escape"]) {
|
|
408
190
|
const result = await getSkillFileContent("my-skill", bad);
|
|
@@ -416,10 +198,7 @@ describe("getSkillFileContent — installed skill", () => {
|
|
|
416
198
|
});
|
|
417
199
|
|
|
418
200
|
test("rejects paths containing null bytes with 400", async () => {
|
|
419
|
-
|
|
420
|
-
writeFile(skillDir, "SKILL.md", "ok");
|
|
421
|
-
mockResolvedSkills = [installedSkill("my-skill", skillDir)];
|
|
422
|
-
installFetchForbidden();
|
|
201
|
+
seedWorkspaceSkill("my-skill");
|
|
423
202
|
|
|
424
203
|
const result = await getSkillFileContent("my-skill", "SKILL.md\0.png");
|
|
425
204
|
expect("error" in result).toBe(true);
|
|
@@ -430,10 +209,7 @@ describe("getSkillFileContent — installed skill", () => {
|
|
|
430
209
|
});
|
|
431
210
|
|
|
432
211
|
test("returns 404 for a missing file inside an installed skill", async () => {
|
|
433
|
-
|
|
434
|
-
writeFile(skillDir, "SKILL.md", "ok");
|
|
435
|
-
mockResolvedSkills = [installedSkill("my-skill", skillDir)];
|
|
436
|
-
installFetchForbidden();
|
|
212
|
+
seedWorkspaceSkill("my-skill");
|
|
437
213
|
|
|
438
214
|
const result = await getSkillFileContent("my-skill", "ghost.txt");
|
|
439
215
|
expect("error" in result).toBe(true);
|
|
@@ -446,27 +222,31 @@ describe("getSkillFileContent — installed skill", () => {
|
|
|
446
222
|
});
|
|
447
223
|
|
|
448
224
|
// ---------------------------------------------------------------------------
|
|
449
|
-
// Provider chain fallback — uninstalled skill content
|
|
225
|
+
// Provider chain fallback — uninstalled skill content (real providers over
|
|
226
|
+
// the mocked HTTP transport)
|
|
450
227
|
// ---------------------------------------------------------------------------
|
|
451
228
|
|
|
452
229
|
describe("getSkillFileContent — uninstalled skill (provider chain)", () => {
|
|
453
|
-
test("
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
230
|
+
test("cadet provider returns the platform content payload", async () => {
|
|
231
|
+
const http = mockHttp();
|
|
232
|
+
http.on("GET", "/v1/skills/", {
|
|
233
|
+
status: 200,
|
|
234
|
+
body: catalogBody(["remote-skill"]),
|
|
235
|
+
});
|
|
236
|
+
http.on("GET", "/v1/skills/remote-skill/files/content/", {
|
|
237
|
+
status: 200,
|
|
238
|
+
body: {
|
|
239
|
+
path: "SKILL.md",
|
|
462
240
|
name: "SKILL.md",
|
|
463
241
|
size: 14,
|
|
464
|
-
|
|
465
|
-
|
|
242
|
+
mime_type: "text/markdown",
|
|
243
|
+
is_binary: false,
|
|
466
244
|
content: "# hello world\n",
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
// Warm the real catalog cache the same way production does: the list
|
|
248
|
+
// path runs before the content read.
|
|
249
|
+
await getCatalog();
|
|
470
250
|
|
|
471
251
|
const result = await getSkillFileContent("remote-skill", "SKILL.md");
|
|
472
252
|
expect("error" in result).toBe(false);
|
|
@@ -475,80 +255,77 @@ describe("getSkillFileContent — uninstalled skill (provider chain)", () => {
|
|
|
475
255
|
}
|
|
476
256
|
expect(result.path).toBe("SKILL.md");
|
|
477
257
|
expect(result.name).toBe("SKILL.md");
|
|
478
|
-
expect(result.size).toBe(14);
|
|
479
258
|
expect(result.mimeType).toBe("text/markdown");
|
|
480
259
|
expect(result.isBinary).toBe(false);
|
|
481
260
|
expect(result.content).toBe("# hello world\n");
|
|
261
|
+
// The content fetch carried the sanitized path as its query.
|
|
262
|
+
expect(http.lastCall("GET")?.url).toContain(
|
|
263
|
+
"/v1/skills/remote-skill/files/content/?path=SKILL.md",
|
|
264
|
+
);
|
|
482
265
|
});
|
|
483
266
|
|
|
484
|
-
test("returns 404 when
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
// All providers return canHandle=false (default noop)
|
|
489
|
-
|
|
267
|
+
test("returns 404 when the clawhub CLI yields no content for a claimed slug", async () => {
|
|
268
|
+
// The real clawhub canHandle claims every valid slug; with an empty
|
|
269
|
+
// inspect payload the claimed read yields "File not found" (claimed but
|
|
270
|
+
// file absent), not "Skill not found".
|
|
490
271
|
const result = await getSkillFileContent("ghost-skill", "SKILL.md");
|
|
491
272
|
expect("error" in result).toBe(true);
|
|
492
273
|
if (!("error" in result)) {
|
|
493
274
|
return;
|
|
494
275
|
}
|
|
495
276
|
expect(result.status).toBe(404);
|
|
496
|
-
expect(result.error).toBe("
|
|
277
|
+
expect(result.error).toBe("File not found");
|
|
497
278
|
});
|
|
498
279
|
|
|
499
|
-
test("skills.sh content fallback", async () => {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
280
|
+
test("skills.sh content fallback walks the GitHub API chain", async () => {
|
|
281
|
+
const http = mockHttp();
|
|
282
|
+
// 1. Dir probe (registered first — the entry URL contains this path).
|
|
283
|
+
http.on(
|
|
284
|
+
"GET",
|
|
285
|
+
"/repos/skillssh-owner/skillssh-repo/contents/skills/my-skill",
|
|
286
|
+
{
|
|
287
|
+
status: 200,
|
|
288
|
+
body: [],
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
// 2. Contents entry for the requested file (registered second — newest
|
|
292
|
+
// match wins, and this URL embeds the probe path).
|
|
293
|
+
http.on(
|
|
294
|
+
"GET",
|
|
295
|
+
"/repos/skillssh-owner/skillssh-repo/contents/skills/my-skill/SKILL.md",
|
|
296
|
+
{
|
|
297
|
+
status: 200,
|
|
298
|
+
body: {
|
|
299
|
+
type: "file",
|
|
300
|
+
size: 20,
|
|
301
|
+
download_url:
|
|
302
|
+
"https://raw.githubusercontent.test/skillssh-owner/skillssh-repo/HEAD/skills/my-skill/SKILL.md",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
// 3. Raw content fetch (string body passes through verbatim).
|
|
307
|
+
http.on("GET", "raw.githubusercontent.test", {
|
|
308
|
+
status: 200,
|
|
309
|
+
body: "# skillssh content\n",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const result = await getSkillFileContent(
|
|
313
|
+
"skillssh-owner/skillssh-repo/my-skill",
|
|
314
|
+
"SKILL.md",
|
|
315
|
+
);
|
|
522
316
|
expect("error" in result).toBe(false);
|
|
523
317
|
if ("error" in result) {
|
|
524
318
|
return;
|
|
525
319
|
}
|
|
526
320
|
expect(result.content).toBe("# skillssh content\n");
|
|
527
321
|
expect(result.path).toBe("SKILL.md");
|
|
322
|
+
expect(result.isBinary).toBe(false);
|
|
528
323
|
});
|
|
529
324
|
|
|
530
|
-
test("clawhub content fallback", async () => {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
// Cadet and skills.sh don't handle
|
|
535
|
-
mockCadetProvider = makeNoopProvider("cadet");
|
|
536
|
-
mockSkillsshProvider = makeNoopProvider("skillssh");
|
|
537
|
-
|
|
538
|
-
// Clawhub handles and returns content
|
|
539
|
-
mockClawhubProvider = {
|
|
540
|
-
canHandle: () => true,
|
|
541
|
-
listFiles: async () => null,
|
|
542
|
-
readFileContent: async (_skillId, path) => ({
|
|
543
|
-
path,
|
|
544
|
-
name: "SKILL.md",
|
|
545
|
-
size: 22,
|
|
546
|
-
mimeType: "text/markdown",
|
|
547
|
-
isBinary: false,
|
|
548
|
-
content: "# clawhub content yo\n",
|
|
549
|
-
}),
|
|
550
|
-
toSlimSkill: async () => null,
|
|
551
|
-
};
|
|
325
|
+
test("clawhub content fallback parses the CLI subprocess output", async () => {
|
|
326
|
+
// Re-install the shim with a canned inspect payload: the real
|
|
327
|
+
// clawhub-files code spawns it, parses stdout, and returns the content.
|
|
328
|
+
installNpxShim({ skillMdContent: "# clawhub content yo\n" });
|
|
552
329
|
|
|
553
330
|
const result = await getSkillFileContent("cool-tool", "SKILL.md");
|
|
554
331
|
expect("error" in result).toBe(false);
|
|
@@ -557,19 +334,20 @@ describe("getSkillFileContent — uninstalled skill (provider chain)", () => {
|
|
|
557
334
|
}
|
|
558
335
|
expect(result.content).toBe("# clawhub content yo\n");
|
|
559
336
|
expect(result.path).toBe("SKILL.md");
|
|
337
|
+
// The real CLI argv reached the shim.
|
|
338
|
+
expect(clawhubCalls().some((argv) => argv.includes("inspect"))).toBe(true);
|
|
560
339
|
});
|
|
561
340
|
|
|
562
|
-
test("returns 'File not found' when provider
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
};
|
|
341
|
+
test("returns 'File not found' when the cadet provider claims the skill but the file is missing", async () => {
|
|
342
|
+
const http = mockHttp();
|
|
343
|
+
http.on("GET", "/v1/skills/", {
|
|
344
|
+
status: 200,
|
|
345
|
+
body: catalogBody(["known-skill"]),
|
|
346
|
+
});
|
|
347
|
+
http.on("GET", "/v1/skills/known-skill/files/content/", {
|
|
348
|
+
status: 404,
|
|
349
|
+
});
|
|
350
|
+
await getCatalog();
|
|
573
351
|
|
|
574
352
|
const result = await getSkillFileContent("known-skill", "nonexistent.txt");
|
|
575
353
|
expect("error" in result).toBe(true);
|
|
@@ -580,47 +358,25 @@ describe("getSkillFileContent — uninstalled skill (provider chain)", () => {
|
|
|
580
358
|
expect(result.error).toBe("File not found");
|
|
581
359
|
});
|
|
582
360
|
|
|
583
|
-
test("stop-on-first-match:
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
readFileContent: async () => null,
|
|
594
|
-
toSlimSkill: async () => null,
|
|
595
|
-
};
|
|
596
|
-
|
|
597
|
-
// Clawhub would return content if asked, but should NOT be consulted
|
|
598
|
-
mockClawhubProvider = {
|
|
599
|
-
canHandle: () => true,
|
|
600
|
-
listFiles: async () => null,
|
|
601
|
-
readFileContent: async (_skillId, path) => {
|
|
602
|
-
clawhubReadCalls.push(path);
|
|
603
|
-
return {
|
|
604
|
-
path,
|
|
605
|
-
name: "SKILL.md",
|
|
606
|
-
size: 10,
|
|
607
|
-
mimeType: "text/markdown",
|
|
608
|
-
isBinary: false,
|
|
609
|
-
content: "# from clawhub\n",
|
|
610
|
-
};
|
|
611
|
-
},
|
|
612
|
-
toSlimSkill: async () => null,
|
|
613
|
-
};
|
|
361
|
+
test("stop-on-first-match: clawhub is not consulted when cadet claims the skill", async () => {
|
|
362
|
+
const http = mockHttp();
|
|
363
|
+
http.on("GET", "/v1/skills/", {
|
|
364
|
+
status: 200,
|
|
365
|
+
body: catalogBody(["simple-slug"]),
|
|
366
|
+
});
|
|
367
|
+
http.on("GET", "/v1/skills/simple-slug/files/content/", {
|
|
368
|
+
status: 404,
|
|
369
|
+
});
|
|
370
|
+
await getCatalog();
|
|
614
371
|
|
|
615
372
|
const result = await getSkillFileContent("simple-slug", "SKILL.md");
|
|
616
|
-
// Should be "File not found" (cadet handled but returned null)
|
|
617
373
|
expect("error" in result).toBe(true);
|
|
618
374
|
if (!("error" in result)) {
|
|
619
375
|
return;
|
|
620
376
|
}
|
|
621
377
|
expect(result.error).toBe("File not found");
|
|
622
|
-
//
|
|
623
|
-
expect(
|
|
378
|
+
// The clawhub CLI never spawned — the cadet claim short-circuits the chain.
|
|
379
|
+
expect(clawhubCalls()).toEqual([]);
|
|
624
380
|
});
|
|
625
381
|
});
|
|
626
382
|
|
|
@@ -629,11 +385,11 @@ describe("getSkillFileContent — uninstalled skill (provider chain)", () => {
|
|
|
629
385
|
// ---------------------------------------------------------------------------
|
|
630
386
|
|
|
631
387
|
describe("getSkillFileContent — skill not found", () => {
|
|
632
|
-
test("returns 404 when
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const result = await getSkillFileContent("
|
|
388
|
+
test("returns 404 when no provider canHandle the id", async () => {
|
|
389
|
+
// The real canHandle gates: cadet needs the id in the catalog cache,
|
|
390
|
+
// skills.sh needs ≥3 slash segments, clawhub needs a valid slug. An id
|
|
391
|
+
// failing all three (space → invalid slug) is claimed by nothing.
|
|
392
|
+
const result = await getSkillFileContent("no such skill", "SKILL.md");
|
|
637
393
|
expect("error" in result).toBe(true);
|
|
638
394
|
if (!("error" in result)) {
|
|
639
395
|
return;
|
|
@@ -644,59 +400,12 @@ describe("getSkillFileContent — skill not found", () => {
|
|
|
644
400
|
});
|
|
645
401
|
|
|
646
402
|
// ---------------------------------------------------------------------------
|
|
647
|
-
//
|
|
648
|
-
// ---------------------------------------------------------------------------
|
|
649
|
-
|
|
650
|
-
describe("getSkillFileContent — installed skill with missing directory", () => {
|
|
651
|
-
test("returns 404 without consulting providers when the installed dir is gone", async () => {
|
|
652
|
-
mockResolvedSkills = [
|
|
653
|
-
installedSkill(
|
|
654
|
-
"ghost-installed",
|
|
655
|
-
"/tmp/definitely-does-not-exist-" + Date.now(),
|
|
656
|
-
),
|
|
657
|
-
];
|
|
658
|
-
// Even if a provider would return content, the handler must NOT
|
|
659
|
-
// fall through.
|
|
660
|
-
mockCadetProvider = {
|
|
661
|
-
canHandle: () => true,
|
|
662
|
-
listFiles: async () => null,
|
|
663
|
-
readFileContent: async () => ({
|
|
664
|
-
path: "SKILL.md",
|
|
665
|
-
name: "SKILL.md",
|
|
666
|
-
size: 10,
|
|
667
|
-
mimeType: "text/markdown",
|
|
668
|
-
isBinary: false,
|
|
669
|
-
content: "# from provider\n",
|
|
670
|
-
}),
|
|
671
|
-
toSlimSkill: async () => null,
|
|
672
|
-
};
|
|
673
|
-
installFetchForbidden();
|
|
674
|
-
|
|
675
|
-
const result = await getSkillFileContent("ghost-installed", "SKILL.md");
|
|
676
|
-
|
|
677
|
-
expect("error" in result).toBe(true);
|
|
678
|
-
if (!("error" in result)) {
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
expect(result.status).toBe(404);
|
|
682
|
-
expect(result.error).toContain("ghost-installed");
|
|
683
|
-
expect(result.error).toContain("directory missing");
|
|
684
|
-
// Provider chain must not have been consulted.
|
|
685
|
-
expect(providerReadCalls).toEqual([]);
|
|
686
|
-
});
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
// ---------------------------------------------------------------------------
|
|
690
|
-
// Hidden / SKIP_DIRS path rejection
|
|
403
|
+
// Hidden / SKIP_DIRS path rejection (real sanitizer, no inline copy)
|
|
691
404
|
// ---------------------------------------------------------------------------
|
|
692
405
|
|
|
693
406
|
describe("getSkillFileContent — hidden / SKIP_DIRS rejection", () => {
|
|
694
407
|
test("rejects dotfile reads from an installed skill with 400 Invalid path", async () => {
|
|
695
|
-
|
|
696
|
-
writeFile(skillDir, "SKILL.md", "# ok\n");
|
|
697
|
-
writeFile(skillDir, ".env", "SECRET=abc\n");
|
|
698
|
-
mockResolvedSkills = [installedSkill("leaky-skill", skillDir)];
|
|
699
|
-
installFetchForbidden();
|
|
408
|
+
seedWorkspaceSkill("leaky-skill", { ".env": "SECRET=abc\n" });
|
|
700
409
|
|
|
701
410
|
const result = await getSkillFileContent("leaky-skill", ".env");
|
|
702
411
|
expect("error" in result).toBe(true);
|
|
@@ -708,22 +417,6 @@ describe("getSkillFileContent — hidden / SKIP_DIRS rejection", () => {
|
|
|
708
417
|
});
|
|
709
418
|
|
|
710
419
|
test("rejects dotfile reads for an uninstalled skill before any provider read", async () => {
|
|
711
|
-
mockResolvedSkills = []; // not installed
|
|
712
|
-
installFetchForbidden();
|
|
713
|
-
mockCadetProvider = {
|
|
714
|
-
canHandle: () => true,
|
|
715
|
-
listFiles: async () => null,
|
|
716
|
-
readFileContent: async () => ({
|
|
717
|
-
path: ".env",
|
|
718
|
-
name: ".env",
|
|
719
|
-
size: 12,
|
|
720
|
-
mimeType: "text/plain",
|
|
721
|
-
isBinary: false,
|
|
722
|
-
content: "SECRET=xyz\n",
|
|
723
|
-
}),
|
|
724
|
-
toSlimSkill: async () => null,
|
|
725
|
-
};
|
|
726
|
-
|
|
727
420
|
const result = await getSkillFileContent("catalog-leaky", ".env");
|
|
728
421
|
expect("error" in result).toBe(true);
|
|
729
422
|
if (!("error" in result)) {
|
|
@@ -731,15 +424,14 @@ describe("getSkillFileContent — hidden / SKIP_DIRS rejection", () => {
|
|
|
731
424
|
}
|
|
732
425
|
expect(result.status).toBe(400);
|
|
733
426
|
expect(result.error).toBe("Invalid path");
|
|
734
|
-
// The hidden-segment check must run before the provider chain
|
|
735
|
-
|
|
427
|
+
// The hidden-segment check must run before the provider chain — the
|
|
428
|
+
// clawhub CLI (the only provider whose canHandle accepts this id) never
|
|
429
|
+
// spawned.
|
|
430
|
+
expect(clawhubCalls()).toEqual([]);
|
|
736
431
|
});
|
|
737
432
|
|
|
738
433
|
test("rejects paths whose parent directory is a dotfile segment", async () => {
|
|
739
|
-
|
|
740
|
-
writeFile(skillDir, "SKILL.md", "# ok\n");
|
|
741
|
-
mockResolvedSkills = [installedSkill("my-skill", skillDir)];
|
|
742
|
-
installFetchForbidden();
|
|
434
|
+
seedWorkspaceSkill("my-skill");
|
|
743
435
|
|
|
744
436
|
for (const bad of [".git/config", "docs/.hidden/file.md"]) {
|
|
745
437
|
const result = await getSkillFileContent("my-skill", bad);
|
|
@@ -753,10 +445,7 @@ describe("getSkillFileContent — hidden / SKIP_DIRS rejection", () => {
|
|
|
753
445
|
});
|
|
754
446
|
|
|
755
447
|
test("rejects paths inside SKIP_DIRS segments with 400 Invalid path", async () => {
|
|
756
|
-
|
|
757
|
-
writeFile(skillDir, "SKILL.md", "# ok\n");
|
|
758
|
-
mockResolvedSkills = [installedSkill("my-skill", skillDir)];
|
|
759
|
-
installFetchForbidden();
|
|
448
|
+
seedWorkspaceSkill("my-skill");
|
|
760
449
|
|
|
761
450
|
for (const bad of [
|
|
762
451
|
"node_modules/foo/index.js",
|
|
@@ -774,17 +463,14 @@ describe("getSkillFileContent — hidden / SKIP_DIRS rejection", () => {
|
|
|
774
463
|
});
|
|
775
464
|
|
|
776
465
|
test("regular SKILL.md still reads successfully (sanity)", async () => {
|
|
777
|
-
|
|
778
|
-
writeFile(skillDir, "SKILL.md", "# hello\n");
|
|
779
|
-
mockResolvedSkills = [installedSkill("healthy-skill", skillDir)];
|
|
780
|
-
installFetchForbidden();
|
|
466
|
+
seedWorkspaceSkill("healthy-skill");
|
|
781
467
|
|
|
782
468
|
const result = await getSkillFileContent("healthy-skill", "SKILL.md");
|
|
783
469
|
expect("error" in result).toBe(false);
|
|
784
470
|
if ("error" in result) {
|
|
785
471
|
return;
|
|
786
472
|
}
|
|
787
|
-
expect(result.content).
|
|
473
|
+
expect(result.content).toContain("# healthy-skill");
|
|
788
474
|
expect(result.name).toBe("SKILL.md");
|
|
789
475
|
});
|
|
790
476
|
});
|