@syengup/friday-channel-next 0.1.40 → 1.0.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/dist/index.js +59 -1
- package/dist/src/approval/friday-approval-capability.d.ts +44 -0
- package/dist/src/approval/friday-approval-capability.js +174 -0
- package/dist/src/channel.js +22 -0
- package/dist/src/codex-reasoning-config.d.ts +11 -0
- package/dist/src/codex-reasoning-config.js +83 -0
- package/dist/src/friday-session.d.ts +4 -0
- package/dist/src/friday-session.js +24 -0
- package/dist/src/http/handlers/approvals.d.ts +9 -0
- package/dist/src/http/handlers/approvals.js +54 -0
- package/dist/src/http/handlers/messages.js +19 -1
- package/dist/src/http/server.js +6 -0
- package/dist/src/http 2/middleware/auth.d.ts +13 -0
- package/dist/src/http 2/middleware/auth.js +29 -0
- package/dist/src/http 2/middleware/body.d.ts +2 -0
- package/dist/src/http 2/middleware/body.js +24 -0
- package/dist/src/http 2/middleware/cors.d.ts +2 -0
- package/dist/src/http 2/middleware/cors.js +11 -0
- package/dist/src/sse/emitter.d.ts +1 -1
- package/index.ts +61 -0
- package/package.json +15 -14
- package/src/approval/friday-approval-capability.test.ts +78 -0
- package/src/approval/friday-approval-capability.ts +227 -0
- package/src/channel.ts +25 -1
- package/src/codex-reasoning-config.test.ts +28 -0
- package/src/codex-reasoning-config.ts +82 -0
- package/src/friday-session.ts +28 -0
- package/src/http/handlers/approvals.ts +61 -0
- package/src/http/handlers/messages.ts +23 -1
- package/src/http/server.ts +7 -0
- package/src/sse/emitter.ts +2 -1
- package/dist/src/health/self-health.d.ts +0 -39
- package/dist/src/health/self-health.js +0 -174
- package/dist/src/http/handlers/sessions-delete.d.ts +0 -2
- package/dist/src/http/handlers/sessions-delete.js +0 -49
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer token authentication middleware for Friday HTTP routes.
|
|
3
|
+
*
|
|
4
|
+
* Validates that the bearer token matches the gateway's configured auth token.
|
|
5
|
+
* This ensures plugin HTTP endpoints use the same token as gateway WS connections.
|
|
6
|
+
*/
|
|
7
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
8
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
9
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
10
|
+
/**
|
|
11
|
+
* Extract and validate bearer token from Authorization header.
|
|
12
|
+
* Returns the token only if it matches the gateway's configured auth token.
|
|
13
|
+
* Returns null if token is missing, malformed, or doesn't match.
|
|
14
|
+
*/
|
|
15
|
+
export function extractBearerToken(req) {
|
|
16
|
+
const auth = req.headers.authorization;
|
|
17
|
+
if (!auth || typeof auth !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
const parts = auth.trim().split(/\s+/);
|
|
20
|
+
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer")
|
|
21
|
+
return null;
|
|
22
|
+
const token = parts[1];
|
|
23
|
+
// Validate token matches the gateway's configured auth token.
|
|
24
|
+
const cfg = getHostOpenClawConfigSnapshot(getFridayNextRuntime().config);
|
|
25
|
+
const runtimeConfig = resolveFridayNextConfig(cfg);
|
|
26
|
+
if (!runtimeConfig.authToken || token !== runtimeConfig.authToken)
|
|
27
|
+
return null;
|
|
28
|
+
return token;
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
2
|
+
return await new Promise((resolve) => {
|
|
3
|
+
const chunks = [];
|
|
4
|
+
let total = 0;
|
|
5
|
+
req.on("data", (chunk) => {
|
|
6
|
+
total += chunk.length;
|
|
7
|
+
if (total > maxBytes) {
|
|
8
|
+
resolve(null);
|
|
9
|
+
req.destroy();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
chunks.push(chunk);
|
|
13
|
+
});
|
|
14
|
+
req.on("end", () => {
|
|
15
|
+
try {
|
|
16
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
resolve(null);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
req.on("error", () => resolve(null));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
2
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
3
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
4
|
+
export function applyCorsHeaders(res) {
|
|
5
|
+
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
6
|
+
if (!cfg.corsEnabled)
|
|
7
|
+
return;
|
|
8
|
+
res.setHeader("Access-Control-Allow-Origin", cfg.corsAllowOrigin || "*");
|
|
9
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Last-Event-ID");
|
|
10
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
11
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ServerResponse } from "node:http";
|
|
2
|
-
export type SseEventType = "connected" | "agent" | "deliver" | "tool-hook" | "outbound" | "ping" | "subagent";
|
|
2
|
+
export type SseEventType = "connected" | "agent" | "deliver" | "tool-hook" | "outbound" | "ping" | "subagent" | "approval";
|
|
3
3
|
export interface SseEvent {
|
|
4
4
|
type: SseEventType;
|
|
5
5
|
data: Record<string, unknown>;
|
package/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { sseEmitter } from "./src/sse/emitter.js";
|
|
|
15
15
|
import {
|
|
16
16
|
forwardAgentEventRaw,
|
|
17
17
|
getLastRegisteredFridayDeviceId,
|
|
18
|
+
isCodexRun,
|
|
18
19
|
resolveFridayDeviceIdForSessionKey,
|
|
19
20
|
} from "./src/friday-session.js";
|
|
20
21
|
import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
|
|
@@ -22,6 +23,7 @@ import { setUpgradeRuntime } from "./src/upgrade-runtime.js";
|
|
|
22
23
|
import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
|
|
23
24
|
import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
|
|
24
25
|
import { createFridayNextLogger } from "./src/logging.js";
|
|
26
|
+
import { ensureCodexReasoningSummary } from "./src/codex-reasoning-config.js";
|
|
25
27
|
|
|
26
28
|
const hookLogger = createFridayNextLogger("hook");
|
|
27
29
|
|
|
@@ -63,6 +65,38 @@ function isFridaySessionKey(sk: string): boolean {
|
|
|
63
65
|
return /^friday-next-/i.test(sk) || /^agent:main:friday-next-/i.test(sk);
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/** Shell/exec-style tools whose stdout the app renders as a `command_output` row (A3). */
|
|
69
|
+
const COMMAND_TOOL_NAMES = new Set([
|
|
70
|
+
"exec",
|
|
71
|
+
"bash",
|
|
72
|
+
"shell",
|
|
73
|
+
"local_shell",
|
|
74
|
+
"command",
|
|
75
|
+
"process",
|
|
76
|
+
"run_terminal_cmd",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
function isCommandTool(toolName: unknown): boolean {
|
|
80
|
+
return typeof toolName === "string" && COMMAND_TOOL_NAMES.has(toolName.trim().toLowerCase());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Best-effort flatten of an after-hook tool result into the stdout string the app expects. */
|
|
84
|
+
function coerceCommandOutput(result: unknown): string {
|
|
85
|
+
if (typeof result === "string") return result;
|
|
86
|
+
if (result && typeof result === "object") {
|
|
87
|
+
const r = result as Record<string, unknown>;
|
|
88
|
+
for (const key of ["output", "stdout", "text"]) {
|
|
89
|
+
if (typeof r[key] === "string") return r[key] as string;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return JSON.stringify(result);
|
|
93
|
+
} catch {
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return result == null ? "" : String(result);
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
function shouldForwardToolEventToFriday(ctx: PluginHookToolContext): boolean {
|
|
67
101
|
if (ctx.runId) {
|
|
68
102
|
if (sseEmitter.getDeviceIdByRunId(ctx.runId)) return true;
|
|
@@ -134,6 +168,10 @@ export default defineChannelPluginEntry({
|
|
|
134
168
|
}
|
|
135
169
|
fridayNextToolHooksRegistered = true;
|
|
136
170
|
|
|
171
|
+
// Make Codex (ChatGPT/OAuth) models emit reasoning summary text so the app can stream
|
|
172
|
+
// "thinking". OpenClaw never sets this; we assert it on the plugin side. Best-effort.
|
|
173
|
+
ensureCodexReasoningSummary((msg) => hookLogger.info(msg));
|
|
174
|
+
|
|
137
175
|
api.on("subagent_delivery_target", (event: any) => {
|
|
138
176
|
if (!event.expectsCompletionMessage) return;
|
|
139
177
|
const ch = event.requesterOrigin?.channel?.trim().toLowerCase();
|
|
@@ -219,6 +257,29 @@ export default defineChannelPluginEntry({
|
|
|
219
257
|
ts: Date.now(),
|
|
220
258
|
},
|
|
221
259
|
});
|
|
260
|
+
|
|
261
|
+
// A3: the Codex app-server backend never puts exec stdout on the `command_output` stream
|
|
262
|
+
// (its tool result carries only exitCode/duration), so the app shows the command row with no
|
|
263
|
+
// output. The after-hook DOES carry the full stdout — synthesize a `command_output` end event
|
|
264
|
+
// keyed by toolCallId (== the forwarded `item kind:command` itemId) so the app attaches it.
|
|
265
|
+
// Codex-only: embedded runs already stream `command_output` on the bus.
|
|
266
|
+
if (isCodexRun(runId) && event.toolCallId && isCommandTool(event.toolName)) {
|
|
267
|
+
const output = coerceCommandOutput(event.result);
|
|
268
|
+
if (output) {
|
|
269
|
+
forwardAgentEventRaw({
|
|
270
|
+
runId,
|
|
271
|
+
stream: "command_output",
|
|
272
|
+
data: {
|
|
273
|
+
itemId: event.toolCallId,
|
|
274
|
+
phase: "end",
|
|
275
|
+
output,
|
|
276
|
+
status: event.error ? "failed" : "completed",
|
|
277
|
+
durationMs: event.durationMs ?? null,
|
|
278
|
+
},
|
|
279
|
+
sessionKey: ctx.sessionKey,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
222
283
|
});
|
|
223
284
|
},
|
|
224
285
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syengup/friday-channel-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "OpenClaw Friday Next Apple channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,6 +12,19 @@
|
|
|
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
|
+
},
|
|
15
28
|
"bin": {
|
|
16
29
|
"friday-channel-next": "install.js"
|
|
17
30
|
},
|
|
@@ -62,17 +75,5 @@
|
|
|
62
75
|
"typescript-eslint": "^8.61.1",
|
|
63
76
|
"vitest": "^4.1.5",
|
|
64
77
|
"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"
|
|
77
78
|
}
|
|
78
|
-
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildPayload } from "./friday-approval-capability.js";
|
|
3
|
+
|
|
4
|
+
const execView = {
|
|
5
|
+
approvalId: "exec:abc",
|
|
6
|
+
approvalKind: "exec",
|
|
7
|
+
title: "Codex command approval",
|
|
8
|
+
commandText: "curl -sS https://example.com",
|
|
9
|
+
commandPreview: "curl ...",
|
|
10
|
+
cwd: "/ws",
|
|
11
|
+
host: "sandbox",
|
|
12
|
+
metadata: [{ label: "Severity", value: "Warning" }],
|
|
13
|
+
actions: [
|
|
14
|
+
{ decision: "allow-once", label: "Allow Once", style: "primary" },
|
|
15
|
+
{ decision: "deny", label: "Deny", style: "danger" },
|
|
16
|
+
],
|
|
17
|
+
expiresAtMs: 123,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const pluginView = {
|
|
21
|
+
approvalId: "plugin:xyz",
|
|
22
|
+
approvalKind: "plugin",
|
|
23
|
+
title: "Codex app-server command approval",
|
|
24
|
+
description: "Command: curl ...",
|
|
25
|
+
toolName: "codex_command_approval",
|
|
26
|
+
severity: "warning",
|
|
27
|
+
metadata: [{ label: "Tool", value: "codex_command_approval" }],
|
|
28
|
+
actions: [{ decision: "allow-always", label: "Allow Always", style: "secondary" }],
|
|
29
|
+
expiresAtMs: 456,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const reqWith = (sessionKey: string) => ({ request: { sessionKey } });
|
|
33
|
+
|
|
34
|
+
describe("buildPayload", () => {
|
|
35
|
+
it("maps an exec approval view (command/cwd/host + actions)", () => {
|
|
36
|
+
const p = buildPayload({
|
|
37
|
+
op: "request",
|
|
38
|
+
view: execView,
|
|
39
|
+
request: reqWith("agent:main:fridaynext:s1"),
|
|
40
|
+
deviceId: "DEV1",
|
|
41
|
+
});
|
|
42
|
+
expect(p.op).toBe("request");
|
|
43
|
+
expect(p.kind).toBe("exec");
|
|
44
|
+
expect(p.approvalId).toBe("exec:abc");
|
|
45
|
+
expect(p.commandText).toBe("curl -sS https://example.com");
|
|
46
|
+
expect(p.cwd).toBe("/ws");
|
|
47
|
+
expect(p.host).toBe("sandbox");
|
|
48
|
+
expect(p.actions.map((a) => a.decision)).toEqual(["allow-once", "deny"]);
|
|
49
|
+
expect(p.sessionKey).toBe("agent:main:fridaynext:s1");
|
|
50
|
+
expect(p.deviceId).toBe("DEV1");
|
|
51
|
+
expect(p.expiresAtMs).toBe(123);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("maps a plugin approval view (toolName/severity/description)", () => {
|
|
55
|
+
const p = buildPayload({
|
|
56
|
+
op: "request",
|
|
57
|
+
view: pluginView,
|
|
58
|
+
request: reqWith("agent:main:fridaynext:s2"),
|
|
59
|
+
deviceId: "DEV2",
|
|
60
|
+
});
|
|
61
|
+
expect(p.kind).toBe("plugin");
|
|
62
|
+
expect(p.toolName).toBe("codex_command_approval");
|
|
63
|
+
expect(p.severity).toBe("warning");
|
|
64
|
+
expect(p.description).toBe("Command: curl ...");
|
|
65
|
+
expect(p.actions[0].decision).toBe("allow-always");
|
|
66
|
+
expect(p.commandText).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("defaults missing fields without throwing", () => {
|
|
70
|
+
const p = buildPayload({ op: "expired", view: {}, request: {}, deviceId: "D" });
|
|
71
|
+
expect(p.op).toBe("expired");
|
|
72
|
+
expect(p.kind).toBe("exec");
|
|
73
|
+
expect(p.approvalId).toBe("");
|
|
74
|
+
expect(p.actions).toEqual([]);
|
|
75
|
+
expect(p.metadata).toEqual([]);
|
|
76
|
+
expect(p.sessionKey).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Friday Next exec/plugin approval capability.
|
|
2
|
+
//
|
|
3
|
+
// Lets the Friday app receive tool-execution approval REQUESTS (e.g. a Codex model wanting to run a
|
|
4
|
+
// shell command that needs confirmation) and submit allow/deny DECISIONS — instead of those
|
|
5
|
+
// approvals only reaching the gateway's built-in ControlUI.
|
|
6
|
+
//
|
|
7
|
+
// Model: unlike Slack (a separate approver list authorized per-account), friday-next uses a
|
|
8
|
+
// device-owner model — the device that owns the originating session is the approver. HTTP requests
|
|
9
|
+
// already carry the channel bearer token, so per-sender authorization happens at the route layer;
|
|
10
|
+
// here we only resolve WHICH device a request belongs to (its session's device) and deliver the
|
|
11
|
+
// prompt there over SSE. The decision round-trips via POST /friday-next/approvals/{id}.
|
|
12
|
+
//
|
|
13
|
+
// We intentionally do NOT set a `delivery.shouldSuppressForwardingFallback` adapter, so enabling
|
|
14
|
+
// this stays additive: ControlUI keeps working as a fallback while the app surface is the primary.
|
|
15
|
+
|
|
16
|
+
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
|
17
|
+
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
|
|
18
|
+
import { sseEmitter } from "../sse/emitter.js";
|
|
19
|
+
import { resolveFridayDeviceIdForSessionKey } from "../friday-session.js";
|
|
20
|
+
import { createFridayNextLogger } from "../logging.js";
|
|
21
|
+
|
|
22
|
+
const logger = createFridayNextLogger("approval");
|
|
23
|
+
|
|
24
|
+
/** SSE payload the app receives for an approval lifecycle event. `op` is the phase. */
|
|
25
|
+
export interface FridayApprovalPayload {
|
|
26
|
+
op: "request" | "resolved" | "expired";
|
|
27
|
+
approvalId: string;
|
|
28
|
+
kind: "exec" | "plugin";
|
|
29
|
+
title: string;
|
|
30
|
+
description?: string | null;
|
|
31
|
+
// exec
|
|
32
|
+
commandText?: string | null;
|
|
33
|
+
commandPreview?: string | null;
|
|
34
|
+
cwd?: string | null;
|
|
35
|
+
host?: string | null;
|
|
36
|
+
// plugin
|
|
37
|
+
toolName?: string | null;
|
|
38
|
+
severity?: string | null;
|
|
39
|
+
metadata: { label: string; value: string }[];
|
|
40
|
+
actions: { decision: string; label: string; style: string }[];
|
|
41
|
+
expiresAtMs?: number | null;
|
|
42
|
+
decision?: string | null;
|
|
43
|
+
resolvedBy?: string | null;
|
|
44
|
+
sessionKey?: string | null;
|
|
45
|
+
runId?: string | null;
|
|
46
|
+
deviceId: string;
|
|
47
|
+
ts: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PreparedTarget {
|
|
51
|
+
deviceId: string;
|
|
52
|
+
}
|
|
53
|
+
interface PendingEntry {
|
|
54
|
+
deviceId: string;
|
|
55
|
+
approvalId: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Pull the originating sessionKey out of an exec/plugin approval request (`request.request.*`). */
|
|
59
|
+
function sessionKeyOf(request: unknown): string | undefined {
|
|
60
|
+
const inner = (request as { request?: { sessionKey?: unknown } } | undefined)?.request;
|
|
61
|
+
const sk = inner?.sessionKey;
|
|
62
|
+
return typeof sk === "string" && sk.trim() ? sk.trim() : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve the friday device that owns this approval's session, if any. */
|
|
66
|
+
function deviceForRequest(request: unknown): string | undefined {
|
|
67
|
+
const sk = sessionKeyOf(request);
|
|
68
|
+
if (!sk) return undefined;
|
|
69
|
+
const dev = resolveFridayDeviceIdForSessionKey(sk);
|
|
70
|
+
return dev ? dev.toUpperCase() : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Build the normalized app payload from a pending/resolved/expired approval view. */
|
|
74
|
+
export function buildPayload(params: {
|
|
75
|
+
op: FridayApprovalPayload["op"];
|
|
76
|
+
view: Record<string, unknown>;
|
|
77
|
+
request: unknown;
|
|
78
|
+
deviceId: string;
|
|
79
|
+
}): FridayApprovalPayload {
|
|
80
|
+
const { op, view, request, deviceId } = params;
|
|
81
|
+
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
82
|
+
const num = (v: unknown): number | null => (typeof v === "number" ? v : null);
|
|
83
|
+
const actionsRaw = Array.isArray(view.actions) ? (view.actions as Record<string, unknown>[]) : [];
|
|
84
|
+
const metaRaw = Array.isArray(view.metadata) ? (view.metadata as Record<string, unknown>[]) : [];
|
|
85
|
+
return {
|
|
86
|
+
op,
|
|
87
|
+
approvalId: str(view.approvalId) ?? "",
|
|
88
|
+
kind: view.approvalKind === "plugin" ? "plugin" : "exec",
|
|
89
|
+
title: str(view.title) ?? "",
|
|
90
|
+
description: str(view.description),
|
|
91
|
+
commandText: str(view.commandText),
|
|
92
|
+
commandPreview: str(view.commandPreview),
|
|
93
|
+
cwd: str(view.cwd),
|
|
94
|
+
host: str(view.host),
|
|
95
|
+
toolName: str(view.toolName),
|
|
96
|
+
severity: str(view.severity),
|
|
97
|
+
metadata: metaRaw.map((m) => ({ label: str(m.label) ?? "", value: str(m.value) ?? "" })),
|
|
98
|
+
actions: actionsRaw.map((a) => ({
|
|
99
|
+
decision: str(a.decision) ?? "",
|
|
100
|
+
label: str(a.label) ?? "",
|
|
101
|
+
style: str(a.style) ?? "secondary",
|
|
102
|
+
})),
|
|
103
|
+
expiresAtMs: num(view.expiresAtMs),
|
|
104
|
+
decision: str(view.decision),
|
|
105
|
+
resolvedBy: str(view.resolvedBy),
|
|
106
|
+
sessionKey: sessionKeyOf(request) ?? null,
|
|
107
|
+
runId: sseEmitter.getLastRunIdForDevice(deviceId),
|
|
108
|
+
deviceId,
|
|
109
|
+
ts: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function emitApproval(deviceId: string, payload: FridayApprovalPayload): void {
|
|
114
|
+
sseEmitter.broadcast({ type: "approval", data: { ...payload } }, deviceId, true);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const fridayApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
|
118
|
+
FridayApprovalPayload,
|
|
119
|
+
PreparedTarget,
|
|
120
|
+
PendingEntry,
|
|
121
|
+
never,
|
|
122
|
+
FridayApprovalPayload
|
|
123
|
+
>({
|
|
124
|
+
eventKinds: ["exec", "plugin"],
|
|
125
|
+
availability: {
|
|
126
|
+
isConfigured: () => true,
|
|
127
|
+
shouldHandle: ({ request }) => deviceForRequest(request) !== undefined,
|
|
128
|
+
},
|
|
129
|
+
presentation: {
|
|
130
|
+
buildPendingPayload: ({ request, view }) => {
|
|
131
|
+
const deviceId = deviceForRequest(request) ?? "";
|
|
132
|
+
return buildPayload({
|
|
133
|
+
op: "request",
|
|
134
|
+
view: view as unknown as Record<string, unknown>,
|
|
135
|
+
request,
|
|
136
|
+
deviceId,
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
buildResolvedResult: ({ request, view }) => {
|
|
140
|
+
const deviceId = deviceForRequest(request) ?? "";
|
|
141
|
+
return {
|
|
142
|
+
kind: "update",
|
|
143
|
+
payload: buildPayload({
|
|
144
|
+
op: "resolved",
|
|
145
|
+
view: view as unknown as Record<string, unknown>,
|
|
146
|
+
request,
|
|
147
|
+
deviceId,
|
|
148
|
+
}),
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
buildExpiredResult: ({ request, view }) => {
|
|
152
|
+
const deviceId = deviceForRequest(request) ?? "";
|
|
153
|
+
return {
|
|
154
|
+
kind: "update",
|
|
155
|
+
payload: buildPayload({
|
|
156
|
+
op: "expired",
|
|
157
|
+
view: view as unknown as Record<string, unknown>,
|
|
158
|
+
request,
|
|
159
|
+
deviceId,
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
transport: {
|
|
165
|
+
prepareTarget: ({ plannedTarget, request }) => {
|
|
166
|
+
const planned =
|
|
167
|
+
typeof plannedTarget?.target?.to === "string" && plannedTarget.target.to.trim()
|
|
168
|
+
? plannedTarget.target.to.trim().toUpperCase()
|
|
169
|
+
: undefined;
|
|
170
|
+
const deviceId = planned ?? deviceForRequest(request);
|
|
171
|
+
if (!deviceId) return null;
|
|
172
|
+
return { dedupeKey: `friday-approval:${deviceId}`, target: { deviceId } };
|
|
173
|
+
},
|
|
174
|
+
deliverPending: ({ preparedTarget, pendingPayload }) => {
|
|
175
|
+
const deviceId = preparedTarget.deviceId;
|
|
176
|
+
logger.info(`deliver approval ${pendingPayload.approvalId} kind=${pendingPayload.kind} -> ${deviceId}`);
|
|
177
|
+
emitApproval(deviceId, { ...pendingPayload, deviceId });
|
|
178
|
+
return { deviceId, approvalId: pendingPayload.approvalId };
|
|
179
|
+
},
|
|
180
|
+
updateEntry: async ({ entry, payload }) => {
|
|
181
|
+
emitApproval(entry.deviceId, { ...payload, deviceId: entry.deviceId });
|
|
182
|
+
},
|
|
183
|
+
deleteEntry: async ({ entry, phase }) => {
|
|
184
|
+
emitApproval(entry.deviceId, {
|
|
185
|
+
op: phase === "resolved" ? "resolved" : "expired",
|
|
186
|
+
approvalId: entry.approvalId,
|
|
187
|
+
kind: "exec",
|
|
188
|
+
title: "",
|
|
189
|
+
metadata: [],
|
|
190
|
+
actions: [],
|
|
191
|
+
deviceId: entry.deviceId,
|
|
192
|
+
ts: Date.now(),
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
observe: {
|
|
197
|
+
onDeliveryError: ({ error }) => {
|
|
198
|
+
logger.warn(`approval delivery failed: ${String(error)}`);
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* friday-next approval capability. `native` declares delivery to the originating device's session;
|
|
205
|
+
* `nativeRuntime` builds the app payload and ferries it over SSE. No `delivery` suppressor → additive
|
|
206
|
+
* with ControlUI.
|
|
207
|
+
*/
|
|
208
|
+
export const fridayApprovalCapability: ChannelApprovalCapability = {
|
|
209
|
+
native: {
|
|
210
|
+
describeDeliveryCapabilities: ({ request }) => {
|
|
211
|
+
const enabled = deviceForRequest(request) !== undefined;
|
|
212
|
+
return {
|
|
213
|
+
enabled,
|
|
214
|
+
preferredSurface: "origin",
|
|
215
|
+
supportsOriginSurface: true,
|
|
216
|
+
supportsApproverDmSurface: false,
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
resolveOriginTarget: ({ request }) => {
|
|
220
|
+
const deviceId = deviceForRequest(request);
|
|
221
|
+
return deviceId ? { to: deviceId } : null;
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
// Cast widens the parameterized adapter to the field's `unknown`-typed shape (function-param
|
|
225
|
+
// contravariance). Same escape hatch Slack uses for its lazy runtime adapter.
|
|
226
|
+
nativeRuntime: fridayApprovalNativeRuntime as unknown as ChannelApprovalCapability["nativeRuntime"],
|
|
227
|
+
};
|
package/src/channel.ts
CHANGED
|
@@ -10,6 +10,9 @@ import os from "node:os";
|
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
12
12
|
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
13
|
+
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
|
|
14
|
+
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
|
15
|
+
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
|
13
16
|
import { createFridayNextLogger } from "./logging.js";
|
|
14
17
|
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
|
15
18
|
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
} from "./friday-session.js";
|
|
25
28
|
import { getRunRoute } from "./run-metadata.js";
|
|
26
29
|
import { getLastFridayInboundAt } from "./friday-inbound-stats.js";
|
|
30
|
+
import { fridayApprovalCapability } from "./approval/friday-approval-capability.js";
|
|
27
31
|
|
|
28
32
|
const logger = createFridayNextLogger("channel");
|
|
29
33
|
const CHANNEL_ID = "friday-next" as const;
|
|
@@ -113,7 +117,22 @@ const fridayLifecycle = {
|
|
|
113
117
|
* (reload/shutdown) so the channel stays `running:true` and continuously deliverable.
|
|
114
118
|
*/
|
|
115
119
|
const fridayGateway = {
|
|
116
|
-
startAccount: async (ctx:
|
|
120
|
+
startAccount: async (ctx: ChannelGatewayContext): Promise<void> => {
|
|
121
|
+
// Activate exec/plugin approval delivery to the app. The gateway's approval-handler bootstrap
|
|
122
|
+
// only wires up our `approvalCapability` once the channel registers an "approval.native" runtime
|
|
123
|
+
// context (the registration event is the gate — without it approvals silently skip friday-next
|
|
124
|
+
// and only reach ControlUI). friday-next's nativeRuntime needs no per-account state — it resolves
|
|
125
|
+
// the target device from each request's sessionKey via global singletons — so context is empty.
|
|
126
|
+
if (ctx.channelRuntime) {
|
|
127
|
+
registerChannelRuntimeContext({
|
|
128
|
+
channelRuntime: ctx.channelRuntime,
|
|
129
|
+
channelId: CHANNEL_ID,
|
|
130
|
+
accountId: ctx.accountId,
|
|
131
|
+
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
|
132
|
+
context: {},
|
|
133
|
+
abortSignal: ctx.abortSignal,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
117
136
|
await waitUntilAbort(ctx.abortSignal);
|
|
118
137
|
},
|
|
119
138
|
};
|
|
@@ -342,3 +361,8 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
342
361
|
},
|
|
343
362
|
},
|
|
344
363
|
});
|
|
364
|
+
|
|
365
|
+
// Attach exec/plugin approval delivery to the app. `createChatChannelPlugin` has no config slot for
|
|
366
|
+
// it, so it's set on the returned plugin object; setting it auto-registers the native approval
|
|
367
|
+
// handler via the gateway's approval bootstrap. Additive with ControlUI (no forwarding suppressor).
|
|
368
|
+
fridayNextChannelPlugin.approvalCapability = fridayApprovalCapability;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { hasTopLevelSummaryKey } from "./codex-reasoning-config.js";
|
|
3
|
+
|
|
4
|
+
describe("hasTopLevelSummaryKey", () => {
|
|
5
|
+
it("returns true when the key is a top-level entry", () => {
|
|
6
|
+
expect(hasTopLevelSummaryKey('model_reasoning_summary = "detailed"\n')).toBe(true);
|
|
7
|
+
expect(
|
|
8
|
+
hasTopLevelSummaryKey('model_reasoning_summary = "auto"\n\n[projects."/x"]\ntrust_level = "trusted"\n'),
|
|
9
|
+
).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns false when the file has no key", () => {
|
|
13
|
+
expect(hasTopLevelSummaryKey("")).toBe(false);
|
|
14
|
+
expect(hasTopLevelSummaryKey('[projects."/x"]\ntrust_level = "trusted"\n')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("treats a key nested under a [section] as NOT top-level (TOML scoping)", () => {
|
|
18
|
+
// This is the trap: appended after a table header the key belongs to that table, so Codex
|
|
19
|
+
// ignores it. Must be reported as absent so the caller prepends a real top-level key.
|
|
20
|
+
const nested = '[projects."/x"]\ntrust_level = "trusted"\nmodel_reasoning_summary = "detailed"\n';
|
|
21
|
+
expect(hasTopLevelSummaryKey(nested)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("ignores commented or partial matches", () => {
|
|
25
|
+
expect(hasTopLevelSummaryKey('# model_reasoning_summary = "detailed"\n')).toBe(false);
|
|
26
|
+
expect(hasTopLevelSummaryKey("model_reasoning_summary_extra = 1\n")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|