psyclaw 0.25.1 → 0.26.0
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/README.md +111 -60
- package/{apps → dist/apps}/panel/index.html +50 -28
- package/dist/src/adapters/pi/extension.d.ts +19 -0
- package/dist/src/adapters/pi/extension.js +735 -0
- package/dist/src/adapters/pi/extension.js.map +1 -0
- package/dist/src/adapters/pi/model.d.ts +72 -0
- package/dist/src/adapters/pi/model.js +152 -0
- package/dist/src/adapters/pi/model.js.map +1 -0
- package/dist/src/adapters/pi/resources.d.ts +23 -0
- package/dist/src/adapters/pi/resources.js +54 -0
- package/dist/src/adapters/pi/resources.js.map +1 -0
- package/dist/src/adapters/pi/rpc.d.ts +58 -0
- package/dist/src/adapters/pi/rpc.js +253 -0
- package/dist/src/adapters/pi/rpc.js.map +1 -0
- package/dist/src/adapters/pi/session.d.ts +30 -0
- package/dist/src/adapters/pi/session.js +64 -0
- package/dist/src/adapters/pi/session.js.map +1 -0
- package/dist/src/agents/catalog.d.ts +30 -0
- package/dist/src/agents/catalog.js +118 -0
- package/dist/src/agents/catalog.js.map +1 -0
- package/dist/src/agents/discover.d.ts +28 -0
- package/dist/src/agents/discover.js +127 -0
- package/dist/src/agents/discover.js.map +1 -0
- package/dist/src/agents/import.d.ts +67 -0
- package/dist/src/agents/import.js +789 -0
- package/dist/src/agents/import.js.map +1 -0
- package/dist/src/analysis/hooks.d.ts +81 -0
- package/dist/src/analysis/hooks.js +139 -0
- package/dist/src/analysis/hooks.js.map +1 -0
- package/dist/src/analysis/pre-registration.d.ts +18 -0
- package/dist/src/analysis/pre-registration.js +86 -0
- package/dist/src/analysis/pre-registration.js.map +1 -0
- package/dist/src/branding.d.ts +44 -0
- package/dist/src/branding.js +128 -0
- package/dist/src/branding.js.map +1 -0
- package/dist/src/chat.d.ts +16 -0
- package/dist/src/chat.js +95 -0
- package/dist/src/chat.js.map +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +316 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/core/citations.d.ts +44 -0
- package/dist/src/core/citations.js +48 -0
- package/dist/src/core/citations.js.map +1 -0
- package/dist/src/core/contracts.d.ts +119 -0
- package/dist/src/core/contracts.js +2 -0
- package/dist/src/core/contracts.js.map +1 -0
- package/dist/src/core/docx.d.ts +11 -0
- package/dist/src/core/docx.js +46 -0
- package/dist/src/core/docx.js.map +1 -0
- package/dist/src/core/doi.d.ts +41 -0
- package/dist/src/core/doi.js +82 -0
- package/dist/src/core/doi.js.map +1 -0
- package/dist/src/core/evidence-policy.d.ts +24 -0
- package/dist/src/core/evidence-policy.js +308 -0
- package/dist/src/core/evidence-policy.js.map +1 -0
- package/dist/src/core/filekind.d.ts +15 -0
- package/dist/src/core/filekind.js +82 -0
- package/dist/src/core/filekind.js.map +1 -0
- package/dist/src/core/hash.d.ts +2 -0
- package/dist/src/core/hash.js +17 -0
- package/dist/src/core/hash.js.map +1 -0
- package/dist/src/core/pricing.d.ts +38 -0
- package/dist/src/core/pricing.js +110 -0
- package/dist/src/core/pricing.js.map +1 -0
- package/dist/src/core/redact.d.ts +12 -0
- package/dist/src/core/redact.js +48 -0
- package/dist/src/core/redact.js.map +1 -0
- package/dist/src/core/references.d.ts +55 -0
- package/dist/src/core/references.js +102 -0
- package/dist/src/core/references.js.map +1 -0
- package/dist/src/core/schemas.d.ts +162 -0
- package/dist/src/core/schemas.js +160 -0
- package/dist/src/core/schemas.js.map +1 -0
- package/dist/src/extension.d.ts +1 -0
- package/dist/src/extension.js +2 -0
- package/dist/src/extension.js.map +1 -0
- package/dist/src/index.d.ts +72 -0
- package/dist/src/index.js +73 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/install/installer.d.ts +147 -0
- package/dist/src/install/installer.js +721 -0
- package/dist/src/install/installer.js.map +1 -0
- package/dist/src/integrations/contracts.d.ts +35 -0
- package/dist/src/integrations/contracts.js +27 -0
- package/dist/src/integrations/contracts.js.map +1 -0
- package/dist/src/integrations/mcp.d.ts +155 -0
- package/dist/src/integrations/mcp.js +826 -0
- package/dist/src/integrations/mcp.js.map +1 -0
- package/dist/src/knowledge-map/contracts.d.ts +20 -0
- package/dist/src/knowledge-map/contracts.js +2 -0
- package/dist/src/knowledge-map/contracts.js.map +1 -0
- package/dist/src/knowledge-map/index.d.ts +4 -0
- package/dist/src/knowledge-map/index.js +39 -0
- package/dist/src/knowledge-map/index.js.map +1 -0
- package/dist/src/literature/contracts.d.ts +106 -0
- package/dist/src/literature/contracts.js +2 -0
- package/dist/src/literature/contracts.js.map +1 -0
- package/dist/src/literature/fulltext.d.ts +7 -0
- package/dist/src/literature/fulltext.js +24 -0
- package/dist/src/literature/fulltext.js.map +1 -0
- package/dist/src/literature/knowledge-map.d.ts +2 -0
- package/dist/src/literature/knowledge-map.js +20 -0
- package/dist/src/literature/knowledge-map.js.map +1 -0
- package/dist/src/literature/ledger.d.ts +3 -0
- package/dist/src/literature/ledger.js +17 -0
- package/dist/src/literature/ledger.js.map +1 -0
- package/dist/src/literature/metadata.d.ts +6 -0
- package/dist/src/literature/metadata.js +32 -0
- package/dist/src/literature/metadata.js.map +1 -0
- package/dist/src/memory/store.d.ts +22 -0
- package/dist/src/memory/store.js +73 -0
- package/dist/src/memory/store.js.map +1 -0
- package/dist/src/orchestration/contracts.d.ts +61 -0
- package/dist/src/orchestration/contracts.js +2 -0
- package/dist/src/orchestration/contracts.js.map +1 -0
- package/dist/src/orchestration/pi-executor.d.ts +28 -0
- package/dist/src/orchestration/pi-executor.js +169 -0
- package/dist/src/orchestration/pi-executor.js.map +1 -0
- package/dist/src/orchestration/runner.d.ts +196 -0
- package/dist/src/orchestration/runner.js +991 -0
- package/dist/src/orchestration/runner.js.map +1 -0
- package/dist/src/orchestration/scheduler.d.ts +8 -0
- package/dist/src/orchestration/scheduler.js +335 -0
- package/dist/src/orchestration/scheduler.js.map +1 -0
- package/dist/src/panel/events.d.ts +20 -0
- package/dist/src/panel/events.js +93 -0
- package/dist/src/panel/events.js.map +1 -0
- package/dist/src/panel/extension.d.ts +8 -0
- package/dist/src/panel/extension.js +115 -0
- package/dist/src/panel/extension.js.map +1 -0
- package/dist/src/panel/literature-map.d.ts +26 -0
- package/dist/src/panel/literature-map.js +34 -0
- package/dist/src/panel/literature-map.js.map +1 -0
- package/dist/src/panel/projection.d.ts +51 -0
- package/dist/src/panel/projection.js +288 -0
- package/dist/src/panel/projection.js.map +1 -0
- package/dist/src/panel/server.d.ts +46 -0
- package/dist/src/panel/server.js +1610 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/usage.d.ts +53 -0
- package/dist/src/panel/usage.js +150 -0
- package/dist/src/panel/usage.js.map +1 -0
- package/dist/src/project/bootstrap.d.ts +10 -0
- package/dist/src/project/bootstrap.js +83 -0
- package/dist/src/project/bootstrap.js.map +1 -0
- package/dist/src/project/hitl.d.ts +12 -0
- package/dist/src/project/hitl.js +43 -0
- package/dist/src/project/hitl.js.map +1 -0
- package/dist/src/project/jsonl.d.ts +16 -0
- package/dist/src/project/jsonl.js +93 -0
- package/dist/src/project/jsonl.js.map +1 -0
- package/dist/src/project/manuscript.d.ts +21 -0
- package/dist/src/project/manuscript.js +74 -0
- package/dist/src/project/manuscript.js.map +1 -0
- package/dist/src/project/paths.d.ts +38 -0
- package/dist/src/project/paths.js +166 -0
- package/dist/src/project/paths.js.map +1 -0
- package/dist/src/project/versions.d.ts +10 -0
- package/dist/src/project/versions.js +14 -0
- package/dist/src/project/versions.js.map +1 -0
- package/dist/src/psyclaw-theme.d.ts +88 -0
- package/dist/src/psyclaw-theme.js +89 -0
- package/dist/src/psyclaw-theme.js.map +1 -0
- package/dist/src/research/brief.d.ts +11 -0
- package/dist/src/research/brief.js +81 -0
- package/dist/src/research/brief.js.map +1 -0
- package/dist/src/research/ledger.d.ts +12 -0
- package/dist/src/research/ledger.js +43 -0
- package/dist/src/research/ledger.js.map +1 -0
- package/dist/src/run.d.ts +20 -0
- package/dist/src/run.js +34 -0
- package/dist/src/run.js.map +1 -0
- package/dist/src/setup.d.ts +54 -0
- package/dist/src/setup.js +221 -0
- package/dist/src/setup.js.map +1 -0
- package/dist/src/skills/contracts.d.ts +105 -0
- package/dist/src/skills/contracts.js +3 -0
- package/dist/src/skills/contracts.js.map +1 -0
- package/dist/src/skills/preflight.d.ts +10 -0
- package/dist/src/skills/preflight.js +39 -0
- package/dist/src/skills/preflight.js.map +1 -0
- package/dist/src/skills/registry.d.ts +28 -0
- package/dist/src/skills/registry.js +540 -0
- package/dist/src/skills/registry.js.map +1 -0
- package/dist/src/style/artifact.d.ts +6 -0
- package/dist/src/style/artifact.js +61 -0
- package/dist/src/style/artifact.js.map +1 -0
- package/dist/src/style/cli-ui.d.ts +41 -0
- package/dist/src/style/cli-ui.js +119 -0
- package/dist/src/style/cli-ui.js.map +1 -0
- package/dist/src/style/contracts.d.ts +34 -0
- package/dist/src/style/contracts.js +2 -0
- package/dist/src/style/contracts.js.map +1 -0
- package/dist/src/style/registry.d.ts +16 -0
- package/dist/src/style/registry.js +50 -0
- package/dist/src/style/registry.js.map +1 -0
- package/dist/src/trust/manifest.d.ts +42 -0
- package/dist/src/trust/manifest.js +53 -0
- package/dist/src/trust/manifest.js.map +1 -0
- package/dist/src/trust/sbom.d.ts +54 -0
- package/dist/src/trust/sbom.js +77 -0
- package/dist/src/trust/sbom.js.map +1 -0
- package/dist/src/tui/app.d.ts +3 -0
- package/dist/src/tui/app.js +103 -0
- package/dist/src/tui/app.js.map +1 -0
- package/dist/src/tui/model.d.ts +13 -0
- package/dist/src/tui/model.js +25 -0
- package/dist/src/tui/model.js.map +1 -0
- package/dist/src/updates/check.d.ts +43 -0
- package/dist/src/updates/check.js +68 -0
- package/dist/src/updates/check.js.map +1 -0
- package/dist/src/updates/manifest.d.ts +19 -0
- package/dist/src/updates/manifest.js +58 -0
- package/dist/src/updates/manifest.js.map +1 -0
- package/dist/src/updates/registry.d.ts +21 -0
- package/dist/src/updates/registry.js +82 -0
- package/dist/src/updates/registry.js.map +1 -0
- package/dist/src/updates/skills.d.ts +15 -0
- package/dist/src/updates/skills.js +39 -0
- package/dist/src/updates/skills.js.map +1 -0
- package/dist/src/updates/status.d.ts +7 -0
- package/dist/src/updates/status.js +37 -0
- package/dist/src/updates/status.js.map +1 -0
- package/dist/src/updates/update.d.ts +43 -0
- package/dist/src/updates/update.js +128 -0
- package/dist/src/updates/update.js.map +1 -0
- package/dist/src/wizard.d.ts +7 -0
- package/dist/src/wizard.js +90 -0
- package/dist/src/wizard.js.map +1 -0
- package/dist/src/workflows/analysis-delegation.d.ts +3 -0
- package/dist/src/workflows/analysis-delegation.js +96 -0
- package/dist/src/workflows/analysis-delegation.js.map +1 -0
- package/dist/src/workflows/expert-review-runner.d.ts +29 -0
- package/dist/src/workflows/expert-review-runner.js +35 -0
- package/dist/src/workflows/expert-review-runner.js.map +1 -0
- package/dist/src/workflows/expert-review.d.ts +140 -0
- package/dist/src/workflows/expert-review.js +111 -0
- package/dist/src/workflows/expert-review.js.map +1 -0
- package/dist/src/workflows/institutional-fulltext.d.ts +16 -0
- package/dist/src/workflows/institutional-fulltext.js +110 -0
- package/dist/src/workflows/institutional-fulltext.js.map +1 -0
- package/dist/src/workflows/literature-review.d.ts +3 -0
- package/dist/src/workflows/literature-review.js +76 -0
- package/dist/src/workflows/literature-review.js.map +1 -0
- package/dist/src/workflows/meta-analysis.d.ts +38 -0
- package/dist/src/workflows/meta-analysis.js +185 -0
- package/dist/src/workflows/meta-analysis.js.map +1 -0
- package/dist/src/workflows/publish.d.ts +51 -0
- package/dist/src/workflows/publish.js +153 -0
- package/dist/src/workflows/publish.js.map +1 -0
- package/dist/src/workflows/spec.d.ts +51 -0
- package/dist/src/workflows/spec.js +157 -0
- package/dist/src/workflows/spec.js.map +1 -0
- package/dist/src/workflows/style-contract.d.ts +24 -0
- package/dist/src/workflows/style-contract.js +23 -0
- package/dist/src/workflows/style-contract.js.map +1 -0
- package/dist/src/workflows/writing-review.d.ts +15 -0
- package/dist/src/workflows/writing-review.js +82 -0
- package/dist/src/workflows/writing-review.js.map +1 -0
- package/package.json +20 -8
- package/scripts/rebrand-pi.mjs +6 -5
- package/scripts/__pycache__/build_dist.cpython-314.pyc +0 -0
- package/scripts/__pycache__/build_whitepaper_docx.cpython-314.pyc +0 -0
- package/scripts/build_preprint_docx.py +0 -729
- package/scripts/build_preprint_figures.py +0 -238
- package/scripts/build_three_line_table_fixture.py +0 -56
- package/scripts/build_whitepaper_docx.py +0 -768
- package/scripts/check-branding.mjs +0 -159
- package/scripts/copy-panel.mjs +0 -11
- package/scripts/verify_docx.py +0 -41
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { redactSecrets } from "../core/redact.js";
|
|
6
|
+
import { sha256Text } from "../core/hash.js";
|
|
7
|
+
const IMPORT_SCHEMA_VERSION = "psyclaw/skill-import/v1";
|
|
8
|
+
const IMPORT_RECEIPT_TOOL = "agents.import";
|
|
9
|
+
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
const SHA256_RE = /^[a-f0-9]{64}$/i;
|
|
11
|
+
const ALLOWED_DIRECTORY_ROOTS = new Set(["references", "scripts", "assets"]);
|
|
12
|
+
const TEXT_EXTENSIONS = new Set([
|
|
13
|
+
".cjs", ".css", ".csv", ".html", ".js", ".json", ".md", ".mjs", ".py", ".r", ".rs", ".sh", ".sql",
|
|
14
|
+
".toml", ".ts", ".tsx", ".txt", ".xml", ".yaml", ".yml",
|
|
15
|
+
]);
|
|
16
|
+
const IMAGE_SIGNATURES = [
|
|
17
|
+
(bytes) => bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
|
|
18
|
+
(bytes) => bytes.length >= 3 && bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])),
|
|
19
|
+
(bytes) => bytes.length >= 6 && (bytes.subarray(0, 6).toString("ascii") === "GIF87a" || bytes.subarray(0, 6).toString("ascii") === "GIF89a"),
|
|
20
|
+
(bytes) => bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP",
|
|
21
|
+
];
|
|
22
|
+
/** Stable, non-sensitive import failure. Callers can inspect `code` without parsing prose. */
|
|
23
|
+
export class SkillImportError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
constructor(code, message = code) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.name = "SkillImportError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function fail(code, message = code) {
|
|
32
|
+
throw new SkillImportError(code, message);
|
|
33
|
+
}
|
|
34
|
+
function samePath(left, right) {
|
|
35
|
+
const normalizedLeft = resolve(left).replaceAll("\\", "/");
|
|
36
|
+
const normalizedRight = resolve(right).replaceAll("\\", "/");
|
|
37
|
+
return process.platform === "win32"
|
|
38
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
39
|
+
: normalizedLeft === normalizedRight;
|
|
40
|
+
}
|
|
41
|
+
function isWithin(base, candidate) {
|
|
42
|
+
const root = resolve(base).replaceAll("\\", "/");
|
|
43
|
+
const target = resolve(candidate).replaceAll("\\", "/");
|
|
44
|
+
const left = process.platform === "win32" ? root.toLowerCase() : root;
|
|
45
|
+
const right = process.platform === "win32" ? target.toLowerCase() : target;
|
|
46
|
+
return right !== left && right.startsWith(`${left}/`);
|
|
47
|
+
}
|
|
48
|
+
function isCrossPlatformAbsolute(value) {
|
|
49
|
+
const normalized = value.replaceAll("\\", "/");
|
|
50
|
+
return normalized.startsWith("/") || /^\\\\/.test(value) || /^[A-Za-z]:/.test(value);
|
|
51
|
+
}
|
|
52
|
+
function isReservedDeviceName(value) {
|
|
53
|
+
const stem = value.split(".", 1)[0]?.toLowerCase() ?? value.toLowerCase();
|
|
54
|
+
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/.test(stem);
|
|
55
|
+
}
|
|
56
|
+
function isSensitiveSegment(value) {
|
|
57
|
+
const lower = value.toLowerCase();
|
|
58
|
+
return lower === ".env" || lower.startsWith(".env.") ||
|
|
59
|
+
/(?:^|[._-])(credentials?|secrets?|tokens?|cookies?|auth|passwords?|passwd|pwd|api[-_]?key|access[-_]?key|private)(?:[._-]|$)/.test(lower) ||
|
|
60
|
+
lower.endsWith(".pem") ||
|
|
61
|
+
lower.endsWith(".key") || lower.endsWith(".p12") || lower.endsWith(".pfx") ||
|
|
62
|
+
lower === ".npmrc" || lower === ".netrc" || lower === "id_rsa" || lower === "id_ed25519";
|
|
63
|
+
}
|
|
64
|
+
function isProtectedSegment(value) {
|
|
65
|
+
const lower = value.toLowerCase();
|
|
66
|
+
return lower === ".git" || lower === "data" || lower === "raw";
|
|
67
|
+
}
|
|
68
|
+
function assertSafeSegment(value, label) {
|
|
69
|
+
if (value.length === 0 || value.length > 128 || value.trim() !== value || value === "." || value === ".." ||
|
|
70
|
+
value.includes("/") || value.includes("\\") || value.includes(":") || /[\u0000-\u001f\u007f]/u.test(value) ||
|
|
71
|
+
/[<>|?*\"]/.test(value) || /[. ]$/.test(value) || isReservedDeviceName(value) || isSensitiveSegment(value)) {
|
|
72
|
+
fail("import.path-invalid", `${label} is not an allowed path segment`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function boundedDisplay(value, fallback) {
|
|
76
|
+
const cleaned = redactSecrets(value).replace(/[\u0000-\u001f\u007f]/gu, " ").trim();
|
|
77
|
+
if (cleaned.length === 0)
|
|
78
|
+
return fallback;
|
|
79
|
+
return cleaned.slice(0, 512);
|
|
80
|
+
}
|
|
81
|
+
function fingerprint(stat) {
|
|
82
|
+
return { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, ino: stat.ino, dev: stat.dev, mode: stat.mode };
|
|
83
|
+
}
|
|
84
|
+
function sameFingerprint(left, right) {
|
|
85
|
+
return left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs &&
|
|
86
|
+
left.ino === right.ino && left.dev === right.dev && left.mode === right.mode;
|
|
87
|
+
}
|
|
88
|
+
function hashBytes(bytes) {
|
|
89
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
90
|
+
}
|
|
91
|
+
async function assertNoSymlinkAncestors(path) {
|
|
92
|
+
const absolute = resolve(path);
|
|
93
|
+
const root = parse(absolute).root;
|
|
94
|
+
const parts = relative(root, absolute).split(sep).filter(Boolean);
|
|
95
|
+
let current = root;
|
|
96
|
+
for (const part of parts) {
|
|
97
|
+
current = join(current, part);
|
|
98
|
+
try {
|
|
99
|
+
const stat = await lstat(current);
|
|
100
|
+
if (stat.isSymbolicLink())
|
|
101
|
+
fail("import.symlink-path", "symbolic-link path is not importable");
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof SkillImportError)
|
|
105
|
+
throw error;
|
|
106
|
+
if (error.code === "ENOENT")
|
|
107
|
+
break;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function assertProjectTarget(root, target) {
|
|
113
|
+
const base = resolve(root);
|
|
114
|
+
const absolute = resolve(target);
|
|
115
|
+
if (!isWithin(base, absolute))
|
|
116
|
+
fail("import.target-containment");
|
|
117
|
+
const rel = relative(base, absolute).replaceAll("\\", "/");
|
|
118
|
+
const parts = rel.split("/").filter(Boolean);
|
|
119
|
+
const lowerRel = parts.join("/").toLowerCase();
|
|
120
|
+
if (parts.length === 0 || parts.some((part) => part === "." || part === ".." || isSensitiveSegment(part)) ||
|
|
121
|
+
lowerRel === "data/raw" || lowerRel.startsWith("data/raw/")) {
|
|
122
|
+
fail("import.target-protected");
|
|
123
|
+
}
|
|
124
|
+
if (parts.some((part) => isProtectedSegment(part))) {
|
|
125
|
+
fail("import.target-protected");
|
|
126
|
+
}
|
|
127
|
+
await assertNoSymlinkAncestors(base);
|
|
128
|
+
await assertNoSymlinkAncestors(absolute);
|
|
129
|
+
return absolute;
|
|
130
|
+
}
|
|
131
|
+
async function ensureDirectoryChain(root, target, created = []) {
|
|
132
|
+
const base = resolve(root);
|
|
133
|
+
const absolute = resolve(target);
|
|
134
|
+
if (!samePath(base, root) || !isWithin(base, absolute) && !samePath(base, absolute))
|
|
135
|
+
fail("import.target-containment");
|
|
136
|
+
const parts = relative(base, absolute).split(sep).filter(Boolean);
|
|
137
|
+
let current = base;
|
|
138
|
+
for (const part of parts) {
|
|
139
|
+
current = join(current, part);
|
|
140
|
+
await assertNoSymlinkAncestors(current);
|
|
141
|
+
try {
|
|
142
|
+
const stat = await lstat(current);
|
|
143
|
+
if (stat.isSymbolicLink())
|
|
144
|
+
fail("import.symlink-target");
|
|
145
|
+
if (!stat.isDirectory())
|
|
146
|
+
fail("import.target-not-directory");
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code !== "ENOENT")
|
|
150
|
+
throw error;
|
|
151
|
+
try {
|
|
152
|
+
await mkdir(current, { recursive: false });
|
|
153
|
+
created.push(current);
|
|
154
|
+
}
|
|
155
|
+
catch (mkdirError) {
|
|
156
|
+
if (mkdirError.code !== "EEXIST")
|
|
157
|
+
throw mkdirError;
|
|
158
|
+
const stat = await lstat(current);
|
|
159
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
160
|
+
fail("import.target-race");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function stableSourceFile(sourcePath, relativePath) {
|
|
166
|
+
await assertNoSymlinkAncestors(sourcePath);
|
|
167
|
+
let before;
|
|
168
|
+
try {
|
|
169
|
+
before = await lstat(sourcePath);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
fail("import.source-unavailable");
|
|
173
|
+
}
|
|
174
|
+
if (before.isSymbolicLink() || !before.isFile())
|
|
175
|
+
fail("import.source-not-regular");
|
|
176
|
+
if (before.size > MAX_FILE_BYTES)
|
|
177
|
+
fail("import.source-too-large");
|
|
178
|
+
const resolvedBefore = await realpath(sourcePath).catch(() => fail("import.source-unavailable"));
|
|
179
|
+
if (!samePath(resolvedBefore, sourcePath))
|
|
180
|
+
fail("import.source-canonical");
|
|
181
|
+
const bytes = await readFile(sourcePath).catch(() => fail("import.source-unavailable"));
|
|
182
|
+
const digest = hashBytes(bytes);
|
|
183
|
+
const after = await lstat(sourcePath).catch(() => fail("import.source-race"));
|
|
184
|
+
const resolvedAfter = await realpath(sourcePath).catch(() => fail("import.source-race"));
|
|
185
|
+
if (after.isSymbolicLink() || !after.isFile() || !samePath(resolvedBefore, resolvedAfter) ||
|
|
186
|
+
!sameFingerprint(fingerprint(before), fingerprint(after)) || bytes.length !== after.size) {
|
|
187
|
+
fail("import.source-race");
|
|
188
|
+
}
|
|
189
|
+
assertAllowedContent(relativePath, bytes);
|
|
190
|
+
return {
|
|
191
|
+
sourcePath: resolve(sourcePath),
|
|
192
|
+
resolvedSourcePath: resolvedAfter,
|
|
193
|
+
relativePath,
|
|
194
|
+
sha256: digest,
|
|
195
|
+
bytes,
|
|
196
|
+
stat: fingerprint(after),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function assertAllowedContent(relativePath, bytes) {
|
|
200
|
+
const lower = relativePath.toLowerCase();
|
|
201
|
+
const extension = extname(lower);
|
|
202
|
+
if (bytes.length === 0) {
|
|
203
|
+
if (TEXT_EXTENSIONS.has(extension))
|
|
204
|
+
return;
|
|
205
|
+
fail("import.file-type-disallowed");
|
|
206
|
+
}
|
|
207
|
+
const header = bytes.subarray(0, 16);
|
|
208
|
+
const executableMagic = header.subarray(0, 2).toString("ascii") === "MZ" ||
|
|
209
|
+
header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) ||
|
|
210
|
+
header.subarray(0, 4).equals(Buffer.from([0xfe, 0xed, 0xfa, 0xce])) ||
|
|
211
|
+
header.subarray(0, 4).equals(Buffer.from([0xce, 0xfa, 0xed, 0xfe])) ||
|
|
212
|
+
header.subarray(0, 4).equals(Buffer.from([0xca, 0xfe, 0xba, 0xbe]));
|
|
213
|
+
const knownImage = [".gif", ".jpeg", ".jpg", ".png", ".webp"].includes(extension) &&
|
|
214
|
+
IMAGE_SIGNATURES.some((check) => check(bytes));
|
|
215
|
+
if (executableMagic || (!knownImage && bytes.includes(0)))
|
|
216
|
+
fail("import.suspicious-binary");
|
|
217
|
+
if (TEXT_EXTENSIONS.has(extension) || extension === "") {
|
|
218
|
+
try {
|
|
219
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
fail("import.suspicious-binary");
|
|
223
|
+
}
|
|
224
|
+
// UTF-8 alone is not enough: arbitrary binary can be valid UTF-8. Reject
|
|
225
|
+
// control bytes while retaining normal tabs/newlines in text scripts.
|
|
226
|
+
for (const byte of bytes) {
|
|
227
|
+
if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d)
|
|
228
|
+
fail("import.suspicious-binary");
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (knownImage)
|
|
233
|
+
return;
|
|
234
|
+
fail("import.file-type-disallowed");
|
|
235
|
+
}
|
|
236
|
+
function assertAllowedRelativePath(parts, isDirectory) {
|
|
237
|
+
for (const part of parts) {
|
|
238
|
+
assertSafeSegment(part, "source path");
|
|
239
|
+
if (part.toLowerCase() === ".git")
|
|
240
|
+
fail("import.file-not-allowlisted");
|
|
241
|
+
}
|
|
242
|
+
if (parts.length === 0)
|
|
243
|
+
return;
|
|
244
|
+
const first = parts[0].toLowerCase();
|
|
245
|
+
if (parts.length === 1) {
|
|
246
|
+
if (isDirectory) {
|
|
247
|
+
if (!ALLOWED_DIRECTORY_ROOTS.has(first))
|
|
248
|
+
fail("import.file-not-allowlisted");
|
|
249
|
+
}
|
|
250
|
+
else if (parts[0] !== "SKILL.md" && !parts[0].toLowerCase().endsWith(".md")) {
|
|
251
|
+
fail("import.file-not-allowlisted");
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!ALLOWED_DIRECTORY_ROOTS.has(first))
|
|
256
|
+
fail("import.file-not-allowlisted");
|
|
257
|
+
}
|
|
258
|
+
async function collectSourceTree(skill) {
|
|
259
|
+
const sourcePath = resolve(skill.path);
|
|
260
|
+
if (isCrossPlatformAbsolute(skill.path.replaceAll("\\", "/")) === false)
|
|
261
|
+
fail("import.source-path-invalid");
|
|
262
|
+
await assertNoSymlinkAncestors(sourcePath);
|
|
263
|
+
const rootStat = await lstat(sourcePath).catch(() => fail("import.source-unavailable"));
|
|
264
|
+
if (rootStat.isSymbolicLink())
|
|
265
|
+
fail("import.source-symlink");
|
|
266
|
+
const rootKind = skill.kind === "dir" ? rootStat.isDirectory() : rootStat.isFile();
|
|
267
|
+
if (!rootKind)
|
|
268
|
+
fail("import.source-kind-mismatch");
|
|
269
|
+
const resolvedRootPath = await realpath(sourcePath).catch(() => fail("import.source-unavailable"));
|
|
270
|
+
if (!samePath(resolvedRootPath, sourcePath))
|
|
271
|
+
fail("import.source-canonical");
|
|
272
|
+
const files = [];
|
|
273
|
+
if (skill.kind === "file") {
|
|
274
|
+
const sourceName = basename(sourcePath);
|
|
275
|
+
if (!sourceName.toLowerCase().endsWith(".md"))
|
|
276
|
+
fail("import.file-not-allowlisted");
|
|
277
|
+
assertAllowedRelativePath([sourceName], false);
|
|
278
|
+
files.push(await stableSourceFile(sourcePath, sourceName));
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
let skillFileFound = false;
|
|
282
|
+
const walk = async (directory, parts) => {
|
|
283
|
+
await assertNoSymlinkAncestors(directory);
|
|
284
|
+
const directoryStat = await lstat(directory).catch(() => fail("import.source-race"));
|
|
285
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory())
|
|
286
|
+
fail("import.source-not-directory");
|
|
287
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => fail("import.source-unavailable"));
|
|
288
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
assertSafeSegment(entry.name, "source path");
|
|
291
|
+
const childParts = [...parts, entry.name];
|
|
292
|
+
const child = join(directory, entry.name);
|
|
293
|
+
if (entry.isSymbolicLink())
|
|
294
|
+
fail("import.source-symlink");
|
|
295
|
+
if (entry.isDirectory()) {
|
|
296
|
+
assertAllowedRelativePath(childParts, true);
|
|
297
|
+
await walk(child, childParts);
|
|
298
|
+
}
|
|
299
|
+
else if (entry.isFile()) {
|
|
300
|
+
assertAllowedRelativePath(childParts, false);
|
|
301
|
+
if (childParts.length === 1 && entry.name === "SKILL.md")
|
|
302
|
+
skillFileFound = true;
|
|
303
|
+
files.push(await stableSourceFile(child, childParts.join("/")));
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
fail("import.source-not-regular");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
await walk(sourcePath, []);
|
|
311
|
+
if (!skillFileFound)
|
|
312
|
+
fail("import.skill-entrypoint-missing");
|
|
313
|
+
}
|
|
314
|
+
const afterRoot = await lstat(sourcePath).catch(() => fail("import.source-race"));
|
|
315
|
+
const afterResolved = await realpath(sourcePath).catch(() => fail("import.source-race"));
|
|
316
|
+
if (afterRoot.isSymbolicLink() || !samePath(resolvedRootPath, afterResolved) || !sameFingerprint(fingerprint(rootStat), fingerprint(afterRoot))) {
|
|
317
|
+
fail("import.source-race");
|
|
318
|
+
}
|
|
319
|
+
files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
320
|
+
return { rootPath: sourcePath, resolvedRootPath, files };
|
|
321
|
+
}
|
|
322
|
+
async function verifySourceStable(before, skill) {
|
|
323
|
+
const after = await collectSourceTree(skill);
|
|
324
|
+
if (!samePath(before.resolvedRootPath, after.resolvedRootPath) || before.files.length !== after.files.length)
|
|
325
|
+
fail("import.source-race");
|
|
326
|
+
for (let index = 0; index < before.files.length; index += 1) {
|
|
327
|
+
const left = before.files[index];
|
|
328
|
+
const right = after.files[index];
|
|
329
|
+
if (left.relativePath !== right.relativePath || left.sha256 !== right.sha256 || !sameFingerprint(left.stat, right.stat) ||
|
|
330
|
+
!samePath(left.resolvedSourcePath, right.resolvedSourcePath))
|
|
331
|
+
fail("import.source-race");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async function declaredSourceRoot(agent, sourcePath) {
|
|
335
|
+
if (agent.skillDirs.length === 0)
|
|
336
|
+
fail("import.source-root-missing");
|
|
337
|
+
const sourceResolved = await realpath(sourcePath).catch(() => fail("import.source-unavailable"));
|
|
338
|
+
let matched = false;
|
|
339
|
+
for (const candidate of agent.skillDirs) {
|
|
340
|
+
if (!isAbsolute(candidate) || isCrossPlatformAbsolute(candidate.replaceAll("\\", "/")) === false)
|
|
341
|
+
continue;
|
|
342
|
+
await assertNoSymlinkAncestors(candidate);
|
|
343
|
+
const stat = await lstat(candidate).catch(() => undefined);
|
|
344
|
+
if (stat === undefined || stat.isSymbolicLink() || !stat.isDirectory())
|
|
345
|
+
continue;
|
|
346
|
+
const resolved = await realpath(candidate).catch(() => undefined);
|
|
347
|
+
if (resolved !== undefined && (samePath(resolved, sourceResolved) || isWithin(resolved, sourceResolved)))
|
|
348
|
+
matched = true;
|
|
349
|
+
}
|
|
350
|
+
if (!matched)
|
|
351
|
+
fail("import.source-root-mismatch");
|
|
352
|
+
}
|
|
353
|
+
function sourceNameMatches(skill) {
|
|
354
|
+
if (typeof skill !== "object" || skill === null || typeof skill.name !== "string" || typeof skill.path !== "string") {
|
|
355
|
+
fail("import.agent-scan-invalid");
|
|
356
|
+
}
|
|
357
|
+
if (skill.kind !== "dir" && skill.kind !== "file")
|
|
358
|
+
fail("import.agent-scan-invalid");
|
|
359
|
+
assertSafeSegment(skill.name, "skill name");
|
|
360
|
+
const sourceName = basename(resolve(skill.path));
|
|
361
|
+
const expected = skill.kind === "file" ? sourceName.replace(/\.md$/i, "") : sourceName;
|
|
362
|
+
if (expected !== skill.name)
|
|
363
|
+
fail("import.skill-name-mismatch");
|
|
364
|
+
}
|
|
365
|
+
function validateAgentScan(agent) {
|
|
366
|
+
if (typeof agent !== "object" || agent === null || typeof agent.id !== "string" || typeof agent.name !== "string" ||
|
|
367
|
+
!Array.isArray(agent.skills) || !Array.isArray(agent.skillDirs))
|
|
368
|
+
fail("import.agent-scan-invalid");
|
|
369
|
+
for (const skillDir of agent.skillDirs) {
|
|
370
|
+
if (typeof skillDir !== "string")
|
|
371
|
+
fail("import.agent-scan-invalid");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function inspectDestination(destinationPath, source, kind) {
|
|
375
|
+
let target;
|
|
376
|
+
try {
|
|
377
|
+
target = await lstat(destinationPath);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
if (error.code === "ENOENT")
|
|
381
|
+
return "new";
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
if (target.isSymbolicLink())
|
|
385
|
+
fail("import.target-symlink");
|
|
386
|
+
if (kind === "file") {
|
|
387
|
+
if (!target.isFile())
|
|
388
|
+
fail("import.target-kind-conflict");
|
|
389
|
+
const digest = await hashRegularFile(destinationPath);
|
|
390
|
+
return digest === source.files[0]?.sha256 ? "already-recorded" : fail("import.target-conflict");
|
|
391
|
+
}
|
|
392
|
+
if (!target.isDirectory())
|
|
393
|
+
fail("import.target-kind-conflict");
|
|
394
|
+
const actual = await collectDestinationFiles(destinationPath);
|
|
395
|
+
if (actual.length !== source.files.length)
|
|
396
|
+
fail("import.target-conflict");
|
|
397
|
+
for (let index = 0; index < source.files.length; index += 1) {
|
|
398
|
+
const expected = source.files[index];
|
|
399
|
+
const found = actual[index];
|
|
400
|
+
if (expected.relativePath !== found.relativePath || expected.sha256 !== found.sha256)
|
|
401
|
+
fail("import.target-conflict");
|
|
402
|
+
}
|
|
403
|
+
return "already-recorded";
|
|
404
|
+
}
|
|
405
|
+
async function hashRegularFile(path) {
|
|
406
|
+
const stat = await lstat(path).catch(() => fail("import.target-unavailable"));
|
|
407
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
408
|
+
fail("import.target-not-regular");
|
|
409
|
+
const resolvedBefore = await realpath(path).catch(() => fail("import.target-unavailable"));
|
|
410
|
+
const bytes = await readFile(path).catch(() => fail("import.target-unavailable"));
|
|
411
|
+
const digest = hashBytes(bytes);
|
|
412
|
+
const after = await lstat(path).catch(() => fail("import.target-race"));
|
|
413
|
+
const resolvedAfter = await realpath(path).catch(() => fail("import.target-race"));
|
|
414
|
+
if (after.isSymbolicLink() || !after.isFile() || !samePath(resolvedBefore, resolvedAfter) ||
|
|
415
|
+
!sameFingerprint(fingerprint(stat), fingerprint(after)) || after.size !== bytes.length)
|
|
416
|
+
fail("import.target-race");
|
|
417
|
+
return digest;
|
|
418
|
+
}
|
|
419
|
+
async function collectDestinationFiles(root) {
|
|
420
|
+
const files = [];
|
|
421
|
+
const walk = async (directory, parts) => {
|
|
422
|
+
await assertNoSymlinkAncestors(directory);
|
|
423
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => fail("import.target-unavailable"));
|
|
424
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
assertSafeSegment(entry.name, "target path");
|
|
427
|
+
if (entry.isSymbolicLink())
|
|
428
|
+
fail("import.target-symlink");
|
|
429
|
+
const child = join(directory, entry.name);
|
|
430
|
+
const childParts = [...parts, entry.name];
|
|
431
|
+
if (entry.isDirectory())
|
|
432
|
+
await walk(child, childParts);
|
|
433
|
+
else if (entry.isFile())
|
|
434
|
+
files.push({ relativePath: childParts.join("/"), sha256: await hashRegularFile(child) });
|
|
435
|
+
else
|
|
436
|
+
fail("import.target-not-regular");
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
await walk(root, []);
|
|
440
|
+
return files;
|
|
441
|
+
}
|
|
442
|
+
function stableIdentity(agentId, plans) {
|
|
443
|
+
const identity = plans.map((plan) => ({
|
|
444
|
+
name: plan.skill.name,
|
|
445
|
+
kind: plan.skill.kind,
|
|
446
|
+
destinationPath: plan.destinationPath,
|
|
447
|
+
sourcePath: plan.source.resolvedRootPath,
|
|
448
|
+
files: plan.source.files.map((file) => [file.relativePath, file.sha256]),
|
|
449
|
+
}));
|
|
450
|
+
return sha256Text(JSON.stringify([agentId, identity]));
|
|
451
|
+
}
|
|
452
|
+
function importReceipt(agentId, idempotencyKey, startedAt, finishedAt, resultHash, alreadyRecorded = false) {
|
|
453
|
+
return {
|
|
454
|
+
schemaVersion: "psyclaw/tool-receipt/v1",
|
|
455
|
+
runId: `import:${agentId}`,
|
|
456
|
+
taskId: agentId,
|
|
457
|
+
tool: IMPORT_RECEIPT_TOOL,
|
|
458
|
+
effect: "write",
|
|
459
|
+
approval: "approved",
|
|
460
|
+
idempotencyKey,
|
|
461
|
+
ok: true,
|
|
462
|
+
...(alreadyRecorded ? { reasonCode: "import.already-recorded" } : {}),
|
|
463
|
+
resultHash,
|
|
464
|
+
startedAt,
|
|
465
|
+
finishedAt,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function manifestText(manifest) {
|
|
469
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
470
|
+
}
|
|
471
|
+
async function writeNoClobber(path, contents) {
|
|
472
|
+
try {
|
|
473
|
+
await writeFile(path, contents, { encoding: "utf8", flag: "wx" });
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
if (error.code !== "EEXIST")
|
|
478
|
+
throw error;
|
|
479
|
+
const existing = await readFile(path, "utf8").catch(() => fail("import.manifest-unavailable"));
|
|
480
|
+
if (existing !== contents)
|
|
481
|
+
fail("import.manifest-conflict");
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async function readExistingManifest(path, agent) {
|
|
486
|
+
const manifestStat = await lstat(path).catch((error) => {
|
|
487
|
+
if (error.code === "ENOENT")
|
|
488
|
+
return undefined;
|
|
489
|
+
throw error;
|
|
490
|
+
});
|
|
491
|
+
if (manifestStat === undefined)
|
|
492
|
+
return undefined;
|
|
493
|
+
if (manifestStat.isSymbolicLink() || !manifestStat.isFile())
|
|
494
|
+
fail("import.manifest-invalid");
|
|
495
|
+
let text;
|
|
496
|
+
try {
|
|
497
|
+
text = await readFile(path, "utf8");
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
if (error.code === "ENOENT")
|
|
501
|
+
return undefined;
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
let value;
|
|
505
|
+
try {
|
|
506
|
+
value = JSON.parse(text);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
fail("import.manifest-invalid");
|
|
510
|
+
}
|
|
511
|
+
if (typeof value !== "object" || value === null)
|
|
512
|
+
fail("import.manifest-invalid");
|
|
513
|
+
const manifest = value;
|
|
514
|
+
if (manifest.schemaVersion !== IMPORT_SCHEMA_VERSION || manifest.agentId !== agent.id || typeof manifest.agentName !== "string" ||
|
|
515
|
+
typeof manifest.importedAt !== "string" || Number.isNaN(new Date(manifest.importedAt).getTime()) ||
|
|
516
|
+
typeof manifest.approval !== "object" || manifest.approval === null ||
|
|
517
|
+
typeof manifest.approval.actor !== "string" || typeof manifest.approval.reason !== "string" ||
|
|
518
|
+
!Array.isArray(manifest.skills) || !manifest.skills.every(isImportedSkill) ||
|
|
519
|
+
!Array.isArray(manifest.diagnostics) || !manifest.diagnostics.every(isImportDiagnostic) || !isToolReceipt(manifest.receipt)) {
|
|
520
|
+
fail("import.manifest-conflict");
|
|
521
|
+
}
|
|
522
|
+
return manifest;
|
|
523
|
+
}
|
|
524
|
+
function isRelativeManifestPath(value) {
|
|
525
|
+
if (typeof value !== "string" || value.length === 0 || isCrossPlatformAbsolute(value))
|
|
526
|
+
return false;
|
|
527
|
+
const parts = value.replaceAll("\\", "/").split("/");
|
|
528
|
+
return parts.every((part) => part.length > 0 && part !== "." && part !== ".." && !isSensitiveSegment(part) && !part.includes(":"));
|
|
529
|
+
}
|
|
530
|
+
function isImportedFile(value) {
|
|
531
|
+
if (typeof value !== "object" || value === null)
|
|
532
|
+
return false;
|
|
533
|
+
const file = value;
|
|
534
|
+
return typeof file.sourcePath === "string" && isCrossPlatformAbsolute(file.sourcePath) &&
|
|
535
|
+
typeof file.resolvedSourcePath === "string" && isCrossPlatformAbsolute(file.resolvedSourcePath) &&
|
|
536
|
+
typeof file.destinationPath === "string" && isCrossPlatformAbsolute(file.destinationPath) &&
|
|
537
|
+
isRelativeManifestPath(file.relativePath) && typeof file.sha256 === "string" && SHA256_RE.test(file.sha256);
|
|
538
|
+
}
|
|
539
|
+
function isImportedSkill(value) {
|
|
540
|
+
if (typeof value !== "object" || value === null)
|
|
541
|
+
return false;
|
|
542
|
+
const skill = value;
|
|
543
|
+
return typeof skill.name === "string" && typeof skill.sourcePath === "string" && isCrossPlatformAbsolute(skill.sourcePath) &&
|
|
544
|
+
typeof skill.resolvedSourcePath === "string" && isCrossPlatformAbsolute(skill.resolvedSourcePath) &&
|
|
545
|
+
typeof skill.destinationPath === "string" && isCrossPlatformAbsolute(skill.destinationPath) &&
|
|
546
|
+
(skill.kind === "dir" || skill.kind === "file") && Array.isArray(skill.files) && skill.files.every(isImportedFile);
|
|
547
|
+
}
|
|
548
|
+
function isImportDiagnostic(value) {
|
|
549
|
+
if (typeof value !== "object" || value === null)
|
|
550
|
+
return false;
|
|
551
|
+
const diagnostic = value;
|
|
552
|
+
return typeof diagnostic.code === "string" && /^[a-z0-9][a-z0-9._-]{0,127}$/.test(diagnostic.code) &&
|
|
553
|
+
(diagnostic.path === undefined || (typeof diagnostic.path === "string" && diagnostic.path.length <= 512));
|
|
554
|
+
}
|
|
555
|
+
function isToolReceipt(value) {
|
|
556
|
+
if (typeof value !== "object" || value === null)
|
|
557
|
+
return false;
|
|
558
|
+
const receipt = value;
|
|
559
|
+
return receipt.schemaVersion === "psyclaw/tool-receipt/v1" &&
|
|
560
|
+
typeof receipt.runId === "string" && receipt.runId.length > 0 &&
|
|
561
|
+
typeof receipt.taskId === "string" && receipt.taskId.length > 0 &&
|
|
562
|
+
receipt.tool === IMPORT_RECEIPT_TOOL && receipt.effect === "write" && receipt.approval === "approved" &&
|
|
563
|
+
receipt.ok === true && typeof receipt.idempotencyKey === "string" && receipt.idempotencyKey.length > 0 &&
|
|
564
|
+
typeof receipt.resultHash === "string" && SHA256_RE.test(receipt.resultHash) &&
|
|
565
|
+
typeof receipt.startedAt === "string" && !Number.isNaN(new Date(receipt.startedAt).getTime()) &&
|
|
566
|
+
typeof receipt.finishedAt === "string" && !Number.isNaN(new Date(receipt.finishedAt).getTime()) &&
|
|
567
|
+
(receipt.reasonCode === undefined || /^[a-z0-9][a-z0-9._-]{0,63}$/.test(receipt.reasonCode));
|
|
568
|
+
}
|
|
569
|
+
async function manifestMatchesPlans(manifest, plans, idempotencyKey, agentId, resultHash) {
|
|
570
|
+
if (manifest.receipt.idempotencyKey !== idempotencyKey || manifest.receipt.runId !== `import:${agentId}` ||
|
|
571
|
+
manifest.receipt.taskId !== agentId || manifest.receipt.resultHash !== resultHash ||
|
|
572
|
+
new Date(manifest.receipt.finishedAt).getTime() < new Date(manifest.receipt.startedAt).getTime())
|
|
573
|
+
return false;
|
|
574
|
+
if (manifest.skills.length !== plans.length)
|
|
575
|
+
return false;
|
|
576
|
+
for (const plan of plans) {
|
|
577
|
+
const existing = manifest.skills.find((skill) => skill.name === plan.skill.name && skill.kind === plan.skill.kind);
|
|
578
|
+
if (existing === undefined || !samePath(existing.destinationPath, plan.destinationPath) ||
|
|
579
|
+
!samePath(existing.resolvedSourcePath, plan.source.resolvedRootPath) || existing.files.length !== plan.source.files.length)
|
|
580
|
+
return false;
|
|
581
|
+
for (const file of plan.source.files) {
|
|
582
|
+
const found = existing.files.find((candidate) => candidate.relativePath === file.relativePath);
|
|
583
|
+
if (found === undefined || found.sha256 !== file.sha256 || !samePath(found.resolvedSourcePath, file.resolvedSourcePath) ||
|
|
584
|
+
!samePath(found.destinationPath, join(plan.destinationPath, file.relativePath)))
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
async function activatePlan(plan, stagingRoot, created) {
|
|
591
|
+
if (plan.status === "already-recorded")
|
|
592
|
+
return false;
|
|
593
|
+
const source = plan.source;
|
|
594
|
+
const destination = plan.destinationPath;
|
|
595
|
+
await assertNoSymlinkAncestors(dirname(destination));
|
|
596
|
+
if (plan.skill.kind === "file") {
|
|
597
|
+
await ensureDirectoryChain(stagingRoot, dirname(join(stagingRoot, "__placeholder__")), []);
|
|
598
|
+
try {
|
|
599
|
+
await copyFile(join(stagingRoot, plan.skill.name + ".md"), destination, constants.COPYFILE_EXCL);
|
|
600
|
+
created.push(destination);
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
if (error.code !== "EEXIST")
|
|
605
|
+
throw error;
|
|
606
|
+
const current = await inspectDestination(destination, source, "file");
|
|
607
|
+
if (current === "already-recorded")
|
|
608
|
+
return false;
|
|
609
|
+
fail("import.target-conflict");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
let targetCreated = false;
|
|
613
|
+
try {
|
|
614
|
+
await mkdir(destination, { recursive: false });
|
|
615
|
+
targetCreated = true;
|
|
616
|
+
created.push(destination);
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
if (error.code !== "EEXIST")
|
|
620
|
+
throw error;
|
|
621
|
+
const current = await inspectDestination(destination, source, "dir");
|
|
622
|
+
if (current === "already-recorded")
|
|
623
|
+
return false;
|
|
624
|
+
fail("import.target-conflict");
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
for (const file of source.files) {
|
|
628
|
+
const staged = join(stagingRoot, plan.skill.name, file.relativePath);
|
|
629
|
+
const target = join(destination, file.relativePath);
|
|
630
|
+
const targetParent = dirname(target);
|
|
631
|
+
await ensureDirectoryChain(destination, targetParent, created);
|
|
632
|
+
try {
|
|
633
|
+
await copyFile(staged, target, constants.COPYFILE_EXCL);
|
|
634
|
+
created.push(target);
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
if (error.code !== "EEXIST")
|
|
638
|
+
throw error;
|
|
639
|
+
const digest = await hashRegularFile(target);
|
|
640
|
+
if (digest !== file.sha256)
|
|
641
|
+
fail("import.target-conflict");
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const finalStatus = await inspectDestination(destination, source, "dir");
|
|
645
|
+
if (finalStatus !== "already-recorded")
|
|
646
|
+
fail("import.activation-unverified");
|
|
647
|
+
return targetCreated || source.files.length > 0;
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function rollbackCreated(paths) {
|
|
654
|
+
for (const path of [...paths].reverse()) {
|
|
655
|
+
try {
|
|
656
|
+
const stat = await lstat(path);
|
|
657
|
+
if (stat.isSymbolicLink())
|
|
658
|
+
continue;
|
|
659
|
+
await rm(path, { recursive: stat.isDirectory(), force: true });
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
// Rollback is best-effort; never replace the original deterministic failure.
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Import only a discovered agent's allowlisted, stable Skill files. The source
|
|
668
|
+
* tree is copied to a private staging directory first; activation uses
|
|
669
|
+
* no-clobber primitives and every manifest is itself no-clobber/idempotent.
|
|
670
|
+
* Importing never enables a Skill.
|
|
671
|
+
*/
|
|
672
|
+
export async function importAgentSkills(options) {
|
|
673
|
+
if (typeof options !== "object" || options === null || typeof options.root !== "string" ||
|
|
674
|
+
typeof options.approval !== "object" || options.approval === null ||
|
|
675
|
+
typeof options.approval.actor !== "string" || typeof options.approval.reason !== "string" ||
|
|
676
|
+
(options.now !== undefined && typeof options.now !== "function")) {
|
|
677
|
+
fail("import.options-invalid");
|
|
678
|
+
}
|
|
679
|
+
validateAgentScan(options.agent);
|
|
680
|
+
if (options.approval.approved !== true)
|
|
681
|
+
throw new SkillImportError("import.approval-required", "Skill import requires explicit approval");
|
|
682
|
+
const clock = options.now ?? (() => new Date().toISOString());
|
|
683
|
+
const startedAt = clock();
|
|
684
|
+
if (Number.isNaN(new Date(startedAt).getTime()))
|
|
685
|
+
fail("import.invalid-clock");
|
|
686
|
+
const agentId = options.agent.id;
|
|
687
|
+
assertSafeSegment(agentId, "agent id");
|
|
688
|
+
const actor = boundedDisplay(options.approval.actor, "unknown");
|
|
689
|
+
const reason = boundedDisplay(options.approval.reason, "explicit approval");
|
|
690
|
+
const agentName = boundedDisplay(options.agent.name, agentId);
|
|
691
|
+
const root = resolve(options.root);
|
|
692
|
+
const rootStat = await lstat(root).catch(() => fail("import.project-unavailable"));
|
|
693
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
|
|
694
|
+
fail("import.project-invalid");
|
|
695
|
+
await assertNoSymlinkAncestors(root);
|
|
696
|
+
const importsRoot = await assertProjectTarget(root, join(root, ".psyclaw", "imports"));
|
|
697
|
+
const destinationRoot = await assertProjectTarget(root, join(importsRoot, agentId));
|
|
698
|
+
await ensureDirectoryChain(root, importsRoot);
|
|
699
|
+
await ensureDirectoryChain(root, destinationRoot);
|
|
700
|
+
const plans = [];
|
|
701
|
+
for (const skill of options.agent.skills) {
|
|
702
|
+
sourceNameMatches(skill);
|
|
703
|
+
await declaredSourceRoot(options.agent, skill.path);
|
|
704
|
+
const source = await collectSourceTree(skill);
|
|
705
|
+
const destinationPath = await assertProjectTarget(root, skill.kind === "dir" ? join(destinationRoot, skill.name) : join(destinationRoot, `${skill.name}.md`));
|
|
706
|
+
const status = await inspectDestination(destinationPath, source, skill.kind);
|
|
707
|
+
plans.push({ skill, destinationPath, source, status });
|
|
708
|
+
}
|
|
709
|
+
plans.sort((left, right) => left.skill.name.localeCompare(right.skill.name));
|
|
710
|
+
const idHash = stableIdentity(agentId, plans);
|
|
711
|
+
const idempotencyKey = `import:skills:${agentId}:${idHash.slice(0, 32)}`;
|
|
712
|
+
const manifestPath = join(destinationRoot, "import-manifest.json");
|
|
713
|
+
await assertProjectTarget(root, manifestPath);
|
|
714
|
+
const existingManifest = await readExistingManifest(manifestPath, options.agent);
|
|
715
|
+
if (existingManifest !== undefined) {
|
|
716
|
+
if (!await manifestMatchesPlans(existingManifest, plans, idempotencyKey, agentId, idHash))
|
|
717
|
+
fail("import.manifest-conflict");
|
|
718
|
+
// A prior manifest is a durable idempotency record. Destination and source
|
|
719
|
+
// snapshots above were still checked, so a changed source cannot silently
|
|
720
|
+
// masquerade as an already-recorded import.
|
|
721
|
+
const receipt = importReceipt(agentId, idempotencyKey, startedAt, startedAt, idHash, true);
|
|
722
|
+
return { manifest: existingManifest, manifestPath, importedCount: 0, receipt };
|
|
723
|
+
}
|
|
724
|
+
const stagingPrefix = join(importsRoot, `.staging-${agentId}-`);
|
|
725
|
+
const stagingRoot = await mkdtemp(stagingPrefix);
|
|
726
|
+
const activated = [];
|
|
727
|
+
try {
|
|
728
|
+
for (const plan of plans) {
|
|
729
|
+
if (plan.status === "already-recorded")
|
|
730
|
+
continue;
|
|
731
|
+
const skillStage = join(stagingRoot, plan.skill.name);
|
|
732
|
+
if (plan.skill.kind === "dir")
|
|
733
|
+
await ensureDirectoryChain(stagingRoot, skillStage);
|
|
734
|
+
for (const file of plan.source.files) {
|
|
735
|
+
const stagedPath = join(stagingRoot, plan.skill.kind === "dir" ? plan.skill.name : "", file.relativePath);
|
|
736
|
+
await ensureDirectoryChain(stagingRoot, dirname(stagedPath));
|
|
737
|
+
await writeFile(stagedPath, file.bytes, { flag: "wx" });
|
|
738
|
+
}
|
|
739
|
+
await verifySourceStable(plan.source, plan.skill);
|
|
740
|
+
}
|
|
741
|
+
for (const plan of plans)
|
|
742
|
+
await activatePlan(plan, stagingRoot, activated);
|
|
743
|
+
const finishedAt = clock();
|
|
744
|
+
if (Number.isNaN(new Date(finishedAt).getTime()) || new Date(finishedAt).getTime() < new Date(startedAt).getTime()) {
|
|
745
|
+
fail("import.invalid-clock");
|
|
746
|
+
}
|
|
747
|
+
const importedSkills = plans.map((plan) => ({
|
|
748
|
+
name: plan.skill.name,
|
|
749
|
+
kind: plan.skill.kind,
|
|
750
|
+
sourcePath: plan.source.rootPath,
|
|
751
|
+
resolvedSourcePath: plan.source.resolvedRootPath,
|
|
752
|
+
destinationPath: plan.destinationPath,
|
|
753
|
+
files: plan.source.files.map((file) => ({
|
|
754
|
+
sourcePath: file.sourcePath,
|
|
755
|
+
resolvedSourcePath: file.resolvedSourcePath,
|
|
756
|
+
destinationPath: join(plan.destinationPath, file.relativePath),
|
|
757
|
+
relativePath: file.relativePath,
|
|
758
|
+
sha256: file.sha256,
|
|
759
|
+
})),
|
|
760
|
+
}));
|
|
761
|
+
const receipt = importReceipt(agentId, idempotencyKey, startedAt, finishedAt, idHash);
|
|
762
|
+
const manifest = {
|
|
763
|
+
schemaVersion: IMPORT_SCHEMA_VERSION,
|
|
764
|
+
agentId,
|
|
765
|
+
agentName,
|
|
766
|
+
approval: { actor, reason },
|
|
767
|
+
importedAt: startedAt,
|
|
768
|
+
skills: importedSkills,
|
|
769
|
+
diagnostics: [
|
|
770
|
+
{ code: "source.canonical-and-hash-verified" },
|
|
771
|
+
{ code: "content.allowlist-enforced" },
|
|
772
|
+
{ code: "target.no-clobber-enforced" },
|
|
773
|
+
{ code: "import.does-not-enable-skill" },
|
|
774
|
+
],
|
|
775
|
+
receipt,
|
|
776
|
+
};
|
|
777
|
+
await writeNoClobber(manifestPath, manifestText(manifest));
|
|
778
|
+
const importedCount = plans.filter((plan) => plan.status === "new").length;
|
|
779
|
+
return { manifest, manifestPath, importedCount, receipt };
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
await rollbackCreated(activated);
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
785
|
+
finally {
|
|
786
|
+
await rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
//# sourceMappingURL=import.js.map
|