open-managed-agents 0.1.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/LICENSE +93 -0
- package/README.md +248 -0
- package/bin/oma.mjs +499 -0
- package/dist/scripts/alpha-openai-compatible-fixture.mjs +134 -0
- package/dist/scripts/alpha-smoke-models.mjs +92 -0
- package/dist/scripts/alpha-smoke.mjs +581 -0
- package/dist/scripts/oma-doctor.js +230 -0
- package/dist/scripts/oma-models.js +299 -0
- package/dist/scripts/oma-workspaces.js +133 -0
- package/dist/src/control-plane/admin/auth.js +43 -0
- package/dist/src/control-plane/admin/routes.js +76 -0
- package/dist/src/control-plane/admin/service.js +102 -0
- package/dist/src/control-plane/admission.js +90 -0
- package/dist/src/control-plane/agents/routes.js +63 -0
- package/dist/src/control-plane/agents/service.js +687 -0
- package/dist/src/control-plane/agents/store.js +391 -0
- package/dist/src/control-plane/agents/types.js +1 -0
- package/dist/src/control-plane/api-constants.js +3 -0
- package/dist/src/control-plane/app.js +907 -0
- package/dist/src/control-plane/console/auth.js +68 -0
- package/dist/src/control-plane/console/static.js +112 -0
- package/dist/src/control-plane/deployment-runtime-config.js +328 -0
- package/dist/src/control-plane/deployment-runtime-event-coordinator.js +25 -0
- package/dist/src/control-plane/deployment-runtime-turn-guard.js +14 -0
- package/dist/src/control-plane/deployment-session-coordinator.js +25 -0
- package/dist/src/control-plane/deployment-session-output-coordinator.js +27 -0
- package/dist/src/control-plane/deployment-storage.js +354 -0
- package/dist/src/control-plane/egress/image.js +48 -0
- package/dist/src/control-plane/egress/policy.js +571 -0
- package/dist/src/control-plane/egress/presets.js +77 -0
- package/dist/src/control-plane/egress/proxy.js +72 -0
- package/dist/src/control-plane/egress/ssrf.js +106 -0
- package/dist/src/control-plane/egress/utils/debug.js +14 -0
- package/dist/src/control-plane/egress/vendor/http-proxy.js +335 -0
- package/dist/src/control-plane/egress/vendor/mitm-ca.js +226 -0
- package/dist/src/control-plane/egress/vendor/mitm-leaf.js +124 -0
- package/dist/src/control-plane/egress/vendor/parent-proxy.js +438 -0
- package/dist/src/control-plane/egress/vendor/request-filter.js +105 -0
- package/dist/src/control-plane/egress/vendor/sandbox-config.js +1 -0
- package/dist/src/control-plane/egress/vendor/tls-terminate-proxy.js +269 -0
- package/dist/src/control-plane/environments/routes.js +54 -0
- package/dist/src/control-plane/environments/service.js +112 -0
- package/dist/src/control-plane/environments/store.js +132 -0
- package/dist/src/control-plane/environments/types.js +1 -0
- package/dist/src/control-plane/errors.js +81 -0
- package/dist/src/control-plane/events/broadcaster.js +217 -0
- package/dist/src/control-plane/events/constants.js +2 -0
- package/dist/src/control-plane/events/persist.js +39 -0
- package/dist/src/control-plane/events/request.js +190 -0
- package/dist/src/control-plane/events/routes.js +159 -0
- package/dist/src/control-plane/events/runtime-helpers.js +134 -0
- package/dist/src/control-plane/events/service.js +1985 -0
- package/dist/src/control-plane/events/session-guards.js +29 -0
- package/dist/src/control-plane/events/sse.js +6 -0
- package/dist/src/control-plane/events/store.js +772 -0
- package/dist/src/control-plane/events/tool-persistence.js +102 -0
- package/dist/src/control-plane/events/types.js +21 -0
- package/dist/src/control-plane/files/routes.js +121 -0
- package/dist/src/control-plane/files/service.js +65 -0
- package/dist/src/control-plane/files/store-common.js +87 -0
- package/dist/src/control-plane/files/store-local.js +488 -0
- package/dist/src/control-plane/files/store-memory.js +270 -0
- package/dist/src/control-plane/files/store.js +2 -0
- package/dist/src/control-plane/files/types.js +7 -0
- package/dist/src/control-plane/http.js +25 -0
- package/dist/src/control-plane/ids.js +43 -0
- package/dist/src/control-plane/logging.js +263 -0
- package/dist/src/control-plane/models/auth-storage-backend.js +238 -0
- package/dist/src/control-plane/models/catalog.js +78 -0
- package/dist/src/control-plane/models/config-security.js +376 -0
- package/dist/src/control-plane/models/deployment-config.js +93 -0
- package/dist/src/control-plane/models/routes.js +32 -0
- package/dist/src/control-plane/models/service.js +138 -0
- package/dist/src/control-plane/observability/instruments.js +65 -0
- package/dist/src/control-plane/observability/metrics.js +125 -0
- package/dist/src/control-plane/observability/routes.js +95 -0
- package/dist/src/control-plane/openapi/document.js +282 -0
- package/dist/src/control-plane/openapi/routes.js +78 -0
- package/dist/src/control-plane/request-idempotency.js +85 -0
- package/dist/src/control-plane/secrets/envelope.js +132 -0
- package/dist/src/control-plane/secrets/master-key.js +44 -0
- package/dist/src/control-plane/secrets/routes.js +20 -0
- package/dist/src/control-plane/secrets/service.js +71 -0
- package/dist/src/control-plane/secrets/store.js +212 -0
- package/dist/src/control-plane/secrets/types.js +3 -0
- package/dist/src/control-plane/sessions/pi/custom-tools.js +146 -0
- package/dist/src/control-plane/sessions/pi/mcp/bridge.js +527 -0
- package/dist/src/control-plane/sessions/pi/mcp/client.js +157 -0
- package/dist/src/control-plane/sessions/pi/mcp/credential.js +28 -0
- package/dist/src/control-plane/sessions/pi/mcp/fetch.js +69 -0
- package/dist/src/control-plane/sessions/pi/mcp/probe.js +202 -0
- package/dist/src/control-plane/sessions/pi/mcp/runtime.js +13 -0
- package/dist/src/control-plane/sessions/pi/runner.js +1104 -0
- package/dist/src/control-plane/sessions/pi/sandbox/cma-glob.js +388 -0
- package/dist/src/control-plane/sessions/pi/sandbox/cma-grep.js +124 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +347 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker.js +1589 -0
- package/dist/src/control-plane/sessions/pi/sandbox/glob.js +48 -0
- package/dist/src/control-plane/sessions/pi/sandbox/image.js +7 -0
- package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +1677 -0
- package/dist/src/control-plane/sessions/pi/sandbox/provider.js +561 -0
- package/dist/src/control-plane/sessions/pi/sandbox/selection.js +168 -0
- package/dist/src/control-plane/sessions/pi/span-normalizer.js +82 -0
- package/dist/src/control-plane/sessions/pi/tool-permissions.js +392 -0
- package/dist/src/control-plane/sessions/pi/translator.js +100 -0
- package/dist/src/control-plane/sessions/request.js +164 -0
- package/dist/src/control-plane/sessions/resources.js +63 -0
- package/dist/src/control-plane/sessions/routes.js +88 -0
- package/dist/src/control-plane/sessions/serialize.js +24 -0
- package/dist/src/control-plane/sessions/service.js +733 -0
- package/dist/src/control-plane/sessions/store.js +538 -0
- package/dist/src/control-plane/sessions/types.js +1 -0
- package/dist/src/control-plane/skills/archive.js +169 -0
- package/dist/src/control-plane/skills/routes.js +41 -0
- package/dist/src/control-plane/skills/service.js +29 -0
- package/dist/src/control-plane/skills/store.js +233 -0
- package/dist/src/control-plane/skills/types.js +5 -0
- package/dist/src/control-plane/sqlite-transaction.js +15 -0
- package/dist/src/control-plane/vaults/mcp-oauth-validate.js +165 -0
- package/dist/src/control-plane/vaults/oauth-refresh-ticker.js +43 -0
- package/dist/src/control-plane/vaults/oauth-refresh.js +448 -0
- package/dist/src/control-plane/vaults/routes.js +66 -0
- package/dist/src/control-plane/vaults/service.js +553 -0
- package/dist/src/control-plane/vaults/store.js +691 -0
- package/dist/src/control-plane/vaults/types.js +1 -0
- package/dist/src/control-plane/wake-loop.js +65 -0
- package/dist/src/control-plane/wiring.js +92 -0
- package/dist/src/control-plane/workspace.js +4 -0
- package/dist/src/control-plane/workspaces/store.js +158 -0
- package/dist/src/egress-proxy-main.js +103 -0
- package/dist/src/main.js +124 -0
- package/dist/src/types/agents.js +1 -0
- package/dist/src/types/common.js +1 -0
- package/dist/src/types/environments.js +1 -0
- package/dist/src/types/events.js +121 -0
- package/dist/src/types/files.js +1 -0
- package/dist/src/types/json.js +19 -0
- package/dist/src/types/sessions.js +1 -0
- package/dist/ui/managed-agents-console/README.md +52 -0
- package/dist/ui/managed-agents-console/docs/agents.md +23 -0
- package/dist/ui/managed-agents-console/docs/console.md +23 -0
- package/dist/ui/managed-agents-console/docs/dreams.md +5 -0
- package/dist/ui/managed-agents-console/docs/environments.md +27 -0
- package/dist/ui/managed-agents-console/docs/events.md +19 -0
- package/dist/ui/managed-agents-console/docs/files.md +17 -0
- package/dist/ui/managed-agents-console/docs/github.md +11 -0
- package/dist/ui/managed-agents-console/docs/integrations.md +19 -0
- package/dist/ui/managed-agents-console/docs/memory.md +11 -0
- package/dist/ui/managed-agents-console/docs/migration.md +17 -0
- package/dist/ui/managed-agents-console/docs/multiagent.md +11 -0
- package/dist/ui/managed-agents-console/docs/outcomes.md +11 -0
- package/dist/ui/managed-agents-console/docs/overview.md +41 -0
- package/dist/ui/managed-agents-console/docs/permissions.md +24 -0
- package/dist/ui/managed-agents-console/docs/quickstart.md +51 -0
- package/dist/ui/managed-agents-console/docs/reference.md +27 -0
- package/dist/ui/managed-agents-console/docs/sandbox-reference.md +19 -0
- package/dist/ui/managed-agents-console/docs/sandbox-security.md +15 -0
- package/dist/ui/managed-agents-console/docs/scheduled-deployments.md +5 -0
- package/dist/ui/managed-agents-console/docs/self-hosted-sandboxes.md +15 -0
- package/dist/ui/managed-agents-console/docs/session-operations.md +21 -0
- package/dist/ui/managed-agents-console/docs/sessions.md +21 -0
- package/dist/ui/managed-agents-console/docs/skills.md +15 -0
- package/dist/ui/managed-agents-console/docs/tools.md +23 -0
- package/dist/ui/managed-agents-console/docs/vaults.md +19 -0
- package/dist/ui/managed-agents-console/docs/webhooks.md +11 -0
- package/dist/ui/managed-agents-console/index.html +47 -0
- package/dist/ui/managed-agents-console/serve.mjs +116 -0
- package/dist/ui/managed-agents-console/src/agents-files.jsx +273 -0
- package/dist/ui/managed-agents-console/src/api.js +1043 -0
- package/dist/ui/managed-agents-console/src/app.jsx +633 -0
- package/dist/ui/managed-agents-console/src/auth.jsx +215 -0
- package/dist/ui/managed-agents-console/src/console.css +733 -0
- package/dist/ui/managed-agents-console/src/data.js +188 -0
- package/dist/ui/managed-agents-console/src/detail.jsx +719 -0
- package/dist/ui/managed-agents-console/src/docs.jsx +200 -0
- package/dist/ui/managed-agents-console/src/environments.jsx +365 -0
- package/dist/ui/managed-agents-console/src/forms.jsx +494 -0
- package/dist/ui/managed-agents-console/src/icons.jsx +48 -0
- package/dist/ui/managed-agents-console/src/skills.jsx +21 -0
- package/dist/ui/managed-agents-console/src/sse.js +185 -0
- package/dist/ui/managed-agents-console/src/states.jsx +90 -0
- package/dist/ui/managed-agents-console/src/tweaks-panel.jsx +541 -0
- package/dist/ui/managed-agents-console/src/ui.jsx +190 -0
- package/dist/ui/managed-agents-console/src/vaults-data.js +89 -0
- package/dist/ui/managed-agents-console/src/vaults.jsx +171 -0
- package/dist/ui/managed-agents-console/vendor/babel.min.js +4 -0
- package/dist/ui/managed-agents-console/vendor/react-dom.production.min.js +267 -0
- package/dist/ui/managed-agents-console/vendor/react.production.min.js +31 -0
- package/dist/ui/openapi-docs/VENDOR.md +27 -0
- package/dist/ui/openapi-docs/index.html +16 -0
- package/dist/ui/openapi-docs/swagger-initializer.js +14 -0
- package/dist/ui/openapi-docs/vendor/LICENSE +202 -0
- package/dist/ui/openapi-docs/vendor/NOTICE +2 -0
- package/dist/ui/openapi-docs/vendor/swagger-ui-bundle.js +2 -0
- package/dist/ui/openapi-docs/vendor/swagger-ui-standalone-preset.js +2 -0
- package/dist/ui/openapi-docs/vendor/swagger-ui.css +3 -0
- package/package.json +53 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import yauzl from "yauzl";
|
|
4
|
+
import { invalidRequest } from "../errors.js";
|
|
5
|
+
import { MAX_SKILL_FILE_BYTES, MAX_SKILL_FILES, MAX_SKILL_VERSION_BYTES, } from "./types.js";
|
|
6
|
+
export async function validateSkillUpload(inputs) {
|
|
7
|
+
if (inputs.length === 0)
|
|
8
|
+
throw invalidRequest("`files[]` is required");
|
|
9
|
+
const zip = inputs.length === 1 && inputs[0].name.toLowerCase().endsWith(".zip");
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = zip ? await unzip(inputs[0].bytes) : inputs;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (typeof error === "object" && error !== null && "status" in error)
|
|
16
|
+
throw error;
|
|
17
|
+
throw invalidRequest("Skill archive must be a valid zip", String(error));
|
|
18
|
+
}
|
|
19
|
+
return validateFiles(raw);
|
|
20
|
+
}
|
|
21
|
+
async function unzip(bytes) {
|
|
22
|
+
const zip = await openZip(bytes);
|
|
23
|
+
const files = [];
|
|
24
|
+
let total = 0;
|
|
25
|
+
try {
|
|
26
|
+
while (true) {
|
|
27
|
+
const entry = await nextEntry(zip);
|
|
28
|
+
if (entry === undefined)
|
|
29
|
+
break;
|
|
30
|
+
assertEntrySafe(entry);
|
|
31
|
+
if (entry.fileName.endsWith("/"))
|
|
32
|
+
continue;
|
|
33
|
+
if (entry.uncompressedSize > MAX_SKILL_FILE_BYTES)
|
|
34
|
+
throw invalidRequest("Skill file exceeds the 20 MiB limit");
|
|
35
|
+
total += entry.uncompressedSize;
|
|
36
|
+
if (total > MAX_SKILL_VERSION_BYTES)
|
|
37
|
+
throw invalidRequest("Skill content exceeds the 100 MiB limit");
|
|
38
|
+
if (files.length >= MAX_SKILL_FILES)
|
|
39
|
+
throw invalidRequest("Skill contains more than 500 files");
|
|
40
|
+
files.push({ name: entry.fileName, bytes: await readEntry(zip, entry) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
zip.close();
|
|
45
|
+
}
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
48
|
+
function openZip(bytes) {
|
|
49
|
+
return new Promise((resolve, reject) => yauzl.fromBuffer(Buffer.from(bytes), {
|
|
50
|
+
lazyEntries: true,
|
|
51
|
+
validateEntrySizes: true,
|
|
52
|
+
decodeStrings: true,
|
|
53
|
+
}, (error, zip) => error || !zip ? reject(error ?? new Error("Invalid zip")) : resolve(zip)));
|
|
54
|
+
}
|
|
55
|
+
function nextEntry(zip) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const onEntry = (entry) => { cleanup(); resolve(entry); };
|
|
58
|
+
const onEnd = () => { cleanup(); resolve(undefined); };
|
|
59
|
+
const onError = (error) => { cleanup(); reject(error); };
|
|
60
|
+
const cleanup = () => { zip.off("entry", onEntry); zip.off("end", onEnd); zip.off("error", onError); };
|
|
61
|
+
zip.once("entry", onEntry);
|
|
62
|
+
zip.once("end", onEnd);
|
|
63
|
+
zip.once("error", onError);
|
|
64
|
+
zip.readEntry();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function assertEntrySafe(entry) {
|
|
68
|
+
const name = entry.fileName;
|
|
69
|
+
if ((entry.generalPurposeBitFlag & 1) !== 0)
|
|
70
|
+
throw invalidRequest("Encrypted zip entries are not supported");
|
|
71
|
+
if (name.includes("\\") || name.includes("\0") || name.startsWith("/") || name.split("/").includes("..")) {
|
|
72
|
+
throw invalidRequest("Skill archive contains an unsafe path");
|
|
73
|
+
}
|
|
74
|
+
const mode = (entry.externalFileAttributes >>> 16) & 0xffff;
|
|
75
|
+
const kind = mode & 0xf000;
|
|
76
|
+
if (kind !== 0 && kind !== 0x8000 && kind !== 0x4000)
|
|
77
|
+
throw invalidRequest("Skill archive contains a non-regular entry");
|
|
78
|
+
}
|
|
79
|
+
function readEntry(zip, entry) {
|
|
80
|
+
return new Promise((resolve, reject) => zip.openReadStream(entry, (error, stream) => {
|
|
81
|
+
if (error || !stream) {
|
|
82
|
+
reject(error ?? new Error("Cannot read zip entry"));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let size = 0;
|
|
87
|
+
stream.on("data", (chunk) => {
|
|
88
|
+
size += chunk.length;
|
|
89
|
+
if (size > MAX_SKILL_FILE_BYTES)
|
|
90
|
+
stream.destroy(new Error("Skill file exceeds the 20 MiB limit"));
|
|
91
|
+
else
|
|
92
|
+
chunks.push(chunk);
|
|
93
|
+
});
|
|
94
|
+
stream.once("error", reject);
|
|
95
|
+
stream.once("end", () => resolve(new Uint8Array(Buffer.concat(chunks))));
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
function validateFiles(raw) {
|
|
99
|
+
if (raw.length === 0 || raw.length > MAX_SKILL_FILES)
|
|
100
|
+
throw invalidRequest("Skill must contain between 1 and 500 files");
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
const folded = new Set();
|
|
103
|
+
const files = [];
|
|
104
|
+
let totalBytes = 0;
|
|
105
|
+
for (const item of raw) {
|
|
106
|
+
const path = normalizePath(item.name);
|
|
107
|
+
const lower = path.toLocaleLowerCase("en-US");
|
|
108
|
+
if (seen.has(path) || folded.has(lower))
|
|
109
|
+
throw invalidRequest("Skill contains duplicate paths");
|
|
110
|
+
seen.add(path);
|
|
111
|
+
folded.add(lower);
|
|
112
|
+
if (item.bytes.byteLength > MAX_SKILL_FILE_BYTES)
|
|
113
|
+
throw invalidRequest("Skill file exceeds the 20 MiB limit");
|
|
114
|
+
totalBytes += item.bytes.byteLength;
|
|
115
|
+
if (totalBytes > MAX_SKILL_VERSION_BYTES)
|
|
116
|
+
throw invalidRequest("Skill content exceeds the 100 MiB limit");
|
|
117
|
+
files.push({ path, bytes: item.bytes, size: item.bytes.byteLength, sha256: sha256(item.bytes) });
|
|
118
|
+
}
|
|
119
|
+
for (const path of seen) {
|
|
120
|
+
const segments = path.split("/");
|
|
121
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
122
|
+
const prefix = segments.slice(0, index).join("/");
|
|
123
|
+
if (seen.has(prefix) || folded.has(prefix.toLocaleLowerCase("en-US"))) {
|
|
124
|
+
throw invalidRequest("Skill contains a file/directory path conflict");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const roots = new Set(files.map((file) => file.path.split("/")[0]));
|
|
129
|
+
if (roots.size !== 1 || files.some((file) => !file.path.includes("/"))) {
|
|
130
|
+
throw invalidRequest("Zip must contain a top-level folder with all files inside it, including SKILL.md");
|
|
131
|
+
}
|
|
132
|
+
const directory = [...roots][0];
|
|
133
|
+
const skillFiles = files.filter((file) => file.path === `${directory}/SKILL.md`);
|
|
134
|
+
if (skillFiles.length !== 1)
|
|
135
|
+
throw invalidRequest("Skill must contain exactly one top-level SKILL.md");
|
|
136
|
+
let text;
|
|
137
|
+
try {
|
|
138
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(skillFiles[0].bytes);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw invalidRequest("SKILL.md must be valid UTF-8");
|
|
142
|
+
}
|
|
143
|
+
let frontmatter;
|
|
144
|
+
try {
|
|
145
|
+
({ frontmatter } = parseFrontmatter(text));
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
throw invalidRequest("SKILL.md frontmatter must be valid YAML", String(error));
|
|
149
|
+
}
|
|
150
|
+
const name = typeof frontmatter.name === "string" ? frontmatter.name : "";
|
|
151
|
+
const description = typeof frontmatter.description === "string" ? frontmatter.description : "";
|
|
152
|
+
if (!/^(?!anthropic$|claude$)(?!-)(?!.*--)[a-z0-9-]{1,64}(?<!-)$/.test(name))
|
|
153
|
+
throw invalidRequest("SKILL.md has an invalid name");
|
|
154
|
+
if (description.length === 0 || description.length > 1024)
|
|
155
|
+
throw invalidRequest("SKILL.md has an invalid description");
|
|
156
|
+
if (name !== directory)
|
|
157
|
+
throw invalidRequest(`The folder name '${directory}' must match the skill name '${name}' in SKILL.md.`);
|
|
158
|
+
const manifest = files.map(({ path, size, sha256 }) => ({ path, size, sha256 })).sort((a, b) => a.path.localeCompare(b.path));
|
|
159
|
+
return { name, description, directory, files, totalBytes, manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))) };
|
|
160
|
+
}
|
|
161
|
+
function normalizePath(value) {
|
|
162
|
+
if (value.includes("\\") || value.includes("\0") || value.startsWith("/") || value.endsWith("/"))
|
|
163
|
+
throw invalidRequest("Skill contains an unsafe path");
|
|
164
|
+
const parts = value.split("/").filter((part) => part.length > 0 && part !== ".").map((part) => part.normalize("NFC"));
|
|
165
|
+
if (parts.length === 0 || parts.includes(".."))
|
|
166
|
+
throw invalidRequest("Skill contains an unsafe path");
|
|
167
|
+
return parts.join("/");
|
|
168
|
+
}
|
|
169
|
+
function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { File as NodeFile } from "node:buffer";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { bodyLimit } from "hono/body-limit";
|
|
4
|
+
import { invalidRequest, requestTooLarge, toApiErrorBody } from "../errors.js";
|
|
5
|
+
import { parseLimit } from "../http.js";
|
|
6
|
+
import { workspaceIdFrom } from "../workspace.js";
|
|
7
|
+
export const MAX_SKILLS_UPLOAD_REQUEST_BYTES = 30 * 1024 * 1024;
|
|
8
|
+
export function skillsRoutes(service, admission) {
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
app.use("*", async (c, next) => { if (c.req.method !== "POST" || admission === undefined)
|
|
11
|
+
return next(); const release = admission.uploads.acquire(workspaceIdFrom(c)); try {
|
|
12
|
+
await next();
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
release();
|
|
16
|
+
} });
|
|
17
|
+
app.use("*", bodyLimit({ maxSize: MAX_SKILLS_UPLOAD_REQUEST_BYTES, onError: (c) => { const error = requestTooLarge("Request exceeds the maximum size. The Skills API accepts requests up to 30MBs."); return c.json(toApiErrorBody(error, c.get("requestId")), 413); } }));
|
|
18
|
+
app.post("/", async (c) => { const body = await multipart(c.req); return c.json(await service.create(workspaceIdFrom(c), stringField(body.display_title), await files(body["files[]"])), 200); });
|
|
19
|
+
app.get("/", (c) => c.json(service.list(workspaceIdFrom(c), parseLimit(c.req.query("limit")) ?? 20, c.req.query("page") || undefined), 200));
|
|
20
|
+
app.get("/:id", (c) => c.json(service.get(workspaceIdFrom(c), c.req.param("id")), 200));
|
|
21
|
+
app.delete("/:id", (c) => c.json(service.delete(workspaceIdFrom(c), c.req.param("id")), 200));
|
|
22
|
+
app.post("/:id/versions", async (c) => { const body = await multipart(c.req); return c.json(await service.createVersion(workspaceIdFrom(c), c.req.param("id"), await files(body["files[]"])), 200); });
|
|
23
|
+
app.get("/:id/versions", (c) => c.json(service.listVersions(workspaceIdFrom(c), c.req.param("id"), parseLimit(c.req.query("limit")) ?? 20, c.req.query("page") || undefined), 200));
|
|
24
|
+
app.get("/:id/versions/:version", (c) => c.json(service.getVersion(workspaceIdFrom(c), c.req.param("id"), c.req.param("version")), 200));
|
|
25
|
+
app.delete("/:id/versions/:version", (c) => c.json(service.deleteVersion(workspaceIdFrom(c), c.req.param("id"), c.req.param("version")), 200));
|
|
26
|
+
return app;
|
|
27
|
+
}
|
|
28
|
+
async function multipart(req) { try {
|
|
29
|
+
return await req.parseBody({ all: true });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw invalidRequest("Request body must be valid multipart/form-data", String(error));
|
|
33
|
+
} }
|
|
34
|
+
function isFile(value) { return value instanceof NodeFile || (!!value && typeof value === "object" && "arrayBuffer" in value && "name" in value); }
|
|
35
|
+
async function files(value) { const values = Array.isArray(value) ? value : value === undefined ? [] : [value]; const out = []; for (const value of values) {
|
|
36
|
+
if (!isFile(value))
|
|
37
|
+
throw invalidRequest("`files[]` is required");
|
|
38
|
+
out.push({ name: value.name, bytes: new Uint8Array(await value.arrayBuffer()) });
|
|
39
|
+
} if (out.length === 0)
|
|
40
|
+
throw invalidRequest("`files[]` is required"); return out; }
|
|
41
|
+
function stringField(value) { return typeof value === "string" ? value : undefined; }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { invalidRequest, notFound } from "../errors.js";
|
|
2
|
+
import { validateSkillUpload } from "./archive.js";
|
|
3
|
+
export class DefaultSkillsService {
|
|
4
|
+
store;
|
|
5
|
+
constructor(store) {
|
|
6
|
+
this.store = store;
|
|
7
|
+
}
|
|
8
|
+
async create(workspaceId, displayTitle, files) {
|
|
9
|
+
const bundle = await validateSkillUpload(files);
|
|
10
|
+
const title = (displayTitle ?? bundle.name).trim();
|
|
11
|
+
if (!title)
|
|
12
|
+
throw invalidRequest("display_title must not be empty");
|
|
13
|
+
return this.store.createSkill(workspaceId, title, bundle);
|
|
14
|
+
}
|
|
15
|
+
async createVersion(workspaceId, skillId, files) {
|
|
16
|
+
this.get(workspaceId, skillId);
|
|
17
|
+
return this.store.createVersion(workspaceId, skillId, await validateSkillUpload(files));
|
|
18
|
+
}
|
|
19
|
+
get(workspaceId, skillId) { const value = this.store.getSkill(workspaceId, skillId); if (!value)
|
|
20
|
+
throw notFound(`Skill ${skillId} not found`); return value; }
|
|
21
|
+
list(workspaceId, limit, after) { return this.store.listSkills(workspaceId, limit, after); }
|
|
22
|
+
getVersion(workspaceId, skillId, version) { const value = this.store.getVersion(workspaceId, skillId, version); if (!value)
|
|
23
|
+
throw notFound(`Skill version ${version} not found`); return value; }
|
|
24
|
+
listVersions(workspaceId, skillId, limit, after) { this.get(workspaceId, skillId); return this.store.listVersions(workspaceId, skillId, limit, after); }
|
|
25
|
+
deleteVersion(workspaceId, skillId, version) { if (!this.store.deleteVersion(workspaceId, skillId, version))
|
|
26
|
+
throw notFound(`Skill version ${version} not found`); return { id: version, type: "skill_version_deleted" }; }
|
|
27
|
+
delete(workspaceId, skillId) { if (!this.store.deleteSkill(workspaceId, skillId))
|
|
28
|
+
throw notFound(`Skill ${skillId} not found`); return { id: skillId, type: "skill_deleted" }; }
|
|
29
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { invalidRequest, notFound } from "../errors.js";
|
|
6
|
+
import { newSkillContentObjectId, newSkillId, newSkillVersionId } from "../ids.js";
|
|
7
|
+
import { withSqliteTransaction } from "../sqlite-transaction.js";
|
|
8
|
+
import { DEFAULT_SKILLS_MAX_VERSIONS, DEFAULT_SKILLS_WORKSPACE_MAX_BYTES, } from "./types.js";
|
|
9
|
+
const SCHEMA = `
|
|
10
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
11
|
+
workspace_id TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL,
|
|
12
|
+
display_title TEXT NOT NULL, source TEXT NOT NULL, latest_version TEXT,
|
|
13
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
14
|
+
PRIMARY KEY(workspace_id,id), UNIQUE(workspace_id,name), UNIQUE(workspace_id,display_title)
|
|
15
|
+
);
|
|
16
|
+
CREATE TABLE IF NOT EXISTS skill_versions (
|
|
17
|
+
workspace_id TEXT NOT NULL, skill_id TEXT NOT NULL, id TEXT NOT NULL, version TEXT NOT NULL,
|
|
18
|
+
description TEXT NOT NULL, directory TEXT NOT NULL, file_count INTEGER NOT NULL,
|
|
19
|
+
total_bytes INTEGER NOT NULL, manifest_sha256 TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
20
|
+
PRIMARY KEY(workspace_id,skill_id,version), UNIQUE(workspace_id,id),
|
|
21
|
+
FOREIGN KEY(workspace_id,skill_id) REFERENCES skills(workspace_id,id) ON DELETE CASCADE
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS skill_files (
|
|
24
|
+
workspace_id TEXT NOT NULL, skill_id TEXT NOT NULL, version TEXT NOT NULL, path TEXT NOT NULL,
|
|
25
|
+
size_bytes INTEGER NOT NULL, sha256 TEXT NOT NULL, content_object_id TEXT NOT NULL UNIQUE,
|
|
26
|
+
PRIMARY KEY(workspace_id,skill_id,version,path),
|
|
27
|
+
FOREIGN KEY(workspace_id,skill_id,version) REFERENCES skill_versions(workspace_id,skill_id,version) ON DELETE CASCADE
|
|
28
|
+
);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS pending_skill_content_rollbacks (
|
|
30
|
+
workspace_id TEXT NOT NULL, content_object_id TEXT PRIMARY KEY, created_at TEXT NOT NULL
|
|
31
|
+
);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS pending_skill_content_deletes (
|
|
33
|
+
workspace_id TEXT NOT NULL, content_object_id TEXT PRIMARY KEY, created_at TEXT NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS skills_list ON skills(workspace_id,id);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS skill_versions_list ON skill_versions(workspace_id,skill_id,version);
|
|
37
|
+
`;
|
|
38
|
+
let versionTick = 0;
|
|
39
|
+
function newVersion() {
|
|
40
|
+
const base = Date.now() * 1000;
|
|
41
|
+
versionTick = (versionTick + 1) % 1000;
|
|
42
|
+
return String(base + versionTick);
|
|
43
|
+
}
|
|
44
|
+
// Versions are fixed-width 16-digit microsecond timestamps for the supported
|
|
45
|
+
// clock range, so SQLite TEXT ordering is chronological ordering.
|
|
46
|
+
export class SqliteSkillsStore {
|
|
47
|
+
db;
|
|
48
|
+
objectsDir;
|
|
49
|
+
memoryObjects;
|
|
50
|
+
maxWorkspaceBytes;
|
|
51
|
+
maxVersions;
|
|
52
|
+
workspaceBytesStmt;
|
|
53
|
+
constructor(db, objectRoot, opts = {}) {
|
|
54
|
+
this.db = db;
|
|
55
|
+
this.objectsDir = objectRoot === undefined ? undefined : resolve(objectRoot, "skill-objects");
|
|
56
|
+
this.memoryObjects = objectRoot === undefined ? new Map() : undefined;
|
|
57
|
+
if (this.objectsDir !== undefined) {
|
|
58
|
+
mkdirSync(this.objectsDir, { recursive: true, mode: 0o700 });
|
|
59
|
+
chmodSync(this.objectsDir, 0o700);
|
|
60
|
+
}
|
|
61
|
+
db.exec(SCHEMA);
|
|
62
|
+
this.maxWorkspaceBytes = opts.maxWorkspaceBytes ?? DEFAULT_SKILLS_WORKSPACE_MAX_BYTES;
|
|
63
|
+
this.maxVersions = opts.maxVersions ?? DEFAULT_SKILLS_MAX_VERSIONS;
|
|
64
|
+
this.workspaceBytesStmt = db.prepare("SELECT COALESCE(SUM(total_bytes),0) total FROM skill_versions WHERE workspace_id = ?");
|
|
65
|
+
// Deployment storage holds an exclusive appliance lock. Sweeping all
|
|
66
|
+
// pending intents is safe only under that documented single-process model.
|
|
67
|
+
this.sweep("pending_skill_content_rollbacks");
|
|
68
|
+
this.sweep("pending_skill_content_deletes");
|
|
69
|
+
}
|
|
70
|
+
createSkill(workspaceId, displayTitle, bundle) {
|
|
71
|
+
const now = new Date().toISOString();
|
|
72
|
+
const id = newSkillId();
|
|
73
|
+
const version = newVersion();
|
|
74
|
+
this.publish(workspaceId, { id, displayTitle, version, now, bundle, createOwner: true });
|
|
75
|
+
return this.getSkill(workspaceId, id);
|
|
76
|
+
}
|
|
77
|
+
createVersion(workspaceId, skillId, bundle) {
|
|
78
|
+
const owner = this.row(workspaceId, skillId);
|
|
79
|
+
if (!owner)
|
|
80
|
+
throw notFound(`Skill ${skillId} not found`);
|
|
81
|
+
if (owner.name !== bundle.name)
|
|
82
|
+
throw invalidRequest("Skill name and directory are immutable across versions");
|
|
83
|
+
const count = Number(this.db.prepare("SELECT COUNT(*) n FROM skill_versions WHERE workspace_id=? AND skill_id=?").get(workspaceId, skillId).n);
|
|
84
|
+
if (count >= this.maxVersions)
|
|
85
|
+
throw invalidRequest(`Skill may retain at most ${this.maxVersions} versions`);
|
|
86
|
+
const version = newVersion();
|
|
87
|
+
this.publish(workspaceId, { id: skillId, displayTitle: owner.display_title, version, now: new Date().toISOString(), bundle, createOwner: false });
|
|
88
|
+
return this.getVersion(workspaceId, skillId, version);
|
|
89
|
+
}
|
|
90
|
+
publish(workspaceId, input) {
|
|
91
|
+
const current = this.workspaceBytesStmt.get(workspaceId).total;
|
|
92
|
+
if (current + input.bundle.totalBytes > this.maxWorkspaceBytes)
|
|
93
|
+
throw invalidRequest("Workspace skill content quota exceeded");
|
|
94
|
+
const objects = input.bundle.files.map((file) => ({ file, id: newSkillContentObjectId() }));
|
|
95
|
+
withSqliteTransaction(this.db, () => {
|
|
96
|
+
const stmt = this.db.prepare("INSERT INTO pending_skill_content_rollbacks VALUES (?,?,?)");
|
|
97
|
+
for (const object of objects)
|
|
98
|
+
stmt.run(workspaceId, object.id, input.now);
|
|
99
|
+
});
|
|
100
|
+
try {
|
|
101
|
+
for (const object of objects)
|
|
102
|
+
this.writeObject(object.id, object.file.bytes);
|
|
103
|
+
withSqliteTransaction(this.db, () => {
|
|
104
|
+
if (input.createOwner) {
|
|
105
|
+
this.db.prepare("INSERT INTO skills VALUES (?,?,?,?,?,?,?,?)").run(workspaceId, input.id, input.bundle.name, input.displayTitle, "custom", input.version, input.now, input.now);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
this.db.prepare("UPDATE skills SET latest_version=?,updated_at=? WHERE workspace_id=? AND id=?").run(input.version, input.now, workspaceId, input.id);
|
|
109
|
+
}
|
|
110
|
+
this.db.prepare("INSERT INTO skill_versions VALUES (?,?,?,?,?,?,?,?,?,?)").run(workspaceId, input.id, newSkillVersionId(), input.version, input.bundle.description, input.bundle.directory, input.bundle.files.length, input.bundle.totalBytes, input.bundle.manifestSha256, input.now);
|
|
111
|
+
const fileStmt = this.db.prepare("INSERT INTO skill_files VALUES (?,?,?,?,?,?,?)");
|
|
112
|
+
for (const object of objects)
|
|
113
|
+
fileStmt.run(workspaceId, input.id, input.version, object.file.path, object.file.size, object.file.sha256, object.id);
|
|
114
|
+
const clear = this.db.prepare("DELETE FROM pending_skill_content_rollbacks WHERE content_object_id=?");
|
|
115
|
+
for (const object of objects)
|
|
116
|
+
clear.run(object.id);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
this.sweep("pending_skill_content_rollbacks");
|
|
121
|
+
throw normalizeConstraint(error, input.displayTitle);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
getSkill(workspaceId, skillId) { const row = this.row(workspaceId, skillId); return row && toSkill(row); }
|
|
125
|
+
listSkills(workspaceId, limit, after) {
|
|
126
|
+
const rows = this.db.prepare(`SELECT * FROM skills WHERE workspace_id=? ${after ? "AND id>?" : ""} ORDER BY id LIMIT ?`).all(...(after ? [workspaceId, after, limit + 1] : [workspaceId, limit + 1]));
|
|
127
|
+
return page(rows, limit, toSkill, (row) => row.id);
|
|
128
|
+
}
|
|
129
|
+
getVersion(workspaceId, skillId, version) {
|
|
130
|
+
const resolved = version === "latest" ? this.row(workspaceId, skillId)?.latest_version : version;
|
|
131
|
+
if (!resolved)
|
|
132
|
+
return undefined;
|
|
133
|
+
const row = this.db.prepare("SELECT * FROM skill_versions WHERE workspace_id=? AND skill_id=? AND version=?").get(workspaceId, skillId, resolved);
|
|
134
|
+
const owner = this.row(workspaceId, skillId);
|
|
135
|
+
return row && owner ? toVersion(row, owner.name) : undefined;
|
|
136
|
+
}
|
|
137
|
+
getVersionFiles(workspaceId, skillId, version) {
|
|
138
|
+
return this.db.prepare("SELECT path,size_bytes,sha256 FROM skill_files WHERE workspace_id=? AND skill_id=? AND version=? ORDER BY path")
|
|
139
|
+
.all(workspaceId, skillId, version)
|
|
140
|
+
.map((row) => {
|
|
141
|
+
const file = row;
|
|
142
|
+
return { path: file.path, sizeBytes: file.size_bytes, sha256: file.sha256 };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
listVersions(workspaceId, skillId, limit, after) {
|
|
146
|
+
const owner = this.row(workspaceId, skillId);
|
|
147
|
+
if (!owner)
|
|
148
|
+
return { data: [], has_more: false, next_page: null };
|
|
149
|
+
const rows = this.db.prepare(`SELECT * FROM skill_versions WHERE workspace_id=? AND skill_id=? ${after ? "AND version>?" : ""} ORDER BY version LIMIT ?`).all(...(after ? [workspaceId, skillId, after, limit + 1] : [workspaceId, skillId, limit + 1]));
|
|
150
|
+
return page(rows, limit, (row) => toVersion(row, owner.name), (row) => row.version);
|
|
151
|
+
}
|
|
152
|
+
deleteVersion(workspaceId, skillId, version) {
|
|
153
|
+
const resolved = version === "latest" ? this.row(workspaceId, skillId)?.latest_version : version;
|
|
154
|
+
if (!resolved)
|
|
155
|
+
return false;
|
|
156
|
+
const row = this.db.prepare("SELECT version FROM skill_versions WHERE workspace_id=? AND skill_id=? AND version=?").get(workspaceId, skillId, resolved);
|
|
157
|
+
if (!row)
|
|
158
|
+
return false;
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
withSqliteTransaction(this.db, () => {
|
|
161
|
+
this.db.prepare("INSERT OR IGNORE INTO pending_skill_content_deletes SELECT workspace_id,content_object_id,? FROM skill_files WHERE workspace_id=? AND skill_id=? AND version=?").run(now, workspaceId, skillId, resolved);
|
|
162
|
+
this.db.prepare("DELETE FROM skill_versions WHERE workspace_id=? AND skill_id=? AND version=?").run(workspaceId, skillId, resolved);
|
|
163
|
+
const latest = this.db.prepare("SELECT version FROM skill_versions WHERE workspace_id=? AND skill_id=? ORDER BY version DESC LIMIT 1").get(workspaceId, skillId);
|
|
164
|
+
this.db.prepare("UPDATE skills SET latest_version=?,updated_at=? WHERE workspace_id=? AND id=?").run(latest?.version ?? null, now, workspaceId, skillId);
|
|
165
|
+
});
|
|
166
|
+
this.sweep("pending_skill_content_deletes");
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
deleteSkill(workspaceId, skillId) {
|
|
170
|
+
const owner = this.row(workspaceId, skillId);
|
|
171
|
+
if (!owner)
|
|
172
|
+
return false;
|
|
173
|
+
const count = this.db.prepare("SELECT COUNT(*) n FROM skill_versions WHERE workspace_id=? AND skill_id=?").get(workspaceId, skillId).n;
|
|
174
|
+
if (count > 0)
|
|
175
|
+
throw invalidRequest("Skill versions must be deleted before deleting the skill");
|
|
176
|
+
this.db.prepare("DELETE FROM skills WHERE workspace_id=? AND id=?").run(workspaceId, skillId);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
openContent(workspaceId, skillId, version, path) {
|
|
180
|
+
const row = this.db.prepare("SELECT content_object_id FROM skill_files WHERE workspace_id=? AND skill_id=? AND version=? AND path=?").get(workspaceId, skillId, version, path);
|
|
181
|
+
if (!row)
|
|
182
|
+
return undefined;
|
|
183
|
+
if (this.memoryObjects !== undefined)
|
|
184
|
+
return this.memoryObjects.get(row.content_object_id)?.slice();
|
|
185
|
+
try {
|
|
186
|
+
return new Uint8Array(readFileSync(this.objectPath(row.content_object_id)));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error.code === "ENOENT")
|
|
190
|
+
return undefined;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
row(workspaceId, id) { return this.db.prepare("SELECT * FROM skills WHERE workspace_id=? AND id=?").get(workspaceId, id); }
|
|
195
|
+
objectPath(id) { if (this.objectsDir === undefined)
|
|
196
|
+
throw new Error("Skill object path unavailable for memory store"); const shard = createHash("sha256").update(id).digest("hex").slice(0, 2); return resolve(this.objectsDir, shard, id); }
|
|
197
|
+
writeObject(id, bytes) { if (this.memoryObjects !== undefined) {
|
|
198
|
+
if (this.memoryObjects.has(id))
|
|
199
|
+
throw new Error(`Skill content object ${id} already exists`);
|
|
200
|
+
this.memoryObjects.set(id, bytes.slice());
|
|
201
|
+
return;
|
|
202
|
+
} const path = this.objectPath(id); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, bytes, { mode: 0o600, flag: "wx" }); }
|
|
203
|
+
sweep(table) {
|
|
204
|
+
const rows = this.db.prepare(`SELECT content_object_id FROM ${table}`).all();
|
|
205
|
+
for (const row of rows) {
|
|
206
|
+
if (this.memoryObjects !== undefined)
|
|
207
|
+
this.memoryObjects.delete(row.content_object_id);
|
|
208
|
+
else {
|
|
209
|
+
try {
|
|
210
|
+
unlinkSync(this.objectPath(row.content_object_id));
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error.code !== "ENOENT")
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
this.db.prepare(`DELETE FROM ${table} WHERE content_object_id=?`).run(row.content_object_id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
close() { }
|
|
221
|
+
}
|
|
222
|
+
export class InMemorySkillsStore extends SqliteSkillsStore {
|
|
223
|
+
constructor(opts = {}) {
|
|
224
|
+
super(new DatabaseSync(":memory:"), undefined, opts);
|
|
225
|
+
}
|
|
226
|
+
close() { this.db.close(); }
|
|
227
|
+
}
|
|
228
|
+
function toSkill(row) { return { id: row.id, display_title: row.display_title, latest_version: row.latest_version, source: "custom", type: "skill", created_at: row.created_at, updated_at: row.updated_at }; }
|
|
229
|
+
function toVersion(row, name) { return { id: row.id, skill_id: row.skill_id, version: row.version, name, description: row.description, directory: row.directory, type: "skill_version", created_at: row.created_at }; }
|
|
230
|
+
function page(rows, limit, map, cursor) { const dataRows = rows.slice(0, limit); return { data: dataRows.map(map), has_more: rows.length > limit, next_page: rows.length > limit ? cursor(dataRows[dataRows.length - 1]) : null }; }
|
|
231
|
+
function normalizeConstraint(error, displayTitle) { const message = error instanceof Error ? error.message : ""; if (message.includes("skills.workspace_id, skills.display_title"))
|
|
232
|
+
return invalidRequest(`Skill cannot reuse an existing display_title: ${displayTitle}`); if (message.includes("skills.workspace_id, skills.name"))
|
|
233
|
+
return invalidRequest("Skill cannot reuse an existing name"); return error; }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const MAX_SKILL_FILE_BYTES = 20 * 1024 * 1024;
|
|
2
|
+
export const MAX_SKILL_VERSION_BYTES = 100 * 1024 * 1024;
|
|
3
|
+
export const MAX_SKILL_FILES = 500;
|
|
4
|
+
export const DEFAULT_SKILLS_WORKSPACE_MAX_BYTES = 1024 * 1024 * 1024;
|
|
5
|
+
export const DEFAULT_SKILLS_MAX_VERSIONS = 20;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
let nextSavepointId = 0;
|
|
2
|
+
export function withSqliteTransaction(db, fn) {
|
|
3
|
+
const savepoint = `oma_tx_${nextSavepointId++}`;
|
|
4
|
+
db.exec(`SAVEPOINT ${savepoint}`);
|
|
5
|
+
try {
|
|
6
|
+
const result = fn();
|
|
7
|
+
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
8
|
+
return result;
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
|
|
12
|
+
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { invalidRequest } from "../errors.js";
|
|
2
|
+
import { probeMcpInitialize, } from "../sessions/pi/mcp/probe.js";
|
|
3
|
+
export async function validateMcpOauthCredential(service, deps, workspaceId, vaultId, credentialId) {
|
|
4
|
+
const credential = service.retrieveCredential(workspaceId, vaultId, credentialId);
|
|
5
|
+
if (credential.archived_at !== null)
|
|
6
|
+
throw invalidRequest("Credential is archived.");
|
|
7
|
+
if (credential.auth.type !== "mcp_oauth") {
|
|
8
|
+
throw invalidRequest("The request was invalid");
|
|
9
|
+
}
|
|
10
|
+
const initial = service.readOauthValidationSnapshot(workspaceId, vaultId, credentialId);
|
|
11
|
+
if (initial === undefined)
|
|
12
|
+
throw invalidRequest("The request was invalid");
|
|
13
|
+
const firstProbe = await probe(initial, deps, secretValues(initial));
|
|
14
|
+
const hasRefreshToken = initial.secrets.refreshToken !== undefined;
|
|
15
|
+
if (!isAuthRejection(firstProbe)) {
|
|
16
|
+
return result({
|
|
17
|
+
credentialId,
|
|
18
|
+
vaultId,
|
|
19
|
+
validatedAt: now(deps),
|
|
20
|
+
hasRefreshToken,
|
|
21
|
+
status: probeStatus(firstProbe),
|
|
22
|
+
probe: firstProbe,
|
|
23
|
+
refreshStatus: "not_attempted",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (initial.refresh === undefined || !hasRefreshToken) {
|
|
27
|
+
return result({
|
|
28
|
+
credentialId,
|
|
29
|
+
vaultId,
|
|
30
|
+
validatedAt: now(deps),
|
|
31
|
+
hasRefreshToken,
|
|
32
|
+
status: "invalid",
|
|
33
|
+
probe: firstProbe,
|
|
34
|
+
refreshStatus: "no_refresh_token",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const refreshed = await deps.refresh.refreshCredential({
|
|
38
|
+
workspaceId,
|
|
39
|
+
vaultId,
|
|
40
|
+
credentialId,
|
|
41
|
+
trigger: "validate",
|
|
42
|
+
expectedAuthVersion: initial.authVersion,
|
|
43
|
+
});
|
|
44
|
+
if (refreshed.outcome !== "ok") {
|
|
45
|
+
const shouldReprobeCurrent = (refreshed.outcome === "skipped" && refreshed.reason === "stale_version") ||
|
|
46
|
+
(refreshed.outcome !== "skipped" && refreshed.persisted === "stale");
|
|
47
|
+
if (shouldReprobeCurrent) {
|
|
48
|
+
const current = service.readOauthValidationSnapshot(workspaceId, vaultId, credentialId);
|
|
49
|
+
if (current === undefined) {
|
|
50
|
+
assertCredentialStillActive(service, workspaceId, vaultId, credentialId);
|
|
51
|
+
}
|
|
52
|
+
else if (current.authVersion !== initial.authVersion) {
|
|
53
|
+
const currentProbe = await probe(current, deps, [
|
|
54
|
+
...secretValues(initial),
|
|
55
|
+
...secretValues(current),
|
|
56
|
+
]);
|
|
57
|
+
return result({
|
|
58
|
+
credentialId,
|
|
59
|
+
vaultId,
|
|
60
|
+
validatedAt: now(deps),
|
|
61
|
+
hasRefreshToken: current.secrets.refreshToken !== undefined,
|
|
62
|
+
status: isAuthRejection(currentProbe)
|
|
63
|
+
? "invalid"
|
|
64
|
+
: probeStatus(currentProbe),
|
|
65
|
+
probe: currentProbe,
|
|
66
|
+
refreshStatus: refreshed.outcome === "skipped" ? "skipped" : "failed",
|
|
67
|
+
refreshResponse: refreshed.tokenEndpointResponse,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result({
|
|
72
|
+
credentialId,
|
|
73
|
+
vaultId,
|
|
74
|
+
validatedAt: now(deps),
|
|
75
|
+
hasRefreshToken,
|
|
76
|
+
status: refreshed.outcome === "invalid" ? "invalid" : "unknown",
|
|
77
|
+
probe: firstProbe,
|
|
78
|
+
refreshStatus: refreshed.outcome === "skipped" ? "skipped" : "failed",
|
|
79
|
+
refreshResponse: refreshed.tokenEndpointResponse,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const current = service.readOauthValidationSnapshot(workspaceId, vaultId, credentialId);
|
|
83
|
+
if (current === undefined) {
|
|
84
|
+
assertCredentialStillActive(service, workspaceId, vaultId, credentialId);
|
|
85
|
+
throw invalidRequest("The request was invalid");
|
|
86
|
+
}
|
|
87
|
+
const secondProbe = await probe(current, deps, [
|
|
88
|
+
...secretValues(initial),
|
|
89
|
+
...secretValues(current),
|
|
90
|
+
]);
|
|
91
|
+
return result({
|
|
92
|
+
credentialId,
|
|
93
|
+
vaultId,
|
|
94
|
+
validatedAt: now(deps),
|
|
95
|
+
hasRefreshToken: current.secrets.refreshToken !== undefined,
|
|
96
|
+
status: isAuthRejection(secondProbe)
|
|
97
|
+
? "invalid"
|
|
98
|
+
: probeStatus(secondProbe),
|
|
99
|
+
probe: secondProbe,
|
|
100
|
+
refreshStatus: "refreshed",
|
|
101
|
+
refreshResponse: refreshed.tokenEndpointResponse,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function probe(state, deps, knownSecrets) {
|
|
105
|
+
const accessToken = state.secrets.accessToken;
|
|
106
|
+
return probeMcpInitialize(state.mcpServerUrl, accessToken === undefined ? undefined : `Bearer ${accessToken}`, deps.fetch, {
|
|
107
|
+
capBytes: 4096,
|
|
108
|
+
timeoutMs: deps.operationTimeoutMs,
|
|
109
|
+
knownSecrets,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function result(input) {
|
|
113
|
+
return {
|
|
114
|
+
type: "vault_credential_validation",
|
|
115
|
+
credential_id: input.credentialId,
|
|
116
|
+
vault_id: input.vaultId,
|
|
117
|
+
validated_at: input.validatedAt,
|
|
118
|
+
has_refresh_token: input.hasRefreshToken,
|
|
119
|
+
status: input.status,
|
|
120
|
+
mcp_probe: {
|
|
121
|
+
method: "initialize",
|
|
122
|
+
http_response: probeHttpResponse(input.probe),
|
|
123
|
+
},
|
|
124
|
+
refresh: {
|
|
125
|
+
status: input.refreshStatus,
|
|
126
|
+
http_response: refreshHttpResponse(input.refreshResponse),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function probeHttpResponse(probe) {
|
|
131
|
+
return probe.reached
|
|
132
|
+
? {
|
|
133
|
+
status_code: probe.statusCode,
|
|
134
|
+
content_type: probe.contentType,
|
|
135
|
+
body: probe.body,
|
|
136
|
+
body_truncated: probe.bodyTruncated,
|
|
137
|
+
}
|
|
138
|
+
: null;
|
|
139
|
+
}
|
|
140
|
+
function refreshHttpResponse(response) {
|
|
141
|
+
return response === undefined
|
|
142
|
+
? null
|
|
143
|
+
: { status_code: response.statusCode, content_type: response.contentType };
|
|
144
|
+
}
|
|
145
|
+
function probeStatus(probe) {
|
|
146
|
+
return probe.reached && probe.initializeSucceeded ? "valid" : "unknown";
|
|
147
|
+
}
|
|
148
|
+
function isAuthRejection(probe) {
|
|
149
|
+
return probe.reached && (probe.statusCode === 401 || probe.statusCode === 403);
|
|
150
|
+
}
|
|
151
|
+
function secretValues(state) {
|
|
152
|
+
return [
|
|
153
|
+
state.secrets.accessToken,
|
|
154
|
+
state.secrets.refreshToken,
|
|
155
|
+
state.secrets.clientSecret,
|
|
156
|
+
].filter((value) => value !== undefined);
|
|
157
|
+
}
|
|
158
|
+
function now(deps) {
|
|
159
|
+
return (deps.now?.() ?? new Date()).toISOString();
|
|
160
|
+
}
|
|
161
|
+
function assertCredentialStillActive(service, workspaceId, vaultId, credentialId) {
|
|
162
|
+
const credential = service.retrieveCredential(workspaceId, vaultId, credentialId);
|
|
163
|
+
if (credential.archived_at !== null)
|
|
164
|
+
throw invalidRequest("Credential is archived.");
|
|
165
|
+
}
|