@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.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,303 @@
1
+ // src/core/plugin-repo.mjs
2
+ // Git plumbing for the plugin store (spec §4.1, §4.3, §6.1-6.2): bare fetch
3
+ // cache, manifest discovery (a root worca-cc-marketplace.json when present, else
4
+ // tree depth 0/1), update candidate preview, and git-archive export of a pinned
5
+ // SHA. All git/tar via execFile — injectable as opts.exec for tests:
6
+ // async (cmd, args, opts?) => { stdout, stderr }.
7
+ // The cache lives at <pluginsRoot>/.cache/<slug>.git — NEVER on any execution
8
+ // path; exports contain no .git, so repo hooks are inert (spec §8).
9
+
10
+ import { execFile } from 'node:child_process';
11
+ import { promisify } from 'node:util';
12
+ import { createHash } from 'node:crypto';
13
+ import { mkdirSync, existsSync, rmSync } from 'node:fs';
14
+ import { mkdtemp, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join, dirname } from 'node:path';
17
+ import { pluginsRoot, pluginDir, readPluginsLock } from './plugins-lock.mjs';
18
+ import { normalizeManifest, findEscapingSymlinks } from './plugin-manifest.mjs';
19
+
20
+ const execFileP = promisify(execFile);
21
+ const defaultExec = (cmd, args, opts = {}) =>
22
+ execFileP(cmd, args, {
23
+ maxBuffer: 16 * 1024 * 1024,
24
+ timeout: 120_000,
25
+ killSignal: 'SIGKILL',
26
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
27
+ ...opts,
28
+ });
29
+
30
+ /** Filesystem slug for a repo URL — shared by the bare-cache dir and marketplace
31
+ * ids. Injective: a readable prefix plus an 8-hex digest of the EXACT input, so
32
+ * distinct urls (http vs https, /a/b vs /a-b, unicode, .git.git) never collide
33
+ * onto one id/cache dir. */
34
+ export function repoSlug(repoUrl) {
35
+ const s = String(repoUrl);
36
+ const readable = s
37
+ .replace(/^[a-z+]+:\/\//i, '').replace(/\.git$/i, '')
38
+ .replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
39
+ .slice(0, 80) || 'repo';
40
+ return `${readable}-${createHash('sha1').update(s).digest('hex').slice(0, 8)}`;
41
+ }
42
+
43
+ /** Bare-cache path for a repo URL: <pluginsRoot>/.cache/<slug>.git. */
44
+ export function repoCacheDir(repoUrl) {
45
+ return join(pluginsRoot(), '.cache', `${repoSlug(repoUrl)}.git`);
46
+ }
47
+
48
+ /** Validate a raw worca-cc-marketplace.json (spec §4.1). Paths are repo-relative
49
+ * dirs, any depth: no absolute, no '..'/'.' segments, no backslashes, no empties. */
50
+ export function parseMarketplaceManifest(raw) {
51
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
52
+ return { ok: false, errors: ['not a JSON object'] };
53
+ }
54
+ const structural = []; // whole-file problems -> ok:false (caller falls back to the scan)
55
+ if (typeof raw.name !== 'string' || !raw.name.trim()) structural.push('"name" is required');
56
+ if (!Array.isArray(raw.plugins)) structural.push('"plugins" must be an array of repo-relative dirs');
57
+ if (structural.length) return { ok: false, errors: structural };
58
+ const warnings = []; // per-entry problems -> skipped entries, manifest still authoritative
59
+ const plugins = [];
60
+ for (const d of raw.plugins) {
61
+ // Segment charset is deliberately strict: it blocks '..'/absolute paths AND
62
+ // any segment beginning with '-', which would otherwise be parsed as a git
63
+ // option when the subdir is passed as a positional to `git archive`/`ls-tree`
64
+ // (see the `--` pathspec guards in fetchCandidate/exportVersion). '=' is
65
+ // outside the class, so `--output=/x` is rejected on both counts.
66
+ if (typeof d !== 'string' || !d.trim() || d.includes('\\') || d.startsWith('/')
67
+ || d.split('/').some((seg) =>
68
+ !/^[A-Za-z0-9._-]+$/.test(seg) || seg === '.' || seg === '..' || seg.startsWith('-'))) {
69
+ warnings.push(`invalid plugin path ${JSON.stringify(d)} — skipped`);
70
+ continue;
71
+ }
72
+ plugins.push(d.replace(/\/+$/, ''));
73
+ }
74
+ return {
75
+ ok: true,
76
+ name: raw.name.trim(),
77
+ description: typeof raw.description === 'string' ? raw.description.trim() : '',
78
+ plugins,
79
+ warnings,
80
+ };
81
+ }
82
+
83
+ async function gitDir(cache, args, exec) {
84
+ const { stdout } = await exec('git', ['--git-dir', cache, ...args]);
85
+ return stdout;
86
+ }
87
+
88
+ /** Clone the bare cache if absent, else fetch all heads (explicit refspec —
89
+ * clone --bare configures no fetch refspec). */
90
+ async function ensureCache(repoUrl, exec) {
91
+ const cache = repoCacheDir(repoUrl);
92
+ if (!existsSync(cache)) {
93
+ mkdirSync(dirname(cache), { recursive: true });
94
+ await exec('git', ['clone', '--quiet', '--bare', repoUrl, cache]);
95
+ } else {
96
+ await gitDir(cache, ['fetch', '--quiet', 'origin', '+refs/heads/*:refs/heads/*', '--prune'], exec);
97
+ }
98
+ return cache;
99
+ }
100
+
101
+ /**
102
+ * `worca plugin add`: clone/refresh the bare cache, then discover plugins in the
103
+ * HEAD tree. A root worca-cc-marketplace.json (spec §4.1), when present and
104
+ * structurally valid, is AUTHORITATIVE — its listed dirs (any depth) are the only
105
+ * candidates, even when the list is empty. Without one, the depth 0/1 scan of
106
+ * spec §4.3 applies unchanged.
107
+ * @returns {{repoUrl:string, sha:string, discovered:Array<{name,subdir,manifest}>,
108
+ * warnings:string[], marketplace:{name:string, description:string}|null}}
109
+ */
110
+ export async function addPluginRepo(repoUrl, { exec = defaultExec } = {}) {
111
+ // `owner/repo` shorthand -> GitHub URL (spec §4.3) — only when it is not a
112
+ // real local path (local fixture repos in tests stay untouched).
113
+ if (/^[\w.-]+\/[\w.-]+$/.test(repoUrl) && !existsSync(repoUrl)) {
114
+ repoUrl = `https://github.com/${repoUrl}`;
115
+ }
116
+ const cache = await ensureCache(repoUrl, exec);
117
+ const sha = (await gitDir(cache, ['rev-parse', 'HEAD'], exec)).trim();
118
+ const allPaths = (await gitDir(cache, ['ls-tree', '-r', '--name-only', sha], exec))
119
+ .split('\n').map((s) => s.trim()).filter(Boolean);
120
+ const warnings = [];
121
+ let marketplace = null;
122
+ let manifestPaths = null; // null -> fall back to the depth 0-1 scan
123
+ if (allPaths.includes('worca-cc-marketplace.json')) {
124
+ let mp = null;
125
+ try {
126
+ mp = parseMarketplaceManifest(JSON.parse(await gitDir(cache, ['show', `${sha}:worca-cc-marketplace.json`], exec)));
127
+ } catch {
128
+ mp = { ok: false, errors: ['invalid JSON'] };
129
+ }
130
+ if (mp.ok) {
131
+ marketplace = { name: mp.name, description: mp.description };
132
+ warnings.push(...(mp.warnings || []).map((w) => `worca-cc-marketplace.json: ${w}`));
133
+ manifestPaths = [];
134
+ for (const dir of mp.plugins) {
135
+ const p = `${dir}/worca-cc-plugin.json`;
136
+ if (!allPaths.includes(p)) {
137
+ warnings.push(`worca-cc-marketplace.json: ${dir}/worca-cc-plugin.json not found in repo — skipped`);
138
+ continue;
139
+ }
140
+ manifestPaths.push(p);
141
+ }
142
+ } else {
143
+ warnings.push(`worca-cc-marketplace.json: ${mp.errors.join('; ')} — falling back to depth 0-1 scan`);
144
+ }
145
+ }
146
+ if (manifestPaths === null) {
147
+ manifestPaths = allPaths
148
+ .filter((p) => p === 'worca-cc-plugin.json' || /^[^/]+\/worca-cc-plugin\.json$/.test(p));
149
+ }
150
+ const discovered = [];
151
+ const seenNames = new Set();
152
+ for (const p of manifestPaths) {
153
+ const subdir = p === 'worca-cc-plugin.json' ? '' : p.slice(0, -'/worca-cc-plugin.json'.length);
154
+ let raw;
155
+ try {
156
+ raw = JSON.parse(await gitDir(cache, ['show', `${sha}:${p}`], exec));
157
+ } catch {
158
+ warnings.push(`${p}: invalid JSON — skipped`);
159
+ continue;
160
+ }
161
+ const res = normalizeManifest(raw, { dir: subdir || '.' });
162
+ if (!res.ok) { warnings.push(...res.errors); continue; }
163
+ if (seenNames.has(res.manifest.name)) {
164
+ warnings.push(`${p}: duplicate plugin name "${res.manifest.name}" — first entry wins, skipped`);
165
+ continue;
166
+ }
167
+ seenNames.add(res.manifest.name);
168
+ discovered.push({ name: res.manifest.name, subdir, manifest: res.manifest });
169
+ }
170
+ return { repoUrl, sha, discovered, warnings, marketplace };
171
+ }
172
+
173
+ /** `<sourceId>.<key>` for every secret configSchema field of a manifest. */
174
+ function manifestSecretKeys(manifest) {
175
+ return (manifest?.taskSources || []).flatMap((s) =>
176
+ (s.configSchema || []).filter((f) => f && f.secret === true).map((f) => `${s.id}.${f.key}`));
177
+ }
178
+
179
+ /** Normalized models/modelSecrets view of a RAW manifest ({} on any failure —
180
+ * a manifest invalid to THIS host contributes no models here either). */
181
+ function manifestModels(raw) {
182
+ if (!raw) return { models: [], modelSecrets: [] };
183
+ const r = normalizeManifest(raw);
184
+ return r.ok ? { models: r.manifest.models, modelSecrets: r.manifest.modelSecrets } : { models: [], modelSecrets: [] };
185
+ }
186
+
187
+ async function showManifest(cache, sha, subdir, exec) {
188
+ const p = subdir ? `${subdir}/worca-cc-plugin.json` : 'worca-cc-plugin.json';
189
+ try { return JSON.parse(await gitDir(cache, ['show', `${sha}:${p}`], exec)); } catch { return null; }
190
+ }
191
+
192
+ /**
193
+ * Update-review manifest delta (spec §6.2): newly requested secrets, new task
194
+ * sources, new agents (added agents/<key>.meta.json files), setup changes —
195
+ * the red-highlight inputs that turn a malicious update into a human review event.
196
+ */
197
+ async function computeManifestDelta(cache, entry, pinnedSha, candidateSha, exec) {
198
+ const pin = await showManifest(cache, pinnedSha, entry.subdir, exec);
199
+ const cand = await showManifest(cache, candidateSha, entry.subdir, exec);
200
+ const pinSecrets = manifestSecretKeys(pin);
201
+ const candSecrets = manifestSecretKeys(cand);
202
+ const pinIds = (pin?.taskSources || []).map((s) => s.id);
203
+ const candIds = (cand?.taskSources || []).map((s) => s.id);
204
+ const scope = entry.subdir ? `${entry.subdir}/agents` : 'agents';
205
+ let newAgents = [];
206
+ try {
207
+ const status = await gitDir(cache, ['diff', '--name-status', pinnedSha, candidateSha, '--', scope], exec);
208
+ newAgents = status.split('\n')
209
+ .filter((l) => l.startsWith('A') && l.endsWith('.meta.json'))
210
+ .map((l) => l.split('\t').pop().split('/').pop().replace(/\.meta\.json$/, ''));
211
+ } catch { newAgents = []; }
212
+ // Model delta (design §9.4): env changes — a base-URL swap redirects ALL API
213
+ // traffic for that model — are the red-highlight review inputs.
214
+ const pinM = manifestModels(pin);
215
+ const candM = manifestModels(cand);
216
+ const byLc = (list) => new Map(list.map((m) => [m.id.toLowerCase(), m]));
217
+ const pinModels = byLc(pinM.models);
218
+ const candModels = byLc(candM.models);
219
+ const envOf = (m) => JSON.stringify(m.env ?? {});
220
+ return {
221
+ newSecrets: candSecrets.filter((k) => !pinSecrets.includes(k)),
222
+ newTaskSources: candIds.filter((id) => !pinIds.includes(id)),
223
+ newAgents,
224
+ setupChanged: JSON.stringify(pin?.setup ?? null) !== JSON.stringify(cand?.setup ?? null),
225
+ newModels: [...candModels.values()].filter((m) => !pinModels.has(m.id.toLowerCase())).map((m) => m.id),
226
+ removedModels: [...pinModels.values()].filter((m) => !candModels.has(m.id.toLowerCase())).map((m) => m.id),
227
+ envChangedModels: [...candModels.values()]
228
+ .filter((m) => pinModels.has(m.id.toLowerCase()) && envOf(pinModels.get(m.id.toLowerCase())) !== envOf(m))
229
+ .map((m) => m.id),
230
+ newModelSecrets: candM.modelSecrets.map((f) => f.key)
231
+ .filter((k) => !pinM.modelSecrets.some((f) => f.key === k)),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Update preview (spec §6.2): fetch, then report commits + diffstat + the
237
+ * manifest delta between the pinned SHA and the new HEAD; { fullDiff: true }
238
+ * additionally returns the complete diff text ("full diff on demand").
239
+ * Read-only; performing the update is Task 5's updatePlugin.
240
+ */
241
+ export async function fetchCandidate(name, { exec = defaultExec, fullDiff = false } = {}) {
242
+ const entry = readPluginsLock()[name];
243
+ if (!entry || !entry.repo) throw new Error(`plugin "${name}" is not installed from a repo`);
244
+ const cache = await ensureCache(entry.repo, exec);
245
+ const candidateSha = (await gitDir(cache, ['rev-parse', 'HEAD'], exec)).trim();
246
+ const pinnedSha = entry.pinnedSha;
247
+ let commits = [];
248
+ let diffstat = '';
249
+ let diffFull = '';
250
+ let manifestDelta = {
251
+ newSecrets: [], newTaskSources: [], newAgents: [], setupChanged: false,
252
+ newModels: [], removedModels: [], envChangedModels: [], newModelSecrets: [],
253
+ };
254
+ if (candidateSha !== pinnedSha) {
255
+ const log = await gitDir(cache, ['log', '--format=%H%x09%s', `${pinnedSha}..${candidateSha}`], exec);
256
+ commits = log.split('\n').filter(Boolean).map((l) => {
257
+ const i = l.indexOf('\t');
258
+ return { sha: l.slice(0, i), subject: l.slice(i + 1) };
259
+ });
260
+ const scope = entry.subdir ? ['--', entry.subdir] : [];
261
+ diffstat = (await gitDir(cache, ['diff', '--stat', pinnedSha, candidateSha, ...scope], exec)).trim();
262
+ if (fullDiff) diffFull = (await gitDir(cache, ['diff', pinnedSha, candidateSha, ...scope], exec)).trim();
263
+ manifestDelta = await computeManifestDelta(cache, entry, pinnedSha, candidateSha, exec);
264
+ }
265
+ return { pinnedSha, candidateSha, commits, diffstat, diffFull, manifestDelta };
266
+ }
267
+
268
+ /**
269
+ * Export a pinned SHA to `${pluginDir(name)}/versions/<sha7>` via git archive ->
270
+ * tar extraction (no .git inside, spec §6.1). Symlinks escaping the export dir
271
+ * are DELETED post-extraction (git archive preserves symlinks) and reported.
272
+ * opts.repoUrl/opts.subdir override the lock entry — required on FIRST install,
273
+ * when no lock entry exists yet (Task 5 passes them explicitly).
274
+ * @returns {Promise<{versionDir:string, warnings:string[]}>}
275
+ */
276
+ export async function exportVersion(name, sha, { exec = defaultExec, repoUrl, subdir } = {}) {
277
+ const entry = readPluginsLock()[name] || {};
278
+ const repo = repoUrl ?? entry.repo;
279
+ const sub = subdir ?? entry.subdir ?? '';
280
+ if (!repo) throw new Error(`plugin "${name}": no repo known (pass { repoUrl } on first install)`);
281
+ const cache = existsSync(repoCacheDir(repo)) ? repoCacheDir(repo) : await ensureCache(repo, exec);
282
+ const versionDir = join(pluginDir(name), 'versions', sha.slice(0, 7));
283
+ rmSync(versionDir, { recursive: true, force: true }); // re-export = fresh dir
284
+ mkdirSync(versionDir, { recursive: true });
285
+ const scratch = await mkdtemp(join(tmpdir(), 'worca-cc-export-'));
286
+ const tarFile = join(scratch, 'export.tar');
287
+ try {
288
+ await gitDir(cache, ['archive', '--format=tar', '-o', tarFile, ...(sub ? [sha, '--', sub] : [sha])], exec);
289
+ const strip = sub ? ['--strip-components', String(sub.split('/').length)] : [];
290
+ await exec('tar', ['-xf', tarFile, '-C', versionDir, ...strip]);
291
+ } catch (err) {
292
+ rmSync(versionDir, { recursive: true, force: true });
293
+ throw err;
294
+ } finally {
295
+ await rm(scratch, { recursive: true, force: true });
296
+ }
297
+ const warnings = [];
298
+ for (const rel of findEscapingSymlinks(versionDir)) {
299
+ rmSync(join(versionDir, rel), { force: true });
300
+ warnings.push(`removed symlink escaping the export dir: ${rel}`);
301
+ }
302
+ return { versionDir, warnings };
303
+ }
@@ -0,0 +1,76 @@
1
+ // src/core/plugin-shim-child.mjs
2
+ // Ephemeral connector runner (spec §7.2). Deliberately imports NOTHING from
3
+ // worca: it runs with a scrubbed env (PATH+HOME only, no WORCA_HOME), so any
4
+ // store/db import here would resolve wrong paths. Protocol: read ONE JSON doc
5
+ // from stdin, run ONE op, write ONE JSON frame to stdout, exit 0. The child
6
+ // ALWAYS exits 0 after writing a frame — a nonzero exit means "crashed before
7
+ // the frame" and the parent maps it to PluginOpError('protocol').
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ const logs = [];
11
+
12
+ async function main() {
13
+ process.stdin.setEncoding('utf8');
14
+ let raw = '';
15
+ for await (const chunk of process.stdin) raw += chunk;
16
+ const msg = JSON.parse(raw);
17
+
18
+ // ctx.state: plain-object snapshot + mutation collection. get() prefers the
19
+ // delta so a connector reads back its own writes within the op; set() only
20
+ // RECORDS — the HOST applies the delta via writePluginState after the frame.
21
+ const snapshot = msg.state && typeof msg.state === 'object' ? msg.state : {};
22
+ const stateDelta = {};
23
+ const ctx = {
24
+ apiVersion: msg.apiVersion ?? 1,
25
+ config: msg.config && typeof msg.config === 'object' ? msg.config : {},
26
+ state: {
27
+ get: async (k) => (k in stateDelta ? stateDelta[k] : (snapshot[k] ?? null)),
28
+ set: async (k, v) => { stateDelta[k] = v; },
29
+ },
30
+ log: (level, text) => { logs.push({ level, msg: String(text) }); }, // stdout is protocol-reserved
31
+ };
32
+
33
+ const mod = await import(pathToFileURL(msg.module).href);
34
+ if (typeof mod.default !== 'function') {
35
+ throw Object.assign(new Error(`connector module has no default-export factory: ${msg.module}`), { kind: 'plugin' });
36
+ }
37
+ const source = mod.default(ctx);
38
+ const fn = source?.[msg.op];
39
+ if (typeof fn !== 'function') {
40
+ // Optional ops (e.g. capabilities) land here; the host treats this kind +
41
+ // message as "op not implemented" (Task 13 defaults writeBack:true on it).
42
+ throw Object.assign(new Error(`connector does not implement op "${msg.op}"`), { kind: 'plugin' });
43
+ }
44
+
45
+ // §7.1 signatures: getTask(id) and reportResult(id, r) are positional; every
46
+ // other op takes the single args object (listTasks(q), validateConfig(), …).
47
+ const args = msg.args && typeof msg.args === 'object' ? msg.args : {};
48
+ let result;
49
+ if (msg.op === 'getTask') {
50
+ result = await fn.call(source, args.id);
51
+ } else if (msg.op === 'reportResult') {
52
+ const { id, ...r } = args;
53
+ result = await fn.call(source, id, r);
54
+ } else {
55
+ result = await fn.call(source, args);
56
+ }
57
+ return { ok: true, result: result === undefined ? null : result, stateDelta, logs };
58
+ }
59
+
60
+ // A pending op promise alone does not keep the event loop alive: a hung
61
+ // connector with no live handles would exit 0 with NO frame (parent would see
62
+ // 'protocol', not 'timeout'). Hold the loop open so a hung op stays hung and
63
+ // the parent's timeout SIGKILL is the only way out; process.exit(0) after the
64
+ // frame ends the process regardless.
65
+ setInterval(() => {}, 1 << 30);
66
+
67
+ main()
68
+ .catch((err) => ({
69
+ ok: false,
70
+ error: { kind: err?.kind || 'plugin', message: err?.message || String(err) },
71
+ logs,
72
+ }))
73
+ .then((frame) => {
74
+ // Write-callback before exit so a piped stdout is fully flushed.
75
+ process.stdout.write(JSON.stringify(frame), () => process.exit(0));
76
+ });
@@ -0,0 +1,197 @@
1
+ // src/core/plugin-shim.mjs
2
+ // Ephemeral child-process shim for task-source connector operations (spec §7.2).
3
+ // One spawn per op: the child (plugin-shim-child.mjs) imports the connector
4
+ // through <plugin>/current/, runs ONE op, writes ONE JSON frame to stdout, exits.
5
+ // stdin : { apiVersion, module, op, config, state, args }
6
+ // stdout : { ok:true, result, stateDelta, logs }
7
+ // | { ok:false, error:{ kind, message }, logs }
8
+ // Config+secrets+state travel via STDIN — never argv (visible in `ps`), never env
9
+ // (inherited by grandchildren). The child env is scrubbed to {PATH, HOME} only, so
10
+ // plugin X can never read plugin Y's secrets or the host environment. stateDelta
11
+ // is applied HOST-side via writePluginState (the child has no WORCA_HOME and
12
+ // never touches the store). WORCA_MOCK=1 short-circuits the spawn with canned
13
+ // per-op responses so smoke/tests run offline with zero plugins installed.
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import { readFileSync } from 'node:fs';
17
+ import { join, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { WORCA_PLUGIN_API } from './plugin-api.mjs';
20
+ import { normalizeManifest, negotiatedApi } from './plugin-manifest.mjs';
21
+ import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs';
22
+ import { readPluginConfig, readPluginState, writePluginState } from './plugin-config.mjs';
23
+
24
+ const CHILD_PATH = fileURLToPath(new URL('./plugin-shim-child.mjs', import.meta.url));
25
+
26
+ /** Error kinds an op can surface (spec §11); anything else normalizes to 'plugin'. */
27
+ const KINDS = new Set(['auth', 'rate-limit', 'network', 'plugin', 'timeout', 'protocol']);
28
+
29
+ export class PluginOpError extends Error {
30
+ /**
31
+ * @param {'auth'|'rate-limit'|'network'|'plugin'|'timeout'|'protocol'} kind
32
+ * @param {string} message
33
+ */
34
+ constructor(kind, message) {
35
+ super(message);
36
+ this.name = 'PluginOpError';
37
+ this.kind = KINDS.has(kind) ? kind : 'plugin';
38
+ }
39
+ }
40
+
41
+ // ── WORCA_MOCK=1: canned responses, never spawns ─────────────────────────────
42
+
43
+ let _mockResponses = null; // op -> value | (args) => value; null = defaults only
44
+
45
+ /** Tests: override/extend the canned per-op responses. Pass null to reset. */
46
+ export function setMockSourceResponses(map) {
47
+ _mockResponses = map && typeof map === 'object' ? map : null;
48
+ }
49
+
50
+ const MOCK_TASKS = [
51
+ { id: 'MOCK-1', title: 'Fix the login redirect', url: 'https://mock.test/MOCK-1', state: 'open', labels: ['bug'], updatedAt: '2026-07-12T00:00:00.000Z' },
52
+ { id: 'MOCK-2', title: 'Add CSV export to reports', url: 'https://mock.test/MOCK-2', state: 'open', labels: ['feature'], updatedAt: '2026-07-12T00:00:00.000Z' },
53
+ ];
54
+ const MOCK_DEFAULTS = {
55
+ listTasks: () => ({ tasks: MOCK_TASKS.map((t) => ({ ...t })) }),
56
+ getTask: (args) => ({
57
+ ...MOCK_TASKS[0],
58
+ id: args?.id || MOCK_TASKS[0].id,
59
+ body: 'Mock task body.\n\n1. reproduce\n2. fix\n3. verify',
60
+ meta: { mock: true },
61
+ }),
62
+ reportResult: () => ({ ok: true }),
63
+ validateConfig: () => ({ ok: true }),
64
+ };
65
+
66
+ /** Same env-flag semantics as claude-runner.mjs#mockEnabled (claude-runner.mjs:100-104). */
67
+ function mockMode() {
68
+ const v = process.env.WORCA_MOCK ?? process.env.ORCH_MOCK;
69
+ return !!v && v !== '0' && v.toLowerCase() !== 'false';
70
+ }
71
+
72
+ async function mockCall(op, args) {
73
+ const entry = _mockResponses && op in _mockResponses ? _mockResponses[op] : MOCK_DEFAULTS[op];
74
+ if (entry === undefined) {
75
+ // Mirror the real child's answer for an unimplemented op — Task 13's
76
+ // capabilities tolerant-default keys on exactly this kind.
77
+ throw new PluginOpError('plugin', `mock: no canned response for op "${op}"`);
78
+ }
79
+ try {
80
+ return await (typeof entry === 'function' ? entry(args) : entry);
81
+ } catch (err) {
82
+ if (err instanceof PluginOpError) throw err;
83
+ throw new PluginOpError(err?.kind || 'plugin', err?.message || String(err));
84
+ }
85
+ }
86
+
87
+ // ── real path ──────────────────────────────────────────────────────────────────
88
+
89
+ /** Resolve lock entry + manifest task-source, mapping every failure to kind 'plugin'. */
90
+ function loadSource(plugin, sourceId) {
91
+ const lock = readPluginsLock();
92
+ const entry = lock[plugin];
93
+ if (!entry) throw new PluginOpError('plugin', `plugin "${plugin}" is not installed`);
94
+ if (entry.enabled === false) throw new PluginOpError('plugin', `plugin "${plugin}" is disabled — enable it in the Plugins view`);
95
+ const dir = pluginCurrentDir(plugin);
96
+ let manifest;
97
+ try {
98
+ const norm = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
99
+ if (!norm.ok) throw new Error(norm.errors.join('; '));
100
+ manifest = norm.manifest;
101
+ } catch (err) {
102
+ throw new PluginOpError('plugin', `plugin "${plugin}": cannot read manifest — ${err.message}`);
103
+ }
104
+ const source = (manifest.taskSources || []).find((s) => s.id === sourceId);
105
+ if (!source) throw new PluginOpError('plugin', `plugin "${plugin}" has no task source "${sourceId}"`);
106
+ // Highest host API the manifest's range allows: an API-1 connector keeps
107
+ // receiving apiVersion 1 after a host API bump (design §4.3).
108
+ const apiVersion = negotiatedApi(manifest.engines?.worcaApi) ?? WORCA_PLUGIN_API;
109
+ return { dir, source, apiVersion };
110
+ }
111
+
112
+ /** Child env: PATH + HOME ONLY (spec §7.2). Notably NOT WORCA_*, tokens, npm_*.
113
+ * Exported for the channel-worker supervisor (chat/channel-host.mjs), which
114
+ * spawns persistent children under the same rule. */
115
+ export function scrubbedEnv() {
116
+ const env = {};
117
+ if (process.env.PATH) env.PATH = process.env.PATH;
118
+ if (process.env.HOME) env.HOME = process.env.HOME;
119
+ return env;
120
+ }
121
+
122
+ /**
123
+ * Run ONE connector op in an ephemeral child. Resolves with the op result;
124
+ * rejects with PluginOpError. `logger(level, msg)` is optional — connector
125
+ * ctx.log lines route there (default: console.error, since stdout is the UI's).
126
+ * @returns {Promise<any>}
127
+ */
128
+ export async function callSource({ plugin, sourceId, op, args = {}, timeoutMs = 30000, logger } = {}) {
129
+ const log = typeof logger === 'function'
130
+ ? logger
131
+ : (level, msg) => console.error(`[plugin:${plugin}] ${level}: ${msg}`);
132
+
133
+ if (mockMode()) {
134
+ // Canned responses, no spawn, no plugin needed. reportResult additionally
135
+ // records its args into the plugin state — mirroring the real-child
136
+ // stateDelta path — so the offline smoke (Task 19) can assert write-back ran.
137
+ const r = await mockCall(op, args);
138
+ if (op === 'reportResult') writePluginState(plugin, { lastReport: JSON.stringify(args) });
139
+ return r;
140
+ }
141
+
142
+ const { dir, source, apiVersion } = loadSource(plugin, sourceId);
143
+ const payload = JSON.stringify({
144
+ apiVersion,
145
+ module: resolve(dir, source.module), // './'-relative, '..'-free (normalizeManifest)
146
+ op,
147
+ config: readPluginConfig(plugin, source.configSchema),
148
+ state: readPluginState(plugin),
149
+ args,
150
+ });
151
+
152
+ const frame = await new Promise((resolveFrame, rejectFrame) => {
153
+ // WORCA_PLUGIN_INSPECT=1 attaches the debugger to the connector child
154
+ // (`worca plugin exec --inspect` sets it; spec §7.2 debuggability).
155
+ const child = spawn(process.execPath,
156
+ [...(process.env.WORCA_PLUGIN_INSPECT ? ['--inspect-brk'] : []), CHILD_PATH], {
157
+ env: scrubbedEnv(),
158
+ stdio: ['pipe', 'pipe', 'pipe'],
159
+ });
160
+ let stdout = '';
161
+ let stderr = '';
162
+ let timedOut = false;
163
+ let settled = false;
164
+ const killTimer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
165
+ const settle = (fn, v) => { if (!settled) { settled = true; clearTimeout(killTimer); fn(v); } };
166
+ child.stdout.setEncoding('utf8').on('data', (c) => { stdout += c; });
167
+ child.stderr.setEncoding('utf8').on('data', (c) => { stderr += c; });
168
+ child.on('error', (err) => settle(rejectFrame, new PluginOpError('protocol', `plugin "${plugin}": spawn failed — ${err.message}`)));
169
+ child.on('close', (code) => {
170
+ if (timedOut) {
171
+ return settle(rejectFrame, new PluginOpError('timeout', `plugin "${plugin}" op "${op}" exceeded ${timeoutMs}ms (child killed)`));
172
+ }
173
+ if (code !== 0) {
174
+ return settle(rejectFrame, new PluginOpError('protocol',
175
+ `plugin "${plugin}" op "${op}": child exited ${code}${stderr ? ` — ${stderr.slice(0, 400)}` : ''}`));
176
+ }
177
+ try {
178
+ settle(resolveFrame, JSON.parse(stdout));
179
+ } catch {
180
+ settle(rejectFrame, new PluginOpError('protocol',
181
+ `plugin "${plugin}" op "${op}": non-JSON on stdout (stdout is protocol-reserved; use ctx.log) — got: ${stdout.slice(0, 200)}`));
182
+ }
183
+ });
184
+ child.stdin.end(payload); // config/secrets/state via stdin only
185
+ });
186
+
187
+ for (const l of Array.isArray(frame.logs) ? frame.logs : []) {
188
+ log(l?.level || 'info', String(l?.msg ?? ''));
189
+ }
190
+ if (!frame.ok) {
191
+ throw new PluginOpError(frame.error?.kind || 'plugin', frame.error?.message || `plugin "${plugin}" op "${op}" failed`);
192
+ }
193
+ if (frame.stateDelta && typeof frame.stateDelta === 'object' && Object.keys(frame.stateDelta).length) {
194
+ writePluginState(plugin, frame.stateDelta); // host-side persist; child never touches the store
195
+ }
196
+ return frame.result;
197
+ }