@agentprojectcontext/apx 1.53.5 → 1.53.7
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/package.json +1 -1
- package/src/core/channels/telegram/ask-callbacks.js +1 -0
- package/src/core/channels/telegram/dispatch.js +7 -1
- package/src/core/channels/telegram/reply.js +3 -1
- package/src/core/config/index.js +11 -1
- package/src/core/confirmation/adapters/telegram.js +16 -4
- package/src/core/confirmation/pending-store.js +30 -4
- package/src/core/http-tools/fetch.js +56 -6
- package/src/core/identity/telegram.js +11 -6
- package/src/host/daemon/api/admin.js +39 -9
- package/src/host/daemon/api/shared.js +17 -21
- package/src/host/daemon/api/top-level.js +4 -2
- package/src/host/daemon/desktop-ws.js +3 -2
- package/src/interfaces/web/dist/assets/{index-DJpij6dQ.js → index-Bks35ks-.js} +24 -24
- package/src/interfaces/web/dist/assets/{index-DJpij6dQ.js.map → index-Bks35ks-.js.map} +1 -1
- package/src/interfaces/web/dist/index.html +1 -1
- package/src/interfaces/web/package-lock.json +15 -15
- package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +53 -6
package/package.json
CHANGED
|
@@ -56,12 +56,17 @@ export async function handleUpdate(self, u) {
|
|
|
56
56
|
// in-memory globalConfig in place so later messages in this daemon session
|
|
57
57
|
// see the update. The resulting block is injected into whichever agent
|
|
58
58
|
// answers (super-agent OR a routed project agent).
|
|
59
|
-
const { sender } = registerSender({
|
|
59
|
+
const { sender, claimedOwner } = registerSender({
|
|
60
60
|
cfg: self.globalConfig,
|
|
61
61
|
channelName: self.channel.name,
|
|
62
62
|
from: msg.from,
|
|
63
63
|
chatType: msg.chat?.type,
|
|
64
64
|
});
|
|
65
|
+
if (claimedOwner) {
|
|
66
|
+
// Trust-on-first-use: this sender just became owner of a previously
|
|
67
|
+
// ownerless private channel. Log it so an unexpected claim is visible.
|
|
68
|
+
self.log(`telegram[${self.channel.name}] owner claimed by user_id=${msg.from?.id} (${author}) — verify this is you`);
|
|
69
|
+
}
|
|
65
70
|
const relationshipBlock = buildRelationshipBlock(sender);
|
|
66
71
|
// Role-based tool gating for the super-agent path (guests → no tools).
|
|
67
72
|
const allowedTools = resolveAllowedTools(self.globalConfig, sender);
|
|
@@ -287,6 +292,7 @@ export async function handleUpdate(self, u) {
|
|
|
287
292
|
previousMessages,
|
|
288
293
|
target,
|
|
289
294
|
author,
|
|
295
|
+
authorId: msg.from?.id,
|
|
290
296
|
relationshipBlock,
|
|
291
297
|
allowedTools,
|
|
292
298
|
contextNote: slashed.handled ? slashed.contextNote : "",
|
|
@@ -99,13 +99,15 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
|
|
|
99
99
|
* inherit it. Throws on failure (caller decides abort-vs-error handling).
|
|
100
100
|
*/
|
|
101
101
|
export function runTelegramSuperAgent(self, {
|
|
102
|
-
chat_id, prompt, previousMessages, target, author, relationshipBlock,
|
|
102
|
+
chat_id, prompt, previousMessages, target, author, authorId, relationshipBlock,
|
|
103
103
|
allowedTools, contextNote, signal, onEvent,
|
|
104
104
|
}) {
|
|
105
105
|
const confirmAdapter = createTelegramConfirmAdapter({
|
|
106
106
|
token: resolveBotToken(self.channel),
|
|
107
107
|
chatId: chat_id,
|
|
108
108
|
pendingStore: getConfirmStore(),
|
|
109
|
+
// Only the user who triggered this turn may answer its confirmations.
|
|
110
|
+
guardActorId: authorId ?? null,
|
|
109
111
|
});
|
|
110
112
|
return runSuperAgent({
|
|
111
113
|
globalConfig: self.globalConfig,
|
package/src/core/config/index.js
CHANGED
|
@@ -218,6 +218,10 @@ function backupConfigBeforeLoss() {
|
|
|
218
218
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
219
219
|
const backup = `${CONFIG_PATH}.${ts}.bak`;
|
|
220
220
|
fs.copyFileSync(CONFIG_PATH, backup);
|
|
221
|
+
// config.json holds API keys + bot tokens — the backup must not be more
|
|
222
|
+
// permissive than the original. copyFileSync inherits the source mode on
|
|
223
|
+
// some platforms but not reliably, so pin it.
|
|
224
|
+
try { fs.chmodSync(backup, 0o600); } catch {}
|
|
221
225
|
return backup;
|
|
222
226
|
} catch {
|
|
223
227
|
return null;
|
|
@@ -280,8 +284,14 @@ export function writeConfig(cfg) {
|
|
|
280
284
|
if (cfg?._allowClear) delete cfg._allowClear;
|
|
281
285
|
|
|
282
286
|
const tmp = `${CONFIG_PATH}.tmp`;
|
|
283
|
-
|
|
287
|
+
// 0o600: config.json stores provider API keys and the Telegram bot token.
|
|
288
|
+
// Without an explicit mode it lands at 0o644 (world-readable) — any local
|
|
289
|
+
// user could read the secrets. Match daemon.token / clients.json.
|
|
290
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
284
291
|
fs.renameSync(tmp, CONFIG_PATH);
|
|
292
|
+
// writeFileSync's mode only applies when the tmp file is created; if it
|
|
293
|
+
// pre-existed with looser perms the bits stick. Pin it after the rename.
|
|
294
|
+
try { fs.chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
285
295
|
}
|
|
286
296
|
|
|
287
297
|
// Normalise `model_fallback` to the new format (`models` as an ordered array
|
|
@@ -31,12 +31,15 @@ import { sendMessage, answerCallbackQuery as apiAnswerCallbackQuery, editMessage
|
|
|
31
31
|
const TIMEOUT_MS = 60_000; // 60 s — long enough for a human, short enough to not block forever
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* @param {{ token: string, chatId: string|number, pendingStore: ConfirmationPendingStore }} opts
|
|
34
|
+
* @param {{ token: string, chatId: string|number, pendingStore: ConfirmationPendingStore, guardActorId?: string|number|null }} opts
|
|
35
35
|
* @returns {{ requestConfirmation, handleCallbackQuery }}
|
|
36
36
|
*/
|
|
37
|
-
export function createTelegramConfirmAdapter({ token, chatId, pendingStore }) {
|
|
37
|
+
export function createTelegramConfirmAdapter({ token, chatId, pendingStore, guardActorId = null }) {
|
|
38
38
|
async function requestConfirmation(tool, _args, description) {
|
|
39
|
-
|
|
39
|
+
// Bind this confirmation to the user who triggered the turn. In a group
|
|
40
|
+
// chat the keyboard is visible to everyone, so without this any member
|
|
41
|
+
// could tap "Yes" and approve the initiator's destructive action.
|
|
42
|
+
const { correlationId, promise } = pendingStore.create({ timeoutMs: TIMEOUT_MS, guardActorId });
|
|
40
43
|
|
|
41
44
|
await sendConfirmKeyboard(token, chatId, description, correlationId, TIMEOUT_MS);
|
|
42
45
|
|
|
@@ -52,12 +55,21 @@ export function createTelegramConfirmAdapter({ token, chatId, pendingStore }) {
|
|
|
52
55
|
|
|
53
56
|
const [, correlationId, answer] = match;
|
|
54
57
|
const confirmed = answer === "yes";
|
|
58
|
+
const presserId = callbackQuery.from?.id ?? null;
|
|
59
|
+
|
|
60
|
+
// Reject a bystander's press without consuming the pending entry or wiping
|
|
61
|
+
// the keyboard — the authorized initiator can still answer. We still return
|
|
62
|
+
// true (the callback matched our namespace and is handled).
|
|
63
|
+
if (!pendingStore.isActorAllowed(correlationId, presserId)) {
|
|
64
|
+
await answerCallbackQuery(token, callbackQuery.id, "⛔ Not your confirmation");
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
55
67
|
|
|
56
68
|
// ACK the callback immediately to clear the loading spinner on the button.
|
|
57
69
|
// Fire-and-forget — a slow ACK is annoying but not fatal.
|
|
58
70
|
await answerCallbackQuery(token, callbackQuery.id, confirmed ? "✅ Confirmed" : "❌ Cancelled");
|
|
59
71
|
|
|
60
|
-
const resolved = pendingStore.resolve(correlationId, confirmed);
|
|
72
|
+
const resolved = pendingStore.resolve(correlationId, confirmed, presserId);
|
|
61
73
|
|
|
62
74
|
// If not resolved, the entry timed out or the process restarted — show "Expired"
|
|
63
75
|
// so the user knows the button is no longer actionable.
|
|
@@ -28,30 +28,56 @@ export class ConfirmationPendingStore {
|
|
|
28
28
|
* - correlationId: embed in the reply (button callback_data, SSE event…)
|
|
29
29
|
* - promise: resolves to true (confirmed) or false (denied / timeout)
|
|
30
30
|
*
|
|
31
|
+
* `guardActorId` (optional): when set, only resolve() calls that supply a
|
|
32
|
+
* matching actorId may answer this confirmation. Channels where the reply is
|
|
33
|
+
* visible to more than the initiator (a Telegram group's inline keyboard)
|
|
34
|
+
* pass the initiator's id so a bystander can't approve someone else's action.
|
|
35
|
+
* Channels whose transport is already 1:1/authenticated (web SSE + token,
|
|
36
|
+
* terminal) leave it null and behave exactly as before.
|
|
37
|
+
*
|
|
31
38
|
* After timeoutMs with no response the promise auto-resolves to false.
|
|
32
39
|
*/
|
|
33
|
-
create({ timeoutMs = 30_000 } = {}) {
|
|
40
|
+
create({ timeoutMs = 30_000, guardActorId = null } = {}) {
|
|
34
41
|
const correlationId = generateId();
|
|
42
|
+
const guard = guardActorId == null ? null : String(guardActorId);
|
|
35
43
|
|
|
36
44
|
const promise = new Promise((resolve) => {
|
|
37
45
|
const timer = setTimeout(() => {
|
|
38
46
|
this._pending.delete(correlationId);
|
|
39
47
|
resolve(false);
|
|
40
48
|
}, timeoutMs);
|
|
41
|
-
this._pending.set(correlationId, { resolve, timer });
|
|
49
|
+
this._pending.set(correlationId, { resolve, timer, guardActorId: guard });
|
|
42
50
|
});
|
|
43
51
|
|
|
44
52
|
return { correlationId, promise };
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Is `actorId` allowed to answer this confirmation? True when the entry is
|
|
57
|
+
* unknown (let resolve() report "expired"), unguarded, or the actor matches
|
|
58
|
+
* the guard. Lets an adapter reject a bystander's press WITHOUT consuming the
|
|
59
|
+
* pending entry, so the real initiator can still respond.
|
|
60
|
+
*/
|
|
61
|
+
isActorAllowed(correlationId, actorId) {
|
|
62
|
+
const entry = this._pending.get(correlationId);
|
|
63
|
+
if (!entry) return true;
|
|
64
|
+
if (entry.guardActorId == null) return true;
|
|
65
|
+
return actorId != null && String(actorId) === entry.guardActorId;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
/**
|
|
48
69
|
* Resolve a pending confirmation.
|
|
49
70
|
* Returns true if found and resolved, false if not found (timed out, already
|
|
50
|
-
* resolved,
|
|
71
|
+
* resolved, stale button after a restart) OR if the entry is guarded and
|
|
72
|
+
* `actorId` doesn't match — in the mismatch case the entry is preserved so
|
|
73
|
+
* the authorized initiator can still answer.
|
|
51
74
|
*/
|
|
52
|
-
resolve(correlationId, value) {
|
|
75
|
+
resolve(correlationId, value, actorId) {
|
|
53
76
|
const entry = this._pending.get(correlationId);
|
|
54
77
|
if (!entry) return false;
|
|
78
|
+
if (entry.guardActorId != null && (actorId == null || String(actorId) !== entry.guardActorId)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
55
81
|
clearTimeout(entry.timer);
|
|
56
82
|
this._pending.delete(correlationId);
|
|
57
83
|
entry.resolve(value);
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
// Fetch resolver
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
|
+
import dns from "node:dns/promises";
|
|
19
|
+
import net from "node:net";
|
|
20
|
+
|
|
18
21
|
let _fetch = null;
|
|
19
22
|
|
|
20
23
|
async function getFetch() {
|
|
@@ -32,18 +35,65 @@ async function getFetch() {
|
|
|
32
35
|
const DEFAULT_TIMEOUT = 30000;
|
|
33
36
|
const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5MB
|
|
34
37
|
|
|
35
|
-
// Block
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
// Block cloud metadata endpoints by name (they may resolve to public-looking
|
|
39
|
+
// IPs on some providers). IP-range blocking is done on the RESOLVED address
|
|
40
|
+
// below, not on the literal host string.
|
|
41
|
+
const BLOCKED_HOST_RE = /^(metadata\.google\.internal\.?|metadata\.goog\.?)$/i;
|
|
42
|
+
|
|
43
|
+
// True for an IPv4 string in a loopback / private / link-local / CGNAT range.
|
|
44
|
+
// Malformed input returns true (fail closed) — validateUrl only feeds this
|
|
45
|
+
// addresses from dns.lookup / net.isIP, so a non-parse means "don't trust it".
|
|
46
|
+
function isPrivateIpv4(ip) {
|
|
47
|
+
const parts = ip.split(".").map((n) => Number(n));
|
|
48
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
|
|
49
|
+
const [a, b] = parts;
|
|
50
|
+
if (a === 0 || a === 127) return true; // this-host / loopback
|
|
51
|
+
if (a === 10) return true; // private
|
|
52
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
53
|
+
if (a === 192 && b === 168) return true; // private
|
|
54
|
+
if (a === 169 && b === 254) return true; // link-local (incl. cloud metadata 169.254.169.254)
|
|
55
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isBlockedAddress(addr) {
|
|
60
|
+
if (!addr) return true;
|
|
61
|
+
const ip = String(addr).toLowerCase();
|
|
62
|
+
const mapped = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); // IPv4-mapped IPv6
|
|
63
|
+
if (mapped) return isPrivateIpv4(mapped[1]);
|
|
64
|
+
const fam = net.isIP(ip);
|
|
65
|
+
if (fam === 4) return isPrivateIpv4(ip);
|
|
66
|
+
if (fam === 6) {
|
|
67
|
+
if (ip === "::1" || ip === "::") return true; // loopback / unspecified
|
|
68
|
+
if (ip.startsWith("fc") || ip.startsWith("fd")) return true; // ULA fc00::/7
|
|
69
|
+
if (/^fe[89ab]/.test(ip)) return true; // link-local fe80::/10
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true; // not a recognizable IP → fail closed
|
|
73
|
+
}
|
|
38
74
|
|
|
39
|
-
|
|
75
|
+
// SSRF guard. Resolving the host to concrete IPs (rather than pattern-matching
|
|
76
|
+
// the literal string) defeats DNS names that point at internal ranges AND
|
|
77
|
+
// numeric encodings like http://2130706433 (= 127.0.0.1) that a regex misses.
|
|
78
|
+
// Residual: a rebind between this lookup and the actual connect is still
|
|
79
|
+
// theoretically possible; pinning the resolved IP would need a custom agent.
|
|
80
|
+
async function validateUrl(rawUrl) {
|
|
40
81
|
let parsed;
|
|
41
82
|
try { parsed = new URL(rawUrl); } catch { throw new Error("Invalid URL"); }
|
|
42
83
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
43
84
|
throw new Error(`Protocol "${parsed.protocol}" is not allowed; use http or https`);
|
|
44
85
|
}
|
|
45
86
|
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
46
|
-
if (BLOCKED_HOST_RE.test(host)
|
|
87
|
+
if (BLOCKED_HOST_RE.test(host)) {
|
|
88
|
+
throw new Error(`Requests to private or link-local addresses are blocked`);
|
|
89
|
+
}
|
|
90
|
+
let addresses;
|
|
91
|
+
try {
|
|
92
|
+
addresses = await dns.lookup(host, { all: true });
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error("Could not resolve host");
|
|
95
|
+
}
|
|
96
|
+
if (!addresses.length || addresses.some(({ address }) => isBlockedAddress(address))) {
|
|
47
97
|
throw new Error(`Requests to private or link-local addresses are blocked`);
|
|
48
98
|
}
|
|
49
99
|
}
|
|
@@ -72,7 +122,7 @@ async function readBody(response, jsonHint) {
|
|
|
72
122
|
|
|
73
123
|
async function doRequest({ url, method = "GET", headers = {}, body = null, timeout_ms = DEFAULT_TIMEOUT, json = false } = {}) {
|
|
74
124
|
if (!url) throw new Error("url required");
|
|
75
|
-
validateUrl(url);
|
|
125
|
+
await validateUrl(url);
|
|
76
126
|
const fetch = await getFetch();
|
|
77
127
|
|
|
78
128
|
const controller = new AbortController();
|
|
@@ -55,16 +55,18 @@ export function resolveSender({ cfg, channelName, from, chatType }) {
|
|
|
55
55
|
* - owner → "*" (all tools)
|
|
56
56
|
* - guest → [] (no tools; text only)
|
|
57
57
|
* - a role defined in telegram.roles → its `tools` ("*" or an array)
|
|
58
|
-
* - any other named role with no definition →
|
|
59
|
-
*
|
|
58
|
+
* - any other named role with no definition → [] (fail closed: a typo'd or
|
|
59
|
+
* removed role must NOT silently grant every tool. Define the role in
|
|
60
|
+
* telegram.roles to grant access.)
|
|
60
61
|
* Returns "*" or an array of tool names.
|
|
61
62
|
*/
|
|
62
63
|
export function resolveAllowedTools(cfg, sender) {
|
|
63
64
|
if (sender?.isOwner) return "*";
|
|
64
65
|
const def = cfg?.telegram?.roles?.[sender?.role];
|
|
65
66
|
if (def && def.tools !== undefined) return def.tools;
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
// Fail closed: guests and any sender whose role isn't explicitly defined get
|
|
68
|
+
// no tools. Only the owner and configured roles receive capabilities.
|
|
69
|
+
return [];
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -108,7 +110,7 @@ export function registerSender({ cfg, channelName, from, chatType }) {
|
|
|
108
110
|
else if (!existing) kind = "guest";
|
|
109
111
|
else if (existing.last_seen?.slice(0, 10) !== now.slice(0, 10)) kind = "touch";
|
|
110
112
|
|
|
111
|
-
if (!kind) return { sender: base(), mutated: false };
|
|
113
|
+
if (!kind) return { sender: base(), mutated: false, claimedOwner: false };
|
|
112
114
|
|
|
113
115
|
if (kind === "claim") {
|
|
114
116
|
upsertTelegramChannel(disk, channelName, { owner_user_id: userId });
|
|
@@ -123,5 +125,8 @@ export function registerSender({ cfg, channelName, from, chatType }) {
|
|
|
123
125
|
upsertContact(disk, userId, { last_seen: now });
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
// Signal an owner-claim (trust-on-first-use) so the caller can surface it —
|
|
129
|
+
// whoever messages a fresh private channel first becomes owner, and that
|
|
130
|
+
// event must be auditable rather than silent.
|
|
131
|
+
return { sender: base(), mutated: true, claimedOwner: kind === "claim" };
|
|
127
132
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Both are auth-gated (the global middleware applies).
|
|
6
6
|
import { readConfig } from "#core/config/index.js";
|
|
7
|
-
import {
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import os from "node:os";
|
|
10
10
|
import path from "node:path";
|
|
@@ -57,22 +57,52 @@ export function register(app, { scheduler, plugins, config, registries }) {
|
|
|
57
57
|
// platform lacks a usable picker — or none is installed — the endpoint
|
|
58
58
|
// returns 501 so the frontend can fall back to the inline directory list.
|
|
59
59
|
app.get("/admin/fs/pick-dir", (req, res) => {
|
|
60
|
-
|
|
60
|
+
// The prompt is attacker-controllable (query string). NEVER interpolate it
|
|
61
|
+
// into a shell string — use execFile (no shell) with an argv array, and
|
|
62
|
+
// pass the prompt out-of-band via an env var so it can't break out of any
|
|
63
|
+
// quoting context in the picker's own scripting language.
|
|
64
|
+
const prompt = String(req.query.prompt || "Select a folder");
|
|
61
65
|
const platform = process.platform;
|
|
62
|
-
|
|
66
|
+
const env = { ...process.env, APX_PICK_PROMPT: prompt };
|
|
67
|
+
let file;
|
|
68
|
+
let args;
|
|
63
69
|
if (platform === "darwin") {
|
|
64
70
|
// try/end-try makes cancel exit with code 0 + empty stdout so we can
|
|
65
|
-
// distinguish "cancelled" from "no picker available".
|
|
66
|
-
|
|
71
|
+
// distinguish "cancelled" from "no picker available". `system attribute`
|
|
72
|
+
// reads the env var, so the prompt never touches the AppleScript source.
|
|
73
|
+
file = "osascript";
|
|
74
|
+
args = [
|
|
75
|
+
"-e", "try",
|
|
76
|
+
"-e", 'POSIX path of (choose folder with prompt (system attribute "APX_PICK_PROMPT"))',
|
|
77
|
+
"-e", "on error",
|
|
78
|
+
"-e", 'return ""',
|
|
79
|
+
"-e", "end try",
|
|
80
|
+
];
|
|
67
81
|
} else if (platform === "linux") {
|
|
68
|
-
|
|
82
|
+
// zenity takes the title as a plain argv value — no shell, no escaping.
|
|
83
|
+
file = "zenity";
|
|
84
|
+
args = ["--file-selection", "--directory", `--title=${prompt}`];
|
|
69
85
|
} else if (platform === "win32") {
|
|
70
|
-
|
|
86
|
+
// Reference the prompt via $env: inside PowerShell rather than splicing it
|
|
87
|
+
// into the -Command string.
|
|
88
|
+
file = "powershell";
|
|
89
|
+
args = [
|
|
90
|
+
"-NoProfile",
|
|
91
|
+
"-Command",
|
|
92
|
+
"$f = (New-Object -ComObject Shell.Application).BrowseForFolder(0, $env:APX_PICK_PROMPT, 0, 0); if ($f) { $f.Self.Path }",
|
|
93
|
+
];
|
|
71
94
|
} else {
|
|
72
95
|
return res.status(501).json({ error: "Native folder picker not supported on this platform" });
|
|
73
96
|
}
|
|
74
|
-
|
|
75
|
-
if (err)
|
|
97
|
+
execFile(file, args, { timeout: 5 * 60 * 1000, env }, (err, stdout) => {
|
|
98
|
+
if (err) {
|
|
99
|
+
// Picker binary absent (e.g. no zenity) → let the frontend fall back to
|
|
100
|
+
// the inline directory list instead of surfacing a hard 500.
|
|
101
|
+
if (err.code === "ENOENT") {
|
|
102
|
+
return res.status(501).json({ error: "Native folder picker not available" });
|
|
103
|
+
}
|
|
104
|
+
return res.status(500).json({ error: err.message });
|
|
105
|
+
}
|
|
76
106
|
const picked = (stdout || "").trim().replace(/[\r\n]+$/g, "");
|
|
77
107
|
if (!picked) return res.json({ cancelled: true });
|
|
78
108
|
res.json({ path: picked.replace(/\/+$/, "") });
|
|
@@ -10,6 +10,7 @@ import { readAgents } from "#core/apc/parser.js";
|
|
|
10
10
|
import { agentMemoryPath } from "#core/agent/memory.js";
|
|
11
11
|
import { apcMemoryFile } from "#core/apc/paths.js";
|
|
12
12
|
import { CHANNELS } from "#core/constants/channels.js";
|
|
13
|
+
import { isKnownSpaRoute } from "./web.js";
|
|
13
14
|
|
|
14
15
|
export const nowIso = () =>
|
|
15
16
|
new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
@@ -51,34 +52,29 @@ export function traceIdMiddleware(req, res, next) {
|
|
|
51
52
|
// /pair/init and /admin/web-token both enforce localhost-only checks of
|
|
52
53
|
// their own — the auth middleware just gets out of their way.
|
|
53
54
|
const UNAUTHENTICATED_PREFIXES = ["/health", "/pair/", "/admin/web-token"];
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"/health", "/admin", "/projects", "/telegram", "/engines", "/runtimes",
|
|
62
|
-
"/messages", "/sessions", "/tools", "/mcp", "/voice", "/tts", "/desktop", "/overlay",
|
|
63
|
-
"/transcribe", "/run", "/files", "/memory", "/env", "/pair", "/deck",
|
|
64
|
-
"/super-agent", "/identity", "/agents", "/tasks",
|
|
65
|
-
];
|
|
66
|
-
function isApiPath(p) {
|
|
67
|
-
for (const prefix of API_PREFIXES) {
|
|
68
|
-
if (p === prefix || p.startsWith(prefix + "/")) return true;
|
|
69
|
-
}
|
|
70
|
-
return false;
|
|
55
|
+
|
|
56
|
+
// Does this path look like a static asset (has a file extension)? Vite emits
|
|
57
|
+
// hashed, extension-bearing filenames (index-abc123.js, logo.svg, font.woff2),
|
|
58
|
+
// so an extension is a reliable "this is a bundle asset, not a data route"
|
|
59
|
+
// signal. Data routes (/skills, /projects, /p/0/tasks) have no extension.
|
|
60
|
+
function isStaticAssetPath(p) {
|
|
61
|
+
return path.extname(p) !== "";
|
|
71
62
|
}
|
|
63
|
+
|
|
72
64
|
function isUnauthenticatedPath(p, method = "GET") {
|
|
73
65
|
if (p === "/health") return true;
|
|
74
66
|
if (p === "/admin/web-token") return true;
|
|
75
67
|
for (const prefix of UNAUTHENTICATED_PREFIXES) {
|
|
76
68
|
if (p === prefix.replace(/\/$/, "") || p.startsWith(prefix)) return true;
|
|
77
69
|
}
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
70
|
+
// SPA bootstrap: the admin bundle loads before it holds a bearer, so a GET
|
|
71
|
+
// for a static asset or a known client-router route is served without auth —
|
|
72
|
+
// the bundle then fetches /admin/web-token. Everything else, including every
|
|
73
|
+
// data GET (/skills, /plugins, /embeddings, …), REQUIRES a token. This is an
|
|
74
|
+
// allowlist by construction: a new data route can never silently become
|
|
75
|
+
// public just because someone forgot to register its prefix (the old
|
|
76
|
+
// denylist failure mode).
|
|
77
|
+
if (method === "GET" && (isStaticAssetPath(p) || isKnownSpaRoute(p))) return true;
|
|
82
78
|
return false;
|
|
83
79
|
}
|
|
84
80
|
|
|
@@ -62,8 +62,9 @@ export function register(app, { projects, registries, resolveTopProject }) {
|
|
|
62
62
|
return res.status(500).json({ error: e.message });
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
const root = path.resolve(p.path);
|
|
65
66
|
const abs = path.resolve(p.path, rel);
|
|
66
|
-
if (!abs.startsWith(path.
|
|
67
|
+
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
67
68
|
return res.status(403).json({ error: "path escapes project root" });
|
|
68
69
|
if (!fs.existsSync(abs))
|
|
69
70
|
return res.status(404).json({ error: "not found" });
|
|
@@ -96,8 +97,9 @@ export function register(app, { projects, registries, resolveTopProject }) {
|
|
|
96
97
|
if (!rel) return res.status(400).json({ error: "path required" });
|
|
97
98
|
if (typeof content !== "string")
|
|
98
99
|
return res.status(400).json({ error: "content must be string" });
|
|
100
|
+
const root = path.resolve(p.path);
|
|
99
101
|
const abs = path.resolve(p.path, rel);
|
|
100
|
-
if (!abs.startsWith(path.
|
|
102
|
+
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
101
103
|
return res.status(403).json({ error: "path escapes project root" });
|
|
102
104
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
103
105
|
fs.writeFileSync(abs, content);
|
|
@@ -16,8 +16,9 @@ export function setDesktopMessageHandler(fn) {
|
|
|
16
16
|
// routes do: a bearer token (master or paired client) carried on the upgrade
|
|
17
17
|
// request. The legitimate desktop window sends `Authorization: Bearer <token>`
|
|
18
18
|
// (src/interfaces/desktop/main.js); browser clients can pass `?token=`. Without
|
|
19
|
-
// this the daemon (
|
|
20
|
-
// the channel and drive the
|
|
19
|
+
// this, any client that can reach the daemon (loopback by default, but the LAN
|
|
20
|
+
// when host is set to 0.0.0.0) could open the channel and drive the
|
|
21
|
+
// super-agent. See QA BUG-WS-AUTH.
|
|
21
22
|
|
|
22
23
|
/** Path-gate: is this upgrade for the desktop (or legacy overlay) WS channel? */
|
|
23
24
|
export function isDesktopUpgradePath(url) {
|