@agfpd/iapeer 0.4.65 → 0.4.66

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": "@agfpd/iapeer",
3
- "version": "0.4.65",
3
+ "version": "0.4.66",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -351,6 +351,26 @@ export function daemonDiscoveryPath(options: StorageOptions = {}): string {
351
351
  return join(pluginStateDir('iapeer', options), 'router.json')
352
352
  }
353
353
 
354
+ /**
355
+ * Normalize an HTTP request-target to origin-form (path[+query]) for path routing.
356
+ * A request that traversed an HTTP proxy arrives in ABSOLUTE-FORM (RFC 7230 §5.3.2:
357
+ * "GET http://host/path"), and Node exposes the whole target as `req.url` — so a
358
+ * path check like `req.url.startsWith('/fleet/')` misses and the request wrongly
359
+ * falls to the MCP catch-all (→ 406). Any client with HTTP(S)_PROXY set is affected
360
+ * (Bun's fetch proxies loopback even when NO_PROXY lists 127.0.0.1). Origin-form and
361
+ * unparseable targets pass through unchanged. Exported for tests. */
362
+ export function normalizeRequestTarget(url: string | undefined): string | undefined {
363
+ if (url && /^https?:\/\//i.test(url)) {
364
+ try {
365
+ const u = new URL(url)
366
+ return u.pathname + u.search
367
+ } catch {
368
+ /* unparseable — leave as received */
369
+ }
370
+ }
371
+ return url
372
+ }
373
+
354
374
  export interface DaemonHandle {
355
375
  socketPath?: string
356
376
  host?: string
@@ -498,6 +518,18 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
498
518
  // gate first, then the fleet-API branch (when wired), then stateless MCP —
499
519
  // a fresh Server + transport per request, fully isolated.
500
520
  const handle = (req: IncomingMessage, res: ServerResponse): void => {
521
+ // RFC 7230 §5.3.2 request-target hygiene (FIRST — before ANY path routing). A
522
+ // request that traversed an HTTP proxy carries the ABSOLUTE-FORM target
523
+ // ("GET http://127.0.0.1:8765/fleet/v1/snapshot"), and Node puts that whole string
524
+ // in req.url — so path routing (/fleet vs /mcp) would miss and fall through to MCP
525
+ // (→ a 406 "must accept text/event-stream" for a plain GET). This bites any client
526
+ // whose environment has HTTP(S)_PROXY set: Bun's fetch (and others) route loopback
527
+ // through the proxy even when NO_PROXY lists 127.0.0.1 (Bun ignores NO_PROXY). We
528
+ // can't fix every client, so the daemon normalizes to origin-form here — a proxied
529
+ // request then routes identically to a direct one, for BOTH the fleet and MCP
530
+ // surfaces. (live-proven: HTTP_PROXY pointed at the daemon itself reproduced the
531
+ // 406; normalization restores /fleet routing.)
532
+ req.url = normalizeRequestTarget(req.url)
501
533
  // H8 bearer layer (FIRST, before any MCP/fleet work): when a token is configured,
502
534
  // reject anything not carrying `Authorization: Bearer <token>`. Off when unset.
503
535
  if (bearer && req.headers.authorization !== `Bearer ${bearer}`) {
@@ -80,6 +80,7 @@ export class FleetUnreachableError extends Error {
80
80
  }
81
81
 
82
82
  interface FetchAttempt {
83
+ transport: 'unix' | 'tcp'
83
84
  url: string
84
85
  init: RequestInit & { unix?: string }
85
86
  }
@@ -89,36 +90,66 @@ interface FetchAttempt {
89
90
  function attempts(addr: FleetAddress, path: string, base: RequestInit, env: NodeJS.ProcessEnv): FetchAttempt[] {
90
91
  const headers = { ...(base.headers as Record<string, string> | undefined), ...bearerHeader(env) }
91
92
  const list: FetchAttempt[] = []
92
- if (addr.sock) list.push({ url: `http://iapeer${path}`, init: { ...base, headers, unix: addr.sock } })
93
- if (addr.tcp) list.push({ url: `${addr.tcp}${path}`, init: { ...base, headers } })
93
+ if (addr.sock) list.push({ transport: 'unix', url: `http://iapeer${path}`, init: { ...base, headers, unix: addr.sock } })
94
+ if (addr.tcp) list.push({ transport: 'tcp', url: `${addr.tcp}${path}`, init: { ...base, headers } })
94
95
  return list
95
96
  }
96
97
 
98
+ function debug(env: NodeJS.ProcessEnv, msg: string): void {
99
+ if (env.IAPEER_TRAY_DEBUG === '1') process.stderr.write(`[tray] ${msg}\n`)
100
+ }
101
+
102
+ /** The response of a fleet request, carrying the ACTUAL wire transport + URL used —
103
+ * so a failure surfaces where the request really went (a proxied request lands on a
104
+ * different route than the client's intended path). */
105
+ export interface FleetResponse {
106
+ res: Response
107
+ transport: string
108
+ url: string
109
+ }
110
+
111
+ /**
112
+ * Try each transport (unix first, TCP second) until one answers. On a THROW, move to
113
+ * the next transport. On a NON-OK status the behavior depends on `retryBadStatus`:
114
+ * GETs (idempotent) retry on the OTHER transport (a single misrouting transport — e.g.
115
+ * one behind a proxy — cannot then sink the client); POSTs do NOT (never re-execute a
116
+ * command). Returns the actual transport+URL for honest diagnostics.
117
+ */
97
118
  async function fetchFleet(
98
119
  addr: FleetAddress,
99
120
  path: string,
100
121
  init: RequestInit,
101
122
  env: NodeJS.ProcessEnv,
102
- ): Promise<Response> {
123
+ opts: { retryBadStatus?: boolean } = {},
124
+ ): Promise<FleetResponse> {
103
125
  const tries = attempts(addr, path, init, env)
104
126
  if (tries.length === 0) throw new FleetUnreachableError('no advertised address (router.json missing sock/tcp)')
105
127
  let lastErr = ''
128
+ let firstBad: FleetResponse | undefined
106
129
  for (const a of tries) {
130
+ let res: Response
107
131
  try {
108
- const res = await fetch(a.url, a.init as RequestInit)
109
- return res
132
+ res = await fetch(a.url, a.init as RequestInit)
110
133
  } catch (e) {
111
134
  lastErr = e instanceof Error ? e.message : String(e)
112
- // try the next transport
135
+ debug(env, `${a.transport} ${a.url} → THREW ${lastErr}`)
136
+ continue
113
137
  }
138
+ debug(env, `${a.transport} ${a.url} → ${res.status}`)
139
+ const wrapped: FleetResponse = { res, transport: a.transport, url: a.url }
140
+ if (res.ok) return wrapped
141
+ if (!firstBad) firstBad = wrapped
142
+ if (!opts.retryBadStatus) return wrapped // POST: return the error as-is (no re-execute)
143
+ // GET: fall through to the next transport; firstBad is returned if none succeed
114
144
  }
115
- throw new FleetUnreachableError(lastErr || 'all transports failed')
145
+ if (firstBad) return firstBad
146
+ throw new FleetUnreachableError(`${lastErr || 'all transports failed'} (tried ${tries.map(t => `${t.transport}=${t.url}`).join(', ')})`)
116
147
  }
117
148
 
118
149
  export async function fleetGetJson<T = unknown>(addr: FleetAddress, path: string, env: NodeJS.ProcessEnv): Promise<T> {
119
- const res = await fetchFleet(addr, path, { method: 'GET' }, env)
150
+ const { res, transport, url } = await fetchFleet(addr, path, { method: 'GET' }, env, { retryBadStatus: true })
120
151
  const text = await res.text()
121
- if (!res.ok) throw new Error(`GET ${path} → ${res.status}: ${text.slice(0, 300)}`)
152
+ if (!res.ok) throw new Error(`GET ${url} (${transport}) → ${res.status}: ${text.slice(0, 300)}`)
122
153
  return JSON.parse(text) as T
123
154
  }
124
155
 
@@ -128,11 +159,12 @@ export async function fleetPostJson<T = unknown>(
128
159
  body: Record<string, unknown>,
129
160
  env: NodeJS.ProcessEnv,
130
161
  ): Promise<{ status: number; body: T }> {
131
- const res = await fetchFleet(
162
+ const { res } = await fetchFleet(
132
163
  addr,
133
164
  path,
134
165
  { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
135
166
  env,
167
+ { retryBadStatus: false }, // a command must never re-execute on the other transport
136
168
  )
137
169
  const text = await res.text()
138
170
  let parsed: T
@@ -170,13 +202,14 @@ export async function streamFleetEvents(
170
202
  opts: { replay?: number; signal?: AbortSignal; env: NodeJS.ProcessEnv },
171
203
  ): Promise<void> {
172
204
  const replay = Math.max(0, Math.min(500, opts.replay ?? 0))
173
- const res = await fetchFleet(
205
+ const { res, transport, url } = await fetchFleet(
174
206
  addr,
175
207
  `/fleet/v1/events?replay=${replay}`,
176
208
  { method: 'GET', headers: { accept: 'text/event-stream' }, signal: opts.signal },
177
209
  opts.env,
210
+ { retryBadStatus: true },
178
211
  )
179
- if (!res.ok || !res.body) throw new Error(`events stream → ${res.status}`)
212
+ if (!res.ok || !res.body) throw new Error(`events stream ${url} (${transport}) → ${res.status}`)
180
213
  const reader = res.body.getReader()
181
214
  const decoder = new TextDecoder()
182
215
  let buf = ''
@@ -69,7 +69,17 @@ function setSwiftBarPluginDir(dir: string, run: Runner): RunResult {
69
69
  }
70
70
 
71
71
  function installSwiftBarApp(run: Runner): RunResult {
72
- return run('brew', ['install', '--cask', 'swiftbar'])
72
+ // --no-quarantine: an automated install has no one to click through the Gatekeeper
73
+ // "app downloaded from the Internet" dialog — quarantine would stall first launch.
74
+ return run('brew', ['install', '--cask', '--no-quarantine', 'swiftbar'])
75
+ }
76
+
77
+ /** Strip any residual quarantine xattr from the app (belt-and-suspenders: an already
78
+ * brew-installed SwiftBar, or a brew that quarantined despite the flag, would else
79
+ * block first launch on a headless / no-click box). Best-effort. */
80
+ function dequarantineApp(run: Runner): void {
81
+ const app = SWIFTBAR_APP_PATHS.find(p => existsSync(p))
82
+ if (app) run('xattr', ['-dr', 'com.apple.quarantine', app])
73
83
  }
74
84
 
75
85
  function launchSwiftBar(run: Runner): void {
@@ -93,6 +103,11 @@ export function buildPluginShim(binPath: string): string {
93
103
  '# <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>',
94
104
  '# <swiftbar.hideLastUpdated>true</swiftbar.hideLastUpdated>',
95
105
  '# <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>',
106
+ // The dashboard ONLY ever talks to the local daemon (unix socket / loopback), so it
107
+ // must never route through an HTTP proxy. Bun's fetch caches proxy config at process
108
+ // start and ignores NO_PROXY for loopback, so clearing the vars must happen in the
109
+ // ENV before the binary launches (a runtime delete is too late). unset here.
110
+ 'unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy',
96
111
  `exec "${binPath}" tray render --stream`,
97
112
  '',
98
113
  ].join('\n')
@@ -195,6 +210,7 @@ export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
195
210
  // 4 — launch + refresh
196
211
  let launched = false
197
212
  if (opts.launch && present) {
213
+ dequarantineApp(run)
198
214
  launchSwiftBar(run)
199
215
  refreshSwiftBar(run)
200
216
  launched = true