@worca/app 0.0.1
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 +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// src/core/plugin-manifest.mjs
|
|
2
|
+
// Parse + validate `worca-cc-plugin.json` (plugin spec §4.1) and whole plugin
|
|
3
|
+
// dirs (§6.6 `worca plugin validate [--strict]`). Pure: fs reads only, no
|
|
4
|
+
// writes, no DB, no worcaHome — callers pass absolute dirs.
|
|
5
|
+
|
|
6
|
+
import { readFileSync, readdirSync, readlinkSync, existsSync } from 'node:fs';
|
|
7
|
+
import { join, resolve, dirname, sep, isAbsolute } from 'node:path';
|
|
8
|
+
import { WORCA_PLUGIN_APIS } from './plugin-api.mjs';
|
|
9
|
+
import { EFFORTS, isReservedModelEnvKey } from './model-env.mjs';
|
|
10
|
+
|
|
11
|
+
/** Plugin names are kebab-case, machine-unique, dir-name safe (spec §4.1). */
|
|
12
|
+
export const PLUGIN_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
13
|
+
/** Private copy of agent-registry.mjs:175 AGENT_KEY_RE (module-private there;
|
|
14
|
+
* agent-store.mjs:15 duplicates it the same way). */
|
|
15
|
+
const KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
16
|
+
const SOURCE_ID_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
17
|
+
const FIELD_TYPES = new Set(['text', 'select']);
|
|
18
|
+
const INPUT_TYPES = new Set(['text', 'select', 'remote-select', 'task-browser']);
|
|
19
|
+
const KNOWN_TOP = new Set(['name', 'version', 'description', 'author', 'homepage', 'license', 'engines', 'taskSources', 'chatChannels', 'setup', 'models', 'modelSecrets']);
|
|
20
|
+
const KNOWN_SOURCE = new Set(['id', 'displayName', 'module', 'configSchema', 'inputs']);
|
|
21
|
+
const KNOWN_CHANNEL = new Set(['id', 'displayName', 'platform', 'module', 'ingress', 'capabilities', 'configSchema']);
|
|
22
|
+
const CHANNEL_INGRESS = new Set(['connect', 'webhook']);
|
|
23
|
+
const KNOWN_FIELD = new Set(['key', 'type', 'label', 'secret', 'required', 'default', 'help', 'options']);
|
|
24
|
+
const KNOWN_INPUT = new Set(['key', 'type', 'label', 'default', 'optionsFrom', 'options']);
|
|
25
|
+
const KNOWN_MODEL = new Set(['id', 'label', 'efforts', 'env']);
|
|
26
|
+
const KNOWN_MODEL_SECRET = new Set(['key', 'label']);
|
|
27
|
+
/** A manifest env value that defers to the plugin's secrets store (design §9.1). */
|
|
28
|
+
const isSecretRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
29
|
+
&& typeof v.secret === 'string' && Object.keys(v).length === 1;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tiny '>=N <M' / '>=N' / exact 'N' range check against the integer host APIs.
|
|
33
|
+
* NO npm semver dep (repo rule: runtime deps are express+ws only). Clauses are
|
|
34
|
+
* whitespace-separated and AND-ed; minor/patch digits are tolerated but ignored
|
|
35
|
+
* (the API version is an integer). The host advertises an API SET
|
|
36
|
+
* (WORCA_PLUGIN_APIS) — the range is satisfied if ANY member satisfies all
|
|
37
|
+
* clauses, so old ">=1 <2" manifests keep installing after an API bump.
|
|
38
|
+
* Unset/blank -> true (no constraint); any unparseable token -> false (fail
|
|
39
|
+
* CLOSED: an unintelligible constraint must not install). A number `apis` arg
|
|
40
|
+
* is accepted for back-compat with callers/tests that pass a single API.
|
|
41
|
+
*/
|
|
42
|
+
export function apiSatisfies(range, apis = WORCA_PLUGIN_APIS) {
|
|
43
|
+
return negotiatedApi(range, apis) !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Highest host API satisfying `range`, or null. Drives the apiVersion handed
|
|
48
|
+
* to plugin children: an API-1 connector keeps receiving 1 after a host bump.
|
|
49
|
+
*/
|
|
50
|
+
export function negotiatedApi(range, apis = WORCA_PLUGIN_APIS) {
|
|
51
|
+
const list = typeof apis === 'number' ? [apis] : apis;
|
|
52
|
+
const spec = typeof range === 'string' ? range.trim() : '';
|
|
53
|
+
const clauses = [];
|
|
54
|
+
if (spec) {
|
|
55
|
+
for (const tok of spec.split(/\s+/)) {
|
|
56
|
+
const m = /^(>=|<=|>|<|=)?(\d+)(?:\.\d+){0,2}$/.exec(tok);
|
|
57
|
+
if (!m) return null; // fail closed
|
|
58
|
+
clauses.push({ op: m[1] || '=', n: Number(m[2]) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let best = null;
|
|
62
|
+
for (const api of list) {
|
|
63
|
+
const ok = clauses.every(({ op, n }) => (
|
|
64
|
+
op === '>=' ? api >= n : op === '<=' ? api <= n
|
|
65
|
+
: op === '>' ? api > n : op === '<' ? api < n : api === n));
|
|
66
|
+
if (ok && (best === null || api > best)) best = api;
|
|
67
|
+
}
|
|
68
|
+
return best;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const str = (v, d = '') => (typeof v === 'string' ? v.trim() : d);
|
|
72
|
+
|
|
73
|
+
function normOptions(raw) {
|
|
74
|
+
if (!Array.isArray(raw)) return [];
|
|
75
|
+
return raw.map((o) => (typeof o === 'string'
|
|
76
|
+
? { value: o, label: o }
|
|
77
|
+
: o && typeof o === 'object' && typeof o.value === 'string'
|
|
78
|
+
? { value: o.value, label: str(o.label) || o.value }
|
|
79
|
+
: null)).filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function collectUnknown(obj, known, where, warnings) {
|
|
83
|
+
for (const k of Object.keys(obj)) {
|
|
84
|
+
if (!known.has(k)) warnings.push(`${where}: unknown field "${k}" ignored`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Shared configSchema field normalizer — identical semantics for taskSources
|
|
89
|
+
* and chatChannels, so plugin-config.mjs (secrets, $env, defaults) needs no
|
|
90
|
+
* per-contribution knowledge. Appends to errors/warnings, returns the list. */
|
|
91
|
+
function normConfigSchema(rawList, at, errors, warnings) {
|
|
92
|
+
const configSchema = [];
|
|
93
|
+
(Array.isArray(rawList) ? rawList : []).forEach((f, j) => {
|
|
94
|
+
const fat = `${at}.configSchema[${j}]`;
|
|
95
|
+
if (!f || typeof f !== 'object') { errors.push(`${fat} must be an object`); return; }
|
|
96
|
+
collectUnknown(f, KNOWN_FIELD, fat, warnings);
|
|
97
|
+
const key = str(f.key);
|
|
98
|
+
if (!KEY_RE.test(key)) { errors.push(`${fat}: "key" must be an identifier, got "${key}"`); return; }
|
|
99
|
+
const type = str(f.type) || 'text';
|
|
100
|
+
if (!FIELD_TYPES.has(type)) { errors.push(`${fat} ("${key}"): type must be text|select, got "${type}"`); return; }
|
|
101
|
+
const options = normOptions(f.options);
|
|
102
|
+
if (type === 'select' && !options.length) errors.push(`${fat} ("${key}"): select fields need "options"`);
|
|
103
|
+
configSchema.push({
|
|
104
|
+
key, type, label: str(f.label) || key,
|
|
105
|
+
secret: f.secret === true, required: f.required === true,
|
|
106
|
+
default: f.default ?? null, help: str(f.help) || null, options,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
return configSchema;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function badModulePath(mod) {
|
|
113
|
+
if (!mod) return 'is required';
|
|
114
|
+
if (isAbsolute(mod) || /\\/.test(mod)) return 'must be a relative ./ path';
|
|
115
|
+
if (!mod.startsWith('./')) return 'must start with "./"';
|
|
116
|
+
if (mod.split('/').includes('..')) return 'must not contain ".."';
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Normalize a parsed worca-cc-plugin.json (spec §4.1). Only `name` is required.
|
|
122
|
+
* Unknown fields are ignored and collected as warnings (validatePluginDir
|
|
123
|
+
* promotes them to errors under --strict).
|
|
124
|
+
* @returns {{ok:true, manifest:object, warnings:string[]}|{ok:false, errors:string[]}}
|
|
125
|
+
*/
|
|
126
|
+
export function normalizeManifest(raw, { dir = '' } = {}) {
|
|
127
|
+
const where = dir ? `${dir}/worca-cc-plugin.json` : 'worca-cc-plugin.json';
|
|
128
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
129
|
+
return { ok: false, errors: [`${where}: manifest must be a JSON object`] };
|
|
130
|
+
}
|
|
131
|
+
const errors = [];
|
|
132
|
+
const warnings = [];
|
|
133
|
+
collectUnknown(raw, KNOWN_TOP, where, warnings);
|
|
134
|
+
|
|
135
|
+
const name = str(raw.name);
|
|
136
|
+
if (!name) errors.push(`${where}: "name" is required`);
|
|
137
|
+
else if (!PLUGIN_NAME_RE.test(name) || name.length > 64) {
|
|
138
|
+
errors.push(`${where}: "name" must be kebab-case (e.g. "github-source"), got "${name}"`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const version = str(raw.version) || null; // absent -> null: pinned SHA is the version (§4.1)
|
|
142
|
+
|
|
143
|
+
const enginesRaw = raw.engines && typeof raw.engines === 'object' ? raw.engines : {};
|
|
144
|
+
const worcaApi = str(enginesRaw['worca-cc-api']) || null;
|
|
145
|
+
if (worcaApi && !apiSatisfies(worcaApi)) {
|
|
146
|
+
errors.push(`${where}: engines.worca-cc-api "${worcaApi}" is not satisfied by host plugin APIs [${WORCA_PLUGIN_APIS.join(', ')}]`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const setupRaw = raw.setup && typeof raw.setup === 'object' ? raw.setup : {};
|
|
150
|
+
if (setupRaw.python != null && setupRaw.python !== 'pyproject') {
|
|
151
|
+
errors.push(`${where}: setup.python must be "pyproject" (got ${JSON.stringify(setupRaw.python)})`);
|
|
152
|
+
}
|
|
153
|
+
const setup = { node: setupRaw.node === true, python: setupRaw.python === 'pyproject' ? 'pyproject' : null };
|
|
154
|
+
|
|
155
|
+
const sourcesRaw = raw.taskSources ?? [];
|
|
156
|
+
const taskSources = [];
|
|
157
|
+
if (!Array.isArray(sourcesRaw)) {
|
|
158
|
+
errors.push(`${where}: "taskSources" must be an array`);
|
|
159
|
+
} else {
|
|
160
|
+
sourcesRaw.forEach((s, i) => {
|
|
161
|
+
const at = `${where}: taskSources[${i}]`;
|
|
162
|
+
if (!s || typeof s !== 'object') { errors.push(`${at} must be an object`); return; }
|
|
163
|
+
collectUnknown(s, KNOWN_SOURCE, at, warnings);
|
|
164
|
+
const id = str(s.id);
|
|
165
|
+
if (!SOURCE_ID_RE.test(id)) errors.push(`${at}: "id" must be kebab-case, got "${id}"`);
|
|
166
|
+
const module = str(s.module);
|
|
167
|
+
const modErr = badModulePath(module);
|
|
168
|
+
if (modErr) errors.push(`${at} ("${id}"): "module" ${modErr}`);
|
|
169
|
+
|
|
170
|
+
const configSchema = normConfigSchema(s.configSchema, at, errors, warnings);
|
|
171
|
+
|
|
172
|
+
const inputs = [];
|
|
173
|
+
(Array.isArray(s.inputs) ? s.inputs : []).forEach((inp, j) => {
|
|
174
|
+
const iat = `${at}.inputs[${j}]`;
|
|
175
|
+
if (!inp || typeof inp !== 'object') { errors.push(`${iat} must be an object`); return; }
|
|
176
|
+
collectUnknown(inp, KNOWN_INPUT, iat, warnings);
|
|
177
|
+
const key = str(inp.key);
|
|
178
|
+
if (!KEY_RE.test(key)) { errors.push(`${iat}: "key" must be an identifier, got "${key}"`); return; }
|
|
179
|
+
const type = str(inp.type) || 'text';
|
|
180
|
+
if (!INPUT_TYPES.has(type)) {
|
|
181
|
+
errors.push(`${iat} ("${key}"): type must be text|select|remote-select|task-browser, got "${type}"`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const optionsFrom = str(inp.optionsFrom) || null;
|
|
185
|
+
if (type === 'remote-select' && !optionsFrom) {
|
|
186
|
+
errors.push(`${iat} ("${key}"): remote-select needs "optionsFrom" (a connector op name)`);
|
|
187
|
+
}
|
|
188
|
+
if (optionsFrom && !KEY_RE.test(optionsFrom)) errors.push(`${iat} ("${key}"): "optionsFrom" must be an identifier`);
|
|
189
|
+
const options = normOptions(inp.options);
|
|
190
|
+
if (type === 'select' && !options.length) errors.push(`${iat} ("${key}"): select inputs need "options"`);
|
|
191
|
+
inputs.push({ key, type, label: str(inp.label) || key, default: inp.default ?? null, optionsFrom, options });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const browsers = inputs.filter((x) => x.type === 'task-browser').length;
|
|
195
|
+
if (browsers !== 1) {
|
|
196
|
+
errors.push(`${at} ("${id}"): must declare exactly ONE input of type "task-browser" (found ${browsers}) — it is what produces the task (spec §7.4)`);
|
|
197
|
+
}
|
|
198
|
+
taskSources.push({ id, displayName: str(s.displayName) || id, module, configSchema, inputs });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const ids = taskSources.map((s) => s.id);
|
|
202
|
+
for (const dup of new Set(ids.filter((v, i) => v && ids.indexOf(v) !== i))) {
|
|
203
|
+
errors.push(`${where}: duplicate taskSources id "${dup}"`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// chatChannels (API 2): persistent channel workers. Same configSchema field
|
|
207
|
+
// semantics as taskSources; deliberately NO task-browser/inputs machinery.
|
|
208
|
+
const channelsRaw = raw.chatChannels ?? [];
|
|
209
|
+
const chatChannels = [];
|
|
210
|
+
if (!Array.isArray(channelsRaw)) {
|
|
211
|
+
errors.push(`${where}: "chatChannels" must be an array`);
|
|
212
|
+
} else {
|
|
213
|
+
channelsRaw.forEach((c, i) => {
|
|
214
|
+
const at = `${where}: chatChannels[${i}]`;
|
|
215
|
+
if (!c || typeof c !== 'object') { errors.push(`${at} must be an object`); return; }
|
|
216
|
+
collectUnknown(c, KNOWN_CHANNEL, at, warnings);
|
|
217
|
+
const id = str(c.id);
|
|
218
|
+
if (!SOURCE_ID_RE.test(id)) errors.push(`${at}: "id" must be kebab-case, got "${id}"`);
|
|
219
|
+
const platform = str(c.platform).toLowerCase();
|
|
220
|
+
if (!platform || !SOURCE_ID_RE.test(platform)) {
|
|
221
|
+
errors.push(`${at} ("${id}"): "platform" must be a non-empty kebab-case hint (e.g. "telegram")`);
|
|
222
|
+
}
|
|
223
|
+
const module = str(c.module);
|
|
224
|
+
const modErr = badModulePath(module);
|
|
225
|
+
if (modErr) errors.push(`${at} ("${id}"): "module" ${modErr}`);
|
|
226
|
+
const ingress = str(c.ingress) || 'connect';
|
|
227
|
+
if (!CHANNEL_INGRESS.has(ingress)) {
|
|
228
|
+
errors.push(`${at} ("${id}"): "ingress" must be connect|webhook, got "${ingress}"`);
|
|
229
|
+
}
|
|
230
|
+
const capsRaw = c.capabilities && typeof c.capabilities === 'object' ? c.capabilities : {};
|
|
231
|
+
const capabilities = {
|
|
232
|
+
inbound: capsRaw.inbound !== false,
|
|
233
|
+
outbound: capsRaw.outbound !== false,
|
|
234
|
+
};
|
|
235
|
+
if (!capabilities.inbound && !capabilities.outbound) {
|
|
236
|
+
errors.push(`${at} ("${id}"): capabilities cannot disable both inbound and outbound`);
|
|
237
|
+
}
|
|
238
|
+
const configSchema = normConfigSchema(c.configSchema, at, errors, warnings);
|
|
239
|
+
chatChannels.push({ id, displayName: str(c.displayName) || id, platform, module, ingress, capabilities, configSchema });
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const chIds = chatChannels.map((c) => c.id);
|
|
243
|
+
for (const dup of new Set(chIds.filter((v, i) => v && chIds.indexOf(v) !== i))) {
|
|
244
|
+
errors.push(`${where}: duplicate chatChannels id "${dup}"`);
|
|
245
|
+
}
|
|
246
|
+
// models + modelSecrets (design §9.1). Manifest-only contribution: no files
|
|
247
|
+
// to check, so ALL validation lives here. Write-time env rules mirror the
|
|
248
|
+
// global-catalog setters (settings.mjs assertEnvPairs): reserved keys are a
|
|
249
|
+
// hard error — the spawn-time prepareModelEnv drop stays as the second gate.
|
|
250
|
+
const secretsRaw = raw.modelSecrets ?? [];
|
|
251
|
+
const modelSecrets = [];
|
|
252
|
+
if (!Array.isArray(secretsRaw)) {
|
|
253
|
+
errors.push(`${where}: "modelSecrets" must be an array`);
|
|
254
|
+
} else {
|
|
255
|
+
secretsRaw.forEach((f, i) => {
|
|
256
|
+
const at = `${where}: modelSecrets[${i}]`;
|
|
257
|
+
if (!f || typeof f !== 'object') { errors.push(`${at} must be an object`); return; }
|
|
258
|
+
collectUnknown(f, KNOWN_MODEL_SECRET, at, warnings);
|
|
259
|
+
const key = str(f.key);
|
|
260
|
+
if (!KEY_RE.test(key)) { errors.push(`${at}: "key" must be an identifier, got "${key}"`); return; }
|
|
261
|
+
modelSecrets.push({ key, label: str(f.label) || key });
|
|
262
|
+
});
|
|
263
|
+
const skeys = modelSecrets.map((f) => f.key);
|
|
264
|
+
for (const dup of new Set(skeys.filter((v, i) => skeys.indexOf(v) !== i))) {
|
|
265
|
+
errors.push(`${where}: duplicate modelSecrets key "${dup}"`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const secretKeys = new Set(modelSecrets.map((f) => f.key));
|
|
269
|
+
|
|
270
|
+
const modelsRaw = raw.models ?? [];
|
|
271
|
+
const models = [];
|
|
272
|
+
if (!Array.isArray(modelsRaw)) {
|
|
273
|
+
errors.push(`${where}: "models" must be an array`);
|
|
274
|
+
} else {
|
|
275
|
+
modelsRaw.forEach((m, i) => {
|
|
276
|
+
const at = `${where}: models[${i}]`;
|
|
277
|
+
if (!m || typeof m !== 'object') { errors.push(`${at} must be an object`); return; }
|
|
278
|
+
collectUnknown(m, KNOWN_MODEL, at, warnings);
|
|
279
|
+
const id = str(m.id);
|
|
280
|
+
if (!id) { errors.push(`${at}: "id" is required`); return; }
|
|
281
|
+
const efforts = [];
|
|
282
|
+
if (m.efforts !== undefined) {
|
|
283
|
+
if (!Array.isArray(m.efforts)) { errors.push(`${at} ("${id}"): "efforts" must be an array`); return; }
|
|
284
|
+
for (const e of m.efforts) {
|
|
285
|
+
if (!EFFORTS.includes(e)) { errors.push(`${at} ("${id}"): unknown effort ${JSON.stringify(e)} — must be one of ${EFFORTS.join(' | ')}`); return; }
|
|
286
|
+
}
|
|
287
|
+
efforts.push(...EFFORTS.filter((e) => m.efforts.includes(e))); // canonical order, deduped
|
|
288
|
+
}
|
|
289
|
+
const env = {};
|
|
290
|
+
const envRaw = m.env && typeof m.env === 'object' && !Array.isArray(m.env) ? m.env : {};
|
|
291
|
+
if (m.env !== undefined && (typeof m.env !== 'object' || Array.isArray(m.env))) {
|
|
292
|
+
errors.push(`${at} ("${id}"): "env" must be an object`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
for (const [k, v] of Object.entries(envRaw)) {
|
|
296
|
+
if (isReservedModelEnvKey(k)) { errors.push(`${at} ("${id}"): env key ${JSON.stringify(k)} is reserved and cannot be set on a model`); continue; }
|
|
297
|
+
if (isSecretRef(v)) {
|
|
298
|
+
if (!secretKeys.has(v.secret)) { errors.push(`${at} ("${id}"): env ${k} references undeclared modelSecrets key ${JSON.stringify(v.secret)}`); continue; }
|
|
299
|
+
env[k] = { secret: v.secret };
|
|
300
|
+
} else if (typeof v === 'string' && v) {
|
|
301
|
+
env[k] = v;
|
|
302
|
+
} else {
|
|
303
|
+
errors.push(`${at} ("${id}"): env value for ${JSON.stringify(k)} must be a non-empty string or {"secret": "<key>"}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
models.push({
|
|
307
|
+
id, label: str(m.label) || id,
|
|
308
|
+
efforts: efforts.length ? efforts : [...EFFORTS],
|
|
309
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
const mids = models.map((m) => m.id.toLowerCase());
|
|
313
|
+
for (const dup of new Set(mids.filter((v, i) => mids.indexOf(v) !== i))) {
|
|
314
|
+
errors.push(`${where}: duplicate models id "${dup}" (ids are case-insensitive)`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (errors.length) return { ok: false, errors };
|
|
319
|
+
return {
|
|
320
|
+
ok: true,
|
|
321
|
+
warnings,
|
|
322
|
+
manifest: {
|
|
323
|
+
name, version,
|
|
324
|
+
description: str(raw.description), author: str(raw.author),
|
|
325
|
+
homepage: str(raw.homepage), license: str(raw.license),
|
|
326
|
+
engines: { worcaApi }, setup, taskSources, chatChannels, models, modelSecrets,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Depth-first scan for symlinks whose target resolves OUTSIDE `root`.
|
|
333
|
+
* Returns root-relative link paths. Does not follow symlinked dirs (no loops).
|
|
334
|
+
* Used here for validate, and by plugin-repo.mjs exportVersion (which deletes
|
|
335
|
+
* them — git archive preserves symlinks, spec §4.3/§6.1).
|
|
336
|
+
*/
|
|
337
|
+
export function findEscapingSymlinks(root) {
|
|
338
|
+
const rootAbs = resolve(root);
|
|
339
|
+
const out = [];
|
|
340
|
+
const walk = (dir) => {
|
|
341
|
+
let entries;
|
|
342
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
343
|
+
for (const e of entries) {
|
|
344
|
+
const p = join(dir, e.name);
|
|
345
|
+
if (e.isSymbolicLink()) {
|
|
346
|
+
let target;
|
|
347
|
+
try { target = readlinkSync(p); } catch { continue; }
|
|
348
|
+
const abs = isAbsolute(target) ? resolve(target) : resolve(dirname(p), target);
|
|
349
|
+
if (abs !== rootAbs && !abs.startsWith(rootAbs + sep)) out.push(p.slice(rootAbs.length + 1));
|
|
350
|
+
} else if (e.isDirectory()) {
|
|
351
|
+
walk(p);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
walk(rootAbs);
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Validate a plugin DIRECTORY (spec §6.6): manifest parse/normalize, module
|
|
361
|
+
* files exist, agents md/meta pairing + key regex, workflows reference only
|
|
362
|
+
* the plugin's own agent keys, skills have SKILL.md, escaping symlinks.
|
|
363
|
+
* strict: unknown-field warnings become errors.
|
|
364
|
+
* @returns {{ok:boolean, manifest:object|null, problems:Array<{level:'error'|'warn', message:string}>}}
|
|
365
|
+
*/
|
|
366
|
+
export function validatePluginDir(absDir, { strict = false } = {}) {
|
|
367
|
+
const problems = [];
|
|
368
|
+
const push = (level, message) => problems.push({ level, message });
|
|
369
|
+
|
|
370
|
+
let manifest = null;
|
|
371
|
+
let raw = null;
|
|
372
|
+
try {
|
|
373
|
+
raw = JSON.parse(readFileSync(join(absDir, 'worca-cc-plugin.json'), 'utf8'));
|
|
374
|
+
} catch (err) {
|
|
375
|
+
push('error', `worca-cc-plugin.json: ${err.code === 'ENOENT' ? 'missing' : `invalid JSON (${err.message})`}`);
|
|
376
|
+
}
|
|
377
|
+
if (raw !== null) {
|
|
378
|
+
const res = normalizeManifest(raw, { dir: absDir });
|
|
379
|
+
if (!res.ok) for (const e of res.errors) push('error', e);
|
|
380
|
+
else {
|
|
381
|
+
manifest = res.manifest;
|
|
382
|
+
for (const w of res.warnings) push(strict ? 'error' : 'warn', w);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (manifest) {
|
|
387
|
+
for (const s of manifest.taskSources) {
|
|
388
|
+
if (!existsSync(join(absDir, s.module))) push('error', `taskSources "${s.id}": module ${s.module} not found`);
|
|
389
|
+
}
|
|
390
|
+
for (const c of manifest.chatChannels) {
|
|
391
|
+
if (!existsSync(join(absDir, c.module))) push('error', `chatChannels "${c.id}": module ${c.module} not found`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// agents/: <key>.md + <key>.meta.json pairs, existing dual-file format (§4.2)
|
|
396
|
+
const agentKeys = new Set();
|
|
397
|
+
const agentsDir = join(absDir, 'agents');
|
|
398
|
+
if (existsSync(agentsDir)) {
|
|
399
|
+
const files = readdirSync(agentsDir);
|
|
400
|
+
for (const f of files.filter((x) => x.endsWith('.meta.json'))) {
|
|
401
|
+
const stem = f.slice(0, -'.meta.json'.length);
|
|
402
|
+
let meta = null;
|
|
403
|
+
try { meta = JSON.parse(readFileSync(join(agentsDir, f), 'utf8')); }
|
|
404
|
+
catch { push('error', `agents/${f}: invalid JSON`); continue; }
|
|
405
|
+
const key = typeof meta?.key === 'string' ? meta.key : '';
|
|
406
|
+
if (!KEY_RE.test(key)) { push('error', `agents/${f}: "${key}" must be a valid agent key (letters/digits/_-)`); continue; }
|
|
407
|
+
if (key !== stem) push('error', `agents/${f}: key "${key}" must match the filename stem "${stem}"`);
|
|
408
|
+
if (!files.includes(`${stem}.md`)) push('error', `agents/${f}: missing sibling ${stem}.md`);
|
|
409
|
+
agentKeys.add(key);
|
|
410
|
+
}
|
|
411
|
+
for (const f of files.filter((x) => x.endsWith('.md'))) {
|
|
412
|
+
const stem = f.slice(0, -3);
|
|
413
|
+
if (!files.includes(`${stem}.meta.json`)) {
|
|
414
|
+
push('warn', `agents/${f}: no ${stem}.meta.json sidecar — the registry will ignore it`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// skills/<name>/SKILL.md required (rides the existing injection mechanism, §9.2)
|
|
420
|
+
const skillsDir = join(absDir, 'skills');
|
|
421
|
+
if (existsSync(skillsDir)) {
|
|
422
|
+
for (const d of readdirSync(skillsDir, { withFileTypes: true })) {
|
|
423
|
+
if (d.isDirectory() && !existsSync(join(skillsDir, d.name, 'SKILL.md'))) {
|
|
424
|
+
push('error', `skills/${d.name}: missing SKILL.md`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// workflows/*.json may reference ONLY the plugin's own agent keys (§9.3)
|
|
430
|
+
const wfDir = join(absDir, 'workflows');
|
|
431
|
+
if (existsSync(wfDir)) {
|
|
432
|
+
for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json'))) {
|
|
433
|
+
let tpl = null;
|
|
434
|
+
try { tpl = JSON.parse(readFileSync(join(wfDir, f), 'utf8')); }
|
|
435
|
+
catch { push('error', `workflows/${f}: invalid JSON`); continue; }
|
|
436
|
+
if (!Array.isArray(tpl?.steps)) { push('error', `workflows/${f}: "steps" must be an array`); continue; }
|
|
437
|
+
const keys = tpl.steps.flat().map((n) => n?.key).filter(Boolean);
|
|
438
|
+
for (const k of new Set(keys)) {
|
|
439
|
+
if (!agentKeys.has(k)) push('error', `workflows/${f}: references agent key "${k}" which this plugin does not ship`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
for (const rel of findEscapingSymlinks(absDir)) push('error', `symlink escapes the plugin dir: ${rel}`);
|
|
445
|
+
|
|
446
|
+
return { ok: manifest !== null && !problems.some((p) => p.level === 'error'), manifest, problems };
|
|
447
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/core/plugin-models.mjs
|
|
2
|
+
// Model contributions from installed plugins (configurable-models-design.md §9):
|
|
3
|
+
// read `models` + `modelSecrets` out of every ENABLED plugin's current manifest,
|
|
4
|
+
// dedupe cross-plugin id collisions deterministically, and resolve {secret}
|
|
5
|
+
// placeholders through the existing per-plugin secrets store (plugin-config.mjs).
|
|
6
|
+
// Reads never throw and are hermetic under node:test for free: readPluginsLock
|
|
7
|
+
// resolves through worcaHome(), whose NODE_TEST_CONTEXT guard makes the lock
|
|
8
|
+
// read return {} unless the test sandboxes WORCA_HOME itself.
|
|
9
|
+
//
|
|
10
|
+
// Import graph: plugins-lock + plugin-manifest + plugin-config only — config.mjs
|
|
11
|
+
// imports THIS module (never the reverse), and plugin-store.mjs may import
|
|
12
|
+
// config.mjs, so nothing here may import either of them.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs';
|
|
17
|
+
import { normalizeManifest } from './plugin-manifest.mjs';
|
|
18
|
+
import { readPluginConfig, redactedConfig } from './plugin-config.mjs';
|
|
19
|
+
|
|
20
|
+
/** Normalized manifest of an installed plugin's current/, or null. Never throws. */
|
|
21
|
+
function manifestAt(name) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(readFileSync(join(pluginCurrentDir(name), 'worca-cc-plugin.json'), 'utf8'));
|
|
24
|
+
const res = normalizeManifest(raw, { dir: pluginCurrentDir(name) });
|
|
25
|
+
return res.ok ? res.manifest : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* RAW model contributions of every enabled plugin, alphabetical by plugin name,
|
|
33
|
+
* models in manifest order: [{plugin, id, label, efforts, env?, secrets:[key]}].
|
|
34
|
+
* `secrets` lists the modelSecrets keys this model's env references. No
|
|
35
|
+
* cross-plugin dedupe here — that is listPluginModels' job; the uninstall
|
|
36
|
+
* guard needs the raw view to compute its carve-outs. Never throws.
|
|
37
|
+
*/
|
|
38
|
+
export function allPluginModels() {
|
|
39
|
+
const lock = readPluginsLock();
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const name of Object.keys(lock).sort()) {
|
|
42
|
+
if (!lock[name] || lock[name].enabled === false) continue;
|
|
43
|
+
const manifest = manifestAt(name);
|
|
44
|
+
if (!manifest) continue;
|
|
45
|
+
for (const m of manifest.models) {
|
|
46
|
+
const secrets = Object.values(m.env ?? {})
|
|
47
|
+
.filter((v) => v && typeof v === 'object')
|
|
48
|
+
.map((v) => v.secret);
|
|
49
|
+
out.push({
|
|
50
|
+
plugin: name, id: m.id, label: m.label, efforts: [...m.efforts],
|
|
51
|
+
...(m.env ? { env: m.env } : {}),
|
|
52
|
+
secrets: [...new Set(secrets)],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The EFFECTIVE plugin model layer: allPluginModels with cross-plugin id
|
|
61
|
+
* collisions resolved — alphabetical plugin-name order wins deterministically,
|
|
62
|
+
* losers dropped with a warning (design §9.2). Never throws.
|
|
63
|
+
*/
|
|
64
|
+
export function listPluginModels() {
|
|
65
|
+
const out = [];
|
|
66
|
+
const seen = new Map(); // lc id -> winning plugin
|
|
67
|
+
for (const m of allPluginModels()) {
|
|
68
|
+
const lc = m.id.toLowerCase();
|
|
69
|
+
const winner = seen.get(lc);
|
|
70
|
+
if (winner) {
|
|
71
|
+
console.warn(`[worca] plugin "${m.plugin}": model id ${JSON.stringify(m.id)} already provided by plugin "${winner}" — dropped`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
seen.set(lc, m.plugin);
|
|
75
|
+
out.push(m);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** modelSecrets of a plugin as a plugin-config schema (all secret text fields). */
|
|
81
|
+
export function modelSecretsSchema(name) {
|
|
82
|
+
const manifest = manifestAt(name);
|
|
83
|
+
return (manifest?.modelSecrets ?? []).map((f) => ({
|
|
84
|
+
key: f.key, type: 'text', label: f.label,
|
|
85
|
+
secret: true, required: false, default: null, help: null, options: [],
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Set-ness of a plugin's modelSecrets, values never included:
|
|
90
|
+
* [{key, label, set}]. Never throws. */
|
|
91
|
+
export function pluginModelSecretStatus(name) {
|
|
92
|
+
const schema = modelSecretsSchema(name);
|
|
93
|
+
if (!schema.length) return [];
|
|
94
|
+
try {
|
|
95
|
+
const red = redactedConfig(name, schema);
|
|
96
|
+
return schema.map((f) => ({ key: f.key, label: f.label, set: red[f.key]?.set === true }));
|
|
97
|
+
} catch {
|
|
98
|
+
return schema.map((f) => ({ key: f.key, label: f.label, set: false }));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Flatten one plugin model's env to plain strings: literals and ${VAR} ref text
|
|
104
|
+
* pass through; {secret} placeholders resolve via the plugin's secrets store
|
|
105
|
+
* ({"$env":"VAR"} indirection honored there). Unresolvable secrets are dropped
|
|
106
|
+
* and reported — same degradation as an unresolvable ${VAR} ref downstream.
|
|
107
|
+
* @param {{plugin:string, env?:object}} model an allPluginModels entry
|
|
108
|
+
* @returns {{env: Record<string,string>, droppedSecrets: string[]}}
|
|
109
|
+
*/
|
|
110
|
+
export function flattenPluginModelEnv(model) {
|
|
111
|
+
const env = {};
|
|
112
|
+
const droppedSecrets = [];
|
|
113
|
+
const entries = Object.entries(model?.env ?? {});
|
|
114
|
+
if (!entries.length) return { env, droppedSecrets };
|
|
115
|
+
let secrets = {};
|
|
116
|
+
const schema = modelSecretsSchema(model.plugin);
|
|
117
|
+
if (schema.length) {
|
|
118
|
+
try { secrets = readPluginConfig(model.plugin, schema); } catch { secrets = {}; }
|
|
119
|
+
}
|
|
120
|
+
for (const [k, v] of entries) {
|
|
121
|
+
if (typeof v === 'string') {
|
|
122
|
+
env[k] = v;
|
|
123
|
+
} else {
|
|
124
|
+
const resolved = secrets[v.secret];
|
|
125
|
+
if (typeof resolved === 'string' && resolved) env[k] = resolved;
|
|
126
|
+
else droppedSecrets.push(`${k} (secret "${v.secret}")`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { env, droppedSecrets };
|
|
130
|
+
}
|