@agentprojectcontext/apx 1.53.6 → 1.54.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.53.6",
3
+ "version": "1.54.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -188,6 +188,7 @@ export async function runResumedTurn(self, ctx) {
188
188
  previousMessages,
189
189
  target,
190
190
  author,
191
+ authorId,
191
192
  relationshipBlock,
192
193
  allowedTools,
193
194
  onEvent,
@@ -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,
@@ -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
- fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n");
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
- const { correlationId, promise } = pendingStore.create({ timeoutMs: TIMEOUT_MS });
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, or stale button after a process restart).
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 private/link-local ranges and cloud metadata endpoints to prevent SSRF.
36
- const BLOCKED_HOST_RE = /^(localhost|metadata\.google\.internal\.?)$/i;
37
- const PRIVATE_IP_RE = /^(127\.\d+\.\d+\.\d+|0\.0\.0\.0|::1|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+|fd[0-9a-f]{2}:)/i;
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
- function validateUrl(rawUrl) {
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) || PRIVATE_IP_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 → "*" (an admin assigned it
59
- * deliberately; default permissive rather than silently muting it)
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
- if (sender?.role === SENDER_ROLES.GUEST) return [];
67
- return "*";
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
- return { sender: base(), mutated: true };
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
  }
@@ -153,7 +153,9 @@ class McpProcess {
153
153
 
154
154
  async listTools() {
155
155
  await this._ensureInitialized();
156
- return this._send("tools/list", {});
156
+ return collectToolPages((cursor) =>
157
+ this._send("tools/list", cursor ? { cursor } : {})
158
+ );
157
159
  }
158
160
 
159
161
  async callTool(name, args) {
@@ -374,7 +376,9 @@ class HttpMcpClient {
374
376
 
375
377
  async listTools() {
376
378
  await this._ensureInitialized();
377
- return this._rpc("tools/list", {});
379
+ return collectToolPages((cursor) =>
380
+ this._rpc("tools/list", cursor ? { cursor } : {})
381
+ );
378
382
  }
379
383
 
380
384
  async callTool(name, args) {
@@ -399,6 +403,21 @@ class HttpMcpClient {
399
403
  }
400
404
  }
401
405
 
406
+ // tools/list is paginated (nextCursor). Follow every page and hand back a
407
+ // single merged { tools } result so callers never see partial catalogs.
408
+ const MAX_TOOL_PAGES = 32;
409
+ async function collectToolPages(fetchPage) {
410
+ const tools = [];
411
+ let cursor;
412
+ for (let i = 0; i < MAX_TOOL_PAGES; i++) {
413
+ const result = await fetchPage(cursor);
414
+ if (Array.isArray(result?.tools)) tools.push(...result.tools);
415
+ cursor = result?.nextCursor;
416
+ if (!cursor) break;
417
+ }
418
+ return { tools };
419
+ }
420
+
402
421
  function parseFirstSseJson(raw) {
403
422
  for (const block of raw.split(/\r?\n\r?\n/)) {
404
423
  const dataLines = [];
@@ -21,7 +21,7 @@ If you can spawn a subagent natively in the current IDE (Claude Code, Cursor,
21
21
  |-------|-----------|------|
22
22
  | Delegate to external coding CLI | **apx-runtime** | `apx run <agent> --runtime claude-code\|codex\|...` |
23
23
  | List/read/resume/summarise/continue sessions | **apx-sessions** | `apx session resume`, `apx sessions list`, "import a codex session" |
24
- | Use a registered MCP tool | **apx-mcp** | `apx mcp run`, "call MCP filesystem", "MCP failing" |
24
+ | Use a registered MCP tool | **apx-mcp** | `apx mcp tools`, `apx mcp run`, "call MCP filesystem", "MCP failing" |
25
25
  | Add/configure/use a project agent | **apx-agent** | "add an agent", vault import, per-agent model, agent memory |
26
26
  | Register/list/configure a project | **apx-project** | "register this project", `apx project list`, per-project config |
27
27
  | Per-project TODO list | **apx-task** | "add a task", "remind me to…", "what's pending" |
@@ -47,7 +47,12 @@ apx mcp remove github --scope runtime --project iacrmar
47
47
  apx mcp enable filesystem --project iacrmar
48
48
  apx mcp disable filesystem --project iacrmar
49
49
 
50
- # Call a tool through the daemon (debugging)
50
+ # Discover tools list catalog, then inspect one tool's schema
51
+ apx mcp tools filesystem # table: tool name + description
52
+ apx mcp tools filesystem read_file # params (types, required) + run example
53
+ apx mcp tools filesystem --json # raw JSON with full inputSchema
54
+
55
+ # Call a tool through the daemon
51
56
  apx mcp run filesystem read_file '{"path":"README.md"}'
52
57
  ```
53
58
 
@@ -95,13 +100,15 @@ apx mcp remove github # errors if github lives in runtime
95
100
 
96
101
  ```bash
97
102
  apx mcp check --project iacrmar # scopes seen + which files exist
98
- apx mcp run <name> <tool> '{...}' # spawn server, call a tool
103
+ apx mcp tools <name> # spawn server + list its tools (proves init works)
104
+ apx mcp logs <name> # spawn/init event log + stderr tail
105
+ apx mcp run <name> <tool> '{...}' # call a tool for real
99
106
  apx log -f # tail unified log for spawn errors
100
107
  ```
101
108
 
102
- "Doesn't show tools" = command failed to start (missing env vars, package not found) or crashed during initialize. Unified log holds the stderr buffer.
109
+ "Doesn't show tools" = command failed to start (missing env vars, package not found) or crashed during initialize. `apx mcp logs <name>` shows the stderr tail; the unified log has the rest.
103
110
 
104
- > `apx mcp tools <name>` is a placeholder stub ("coming in v0.2"). Use `apx mcp run` to verify spawn.
111
+ Standard workflow to use any MCP: `apx mcp tools <name>` `apx mcp tools <name> <tool>` (copy the run example) `apx mcp run <name> <tool> '<json>'`.
105
112
 
106
113
  ## Don't
107
114
 
@@ -151,10 +151,17 @@ apx mcp add github \
151
151
  ## Debugging
152
152
 
153
153
  ```bash
154
- # Smoke test (apx mcp tools is a v0.2 stub don't rely on it)
154
+ # Smoke test spawn the server and list its tool catalog
155
+ apx mcp tools my-server
156
+
157
+ # Inspect one tool's schema + copy-paste run example
158
+ apx mcp tools my-server search_inventory
159
+
160
+ # Call it for real
155
161
  apx mcp run my-server search_inventory '{"query":"shoes"}'
156
162
 
157
- # Spawn errors / stderr
163
+ # Spawn errors / stderr tail
164
+ apx mcp logs my-server
158
165
  apx log -f
159
166
 
160
167
  # Scopes / files / env APX sees
@@ -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 { exec } from "node:child_process";
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
- const prompt = String(req.query.prompt || "Select a folder").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
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
- let cmd;
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
- cmd = `osascript -e 'try' -e 'POSIX path of (choose folder with prompt "${prompt}")' -e 'on error' -e 'return ""' -e 'end try'`;
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
- cmd = `command -v zenity >/dev/null && zenity --file-selection --directory --title="${prompt}" 2>/dev/null || true`;
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
- cmd = `powershell -NoProfile -Command "$f = (New-Object -ComObject Shell.Application).BrowseForFolder(0, '${prompt.replace(/'/g, "''")}', 0, 0); if ($f) { $f.Self.Path }"`;
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
- exec(cmd, { timeout: 5 * 60 * 1000 }, (err, stdout) => {
75
- if (err) return res.status(500).json({ error: err.message });
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(/\/+$/, "") });
@@ -5,6 +5,7 @@
5
5
  // POST /projects/:pid/mcps?scope=shared|runtime|global (default: shared)
6
6
  // DELETE /projects/:pid/mcps/:name?scope=… (default: shared)
7
7
  // GET /projects/:pid/mcps/check
8
+ // GET /projects/:pid/mcps/:name/tools
8
9
  // POST /projects/:pid/mcps/:name/call
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
@@ -190,6 +191,20 @@ export function register(app, { projects, registries, project }) {
190
191
  });
191
192
  });
192
193
 
194
+ // Full tool catalog — tools/list with input schemas, all pages merged.
195
+ // This is what `apx mcp tools` renders; /test below stays as the
196
+ // lightweight smoke check for the web UI card.
197
+ app.get("/projects/:pid/mcps/:name/tools", async (req, res) => {
198
+ const p = project(req, res);
199
+ if (!p) return;
200
+ try {
201
+ const result = await registries.for(p).listTools(req.params.name);
202
+ res.json({ tools: Array.isArray(result?.tools) ? result.tools : [] });
203
+ } catch (e) {
204
+ res.status(500).json({ error: e.message });
205
+ }
206
+ });
207
+
193
208
  app.post("/projects/:pid/mcps/:name/call", async (req, res) => {
194
209
  const p = project(req, res);
195
210
  if (!p) return;
@@ -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
- // API prefixes that MUST stay authenticated. Anything not in this list,
55
- // requested with GET, is treated as a static SPA asset (HTML/CSS/JS/font)
56
- // or a client-router path and served without auth — the bundle itself
57
- // fetches /admin/web-token to obtain a bearer for the subsequent API
58
- // calls. This is safe because all data-bearing routes start with one of
59
- // the API prefixes below.
60
- const API_PREFIXES = [
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
- // GET requests that don't hit an API prefix are SPA assets / client-router
79
- // paths; let them through so the admin bundle can load before it has a
80
- // bearer.
81
- if (method === "GET" && !isApiPath(p)) return true;
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.resolve(p.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.resolve(p.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 (which binds 0.0.0.0 by default) would let any LAN client open
20
- // the channel and drive the super-agent. See QA BUG-WS-AUTH.
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) {
@@ -159,13 +159,130 @@ export async function cmdMcpRun(args) {
159
159
  process.stdout.write(JSON.stringify(result.result, null, 2) + "\n");
160
160
  }
161
161
 
162
+ // Turn a JSON-Schema type into a short placeholder for the run-example JSON.
163
+ function placeholderFor(schema) {
164
+ const t = Array.isArray(schema?.type) ? schema.type[0] : schema?.type;
165
+ if (schema?.enum?.length) return schema.enum[0];
166
+ if (t === "number" || t === "integer") return 0;
167
+ if (t === "boolean") return false;
168
+ if (t === "array") return [];
169
+ if (t === "object") return {};
170
+ return `<${t || "string"}>`;
171
+ }
172
+
173
+ function schemaTypeLabel(schema) {
174
+ if (schema?.enum?.length) return schema.enum.join("|");
175
+ const t = Array.isArray(schema?.type) ? schema.type.join("|") : schema?.type;
176
+ return t || "any";
177
+ }
178
+
179
+ function firstLine(s) {
180
+ return String(s || "").split("\n")[0].trim();
181
+ }
182
+
183
+ function printToolDetail(mcpName, tool) {
184
+ console.log(`${mcpName} · ${tool.name}`);
185
+ if (tool.description) console.log(` ${tool.description.trim().replace(/\n/g, "\n ")}`);
186
+
187
+ const props = tool.inputSchema?.properties || {};
188
+ const required = new Set(tool.inputSchema?.required || []);
189
+ const keys = Object.keys(props);
190
+ console.log("");
191
+ if (keys.length === 0) {
192
+ console.log(" Params: (none)");
193
+ } else {
194
+ console.log(" Params:");
195
+ const nameW = Math.max(...keys.map((k) => k.length), 4) + 2;
196
+ const typeW = Math.max(...keys.map((k) => schemaTypeLabel(props[k]).length), 4) + 2;
197
+ for (const k of keys) {
198
+ const req = required.has(k) ? "(required)" : "(optional)";
199
+ console.log(
200
+ ` ${k.padEnd(nameW)}${schemaTypeLabel(props[k]).padEnd(typeW)}${req} ${firstLine(props[k]?.description)}`
201
+ );
202
+ }
203
+ }
204
+
205
+ // Example invocation with the required params stubbed in.
206
+ const example = {};
207
+ for (const k of keys) {
208
+ if (required.has(k)) example[k] = placeholderFor(props[k]);
209
+ }
210
+ console.log("");
211
+ console.log(" Run:");
212
+ console.log(` apx mcp run ${mcpName} ${tool.name} '${JSON.stringify(example)}'`);
213
+ }
214
+
162
215
  export async function cmdMcpTools(args) {
163
216
  const name = args._[0];
164
- if (!name) throw new Error("apx mcp tools: missing <name>");
165
- // Daemon doesn't have a dedicated tools/list endpoint yet; we'd extend it in v0.2.
166
- // For now, print a hint:
167
- console.log(`(apx mcp tools list of tools/list will arrive in v0.2)`);
168
- console.log(`To call a tool: apx mcp run ${name} <tool> '<json>'`);
217
+ if (!name) throw new Error("apx mcp tools: usage: apx mcp tools <name> [<tool>] [--json]");
218
+ const toolFilter = args._[1];
219
+ const pid = await resolveProjectId(args?.flags?.project);
220
+ const data = await http.get(`/projects/${pid}/mcps/${name}/tools`);
221
+ const tools = data.tools || [];
222
+
223
+ if (toolFilter) {
224
+ const tool = tools.find((t) => t.name === toolFilter);
225
+ if (!tool) {
226
+ const hint = tools.length
227
+ ? `Available: ${tools.map((t) => t.name).join(", ")}`
228
+ : "(server reported no tools)";
229
+ throw new Error(`MCP "${name}" has no tool "${toolFilter}". ${hint}`);
230
+ }
231
+ if (args?.flags?.json) {
232
+ process.stdout.write(JSON.stringify(tool, null, 2) + "\n");
233
+ return;
234
+ }
235
+ printToolDetail(name, tool);
236
+ return;
237
+ }
238
+
239
+ if (args?.flags?.json) {
240
+ process.stdout.write(JSON.stringify(tools, null, 2) + "\n");
241
+ return;
242
+ }
243
+ if (tools.length === 0) {
244
+ console.log(`(MCP "${name}" reported no tools)`);
245
+ return;
246
+ }
247
+ const nameW = Math.max(...tools.map((t) => t.name.length), 4) + 2;
248
+ console.log(`${tools.length} tool${tools.length === 1 ? "" : "s"} — apx mcp tools ${name} <tool> for schema\n`);
249
+ console.log("TOOL".padEnd(nameW) + " DESCRIPTION");
250
+ for (const t of tools) {
251
+ console.log(t.name.padEnd(nameW) + " " + firstLine(t.description).slice(0, 100));
252
+ }
253
+ }
254
+
255
+ export async function cmdMcpLogs(args) {
256
+ const name = args._[0];
257
+ if (!name) throw new Error("apx mcp logs: missing <name>");
258
+ const pid = await resolveProjectId(args?.flags?.project);
259
+ const logs = await http.get(`/projects/${pid}/mcps/${name}/logs`);
260
+ if (args?.flags?.json) {
261
+ process.stdout.write(JSON.stringify(logs, null, 2) + "\n");
262
+ return;
263
+ }
264
+ const target = logs.transport === "http"
265
+ ? logs.url
266
+ : [logs.command, ...(logs.args || [])].filter(Boolean).join(" ");
267
+ console.log(`${name} (${logs.transport})${target ? " — " + target : ""}`);
268
+ if (logs.transport === "stdio") {
269
+ console.log(` running: ${logs.running ? "yes" : "no"} started: ${logs.started_at || "-"} last exit: ${logs.last_exit_code ?? "-"}`);
270
+ } else {
271
+ console.log(` started: ${logs.started_at || "-"} last error: ${logs.last_error || "-"}`);
272
+ }
273
+ if (logs.note) console.log(` ${logs.note}`);
274
+ if (logs.events?.length) {
275
+ console.log("\nEvents:");
276
+ for (const e of logs.events) {
277
+ console.log(` ${e.ts} [${e.level}] ${e.msg}`);
278
+ }
279
+ }
280
+ if (logs.stderr_tail?.trim()) {
281
+ console.log("\nstderr tail:");
282
+ for (const line of logs.stderr_tail.trim().split("\n")) {
283
+ console.log(` ${line}`);
284
+ }
285
+ }
169
286
  }
170
287
 
171
288
  export async function cmdMcpCheck(args = {}) {
@@ -46,6 +46,7 @@ import {
46
46
  cmdMcpDisable,
47
47
  cmdMcpRun,
48
48
  cmdMcpTools,
49
+ cmdMcpLogs,
49
50
  cmdMcpCheck,
50
51
  } from "./commands/mcp.js";
51
52
  import {
@@ -713,7 +714,8 @@ const HELP_TOPICS = new Map(Object.entries({
713
714
  ["enable <name>", "Enable a project-owned MCP server."],
714
715
  ["disable <name>", "Disable a project-owned MCP server."],
715
716
  ["run <name> <tool>", "Call one MCP tool."],
716
- ["tools <name>", "Show tool-list hint."],
717
+ ["tools <name> [<tool>]", "List a server's tools, or show one tool's schema."],
718
+ ["logs <name>", "Show spawn/init logs and stderr tail for a server."],
717
719
  ["check", "Audit source files, merge order, and conflicts."],
718
720
  ],
719
721
  options: [["--project <name|id|path>", "Pin command to a specific project."]],
@@ -785,10 +787,27 @@ const HELP_TOPICS = new Map(Object.entries({
785
787
  }),
786
788
  "mcp tools": topic({
787
789
  title: "apx mcp tools",
788
- summary: "Show MCP tool-list guidance for a server.",
789
- usage: ["apx mcp tools <name> [--project <name|id|path>]"],
790
- options: [["--project <name|id|path>", "Pin command to a specific project."]],
791
- examples: ["apx mcp tools filesystem"],
790
+ summary: "List an MCP server's tools, or show one tool's input schema with a ready-to-run example.",
791
+ usage: ["apx mcp tools <name> [<tool>] [--json] [--project <name|id|path>]"],
792
+ options: [
793
+ ["--json", "Raw JSON output (full tool objects with inputSchema)."],
794
+ ["--project <name|id|path>", "Pin command to a specific project."],
795
+ ],
796
+ examples: [
797
+ "apx mcp tools filesystem",
798
+ "apx mcp tools filesystem read_file",
799
+ "apx mcp tools dokploy-mcp --json",
800
+ ],
801
+ }),
802
+ "mcp logs": topic({
803
+ title: "apx mcp logs",
804
+ summary: "Show an MCP server's spawn/init event log and stderr tail — first stop when a server doesn't list tools.",
805
+ usage: ["apx mcp logs <name> [--json] [--project <name|id|path>]"],
806
+ options: [
807
+ ["--json", "Raw JSON output."],
808
+ ["--project <name|id|path>", "Pin command to a specific project."],
809
+ ],
810
+ examples: ["apx mcp logs dokploy-mcp"],
792
811
  }),
793
812
  "mcp check": topic({
794
813
  title: "apx mcp check",
@@ -2022,7 +2041,8 @@ function buildHelp(version) {
2022
2041
  hCmd("apx mcp remove <name>", 36, ""),
2023
2042
  hCmd("apx mcp enable/disable", 36, "<name>"),
2024
2043
  hCmd("apx mcp run <name> <tool>", 36, "[<json-args>] call a tool through the daemon"),
2025
- hCmd("apx mcp tools <name>", 36, "list available tools"),
2044
+ hCmd("apx mcp tools <name>", 36, "[<tool>] list tools, or one tool's schema + run example"),
2045
+ hCmd("apx mcp logs <name>", 36, "spawn/init log + stderr tail"),
2026
2046
  hCmd("apx mcp check", 36, "audit multi-source merge"),
2027
2047
 
2028
2048
  hSec("Daemon Service"),
@@ -2372,6 +2392,7 @@ async function dispatch(cmd, rest) {
2372
2392
  else if (sub === "disable") await cmdMcpDisable(a);
2373
2393
  else if (sub === "run") await cmdMcpRun(a);
2374
2394
  else if (sub === "tools") await cmdMcpTools(a);
2395
+ else if (sub === "logs") await cmdMcpLogs(a);
2375
2396
  else if (sub === "check") await cmdMcpCheck(a);
2376
2397
  else die(`unknown mcp subcommand: ${sub || "(none)"}`);
2377
2398
  break;
@@ -1259,9 +1259,9 @@
1259
1259
  }
1260
1260
  },
1261
1261
  "node_modules/@reduxjs/toolkit/node_modules/immer": {
1262
- "version": "11.1.8",
1263
- "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
1264
- "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
1262
+ "version": "11.1.9",
1263
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
1264
+ "integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==",
1265
1265
  "license": "MIT",
1266
1266
  "funding": {
1267
1267
  "type": "opencollective",
@@ -2059,9 +2059,9 @@
2059
2059
  "license": "MIT"
2060
2060
  },
2061
2061
  "node_modules/@types/node": {
2062
- "version": "26.0.1",
2063
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz",
2064
- "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==",
2062
+ "version": "26.1.0",
2063
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
2064
+ "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
2065
2065
  "devOptional": true,
2066
2066
  "license": "MIT",
2067
2067
  "dependencies": {
@@ -2162,9 +2162,9 @@
2162
2162
  }
2163
2163
  },
2164
2164
  "node_modules/baseline-browser-mapping": {
2165
- "version": "2.10.40",
2166
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
2167
- "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
2165
+ "version": "2.10.41",
2166
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
2167
+ "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
2168
2168
  "dev": true,
2169
2169
  "license": "Apache-2.0",
2170
2170
  "bin": {
@@ -2580,9 +2580,9 @@
2580
2580
  "license": "MIT"
2581
2581
  },
2582
2582
  "node_modules/electron-to-chromium": {
2583
- "version": "1.5.383",
2584
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz",
2585
- "integrity": "sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==",
2583
+ "version": "1.5.385",
2584
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
2585
+ "integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==",
2586
2586
  "dev": true,
2587
2587
  "license": "ISC"
2588
2588
  },
@@ -3256,9 +3256,9 @@
3256
3256
  "license": "ISC"
3257
3257
  },
3258
3258
  "node_modules/picomatch": {
3259
- "version": "4.0.4",
3260
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
3261
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
3259
+ "version": "4.0.5",
3260
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
3261
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
3262
3262
  "license": "MIT",
3263
3263
  "engines": {
3264
3264
  "node": ">=12"