@schlessera/brain-ui-server 0.27.0 → 0.28.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 (58) hide show
  1. package/README.md +2 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +13 -1
  4. package/dist/app.js.map +1 -1
  5. package/dist/brain/client.d.ts +2 -0
  6. package/dist/brain/client.d.ts.map +1 -1
  7. package/dist/brain/client.js +7 -0
  8. package/dist/brain/client.js.map +1 -1
  9. package/dist/config/env.d.ts +6 -0
  10. package/dist/config/env.d.ts.map +1 -1
  11. package/dist/config/env.js +25 -0
  12. package/dist/config/env.js.map +1 -1
  13. package/dist/db/settings.d.ts +8 -0
  14. package/dist/db/settings.d.ts.map +1 -1
  15. package/dist/db/settings.js +14 -0
  16. package/dist/db/settings.js.map +1 -1
  17. package/dist/routes/skills.d.ts +28 -0
  18. package/dist/routes/skills.d.ts.map +1 -0
  19. package/dist/routes/skills.js +173 -0
  20. package/dist/routes/skills.js.map +1 -0
  21. package/dist/routes/tool-permissions.d.ts +17 -0
  22. package/dist/routes/tool-permissions.d.ts.map +1 -0
  23. package/dist/routes/tool-permissions.js +26 -0
  24. package/dist/routes/tool-permissions.js.map +1 -0
  25. package/dist/skills/install.d.ts +77 -0
  26. package/dist/skills/install.d.ts.map +1 -0
  27. package/dist/skills/install.js +273 -0
  28. package/dist/skills/install.js.map +1 -0
  29. package/dist/skills/manager.d.ts +58 -0
  30. package/dist/skills/manager.d.ts.map +1 -0
  31. package/dist/skills/manager.js +208 -0
  32. package/dist/skills/manager.js.map +1 -0
  33. package/dist/ws/bridge.d.ts.map +1 -1
  34. package/dist/ws/bridge.js +9 -0
  35. package/dist/ws/bridge.js.map +1 -1
  36. package/dist/ws/connection.d.ts.map +1 -1
  37. package/dist/ws/connection.js +1 -0
  38. package/dist/ws/connection.js.map +1 -1
  39. package/dist/ws/dispatch.d.ts.map +1 -1
  40. package/dist/ws/dispatch.js +5 -0
  41. package/dist/ws/dispatch.js.map +1 -1
  42. package/dist/ws/host.d.ts +14 -0
  43. package/dist/ws/host.d.ts.map +1 -1
  44. package/dist/ws/host.js +2 -0
  45. package/dist/ws/host.js.map +1 -1
  46. package/package.json +5 -3
  47. package/src/app.ts +18 -0
  48. package/src/brain/client.ts +12 -0
  49. package/src/config/env.ts +28 -0
  50. package/src/db/settings.ts +17 -0
  51. package/src/routes/skills.ts +203 -0
  52. package/src/routes/tool-permissions.ts +37 -0
  53. package/src/skills/install.ts +332 -0
  54. package/src/skills/manager.ts +258 -0
  55. package/src/ws/bridge.ts +11 -0
  56. package/src/ws/connection.ts +1 -0
  57. package/src/ws/dispatch.ts +5 -0
  58. package/src/ws/host.ts +16 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Custom-skill management over the brain repo's canonical skill home.
3
+ *
4
+ * The store IS the filesystem convention `brain skills sync` already defines:
5
+ *
6
+ * - `.agents/skills/<name>` as a REAL directory = a user's custom skill —
7
+ * the local layer, highest discovery precedence, never touched by sync,
8
+ * inside the brain repo so it persists across deployments and rides the
9
+ * repo's own git backup.
10
+ * - `.agents/skills/<name>` as a SYMLINK = a package skill (core or module),
11
+ * materialized by sync. Read-only here: managed by the packages.
12
+ * - `.agents/skills-disabled/<name>` = a custom skill parked out of
13
+ * discovery. Disable is a directory move, so it applies to EVERY backend
14
+ * at once — after a sync, the claude/pi/codex/gemini emitters prune their
15
+ * links and the AGENTS.md index drops the row.
16
+ *
17
+ * After every mutation the caller runs `brain skills sync` so all agent
18
+ * integration dirs and the AGENTS.md index block follow the canonical home.
19
+ *
20
+ * Security posture: names are strictly validated (no traversal), mutations
21
+ * refuse to operate through symlinks (a package skill can never be edited or
22
+ * deleted from here), and content is size-capped. The routes sit behind the
23
+ * /api auth guard — this surface configures what the agent does.
24
+ */
25
+
26
+ import {
27
+ existsSync,
28
+ lstatSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ readFileSync,
32
+ readlinkSync,
33
+ renameSync,
34
+ rmSync,
35
+ writeFileSync,
36
+ } from "fs";
37
+ import { join, resolve } from "path";
38
+ import matter from "gray-matter";
39
+
40
+ /** Agent Skills standard: lowercase kebab, no traversal, bounded. */
41
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
42
+ /** A SKILL.md past this is not a skill, it's a document dump. */
43
+ export const MAX_SKILL_CONTENT_BYTES = 128 * 1024;
44
+
45
+ export interface SkillEntry {
46
+ /** Directory name (= the skill's identity for enable/disable/remove). */
47
+ name: string;
48
+ /** Frontmatter description, or empty when unparseable. */
49
+ description: string;
50
+ /** Where the skill comes from; only "custom" entries are editable. */
51
+ source: "builtin" | "custom";
52
+ enabled: boolean;
53
+ /** Set when the SKILL.md failed to parse — shown, not hidden. */
54
+ warning?: string;
55
+ }
56
+
57
+ export interface SkillDetail extends SkillEntry {
58
+ content: string;
59
+ /** Files in the skill directory besides SKILL.md (repo-relative names). */
60
+ extraFiles: string[];
61
+ }
62
+
63
+ export class SkillValidationError extends Error {}
64
+ export class SkillNotFoundError extends Error {}
65
+ export class SkillConflictError extends Error {}
66
+
67
+ export interface SkillManager {
68
+ list(): SkillEntry[];
69
+ get(name: string): SkillDetail;
70
+ create(name: string, content: string): SkillEntry;
71
+ update(name: string, content: string): SkillEntry;
72
+ setEnabled(name: string, enabled: boolean): SkillEntry;
73
+ remove(name: string): void;
74
+ }
75
+
76
+ export function createSkillManager(brainPath: string): SkillManager {
77
+ const enabledDir = join(brainPath, ".agents", "skills");
78
+ const disabledDir = join(brainPath, ".agents", "skills-disabled");
79
+
80
+ function assertValidName(name: string): void {
81
+ if (!NAME_PATTERN.test(name)) {
82
+ throw new SkillValidationError(
83
+ "Skill name must be lowercase kebab-case (a-z, 0-9, hyphens), max 64 chars."
84
+ );
85
+ }
86
+ }
87
+
88
+ /** lstat without following; null when absent. */
89
+ function kindOf(path: string): "dir" | "symlink" | "other" | null {
90
+ try {
91
+ const st = lstatSync(path);
92
+ if (st.isSymbolicLink()) return "symlink";
93
+ if (st.isDirectory()) return "dir";
94
+ return "other";
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function parseSkillFile(dir: string): { description: string; warning?: string } {
101
+ const file = join(dir, "SKILL.md");
102
+ if (!existsSync(file)) {
103
+ return { description: "", warning: "SKILL.md is missing" };
104
+ }
105
+ try {
106
+ const data = matter(readFileSync(file, "utf-8")).data as Record<string, unknown>;
107
+ const description = typeof data.description === "string" ? data.description : "";
108
+ if (!description) return { description: "", warning: "frontmatter has no description" };
109
+ return { description };
110
+ } catch (e) {
111
+ return {
112
+ description: "",
113
+ warning: `frontmatter does not parse: ${e instanceof Error ? e.message : String(e)}`,
114
+ };
115
+ }
116
+ }
117
+
118
+ function entryFor(name: string, dir: string, source: "builtin" | "custom", enabled: boolean): SkillEntry {
119
+ const parsed = parseSkillFile(dir);
120
+ return {
121
+ name,
122
+ description: parsed.description,
123
+ source,
124
+ enabled,
125
+ ...(parsed.warning ? { warning: parsed.warning } : {}),
126
+ };
127
+ }
128
+
129
+ /** Validate content as a plausible skill for directory `name`. */
130
+ function validateContent(name: string, content: string): void {
131
+ if (Buffer.byteLength(content, "utf-8") > MAX_SKILL_CONTENT_BYTES) {
132
+ throw new SkillValidationError(
133
+ `SKILL.md exceeds ${MAX_SKILL_CONTENT_BYTES / 1024}KB.`
134
+ );
135
+ }
136
+ let data: Record<string, unknown>;
137
+ try {
138
+ data = matter(content).data as Record<string, unknown>;
139
+ } catch (e) {
140
+ throw new SkillValidationError(
141
+ `Frontmatter does not parse: ${e instanceof Error ? e.message : String(e)}`
142
+ );
143
+ }
144
+ if (data.name !== name) {
145
+ throw new SkillValidationError(
146
+ `Frontmatter \`name\` must equal the skill directory name ("${name}").`
147
+ );
148
+ }
149
+ if (typeof data.description !== "string" || !data.description.trim()) {
150
+ throw new SkillValidationError(
151
+ "Frontmatter needs a non-empty `description` — it is what makes agents find the skill."
152
+ );
153
+ }
154
+ }
155
+
156
+ /** The custom skill's live directory, or throw. Never follows symlinks. */
157
+ function customDir(name: string): { dir: string; enabled: boolean } {
158
+ assertValidName(name);
159
+ const enabled = join(enabledDir, name);
160
+ const disabled = join(disabledDir, name);
161
+ if (kindOf(enabled) === "dir") return { dir: enabled, enabled: true };
162
+ if (kindOf(disabled) === "dir") return { dir: disabled, enabled: false };
163
+ if (kindOf(enabled) === "symlink") {
164
+ throw new SkillConflictError(
165
+ `"${name}" is a package skill (managed by brain-kit); it cannot be edited here.`
166
+ );
167
+ }
168
+ throw new SkillNotFoundError(`No custom skill named "${name}".`);
169
+ }
170
+
171
+ return {
172
+ list(): SkillEntry[] {
173
+ const entries: SkillEntry[] = [];
174
+ if (existsSync(enabledDir)) {
175
+ for (const entry of readdirSync(enabledDir).sort()) {
176
+ const p = join(enabledDir, entry);
177
+ const kind = kindOf(p);
178
+ if (kind === "symlink") entries.push(entryFor(entry, p, "builtin", true));
179
+ else if (kind === "dir") entries.push(entryFor(entry, p, "custom", true));
180
+ }
181
+ }
182
+ if (existsSync(disabledDir)) {
183
+ for (const entry of readdirSync(disabledDir).sort()) {
184
+ const p = join(disabledDir, entry);
185
+ if (kindOf(p) === "dir") entries.push(entryFor(entry, p, "custom", false));
186
+ }
187
+ }
188
+ return entries;
189
+ },
190
+
191
+ get(name: string): SkillDetail {
192
+ assertValidName(name);
193
+ // Builtins are readable (their content is useful reference) but the
194
+ // detail is marked read-only via source.
195
+ const enabledPath = join(enabledDir, name);
196
+ const kind = kindOf(enabledPath);
197
+ if (kind === "symlink") {
198
+ // Follow explicitly for READ only.
199
+ // resolve, not join: sync writes relative link targets, but a test or
200
+ // hand-made link may be absolute.
201
+ const target = resolve(enabledDir, readlinkSync(enabledPath));
202
+ const content = existsSync(join(target, "SKILL.md"))
203
+ ? readFileSync(join(target, "SKILL.md"), "utf-8")
204
+ : "";
205
+ return { ...entryFor(name, target, "builtin", true), content, extraFiles: [] };
206
+ }
207
+ const { dir, enabled } = customDir(name);
208
+ const content = existsSync(join(dir, "SKILL.md"))
209
+ ? readFileSync(join(dir, "SKILL.md"), "utf-8")
210
+ : "";
211
+ const extraFiles = readdirSync(dir)
212
+ .filter((f) => f !== "SKILL.md")
213
+ .sort();
214
+ return { ...entryFor(name, dir, "custom", enabled), content, extraFiles };
215
+ },
216
+
217
+ create(name: string, content: string): SkillEntry {
218
+ assertValidName(name);
219
+ validateContent(name, content);
220
+ if (kindOf(join(enabledDir, name)) !== null) {
221
+ throw new SkillConflictError(`A skill named "${name}" already exists.`);
222
+ }
223
+ if (kindOf(join(disabledDir, name)) !== null) {
224
+ throw new SkillConflictError(`A disabled skill named "${name}" already exists.`);
225
+ }
226
+ const dir = join(enabledDir, name);
227
+ mkdirSync(dir, { recursive: true });
228
+ writeFileSync(join(dir, "SKILL.md"), content, "utf-8");
229
+ return entryFor(name, dir, "custom", true);
230
+ },
231
+
232
+ update(name: string, content: string): SkillEntry {
233
+ const { dir, enabled } = customDir(name);
234
+ validateContent(name, content);
235
+ writeFileSync(join(dir, "SKILL.md"), content, "utf-8");
236
+ return entryFor(name, dir, "custom", enabled);
237
+ },
238
+
239
+ setEnabled(name: string, enabled: boolean): SkillEntry {
240
+ const current = customDir(name);
241
+ if (current.enabled === enabled) {
242
+ return entryFor(name, current.dir, "custom", enabled);
243
+ }
244
+ const target = enabled ? join(enabledDir, name) : join(disabledDir, name);
245
+ if (kindOf(target) !== null) {
246
+ throw new SkillConflictError(`"${name}" already exists at the target location.`);
247
+ }
248
+ mkdirSync(enabled ? enabledDir : disabledDir, { recursive: true });
249
+ renameSync(current.dir, target);
250
+ return entryFor(name, target, "custom", enabled);
251
+ },
252
+
253
+ remove(name: string): void {
254
+ const { dir } = customDir(name);
255
+ rmSync(dir, { recursive: true, force: true });
256
+ },
257
+ };
258
+ }
package/src/ws/bridge.ts CHANGED
@@ -65,6 +65,16 @@ export function makeBridge(
65
65
  }
66
66
  },
67
67
  requestPermission: (req) => {
68
+ // A remembered "always allow" answers grantable tool requests without
69
+ // a card. NEVER for kind "command" — those are destructive-pattern
70
+ // confirmations for tools that are already auto-allowed, and
71
+ // remembering them would silently disable the seatbelt.
72
+ if (
73
+ req.kind !== "command" &&
74
+ host.toolPermissions?.isAutoAllowed(req.toolName)
75
+ ) {
76
+ return Promise.resolve({ behavior: "allow" });
77
+ }
68
78
  if (!host.clients.hasClients()) {
69
79
  // Not a failure — the card is parked and re-delivered on reconnect
70
80
  // (see resendPendingInteractive) — but the wait was invisible before
@@ -83,6 +93,7 @@ export function makeBridge(
83
93
  toolName: req.toolName,
84
94
  input: req.input,
85
95
  description: req.description,
96
+ ...(req.kind ? { kind: req.kind } : {}),
86
97
  },
87
98
  turn,
88
99
  turnId
@@ -24,6 +24,7 @@ function resendPendingInteractive(host: WsHost, ws: WSContextType): void {
24
24
  toolName: p.request.toolName,
25
25
  input: p.request.input,
26
26
  description: p.request.description,
27
+ ...(p.request.kind ? { kind: p.request.kind } : {}),
27
28
  },
28
29
  p.turn,
29
30
  p.turnId
@@ -139,6 +139,11 @@ export async function handleClientMessage(
139
139
  const pending = coordinator.pendingApprovals.get(msg.toolUseId);
140
140
  if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
141
141
  coordinator.pendingApprovals.delete(msg.toolUseId);
142
+ // Remember-on-approve. Kind "command" never persists (the client
143
+ // hides the option, but the wire is not trusted to enforce policy).
144
+ if (msg.always && pending.request.kind !== "command") {
145
+ host.toolPermissions?.add(pending.request.toolName);
146
+ }
142
147
  pending.resolve(
143
148
  msg.updatedInput
144
149
  ? { behavior: "allow", updatedInput: msg.updatedInput }
package/src/ws/host.ts CHANGED
@@ -81,6 +81,20 @@ export interface WsHostOptions {
81
81
  * most existing tests want.
82
82
  */
83
83
  activity?: ActivityRuntime;
84
+ /**
85
+ * The user's remembered "always allow" tool grants. Optional: a host
86
+ * without one never auto-answers and never persists an `always` approval —
87
+ * every card stays per-use (what existing tests expect).
88
+ */
89
+ toolPermissions?: ToolPermissions;
90
+ }
91
+
92
+ /** The remembered per-tool auto-allow store the ws layer consults. */
93
+ export interface ToolPermissions {
94
+ /** Whether requests for this tool are answered "allow" without a card. */
95
+ isAutoAllowed(toolName: string): boolean;
96
+ /** Remember this tool as always allowed. */
97
+ add(toolName: string): void;
84
98
  }
85
99
 
86
100
  /** Identity of one turn, as it appears on a log record. */
@@ -119,6 +133,7 @@ export class WsHost {
119
133
  readonly observability: Observability;
120
134
  readonly wsRate: { ratePerSecond: number; burst: number } | null;
121
135
  readonly activity: ActivityRuntime | null;
136
+ readonly toolPermissions: ToolPermissions | null;
122
137
  /** Scoped instruments, resolved once — `[ws]` is the existing log prefix. */
123
138
  readonly log: ReturnType<Observability["logger"]>;
124
139
  private readonly framesDropped: ReturnType<
@@ -148,6 +163,7 @@ export class WsHost {
148
163
  this.wsRate =
149
164
  options.wsRate && options.wsRate.ratePerSecond > 0 ? options.wsRate : null;
150
165
  this.activity = options.activity ?? null;
166
+ this.toolPermissions = options.toolPermissions ?? null;
151
167
  this.log = this.observability.logger("ws");
152
168
  const meter = this.observability.meter("ws");
153
169
  this.framesDropped = meter.createCounter("ws.frames.dropped", {