@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
  }