@syengup/friday-channel-next 0.1.30 → 0.1.37
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/README.md +8 -4
- package/dist/index.js +1 -1
- package/dist/src/agent/abort-run.d.ts +12 -1
- package/dist/src/agent/abort-run.js +24 -9
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- package/dist/src/agent/media-bridge.d.ts +8 -1
- package/dist/src/agent/media-bridge.js +23 -2
- package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
- package/dist/src/agent/node-pairing-bridge.js +6 -2
- package/dist/src/agent/subagent-registry.js +0 -3
- package/dist/src/agent-forward-runtime.d.ts +15 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/agent-id.d.ts +8 -0
- package/dist/src/agent-id.js +21 -0
- package/dist/src/channel-actions.js +48 -15
- package/dist/src/channel.js +22 -3
- package/dist/src/collect-message-media-paths.js +10 -1
- package/dist/src/friday-session.js +34 -10
- package/dist/src/history/normalize-message.js +22 -8
- package/dist/src/http/handlers/agent-config.d.ts +27 -0
- package/dist/src/http/handlers/agent-config.js +188 -0
- package/dist/src/http/handlers/agent-files.d.ts +21 -0
- package/dist/src/http/handlers/agent-files.js +137 -0
- package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
- package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
- package/dist/src/http/handlers/agents-list.js +1 -19
- package/dist/src/http/handlers/cancel.js +14 -6
- package/dist/src/http/handlers/device-approve.js +3 -1
- package/dist/src/http/handlers/files-download.js +6 -8
- package/dist/src/http/handlers/files.d.ts +16 -0
- package/dist/src/http/handlers/files.js +81 -13
- package/dist/src/http/handlers/health.js +18 -4
- package/dist/src/http/handlers/history-messages.js +1 -1
- package/dist/src/http/handlers/history-sessions.js +5 -3
- package/dist/src/http/handlers/messages.js +33 -14
- package/dist/src/http/handlers/models-list.d.ts +5 -0
- package/dist/src/http/handlers/models-list.js +9 -1
- package/dist/src/http/handlers/nodes-approve.js +1 -6
- package/dist/src/http/handlers/plugin-info.js +1 -1
- package/dist/src/http/handlers/sessions-settings.js +15 -10
- package/dist/src/http/server.js +27 -2
- package/dist/src/link-preview/og-parse.js +3 -1
- package/dist/src/link-preview/ssrf-guard.js +6 -2
- package/dist/src/media-fetch.js +4 -1
- package/dist/src/plugin-install-info.js +4 -1
- package/dist/src/session/session-manager.js +9 -3
- package/dist/src/session-usage-store.js +3 -1
- package/dist/src/skills-discovery.d.ts +59 -0
- package/dist/src/skills-discovery.js +252 -0
- package/dist/src/sse/offline-queue.js +4 -1
- package/dist/src/thinking-levels.d.ts +21 -0
- package/dist/src/thinking-levels.js +48 -0
- package/dist/src/tool-catalog.d.ts +53 -0
- package/dist/src/tool-catalog.js +191 -0
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +4 -2
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +23 -8
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.test.ts +71 -0
- package/src/agent/media-bridge.ts +30 -1
- package/src/agent/node-pairing-bridge.ts +29 -15
- package/src/agent/run-usage-accumulator.ts +4 -2
- package/src/agent/subagent-registry.ts +0 -4
- package/src/agent-forward-runtime.ts +11 -0
- package/src/agent-id.ts +24 -0
- package/src/agent-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +57 -4
- package/src/channel-actions.ts +41 -15
- package/src/channel.lifecycle.test.ts +41 -0
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +140 -120
- package/src/collect-message-media-paths.ts +15 -6
- package/src/config.ts +1 -4
- package/src/e2e/agents-list.e2e.test.ts +9 -2
- package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
- package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
- package/src/e2e/auto-approve.integration.test.ts +13 -7
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
- package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
- package/src/e2e/offline-replay.e2e.test.ts +17 -3
- package/src/e2e/send-text.e2e.test.ts +11 -2
- package/src/e2e/slash-commands.e2e.test.ts +5 -1
- package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
- package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
- package/src/e2e/subagent.e2e.test.ts +136 -53
- package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
- package/src/friday-session.forward-agent.test.ts +44 -12
- package/src/friday-session.ts +44 -20
- package/src/history/normalize-message.test.ts +35 -8
- package/src/history/normalize-message.ts +24 -12
- package/src/history/read-transcript.ts +1 -4
- package/src/http/handlers/agent-config.test.ts +212 -0
- package/src/http/handlers/agent-config.ts +232 -0
- package/src/http/handlers/agent-files.test.ts +136 -0
- package/src/http/handlers/agent-files.ts +149 -0
- package/src/http/handlers/agent-tools-catalog.ts +42 -0
- package/src/http/handlers/agents-list.test.ts +1 -5
- package/src/http/handlers/agents-list.ts +1 -22
- package/src/http/handlers/cancel.test.ts +23 -4
- package/src/http/handlers/cancel.ts +14 -6
- package/src/http/handlers/device-approve.test.ts +12 -3
- package/src/http/handlers/device-approve.ts +33 -21
- package/src/http/handlers/files-download.ts +17 -13
- package/src/http/handlers/files.test.ts +120 -0
- package/src/http/handlers/files.ts +115 -17
- package/src/http/handlers/health.test.ts +43 -11
- package/src/http/handlers/health.ts +22 -6
- package/src/http/handlers/history-messages.test.ts +51 -9
- package/src/http/handlers/history-messages.ts +4 -1
- package/src/http/handlers/history-sessions.test.ts +46 -9
- package/src/http/handlers/history-sessions.ts +5 -3
- package/src/http/handlers/history-set-title.test.ts +14 -5
- package/src/http/handlers/link-preview.test.ts +57 -16
- package/src/http/handlers/link-preview.ts +4 -1
- package/src/http/handlers/messages.test.ts +12 -8
- package/src/http/handlers/messages.ts +64 -21
- package/src/http/handlers/models-list.test.ts +114 -0
- package/src/http/handlers/models-list.ts +26 -8
- package/src/http/handlers/nodes-approve.test.ts +15 -4
- package/src/http/handlers/nodes-approve.ts +38 -40
- package/src/http/handlers/plugin-info.ts +5 -6
- package/src/http/handlers/plugin-upgrade.ts +4 -1
- package/src/http/handlers/sessions-settings.ts +16 -11
- package/src/http/handlers/sse.ts +3 -1
- package/src/http/server.ts +33 -6
- package/src/link-preview/og-parse.test.ts +6 -2
- package/src/link-preview/og-parse.ts +10 -3
- package/src/link-preview/preview-service.ts +4 -1
- package/src/link-preview/ssrf-guard.test.ts +78 -16
- package/src/link-preview/ssrf-guard.ts +7 -2
- package/src/media-fetch.test.ts +8 -3
- package/src/media-fetch.ts +5 -3
- package/src/openclaw.d.ts +41 -10
- package/src/plugin-install-info.ts +20 -9
- package/src/run-metadata.ts +2 -1
- package/src/session/session-manager.ts +19 -11
- package/src/session-usage-snapshot.ts +3 -1
- package/src/session-usage-store.ts +3 -1
- package/src/skills-discovery.test.ts +152 -0
- package/src/skills-discovery.ts +264 -0
- package/src/sse/emitter.test.ts +1 -1
- package/src/sse/emitter.ts +9 -3
- package/src/sse/offline-queue.ts +17 -8
- package/src/test-support/app-simulator.ts +17 -3
- package/src/test-support/mock-dispatch.ts +17 -4
- package/src/thinking-levels.test.ts +143 -0
- package/src/thinking-levels.ts +70 -0
- package/src/tool-catalog.ts +261 -0
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +6 -2
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET/PUT /friday-next/agents/{id}/config
|
|
3
|
+
*
|
|
4
|
+
* Reads and edits a single agent's runtime configuration — the same fields
|
|
5
|
+
* OpenClaw's ControlUI manages, but written through the plugin's own config
|
|
6
|
+
* channel (`api.runtime.config.mutateConfigFile`, proven by plugin-upgrade) so
|
|
7
|
+
* NO OpenClaw core changes are needed. All edits land in `agents.list[]` of the
|
|
8
|
+
* host config file (`~/.clawrc`), exactly where ControlUI's `config.set` writes.
|
|
9
|
+
*
|
|
10
|
+
* Editable fields:
|
|
11
|
+
* - model → agents.list[i].model (string | {primary,fallbacks})
|
|
12
|
+
* - thinkingDefault → agents.list[i].thinkingDefault
|
|
13
|
+
* - tools → agents.list[i].tools ({profile,allow,alsoAllow,deny})
|
|
14
|
+
* - skills → agents.list[i].skills (string[]; [] disables all, absent inherits defaults)
|
|
15
|
+
*
|
|
16
|
+
* Clearing an override MUST delete the field (not leave a stale value) so the
|
|
17
|
+
* core's config merge falls back to `agents.defaults` — same hazard documented
|
|
18
|
+
* for the default-model bug. PUT therefore treats an explicit `null` as "clear".
|
|
19
|
+
*/
|
|
20
|
+
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
21
|
+
import { getUpgradeRuntime } from "../../upgrade-runtime.js";
|
|
22
|
+
import { normalizeAgentId } from "../../agent-id.js";
|
|
23
|
+
import { discoverAvailableSkills } from "../../skills-discovery.js";
|
|
24
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
25
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
26
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
27
|
+
function json(res, status, body) {
|
|
28
|
+
res.statusCode = status;
|
|
29
|
+
res.setHeader("Content-Type", "application/json");
|
|
30
|
+
res.end(JSON.stringify(body));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
function readString(value) {
|
|
34
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
35
|
+
}
|
|
36
|
+
function readStringArray(value) {
|
|
37
|
+
if (!Array.isArray(value))
|
|
38
|
+
return undefined;
|
|
39
|
+
const out = value
|
|
40
|
+
.filter((v) => typeof v === "string" && v.trim().length > 0)
|
|
41
|
+
.map((v) => v.trim());
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function readToolsConfig(value) {
|
|
45
|
+
if (!value || typeof value !== "object")
|
|
46
|
+
return undefined;
|
|
47
|
+
const t = value;
|
|
48
|
+
const view = {};
|
|
49
|
+
const profile = readString(t.profile);
|
|
50
|
+
if (profile)
|
|
51
|
+
view.profile = profile;
|
|
52
|
+
const allow = readStringArray(t.allow);
|
|
53
|
+
if (allow)
|
|
54
|
+
view.allow = allow;
|
|
55
|
+
const alsoAllow = readStringArray(t.alsoAllow);
|
|
56
|
+
if (alsoAllow)
|
|
57
|
+
view.alsoAllow = alsoAllow;
|
|
58
|
+
const deny = readStringArray(t.deny);
|
|
59
|
+
if (deny)
|
|
60
|
+
view.deny = deny;
|
|
61
|
+
return view;
|
|
62
|
+
}
|
|
63
|
+
/** Locate the configured `agents.list[]` entry whose normalized id matches `agentId`. */
|
|
64
|
+
function findAgentEntry(cfg, agentId) {
|
|
65
|
+
const agents = cfg?.agents;
|
|
66
|
+
const list = agents?.list;
|
|
67
|
+
if (!Array.isArray(list))
|
|
68
|
+
return undefined;
|
|
69
|
+
return list.find((a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId);
|
|
70
|
+
}
|
|
71
|
+
function buildConfigView(agentId) {
|
|
72
|
+
const rt = getFridayAgentForwardRuntime();
|
|
73
|
+
const cfg = rt?.getConfig();
|
|
74
|
+
const entry = cfg ? findAgentEntry(cfg, agentId) : undefined;
|
|
75
|
+
return {
|
|
76
|
+
id: agentId,
|
|
77
|
+
exists: entry !== undefined,
|
|
78
|
+
model: entry?.model,
|
|
79
|
+
thinkingDefault: readString(entry?.thinkingDefault),
|
|
80
|
+
tools: readToolsConfig(entry?.tools),
|
|
81
|
+
// undefined = no `skills` field (inherit defaults); [] = field present but empty (all disabled).
|
|
82
|
+
skills: readStringArray(entry?.skills),
|
|
83
|
+
availableSkills: discoverAvailableSkills(cfg, agentId),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function readPatch(body, key, coerce) {
|
|
87
|
+
if (!(key in body))
|
|
88
|
+
return { sent: false, clear: false };
|
|
89
|
+
const raw = body[key];
|
|
90
|
+
if (raw === null)
|
|
91
|
+
return { sent: true, clear: true };
|
|
92
|
+
const value = coerce(raw);
|
|
93
|
+
if (value === undefined)
|
|
94
|
+
return { sent: false, clear: false };
|
|
95
|
+
return { sent: true, clear: false, value };
|
|
96
|
+
}
|
|
97
|
+
function coerceModel(raw) {
|
|
98
|
+
if (typeof raw === "string")
|
|
99
|
+
return raw.trim() || undefined;
|
|
100
|
+
if (raw && typeof raw === "object") {
|
|
101
|
+
const primary = readString(raw.primary);
|
|
102
|
+
if (!primary)
|
|
103
|
+
return undefined;
|
|
104
|
+
const fallbacks = readStringArray(raw.fallbacks);
|
|
105
|
+
return fallbacks && fallbacks.length > 0 ? { primary, fallbacks } : { primary };
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
function coerceTools(raw) {
|
|
110
|
+
return readToolsConfig(raw);
|
|
111
|
+
}
|
|
112
|
+
/** Skills: array (incl. empty = disable all) only; non-arrays are rejected upstream. */
|
|
113
|
+
function coerceSkills(raw) {
|
|
114
|
+
return Array.isArray(raw) ? (readStringArray(raw) ?? []) : undefined;
|
|
115
|
+
}
|
|
116
|
+
// --- handler -----------------------------------------------------------------
|
|
117
|
+
export async function handleAgentConfig(req, res, rawAgentId) {
|
|
118
|
+
if (req.method !== "GET" && req.method !== "PUT") {
|
|
119
|
+
return json(res, 405, { error: "Method Not Allowed" });
|
|
120
|
+
}
|
|
121
|
+
if (!extractBearerToken(req)) {
|
|
122
|
+
return json(res, 401, { error: "Unauthorized: bearer token mismatch" });
|
|
123
|
+
}
|
|
124
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
125
|
+
if (req.method === "GET") {
|
|
126
|
+
return json(res, 200, { ok: true, ...buildConfigView(agentId) });
|
|
127
|
+
}
|
|
128
|
+
// PUT — partial patch.
|
|
129
|
+
const body = await readJsonBody(req);
|
|
130
|
+
if (!body)
|
|
131
|
+
return json(res, 400, { error: "Invalid or missing JSON body" });
|
|
132
|
+
const model = readPatch(body, "model", coerceModel);
|
|
133
|
+
const thinkingDefault = readPatch(body, "thinkingDefault", (r) => readString(r));
|
|
134
|
+
const tools = readPatch(body, "tools", coerceTools);
|
|
135
|
+
const skills = readPatch(body, "skills", coerceSkills);
|
|
136
|
+
if ("skills" in body && body.skills !== null && !Array.isArray(body.skills)) {
|
|
137
|
+
return json(res, 400, {
|
|
138
|
+
error: "skills must be an array of skill ids, [] to disable all, or null to inherit defaults",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (!model.sent && !thinkingDefault.sent && !tools.sent && !skills.sent) {
|
|
142
|
+
return json(res, 400, {
|
|
143
|
+
error: "No editable fields provided (model, thinkingDefault, tools, skills)",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const upgrade = getUpgradeRuntime();
|
|
147
|
+
if (!upgrade)
|
|
148
|
+
return json(res, 503, { error: "Config write runtime unavailable" });
|
|
149
|
+
const log = createFridayNextLogger("agent-config");
|
|
150
|
+
try {
|
|
151
|
+
await upgrade.mutateConfigFile({
|
|
152
|
+
afterWrite: { mode: "auto" },
|
|
153
|
+
mutate: (draftRaw) => {
|
|
154
|
+
const draft = draftRaw;
|
|
155
|
+
const agents = (draft.agents ??= {});
|
|
156
|
+
const list = (agents.list ??= []);
|
|
157
|
+
let entry = list.find((a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId);
|
|
158
|
+
if (!entry) {
|
|
159
|
+
// Implicit agent (e.g. "main") with no list entry yet — create a bare one.
|
|
160
|
+
// Never set `default: true`: that would change default-agent resolution.
|
|
161
|
+
entry = { id: agentId };
|
|
162
|
+
list.push(entry);
|
|
163
|
+
}
|
|
164
|
+
applyField(entry, "model", model);
|
|
165
|
+
applyField(entry, "thinkingDefault", thinkingDefault);
|
|
166
|
+
applyField(entry, "tools", tools);
|
|
167
|
+
applyField(entry, "skills", skills);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
173
|
+
log.error(`agent config write failed for "${agentId}": ${msg}`);
|
|
174
|
+
return json(res, 500, { error: "Failed to write agent config", detail: msg });
|
|
175
|
+
}
|
|
176
|
+
log.info(`agent config updated for "${agentId}"`);
|
|
177
|
+
return json(res, 200, { ok: true, ...buildConfigView(agentId) });
|
|
178
|
+
}
|
|
179
|
+
/** Apply a patch: clear → delete the key; set → assign; not sent → leave as-is. */
|
|
180
|
+
function applyField(entry, key, patch) {
|
|
181
|
+
if (!patch.sent)
|
|
182
|
+
return;
|
|
183
|
+
if (patch.clear) {
|
|
184
|
+
delete entry[key];
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
entry[key] = patch.value;
|
|
188
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET/PUT /friday-next/agents/{id}/files[/{name}]
|
|
3
|
+
*
|
|
4
|
+
* Reads and edits an agent's core workspace files — the same whitelist ControlUI
|
|
5
|
+
* exposes (AGENTS/IDENTITY/SOUL/TOOLS/MEMORY/USER/HEARTBEAT/BOOTSTRAP.md). These
|
|
6
|
+
* are plain workspace files, not config: written directly via Node fs into the
|
|
7
|
+
* dir resolved by `api.runtime.agent.resolveAgentWorkspaceDir` (the same call
|
|
8
|
+
* agents-list uses to read IDENTITY.md). No config mutation, no gateway restart —
|
|
9
|
+
* the agent re-reads them on its next run.
|
|
10
|
+
*
|
|
11
|
+
* - GET /agents/{id}/files → status of every whitelist file
|
|
12
|
+
* - GET /agents/{id}/files/{name} → one file's content
|
|
13
|
+
* - PUT /agents/{id}/files/{name} → write one file (body: { content })
|
|
14
|
+
*
|
|
15
|
+
* Security: the file name MUST be in the whitelist (no path traversal), and the
|
|
16
|
+
* resolved path is re-checked to stay inside the workspace dir as defense in depth.
|
|
17
|
+
*/
|
|
18
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
19
|
+
/** Core workspace files an agent edits, mirroring ControlUI's `agents.files` whitelist. */
|
|
20
|
+
export declare const CORE_AGENT_FILES: readonly ["AGENTS.md", "IDENTITY.md", "SOUL.md", "TOOLS.md", "MEMORY.md", "USER.md", "HEARTBEAT.md", "BOOTSTRAP.md"];
|
|
21
|
+
export declare function handleAgentFiles(req: IncomingMessage, res: ServerResponse, rawAgentId: string, fileName: string | undefined): Promise<boolean>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET/PUT /friday-next/agents/{id}/files[/{name}]
|
|
3
|
+
*
|
|
4
|
+
* Reads and edits an agent's core workspace files — the same whitelist ControlUI
|
|
5
|
+
* exposes (AGENTS/IDENTITY/SOUL/TOOLS/MEMORY/USER/HEARTBEAT/BOOTSTRAP.md). These
|
|
6
|
+
* are plain workspace files, not config: written directly via Node fs into the
|
|
7
|
+
* dir resolved by `api.runtime.agent.resolveAgentWorkspaceDir` (the same call
|
|
8
|
+
* agents-list uses to read IDENTITY.md). No config mutation, no gateway restart —
|
|
9
|
+
* the agent re-reads them on its next run.
|
|
10
|
+
*
|
|
11
|
+
* - GET /agents/{id}/files → status of every whitelist file
|
|
12
|
+
* - GET /agents/{id}/files/{name} → one file's content
|
|
13
|
+
* - PUT /agents/{id}/files/{name} → write one file (body: { content })
|
|
14
|
+
*
|
|
15
|
+
* Security: the file name MUST be in the whitelist (no path traversal), and the
|
|
16
|
+
* resolved path is re-checked to stay inside the workspace dir as defense in depth.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
21
|
+
import { normalizeAgentId } from "../../agent-id.js";
|
|
22
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
23
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
24
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
25
|
+
/** Core workspace files an agent edits, mirroring ControlUI's `agents.files` whitelist. */
|
|
26
|
+
export const CORE_AGENT_FILES = [
|
|
27
|
+
"AGENTS.md",
|
|
28
|
+
"IDENTITY.md",
|
|
29
|
+
"SOUL.md",
|
|
30
|
+
"TOOLS.md",
|
|
31
|
+
"MEMORY.md",
|
|
32
|
+
"USER.md",
|
|
33
|
+
"HEARTBEAT.md",
|
|
34
|
+
"BOOTSTRAP.md",
|
|
35
|
+
];
|
|
36
|
+
const CORE_FILE_SET = new Set(CORE_AGENT_FILES);
|
|
37
|
+
/** Max core-file size on write (256 KiB) — these are prompts, not data dumps. */
|
|
38
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
39
|
+
function json(res, status, body) {
|
|
40
|
+
res.statusCode = status;
|
|
41
|
+
res.setHeader("Content-Type", "application/json");
|
|
42
|
+
res.end(JSON.stringify(body));
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/** Resolve the agent's workspace dir, or undefined if the runtime can't. */
|
|
46
|
+
function resolveWorkspace(agentId) {
|
|
47
|
+
const rt = getFridayAgentForwardRuntime();
|
|
48
|
+
if (!rt?.resolveAgentWorkspaceDir)
|
|
49
|
+
return undefined;
|
|
50
|
+
try {
|
|
51
|
+
const dir = rt.resolveAgentWorkspaceDir(rt.getConfig(), agentId);
|
|
52
|
+
return dir || undefined;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Whitelisted, traversal-safe absolute path for `name` inside `workspace`, or null. */
|
|
59
|
+
function safeFilePath(workspace, name) {
|
|
60
|
+
if (!CORE_FILE_SET.has(name))
|
|
61
|
+
return null;
|
|
62
|
+
const resolved = path.resolve(workspace, name);
|
|
63
|
+
// Defense in depth: the resolved path must sit directly inside the workspace.
|
|
64
|
+
if (path.dirname(resolved) !== path.resolve(workspace))
|
|
65
|
+
return null;
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
export async function handleAgentFiles(req, res, rawAgentId, fileName) {
|
|
69
|
+
const method = req.method;
|
|
70
|
+
if (method !== "GET" && method !== "PUT") {
|
|
71
|
+
return json(res, 405, { error: "Method Not Allowed" });
|
|
72
|
+
}
|
|
73
|
+
if (!extractBearerToken(req)) {
|
|
74
|
+
return json(res, 401, { error: "Unauthorized: bearer token mismatch" });
|
|
75
|
+
}
|
|
76
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
77
|
+
const workspace = resolveWorkspace(agentId);
|
|
78
|
+
if (!workspace)
|
|
79
|
+
return json(res, 503, { error: "Agent workspace not resolvable" });
|
|
80
|
+
// GET /files — list whitelist status.
|
|
81
|
+
if (method === "GET" && !fileName) {
|
|
82
|
+
const files = CORE_AGENT_FILES.map((name) => {
|
|
83
|
+
try {
|
|
84
|
+
const stat = fs.statSync(path.join(workspace, name));
|
|
85
|
+
return { name, exists: true, bytes: stat.size };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { name, exists: false, bytes: 0 };
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return json(res, 200, { ok: true, id: agentId, files });
|
|
92
|
+
}
|
|
93
|
+
if (!fileName || !CORE_FILE_SET.has(fileName)) {
|
|
94
|
+
return json(res, 400, {
|
|
95
|
+
error: `Unknown core file; allowed: ${CORE_AGENT_FILES.join(", ")}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const filePath = safeFilePath(workspace, fileName);
|
|
99
|
+
if (!filePath)
|
|
100
|
+
return json(res, 400, { error: "Invalid file name" });
|
|
101
|
+
if (method === "GET") {
|
|
102
|
+
try {
|
|
103
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
104
|
+
return json(res, 200, { ok: true, id: agentId, name: fileName, exists: true, content });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return json(res, 200, { ok: true, id: agentId, name: fileName, exists: false, content: "" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// PUT — write content.
|
|
111
|
+
const body = await readJsonBody(req);
|
|
112
|
+
if (!body || typeof body.content !== "string") {
|
|
113
|
+
return json(res, 400, { error: "Missing required field: content (string)" });
|
|
114
|
+
}
|
|
115
|
+
const content = body.content;
|
|
116
|
+
if (Buffer.byteLength(content, "utf-8") > MAX_FILE_BYTES) {
|
|
117
|
+
return json(res, 413, { error: `content exceeds ${MAX_FILE_BYTES} bytes` });
|
|
118
|
+
}
|
|
119
|
+
const log = createFridayNextLogger("agent-files");
|
|
120
|
+
try {
|
|
121
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
122
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
126
|
+
log.error(`write ${fileName} for "${agentId}" failed: ${msg}`);
|
|
127
|
+
return json(res, 500, { error: "Failed to write file", detail: msg });
|
|
128
|
+
}
|
|
129
|
+
log.info(`wrote ${fileName} for "${agentId}" (${Buffer.byteLength(content, "utf-8")} bytes)`);
|
|
130
|
+
return json(res, 200, {
|
|
131
|
+
ok: true,
|
|
132
|
+
id: agentId,
|
|
133
|
+
name: fileName,
|
|
134
|
+
exists: true,
|
|
135
|
+
bytes: Buffer.byteLength(content, "utf-8"),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/agents/{id}/tools/catalog
|
|
3
|
+
*
|
|
4
|
+
* Returns the agent's full tool catalog (core + plugin tools, grouped by category,
|
|
5
|
+
* with descriptions, profiles, and per-tool effective `enabled`/`inProfile` state) for
|
|
6
|
+
* the app's toolbox editor — mirroring ControlUI. Edits are saved via the existing
|
|
7
|
+
* `PUT /agents/{id}/config` (tools.{profile,allow,alsoAllow,deny}).
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
+
export declare function handleAgentToolsCatalog(req: IncomingMessage, res: ServerResponse, rawAgentId: string): Promise<boolean>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/agents/{id}/tools/catalog
|
|
3
|
+
*
|
|
4
|
+
* Returns the agent's full tool catalog (core + plugin tools, grouped by category,
|
|
5
|
+
* with descriptions, profiles, and per-tool effective `enabled`/`inProfile` state) for
|
|
6
|
+
* the app's toolbox editor — mirroring ControlUI. Edits are saved via the existing
|
|
7
|
+
* `PUT /agents/{id}/config` (tools.{profile,allow,alsoAllow,deny}).
|
|
8
|
+
*/
|
|
9
|
+
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
10
|
+
import { normalizeAgentId } from "../../agent-id.js";
|
|
11
|
+
import { buildAgentToolsCatalog } from "../../tool-catalog.js";
|
|
12
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
13
|
+
function json(res, status, body) {
|
|
14
|
+
res.statusCode = status;
|
|
15
|
+
res.setHeader("Content-Type", "application/json");
|
|
16
|
+
res.end(JSON.stringify(body));
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
export async function handleAgentToolsCatalog(req, res, rawAgentId) {
|
|
20
|
+
if (req.method !== "GET") {
|
|
21
|
+
return json(res, 405, { error: "Method Not Allowed" });
|
|
22
|
+
}
|
|
23
|
+
if (!extractBearerToken(req)) {
|
|
24
|
+
return json(res, 401, { error: "Unauthorized: bearer token mismatch" });
|
|
25
|
+
}
|
|
26
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
27
|
+
const cfg = getFridayAgentForwardRuntime()?.getConfig();
|
|
28
|
+
const catalog = await buildAgentToolsCatalog(cfg, agentId);
|
|
29
|
+
if (!catalog) {
|
|
30
|
+
return json(res, 503, { error: "Tool catalog unavailable" });
|
|
31
|
+
}
|
|
32
|
+
return json(res, 200, { ok: true, id: agentId, ...catalog });
|
|
33
|
+
}
|
|
@@ -2,25 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { getFridayAgentForwardRuntime, } from "../../agent-forward-runtime.js";
|
|
4
4
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
|
-
|
|
6
|
-
/** Agent ids already in path/shell-safe form skip the slug rewrite below. */
|
|
7
|
-
const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
|
|
8
|
-
/**
|
|
9
|
-
* Mirror of OpenClaw's `normalizeAgentId` (src/routing/session-key.ts): trim,
|
|
10
|
-
* lowercase, keep path/shell-safe. Empty → "main".
|
|
11
|
-
*/
|
|
12
|
-
function normalizeAgentId(value) {
|
|
13
|
-
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
14
|
-
if (!trimmed)
|
|
15
|
-
return DEFAULT_AGENT_ID;
|
|
16
|
-
const lowered = trimmed.toLowerCase();
|
|
17
|
-
if (SAFE_AGENT_ID.test(lowered))
|
|
18
|
-
return lowered;
|
|
19
|
-
return (lowered
|
|
20
|
-
.replace(/[^a-z0-9_-]+/g, "-")
|
|
21
|
-
.replace(/^-+|-+$/g, "")
|
|
22
|
-
.slice(0, 64) || DEFAULT_AGENT_ID);
|
|
23
|
-
}
|
|
5
|
+
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../agent-id.js";
|
|
24
6
|
/** Extract a primary model ref from the `model` field (string or {primary,...}). */
|
|
25
7
|
function resolvePrimaryModel(model) {
|
|
26
8
|
if (typeof model === "string")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { abortRunForSessionKey } from "../../agent/abort-run.js";
|
|
2
|
+
import { getRunRoute } from "../../run-metadata.js";
|
|
2
3
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
3
4
|
import { readJsonBody } from "../middleware/body.js";
|
|
4
5
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
@@ -18,16 +19,23 @@ export async function handleCancel(req, res) {
|
|
|
18
19
|
}
|
|
19
20
|
const body = await readJsonBody(req);
|
|
20
21
|
const runId = typeof body?.runId === "string" ? body.runId.trim() : "";
|
|
21
|
-
|
|
22
|
+
// sessionKey is the primary identifier (one active run per session); runId is a
|
|
23
|
+
// back-compat fallback for older apps — resolve it to a sessionKey via the run route.
|
|
24
|
+
const sessionKey = (typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "") ||
|
|
25
|
+
(runId ? (getRunRoute(runId)?.sessionKey?.trim() ?? "") : "");
|
|
26
|
+
if (!sessionKey && !runId) {
|
|
22
27
|
res.statusCode = 400;
|
|
23
28
|
res.setHeader("Content-Type", "application/json");
|
|
24
|
-
res.end(JSON.stringify({ error: "Missing runId" }));
|
|
29
|
+
res.end(JSON.stringify({ error: "Missing sessionKey or runId" }));
|
|
25
30
|
return true;
|
|
26
31
|
}
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
const result = sessionKey
|
|
33
|
+
? await abortRunForSessionKey(sessionKey)
|
|
34
|
+
: { aborted: false, drained: false };
|
|
35
|
+
if (runId)
|
|
36
|
+
sseEmitter.untrackRun(runId);
|
|
29
37
|
res.statusCode = 200;
|
|
30
38
|
res.setHeader("Content-Type", "application/json");
|
|
31
|
-
res.end(JSON.stringify({ ok: true, runId, cancelled: true }));
|
|
39
|
+
res.end(JSON.stringify({ ok: true, sessionKey, runId, cancelled: true, ...result }));
|
|
32
40
|
return true;
|
|
33
41
|
}
|
|
@@ -92,7 +92,9 @@ export async function handleDeviceApprove(req, res) {
|
|
|
92
92
|
if (approved.status === "forbidden") {
|
|
93
93
|
res.statusCode = 403;
|
|
94
94
|
res.setHeader("Content-Type", "application/json");
|
|
95
|
-
res.end(JSON.stringify({
|
|
95
|
+
res.end(JSON.stringify({
|
|
96
|
+
error: `Device approval forbidden: ${approved.reason ?? "unknown"}`,
|
|
97
|
+
}));
|
|
96
98
|
return true;
|
|
97
99
|
}
|
|
98
100
|
res.statusCode = 200;
|
|
@@ -51,7 +51,10 @@ function tryDecodeURIComponent(segment) {
|
|
|
51
51
|
* Safe Content-Disposition: strip CR/LF/quotes from basename; add RFC 5987 filename* for Unicode.
|
|
52
52
|
*/
|
|
53
53
|
function contentDispositionInline(filename) {
|
|
54
|
-
const base = path
|
|
54
|
+
const base = path
|
|
55
|
+
.basename(filename)
|
|
56
|
+
.replace(/[\r\n"]/g, "_")
|
|
57
|
+
.replace(/\\/g, "_") || "file";
|
|
55
58
|
const ascii = /^[\x20-\x7E]*$/.test(base) ? base : "file";
|
|
56
59
|
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base)}`;
|
|
57
60
|
}
|
|
@@ -62,9 +65,7 @@ function sendBuffer(req, res, buffer, mimeType, filename) {
|
|
|
62
65
|
const total = buffer.length;
|
|
63
66
|
const disposition = contentDispositionInline(filename);
|
|
64
67
|
const rangeRaw = req.headers.range;
|
|
65
|
-
const range = typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim())
|
|
66
|
-
? rangeRaw.trim()
|
|
67
|
-
: undefined;
|
|
68
|
+
const range = typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim()) ? rangeRaw.trim() : undefined;
|
|
68
69
|
res.setHeader("Accept-Ranges", "bytes");
|
|
69
70
|
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
70
71
|
res.setHeader("Content-Type", mimeType);
|
|
@@ -178,10 +179,7 @@ export async function handleFilesDownload(req, res) {
|
|
|
178
179
|
// fileId may include an extension (e.g. "uuid.png") — strip it to get the base id
|
|
179
180
|
const baseId = fileToken.replace(/\.[^.]+$/, "");
|
|
180
181
|
const mediaDir = path.join(os.homedir(), ".openclaw", "media", "inbound");
|
|
181
|
-
const candidates = [
|
|
182
|
-
path.join(mediaDir, baseId),
|
|
183
|
-
path.join(mediaDir, fileToken),
|
|
184
|
-
];
|
|
182
|
+
const candidates = [path.join(mediaDir, baseId), path.join(mediaDir, fileToken)];
|
|
185
183
|
for (const filePath of candidates) {
|
|
186
184
|
if (fs.existsSync(filePath)) {
|
|
187
185
|
try {
|
|
@@ -17,6 +17,18 @@ export interface StoredFile {
|
|
|
17
17
|
path: string;
|
|
18
18
|
createdAt: number;
|
|
19
19
|
}
|
|
20
|
+
/** Clear the in-memory file index. Test-only: simulates a gateway restart. */
|
|
21
|
+
export declare function clearFileIndexForTest(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Remember the original upload filename for an inbound media file.
|
|
24
|
+
*
|
|
25
|
+
* When a user sends an attachment, core's media-store copies it to
|
|
26
|
+
* `~/.openclaw/media/inbound/<uuid>` — a bare uuid with no extension and no original
|
|
27
|
+
* name — and the transcript records THAT path. So on history rebuild the original name
|
|
28
|
+
* is unrecoverable. We stash it here (keyed by the inbound basename, reusing the sidecar
|
|
29
|
+
* scheme but inside our own attachments dir) at send time, while we still know it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function rememberInboundMediaName(inboundPath: string, filename: string, mimeType: string): void;
|
|
20
32
|
/**
|
|
21
33
|
* Read a file from `attachments/` by URL path token (disk basename).
|
|
22
34
|
* Used when the in-memory index was cleared after a gateway restart.
|
|
@@ -24,7 +36,10 @@ export interface StoredFile {
|
|
|
24
36
|
export declare function readAttachmentFileFromDisk(fileToken: string): {
|
|
25
37
|
buffer: Buffer;
|
|
26
38
|
mimeType: string;
|
|
39
|
+
/** Original display/download filename (from sidecar; falls back to the on-disk basename). */
|
|
27
40
|
filename: string;
|
|
41
|
+
/** On-disk basename / urlToken — use this to build `/friday-next/files/{token}` URLs. */
|
|
42
|
+
diskName: string;
|
|
28
43
|
} | null;
|
|
29
44
|
/**
|
|
30
45
|
* Copy a local file into `attachments/` and register it (no full-buffer read for the copy path).
|
|
@@ -54,6 +69,7 @@ export declare function getExternalFileSourceByUrlToken(token: string): string |
|
|
|
54
69
|
export declare function readFile(id: string): {
|
|
55
70
|
buffer: Buffer | null;
|
|
56
71
|
mimeType: string;
|
|
72
|
+
filename?: string;
|
|
57
73
|
};
|
|
58
74
|
/**
|
|
59
75
|
* Copy a file from a local filesystem path into the Friday Next channel file store
|