@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 +1 -1
- package/src/daemon/index.ts +32 -0
- package/src/tray/client.ts +45 -12
- package/src/tray/install.ts +17 -1
package/package.json
CHANGED
package/src/daemon/index.ts
CHANGED
|
@@ -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}`) {
|
package/src/tray/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 = ''
|
package/src/tray/install.ts
CHANGED
|
@@ -69,7 +69,17 @@ function setSwiftBarPluginDir(dir: string, run: Runner): RunResult {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function installSwiftBarApp(run: Runner): RunResult {
|
|
72
|
-
|
|
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
|