@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
package/dist/index.js
CHANGED
|
@@ -6,12 +6,13 @@ import { getHostOpenClawConfigSnapshot } from "./src/host-config.js";
|
|
|
6
6
|
import { registerFridayNextHttpRoutes } from "./src/http/server.js";
|
|
7
7
|
import { getFridayNextRuntime } from "./src/runtime.js";
|
|
8
8
|
import { sseEmitter } from "./src/sse/emitter.js";
|
|
9
|
-
import { forwardAgentEventRaw, getLastRegisteredFridayDeviceId, resolveFridayDeviceIdForSessionKey, } from "./src/friday-session.js";
|
|
9
|
+
import { forwardAgentEventRaw, getLastRegisteredFridayDeviceId, isCodexRun, resolveFridayDeviceIdForSessionKey, } from "./src/friday-session.js";
|
|
10
10
|
import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
|
|
11
11
|
import { setUpgradeRuntime } from "./src/upgrade-runtime.js";
|
|
12
12
|
import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
|
|
13
13
|
import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
|
|
14
14
|
import { createFridayNextLogger } from "./src/logging.js";
|
|
15
|
+
import { ensureCodexReasoningSummary } from "./src/codex-reasoning-config.js";
|
|
15
16
|
const hookLogger = createFridayNextLogger("hook");
|
|
16
17
|
export { fridayNextChannelPlugin } from "./src/channel.js";
|
|
17
18
|
export { setFridayNextRuntime } from "./src/runtime.js";
|
|
@@ -50,6 +51,38 @@ function deviceIdFromToolContext(ctx) {
|
|
|
50
51
|
function isFridaySessionKey(sk) {
|
|
51
52
|
return /^friday-next-/i.test(sk) || /^agent:main:friday-next-/i.test(sk);
|
|
52
53
|
}
|
|
54
|
+
/** Shell/exec-style tools whose stdout the app renders as a `command_output` row (A3). */
|
|
55
|
+
const COMMAND_TOOL_NAMES = new Set([
|
|
56
|
+
"exec",
|
|
57
|
+
"bash",
|
|
58
|
+
"shell",
|
|
59
|
+
"local_shell",
|
|
60
|
+
"command",
|
|
61
|
+
"process",
|
|
62
|
+
"run_terminal_cmd",
|
|
63
|
+
]);
|
|
64
|
+
function isCommandTool(toolName) {
|
|
65
|
+
return typeof toolName === "string" && COMMAND_TOOL_NAMES.has(toolName.trim().toLowerCase());
|
|
66
|
+
}
|
|
67
|
+
/** Best-effort flatten of an after-hook tool result into the stdout string the app expects. */
|
|
68
|
+
function coerceCommandOutput(result) {
|
|
69
|
+
if (typeof result === "string")
|
|
70
|
+
return result;
|
|
71
|
+
if (result && typeof result === "object") {
|
|
72
|
+
const r = result;
|
|
73
|
+
for (const key of ["output", "stdout", "text"]) {
|
|
74
|
+
if (typeof r[key] === "string")
|
|
75
|
+
return r[key];
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return JSON.stringify(result);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return result == null ? "" : String(result);
|
|
85
|
+
}
|
|
53
86
|
function shouldForwardToolEventToFriday(ctx) {
|
|
54
87
|
if (ctx.runId) {
|
|
55
88
|
if (sseEmitter.getDeviceIdByRunId(ctx.runId))
|
|
@@ -113,6 +146,9 @@ export default defineChannelPluginEntry({
|
|
|
113
146
|
return;
|
|
114
147
|
}
|
|
115
148
|
fridayNextToolHooksRegistered = true;
|
|
149
|
+
// Make Codex (ChatGPT/OAuth) models emit reasoning summary text so the app can stream
|
|
150
|
+
// "thinking". OpenClaw never sets this; we assert it on the plugin side. Best-effort.
|
|
151
|
+
ensureCodexReasoningSummary((msg) => hookLogger.info(msg));
|
|
116
152
|
api.on("subagent_delivery_target", (event) => {
|
|
117
153
|
if (!event.expectsCompletionMessage)
|
|
118
154
|
return;
|
|
@@ -189,6 +225,28 @@ export default defineChannelPluginEntry({
|
|
|
189
225
|
ts: Date.now(),
|
|
190
226
|
},
|
|
191
227
|
});
|
|
228
|
+
// A3: the Codex app-server backend never puts exec stdout on the `command_output` stream
|
|
229
|
+
// (its tool result carries only exitCode/duration), so the app shows the command row with no
|
|
230
|
+
// output. The after-hook DOES carry the full stdout — synthesize a `command_output` end event
|
|
231
|
+
// keyed by toolCallId (== the forwarded `item kind:command` itemId) so the app attaches it.
|
|
232
|
+
// Codex-only: embedded runs already stream `command_output` on the bus.
|
|
233
|
+
if (isCodexRun(runId) && event.toolCallId && isCommandTool(event.toolName)) {
|
|
234
|
+
const output = coerceCommandOutput(event.result);
|
|
235
|
+
if (output) {
|
|
236
|
+
forwardAgentEventRaw({
|
|
237
|
+
runId,
|
|
238
|
+
stream: "command_output",
|
|
239
|
+
data: {
|
|
240
|
+
itemId: event.toolCallId,
|
|
241
|
+
phase: "end",
|
|
242
|
+
output,
|
|
243
|
+
status: event.error ? "failed" : "completed",
|
|
244
|
+
durationMs: event.durationMs ?? null,
|
|
245
|
+
},
|
|
246
|
+
sessionKey: ctx.sessionKey,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
192
250
|
});
|
|
193
251
|
},
|
|
194
252
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
|
|
2
|
+
/** SSE payload the app receives for an approval lifecycle event. `op` is the phase. */
|
|
3
|
+
export interface FridayApprovalPayload {
|
|
4
|
+
op: "request" | "resolved" | "expired";
|
|
5
|
+
approvalId: string;
|
|
6
|
+
kind: "exec" | "plugin";
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string | null;
|
|
9
|
+
commandText?: string | null;
|
|
10
|
+
commandPreview?: string | null;
|
|
11
|
+
cwd?: string | null;
|
|
12
|
+
host?: string | null;
|
|
13
|
+
toolName?: string | null;
|
|
14
|
+
severity?: string | null;
|
|
15
|
+
metadata: {
|
|
16
|
+
label: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}[];
|
|
19
|
+
actions: {
|
|
20
|
+
decision: string;
|
|
21
|
+
label: string;
|
|
22
|
+
style: string;
|
|
23
|
+
}[];
|
|
24
|
+
expiresAtMs?: number | null;
|
|
25
|
+
decision?: string | null;
|
|
26
|
+
resolvedBy?: string | null;
|
|
27
|
+
sessionKey?: string | null;
|
|
28
|
+
runId?: string | null;
|
|
29
|
+
deviceId: string;
|
|
30
|
+
ts: number;
|
|
31
|
+
}
|
|
32
|
+
/** Build the normalized app payload from a pending/resolved/expired approval view. */
|
|
33
|
+
export declare function buildPayload(params: {
|
|
34
|
+
op: FridayApprovalPayload["op"];
|
|
35
|
+
view: Record<string, unknown>;
|
|
36
|
+
request: unknown;
|
|
37
|
+
deviceId: string;
|
|
38
|
+
}): FridayApprovalPayload;
|
|
39
|
+
/**
|
|
40
|
+
* friday-next approval capability. `native` declares delivery to the originating device's session;
|
|
41
|
+
* `nativeRuntime` builds the app payload and ferries it over SSE. No `delivery` suppressor → additive
|
|
42
|
+
* with ControlUI.
|
|
43
|
+
*/
|
|
44
|
+
export declare const fridayApprovalCapability: ChannelApprovalCapability;
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
|
16
|
+
import { sseEmitter } from "../sse/emitter.js";
|
|
17
|
+
import { resolveFridayDeviceIdForSessionKey } from "../friday-session.js";
|
|
18
|
+
import { createFridayNextLogger } from "../logging.js";
|
|
19
|
+
const logger = createFridayNextLogger("approval");
|
|
20
|
+
/** Pull the originating sessionKey out of an exec/plugin approval request (`request.request.*`). */
|
|
21
|
+
function sessionKeyOf(request) {
|
|
22
|
+
const inner = request?.request;
|
|
23
|
+
const sk = inner?.sessionKey;
|
|
24
|
+
return typeof sk === "string" && sk.trim() ? sk.trim() : undefined;
|
|
25
|
+
}
|
|
26
|
+
/** Resolve the friday device that owns this approval's session, if any. */
|
|
27
|
+
function deviceForRequest(request) {
|
|
28
|
+
const sk = sessionKeyOf(request);
|
|
29
|
+
if (!sk)
|
|
30
|
+
return undefined;
|
|
31
|
+
const dev = resolveFridayDeviceIdForSessionKey(sk);
|
|
32
|
+
return dev ? dev.toUpperCase() : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Build the normalized app payload from a pending/resolved/expired approval view. */
|
|
35
|
+
export function buildPayload(params) {
|
|
36
|
+
const { op, view, request, deviceId } = params;
|
|
37
|
+
const str = (v) => (typeof v === "string" ? v : null);
|
|
38
|
+
const num = (v) => (typeof v === "number" ? v : null);
|
|
39
|
+
const actionsRaw = Array.isArray(view.actions) ? view.actions : [];
|
|
40
|
+
const metaRaw = Array.isArray(view.metadata) ? view.metadata : [];
|
|
41
|
+
return {
|
|
42
|
+
op,
|
|
43
|
+
approvalId: str(view.approvalId) ?? "",
|
|
44
|
+
kind: view.approvalKind === "plugin" ? "plugin" : "exec",
|
|
45
|
+
title: str(view.title) ?? "",
|
|
46
|
+
description: str(view.description),
|
|
47
|
+
commandText: str(view.commandText),
|
|
48
|
+
commandPreview: str(view.commandPreview),
|
|
49
|
+
cwd: str(view.cwd),
|
|
50
|
+
host: str(view.host),
|
|
51
|
+
toolName: str(view.toolName),
|
|
52
|
+
severity: str(view.severity),
|
|
53
|
+
metadata: metaRaw.map((m) => ({ label: str(m.label) ?? "", value: str(m.value) ?? "" })),
|
|
54
|
+
actions: actionsRaw.map((a) => ({
|
|
55
|
+
decision: str(a.decision) ?? "",
|
|
56
|
+
label: str(a.label) ?? "",
|
|
57
|
+
style: str(a.style) ?? "secondary",
|
|
58
|
+
})),
|
|
59
|
+
expiresAtMs: num(view.expiresAtMs),
|
|
60
|
+
decision: str(view.decision),
|
|
61
|
+
resolvedBy: str(view.resolvedBy),
|
|
62
|
+
sessionKey: sessionKeyOf(request) ?? null,
|
|
63
|
+
runId: sseEmitter.getLastRunIdForDevice(deviceId),
|
|
64
|
+
deviceId,
|
|
65
|
+
ts: Date.now(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function emitApproval(deviceId, payload) {
|
|
69
|
+
sseEmitter.broadcast({ type: "approval", data: { ...payload } }, deviceId, true);
|
|
70
|
+
}
|
|
71
|
+
const fridayApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter({
|
|
72
|
+
eventKinds: ["exec", "plugin"],
|
|
73
|
+
availability: {
|
|
74
|
+
isConfigured: () => true,
|
|
75
|
+
shouldHandle: ({ request }) => deviceForRequest(request) !== undefined,
|
|
76
|
+
},
|
|
77
|
+
presentation: {
|
|
78
|
+
buildPendingPayload: ({ request, view }) => {
|
|
79
|
+
const deviceId = deviceForRequest(request) ?? "";
|
|
80
|
+
return buildPayload({
|
|
81
|
+
op: "request",
|
|
82
|
+
view: view,
|
|
83
|
+
request,
|
|
84
|
+
deviceId,
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
buildResolvedResult: ({ request, view }) => {
|
|
88
|
+
const deviceId = deviceForRequest(request) ?? "";
|
|
89
|
+
return {
|
|
90
|
+
kind: "update",
|
|
91
|
+
payload: buildPayload({
|
|
92
|
+
op: "resolved",
|
|
93
|
+
view: view,
|
|
94
|
+
request,
|
|
95
|
+
deviceId,
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
buildExpiredResult: ({ request, view }) => {
|
|
100
|
+
const deviceId = deviceForRequest(request) ?? "";
|
|
101
|
+
return {
|
|
102
|
+
kind: "update",
|
|
103
|
+
payload: buildPayload({
|
|
104
|
+
op: "expired",
|
|
105
|
+
view: view,
|
|
106
|
+
request,
|
|
107
|
+
deviceId,
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
transport: {
|
|
113
|
+
prepareTarget: ({ plannedTarget, request }) => {
|
|
114
|
+
const planned = typeof plannedTarget?.target?.to === "string" && plannedTarget.target.to.trim()
|
|
115
|
+
? plannedTarget.target.to.trim().toUpperCase()
|
|
116
|
+
: undefined;
|
|
117
|
+
const deviceId = planned ?? deviceForRequest(request);
|
|
118
|
+
if (!deviceId)
|
|
119
|
+
return null;
|
|
120
|
+
return { dedupeKey: `friday-approval:${deviceId}`, target: { deviceId } };
|
|
121
|
+
},
|
|
122
|
+
deliverPending: ({ preparedTarget, pendingPayload }) => {
|
|
123
|
+
const deviceId = preparedTarget.deviceId;
|
|
124
|
+
logger.info(`deliver approval ${pendingPayload.approvalId} kind=${pendingPayload.kind} -> ${deviceId}`);
|
|
125
|
+
emitApproval(deviceId, { ...pendingPayload, deviceId });
|
|
126
|
+
return { deviceId, approvalId: pendingPayload.approvalId };
|
|
127
|
+
},
|
|
128
|
+
updateEntry: async ({ entry, payload }) => {
|
|
129
|
+
emitApproval(entry.deviceId, { ...payload, deviceId: entry.deviceId });
|
|
130
|
+
},
|
|
131
|
+
deleteEntry: async ({ entry, phase }) => {
|
|
132
|
+
emitApproval(entry.deviceId, {
|
|
133
|
+
op: phase === "resolved" ? "resolved" : "expired",
|
|
134
|
+
approvalId: entry.approvalId,
|
|
135
|
+
kind: "exec",
|
|
136
|
+
title: "",
|
|
137
|
+
metadata: [],
|
|
138
|
+
actions: [],
|
|
139
|
+
deviceId: entry.deviceId,
|
|
140
|
+
ts: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
observe: {
|
|
145
|
+
onDeliveryError: ({ error }) => {
|
|
146
|
+
logger.warn(`approval delivery failed: ${String(error)}`);
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
151
|
+
* friday-next approval capability. `native` declares delivery to the originating device's session;
|
|
152
|
+
* `nativeRuntime` builds the app payload and ferries it over SSE. No `delivery` suppressor → additive
|
|
153
|
+
* with ControlUI.
|
|
154
|
+
*/
|
|
155
|
+
export const fridayApprovalCapability = {
|
|
156
|
+
native: {
|
|
157
|
+
describeDeliveryCapabilities: ({ request }) => {
|
|
158
|
+
const enabled = deviceForRequest(request) !== undefined;
|
|
159
|
+
return {
|
|
160
|
+
enabled,
|
|
161
|
+
preferredSurface: "origin",
|
|
162
|
+
supportsOriginSurface: true,
|
|
163
|
+
supportsApproverDmSurface: false,
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
resolveOriginTarget: ({ request }) => {
|
|
167
|
+
const deviceId = deviceForRequest(request);
|
|
168
|
+
return deviceId ? { to: deviceId } : null;
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
// Cast widens the parameterized adapter to the field's `unknown`-typed shape (function-param
|
|
172
|
+
// contravariance). Same escape hatch Slack uses for its lazy runtime adapter.
|
|
173
|
+
nativeRuntime: fridayApprovalNativeRuntime,
|
|
174
|
+
};
|
package/dist/src/channel.js
CHANGED
|
@@ -9,6 +9,8 @@ import os from "node:os";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
11
11
|
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
12
|
+
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
|
|
13
|
+
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
|
12
14
|
import { createFridayNextLogger } from "./logging.js";
|
|
13
15
|
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
|
|
14
16
|
import { sseEmitter } from "./sse/emitter.js";
|
|
@@ -19,6 +21,7 @@ import { resolveMediaMaxBytes } from "./agent/media-bridge.js";
|
|
|
19
21
|
import { resolveFridayDeviceIdForOutbound, resolveHistorySessionKeyForFridayDevice, } from "./friday-session.js";
|
|
20
22
|
import { getRunRoute } from "./run-metadata.js";
|
|
21
23
|
import { getLastFridayInboundAt } from "./friday-inbound-stats.js";
|
|
24
|
+
import { fridayApprovalCapability } from "./approval/friday-approval-capability.js";
|
|
22
25
|
const logger = createFridayNextLogger("channel");
|
|
23
26
|
const CHANNEL_ID = "friday-next";
|
|
24
27
|
function pickFirstString(source, keys) {
|
|
@@ -97,6 +100,21 @@ const fridayLifecycle = {
|
|
|
97
100
|
*/
|
|
98
101
|
const fridayGateway = {
|
|
99
102
|
startAccount: async (ctx) => {
|
|
103
|
+
// Activate exec/plugin approval delivery to the app. The gateway's approval-handler bootstrap
|
|
104
|
+
// only wires up our `approvalCapability` once the channel registers an "approval.native" runtime
|
|
105
|
+
// context (the registration event is the gate — without it approvals silently skip friday-next
|
|
106
|
+
// and only reach ControlUI). friday-next's nativeRuntime needs no per-account state — it resolves
|
|
107
|
+
// the target device from each request's sessionKey via global singletons — so context is empty.
|
|
108
|
+
if (ctx.channelRuntime) {
|
|
109
|
+
registerChannelRuntimeContext({
|
|
110
|
+
channelRuntime: ctx.channelRuntime,
|
|
111
|
+
channelId: CHANNEL_ID,
|
|
112
|
+
accountId: ctx.accountId,
|
|
113
|
+
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
|
114
|
+
context: {},
|
|
115
|
+
abortSignal: ctx.abortSignal,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
100
118
|
await waitUntilAbort(ctx.abortSignal);
|
|
101
119
|
},
|
|
102
120
|
};
|
|
@@ -297,3 +315,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
297
315
|
},
|
|
298
316
|
},
|
|
299
317
|
});
|
|
318
|
+
// Attach exec/plugin approval delivery to the app. `createChatChannelPlugin` has no config slot for
|
|
319
|
+
// it, so it's set on the returned plugin object; setting it auto-registers the native approval
|
|
320
|
+
// handler via the gateway's approval bootstrap. Additive with ControlUI (no forwarding suppressor).
|
|
321
|
+
fridayNextChannelPlugin.approvalCapability = fridayApprovalCapability;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True if a top-level `model_reasoning_summary` key already exists. TOML scoping matters: a key is
|
|
3
|
+
* only top-level (and thus honored by Codex) if it appears before the first `[section]` header, so
|
|
4
|
+
* we stop scanning at the first table header.
|
|
5
|
+
*/
|
|
6
|
+
export declare function hasTopLevelSummaryKey(content: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Best-effort: ensure every agent's Codex config requests a reasoning summary. Never throws —
|
|
9
|
+
* activation must not fail because of a config write. `log` receives a one-line summary per change.
|
|
10
|
+
*/
|
|
11
|
+
export declare function ensureCodexReasoningSummary(log: (msg: string) => void): void;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Ensures the Codex app-server backend emits reasoning *summary* text so Friday can stream it.
|
|
2
|
+
//
|
|
3
|
+
// Background: OpenAI models authenticated via ChatGPT/OAuth run through OpenClaw's Codex
|
|
4
|
+
// app-server backend. That backend sends `reasoning_effort` per turn but never requests a
|
|
5
|
+
// reasoning summary, and OpenClaw exposes no `openclaw.json` lever for it. Without a summary the
|
|
6
|
+
// model's reasoning stays encrypted (`encrypted_content`) and no reasoning text reaches the
|
|
7
|
+
// channel — so the Friday app shows no streaming "thinking" for Codex models.
|
|
8
|
+
//
|
|
9
|
+
// The only switch that makes Codex return summary text is the Codex CLI's own
|
|
10
|
+
// `model_reasoning_summary` key in `~/.openclaw/agents/<id>/agent/codex-home/config.toml`.
|
|
11
|
+
// We keep the fix on the plugin side by asserting that key on activation (idempotently, for every
|
|
12
|
+
// agent that has a codex-home), so it survives OpenClaw rewrites of that file across restarts.
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
const CONFIG_KEY = "model_reasoning_summary";
|
|
17
|
+
// "detailed" is the value verified end-to-end (reasoning streamed to the app). Codex also accepts
|
|
18
|
+
// "auto"/"concise"; tune here if the summaries feel too verbose.
|
|
19
|
+
const SUMMARY_VALUE = "detailed";
|
|
20
|
+
function resolveOpenClawHome() {
|
|
21
|
+
const env = process.env.OPENCLAW_HOME?.trim();
|
|
22
|
+
return env && env.length > 0 ? env : join(homedir(), ".openclaw");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* True if a top-level `model_reasoning_summary` key already exists. TOML scoping matters: a key is
|
|
26
|
+
* only top-level (and thus honored by Codex) if it appears before the first `[section]` header, so
|
|
27
|
+
* we stop scanning at the first table header.
|
|
28
|
+
*/
|
|
29
|
+
export function hasTopLevelSummaryKey(content) {
|
|
30
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
31
|
+
const line = raw.trim();
|
|
32
|
+
if (line.startsWith("["))
|
|
33
|
+
break;
|
|
34
|
+
if (new RegExp(`^${CONFIG_KEY}\\s*=`).test(line))
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
function ensureKeyInCodexHome(codexHome) {
|
|
40
|
+
const configPath = join(codexHome, "config.toml");
|
|
41
|
+
const header = `${CONFIG_KEY} = "${SUMMARY_VALUE}"\n`;
|
|
42
|
+
if (!existsSync(configPath)) {
|
|
43
|
+
writeFileSync(configPath, header, "utf8");
|
|
44
|
+
return "added";
|
|
45
|
+
}
|
|
46
|
+
const content = readFileSync(configPath, "utf8");
|
|
47
|
+
if (hasTopLevelSummaryKey(content))
|
|
48
|
+
return "present";
|
|
49
|
+
// Prepend so the key stays top-level even if the file starts with a `[section]` table.
|
|
50
|
+
writeFileSync(configPath, `${header}\n${content}`, "utf8");
|
|
51
|
+
return "added";
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Best-effort: ensure every agent's Codex config requests a reasoning summary. Never throws —
|
|
55
|
+
* activation must not fail because of a config write. `log` receives a one-line summary per change.
|
|
56
|
+
*/
|
|
57
|
+
export function ensureCodexReasoningSummary(log) {
|
|
58
|
+
try {
|
|
59
|
+
const agentsDir = join(resolveOpenClawHome(), "agents");
|
|
60
|
+
if (!existsSync(agentsDir))
|
|
61
|
+
return;
|
|
62
|
+
for (const agentId of readdirSync(agentsDir)) {
|
|
63
|
+
const codexHome = join(agentsDir, agentId, "agent", "codex-home");
|
|
64
|
+
// Only touch agents Codex has actually initialized (codex-home exists). New agents are
|
|
65
|
+
// picked up on the next activation/restart.
|
|
66
|
+
if (!existsSync(codexHome))
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(codexHome, { recursive: true });
|
|
70
|
+
const result = ensureKeyInCodexHome(codexHome);
|
|
71
|
+
if (result === "added") {
|
|
72
|
+
log(`codex reasoning summary enabled (agent=${agentId})`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
log(`codex reasoning summary write failed (agent=${agentId}): ${String(err)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
log(`codex reasoning summary ensure failed: ${String(err)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** Vitest-only: clears per-run reasoning text cache used for incremental `delta` rewriting. */
|
|
2
2
|
export declare function resetThinkingStreamAccumStateForTest(): void;
|
|
3
|
+
/** True once a `codex_app_server.*` frame has been seen for this run. */
|
|
4
|
+
export declare function isCodexRun(runId: string): boolean;
|
|
5
|
+
/** Vitest-only */
|
|
6
|
+
export declare function resetCodexRunTrackingForTest(): void;
|
|
3
7
|
/** Vitest-only */
|
|
4
8
|
export declare function resetOpenClawRunDeviceMappingForTest(): void;
|
|
5
9
|
/** Parse deviceId from a Friday Next channel sessionKey (friday-{deviceId} or legacy agent:main:friday-*). */
|
|
@@ -20,6 +20,24 @@ function commonPrefixLength(a, b) {
|
|
|
20
20
|
export function resetThinkingStreamAccumStateForTest() {
|
|
21
21
|
lastThinkingTextByRun.clear();
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Runs backed by the OpenClaw Codex app-server backend (model api `openai-chatgpt-responses`).
|
|
25
|
+
* They emit their activity under a `codex_app_server.*` stream namespace and — unlike the embedded
|
|
26
|
+
* runner — do NOT put reasoning text on the agent-event bus (`stream: "thinking"`); that text only
|
|
27
|
+
* arrives via the dispatch `onReasoningStream` callback. Likewise exec stdout never reaches the
|
|
28
|
+
* `command_output` stream. We mark a run as Codex the first time we see any `codex_app_server.*`
|
|
29
|
+
* frame so the message handler / tool hooks know to synthesize the missing `thinking` /
|
|
30
|
+
* `command_output` events for it (and ONLY for it — embedded runs already get both via the bus).
|
|
31
|
+
*/
|
|
32
|
+
const codexRunIds = new Set();
|
|
33
|
+
/** True once a `codex_app_server.*` frame has been seen for this run. */
|
|
34
|
+
export function isCodexRun(runId) {
|
|
35
|
+
return codexRunIds.has(runId);
|
|
36
|
+
}
|
|
37
|
+
/** Vitest-only */
|
|
38
|
+
export function resetCodexRunTrackingForTest() {
|
|
39
|
+
codexRunIds.clear();
|
|
40
|
+
}
|
|
23
41
|
/**
|
|
24
42
|
* OpenClaw `runId` → device UUID (uppercase).
|
|
25
43
|
* When `lifecycle.end` / `error` is emitted, the gateway may call `clearAgentRunContext` before this extension's
|
|
@@ -298,6 +316,11 @@ export function forwardAgentEventRaw(evt) {
|
|
|
298
316
|
sk = latestHistorySessionKeyForDeviceId(deviceIdRaw) ?? `friday-next-${deviceIdRaw}`;
|
|
299
317
|
}
|
|
300
318
|
openClawRunIdToDeviceId.set(evt.runId, deviceIdRaw.toUpperCase());
|
|
319
|
+
// Flag Codex app-server runs so the message handler / tool hooks synthesize the `thinking` /
|
|
320
|
+
// `command_output` events that this backend never emits on the bus (see `isCodexRun`).
|
|
321
|
+
if (typeof evt.stream === "string" && evt.stream.startsWith("codex_app_server")) {
|
|
322
|
+
codexRunIds.add(evt.runId);
|
|
323
|
+
}
|
|
301
324
|
// Register sessionKey → runId so we can resolve parentRunId
|
|
302
325
|
if (sk && evt.stream === "lifecycle" && evt.data.phase === "start") {
|
|
303
326
|
registerSessionKeyForRun(sk, evt.runId);
|
|
@@ -432,6 +455,7 @@ export function forwardAgentEventRaw(evt) {
|
|
|
432
455
|
}
|
|
433
456
|
if (phase === "end" || phase === "error") {
|
|
434
457
|
lastThinkingTextByRun.delete(evt.runId);
|
|
458
|
+
codexRunIds.delete(evt.runId);
|
|
435
459
|
}
|
|
436
460
|
}
|
|
437
461
|
const lifecyclePhase = evt.stream === "lifecycle" && typeof evt.data.phase === "string" ? evt.data.phase : "";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
/**
|
|
3
|
+
* POST /friday-next/approvals/{approvalId}
|
|
4
|
+
* Body: { decision: "allow-once" | "allow-always" | "deny", deviceId?: string }
|
|
5
|
+
*
|
|
6
|
+
* Submits the app user's decision for a pending exec/plugin approval back to the gateway. The bearer
|
|
7
|
+
* token gates auth (the device owner is the approver); the gateway then resumes / aborts the run.
|
|
8
|
+
*/
|
|
9
|
+
export declare function handleApprovalDecision(req: IncomingMessage, res: ServerResponse, approvalId: string): Promise<boolean>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
|
|
2
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
3
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
5
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
6
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
7
|
+
const VALID_DECISIONS = new Set(["allow-once", "allow-always", "deny"]);
|
|
8
|
+
/**
|
|
9
|
+
* POST /friday-next/approvals/{approvalId}
|
|
10
|
+
* Body: { decision: "allow-once" | "allow-always" | "deny", deviceId?: string }
|
|
11
|
+
*
|
|
12
|
+
* Submits the app user's decision for a pending exec/plugin approval back to the gateway. The bearer
|
|
13
|
+
* token gates auth (the device owner is the approver); the gateway then resumes / aborts the run.
|
|
14
|
+
*/
|
|
15
|
+
export async function handleApprovalDecision(req, res, approvalId) {
|
|
16
|
+
const log = createFridayNextLogger("approvals");
|
|
17
|
+
const json = (status, body) => {
|
|
18
|
+
res.statusCode = status;
|
|
19
|
+
res.setHeader("Content-Type", "application/json");
|
|
20
|
+
res.end(JSON.stringify(body));
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
if (req.method !== "POST")
|
|
24
|
+
return json(405, { error: "Method Not Allowed" });
|
|
25
|
+
if (!extractBearerToken(req))
|
|
26
|
+
return json(401, { error: "Unauthorized: bearer token mismatch" });
|
|
27
|
+
if (!approvalId.trim())
|
|
28
|
+
return json(400, { error: "Missing approvalId" });
|
|
29
|
+
const body = await readJsonBody(req);
|
|
30
|
+
if (!body)
|
|
31
|
+
return json(400, { error: "Invalid JSON body" });
|
|
32
|
+
const decision = typeof body.decision === "string" ? body.decision.trim() : "";
|
|
33
|
+
if (!VALID_DECISIONS.has(decision)) {
|
|
34
|
+
return json(400, { error: "decision must be allow-once | allow-always | deny" });
|
|
35
|
+
}
|
|
36
|
+
const deviceId = typeof body.deviceId === "string" ? body.deviceId.trim().toUpperCase() : "";
|
|
37
|
+
const cfg = getHostOpenClawConfigSnapshot(getFridayNextRuntime().config);
|
|
38
|
+
try {
|
|
39
|
+
await resolveApprovalOverGateway({
|
|
40
|
+
cfg: cfg,
|
|
41
|
+
approvalId: approvalId.trim(),
|
|
42
|
+
decision: decision,
|
|
43
|
+
senderId: deviceId || null,
|
|
44
|
+
allowPluginFallback: true,
|
|
45
|
+
clientDisplayName: deviceId ? `Friday Next (${deviceId})` : "Friday Next",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
log.error(`resolveApprovalOverGateway failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
50
|
+
return json(502, { error: "Approval resolution failed", detail: String(err) });
|
|
51
|
+
}
|
|
52
|
+
log.info(`approval ${approvalId} resolved decision=${decision} device=${deviceId || "(none)"}`);
|
|
53
|
+
return json(200, { ok: true, approvalId: approvalId.trim(), decision });
|
|
54
|
+
}
|
|
@@ -17,7 +17,7 @@ import { resolveAgentDefaults, setSessionSettings, splitModelRef, toSessionStore
|
|
|
17
17
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
18
18
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
19
19
|
import { readJsonBody } from "../middleware/body.js";
|
|
20
|
-
import { registerFridaySessionDeviceMapping } from "../../friday-session.js";
|
|
20
|
+
import { forwardAgentEventRaw, isCodexRun, 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";
|
|
@@ -467,6 +467,12 @@ export async function handleMessages(req, res) {
|
|
|
467
467
|
runId,
|
|
468
468
|
suppressTyping: true,
|
|
469
469
|
disableBlockStreaming: true,
|
|
470
|
+
// A1: feed the chosen thinking level into the run as a one-shot override so the model
|
|
471
|
+
// request asks for a reasoning summary. The session-stored `thinkingLevel` alone is NOT
|
|
472
|
+
// honored by the reply dispatch; `thinkingLevelOverride` has top priority in OpenClaw's
|
|
473
|
+
// resolution chain (get-reply-directives). Required for Codex (openai-chatgpt-responses)
|
|
474
|
+
// to emit any reasoning at all.
|
|
475
|
+
...(thinkingLevel ? { thinkingLevelOverride: thinkingLevel } : {}),
|
|
470
476
|
onModelSelected: (sel) => {
|
|
471
477
|
const name = typeof sel.model === "string" ? sel.model.trim() : "";
|
|
472
478
|
if (name) {
|
|
@@ -481,6 +487,18 @@ export async function handleMessages(req, res) {
|
|
|
481
487
|
: undefined;
|
|
482
488
|
const text = typeof rawText === "string" ? rawText : "";
|
|
483
489
|
log("REASONING_STREAM", normalizedDeviceId, runId, `textLen=${text.length}`);
|
|
490
|
+
// A2: the embedded runner already emits `stream: "thinking"` on the agent-event bus, so
|
|
491
|
+
// forwarding here would double it. The Codex app-server backend does NOT — reasoning text
|
|
492
|
+
// only arrives via this callback (cumulative snapshot). Forward it as a thinking event
|
|
493
|
+
// (reusing forwardAgentEventRaw's cumulative→delta rewrite) ONLY for Codex runs.
|
|
494
|
+
if (text && isCodexRun(runId)) {
|
|
495
|
+
forwardAgentEventRaw({
|
|
496
|
+
runId,
|
|
497
|
+
stream: "thinking",
|
|
498
|
+
data: { text },
|
|
499
|
+
sessionKey: baseSessionKey,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
484
502
|
},
|
|
485
503
|
onReasoningEnd: async () => {
|
|
486
504
|
log("REASONING_STREAM_END", normalizedDeviceId, runId);
|
package/dist/src/http/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { handleFilesDownload } from "./handlers/files-download.js";
|
|
|
11
11
|
import { handleCancel } from "./handlers/cancel.js";
|
|
12
12
|
import { handleDeviceApprove } from "./handlers/device-approve.js";
|
|
13
13
|
import { handleNodesApprove } from "./handlers/nodes-approve.js";
|
|
14
|
+
import { handleApprovalDecision } from "./handlers/approvals.js";
|
|
14
15
|
import { handleSessionsSettings } from "./handlers/sessions-settings.js";
|
|
15
16
|
import { handleModelsList } from "./handlers/models-list.js";
|
|
16
17
|
import { handleAgentsList } from "./handlers/agents-list.js";
|
|
@@ -65,6 +66,11 @@ async function handleFridayNextRoute(req, res) {
|
|
|
65
66
|
if (req.method === "POST" && pathname === "/friday-next/nodes-approve") {
|
|
66
67
|
return await handleNodesApprove(req, res);
|
|
67
68
|
}
|
|
69
|
+
// Route: POST /friday-next/approvals/{approvalId} (submit exec/plugin approval decision)
|
|
70
|
+
if (req.method === "POST" && pathname.startsWith("/friday-next/approvals/")) {
|
|
71
|
+
const approvalId = decodeURIComponent(pathname.slice("/friday-next/approvals/".length));
|
|
72
|
+
return await handleApprovalDecision(req, res, approvalId);
|
|
73
|
+
}
|
|
68
74
|
if ((req.method === "PUT" || req.method === "GET") &&
|
|
69
75
|
pathname === "/friday-next/sessions/settings") {
|
|
70
76
|
return await handleSessionsSettings(req, res);
|
|
@@ -0,0 +1,13 @@
|
|
|
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 type { IncomingMessage } from "node:http";
|
|
8
|
+
/**
|
|
9
|
+
* Extract and validate bearer token from Authorization header.
|
|
10
|
+
* Returns the token only if it matches the gateway's configured auth token.
|
|
11
|
+
* Returns null if token is missing, malformed, or doesn't match.
|
|
12
|
+
*/
|
|
13
|
+
export declare function extractBearerToken(req: IncomingMessage): string | null;
|