machine-bridge-mcp 1.2.7 → 1.2.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/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +37 -19
- package/README.md +120 -410
- package/SECURITY.md +2 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +13 -4
- package/docs/AUDIT.md +22 -0
- package/docs/ENGINEERING.md +2 -2
- package/docs/OVERVIEW.md +113 -0
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +78 -33
- package/docs/TESTING.md +14 -7
- package/docs/THREAT_MODEL.md +142 -0
- package/package.json +18 -6
- package/scripts/check-plan.mjs +91 -0
- package/scripts/coverage-check.mjs +22 -0
- package/scripts/github-push.mjs +49 -0
- package/scripts/github-release.mjs +15 -8
- package/scripts/local-release-acceptance.mjs +186 -0
- package/scripts/release-acceptance.mjs +181 -0
- package/scripts/release-impact-check.mjs +19 -3
- package/scripts/release-state.mjs +1 -1
- package/scripts/run-checks.mjs +29 -0
- package/scripts/start-release-candidate.mjs +113 -0
- package/src/local/agent-context-projection.mjs +158 -0
- package/src/local/agent-context.mjs +23 -332
- package/src/local/agent-skill-discovery.mjs +230 -0
- package/src/local/agent-text-file.mjs +41 -0
- package/src/local/browser-bridge-http.mjs +48 -0
- package/src/local/browser-bridge.mjs +48 -222
- package/src/local/browser-broker-routes.mjs +136 -0
- package/src/local/browser-broker-server.mjs +59 -0
- package/src/local/browser-request-registry.mjs +67 -0
- package/src/local/managed-job-lock.mjs +99 -0
- package/src/local/managed-job-projection.mjs +68 -0
- package/src/local/managed-job-runner.mjs +73 -0
- package/src/local/managed-job-storage.mjs +93 -0
- package/src/local/managed-jobs.mjs +12 -297
- package/src/local/runtime-paths.mjs +107 -0
- package/src/local/runtime-relay.mjs +73 -0
- package/src/local/runtime-tool-handlers.mjs +66 -0
- package/src/local/runtime.mjs +22 -204
- package/src/local/windows-launcher.mjs +57 -0
- package/src/local/windows-service.mjs +7 -56
- package/src/worker/index.ts +9 -118
- package/src/worker/mcp-jsonrpc.ts +94 -0
- package/src/worker/oauth-authorization-page.ts +70 -0
- package/src/worker/oauth-controller.ts +9 -58
- package/src/worker/websocket-protocol.ts +24 -0
- package/tsconfig.local.json +6 -1
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { commandMatchText, recommendTools, relevanceScore } from "./capability-ranking.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
4
|
import { createBuiltinInstruction, discoverAutomaticProjectInstruction } from "./default-instructions.mjs";
|
|
7
5
|
import { automaticPackageCommands, readProjectPackageMetadata } from "./project-package.mjs";
|
|
8
6
|
import { clampInteger } from "./numbers.mjs";
|
|
9
7
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
discoverLocalSkills, listSkillFiles, MAX_SKILL_ENTRY_BYTES, MAX_SKILL_FILES, MAX_SKILL_RESULTS,
|
|
9
|
+
} from "./agent-skill-discovery.mjs";
|
|
10
|
+
import { readOptionalRegularUtf8, readRegularUtf8 } from "./agent-text-file.mjs";
|
|
11
|
+
import {
|
|
12
|
+
capabilityFingerprint, contextSkillSummaries, effectiveInstructionItems, publicCommands,
|
|
13
|
+
publicSkill, publicSkillWarnings, publicVirtualInstruction, renderEffectiveInstructions, sha256,
|
|
14
|
+
} from "./agent-context-projection.mjs";
|
|
15
|
+
import {
|
|
16
|
+
MAX_COMMAND_ARGUMENT_BYTES, MAX_COMMANDS,
|
|
17
|
+
assertContainedPath, isContainedPath, normalizeAgentConfig, requiredString,
|
|
12
18
|
resolveConfiguredPath, resolveInstructionPath, validateStringArray,
|
|
13
19
|
} from "./agent-contract.mjs";
|
|
20
|
+
export { parseSkillMetadata } from "./agent-skill-discovery.mjs";
|
|
14
21
|
|
|
15
22
|
const CONFIG_RELATIVE_PATH = join(".machine-bridge", "agent.json");
|
|
16
23
|
const GLOBAL_CONFIG_RELATIVE_PATH = join(".config", "machine-bridge-mcp", "agent.json");
|
|
@@ -20,11 +27,6 @@ const MAX_CONTEXT_SKILL_SUMMARY_CHARS = 8_000;
|
|
|
20
27
|
const MAX_CONFIG_BYTES = 256 * 1024;
|
|
21
28
|
const MAX_INSTRUCTION_FILE_BYTES = 512 * 1024;
|
|
22
29
|
const MAX_INSTRUCTION_FILES = 64;
|
|
23
|
-
const MAX_SKILL_ENTRY_BYTES = 512 * 1024;
|
|
24
|
-
const MAX_SKILL_RESULTS = 500;
|
|
25
|
-
const MAX_SKILL_SCAN_ENTRIES = 20_000;
|
|
26
|
-
const MAX_SKILL_SCAN_DEPTH = 8;
|
|
27
|
-
const MAX_SKILL_FILES = 500;
|
|
28
30
|
|
|
29
31
|
export class AgentContextManager {
|
|
30
32
|
constructor({ workspace, policy, displayPath, resolveExistingPath, throwIfCancelled = () => {}, home = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "" }) {
|
|
@@ -377,87 +379,16 @@ export class AgentContextManager {
|
|
|
377
379
|
}
|
|
378
380
|
|
|
379
381
|
async discoverSkills(state, options = {}, context = {}) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
this.throwIfCancelled(context);
|
|
391
|
-
const rootInfo = await lstat(configuredRoot).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
392
|
-
if (!rootInfo) continue;
|
|
393
|
-
const root = await realpath(configuredRoot);
|
|
394
|
-
const canonicalRootInfo = await stat(root);
|
|
395
|
-
if (!canonicalRootInfo.isDirectory()) throw new Error(`skill root is not a directory: ${this.displayPath(configuredRoot)}`);
|
|
396
|
-
assertAllowedPath(root, this.workspace, this.policy.unrestrictedPaths === true, "skill root");
|
|
397
|
-
const stack = [{ directory: root, depth: 0 }];
|
|
398
|
-
while (stack.length) {
|
|
399
|
-
this.throwIfCancelled(context);
|
|
400
|
-
const current = stack.pop();
|
|
401
|
-
if (seenDirectories.has(current.directory)) continue;
|
|
402
|
-
seenDirectories.add(current.directory);
|
|
403
|
-
visitedEntries += 1;
|
|
404
|
-
if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) {
|
|
405
|
-
truncated = true;
|
|
406
|
-
break;
|
|
407
|
-
}
|
|
408
|
-
const entrypoint = await findSkillEntrypoint(current.directory);
|
|
409
|
-
if (entrypoint) {
|
|
410
|
-
const canonical = await realpath(entrypoint);
|
|
411
|
-
if (!seenEntrypoints.has(canonical)) {
|
|
412
|
-
seenEntrypoints.add(canonical);
|
|
413
|
-
try {
|
|
414
|
-
const summary = await summarizeSkill(canonical, root);
|
|
415
|
-
const haystack = `${summary.id}
|
|
416
|
-
${summary.name}
|
|
417
|
-
${summary.description}
|
|
418
|
-
${summary.entrypoint}`.toLowerCase();
|
|
419
|
-
if (!query || haystack.includes(query)) {
|
|
420
|
-
skills.push(summary);
|
|
421
|
-
if (skills.length >= maxResults) {
|
|
422
|
-
truncated = true;
|
|
423
|
-
break;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
} catch (error) {
|
|
427
|
-
if (warnings.length < 100) warnings.push({ entrypoint: canonical, message: boundedMessage(error) });
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
if (current.depth >= MAX_SKILL_SCAN_DEPTH) continue;
|
|
433
|
-
const handle = await opendir(current.directory);
|
|
434
|
-
for await (const entry of handle) {
|
|
435
|
-
this.throwIfCancelled(context);
|
|
436
|
-
visitedEntries += 1;
|
|
437
|
-
if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) {
|
|
438
|
-
truncated = true;
|
|
439
|
-
break;
|
|
440
|
-
}
|
|
441
|
-
const child = join(current.directory, entry.name);
|
|
442
|
-
if (entry.isDirectory()) {
|
|
443
|
-
stack.push({ directory: child, depth: current.depth + 1 });
|
|
444
|
-
} else if (entry.isSymbolicLink()) {
|
|
445
|
-
const target = await realpath(child).catch(() => "");
|
|
446
|
-
if (!target) continue;
|
|
447
|
-
const targetInfo = await stat(target).catch(() => null);
|
|
448
|
-
if (!targetInfo?.isDirectory()) continue;
|
|
449
|
-
assertAllowedPath(target, this.workspace, this.policy.unrestrictedPaths === true, "skill symlink target");
|
|
450
|
-
stack.push({ directory: target, depth: current.depth + 1 });
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
if (truncated) break;
|
|
454
|
-
}
|
|
455
|
-
if (truncated && skills.length >= maxResults) break;
|
|
456
|
-
if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) break;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
skills.sort((left, right) => left.name.localeCompare(right.name) || left.entrypoint.localeCompare(right.entrypoint));
|
|
460
|
-
return { skills, warnings, truncated };
|
|
382
|
+
return discoverLocalSkills({
|
|
383
|
+
skillRoots: state.skillRoots,
|
|
384
|
+
query: String(options.query || ""),
|
|
385
|
+
maxResults: clampInteger(options.maxResults, 100, 1, MAX_SKILL_RESULTS),
|
|
386
|
+
workspace: this.workspace,
|
|
387
|
+
unrestricted: this.policy.unrestrictedPaths === true,
|
|
388
|
+
displayPath: this.displayPath,
|
|
389
|
+
context,
|
|
390
|
+
throwIfCancelled: this.throwIfCancelled,
|
|
391
|
+
});
|
|
461
392
|
}
|
|
462
393
|
}
|
|
463
394
|
|
|
@@ -519,243 +450,3 @@ async function readOptionalConfig(configPath, allowedRoot, rejectPathAliases) {
|
|
|
519
450
|
}
|
|
520
451
|
return normalizeAgentConfig(parsed, configPath);
|
|
521
452
|
}
|
|
522
|
-
|
|
523
|
-
async function findSkillEntrypoint(directory) {
|
|
524
|
-
for (const name of ["SKILL.md", "skill.md"]) {
|
|
525
|
-
const candidate = join(directory, name);
|
|
526
|
-
const info = await lstat(candidate).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
527
|
-
if (!info) continue;
|
|
528
|
-
if (info.isSymbolicLink()) throw new Error(`skill entrypoint must not be a symbolic link: ${candidate}`);
|
|
529
|
-
if (!info.isFile()) throw new Error(`skill entrypoint is not a regular file: ${candidate}`);
|
|
530
|
-
return candidate;
|
|
531
|
-
}
|
|
532
|
-
return "";
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
async function summarizeSkill(entrypoint, sourceRoot) {
|
|
536
|
-
const content = await readRegularUtf8(entrypoint, MAX_SKILL_ENTRY_BYTES, "skill entrypoint");
|
|
537
|
-
const metadata = parseSkillMetadata(content.text);
|
|
538
|
-
if (!metadata.name || !metadata.description) throw new Error("SKILL.md front matter requires non-empty name and description fields");
|
|
539
|
-
const directory = dirname(entrypoint);
|
|
540
|
-
const name = metadata.name.slice(0, 200);
|
|
541
|
-
return {
|
|
542
|
-
id: `skill_${sha256(entrypoint).slice(0, 16)}`,
|
|
543
|
-
name,
|
|
544
|
-
description: metadata.description.slice(0, 1000),
|
|
545
|
-
entrypoint,
|
|
546
|
-
directory,
|
|
547
|
-
sourceRoot,
|
|
548
|
-
bytes: content.bytes,
|
|
549
|
-
sha256: sha256(content.text),
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
export function parseSkillMetadata(content) {
|
|
554
|
-
const text = String(content || "");
|
|
555
|
-
if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return {};
|
|
556
|
-
const lines = text.split(/\r?\n/);
|
|
557
|
-
let end = -1;
|
|
558
|
-
for (let index = 1; index < Math.min(lines.length, 200); index += 1) {
|
|
559
|
-
if (lines[index].trim() === "---") {
|
|
560
|
-
end = index;
|
|
561
|
-
break;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
if (end === -1) return {};
|
|
565
|
-
const metadata = {};
|
|
566
|
-
for (const line of lines.slice(1, end)) {
|
|
567
|
-
const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
|
|
568
|
-
if (!match) continue;
|
|
569
|
-
const key = match[1].toLowerCase();
|
|
570
|
-
if (key !== "name" && key !== "description") continue;
|
|
571
|
-
metadata[key] = unquoteScalar(match[2].trim());
|
|
572
|
-
}
|
|
573
|
-
return metadata;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
async function listSkillFiles(root, maxFiles, context, throwIfCancelled) {
|
|
577
|
-
const files = [];
|
|
578
|
-
const stack = [{ directory: root, depth: 0 }];
|
|
579
|
-
let visited = 0;
|
|
580
|
-
let truncated = false;
|
|
581
|
-
while (stack.length) {
|
|
582
|
-
throwIfCancelled(context);
|
|
583
|
-
const current = stack.pop();
|
|
584
|
-
const handle = await opendir(current.directory);
|
|
585
|
-
for await (const entry of handle) {
|
|
586
|
-
throwIfCancelled(context);
|
|
587
|
-
visited += 1;
|
|
588
|
-
if (visited > MAX_SKILL_SCAN_ENTRIES) {
|
|
589
|
-
truncated = true;
|
|
590
|
-
break;
|
|
591
|
-
}
|
|
592
|
-
const full = join(current.directory, entry.name);
|
|
593
|
-
const rel = relative(root, full).split(sep).join("/");
|
|
594
|
-
if (entry.isDirectory()) {
|
|
595
|
-
if (current.depth < MAX_SKILL_SCAN_DEPTH) stack.push({ directory: full, depth: current.depth + 1 });
|
|
596
|
-
else truncated = true;
|
|
597
|
-
} else if (entry.isFile()) {
|
|
598
|
-
const info = await lstat(full);
|
|
599
|
-
files.push({ path: rel, bytes: info.size, type: "file" });
|
|
600
|
-
} else if (entry.isSymbolicLink()) {
|
|
601
|
-
files.push({ path: rel, bytes: 0, type: "symlink" });
|
|
602
|
-
}
|
|
603
|
-
if (files.length >= maxFiles) {
|
|
604
|
-
truncated = true;
|
|
605
|
-
break;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
if (truncated && (files.length >= maxFiles || visited > MAX_SKILL_SCAN_ENTRIES)) break;
|
|
609
|
-
}
|
|
610
|
-
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
611
|
-
return { files, truncated };
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function capabilityFingerprint(state, skills) {
|
|
615
|
-
return sha256(JSON.stringify({
|
|
616
|
-
configs: state.configFiles,
|
|
617
|
-
instructions: [
|
|
618
|
-
state.builtinInstructions?.sha256 || "",
|
|
619
|
-
state.automaticProjectContext?.sha256 || "",
|
|
620
|
-
state.modelInstructions?.sha256 || "",
|
|
621
|
-
...state.instructions.map((item) => item.sha256),
|
|
622
|
-
],
|
|
623
|
-
skills: skills.map((skill) => [skill.id, skill.sha256]),
|
|
624
|
-
commands: [...state.commands.values()].map((command) => [command.name, command.argv]),
|
|
625
|
-
}));
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function publicSkill(skill, displayPath) {
|
|
629
|
-
return {
|
|
630
|
-
id: skill.id,
|
|
631
|
-
name: skill.name,
|
|
632
|
-
description: skill.description,
|
|
633
|
-
entrypoint: displayPath(skill.entrypoint),
|
|
634
|
-
source_root: displayPath(skill.sourceRoot),
|
|
635
|
-
bytes: skill.bytes,
|
|
636
|
-
sha256: skill.sha256,
|
|
637
|
-
};
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function contextSkillSummaries(skills, displayPath, maxSkills, budgetChars) {
|
|
641
|
-
const selected = [];
|
|
642
|
-
let used = 0;
|
|
643
|
-
for (const skill of skills) {
|
|
644
|
-
if (selected.length >= maxSkills) return { skills: selected, truncated: true };
|
|
645
|
-
const item = publicSkill(skill, displayPath);
|
|
646
|
-
const fullSize = JSON.stringify(item).length;
|
|
647
|
-
if (used + fullSize <= budgetChars) {
|
|
648
|
-
selected.push(item);
|
|
649
|
-
used += fullSize;
|
|
650
|
-
continue;
|
|
651
|
-
}
|
|
652
|
-
const withoutDescription = { ...item, description: "", description_truncated: true };
|
|
653
|
-
const baseSize = JSON.stringify(withoutDescription).length;
|
|
654
|
-
const available = budgetChars - used - baseSize;
|
|
655
|
-
if (available >= 32) {
|
|
656
|
-
selected.push({ ...withoutDescription, description: item.description.slice(0, available) });
|
|
657
|
-
}
|
|
658
|
-
return { skills: selected, truncated: true };
|
|
659
|
-
}
|
|
660
|
-
return { skills: selected, truncated: false };
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function publicSkillWarnings(warnings, displayPath) {
|
|
664
|
-
return warnings.map((warning) => ({ entrypoint: displayPath(warning.entrypoint), message: warning.message }));
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
function publicCommands(commands, displayPath) {
|
|
668
|
-
return [...commands.values()]
|
|
669
|
-
.sort((left, right) => left.name.localeCompare(right.name))
|
|
670
|
-
.map((command) => ({
|
|
671
|
-
name: command.name,
|
|
672
|
-
description: command.description,
|
|
673
|
-
argv: [...command.argv],
|
|
674
|
-
cwd: displayPath(command.cwd),
|
|
675
|
-
timeout_seconds: command.timeoutSeconds,
|
|
676
|
-
allow_extra_args: command.allowExtraArgs,
|
|
677
|
-
source: displayPath(command.source),
|
|
678
|
-
source_type: command.sourceType || "agent-config",
|
|
679
|
-
...(command.script ? { package_script: command.script } : {}),
|
|
680
|
-
}));
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
function effectiveInstructionItems(state) {
|
|
684
|
-
return [
|
|
685
|
-
...(state.builtinInstructions ? [state.builtinInstructions] : []),
|
|
686
|
-
...(state.automaticProjectContext ? [state.automaticProjectContext] : []),
|
|
687
|
-
...(state.modelInstructions ? [state.modelInstructions] : []),
|
|
688
|
-
...state.instructions,
|
|
689
|
-
];
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
function publicVirtualInstruction(item, includeContent) {
|
|
693
|
-
if (!item) return null;
|
|
694
|
-
return {
|
|
695
|
-
source: item.source,
|
|
696
|
-
scope: item.scope,
|
|
697
|
-
bytes: item.bytes,
|
|
698
|
-
sha256: item.sha256,
|
|
699
|
-
precedence: item.precedence,
|
|
700
|
-
...(includeContent ? { content: item.content } : {}),
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
function renderEffectiveInstructions(instructions, displayPath) {
|
|
705
|
-
return instructions.map((item) => {
|
|
706
|
-
const source = item.source || displayPath(item.path);
|
|
707
|
-
return [
|
|
708
|
-
`--- BEGIN ${source} (precedence ${item.precedence}) ---`,
|
|
709
|
-
item.content,
|
|
710
|
-
`--- END ${source} ---`,
|
|
711
|
-
].join("\n");
|
|
712
|
-
}).join("\n\n");
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
async function readOptionalRegularUtf8(filePath, maxBytes, label) {
|
|
716
|
-
const info = await lstat(filePath).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
717
|
-
if (!info) return null;
|
|
718
|
-
if (info.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link: ${filePath}`);
|
|
719
|
-
if (!info.isFile()) throw new Error(`${label} is not a regular file: ${filePath}`);
|
|
720
|
-
return readRegularUtf8(filePath, maxBytes, label);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
async function readRegularUtf8(filePath, maxBytes, label) {
|
|
724
|
-
const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
725
|
-
try {
|
|
726
|
-
const info = await handle.stat();
|
|
727
|
-
if (!info.isFile()) throw new Error(`${label} is not a regular file: ${filePath}`);
|
|
728
|
-
if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
|
|
729
|
-
const buffer = Buffer.alloc(info.size);
|
|
730
|
-
let offset = 0;
|
|
731
|
-
while (offset < buffer.length) {
|
|
732
|
-
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
733
|
-
if (!bytesRead) break;
|
|
734
|
-
offset += bytesRead;
|
|
735
|
-
}
|
|
736
|
-
let text;
|
|
737
|
-
try {
|
|
738
|
-
text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
|
|
739
|
-
} catch {
|
|
740
|
-
throw new Error(`${label} is not valid UTF-8 text: ${filePath}`);
|
|
741
|
-
}
|
|
742
|
-
return { text, bytes: offset };
|
|
743
|
-
} finally {
|
|
744
|
-
await handle.close();
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
function boundedMessage(error) {
|
|
749
|
-
return String(error?.message || error || "invalid local skill").replace(/[\r\n]+/g, " ").slice(0, 1000);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function unquoteScalar(value) {
|
|
753
|
-
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
754
|
-
return value.slice(1, -1);
|
|
755
|
-
}
|
|
756
|
-
return value;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
function sha256(value) {
|
|
760
|
-
return createHash("sha256").update(String(value)).digest("hex");
|
|
761
|
-
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { lstat, opendir, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
4
|
+
import { assertAllowedPath, MAX_SKILL_ROOTS } from "./agent-contract.mjs";
|
|
5
|
+
import { sha256 } from "./agent-context-projection.mjs";
|
|
6
|
+
import { readRegularUtf8 } from "./agent-text-file.mjs";
|
|
7
|
+
|
|
8
|
+
export const MAX_SKILL_ENTRY_BYTES = 512 * 1024;
|
|
9
|
+
export const MAX_SKILL_RESULTS = 500;
|
|
10
|
+
export const MAX_SKILL_FILES = 500;
|
|
11
|
+
const MAX_SKILL_SCAN_ENTRIES = 20_000;
|
|
12
|
+
const MAX_SKILL_SCAN_DEPTH = 8;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} SkillSummary
|
|
16
|
+
* @property {string} id
|
|
17
|
+
* @property {string} name
|
|
18
|
+
* @property {string} description
|
|
19
|
+
* @property {string} entrypoint
|
|
20
|
+
* @property {string} directory
|
|
21
|
+
* @property {string} sourceRoot
|
|
22
|
+
* @property {number} bytes
|
|
23
|
+
* @property {string} sha256
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} SkillDiscoveryOptions
|
|
27
|
+
* @property {string[]} skillRoots
|
|
28
|
+
* @property {string} query
|
|
29
|
+
* @property {number} maxResults
|
|
30
|
+
* @property {string} workspace
|
|
31
|
+
* @property {boolean} unrestricted
|
|
32
|
+
* @property {(value: string) => string} displayPath
|
|
33
|
+
* @property {unknown} context
|
|
34
|
+
* @property {(context: unknown) => void} throwIfCancelled
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** @param {SkillDiscoveryOptions} options */
|
|
38
|
+
export async function discoverLocalSkills(options) {
|
|
39
|
+
const query = String(options.query || "").trim().toLowerCase();
|
|
40
|
+
/** @type {SkillSummary[]} */
|
|
41
|
+
const skills = [];
|
|
42
|
+
/** @type {Array<{entrypoint: string, message: string}>} */
|
|
43
|
+
const warnings = [];
|
|
44
|
+
const seenEntrypoints = new Set();
|
|
45
|
+
const seenDirectories = new Set();
|
|
46
|
+
let visitedEntries = 0;
|
|
47
|
+
let truncated = false;
|
|
48
|
+
|
|
49
|
+
for (const configuredRoot of options.skillRoots.slice(0, MAX_SKILL_ROOTS)) {
|
|
50
|
+
options.throwIfCancelled(options.context);
|
|
51
|
+
const rootInfo = await lstat(configuredRoot).catch((error) => isMissing(error) ? null : Promise.reject(error));
|
|
52
|
+
if (!rootInfo) continue;
|
|
53
|
+
const root = await realpath(configuredRoot);
|
|
54
|
+
const canonicalRootInfo = await stat(root);
|
|
55
|
+
if (!canonicalRootInfo.isDirectory()) throw new Error(`skill root is not a directory: ${options.displayPath(configuredRoot)}`);
|
|
56
|
+
assertAllowedPath(root, options.workspace, options.unrestricted, "skill root");
|
|
57
|
+
const stack = [{ directory: root, depth: 0 }];
|
|
58
|
+
while (stack.length) {
|
|
59
|
+
options.throwIfCancelled(options.context);
|
|
60
|
+
const current = stack.pop();
|
|
61
|
+
if (!current || seenDirectories.has(current.directory)) continue;
|
|
62
|
+
seenDirectories.add(current.directory);
|
|
63
|
+
visitedEntries += 1;
|
|
64
|
+
if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) {
|
|
65
|
+
truncated = true;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const entrypoint = await findSkillEntrypoint(current.directory);
|
|
69
|
+
if (entrypoint) {
|
|
70
|
+
const canonical = await realpath(entrypoint);
|
|
71
|
+
if (!seenEntrypoints.has(canonical)) {
|
|
72
|
+
seenEntrypoints.add(canonical);
|
|
73
|
+
try {
|
|
74
|
+
const summary = await summarizeSkill(canonical, root);
|
|
75
|
+
const haystack = `${summary.id}\n${summary.name}\n${summary.description}\n${summary.entrypoint}`.toLowerCase();
|
|
76
|
+
if (!query || haystack.includes(query)) {
|
|
77
|
+
skills.push(summary);
|
|
78
|
+
if (skills.length >= options.maxResults) {
|
|
79
|
+
truncated = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (warnings.length < 100) warnings.push({ entrypoint: canonical, message: boundedMessage(error) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (current.depth >= MAX_SKILL_SCAN_DEPTH) continue;
|
|
90
|
+
const handle = await opendir(current.directory);
|
|
91
|
+
for await (const entry of handle) {
|
|
92
|
+
options.throwIfCancelled(options.context);
|
|
93
|
+
visitedEntries += 1;
|
|
94
|
+
if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) {
|
|
95
|
+
truncated = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
const child = join(current.directory, entry.name);
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
stack.push({ directory: child, depth: current.depth + 1 });
|
|
101
|
+
} else if (entry.isSymbolicLink()) {
|
|
102
|
+
const target = await realpath(child).catch(() => "");
|
|
103
|
+
if (!target) continue;
|
|
104
|
+
const targetInfo = await stat(target).catch(() => null);
|
|
105
|
+
if (!targetInfo?.isDirectory()) continue;
|
|
106
|
+
assertAllowedPath(target, options.workspace, options.unrestricted, "skill symlink target");
|
|
107
|
+
stack.push({ directory: target, depth: current.depth + 1 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (truncated) break;
|
|
111
|
+
}
|
|
112
|
+
if (truncated && skills.length >= options.maxResults) break;
|
|
113
|
+
if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
skills.sort((left, right) => left.name.localeCompare(right.name) || left.entrypoint.localeCompare(right.entrypoint));
|
|
117
|
+
return { skills, warnings, truncated };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @param {string} root @param {number} maxFiles @param {unknown} context @param {(context: unknown) => void} throwIfCancelled */
|
|
121
|
+
export async function listSkillFiles(root, maxFiles, context, throwIfCancelled) {
|
|
122
|
+
/** @type {Array<{path: string, bytes: number, type: "file" | "symlink"}>} */
|
|
123
|
+
const files = [];
|
|
124
|
+
const stack = [{ directory: root, depth: 0 }];
|
|
125
|
+
let visited = 0;
|
|
126
|
+
let truncated = false;
|
|
127
|
+
while (stack.length) {
|
|
128
|
+
throwIfCancelled(context);
|
|
129
|
+
const current = stack.pop();
|
|
130
|
+
if (!current) continue;
|
|
131
|
+
const handle = await opendir(current.directory);
|
|
132
|
+
for await (const entry of handle) {
|
|
133
|
+
throwIfCancelled(context);
|
|
134
|
+
visited += 1;
|
|
135
|
+
if (visited > MAX_SKILL_SCAN_ENTRIES) {
|
|
136
|
+
truncated = true;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const full = join(current.directory, entry.name);
|
|
140
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
if (current.depth < MAX_SKILL_SCAN_DEPTH) stack.push({ directory: full, depth: current.depth + 1 });
|
|
143
|
+
else truncated = true;
|
|
144
|
+
} else if (entry.isFile()) {
|
|
145
|
+
const info = await lstat(full);
|
|
146
|
+
files.push({ path: rel, bytes: info.size, type: "file" });
|
|
147
|
+
} else if (entry.isSymbolicLink()) {
|
|
148
|
+
files.push({ path: rel, bytes: 0, type: "symlink" });
|
|
149
|
+
}
|
|
150
|
+
if (files.length >= maxFiles) {
|
|
151
|
+
truncated = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (truncated && (files.length >= maxFiles || visited > MAX_SKILL_SCAN_ENTRIES)) break;
|
|
156
|
+
}
|
|
157
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
158
|
+
return { files, truncated };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {unknown} content @returns {{name?: string, description?: string}} */
|
|
162
|
+
export function parseSkillMetadata(content) {
|
|
163
|
+
const text = String(content || "");
|
|
164
|
+
if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return {};
|
|
165
|
+
const lines = text.split(/\r?\n/);
|
|
166
|
+
let end = -1;
|
|
167
|
+
for (let index = 1; index < Math.min(lines.length, 200); index += 1) {
|
|
168
|
+
if (lines[index].trim() === "---") {
|
|
169
|
+
end = index;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (end === -1) return {};
|
|
174
|
+
/** @type {{name?: string, description?: string}} */
|
|
175
|
+
const metadata = {};
|
|
176
|
+
for (const line of lines.slice(1, end)) {
|
|
177
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
|
|
178
|
+
if (!match) continue;
|
|
179
|
+
const key = match[1].toLowerCase();
|
|
180
|
+
if (key === "name" || key === "description") metadata[key] = unquoteScalar(match[2].trim());
|
|
181
|
+
}
|
|
182
|
+
return metadata;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {string} directory */
|
|
186
|
+
async function findSkillEntrypoint(directory) {
|
|
187
|
+
for (const name of ["SKILL.md", "skill.md"]) {
|
|
188
|
+
const candidate = join(directory, name);
|
|
189
|
+
const info = await lstat(candidate).catch((error) => isMissing(error) ? null : Promise.reject(error));
|
|
190
|
+
if (!info) continue;
|
|
191
|
+
if (info.isSymbolicLink()) throw new Error(`skill entrypoint must not be a symbolic link: ${candidate}`);
|
|
192
|
+
if (!info.isFile()) throw new Error(`skill entrypoint is not a regular file: ${candidate}`);
|
|
193
|
+
return candidate;
|
|
194
|
+
}
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @param {string} entrypoint @param {string} sourceRoot @returns {Promise<SkillSummary>} */
|
|
199
|
+
async function summarizeSkill(entrypoint, sourceRoot) {
|
|
200
|
+
const content = await readRegularUtf8(entrypoint, MAX_SKILL_ENTRY_BYTES, "skill entrypoint");
|
|
201
|
+
const metadata = parseSkillMetadata(content.text);
|
|
202
|
+
if (!metadata.name || !metadata.description) throw new Error("SKILL.md front matter requires non-empty name and description fields");
|
|
203
|
+
return {
|
|
204
|
+
id: `skill_${sha256(entrypoint).slice(0, 16)}`,
|
|
205
|
+
name: metadata.name.slice(0, 200),
|
|
206
|
+
description: metadata.description.slice(0, 1000),
|
|
207
|
+
entrypoint,
|
|
208
|
+
directory: dirname(entrypoint),
|
|
209
|
+
sourceRoot,
|
|
210
|
+
bytes: content.bytes,
|
|
211
|
+
sha256: sha256(content.text),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @param {unknown} error */
|
|
216
|
+
function boundedMessage(error) {
|
|
217
|
+
const message = error instanceof Error ? error.message : String(error || "invalid local skill");
|
|
218
|
+
return message.replace(/[\r\n]+/g, " ").slice(0, 1000);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {string} value */
|
|
222
|
+
function unquoteScalar(value) {
|
|
223
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) return value.slice(1, -1);
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @param {unknown} error */
|
|
228
|
+
function isMissing(error) {
|
|
229
|
+
return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
|
|
230
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { lstat, open } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
/** @param {string} filePath @param {number} maxBytes @param {string} label */
|
|
6
|
+
export async function readOptionalRegularUtf8(filePath, maxBytes, label) {
|
|
7
|
+
const info = await lstat(filePath).catch((error) => isMissing(error) ? null : Promise.reject(error));
|
|
8
|
+
if (!info) return null;
|
|
9
|
+
if (info.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link: ${filePath}`);
|
|
10
|
+
if (!info.isFile()) throw new Error(`${label} is not a regular file: ${filePath}`);
|
|
11
|
+
return readRegularUtf8(filePath, maxBytes, label);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @param {string} filePath @param {number} maxBytes @param {string} label */
|
|
15
|
+
export async function readRegularUtf8(filePath, maxBytes, label) {
|
|
16
|
+
const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
17
|
+
try {
|
|
18
|
+
const info = await handle.stat();
|
|
19
|
+
if (!info.isFile()) throw new Error(`${label} is not a regular file: ${filePath}`);
|
|
20
|
+
if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
|
|
21
|
+
const buffer = Buffer.alloc(info.size);
|
|
22
|
+
let offset = 0;
|
|
23
|
+
while (offset < buffer.length) {
|
|
24
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
25
|
+
if (!bytesRead) break;
|
|
26
|
+
offset += bytesRead;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return { text: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)), bytes: offset };
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error(`${label} is not valid UTF-8 text: ${filePath}`);
|
|
32
|
+
}
|
|
33
|
+
} finally {
|
|
34
|
+
await handle.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {unknown} error */
|
|
39
|
+
function isMissing(error) {
|
|
40
|
+
return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
|
|
41
|
+
}
|