@syengup/friday-channel-next 0.1.37 → 0.1.39
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/dist/src/agent/operator-scope.d.ts +19 -0
- package/dist/src/agent/operator-scope.js +54 -0
- package/dist/src/http/handlers/messages.js +9 -0
- package/dist/src/skills-discovery.d.ts +11 -4
- package/dist/src/skills-discovery.js +55 -12
- package/package.json +14 -15
- package/src/agent/operator-scope.test.ts +66 -0
- package/src/agent/operator-scope.ts +63 -0
- package/src/http/handlers/messages.ts +10 -0
- package/src/openclaw.d.ts +10 -0
- package/src/skills-discovery.test.ts +50 -1
- package/src/skills-discovery.ts +52 -12
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type ScopeLike = {
|
|
2
|
+
client?: {
|
|
3
|
+
connect?: {
|
|
4
|
+
scopes?: unknown;
|
|
5
|
+
};
|
|
6
|
+
};
|
|
7
|
+
} | null | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Adds the required operator scopes to a gateway-request-scope's
|
|
10
|
+
* `client.connect.scopes` array in place. Pure and idempotent.
|
|
11
|
+
* Returns the scopes that were actually added (empty if none / no array present).
|
|
12
|
+
*/
|
|
13
|
+
export declare function elevateScopeForSubagentSpawn(scope: ScopeLike): string[];
|
|
14
|
+
/**
|
|
15
|
+
* Fetches the live plugin gateway-request-scope and elevates it so the dispatched
|
|
16
|
+
* agent can spawn subagents. Never throws — returns the scopes added (or []).
|
|
17
|
+
*/
|
|
18
|
+
export declare function ensureSubagentSpawnScope(): string[];
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Operator-scope elevation for friday-next agent dispatch.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: friday-next registers all its routes with auth:"plugin" (it does
|
|
4
|
+
// its own device-token auth, not the gateway operator token). Core's
|
|
5
|
+
// createPluginRouteRuntimeScope gives auth!="gateway" routes an EMPTY operator-scope
|
|
6
|
+
// set. When an agent dispatched from such a route spawns a subagent, the spawn
|
|
7
|
+
// re-enters the in-process gateway `agent` method, which requires `operator.write`
|
|
8
|
+
// (core-descriptors: { name:"agent", scope:"operator.write" }). With an empty ambient
|
|
9
|
+
// scope the spawn fails with `{"error":"missing scope: operator.write"}`.
|
|
10
|
+
//
|
|
11
|
+
// The subagent spawn reads getPluginRuntimeGatewayRequestScope() at spawn time and
|
|
12
|
+
// uses that scope's client for authorization. Because AsyncLocalStorage returns the
|
|
13
|
+
// SAME store object reference, mutating its client.connect.scopes once — before we
|
|
14
|
+
// kick off the dispatch, while still inside the route's ALS context — propagates the
|
|
15
|
+
// elevated scopes to every later reader, including the subagent spawn.
|
|
16
|
+
//
|
|
17
|
+
// Subagent lifecycle admin methods (sessions.patch/delete) are unaffected: core pins
|
|
18
|
+
// those to ADMIN_SCOPE via a synthetic client, so only the `agent` method depends on
|
|
19
|
+
// this ambient operator.write. See memory: subagent-spawn-missing-operator-write.
|
|
20
|
+
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
|
21
|
+
/** Operator scopes the friday-next dispatch needs so agents can spawn subagents. */
|
|
22
|
+
const REQUIRED_OPERATOR_SCOPES = ["operator.write", "operator.read"];
|
|
23
|
+
/**
|
|
24
|
+
* Adds the required operator scopes to a gateway-request-scope's
|
|
25
|
+
* `client.connect.scopes` array in place. Pure and idempotent.
|
|
26
|
+
* Returns the scopes that were actually added (empty if none / no array present).
|
|
27
|
+
*/
|
|
28
|
+
export function elevateScopeForSubagentSpawn(scope) {
|
|
29
|
+
const connect = scope?.client?.connect;
|
|
30
|
+
if (!connect || !Array.isArray(connect.scopes)) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const scopes = connect.scopes;
|
|
34
|
+
const added = [];
|
|
35
|
+
for (const scopeName of REQUIRED_OPERATOR_SCOPES) {
|
|
36
|
+
if (!scopes.includes(scopeName)) {
|
|
37
|
+
scopes.push(scopeName);
|
|
38
|
+
added.push(scopeName);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return added;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Fetches the live plugin gateway-request-scope and elevates it so the dispatched
|
|
45
|
+
* agent can spawn subagents. Never throws — returns the scopes added (or []).
|
|
46
|
+
*/
|
|
47
|
+
export function ensureSubagentSpawnScope() {
|
|
48
|
+
try {
|
|
49
|
+
return elevateScopeForSubagentSpawn(getPluginRuntimeGatewayRequestScope());
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -21,6 +21,7 @@ import { registerFridaySessionDeviceMapping } from "../../friday-session.js";
|
|
|
21
21
|
import { touchFridayInbound } from "../../friday-inbound-stats.js";
|
|
22
22
|
import { fridayAttachmentLookupKey, fridayFilesPublicUrl, readFile, rememberInboundMediaName, resolveMediaAttachment, resolveMediaUrl, } from "./files.js";
|
|
23
23
|
import { runFridayDispatch } from "../../agent/dispatch-bridge.js";
|
|
24
|
+
import { ensureSubagentSpawnScope } from "../../agent/operator-scope.js";
|
|
24
25
|
import { saveInboundMediaBuffer } from "../../agent/media-bridge.js";
|
|
25
26
|
import { contextTokensFromUsageRecord, getRunMetadata, getRunRoute, hasRunFinalDelivered, markRunFinalDelivered, registerRunRoute, setRunMetadata, } from "../../run-metadata.js";
|
|
26
27
|
import { createFridayNextLogger, setFridayNextLogLevel } from "../../logging.js";
|
|
@@ -506,6 +507,14 @@ export async function handleMessages(req, res) {
|
|
|
506
507
|
sseEmitter.untrackRun(runId);
|
|
507
508
|
}
|
|
508
509
|
};
|
|
510
|
+
// Elevate the route's (empty) operator scope so the dispatched agent can spawn
|
|
511
|
+
// subagents. Must run here, synchronously inside the route's AsyncLocalStorage
|
|
512
|
+
// context, so the live scope object the subagent spawn later reads carries
|
|
513
|
+
// operator.write. See agent/operator-scope.ts.
|
|
514
|
+
const elevatedScopes = ensureSubagentSpawnScope();
|
|
515
|
+
if (elevatedScopes.length > 0) {
|
|
516
|
+
log("SCOPE_ELEVATED", normalizedDeviceId, runId, elevatedScopes.join(","));
|
|
517
|
+
}
|
|
509
518
|
runAgent().catch((err) => {
|
|
510
519
|
log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
|
|
511
520
|
sseEmitter.untrackRun(runId);
|
|
@@ -17,12 +17,19 @@
|
|
|
17
17
|
* in another agent's workspace (main is just another agent, not a shared pool)
|
|
18
18
|
* - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
|
|
19
19
|
* - built-in : bundled core skills (`<openclaw>/skills`)
|
|
20
|
-
* - extra :
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - extra : everything ControlUI buckets as "EXTRA" —
|
|
21
|
+
* • `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
|
|
22
|
+
* plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
|
|
23
|
+
* • `<workspace>/.agents/skills` : project-level agents skills (target agent)
|
|
24
|
+
* • `~/.agents/skills` : personal agents skills
|
|
25
|
+
* • config `skills.load.extraDirs`
|
|
26
|
+
* • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
|
|
27
|
+
* gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
|
|
28
|
+
* plugin-skills, kept as a belt-and-suspenders fallback
|
|
24
29
|
*
|
|
25
30
|
* Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
|
|
31
|
+
* The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
|
|
32
|
+
* this the entire plugin-provided set is invisible.
|
|
26
33
|
*
|
|
27
34
|
* A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
|
|
28
35
|
* the containing dir name) — NOT the dir name itself, which often differs (e.g. the
|
|
@@ -17,12 +17,19 @@
|
|
|
17
17
|
* in another agent's workspace (main is just another agent, not a shared pool)
|
|
18
18
|
* - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
|
|
19
19
|
* - built-in : bundled core skills (`<openclaw>/skills`)
|
|
20
|
-
* - extra :
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - extra : everything ControlUI buckets as "EXTRA" —
|
|
21
|
+
* • `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
|
|
22
|
+
* plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
|
|
23
|
+
* • `<workspace>/.agents/skills` : project-level agents skills (target agent)
|
|
24
|
+
* • `~/.agents/skills` : personal agents skills
|
|
25
|
+
* • config `skills.load.extraDirs`
|
|
26
|
+
* • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
|
|
27
|
+
* gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
|
|
28
|
+
* plugin-skills, kept as a belt-and-suspenders fallback
|
|
24
29
|
*
|
|
25
30
|
* Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
|
|
31
|
+
* The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
|
|
32
|
+
* this the entire plugin-provided set is invisible.
|
|
26
33
|
*
|
|
27
34
|
* A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
|
|
28
35
|
* the containing dir name) — NOT the dir name itself, which often differs (e.g. the
|
|
@@ -36,6 +43,7 @@
|
|
|
36
43
|
* `skills[]` config, already returned separately by the config view.
|
|
37
44
|
*/
|
|
38
45
|
import fs from "node:fs";
|
|
46
|
+
import os from "node:os";
|
|
39
47
|
import path from "node:path";
|
|
40
48
|
import { createRequire } from "node:module";
|
|
41
49
|
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
@@ -100,9 +108,24 @@ function collectSkills(root, source, out, depth = 0) {
|
|
|
100
108
|
return; // a skill is a leaf — don't treat its internals as nested skills
|
|
101
109
|
}
|
|
102
110
|
for (const e of entries) {
|
|
103
|
-
if (e.
|
|
104
|
-
|
|
111
|
+
if (e.name.startsWith(".") || IGNORED_WALK_DIRS.has(e.name))
|
|
112
|
+
continue;
|
|
113
|
+
const full = path.join(root, e.name);
|
|
114
|
+
// Follow symlinked dirs: `readdirSync(withFileTypes)` reports a symlink as
|
|
115
|
+
// `isSymbolicLink()` (never `isDirectory()`), and OpenClaw publishes plugin
|
|
116
|
+
// skills as symlinks under `<configDir>/plugin-skills/` — so without this the
|
|
117
|
+
// entire plugin-skills (e.g. the miloco-* set) source would be silently skipped.
|
|
118
|
+
let isDir = e.isDirectory();
|
|
119
|
+
if (!isDir && e.isSymbolicLink()) {
|
|
120
|
+
try {
|
|
121
|
+
isDir = fs.statSync(full).isDirectory();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
isDir = false; // broken/unreadable symlink
|
|
125
|
+
}
|
|
105
126
|
}
|
|
127
|
+
if (isDir)
|
|
128
|
+
collectSkills(full, source, out, depth + 1);
|
|
106
129
|
}
|
|
107
130
|
}
|
|
108
131
|
let cachedOpenClawRoot;
|
|
@@ -216,24 +239,44 @@ export function discoverAvailableSkills(cfg, agentId) {
|
|
|
216
239
|
// leaked main's skills into every other agent's catalog.
|
|
217
240
|
try {
|
|
218
241
|
const ws = resolveWs(cfg, agentId);
|
|
219
|
-
if (ws)
|
|
242
|
+
if (ws) {
|
|
220
243
|
sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
|
|
244
|
+
// Project-level agents skills: `<workspace>/.agents/skills` (core source
|
|
245
|
+
// `agents-skills-project`), scoped to this same target agent's workspace.
|
|
246
|
+
sources.push({ dir: path.join(ws, ".agents", "skills"), source: "extra" });
|
|
247
|
+
}
|
|
221
248
|
}
|
|
222
249
|
catch {
|
|
223
250
|
// skip unresolvable workspace
|
|
224
251
|
}
|
|
225
|
-
// Managed
|
|
226
|
-
// DEFAULT agent's workspace parent (the default workspace
|
|
227
|
-
// whereas non-default workspaces may be nested under it).
|
|
252
|
+
// Managed (`<configDir>/skills`) + plugin-skills (`<configDir>/plugin-skills`) dirs. Both are
|
|
253
|
+
// agent-independent; anchor off the DEFAULT agent's workspace parent (the default workspace
|
|
254
|
+
// lives directly under configDir, whereas non-default workspaces may be nested under it).
|
|
228
255
|
try {
|
|
229
256
|
const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
|
|
230
|
-
if (defaultWs)
|
|
231
|
-
|
|
257
|
+
if (defaultWs) {
|
|
258
|
+
const configDir = path.dirname(defaultWs);
|
|
259
|
+
sources.push({ dir: path.join(configDir, "skills"), source: "installed" });
|
|
260
|
+
// OpenClaw symlinks every ACTIVATED plugin's skills (incl. third-party plugins under
|
|
261
|
+
// `<configDir>/extensions/*`, e.g. the miloco-* set) into `<configDir>/plugin-skills/`
|
|
262
|
+
// and loads it as an "extra" source. The old dist/extensions scan only caught BUNDLED
|
|
263
|
+
// extensions, so plugin-provided skills were missing entirely.
|
|
264
|
+
sources.push({ dir: path.join(configDir, "plugin-skills"), source: "extra" });
|
|
265
|
+
}
|
|
232
266
|
}
|
|
233
267
|
catch {
|
|
234
268
|
// skip unresolvable managed dir
|
|
235
269
|
}
|
|
236
270
|
}
|
|
271
|
+
// Personal agents skills: `~/.agents/skills` (core source `agents-skills-personal`).
|
|
272
|
+
try {
|
|
273
|
+
const home = os.homedir();
|
|
274
|
+
if (home)
|
|
275
|
+
sources.push({ dir: path.join(home, ".agents", "skills"), source: "extra" });
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// home dir unresolvable on this platform — skip
|
|
279
|
+
}
|
|
237
280
|
const extraDirs = c?.skills?.load?.extraDirs;
|
|
238
281
|
if (Array.isArray(extraDirs)) {
|
|
239
282
|
for (const d of extraDirs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syengup/friday-channel-next",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
4
4
|
"description": "OpenClaw Friday Next Apple channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,19 +12,6 @@
|
|
|
12
12
|
"tsconfig.json",
|
|
13
13
|
"openclaw.plugin.json"
|
|
14
14
|
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc -p tsconfig.json",
|
|
17
|
-
"lint": "eslint .",
|
|
18
|
-
"lint:fix": "eslint . --fix",
|
|
19
|
-
"format": "prettier --write .",
|
|
20
|
-
"format:check": "prettier --check .",
|
|
21
|
-
"prepublishOnly": "pnpm build && rm -rf dist/attachments",
|
|
22
|
-
"test": "npm run test:unit && npm run test:e2e",
|
|
23
|
-
"test:unit": "vitest run",
|
|
24
|
-
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
25
|
-
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
26
|
-
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
27
|
-
},
|
|
28
15
|
"bin": {
|
|
29
16
|
"friday-channel-next": "install.js"
|
|
30
17
|
},
|
|
@@ -75,5 +62,17 @@
|
|
|
75
62
|
"typescript-eslint": "^8.61.1",
|
|
76
63
|
"vitest": "^4.1.5",
|
|
77
64
|
"zod": "^4.3.6"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc -p tsconfig.json",
|
|
68
|
+
"lint": "eslint .",
|
|
69
|
+
"lint:fix": "eslint . --fix",
|
|
70
|
+
"format": "prettier --write .",
|
|
71
|
+
"format:check": "prettier --check .",
|
|
72
|
+
"test": "npm run test:unit && npm run test:e2e",
|
|
73
|
+
"test:unit": "vitest run",
|
|
74
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
75
|
+
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
76
|
+
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
78
77
|
}
|
|
79
|
-
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// The helper imports the live scope getter from core; the pure function under test
|
|
4
|
+
// never calls it, but the module-level import must resolve. Mock it so the unit test
|
|
5
|
+
// does not depend on the OpenClaw dist runtime.
|
|
6
|
+
const getScope = vi.fn();
|
|
7
|
+
vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
|
|
8
|
+
getPluginRuntimeGatewayRequestScope: () => getScope(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { elevateScopeForSubagentSpawn, ensureSubagentSpawnScope } from "./operator-scope.js";
|
|
12
|
+
|
|
13
|
+
function makeScope(scopes: string[]) {
|
|
14
|
+
return { client: { connect: { role: "operator", scopes } } };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("elevateScopeForSubagentSpawn", () => {
|
|
18
|
+
it("adds operator.write + operator.read to an empty plugin-route scope", () => {
|
|
19
|
+
// friday-next routes register with auth:"plugin", which core gives EMPTY operator
|
|
20
|
+
// scopes. Subagent spawn re-enters the gateway `agent` method (requires
|
|
21
|
+
// operator.write) and fails with "missing scope: operator.write" without this.
|
|
22
|
+
const scope = makeScope([]);
|
|
23
|
+
const added = elevateScopeForSubagentSpawn(scope);
|
|
24
|
+
expect(scope.client.connect.scopes).toContain("operator.write");
|
|
25
|
+
expect(scope.client.connect.scopes).toContain("operator.read");
|
|
26
|
+
expect(added).toEqual(["operator.write", "operator.read"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("is idempotent — does not duplicate already-present scopes", () => {
|
|
30
|
+
const scope = makeScope(["operator.write"]);
|
|
31
|
+
const added = elevateScopeForSubagentSpawn(scope);
|
|
32
|
+
expect(added).toEqual(["operator.read"]);
|
|
33
|
+
expect(scope.client.connect.scopes.filter((s) => s === "operator.write")).toHaveLength(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("preserves unrelated existing scopes", () => {
|
|
37
|
+
const scope = makeScope(["operator.admin"]);
|
|
38
|
+
elevateScopeForSubagentSpawn(scope);
|
|
39
|
+
expect(scope.client.connect.scopes).toContain("operator.admin");
|
|
40
|
+
expect(scope.client.connect.scopes).toContain("operator.write");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns [] and never throws when no scope/client is present", () => {
|
|
44
|
+
expect(elevateScopeForSubagentSpawn(undefined)).toEqual([]);
|
|
45
|
+
expect(elevateScopeForSubagentSpawn(null)).toEqual([]);
|
|
46
|
+
expect(elevateScopeForSubagentSpawn({})).toEqual([]);
|
|
47
|
+
expect(elevateScopeForSubagentSpawn({ client: { connect: {} } })).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("ensureSubagentSpawnScope", () => {
|
|
52
|
+
it("elevates the live scope returned by the SDK getter", () => {
|
|
53
|
+
const scope = makeScope([]);
|
|
54
|
+
getScope.mockReturnValue(scope);
|
|
55
|
+
const added = ensureSubagentSpawnScope();
|
|
56
|
+
expect(added).toEqual(["operator.write", "operator.read"]);
|
|
57
|
+
expect(scope.client.connect.scopes).toContain("operator.write");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("swallows errors from the getter and returns []", () => {
|
|
61
|
+
getScope.mockImplementation(() => {
|
|
62
|
+
throw new Error("no scope");
|
|
63
|
+
});
|
|
64
|
+
expect(ensureSubagentSpawnScope()).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Operator-scope elevation for friday-next agent dispatch.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: friday-next registers all its routes with auth:"plugin" (it does
|
|
4
|
+
// its own device-token auth, not the gateway operator token). Core's
|
|
5
|
+
// createPluginRouteRuntimeScope gives auth!="gateway" routes an EMPTY operator-scope
|
|
6
|
+
// set. When an agent dispatched from such a route spawns a subagent, the spawn
|
|
7
|
+
// re-enters the in-process gateway `agent` method, which requires `operator.write`
|
|
8
|
+
// (core-descriptors: { name:"agent", scope:"operator.write" }). With an empty ambient
|
|
9
|
+
// scope the spawn fails with `{"error":"missing scope: operator.write"}`.
|
|
10
|
+
//
|
|
11
|
+
// The subagent spawn reads getPluginRuntimeGatewayRequestScope() at spawn time and
|
|
12
|
+
// uses that scope's client for authorization. Because AsyncLocalStorage returns the
|
|
13
|
+
// SAME store object reference, mutating its client.connect.scopes once — before we
|
|
14
|
+
// kick off the dispatch, while still inside the route's ALS context — propagates the
|
|
15
|
+
// elevated scopes to every later reader, including the subagent spawn.
|
|
16
|
+
//
|
|
17
|
+
// Subagent lifecycle admin methods (sessions.patch/delete) are unaffected: core pins
|
|
18
|
+
// those to ADMIN_SCOPE via a synthetic client, so only the `agent` method depends on
|
|
19
|
+
// this ambient operator.write. See memory: subagent-spawn-missing-operator-write.
|
|
20
|
+
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
|
21
|
+
|
|
22
|
+
/** Operator scopes the friday-next dispatch needs so agents can spawn subagents. */
|
|
23
|
+
const REQUIRED_OPERATOR_SCOPES = ["operator.write", "operator.read"] as const;
|
|
24
|
+
|
|
25
|
+
type ScopeLike =
|
|
26
|
+
| {
|
|
27
|
+
client?: { connect?: { scopes?: unknown } };
|
|
28
|
+
}
|
|
29
|
+
| null
|
|
30
|
+
| undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Adds the required operator scopes to a gateway-request-scope's
|
|
34
|
+
* `client.connect.scopes` array in place. Pure and idempotent.
|
|
35
|
+
* Returns the scopes that were actually added (empty if none / no array present).
|
|
36
|
+
*/
|
|
37
|
+
export function elevateScopeForSubagentSpawn(scope: ScopeLike): string[] {
|
|
38
|
+
const connect = scope?.client?.connect;
|
|
39
|
+
if (!connect || !Array.isArray(connect.scopes)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const scopes = connect.scopes as string[];
|
|
43
|
+
const added: string[] = [];
|
|
44
|
+
for (const scopeName of REQUIRED_OPERATOR_SCOPES) {
|
|
45
|
+
if (!scopes.includes(scopeName)) {
|
|
46
|
+
scopes.push(scopeName);
|
|
47
|
+
added.push(scopeName);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return added;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetches the live plugin gateway-request-scope and elevates it so the dispatched
|
|
55
|
+
* agent can spawn subagents. Never throws — returns the scopes added (or []).
|
|
56
|
+
*/
|
|
57
|
+
export function ensureSubagentSpawnScope(): string[] {
|
|
58
|
+
try {
|
|
59
|
+
return elevateScopeForSubagentSpawn(getPluginRuntimeGatewayRequestScope());
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
resolveMediaUrl,
|
|
49
49
|
} from "./files.js";
|
|
50
50
|
import { runFridayDispatch } from "../../agent/dispatch-bridge.js";
|
|
51
|
+
import { ensureSubagentSpawnScope } from "../../agent/operator-scope.js";
|
|
51
52
|
import { saveInboundMediaBuffer } from "../../agent/media-bridge.js";
|
|
52
53
|
import {
|
|
53
54
|
contextTokensFromUsageRecord,
|
|
@@ -674,6 +675,15 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
674
675
|
}
|
|
675
676
|
};
|
|
676
677
|
|
|
678
|
+
// Elevate the route's (empty) operator scope so the dispatched agent can spawn
|
|
679
|
+
// subagents. Must run here, synchronously inside the route's AsyncLocalStorage
|
|
680
|
+
// context, so the live scope object the subagent spawn later reads carries
|
|
681
|
+
// operator.write. See agent/operator-scope.ts.
|
|
682
|
+
const elevatedScopes = ensureSubagentSpawnScope();
|
|
683
|
+
if (elevatedScopes.length > 0) {
|
|
684
|
+
log("SCOPE_ELEVATED", normalizedDeviceId, runId, elevatedScopes.join(","));
|
|
685
|
+
}
|
|
686
|
+
|
|
677
687
|
runAgent().catch((err) => {
|
|
678
688
|
log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
|
|
679
689
|
sseEmitter.untrackRun(runId);
|
package/src/openclaw.d.ts
CHANGED
|
@@ -93,6 +93,16 @@ declare module "openclaw/plugin-sdk/reply-dispatch-runtime" {
|
|
|
93
93
|
export const dispatchReplyWithDispatcher: (...args: any[]) => any;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
declare module "openclaw/plugin-sdk/plugin-runtime" {
|
|
97
|
+
/**
|
|
98
|
+
* Returns the request-local plugin gateway-request-scope (operator client/scopes,
|
|
99
|
+
* context) when called from within a plugin HTTP-route handler's async context.
|
|
100
|
+
*/
|
|
101
|
+
export const getPluginRuntimeGatewayRequestScope: () =>
|
|
102
|
+
| { client?: { connect?: { scopes?: string[] } } }
|
|
103
|
+
| undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
96
106
|
declare module "openclaw/plugin-sdk/status-helpers" {
|
|
97
107
|
export type ChannelAccountSnapshot = any;
|
|
98
108
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
@@ -22,6 +22,8 @@ function makeSkills(parent: string, ids: string[]): void {
|
|
|
22
22
|
|
|
23
23
|
describe("discoverAvailableSkills", () => {
|
|
24
24
|
let root: string;
|
|
25
|
+
let emptyHome: string;
|
|
26
|
+
let savedHome: string | undefined;
|
|
25
27
|
|
|
26
28
|
function wire(configRoot: string, cfg: unknown): void {
|
|
27
29
|
setFridayAgentForwardRuntime({
|
|
@@ -39,10 +41,21 @@ describe("discoverAvailableSkills", () => {
|
|
|
39
41
|
} as never);
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
// Isolate `~/.agents/skills` (a real discovery source) from the dev machine's
|
|
46
|
+
// home so exact-match assertions don't pick up the operator's actual skills.
|
|
47
|
+
savedHome = process.env.HOME;
|
|
48
|
+
emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "friday-home-"));
|
|
49
|
+
process.env.HOME = emptyHome;
|
|
50
|
+
});
|
|
51
|
+
|
|
42
52
|
afterEach(() => {
|
|
53
|
+
if (savedHome === undefined) delete process.env.HOME;
|
|
54
|
+
else process.env.HOME = savedHome;
|
|
43
55
|
resetFridayAgentForwardRuntimeForTest();
|
|
44
56
|
resetOpenClawRootCacheForTest();
|
|
45
57
|
if (root) fs.rmSync(root, { recursive: true, force: true });
|
|
58
|
+
if (emptyHome) fs.rmSync(emptyHome, { recursive: true, force: true });
|
|
46
59
|
});
|
|
47
60
|
|
|
48
61
|
it("scans only the target agent's own workspace (not the default agent's), plus managed + extra dirs, deduped and sorted", () => {
|
|
@@ -118,6 +131,42 @@ describe("discoverAvailableSkills", () => {
|
|
|
118
131
|
expect(result.find((s) => s.id === "self-improvement")?.description).toBe("x");
|
|
119
132
|
});
|
|
120
133
|
|
|
134
|
+
it("discovers plugin-skills (symlinks), plus project + personal agents skills as 'extra'", () => {
|
|
135
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
|
|
136
|
+
const configRoot = path.join(root, "configdir");
|
|
137
|
+
const operatorWs = path.join(configRoot, "workspace", "agents", "operator");
|
|
138
|
+
|
|
139
|
+
// target agent's own workspace skills
|
|
140
|
+
makeSkills(path.join(operatorWs, "skills"), ["own-skill"]);
|
|
141
|
+
// project-level agents skills: <workspace>/.agents/skills
|
|
142
|
+
makeSkills(path.join(operatorWs, ".agents", "skills"), ["project-skill"]);
|
|
143
|
+
// personal agents skills: <home>/.agents/skills (HOME isolated in beforeEach)
|
|
144
|
+
makeSkills(path.join(emptyHome, ".agents", "skills"), ["personal-skill"]);
|
|
145
|
+
|
|
146
|
+
// plugin-skills: real plugin skill dirs SYMLINKED into <configDir>/plugin-skills/,
|
|
147
|
+
// exactly how OpenClaw publishes third-party plugin skills (e.g. the miloco-* set).
|
|
148
|
+
const pluginPkg = path.join(root, "miloco-openclaw-plugin", "skills");
|
|
149
|
+
makeSkills(pluginPkg, ["miloco-devices", "miloco-notify"]);
|
|
150
|
+
const pluginSkillsDir = path.join(configRoot, "plugin-skills");
|
|
151
|
+
fs.mkdirSync(pluginSkillsDir, { recursive: true });
|
|
152
|
+
for (const id of ["miloco-devices", "miloco-notify"]) {
|
|
153
|
+
fs.symlinkSync(path.join(pluginPkg, id), path.join(pluginSkillsDir, id), "dir");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cfg = { agents: { list: [{ id: "main", default: true }, { id: "operator" }] } };
|
|
157
|
+
wire(configRoot, cfg);
|
|
158
|
+
|
|
159
|
+
const result = discoverAvailableSkills(cfg, "operator");
|
|
160
|
+
const bySource = Object.fromEntries(result.map((s) => [s.id, s.source]));
|
|
161
|
+
expect(bySource).toEqual({
|
|
162
|
+
"own-skill": "workspace",
|
|
163
|
+
"project-skill": "extra",
|
|
164
|
+
"personal-skill": "extra",
|
|
165
|
+
"miloco-devices": "extra", // followed through the symlink — the miloco regression
|
|
166
|
+
"miloco-notify": "extra",
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
121
170
|
it("returns [] without throwing when nothing is resolvable", () => {
|
|
122
171
|
resetFridayAgentForwardRuntimeForTest();
|
|
123
172
|
expect(discoverAvailableSkills({}, "main")).toEqual([]);
|
package/src/skills-discovery.ts
CHANGED
|
@@ -17,12 +17,19 @@
|
|
|
17
17
|
* in another agent's workspace (main is just another agent, not a shared pool)
|
|
18
18
|
* - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
|
|
19
19
|
* - built-in : bundled core skills (`<openclaw>/skills`)
|
|
20
|
-
* - extra :
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - extra : everything ControlUI buckets as "EXTRA" —
|
|
21
|
+
* • `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
|
|
22
|
+
* plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
|
|
23
|
+
* • `<workspace>/.agents/skills` : project-level agents skills (target agent)
|
|
24
|
+
* • `~/.agents/skills` : personal agents skills
|
|
25
|
+
* • config `skills.load.extraDirs`
|
|
26
|
+
* • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
|
|
27
|
+
* gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
|
|
28
|
+
* plugin-skills, kept as a belt-and-suspenders fallback
|
|
24
29
|
*
|
|
25
30
|
* Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
|
|
31
|
+
* The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
|
|
32
|
+
* this the entire plugin-provided set is invisible.
|
|
26
33
|
*
|
|
27
34
|
* A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
|
|
28
35
|
* the containing dir name) — NOT the dir name itself, which often differs (e.g. the
|
|
@@ -37,6 +44,7 @@
|
|
|
37
44
|
*/
|
|
38
45
|
|
|
39
46
|
import fs from "node:fs";
|
|
47
|
+
import os from "node:os";
|
|
40
48
|
import path from "node:path";
|
|
41
49
|
import { createRequire } from "node:module";
|
|
42
50
|
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
@@ -107,9 +115,21 @@ function collectSkills(
|
|
|
107
115
|
return; // a skill is a leaf — don't treat its internals as nested skills
|
|
108
116
|
}
|
|
109
117
|
for (const e of entries) {
|
|
110
|
-
if (e.
|
|
111
|
-
|
|
118
|
+
if (e.name.startsWith(".") || IGNORED_WALK_DIRS.has(e.name)) continue;
|
|
119
|
+
const full = path.join(root, e.name);
|
|
120
|
+
// Follow symlinked dirs: `readdirSync(withFileTypes)` reports a symlink as
|
|
121
|
+
// `isSymbolicLink()` (never `isDirectory()`), and OpenClaw publishes plugin
|
|
122
|
+
// skills as symlinks under `<configDir>/plugin-skills/` — so without this the
|
|
123
|
+
// entire plugin-skills (e.g. the miloco-* set) source would be silently skipped.
|
|
124
|
+
let isDir = e.isDirectory();
|
|
125
|
+
if (!isDir && e.isSymbolicLink()) {
|
|
126
|
+
try {
|
|
127
|
+
isDir = fs.statSync(full).isDirectory();
|
|
128
|
+
} catch {
|
|
129
|
+
isDir = false; // broken/unreadable symlink
|
|
130
|
+
}
|
|
112
131
|
}
|
|
132
|
+
if (isDir) collectSkills(full, source, out, depth + 1);
|
|
113
133
|
}
|
|
114
134
|
}
|
|
115
135
|
|
|
@@ -227,22 +247,42 @@ export function discoverAvailableSkills(cfg: unknown, agentId: string): Discover
|
|
|
227
247
|
// leaked main's skills into every other agent's catalog.
|
|
228
248
|
try {
|
|
229
249
|
const ws = resolveWs(cfg, agentId);
|
|
230
|
-
if (ws)
|
|
250
|
+
if (ws) {
|
|
251
|
+
sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
|
|
252
|
+
// Project-level agents skills: `<workspace>/.agents/skills` (core source
|
|
253
|
+
// `agents-skills-project`), scoped to this same target agent's workspace.
|
|
254
|
+
sources.push({ dir: path.join(ws, ".agents", "skills"), source: "extra" });
|
|
255
|
+
}
|
|
231
256
|
} catch {
|
|
232
257
|
// skip unresolvable workspace
|
|
233
258
|
}
|
|
234
|
-
// Managed
|
|
235
|
-
// DEFAULT agent's workspace parent (the default workspace
|
|
236
|
-
// whereas non-default workspaces may be nested under it).
|
|
259
|
+
// Managed (`<configDir>/skills`) + plugin-skills (`<configDir>/plugin-skills`) dirs. Both are
|
|
260
|
+
// agent-independent; anchor off the DEFAULT agent's workspace parent (the default workspace
|
|
261
|
+
// lives directly under configDir, whereas non-default workspaces may be nested under it).
|
|
237
262
|
try {
|
|
238
263
|
const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
|
|
239
|
-
if (defaultWs)
|
|
240
|
-
|
|
264
|
+
if (defaultWs) {
|
|
265
|
+
const configDir = path.dirname(defaultWs);
|
|
266
|
+
sources.push({ dir: path.join(configDir, "skills"), source: "installed" });
|
|
267
|
+
// OpenClaw symlinks every ACTIVATED plugin's skills (incl. third-party plugins under
|
|
268
|
+
// `<configDir>/extensions/*`, e.g. the miloco-* set) into `<configDir>/plugin-skills/`
|
|
269
|
+
// and loads it as an "extra" source. The old dist/extensions scan only caught BUNDLED
|
|
270
|
+
// extensions, so plugin-provided skills were missing entirely.
|
|
271
|
+
sources.push({ dir: path.join(configDir, "plugin-skills"), source: "extra" });
|
|
272
|
+
}
|
|
241
273
|
} catch {
|
|
242
274
|
// skip unresolvable managed dir
|
|
243
275
|
}
|
|
244
276
|
}
|
|
245
277
|
|
|
278
|
+
// Personal agents skills: `~/.agents/skills` (core source `agents-skills-personal`).
|
|
279
|
+
try {
|
|
280
|
+
const home = os.homedir();
|
|
281
|
+
if (home) sources.push({ dir: path.join(home, ".agents", "skills"), source: "extra" });
|
|
282
|
+
} catch {
|
|
283
|
+
// home dir unresolvable on this platform — skip
|
|
284
|
+
}
|
|
285
|
+
|
|
246
286
|
const extraDirs = (
|
|
247
287
|
(c?.skills as Record<string, unknown> | undefined)?.load as Record<string, unknown> | undefined
|
|
248
288
|
)?.extraDirs;
|