@chatpanel/bridge 0.10.27 → 0.10.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.10.27",
3
+ "version": "0.10.29",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
@@ -36,7 +36,9 @@
36
36
  "start": "node src/server.js",
37
37
  "dev": "node --watch src/server.js",
38
38
  "test": "node --test tests/*.test.mjs",
39
- "build:bin": "bash scripts/build-binaries.sh"
39
+ "build:bin": "bash scripts/build-binaries.sh",
40
+ "sync:events": "node scripts/sync-events.mjs",
41
+ "test:events-sync": "node scripts/sync-events.mjs --check"
40
42
  },
41
43
  "dependencies": {},
42
44
  "optionalDependencies": {
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // Vendors the shared contracts the bridge needs from `chatpanel-events`.
3
+ //
4
+ // The bridge has ZERO runtime dependencies on purpose — it is installed by a curl
5
+ // one-liner and compiled into a single binary, and every dependency is a thing that can
6
+ // fail at install time on someone's laptop. So shared contracts arrive the way
7
+ // @chatpanel/pii does: copied in, generated, never hand-edited.
8
+ //
9
+ // node scripts/sync-events.mjs refresh src/events/ from the package
10
+ // node scripts/sync-events.mjs --check verify they match (CI drift guard); exit 1 if not
11
+ //
12
+ // A hand-copy is how `sanitize.js` could silently diverge from the engine it came from.
13
+ // This makes divergence a failing test instead of a bug report.
14
+
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { dirname, join } from 'node:path';
18
+
19
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ // Deliberately short. `skill-manifest.js` imports only `scopes.js`, which is why that
22
+ // vocabulary was split out of `capability.js` — vendoring the capability machinery and
23
+ // the event schema to reach a five-element array would defeat the point.
24
+ const FILES = ['scopes.js', 'skill-manifest.js'];
25
+
26
+ function pkgDir() {
27
+ return [
28
+ join(ROOT, 'node_modules', '@chatpanel', 'events'),
29
+ join(ROOT, '..', 'chatpanel-events'),
30
+ ].find((d) => existsSync(join(d, 'skill-manifest.js')));
31
+ }
32
+
33
+ const check = process.argv.includes('--check');
34
+ const src = pkgDir();
35
+
36
+ if (!src) {
37
+ const msg = 'chatpanel-events not found (check out ../chatpanel-events).';
38
+ if (check) { console.error(`sync-events --check: ${msg}`); process.exit(1); }
39
+ console.warn(`sync-events: ${msg} Leaving src/events as-is.`);
40
+ process.exit(0);
41
+ }
42
+
43
+ const outDir = join(ROOT, 'src', 'events');
44
+ if (!check) mkdirSync(outDir, { recursive: true });
45
+
46
+ const banner = (f) => `// GENERATED — do not edit.\n`
47
+ + `// Source of truth: chatpanel-events/${f} (npm @chatpanel/events).\n`
48
+ + `// Edit there, then run: npm run sync:events\n`
49
+ + `//\n`
50
+ + `// Vendored rather than depended on: the bridge ships zero runtime dependencies so a\n`
51
+ + `// curl one-liner install cannot fail on someone's registry, and so the compiled\n`
52
+ + `// single-file binary has nothing to resolve.\n\n`;
53
+
54
+ let drift = 0;
55
+ for (const f of FILES) {
56
+ const want = banner(f) + readFileSync(join(src, f), 'utf8');
57
+ const dest = join(outDir, f);
58
+ const have = existsSync(dest) ? readFileSync(dest, 'utf8') : null;
59
+ if (have === want) continue;
60
+ if (check) { console.error(`sync-events --check: src/events/${f} differs from chatpanel-events`); drift += 1; continue; }
61
+ writeFileSync(dest, want);
62
+ console.log(`sync-events: updated src/events/${f}`);
63
+ }
64
+
65
+ if (check) {
66
+ if (drift) { console.error('Run `npm run sync:events` and commit the result.'); process.exit(1); }
67
+ console.log('sync-events --check: src/events matches chatpanel-events ✓');
68
+ }
@@ -0,0 +1,20 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-events/scopes.js (npm @chatpanel/events).
3
+ // Edit there, then run: npm run sync:events
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // scopes.js — the data-scope vocabulary, on its own so it can travel alone.
10
+ //
11
+ // One list names what anything in ChatPanel may touch. Capabilities declare `reads`
12
+ // and `writes` from it, sources declare `reads`, and a skill package declares `reads`
13
+ // — three declarations, one vocabulary, or "what may this reach" gets three answers.
14
+ //
15
+ // It is a separate module rather than a constant inside capability.js because the
16
+ // consumers have very different weights. The bridge vendors the skill contract and has
17
+ // zero runtime dependencies by design; pulling the capability machinery and the event
18
+ // schema behind it to reach a five-element array would be the transitive-graph mistake
19
+ // the extension's first-paint budget exists to prevent, one repo over.
20
+ export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'page', 'files', 'net']);
@@ -0,0 +1,338 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-events/skill-manifest.js (npm @chatpanel/events).
3
+ // Edit there, then run: npm run sync:events
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // skill-manifest.js — what a skill IS, for every client that stores or runs one.
10
+ //
11
+ // A skill was a settings record: a prompt plus a few dropdowns, normalized by whichever
12
+ // client happened to save it. That was survivable while every skill was hand-written by
13
+ // the person running it. It stops being survivable the moment a skill can ARRIVE from
14
+ // somewhere — a hub, a repo, another agent's skill directory — because the questions
15
+ // change from "what does this prompt say" to "where did it come from, what may it reach,
16
+ // and has anything checked it".
17
+ //
18
+ // So this module owns the record. Not the storage (a platform question), not the UI (a
19
+ // platform question), not the fetching (F6 S3) — the shape, its evolution, and the two
20
+ // derivations no client may compute for itself:
21
+ //
22
+ // • TRUST IS DERIVED, NEVER DECLARED. A skill cannot say it is trusted. `trustOf()`
23
+ // reads provenance: shipped by us -> 'built-in', fetched -> 'community', neither ->
24
+ // 'user'. A stored `trust` field is stripped on normalize, and `builtin` is forced
25
+ // false whenever an origin is present — otherwise "trusted" would be a string an
26
+ // importer sets.
27
+ // • DECLARED ACCESS IS COMPUTED FROM THE RECORD. Everything a skill can reach is
28
+ // readable before it runs, which is what makes load-time approval possible (P10).
29
+ // If a reviewer's summary were assembled by the install screen, the install screen
30
+ // would be the security boundary.
31
+ //
32
+ // Versioning follows the log's discipline: additive only, absence means the previous
33
+ // default, and the upcast chain exists from the start so adding v3 does not mean
34
+ // rewriting every reader.
35
+
36
+ import { DATA_SCOPES } from './scopes.js';
37
+
38
+ export class SkillManifestError extends Error {
39
+ constructor(code, message) { super(message); this.name = 'SkillManifestError'; this.code = code; }
40
+ }
41
+
42
+ /** Schema version of the RECORD. Distinct from `version`, which is the author's semver. */
43
+ export const SKILL_MANIFEST_VERSION = 2;
44
+
45
+ export const SKILL_CONTEXTS = Object.freeze(['auto', 'page', 'selection', 'tabs', 'none']);
46
+ export const SKILL_HISTORY_SCOPES = Object.freeze(['none', 'chats', 'meetings', 'all']);
47
+ export const SKILL_MCP_MODES = Object.freeze(['none', 'selected', 'default']);
48
+
49
+ /**
50
+ * Derived, never stored. 'community' is deliberately the level for a skill fetched from
51
+ * a vendor repo with a famous name — only what we ship went through our review.
52
+ */
53
+ export const SKILL_TRUST = Object.freeze(['built-in', 'user', 'community']);
54
+
55
+ /**
56
+ * The directories a package may carry, matching the agentskills.io layout. `scripts` is
57
+ * the tier-3 one: it cannot run in a browser extension and must not be offered as if it
58
+ * could (see `needsBridge`).
59
+ */
60
+ export const SKILL_FILE_KINDS = Object.freeze(['references', 'scripts', 'assets', 'templates', 'examples']);
61
+
62
+ const str = (v) => typeof v === 'string' && v.length > 0;
63
+
64
+ // A slash command is typed by a human and matched case-insensitively against a stored
65
+ // string; anything outside this grammar either cannot be typed or collides with the
66
+ // leading-slash parse.
67
+ const COMMAND = /^[a-z0-9][a-z0-9_-]*$/;
68
+
69
+ /**
70
+ * A file path inside a package directory.
71
+ *
72
+ * The check is a security boundary, not tidiness: these strings become filesystem paths
73
+ * in the bridge's skill store, and a package is authored by a stranger. Rejected are
74
+ * absolute paths, any `..` segment, backslashes (a Windows separator that a POSIX
75
+ * `split('/')` would not see as one), leading/trailing whitespace and control characters.
76
+ * Allowing one of them is a directory-traversal write on someone's machine.
77
+ */
78
+ export function isSafeSkillPath(p) {
79
+ if (!str(p) || p.length > 255) return false;
80
+ if (p !== p.trim()) return false;
81
+ if (p.includes('\\')) return false;
82
+ // eslint-disable-next-line no-control-regex
83
+ if (/[\u0000-\u001f\u007f]/.test(p)) return false;
84
+ if (p.startsWith('/') || /^[a-z]:/i.test(p)) return false;
85
+ const parts = p.split('/');
86
+ if (parts.some((seg) => seg === '' || seg === '.' || seg === '..')) return false;
87
+ return true;
88
+ }
89
+
90
+ /** Did this skill come from somewhere, or did the user write it? */
91
+ export function originOf(skill) {
92
+ const o = skill?.origin;
93
+ return o && typeof o === 'object' && str(o.source) && str(o.id) ? o : null;
94
+ }
95
+
96
+ /**
97
+ * Derived from provenance, never read from the record. A skill that arrived from outside
98
+ * is 'community' whoever published it; 'built-in' means we shipped it.
99
+ */
100
+ export function trustOf(skill) {
101
+ if (originOf(skill)) return 'community';
102
+ return skill?.builtin ? 'built-in' : 'user';
103
+ }
104
+
105
+ /** The files a package carries, per directory, already filtered to safe paths. */
106
+ export function skillFiles(skill) {
107
+ const files = skill?.files;
108
+ const out = {};
109
+ if (!files || typeof files !== 'object') return out;
110
+ for (const kind of SKILL_FILE_KINDS) {
111
+ const list = Array.isArray(files[kind]) ? files[kind].filter(isSafeSkillPath) : [];
112
+ if (list.length) out[kind] = [...new Set(list)];
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * True when running this skill fully needs a host that can execute code — i.e. the
119
+ * bridge. The extension may still store, show and run the PROMPT half; what it may not
120
+ * do is pretend the scripts ran.
121
+ */
122
+ export function needsBridge(skill) {
123
+ return (skillFiles(skill).scripts || []).length > 0;
124
+ }
125
+
126
+ /**
127
+ * Everything this skill can reach, computed from the record. This is what an install
128
+ * review, the Plugins lens and an admin export all read — one derivation, so the three
129
+ * cannot disagree about what was approved.
130
+ */
131
+ export function declaredAccess(skill = {}) {
132
+ const files = skillFiles(skill);
133
+ const mcpMode = SKILL_MCP_MODES.includes(skill.mcpMode) ? skill.mcpMode : 'none';
134
+ const context = SKILL_CONTEXTS.includes(skill.context) ? skill.context : 'auto';
135
+ const history = SKILL_HISTORY_SCOPES.includes(skill.historyContext) ? skill.historyContext : 'none';
136
+ const reads = new Set(Array.isArray(skill.reads) ? skill.reads.filter((r) => DATA_SCOPES.includes(r)) : []);
137
+ // The dropdowns ARE access statements; a record that declared `reads` separately from
138
+ // them could claim less than it takes.
139
+ if (context !== 'none') reads.add('page');
140
+ if (history === 'chats' || history === 'all') reads.add('chats');
141
+ if (history === 'meetings' || history === 'all') reads.add('meetings');
142
+ return {
143
+ trust: trustOf(skill),
144
+ reads: [...reads].sort(),
145
+ page: context,
146
+ history,
147
+ mcp: mcpMode,
148
+ mcpServerIds: mcpMode === 'selected' && Array.isArray(skill.mcpServerIds) ? [...skill.mcpServerIds] : [],
149
+ sources: Array.isArray(skill.sources) ? [...new Set(skill.sources.filter(str))].sort() : [],
150
+ surfaces: Array.isArray(skill.surfaces) ? [...new Set(skill.surfaces.filter(str))].sort() : [],
151
+ scripts: files.scripts || [],
152
+ needsBridge: needsBridge(skill),
153
+ meeting: !!skill.meeting,
154
+ };
155
+ }
156
+
157
+ /** Human label for where a skill came from, for the surfaces that must show provenance. */
158
+ export function originLabel(skill) {
159
+ const o = originOf(skill);
160
+ if (!o) return trustOf(skill) === 'built-in' ? 'Built-in' : 'Written here';
161
+ return o.id.length > 48 ? `${o.source} · …${o.id.slice(-40)}` : `${o.source} · ${o.id}`;
162
+ }
163
+
164
+ /** Two records describing the same upstream skill — for update checks and dedupe. */
165
+ export function sameSkillOrigin(a, b) {
166
+ const x = originOf(a);
167
+ const y = originOf(b);
168
+ return !!x && !!y && x.source === y.source && x.id === y.id;
169
+ }
170
+
171
+ /**
172
+ * Has upstream changed since this was installed? `null` when unanswerable (no origin, or
173
+ * nothing was hashed) — which callers must treat as "do not claim it is current" rather
174
+ * than as "up to date".
175
+ */
176
+ export function skillIsStale(skill, upstreamHash) {
177
+ const o = originOf(skill);
178
+ if (!o || !str(o.hash) || !str(upstreamHash)) return null;
179
+ return o.hash !== upstreamHash;
180
+ }
181
+
182
+ /**
183
+ * Validate a skill DECLARATION — the static surface approved before anything runs.
184
+ * Throws on the first problem, like `validateCapability`.
185
+ */
186
+ export function validateSkill(skill) {
187
+ if (!skill || typeof skill !== 'object') throw new SkillManifestError('SHAPE', 'skill must be an object');
188
+ if (!str(skill.id)) throw new SkillManifestError('SHAPE', 'skill.id required');
189
+ if (!str(skill.name)) throw new SkillManifestError('SHAPE', `skill '${skill.id}': name required`);
190
+ if (skill.prompt != null && typeof skill.prompt !== 'string') {
191
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': prompt must be a string`);
192
+ }
193
+ if (skill.command != null && skill.command !== '' && !COMMAND.test(String(skill.command))) {
194
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': command must match ${COMMAND}`);
195
+ }
196
+ for (const [field, allowed] of [
197
+ ['context', SKILL_CONTEXTS], ['historyContext', SKILL_HISTORY_SCOPES], ['mcpMode', SKILL_MCP_MODES],
198
+ ]) {
199
+ if (skill[field] != null && !allowed.includes(skill[field])) {
200
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': ${field} must be one of ${allowed}`);
201
+ }
202
+ }
203
+ if (skill.reads != null) {
204
+ if (!Array.isArray(skill.reads) || !skill.reads.every((r) => DATA_SCOPES.includes(r))) {
205
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': reads must be within ${DATA_SCOPES}`);
206
+ }
207
+ }
208
+ for (const field of ['sources', 'surfaces']) {
209
+ if (skill[field] != null && (!Array.isArray(skill[field]) || !skill[field].every(str))) {
210
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': ${field} must be an array of ids`);
211
+ }
212
+ }
213
+ if (skill.origin != null) {
214
+ const o = skill.origin;
215
+ if (typeof o !== 'object') throw new SkillManifestError('SHAPE', `skill '${skill.id}': origin must be an object`);
216
+ // Without a source and an id it cannot be re-fetched or compared, which is the whole
217
+ // reason the field exists; a half-origin is worse than none because it looks answered.
218
+ if (!str(o.source) || !str(o.id)) {
219
+ throw new SkillManifestError('ORIGIN', `skill '${skill.id}': origin needs both source and id`);
220
+ }
221
+ }
222
+ if (skill.files != null) {
223
+ if (typeof skill.files !== 'object') throw new SkillManifestError('SHAPE', `skill '${skill.id}': files must be an object`);
224
+ for (const kind of Object.keys(skill.files)) {
225
+ if (!SKILL_FILE_KINDS.includes(kind)) {
226
+ throw new SkillManifestError('FILES', `skill '${skill.id}': unknown file kind '${kind}'`);
227
+ }
228
+ const list = skill.files[kind];
229
+ if (!Array.isArray(list) || !list.every(str)) {
230
+ throw new SkillManifestError('FILES', `skill '${skill.id}': files.${kind} must be an array of paths`);
231
+ }
232
+ const bad = list.find((p) => !isSafeSkillPath(p));
233
+ if (bad) throw new SkillManifestError('PATH', `skill '${skill.id}': unsafe path in files.${kind}: ${JSON.stringify(bad)}`);
234
+ }
235
+ }
236
+ // Files without an origin means someone hand-wrote a package record; that is allowed,
237
+ // but scripts without an origin cannot be scanned against anything, and an unscannable
238
+ // script is exactly what the admission gate exists to refuse.
239
+ if (!originOf(skill) && (skill.files?.scripts || []).length) {
240
+ throw new SkillManifestError('ORIGIN', `skill '${skill.id}': scripts require an origin to be scannable`);
241
+ }
242
+ return skill;
243
+ }
244
+
245
+ /**
246
+ * v(n) -> v(n+1). Each MUST be total: it may not throw for any record of its version.
247
+ *
248
+ * v1 -> v2 stamps the version and nothing else, on purpose. Every field F6 adds is
249
+ * absence-means-the-old-default, so there is nothing to fill in — the same discipline as
250
+ * storing which plugins are DISABLED rather than the full state, so a field added later
251
+ * needs no migration.
252
+ */
253
+ export const SKILL_UPCASTERS = Object.freeze({
254
+ 1: (s) => ({ ...s, v: SKILL_MANIFEST_VERSION }),
255
+ });
256
+
257
+ /** Carry a stored skill forward to the current schema. Pure; never mutates the input. */
258
+ export function upcastSkill(stored) {
259
+ if (!stored || typeof stored !== 'object') throw new SkillManifestError('SHAPE', 'skill must be an object');
260
+ // A record written before the version existed is v1 by definition.
261
+ let s = typeof stored.v === 'number' ? stored : { ...stored, v: 1 };
262
+ let guard = 0;
263
+ while (s.v < SKILL_MANIFEST_VERSION) {
264
+ const step = SKILL_UPCASTERS[s.v];
265
+ if (!step) throw new SkillManifestError('UPCAST', `no upcaster from v${s.v}`);
266
+ s = step(s);
267
+ if (++guard > 64) throw new SkillManifestError('UPCAST', 'upcaster chain did not terminate');
268
+ }
269
+ if (s.v > SKILL_MANIFEST_VERSION) {
270
+ throw new SkillManifestError('UPCAST', `skill is v${s.v}; this reader only knows v${SKILL_MANIFEST_VERSION}`);
271
+ }
272
+ return s;
273
+ }
274
+
275
+ export function upcastSkills(stored) {
276
+ return (Array.isArray(stored) ? stored : []).map(upcastSkill);
277
+ }
278
+
279
+ /**
280
+ * Coerce a record into the canonical shape a writer stores. Total and forgiving — this
281
+ * runs on save, where throwing would cost a user their edit; `validateSkill` is the
282
+ * strict gate, used where a record ARRIVES.
283
+ *
284
+ * The security-relevant coercions are the ones that cannot be left to a caller:
285
+ * `trust` is never persisted, `builtin` cannot survive an origin, and an unsafe file
286
+ * path is dropped rather than stored and rejected later.
287
+ */
288
+ export function normalizeSkill(skill) {
289
+ if (!skill || typeof skill !== 'object') return skill;
290
+ const out = { ...upcastSkill(skill) };
291
+
292
+ // Skills predate the enabled flag — absence means enabled.
293
+ out.enabled = out.enabled !== false;
294
+
295
+ const mode = String(out.mcpMode || 'none').toLowerCase();
296
+ out.mcpMode = SKILL_MCP_MODES.includes(mode) ? mode : 'none';
297
+ const ids = Array.isArray(out.mcpServerIds) ? out.mcpServerIds : [];
298
+ out.mcpServerIds = out.mcpMode === 'selected'
299
+ ? [...new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))]
300
+ : [];
301
+
302
+ // Derived, never stored: a record that could assert its own trust would make every
303
+ // check downstream a formality.
304
+ delete out.trust;
305
+
306
+ const origin = originOf(out);
307
+ if (origin) {
308
+ out.builtin = false; // "we shipped it" is not something an import may claim
309
+ out.origin = {
310
+ source: origin.source,
311
+ id: origin.id,
312
+ ...(str(origin.url) ? { url: origin.url } : {}),
313
+ ...(str(origin.hash) ? { hash: origin.hash } : {}),
314
+ ...(origin.scanned && typeof origin.scanned === 'object' ? { scanned: { ...origin.scanned } } : {}),
315
+ };
316
+ } else if (out.origin != null) {
317
+ delete out.origin; // a half-origin looks answered and is not
318
+ }
319
+
320
+ if (out.reads != null) {
321
+ out.reads = [...new Set((Array.isArray(out.reads) ? out.reads : []).filter((r) => DATA_SCOPES.includes(r)))].sort();
322
+ }
323
+ for (const field of ['sources', 'surfaces']) {
324
+ if (out[field] != null) {
325
+ out[field] = [...new Set((Array.isArray(out[field]) ? out[field] : []).filter(str))];
326
+ }
327
+ }
328
+
329
+ if (out.files != null) {
330
+ const files = skillFiles(out);
331
+ if (Object.keys(files).length) out.files = files;
332
+ else delete out.files;
333
+ }
334
+
335
+ if (out.version != null && !str(out.version)) delete out.version;
336
+
337
+ return out;
338
+ }
package/src/server.js CHANGED
@@ -14,6 +14,9 @@
14
14
  // POST /v1/chat/completions, /v1/completions, /v1/responses
15
15
  // → OpenAI-compatible text adapters for the local agents
16
16
  // POST /v1/messages → Anthropic-compatible text adapter for the local agents
17
+ // GET /skills → skill packages on disk (name + description + files)
18
+ // GET /skills/<name> → one skill, SKILL.md body included
19
+ // GET /skills/<name>/file/<path> → one reference/template/asset from that package
17
20
  //
18
21
  // Binds to 127.0.0.1 only. A request guard (see `guard()`) enforces a loopback
19
22
  // Host (anti DNS-rebinding) and an allowlisted Origin; the command-spawning
@@ -33,6 +36,7 @@ import { pi, opencode, kiro, copilot, deepseek } from './engines/cli-agents.js';
33
36
  import { connectorsFor } from './connectors.js';
34
37
  import * as custom from './engines/custom.js';
35
38
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
39
+ import { skillIndex, listRecords, readRecord, readPackageFile, skillsHealth } from './skills.js';
36
40
  import { AGENT_CLIS, enrichPath, enrichAgentEnv, findAgentBin, resolveCommand } from './env.js';
37
41
  import { stripHidden } from './sanitize.js';
38
42
  import { checkForUpdate, selfUpdate } from './update.js';
@@ -60,7 +64,7 @@ import {
60
64
  // Hardcoded (not read from package.json) so it survives Bun's single-file
61
65
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
62
66
  // this drifts from package.json, so the two can't silently diverge.
63
- const VERSION = '0.10.27';
67
+ const VERSION = '0.10.29';
64
68
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
65
69
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
66
70
 
@@ -273,6 +277,28 @@ const PRIVILEGED_POST = new Set([
273
277
  ]);
274
278
  const PRIVILEGED_GET = new Set(['/debug']);
275
279
 
280
+ // /skills* is NOT privileged, and that is a considered position rather than a
281
+ // convenience. `privileged` adds exactly one thing over the origin allowlist: it requires
282
+ // the Origin header to be PRESENT, so a no-Origin local process cannot pose as the
283
+ // extension. Work through who that actually excludes for a read-only listing of files:
284
+ //
285
+ // • a web page — always sends Origin, and a non-allowed one is already refused above;
286
+ // • a page on http://localhost — sends Origin, which `isExtensionOrigin` already
287
+ // accepts, so the privileged check never stopped it either way;
288
+ // • a page using <img>/<script> to force a no-Origin GET — cannot read the response,
289
+ // because it is JSON with no CORS grant to that origin;
290
+ // • remote SSRF into 127.0.0.1 — arrives with a non-loopback Host and dies at
291
+ // `hostAllowed` long before this;
292
+ // • a local process — the only caller left, and it can read the very same SKILL.md
293
+ // files straight off the disk.
294
+ //
295
+ // So the requirement excluded nothing that could not already read the bytes, while
296
+ // breaking the one client that should have them: Chrome omits Origin on a simple GET from
297
+ // an extension page (the POST routes get it only because a JSON body forces a preflight).
298
+ // The origin allowlist stays and is what keeps web pages out. /debug remains privileged —
299
+ // it exposes configuration a local process cannot otherwise see.
300
+ const isPrivilegedGetPath = (p) => PRIVILEGED_GET.has(p);
301
+
276
302
  // SSRF guard for /mcp-remote lives in ./ssrf.js (assertPublicHttpUrl). Loopback
277
303
  // is allowed (the user's own localhost MCP server — the common "via bridge"
278
304
  // case; the extension can reach it directly anyway), cloud metadata is always
@@ -287,7 +313,7 @@ function guard(req, pathname) {
287
313
  if (origin && !originAllowed(origin)) return 'forbidden origin';
288
314
  const privileged =
289
315
  (req.method === 'POST' && PRIVILEGED_POST.has(pathname)) ||
290
- (req.method === 'GET' && PRIVILEGED_GET.has(pathname));
316
+ (req.method === 'GET' && isPrivilegedGetPath(pathname));
291
317
  if (privileged && !(isExtensionOrigin(origin) || tokenOk(req))) {
292
318
  return 'forbidden: this endpoint requires the ChatPanel extension or a valid bridge token';
293
319
  }
@@ -336,7 +362,41 @@ async function handleHealth(res) {
336
362
  }),
337
363
  );
338
364
  const update = await checkForUpdate(VERSION).catch(() => ({ current: VERSION, updateAvailable: false }));
339
- json(res, 200, { ok: true, version: VERSION, agents, update });
365
+ // ADDITIVE, and the client's only way to know this bridge can host skill packages —
366
+ // an older bridge simply omits it, which is what stops a newer extension assuming the
367
+ // endpoints exist. Never let a scan failure cost the caller its health check.
368
+ const skills = await skillsHealth().catch(() => null);
369
+ json(res, 200, { ok: true, version: VERSION, agents, update, ...(skills ? { skills } : {}) });
370
+ }
371
+
372
+ // --------------------------------------------------------------------------
373
+ // Skill packages (F6 S2). Read-only: the store serves what is already on disk.
374
+ // Installing FROM a hub waits for the scanner — a write endpoint that lands before
375
+ // the gate is a window in which unscanned packages can be written, and windows like
376
+ // that do not close on schedule.
377
+ //
378
+ // The three routes are the progressive-disclosure ladder, so a client pays for a
379
+ // skill's body only when it picks one, and for a reference file only when it needs it.
380
+ // --------------------------------------------------------------------------
381
+ async function handleSkillsList(res) {
382
+ const { index, problems } = await skillIndex();
383
+ json(res, 200, { ok: true, skills: listRecords(index), problems });
384
+ }
385
+
386
+ async function handleSkillRead(res, name) {
387
+ const { index } = await skillIndex();
388
+ const skill = readRecord(index, name);
389
+ if (!skill) return json(res, 404, { ok: false, error: 'unknown skill' });
390
+ json(res, 200, { ok: true, skill });
391
+ }
392
+
393
+ async function handleSkillFile(res, name, relPath) {
394
+ const { index } = await skillIndex();
395
+ const out = await readPackageFile(index, name, relPath);
396
+ // One shape for every refusal: a caller learns that it may not have the file, not
397
+ // whether the path exists, which is the difference between an error and an oracle.
398
+ if (out.error) return json(res, out.error === 'unknown skill' ? 404 : 400, { ok: false, error: out.error });
399
+ json(res, 200, { ok: true, ...out });
340
400
  }
341
401
 
342
402
  async function compatibleModels() {
@@ -978,6 +1038,17 @@ const server = createServer(async (req, res) => {
978
1038
  if (blocked) return json(res, 403, { error: blocked });
979
1039
  try {
980
1040
  if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
1041
+ if (req.method === 'GET' && url.pathname === '/skills') return handleSkillsList(res);
1042
+ if (req.method === 'GET' && url.pathname.startsWith('/skills/')) {
1043
+ const rest = url.pathname.slice('/skills/'.length);
1044
+ const cut = rest.indexOf('/file/');
1045
+ if (cut === -1) return handleSkillRead(res, decodeURIComponent(rest));
1046
+ return handleSkillFile(
1047
+ res,
1048
+ decodeURIComponent(rest.slice(0, cut)),
1049
+ decodeURIComponent(rest.slice(cut + '/file/'.length)),
1050
+ );
1051
+ }
981
1052
  if (req.method === 'GET' && url.pathname === '/v1/models') return handleCompatibleModels(res);
982
1053
  if (req.method === 'GET' && url.pathname.startsWith('/v1/models/')) {
983
1054
  return handleCompatibleModels(res, decodeURIComponent(url.pathname.slice('/v1/models/'.length)));
package/src/skills.js ADDED
@@ -0,0 +1,334 @@
1
+ // skills.js — the skill package store.
2
+ //
3
+ // A ChatPanel skill has been a prompt in the extension's settings, which is fine until a
4
+ // skill wants to carry the things the agentskills.io format carries: reference documents
5
+ // loaded only when needed, templates, and scripts. None of those can live in a browser
6
+ // extension — it has no filesystem, and running someone else's script inside it is on the
7
+ // Never list. They live here.
8
+ //
9
+ // So the bridge becomes a skill HOST, not a skill consumer. It scans directories, parses
10
+ // each SKILL.md into the shared record, and serves three levels on demand — the
11
+ // progressive-disclosure ladder the format is built around:
12
+ //
13
+ // list() name + description + what files exist (cheap, every turn)
14
+ // read(name) the full SKILL.md body (when it is chosen)
15
+ // readFile(name, p) one reference/asset (when it is needed)
16
+ //
17
+ // It also scans ~/.agents/skills — the cross-tool convention several agent CLIs already
18
+ // use — so a skill written in another tool appears here with no export step. That is the
19
+ // point of putting the store in the bridge rather than in one client.
20
+ //
21
+ // ── SECURITY ──────────────────────────────────────────────────────────────────────────
22
+ // This module turns HTTP request strings into filesystem reads, which makes it the most
23
+ // dangerous file in the repo. Three rules, none of them optional:
24
+ //
25
+ // 1. A REQUESTED NAME IS NEVER A PATH. `read('../../.ssh/id_rsa')` resolves the name
26
+ // against the scanned INDEX; a name that is not in the index does not exist. There
27
+ // is no code path from a URL segment to a path join.
28
+ // 2. A REQUESTED FILE PATH IS CHECKED TWICE — lexically (the shared `isSafeSkillPath`,
29
+ // which refuses traversal, absolute paths, drive letters, backslashes and control
30
+ // characters) and then again after resolution, because a SYMLINK inside a skill
31
+ // directory passes every lexical check and still points at ~/.ssh.
32
+ // 3. EVERY READ IS CAPPED. A skill directory is not necessarily authored by the person
33
+ // running it, and an unbounded read of a file someone else chose is a denial of
34
+ // service against the process the browser depends on.
35
+
36
+ import { readFile as fsReadFile, readdir, stat, realpath } from 'node:fs/promises';
37
+ import { createHash } from 'node:crypto';
38
+ import os from 'node:os';
39
+ import { join, resolve, sep } from 'node:path';
40
+ import { isSafeSkillPath, normalizeSkill, SKILL_FILE_KINDS } from './events/skill-manifest.js';
41
+
42
+ const MAX_SKILL_MD = 512 * 1024; // a procedure document, not a corpus
43
+ const MAX_ASSET = 4 * 1024 * 1024; // a reference doc or a template; images live elsewhere
44
+ const MAX_SKILLS = 500; // a scan is bounded work, not "whatever is on disk"
45
+ const MAX_DEPTH = 2; // <root>/<name>/ and <root>/<category>/<name>/
46
+
47
+ /**
48
+ * The agent CLIs that keep skills in a well-known directory, and what to call each one.
49
+ *
50
+ * Every one of these stores the SAME agentskills.io layout — `<name>/SKILL.md` plus
51
+ * optional `references/`. In practice a machine ends up with the same skill copied into
52
+ * several of them, which is the duplication the shared `~/.agents/skills` convention
53
+ * exists to end and has not yet. Reading all of them is what lets ChatPanel show the
54
+ * user's actual skills rather than the subset that happens to live in one folder.
55
+ *
56
+ * Ordered: ChatPanel's own directory, then the cross-tool convention, then each agent.
57
+ * The order IS the precedence — see skillRoots.
58
+ */
59
+ export const AGENT_SKILL_DIRS = Object.freeze([
60
+ { source: 'local', label: 'ChatPanel', segments: ['.chatpanel', 'skills'], writable: true },
61
+ { source: 'agents-dir', label: 'Shared (~/.agents)', segments: ['.agents', 'skills'] },
62
+ { source: 'claude', label: 'Claude Code', segments: ['.claude', 'skills'] },
63
+ { source: 'codex', label: 'Codex', segments: ['.codex', 'skills'] },
64
+ { source: 'copilot', label: 'GitHub Copilot', segments: ['.copilot', 'skills'] },
65
+ { source: 'gemini', label: 'Antigravity / Gemini', segments: ['.gemini', 'skills'] },
66
+ { source: 'opencode', label: 'OpenCode', segments: ['.opencode', 'skills'] },
67
+ { source: 'kiro', label: 'Kiro', segments: ['.kiro', 'skills'] },
68
+ { source: 'pi', label: 'Pi', segments: ['.pi', 'skills'] },
69
+ { source: 'hermes', label: 'Hermes', segments: ['.hermes', 'skills'] },
70
+ ]);
71
+
72
+ /**
73
+ * Where skills are scanned from, in precedence order — first hit wins on a name clash.
74
+ *
75
+ * ChatPanel's own directory is authoritative, then the shared cross-tool one, then each
76
+ * agent's. That order matters because the same skill genuinely does exist in several of
77
+ * these at once: whichever root answers first is the copy that gets shown, and a stable
78
+ * order means the answer does not change between scans.
79
+ *
80
+ * Only the first is written. The rest belong to whatever else the user runs, and a tool
81
+ * that edits another tool's configuration directory is a tool people uninstall.
82
+ */
83
+ export function skillRoots(env = process.env, home = os.homedir()) {
84
+ const extra = String(env.CHATPANEL_SKILL_DIRS || '')
85
+ .split(/[:;\n]/)
86
+ .map((s) => s.trim())
87
+ .filter(Boolean);
88
+ return [
89
+ ...AGENT_SKILL_DIRS.map((d) => ({
90
+ dir: join(home, ...d.segments),
91
+ source: d.source,
92
+ label: d.label,
93
+ writable: !!d.writable,
94
+ })),
95
+ ...extra.map((dir) => ({ dir: resolve(dir), source: 'external', label: 'Custom', writable: false })),
96
+ ];
97
+ }
98
+
99
+ /**
100
+ * The YAML subset a SKILL.md frontmatter block actually uses: scalars, inline `[a, b]`
101
+ * lists, and one level of nesting. Deliberately NOT a YAML parser — a real one is a
102
+ * dependency the bridge does not take, and a full parser is a larger attack surface than
103
+ * the five fields we read. Anything it does not understand is ignored, never guessed at.
104
+ */
105
+ export function parseFrontmatter(text) {
106
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(String(text || ''));
107
+ if (!m) return { meta: {}, body: String(text || '') };
108
+ const out = {};
109
+ const stack = [{ indent: -1, obj: out }];
110
+ for (const raw of m[1].split(/\r?\n/)) {
111
+ if (!raw.trim() || raw.trim().startsWith('#')) continue;
112
+ const indent = raw.length - raw.trimStart().length;
113
+ const line = raw.trim();
114
+ const kv = /^([A-Za-z_][\w.-]*)\s*:\s*(.*)$/.exec(line);
115
+ if (!kv) continue;
116
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
117
+ const parent = stack[stack.length - 1].obj;
118
+ const [, key, rest] = kv;
119
+ if (rest === '') {
120
+ const child = {};
121
+ parent[key] = child;
122
+ stack.push({ indent, obj: child });
123
+ continue;
124
+ }
125
+ parent[key] = scalar(rest);
126
+ }
127
+ return { meta: out, body: String(text).slice(m[0].length) };
128
+ }
129
+
130
+ function scalar(v) {
131
+ const s = v.trim().replace(/\s+#.*$/, '').trim();
132
+ if (/^\[.*\]$/.test(s)) {
133
+ return s.slice(1, -1).split(',').map((x) => unquote(x.trim())).filter(Boolean);
134
+ }
135
+ if (s === 'true') return true;
136
+ if (s === 'false') return false;
137
+ return unquote(s);
138
+ }
139
+
140
+ const unquote = (s) => (/^(['"]).*\1$/.test(s) ? s.slice(1, -1) : s);
141
+
142
+ /** A directory name usable as a skill id when the frontmatter has no `name`. */
143
+ const ID = /^[a-z0-9][a-z0-9_-]*$/;
144
+
145
+ /**
146
+ * Turn a parsed SKILL.md plus its directory listing into the shared record.
147
+ *
148
+ * Local skills carry an ORIGIN, and therefore read as `community` rather than `user`.
149
+ * That is deliberate and it is the conservative answer: we cannot tell a skill the user
150
+ * wrote from one that arrived in a synced or checked-out directory, and treating the
151
+ * second as trusted is exactly the mistake. Only skills authored in ChatPanel itself are
152
+ * the user's own.
153
+ */
154
+ export function skillRecord({ meta, body, dirName, relPath, source, files, hash }) {
155
+ const name = typeof meta.name === 'string' && meta.name.trim() ? meta.name.trim() : dirName;
156
+ const id = ID.test(String(name)) ? String(name) : dirName;
157
+ const grouped = {};
158
+ for (const kind of SKILL_FILE_KINDS) {
159
+ const list = files.filter((f) => f.startsWith(`${kind}/`)).map((f) => f.slice(kind.length + 1));
160
+ if (list.length) grouped[kind] = list;
161
+ }
162
+ return normalizeSkill({
163
+ id,
164
+ name: String(name),
165
+ command: ID.test(id) ? id : '',
166
+ description: typeof meta.description === 'string' ? meta.description : '',
167
+ prompt: body.trim(),
168
+ ...(typeof meta.version === 'string' ? { version: meta.version } : {}),
169
+ ...(Array.isArray(meta.platforms) ? { platforms: meta.platforms } : {}),
170
+ ...(Object.keys(grouped).length ? { files: grouped } : {}),
171
+ origin: { source, id: relPath, hash },
172
+ });
173
+ }
174
+
175
+ /** Does this skill run on the machine we are on? */
176
+ export function platformOk(skill, platform = process.platform) {
177
+ const want = skill?.platforms;
178
+ if (!Array.isArray(want) || !want.length) return true;
179
+ const here = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux';
180
+ return want.includes(here);
181
+ }
182
+
183
+ async function listDir(dir) {
184
+ try {
185
+ return await readdir(dir, { withFileTypes: true });
186
+ } catch {
187
+ return []; // a root that does not exist is not an error — most machines have neither
188
+ }
189
+ }
190
+
191
+ /** Files a package carries, relative to its directory, capped and traversal-checked. */
192
+ async function packageFiles(dir) {
193
+ const out = [];
194
+ for (const kind of SKILL_FILE_KINDS) {
195
+ for (const entry of await listDir(join(dir, kind))) {
196
+ if (!entry.isFile()) continue; // no recursion, and never a symlink or a device
197
+ const rel = `${kind}/${entry.name}`;
198
+ if (isSafeSkillPath(rel)) out.push(rel);
199
+ if (out.length >= 200) return out;
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+
205
+ async function loadSkill(dir, relPath, source) {
206
+ let text;
207
+ try {
208
+ const st = await stat(join(dir, 'SKILL.md'));
209
+ if (!st.isFile() || st.size > MAX_SKILL_MD) return null;
210
+ text = await fsReadFile(join(dir, 'SKILL.md'), 'utf8');
211
+ } catch {
212
+ return null;
213
+ }
214
+ const { meta, body } = parseFrontmatter(text);
215
+ const hash = `sha256-${createHash('sha256').update(text).digest('hex').slice(0, 32)}`;
216
+ const dirName = relPath.split('/').pop();
217
+ const files = await packageFiles(dir);
218
+ const skill = skillRecord({ meta, body, dirName, relPath, source, files, hash });
219
+ return { skill, dir };
220
+ }
221
+
222
+ /**
223
+ * Scan every root. Returns an INDEX keyed by id — and that index is the only thing a
224
+ * later read may resolve a requested name against.
225
+ */
226
+ export async function scanSkills({ roots = skillRoots(), platform = process.platform } = {}) {
227
+ const index = new Map();
228
+ const problems = [];
229
+ for (const { dir: root, source } of roots) {
230
+ const walk = async (dir, rel, depth) => {
231
+ if (index.size >= MAX_SKILLS || depth > MAX_DEPTH) return;
232
+ for (const entry of await listDir(dir)) {
233
+ if (index.size >= MAX_SKILLS) return;
234
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
235
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
236
+ const child = join(dir, entry.name);
237
+ const loaded = await loadSkill(child, childRel, source).catch((e) => {
238
+ problems.push({ path: childRel, reason: String(e?.message || e) });
239
+ return null;
240
+ });
241
+ if (loaded) {
242
+ // First root wins: ChatPanel's own directory is authoritative over a shared one.
243
+ if (!index.has(loaded.skill.id) && platformOk(loaded.skill, platform)) {
244
+ index.set(loaded.skill.id, { ...loaded, root, source });
245
+ }
246
+ } else {
247
+ await walk(child, childRel, depth + 1);
248
+ }
249
+ }
250
+ };
251
+ await walk(root, '', 1);
252
+ }
253
+ return { index, problems };
254
+ }
255
+
256
+ /** Level 0 — what exists, cheaply. No bodies. */
257
+ export function listRecords(index) {
258
+ return [...index.values()].map(({ skill }) => {
259
+ const { prompt, ...rest } = skill;
260
+ return { ...rest, promptChars: (prompt || '').length };
261
+ });
262
+ }
263
+
264
+ /** Level 1 — one skill, body included. `name` is resolved against the index, never joined. */
265
+ export function readRecord(index, name) {
266
+ const hit = index.get(String(name || ''));
267
+ return hit ? hit.skill : null;
268
+ }
269
+
270
+ /**
271
+ * Level 2 — one file inside a package.
272
+ *
273
+ * The lexical check happens first and the resolved-path containment check happens after,
274
+ * because they catch different things: the first stops `../`, the second stops a symlink
275
+ * that points outside the package while looking perfectly ordinary.
276
+ */
277
+ export async function readPackageFile(index, name, relPath) {
278
+ const hit = index.get(String(name || ''));
279
+ if (!hit) return { error: 'unknown skill' };
280
+ if (!isSafeSkillPath(relPath)) return { error: 'unsafe path' };
281
+ const kind = String(relPath).split('/')[0];
282
+ if (!SKILL_FILE_KINDS.includes(kind)) return { error: 'unsafe path' };
283
+
284
+ const target = resolve(hit.dir, relPath);
285
+ let real;
286
+ try {
287
+ real = await realpath(target);
288
+ } catch {
289
+ return { error: 'not found' };
290
+ }
291
+ // A symlink passes every string check ever written. Compare what the filesystem
292
+ // actually resolved to against the package root, with a separator so `/skills-evil`
293
+ // cannot pass as a child of `/skills`.
294
+ const rootReal = await realpath(hit.dir).catch(() => hit.dir);
295
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) return { error: 'outside package' };
296
+
297
+ const st = await stat(real).catch(() => null);
298
+ if (!st?.isFile()) return { error: 'not found' };
299
+ if (st.size > MAX_ASSET) return { error: 'file too large' };
300
+ return { path: relPath, text: await fsReadFile(real, 'utf8') };
301
+ }
302
+
303
+ /**
304
+ * A cached view, because /health and every turn ask "what skills exist" far more often
305
+ * than the directories change. Short TTL rather than a watcher: a watcher on directories
306
+ * that may not exist, across three roots, is more moving parts than a 5-second staleness
307
+ * window is worth.
308
+ */
309
+ const CACHE_MS = 5000;
310
+ let cached = null;
311
+
312
+ export async function skillIndex({ force = false, now = Date.now } = {}) {
313
+ const t = now();
314
+ if (!force && cached && t - cached.at < CACHE_MS) return cached.value;
315
+ const value = await scanSkills();
316
+ cached = { at: t, value };
317
+ return value;
318
+ }
319
+
320
+ export function clearSkillCache() { cached = null; }
321
+
322
+ /** The `/health` summary — counts and roots, never contents. */
323
+ export async function skillsHealth() {
324
+ const { index, problems } = await skillIndex();
325
+ const used = new Set([...index.values()].map((v) => v.root));
326
+ return {
327
+ count: index.size,
328
+ // Only roots that actually contributed. A list of every path we looked in would be
329
+ // mostly absent directories, and would say nothing about what the user has.
330
+ roots: skillRoots().filter((r) => used.has(r.dir)).map((r) => r.dir),
331
+ sources: [...new Set([...index.values()].map((v) => v.source))].sort(),
332
+ ...(problems.length ? { problems: problems.length } : {}),
333
+ };
334
+ }