@schlessera/brain-ui-server 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +13 -1
- package/dist/app.js.map +1 -1
- package/dist/brain/client.d.ts +2 -0
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +7 -0
- package/dist/brain/client.js.map +1 -1
- package/dist/config/env.d.ts +2 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +11 -0
- package/dist/config/env.js.map +1 -1
- package/dist/db/settings.d.ts +8 -0
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +14 -0
- package/dist/db/settings.js.map +1 -1
- package/dist/routes/skills.d.ts +28 -0
- package/dist/routes/skills.d.ts.map +1 -0
- package/dist/routes/skills.js +173 -0
- package/dist/routes/skills.js.map +1 -0
- package/dist/routes/tool-permissions.d.ts +17 -0
- package/dist/routes/tool-permissions.d.ts.map +1 -0
- package/dist/routes/tool-permissions.js +26 -0
- package/dist/routes/tool-permissions.js.map +1 -0
- package/dist/skills/install.d.ts +77 -0
- package/dist/skills/install.d.ts.map +1 -0
- package/dist/skills/install.js +273 -0
- package/dist/skills/install.js.map +1 -0
- package/dist/skills/manager.d.ts +58 -0
- package/dist/skills/manager.d.ts.map +1 -0
- package/dist/skills/manager.js +208 -0
- package/dist/skills/manager.js.map +1 -0
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +9 -0
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +1 -0
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +5 -0
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +14 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +2 -0
- package/dist/ws/host.js.map +1 -1
- package/package.json +5 -3
- package/src/app.ts +18 -0
- package/src/brain/client.ts +12 -0
- package/src/config/env.ts +13 -0
- package/src/db/settings.ts +17 -0
- package/src/routes/skills.ts +203 -0
- package/src/routes/tool-permissions.ts +37 -0
- package/src/skills/install.ts +332 -0
- package/src/skills/manager.ts +258 -0
- package/src/ws/bridge.ts +11 -0
- package/src/ws/connection.ts +1 -0
- package/src/ws/dispatch.ts +5 -0
- 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
|
package/src/ws/connection.ts
CHANGED
package/src/ws/dispatch.ts
CHANGED
|
@@ -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", {
|