@aarmos/bridge 0.1.10 → 0.1.11

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.
Files changed (45) hide show
  1. package/dist/bridge.js +7 -129
  2. package/dist/chunk-GMH5B5YX.js +871 -0
  3. package/dist/index.js +1500 -219
  4. package/dist/verify-O6X7MFEE.js +409 -0
  5. package/package.json +4 -3
  6. package/dist/bridge.js.map +0 -1
  7. package/dist/commands/attest.js +0 -62
  8. package/dist/commands/attest.js.map +0 -1
  9. package/dist/commands/demo.js +0 -171
  10. package/dist/commands/demo.js.map +0 -1
  11. package/dist/commands/dev.js +0 -56
  12. package/dist/commands/dev.js.map +0 -1
  13. package/dist/commands/pack.js +0 -96
  14. package/dist/commands/pack.js.map +0 -1
  15. package/dist/commands/playbook.js +0 -55
  16. package/dist/commands/playbook.js.map +0 -1
  17. package/dist/commands/run.js +0 -290
  18. package/dist/commands/run.js.map +0 -1
  19. package/dist/commands/simulate.js +0 -180
  20. package/dist/commands/simulate.js.map +0 -1
  21. package/dist/commands/verify.js +0 -87
  22. package/dist/commands/verify.js.map +0 -1
  23. package/dist/index.js.map +0 -1
  24. package/dist/lib/attest.js +0 -167
  25. package/dist/lib/attest.js.map +0 -1
  26. package/dist/lib/http-broker.js +0 -82
  27. package/dist/lib/http-broker.js.map +0 -1
  28. package/dist/lib/llm-test.js +0 -174
  29. package/dist/lib/llm-test.js.map +0 -1
  30. package/dist/lib/mcp-broker.js +0 -85
  31. package/dist/lib/mcp-broker.js.map +0 -1
  32. package/dist/lib/pack.js +0 -84
  33. package/dist/lib/pack.js.map +0 -1
  34. package/dist/lib/playbook.js +0 -237
  35. package/dist/lib/playbook.js.map +0 -1
  36. package/dist/lib/scope-templates.js +0 -152
  37. package/dist/lib/scope-templates.js.map +0 -1
  38. package/dist/rpc/router.js +0 -349
  39. package/dist/rpc/router.js.map +0 -1
  40. package/dist/session.js +0 -100
  41. package/dist/session.js.map +0 -1
  42. package/dist/watchers/files.js +0 -31
  43. package/dist/watchers/files.js.map +0 -1
  44. package/dist/watchers/git.js +0 -39
  45. package/dist/watchers/git.js.map +0 -1
@@ -0,0 +1,871 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bridge.ts
4
+ import { WebSocketServer, WebSocket } from "ws";
5
+
6
+ // src/rpc/router.ts
7
+ import { z as z3 } from "zod";
8
+ import fs from "fs/promises";
9
+ import path from "path";
10
+ import { spawn } from "child_process";
11
+ import { simpleGit } from "simple-git";
12
+ import pg from "pg";
13
+
14
+ // src/lib/http-broker.ts
15
+ import { z } from "zod";
16
+ var HttpCallParams = z.object({
17
+ method: z.enum(["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"]).default("GET"),
18
+ url: z.string().url(),
19
+ headers: z.record(z.string()).optional(),
20
+ body: z.string().optional(),
21
+ // caller stringifies JSON — keeps signing deterministic
22
+ bearerEnv: z.string().optional(),
23
+ timeoutMs: z.number().int().min(100).max(6e4).default(15e3)
24
+ });
25
+ function hostOf(url) {
26
+ try {
27
+ return new URL(url).host;
28
+ } catch {
29
+ return "";
30
+ }
31
+ }
32
+ function previewHttpCall(p, allowed) {
33
+ const host = hostOf(p.url);
34
+ if (!allowed.has(host)) {
35
+ throw new Error(
36
+ `http.call host not allowed: ${host} (start with --allow-http ${host} or add it to the pack)`
37
+ );
38
+ }
39
+ const redactedHeaders = { ...p.headers ?? {} };
40
+ for (const k of Object.keys(redactedHeaders)) {
41
+ if (/^authorization$/i.test(k)) redactedHeaders[k] = "***";
42
+ }
43
+ if (p.bearerEnv) redactedHeaders["Authorization"] = `Bearer $${p.bearerEnv}`;
44
+ return {
45
+ method: p.method,
46
+ url: p.url,
47
+ host,
48
+ headers: redactedHeaders,
49
+ bodyBytes: p.body?.length ?? 0
50
+ };
51
+ }
52
+ async function executeHttpCall(p, allowed) {
53
+ const host = hostOf(p.url);
54
+ if (!allowed.has(host)) throw new Error(`http.call host not allowed: ${host}`);
55
+ const headers = { ...p.headers ?? {} };
56
+ if (p.bearerEnv) {
57
+ const tok = process.env[p.bearerEnv];
58
+ if (!tok) throw new Error(`http.call: env ${p.bearerEnv} is not set on this host`);
59
+ headers["Authorization"] = `Bearer ${tok}`;
60
+ }
61
+ const controller = new AbortController();
62
+ const t = setTimeout(() => controller.abort(), p.timeoutMs);
63
+ try {
64
+ const res = await fetch(p.url, {
65
+ method: p.method,
66
+ headers,
67
+ body: p.body,
68
+ signal: controller.signal
69
+ });
70
+ const bodyBuf = new Uint8Array(await res.arrayBuffer());
71
+ const capped = bodyBuf.length > 1048576 ? bodyBuf.slice(0, 1048576) : bodyBuf;
72
+ const bodySnippet = new TextDecoder("utf-8", { fatal: false }).decode(capped).slice(0, 4e3);
73
+ const outHeaders = {};
74
+ res.headers.forEach((v, k) => outHeaders[k] = v);
75
+ return { status: res.status, headers: outHeaders, bodySnippet, bytes: bodyBuf.length };
76
+ } finally {
77
+ clearTimeout(t);
78
+ }
79
+ }
80
+
81
+ // src/lib/mcp-broker.ts
82
+ import { z as z2 } from "zod";
83
+ var McpToolParams = z2.object({
84
+ endpoint: z2.string().url(),
85
+ tool: z2.string().min(1),
86
+ args: z2.record(z2.unknown()).default({}),
87
+ bearerEnv: z2.string().optional(),
88
+ /** Optional operator hint for the risk classifier. Defaults to "write". */
89
+ riskHint: z2.enum(["read", "write", "destructive"]).optional(),
90
+ timeoutMs: z2.number().int().min(100).max(6e4).default(15e3)
91
+ });
92
+ function previewMcpTool(p, allowed) {
93
+ if (!allowed.has(p.endpoint)) {
94
+ throw new Error(
95
+ `mcp.tool endpoint not allowed: ${p.endpoint} (start with --allow-mcp ${p.endpoint})`
96
+ );
97
+ }
98
+ return {
99
+ endpoint: p.endpoint,
100
+ tool: p.tool,
101
+ argKeys: Object.keys(p.args ?? {}),
102
+ riskHint: p.riskHint ?? "write"
103
+ };
104
+ }
105
+ async function executeMcpTool(p, allowed) {
106
+ if (!allowed.has(p.endpoint)) throw new Error(`mcp.tool endpoint not allowed: ${p.endpoint}`);
107
+ const headers = {
108
+ "Content-Type": "application/json",
109
+ // Streamable HTTP servers reject requests without both types.
110
+ Accept: "application/json, text/event-stream"
111
+ };
112
+ if (p.bearerEnv) {
113
+ const tok = process.env[p.bearerEnv];
114
+ if (!tok) throw new Error(`mcp.tool: env ${p.bearerEnv} is not set on this host`);
115
+ headers["Authorization"] = `Bearer ${tok}`;
116
+ }
117
+ const body = JSON.stringify({
118
+ jsonrpc: "2.0",
119
+ id: Date.now(),
120
+ method: "tools/call",
121
+ params: { name: p.tool, arguments: p.args ?? {} }
122
+ });
123
+ const controller = new AbortController();
124
+ const t = setTimeout(() => controller.abort(), p.timeoutMs);
125
+ try {
126
+ const res = await fetch(p.endpoint, {
127
+ method: "POST",
128
+ headers,
129
+ body,
130
+ signal: controller.signal
131
+ });
132
+ const text = await res.text();
133
+ const payload = text.startsWith("event:") || text.includes("\ndata:") ? extractFirstSseData(text) : text;
134
+ let parsed = payload;
135
+ try {
136
+ parsed = JSON.parse(payload);
137
+ } catch {
138
+ }
139
+ const envelope = parsed;
140
+ if (envelope && typeof envelope === "object" && "error" in envelope && envelope.error) {
141
+ return { ok: false, error: envelope.error };
142
+ }
143
+ return { ok: res.ok, result: envelope?.result ?? parsed };
144
+ } finally {
145
+ clearTimeout(t);
146
+ }
147
+ }
148
+ function extractFirstSseData(text) {
149
+ for (const line of text.split(/\r?\n/)) {
150
+ if (line.startsWith("data:")) return line.slice(5).trim();
151
+ }
152
+ return text;
153
+ }
154
+
155
+ // src/lib/llm-test.ts
156
+ var DOCS = "https://aarmos.io/docs/byo-llm";
157
+ var DEFAULT_TIMEOUT_MS = 6e3;
158
+ var TEMPLATES = {
159
+ LLM_AUTH_INVALID: {
160
+ message: "Provider rejected the API key.",
161
+ hint: "Re-check the key in Settings \u2192 AI Provider. Rotated keys need to be pasted again.",
162
+ docsUrl: DOCS
163
+ },
164
+ LLM_RATE_LIMITED: {
165
+ message: "Provider rate-limited the test request.",
166
+ hint: "Wait a few seconds and retry. Persistent 429s usually mean a per-minute cap on the account.",
167
+ docsUrl: DOCS
168
+ },
169
+ LLM_QUOTA_EXCEEDED: {
170
+ message: "Provider account is out of credit/quota.",
171
+ hint: "Top up or switch provider. Test-connection uses 1 token \u2014 this is a real account problem, not the test.",
172
+ docsUrl: DOCS
173
+ },
174
+ LLM_NETWORK: {
175
+ message: "Could not reach the provider.",
176
+ hint: "Check the machine's network. For self-hosted endpoints, verify the daemon is running.",
177
+ docsUrl: DOCS
178
+ },
179
+ LLM_UNREACHABLE: {
180
+ message: "Custom endpoint did not respond.",
181
+ hint: "Verify the base URL is reachable from this machine (curl it) and CORS isn't blocking the browser client.",
182
+ docsUrl: DOCS
183
+ },
184
+ LLM_TIMEOUT: {
185
+ message: "Provider did not respond within the test window.",
186
+ hint: "Retry, or raise the timeout. A cold container can take >6s on the first request.",
187
+ docsUrl: DOCS
188
+ },
189
+ LLM_BAD_REQUEST: {
190
+ message: "Provider rejected the request shape.",
191
+ hint: "Usually a wrong model name or an endpoint that doesn't speak OpenAI-compatible chat completions.",
192
+ docsUrl: DOCS
193
+ },
194
+ LLM_MODEL_NOT_FOUND: {
195
+ message: "Provider does not know that model.",
196
+ hint: "Pick a model the account has access to. Model IDs are case-sensitive.",
197
+ docsUrl: DOCS
198
+ },
199
+ LLM_UNKNOWN: {
200
+ message: "Provider returned an unexpected error.",
201
+ hint: "Re-run with the CLI to see the raw upstream response, then file an issue if it looks like a bug.",
202
+ docsUrl: DOCS
203
+ }
204
+ };
205
+ function makeError(code, overrides = {}) {
206
+ const base = TEMPLATES[code];
207
+ return {
208
+ code,
209
+ message: overrides.message ?? base.message,
210
+ hint: overrides.hint ?? base.hint,
211
+ docsUrl: overrides.docsUrl ?? base.docsUrl
212
+ };
213
+ }
214
+ function classifyProviderError(status, message, providerId, isCustom, aborted = false) {
215
+ const msg = (message || "").toLowerCase();
216
+ const prefix = `${providerId}${status ? ` [${status}]` : ""}: `;
217
+ const wrap = (base) => ({
218
+ ...base,
219
+ message: `${prefix}${message || base.message}`
220
+ });
221
+ if (aborted || /abort/.test(msg)) return wrap(makeError("LLM_TIMEOUT"));
222
+ if (status === 401 || status === 403 || /invalid api key|unauthori[sz]ed|permission|forbidden/.test(msg)) {
223
+ return wrap(makeError("LLM_AUTH_INVALID"));
224
+ }
225
+ if (status === 429 || /rate.?limit|too many requests|rpm/.test(msg)) {
226
+ if (/quota|exhausted|billing|insufficient|credit/.test(msg)) {
227
+ return wrap(makeError("LLM_QUOTA_EXCEEDED"));
228
+ }
229
+ return wrap(makeError("LLM_RATE_LIMITED"));
230
+ }
231
+ if (status === 402 || /quota|exhausted|billing|insufficient|credit/.test(msg)) {
232
+ return wrap(makeError("LLM_QUOTA_EXCEEDED"));
233
+ }
234
+ if (status === 404 || /model.*(not.?found|does not exist|unknown)/.test(msg)) {
235
+ return wrap(makeError("LLM_MODEL_NOT_FOUND"));
236
+ }
237
+ if (status === 400 || status === 422) return wrap(makeError("LLM_BAD_REQUEST"));
238
+ if (status === 0 || /failed to fetch|network|econnrefused|etimedout|enotfound|dns/.test(msg)) {
239
+ return wrap(makeError(isCustom ? "LLM_UNREACHABLE" : "LLM_NETWORK"));
240
+ }
241
+ return wrap(makeError("LLM_UNKNOWN"));
242
+ }
243
+ function isValidEndpoint(u) {
244
+ try {
245
+ const url = new URL(u);
246
+ return url.protocol === "http:" || url.protocol === "https:";
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+ async function testLlmConnection(p) {
252
+ const providerId = p.providerId || "custom";
253
+ const isCustom = providerId === "custom" || !p.baseUrl.includes("api.openai.com");
254
+ if (!isValidEndpoint(p.baseUrl)) {
255
+ return {
256
+ ok: false,
257
+ latencyMs: 0,
258
+ error: classifyProviderError(0, `invalid baseUrl: ${p.baseUrl}`, providerId, isCustom)
259
+ };
260
+ }
261
+ if (!p.model) {
262
+ return {
263
+ ok: false,
264
+ latencyMs: 0,
265
+ error: classifyProviderError(400, "missing model id", providerId, isCustom)
266
+ };
267
+ }
268
+ const url = p.baseUrl.replace(/\/+$/, "") + "/chat/completions";
269
+ const headers = {
270
+ "Content-Type": "application/json",
271
+ ...p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {},
272
+ ...p.headers ?? {}
273
+ };
274
+ const ctrl = new AbortController();
275
+ const timeoutMs = p.timeoutMs ?? DEFAULT_TIMEOUT_MS;
276
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
277
+ const started = Date.now();
278
+ let aborted = false;
279
+ try {
280
+ const res = await fetch(url, {
281
+ method: "POST",
282
+ headers,
283
+ body: JSON.stringify({
284
+ model: p.model,
285
+ messages: [{ role: "user", content: "ping" }],
286
+ max_tokens: 1
287
+ }),
288
+ signal: ctrl.signal
289
+ });
290
+ const latencyMs = Date.now() - started;
291
+ if (res.ok) return { ok: true, latencyMs, providerId, model: p.model };
292
+ const body = await res.text().catch(() => "");
293
+ return {
294
+ ok: false,
295
+ latencyMs,
296
+ error: classifyProviderError(res.status, body.slice(0, 240), providerId, isCustom)
297
+ };
298
+ } catch (e) {
299
+ const latencyMs = Date.now() - started;
300
+ const raw = e instanceof Error ? e.message : String(e);
301
+ aborted = ctrl.signal.aborted;
302
+ return {
303
+ ok: false,
304
+ latencyMs,
305
+ error: classifyProviderError(0, raw, providerId, isCustom, aborted)
306
+ };
307
+ } finally {
308
+ clearTimeout(timer);
309
+ }
310
+ }
311
+
312
+ // src/rpc/router.ts
313
+ var TestConnectionParams = z3.object({
314
+ providerId: z3.string().min(1),
315
+ baseUrl: z3.string().url(),
316
+ apiKey: z3.string().optional(),
317
+ model: z3.string().min(1),
318
+ headers: z3.record(z3.string()).optional(),
319
+ timeoutMs: z3.number().int().min(100).max(3e4).optional()
320
+ });
321
+ var RpcRequest = z3.object({
322
+ id: z3.union([z3.string(), z3.number()]),
323
+ type: z3.literal("rpc"),
324
+ method: z3.string(),
325
+ params: z3.unknown().optional()
326
+ });
327
+ var BatchAction = z3.object({
328
+ kind: z3.enum(["fs.write", "shell.exec", "http.call", "mcp.tool"]),
329
+ params: z3.unknown()
330
+ });
331
+ async function handleRpc(session, msg) {
332
+ const parsed = RpcRequest.safeParse(msg);
333
+ if (!parsed.success) return { type: "error", error: "invalid rpc envelope" };
334
+ const { id, method, params } = parsed.data;
335
+ try {
336
+ const result = await dispatch(session, method, params);
337
+ return { id, type: "result", result };
338
+ } catch (err) {
339
+ const maybe = err && typeof err === "object" && "aarmos" in err ? err.aarmos : void 0;
340
+ const message = err instanceof Error ? err.message : String(err);
341
+ return maybe ? { id, type: "error", error: message, aarmos: maybe } : { id, type: "error", error: message };
342
+ }
343
+ }
344
+ async function dispatch(session, method, params) {
345
+ switch (method) {
346
+ // ---- reads (always live, never previewed) ------------------------------
347
+ case "fs.read": {
348
+ const { path: rel, encoding } = z3.object({ path: z3.string(), encoding: z3.enum(["utf8", "base64"]).default("utf8") }).parse(params);
349
+ const abs = session.resolveInJail(rel);
350
+ const buf = await fs.readFile(abs);
351
+ return { path: rel, encoding, data: encoding === "utf8" ? buf.toString("utf8") : buf.toString("base64") };
352
+ }
353
+ case "fs.list": {
354
+ const { path: rel } = z3.object({ path: z3.string().default(".") }).parse(params);
355
+ const abs = session.resolveInJail(rel);
356
+ const entries = await fs.readdir(abs, { withFileTypes: true });
357
+ return entries.map((e) => ({ name: e.name, dir: e.isDirectory(), file: e.isFile() }));
358
+ }
359
+ case "git.status": {
360
+ return await simpleGit(session.workspace).status();
361
+ }
362
+ case "git.branch": {
363
+ return await simpleGit(session.workspace).branch();
364
+ }
365
+ // ---- mutations (previewed under dry-run) -------------------------------
366
+ case "fs.write": {
367
+ const preview = await previewFsWrite(session, params);
368
+ if (session.dryRun) return { preview: true, action: preview };
369
+ return await executeFsWrite(session, params);
370
+ }
371
+ case "shell.exec": {
372
+ const preview = previewShellExec(session, params);
373
+ if (session.dryRun) return { preview: true, action: preview };
374
+ return await executeShellExec(session, params);
375
+ }
376
+ case "http.call": {
377
+ const p = HttpCallParams.parse(params);
378
+ const preview = { kind: "http.call", params: p, preview: previewHttpCall(p, session.httpAllow) };
379
+ if (session.dryRun) return { preview: true, action: preview };
380
+ return await executeHttpCall(p, session.httpAllow);
381
+ }
382
+ case "mcp.tool": {
383
+ const p = McpToolParams.parse(params);
384
+ const preview = { kind: "mcp.tool", params: p, preview: previewMcpTool(p, session.mcpAllow) };
385
+ if (session.dryRun) return { preview: true, action: preview };
386
+ return await executeMcpTool(p, session.mcpAllow);
387
+ }
388
+ // ---- LLM diagnostics (no receipt, no chat) -----------------------------
389
+ // Dry-runs a BYO-LLM provider so the CLI can surface the same
390
+ // structured LLM_* AarmosError codes the PWA shows in Settings, without
391
+ // minting an AVAR chat receipt. Never routed through the sticky
392
+ // active-provider ring; caller supplies the config each call.
393
+ case "aarmos.testConnection": {
394
+ const p = TestConnectionParams.parse(params);
395
+ return await testLlmConnection(p);
396
+ }
397
+ // ---- session controls --------------------------------------------------
398
+ case "session.setDryRun": {
399
+ const { on } = z3.object({ on: z3.boolean() }).parse(params);
400
+ session.dryRun = on;
401
+ return { dryRun: session.dryRun };
402
+ }
403
+ case "session.state": {
404
+ return { dryRun: session.dryRun, workspace: session.workspace };
405
+ }
406
+ // ---- read-only Postgres (only if operator passed --allow-sql) ----------
407
+ case "db.list": {
408
+ return {
409
+ databases: [...session.sqlAllow.values()].map((s) => ({ alias: s.alias }))
410
+ };
411
+ }
412
+ case "db.query": {
413
+ return await executeDbQuery(session, params);
414
+ }
415
+ // ---- scoped filesystem read (only if operator passed --allow-read) ------
416
+ case "fs.scopedRoots": {
417
+ return { roots: [...session.readAllow] };
418
+ }
419
+ case "fs.scopedRead": {
420
+ const { path: abs, encoding } = z3.object({
421
+ path: z3.string(),
422
+ encoding: z3.enum(["utf8", "base64"]).default("utf8")
423
+ }).parse(params);
424
+ const real = session.assertReadable(abs);
425
+ const buf = await fs.readFile(real);
426
+ if (buf.byteLength > 2 * 1024 * 1024) {
427
+ throw new Error(
428
+ `file too large for scopedRead (${buf.byteLength} bytes; cap 2 MiB)`
429
+ );
430
+ }
431
+ return {
432
+ path: real,
433
+ encoding,
434
+ bytes: buf.byteLength,
435
+ data: encoding === "utf8" ? buf.toString("utf8") : buf.toString("base64")
436
+ };
437
+ }
438
+ case "fs.scopedList": {
439
+ const { path: abs } = z3.object({ path: z3.string() }).parse(params);
440
+ const real = session.assertReadable(abs);
441
+ const entries = await fs.readdir(real, { withFileTypes: true });
442
+ return {
443
+ path: real,
444
+ entries: entries.map((e) => ({
445
+ name: e.name,
446
+ absPath: path.join(real, e.name),
447
+ dir: e.isDirectory(),
448
+ file: e.isFile()
449
+ }))
450
+ };
451
+ }
452
+ // ---- batch apply (commits an approved plan, ignoring dryRun) -----------
453
+ case "apply.batch": {
454
+ const { actions } = z3.object({ actions: z3.array(BatchAction) }).parse(params);
455
+ const results = [];
456
+ for (const a of actions) {
457
+ try {
458
+ let res;
459
+ if (a.kind === "fs.write") res = await executeFsWrite(session, a.params);
460
+ else if (a.kind === "shell.exec") res = await executeShellExec(session, a.params);
461
+ else if (a.kind === "http.call") res = await executeHttpCall(HttpCallParams.parse(a.params), session.httpAllow);
462
+ else res = await executeMcpTool(McpToolParams.parse(a.params), session.mcpAllow);
463
+ results.push({ ok: true, kind: a.kind, result: res });
464
+ } catch (err) {
465
+ results.push({
466
+ ok: false,
467
+ kind: a.kind,
468
+ error: err instanceof Error ? err.message : String(err)
469
+ });
470
+ break;
471
+ }
472
+ }
473
+ return { applied: results.length, results };
474
+ }
475
+ default:
476
+ throw new Error(`unknown method: ${method}`);
477
+ }
478
+ }
479
+ var FsWriteParams = z3.object({
480
+ path: z3.string(),
481
+ data: z3.string(),
482
+ encoding: z3.enum(["utf8", "base64"]).default("utf8")
483
+ });
484
+ async function previewFsWrite(session, params) {
485
+ const p = FsWriteParams.parse(params);
486
+ const abs = session.resolveInJail(p.path);
487
+ let before = null;
488
+ let existed = false;
489
+ try {
490
+ const buf = await fs.readFile(abs);
491
+ existed = true;
492
+ before = p.encoding === "utf8" ? buf.toString("utf8") : buf.toString("base64");
493
+ } catch {
494
+ }
495
+ const after = p.data;
496
+ return {
497
+ kind: "fs.write",
498
+ params: p,
499
+ preview: {
500
+ path: p.path,
501
+ encoding: p.encoding,
502
+ existed,
503
+ bytesBefore: before?.length ?? 0,
504
+ bytesAfter: after.length,
505
+ before,
506
+ after
507
+ }
508
+ };
509
+ }
510
+ async function executeFsWrite(session, params) {
511
+ const p = FsWriteParams.parse(params);
512
+ const abs = session.resolveInJail(p.path);
513
+ await fs.mkdir(pathDirname(abs), { recursive: true });
514
+ await fs.writeFile(abs, p.encoding === "utf8" ? p.data : Buffer.from(p.data, "base64"));
515
+ return { path: p.path, bytes: p.data.length };
516
+ }
517
+ var ShellExecParams = z3.object({
518
+ bin: z3.string(),
519
+ args: z3.array(z3.string()).default([]),
520
+ cwd: z3.string().default(".")
521
+ });
522
+ function previewShellExec(session, params) {
523
+ const p = ShellExecParams.parse(params);
524
+ if (!session.shellAllow.has(p.bin)) throw new Error(`shell binary not allowed: ${p.bin}`);
525
+ const absCwd = session.resolveInJail(p.cwd);
526
+ return {
527
+ kind: "shell.exec",
528
+ params: p,
529
+ preview: {
530
+ commandLine: [p.bin, ...p.args].join(" "),
531
+ cwd: absCwd,
532
+ binary: p.bin,
533
+ args: p.args
534
+ }
535
+ };
536
+ }
537
+ async function executeShellExec(session, params) {
538
+ const p = ShellExecParams.parse(params);
539
+ if (!session.shellAllow.has(p.bin)) throw new Error(`shell binary not allowed: ${p.bin}`);
540
+ const absCwd = session.resolveInJail(p.cwd);
541
+ return await execAllowed(p.bin, p.args, absCwd);
542
+ }
543
+ function pathDirname(p) {
544
+ const i = p.lastIndexOf("/");
545
+ return i >= 0 ? p.slice(0, i) : ".";
546
+ }
547
+ function execAllowed(bin, args, cwd) {
548
+ return new Promise((resolve) => {
549
+ const child = spawn(bin, args, { cwd, shell: false });
550
+ let stdout = "";
551
+ let stderr = "";
552
+ child.stdout.on("data", (b) => stdout += b.toString());
553
+ child.stderr.on("data", (b) => stderr += b.toString());
554
+ child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr }));
555
+ });
556
+ }
557
+ var DbQueryParams = z3.object({
558
+ alias: z3.string().optional(),
559
+ sql: z3.string().min(1),
560
+ params: z3.array(z3.unknown()).default([]),
561
+ rowLimit: z3.number().int().min(1).max(1e4).default(500),
562
+ statementTimeoutMs: z3.number().int().min(100).max(3e4).default(5e3)
563
+ });
564
+ async function executeDbQuery(session, params) {
565
+ const p = DbQueryParams.parse(params);
566
+ if (session.sqlAllow.size === 0) {
567
+ throw new Error(
568
+ "db.query is not permitted \u2014 start the CLI with --allow-sql <alias>=<dsn> to opt in."
569
+ );
570
+ }
571
+ let entry;
572
+ if (p.alias) {
573
+ entry = session.sqlAllow.get(p.alias);
574
+ if (!entry) throw new Error(`unknown --allow-sql alias: ${p.alias}`);
575
+ } else if (session.sqlAllow.size === 1) {
576
+ entry = session.sqlAllow.values().next().value;
577
+ } else {
578
+ throw new Error(
579
+ `alias required (registered: ${[...session.sqlAllow.keys()].join(", ")})`
580
+ );
581
+ }
582
+ const client = new pg.Client({ connectionString: entry.dsn });
583
+ const started = Date.now();
584
+ try {
585
+ await client.connect();
586
+ await client.query("BEGIN READ ONLY");
587
+ await client.query(`SET LOCAL statement_timeout = ${p.statementTimeoutMs}`);
588
+ const res = await client.query({ text: p.sql, values: p.params });
589
+ await client.query("ROLLBACK");
590
+ const truncated = res.rows.length > p.rowLimit;
591
+ return {
592
+ alias: entry.alias,
593
+ command: res.command,
594
+ rowCount: res.rowCount,
595
+ returnedRows: Math.min(res.rows.length, p.rowLimit),
596
+ truncated,
597
+ fields: res.fields?.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
598
+ rows: truncated ? res.rows.slice(0, p.rowLimit) : res.rows,
599
+ elapsedMs: Date.now() - started
600
+ };
601
+ } finally {
602
+ try {
603
+ await client.end();
604
+ } catch {
605
+ }
606
+ }
607
+ }
608
+
609
+ // src/watchers/files.ts
610
+ import chokidar from "chokidar";
611
+ import path2 from "path";
612
+ function startFileWatcher(session, emit) {
613
+ const watcher = chokidar.watch(session.workspace, {
614
+ ignored: [
615
+ /(^|[/\\])\../,
616
+ // dotfiles
617
+ /node_modules/,
618
+ /dist|build|\.next|\.turbo|\.cache/
619
+ ],
620
+ ignoreInitial: true,
621
+ awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 40 },
622
+ persistent: true
623
+ });
624
+ const send = (kind) => (p) => {
625
+ const rel = path2.relative(session.workspace, p) || ".";
626
+ emit({ kind, path: rel, at: Date.now() });
627
+ };
628
+ watcher.on("add", send("add")).on("change", send("change")).on("unlink", send("unlink")).on("addDir", send("addDir")).on("unlinkDir", send("unlinkDir"));
629
+ return () => void watcher.close();
630
+ }
631
+
632
+ // src/watchers/git.ts
633
+ import { simpleGit as simpleGit2 } from "simple-git";
634
+ function startGitWatcher(session, emit) {
635
+ const git = simpleGit2(session.workspace);
636
+ let lastBranch = null;
637
+ let lastHead = null;
638
+ let stopped = false;
639
+ const tick = async () => {
640
+ if (stopped) return;
641
+ try {
642
+ const status = await git.status();
643
+ const head = (await git.revparse(["HEAD"])).trim();
644
+ const branch = status.current ?? null;
645
+ if (branch !== lastBranch || head !== lastHead) {
646
+ const kind = branch !== lastBranch ? "branch" : "head";
647
+ lastBranch = branch;
648
+ lastHead = head;
649
+ emit({ kind, branch, head, at: Date.now() });
650
+ }
651
+ } catch {
652
+ stopped = true;
653
+ return;
654
+ }
655
+ if (!stopped) setTimeout(tick, 1500);
656
+ };
657
+ void tick();
658
+ return () => {
659
+ stopped = true;
660
+ };
661
+ }
662
+
663
+ // src/session.ts
664
+ import path3 from "path";
665
+ import fs2 from "fs";
666
+ var DEFAULT_SHELL_ALLOW = [
667
+ "git",
668
+ "npm",
669
+ "pnpm",
670
+ "bun",
671
+ "node",
672
+ "ls",
673
+ "cat",
674
+ "rg",
675
+ "grep"
676
+ ];
677
+ function createSession(opts) {
678
+ const workspace = path3.resolve(opts.workspace);
679
+ const shellAllow = /* @__PURE__ */ new Set([...DEFAULT_SHELL_ALLOW, ...opts.extraShellAllow]);
680
+ const sqlAllow = /* @__PURE__ */ new Map();
681
+ for (const entry of opts.sqlAllow ?? []) {
682
+ if (sqlAllow.has(entry.alias)) {
683
+ throw new Error(`duplicate --allow-sql alias: ${entry.alias}`);
684
+ }
685
+ sqlAllow.set(entry.alias, entry);
686
+ }
687
+ const readAllow = [];
688
+ for (const raw of opts.readAllow ?? []) {
689
+ const abs = path3.resolve(raw);
690
+ let real;
691
+ try {
692
+ real = fs2.realpathSync(abs);
693
+ } catch {
694
+ throw new Error(`--allow-read path does not exist: ${raw}`);
695
+ }
696
+ readAllow.push(real);
697
+ }
698
+ const httpAllow = new Set(opts.httpAllow ?? []);
699
+ const mcpAllow = new Set(opts.mcpAllow ?? []);
700
+ const capabilities = [
701
+ "fs.read",
702
+ "fs.write",
703
+ "fs.list",
704
+ "shell.exec",
705
+ "git.status",
706
+ "git.branch",
707
+ "events.fs",
708
+ "events.git",
709
+ "session.setDryRun",
710
+ "apply.batch"
711
+ ];
712
+ if (sqlAllow.size > 0) {
713
+ capabilities.push("db.list", "db.query");
714
+ }
715
+ if (readAllow.length > 0) {
716
+ capabilities.push("fs.scopedRoots", "fs.scopedRead", "fs.scopedList");
717
+ }
718
+ if (httpAllow.size > 0) capabilities.push("http.call");
719
+ if (mcpAllow.size > 0) capabilities.push("mcp.tool");
720
+ return {
721
+ workspace,
722
+ shellAllow,
723
+ sqlAllow,
724
+ readAllow,
725
+ httpAllow,
726
+ mcpAllow,
727
+ dryRun: true,
728
+ capabilities,
729
+ resolveInJail(rel) {
730
+ const abs = path3.resolve(workspace, rel);
731
+ const rrel = path3.relative(workspace, abs);
732
+ if (rrel.startsWith("..") || path3.isAbsolute(rrel)) {
733
+ throw new Error(`path escapes workspace: ${rel}`);
734
+ }
735
+ return abs;
736
+ },
737
+ assertReadable(abs) {
738
+ if (!path3.isAbsolute(abs)) {
739
+ throw new Error(`fs.scoped* requires an absolute path: ${abs}`);
740
+ }
741
+ let real;
742
+ try {
743
+ real = fs2.realpathSync(abs);
744
+ } catch {
745
+ throw new Error(`path not found: ${abs}`);
746
+ }
747
+ for (const root of readAllow) {
748
+ const rel = path3.relative(root, real);
749
+ if (rel === "" || !rel.startsWith("..") && !path3.isAbsolute(rel)) {
750
+ return real;
751
+ }
752
+ }
753
+ throw new Error(`path is not inside any --allow-read root: ${abs}`);
754
+ }
755
+ };
756
+ }
757
+
758
+ // src/bridge.ts
759
+ async function startBridge(opts) {
760
+ installProcessGuards();
761
+ const wss = new WebSocketServer({
762
+ host: "127.0.0.1",
763
+ port: opts.port,
764
+ // Origin check — reject anything outside the allow list.
765
+ verifyClient: (info, cb) => {
766
+ const origin = info.origin ?? "";
767
+ if (opts.allowedOrigins.includes(origin)) return cb(true);
768
+ console.warn(`[aarmos] rejected origin: ${origin}`);
769
+ cb(false, 403, "origin not allowed");
770
+ }
771
+ });
772
+ wss.on("error", (err) => {
773
+ console.warn(`[aarmos] server error (continuing): ${err.message}`);
774
+ });
775
+ let activeClient = null;
776
+ wss.on("connection", (ws, req) => {
777
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
778
+ const token = url.searchParams.get("token");
779
+ if (!token || token !== opts.pairingToken) {
780
+ ws.close(4401, "invalid pairing token");
781
+ return;
782
+ }
783
+ if (activeClient && activeClient.readyState === WebSocket.OPEN) {
784
+ ws.close(4409, "another client is already paired");
785
+ return;
786
+ }
787
+ activeClient = ws;
788
+ const session = createSession({
789
+ workspace: opts.workspace,
790
+ extraShellAllow: opts.extraShellAllow,
791
+ sqlAllow: opts.sqlAllow,
792
+ readAllow: opts.readAllow
793
+ });
794
+ console.log("\u25C6 PWA paired");
795
+ const send = (msg) => {
796
+ if (ws.readyState === WebSocket.OPEN) {
797
+ try {
798
+ ws.send(JSON.stringify(msg));
799
+ } catch (err) {
800
+ console.warn(`[aarmos] send failed (client likely gone): ${err.message}`);
801
+ }
802
+ }
803
+ };
804
+ const stopFiles = startFileWatcher(
805
+ session,
806
+ (evt) => send({ type: "event", channel: "fs", event: evt })
807
+ );
808
+ const stopGit = startGitWatcher(
809
+ session,
810
+ (evt) => send({ type: "event", channel: "git", event: evt })
811
+ );
812
+ ws.on("message", async (raw) => {
813
+ let msg;
814
+ try {
815
+ msg = JSON.parse(String(raw));
816
+ } catch {
817
+ return send({ type: "error", error: "invalid json" });
818
+ }
819
+ try {
820
+ const res = await handleRpc(session, msg);
821
+ send(res);
822
+ } catch (err) {
823
+ send({ type: "error", error: err.message });
824
+ }
825
+ });
826
+ ws.on("error", (err) => {
827
+ console.warn(`[aarmos] socket error: ${err.message}`);
828
+ });
829
+ ws.on("close", (code, reason) => {
830
+ stopFiles();
831
+ stopGit();
832
+ if (activeClient === ws) activeClient = null;
833
+ const why = reason?.toString() || `code ${code}`;
834
+ console.log(
835
+ `\u25C7 Client disconnected (${why}). Standing by on ${opts.port} \u2014 rerun pairing from the PWA to reconnect.`
836
+ );
837
+ });
838
+ send({
839
+ type: "hello",
840
+ workspace: opts.workspace,
841
+ capabilities: session.capabilities,
842
+ dryRun: session.dryRun,
843
+ protocol: "aarmos-bridge/1"
844
+ });
845
+ });
846
+ await new Promise((resolve) => wss.once("listening", () => resolve()));
847
+ const address = wss.address();
848
+ const boundPort = typeof address === "object" && address ? address.port : opts.port;
849
+ return { port: boundPort, url: `ws://127.0.0.1:${boundPort}` };
850
+ }
851
+ var guardsInstalled = false;
852
+ function installProcessGuards() {
853
+ if (guardsInstalled) return;
854
+ guardsInstalled = true;
855
+ process.on("uncaughtException", (err) => {
856
+ console.warn(`[aarmos] uncaughtException (kept alive): ${err.message}`);
857
+ });
858
+ process.on("unhandledRejection", (reason) => {
859
+ console.warn(`[aarmos] unhandledRejection (kept alive): ${String(reason)}`);
860
+ });
861
+ process.on("SIGINT", () => {
862
+ console.log("\n\u25C7 Shutting down aarmos bridge. bye.");
863
+ process.exit(0);
864
+ });
865
+ }
866
+
867
+ export {
868
+ handleRpc,
869
+ createSession,
870
+ startBridge
871
+ };