@syengup/friday-channel-next 0.1.37 → 0.1.38
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/package.json +1 -1
- 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
|
@@ -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);
|
package/package.json
CHANGED
|
@@ -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
|
}
|