arena-bridge 0.6.6 → 0.6.8

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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to this project are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.6.8
8
+
9
+ ### Added
10
+ - Detailed work log: every call is recorded with its arguments, outcome and duration, and the
11
+ evidence is kept next to the log — screenshots taken on the desktop, the accessibility trees
12
+ that were read, menu trees and the list of Spaces.
13
+ - `audit_log` / `audit_artifact` tools and `GET /audit`, `GET /audit/artifact` endpoints let the
14
+ agent look back at what it saw.
15
+ - `arena-bridge audit` lists the calls, `arena-bridge audit artifacts` lists the saved files,
16
+ `arena-bridge audit <id>` prints one entry, `arena-bridge audit prune` removes old artifacts.
17
+
18
+ ## 0.6.7
19
+
20
+ ### Added
21
+ - `arena-bridge service install --bin ngrok --domain <name>` installs the macOS background
22
+ service with a fixed public address; `arena-bridge service status` now shows the current URL.
23
+
7
24
  ## 0.6.6
8
25
 
9
26
  ### Fixed
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  <img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="license">
13
13
  <img src="https://img.shields.io/badge/node-%3E%3D18-green.svg?style=flat-square" alt="node">
14
14
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen.svg?style=flat-square" alt="dependencies">
15
- <img src="https://img.shields.io/badge/tests-114%20passed-brightgreen.svg?style=flat-square" alt="tests">
15
+ <img src="https://img.shields.io/badge/tests-120%20passed-brightgreen.svg?style=flat-square" alt="tests">
16
16
  </p>
17
17
 
18
18
  <p align="center">
@@ -64,6 +64,18 @@ arena-bridge setup --root ~/projects --root ~/Documents
64
64
 
65
65
  ### В фоне, чтобы не открывать терминал
66
66
 
67
+ Мост можно поднять как службу macOS — он стартует вместе с системой, переживает закрытие
68
+ терминала и поднимается заново после сбоя:
69
+
70
+ ```bash
71
+ arena-bridge service install # облако, случайный адрес
72
+ arena-bridge service install --bin ngrok --domain mymac.ngrok-free.app # постоянный адрес
73
+ arena-bridge service status # состояние и текущий URL
74
+ arena-bridge url # текущий публичный адрес
75
+ arena-bridge service uninstall
76
+ ```
77
+
78
+
67
79
  ```bash
68
80
  arena-bridge service install # launchd: стартует вместе с системой, URL всегда живой
69
81
  arena-bridge url # текущий публичный URL
@@ -83,6 +95,7 @@ arena-bridge setup --root ~/projects
83
95
  | Группа | Инструменты | Что это |
84
96
  | --- | --- | --- |
85
97
  | Файлы | `list_dir` `read_file` `write_file` `edit_file` `delete_path` `search_files` `file_get` `file_put` | работа с файлами в разрешённых папках |
98
+ | Журнал | `audit_log` `audit_artifact` | что делалось, какие снимки и деревья Accessibility сохранены |
86
99
  | Шелл | `bash` `sysinfo` | команды и сведения о машине |
87
100
  | Процессы | `start_process` `read_process_output` `write_process` `kill_process` | фоновые дев-серверы |
88
101
  | Chrome | `chrome_launch` `chrome_profiles` `chrome_attach` `chrome_open` `chrome_navigate` `chrome_snapshot` `chrome_click` `chrome_type` `chrome_press` `chrome_screenshot` `chrome_evaluate` `chrome_tabs` `chrome_stop` | браузер через CDP |
@@ -187,6 +200,25 @@ macOS отдаёт приложениям два разных слоя: карт
187
200
 
188
201
  ---
189
202
 
203
+ ## Журнал работы с артефактами
204
+
205
+ Каждый вызов записывается: аргументы, результат, время выполнения. Рядом с журналом хранятся
206
+ **доказательства** — снимки экрана, деревья Accessibility, деревья меню, список рабочих столов.
207
+
208
+ ```bash
209
+ arena-bridge audit # последние вызовы
210
+ arena-bridge audit artifacts # сохранённые файлы (скриншоты, деревья)
211
+ arena-bridge audit <id> # одна запись целиком
212
+ arena-bridge audit prune # удалить старые артефакты (по умолчанию старше 14 дней)
213
+ arena-bridge audit dir # где всё это лежит
214
+ ```
215
+
216
+ Агенту доступно то же самое: `audit_log` (вызовы) и `audit_artifact` (сам артефакт — PNG в base64
217
+ или дерево Accessibility в JSON), а также `GET /audit` и `GET /audit/artifact?entry=<id>&file=<name>`.
218
+ Полезно, чтобы оглянуться назад: что было на экране и что находилось в интерфейсе.
219
+
220
+ ---
221
+
190
222
  ## Доверенное окно: работать без подтверждений
191
223
 
192
224
  ```bash
@@ -297,6 +329,8 @@ GET /health liveness (без авторизации)
297
329
  GET /tools каталог инструментов с JSON-схемами
298
330
  GET /prompt этот промпт для агента
299
331
  GET /approvals ожидающие подтверждения
332
+ GET /audit журнал работы с артефактами
333
+ GET /audit/artifact сохранённый артефакт (entry=, file=)
300
334
  POST /call {"tool":"bash","args":{...}}
301
335
  POST /exec {"command":"...","cwd":"...","timeout_sec":120}
302
336
  GET|PUT|DELETE /files?path=/abs/path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arena-bridge",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Give a cloud AI agent (Arena.ai Agent Mode, Claude, Cursor, ...) controlled access to your own machine over HTTP/MCP: files, shell, Chrome and the macOS desktop, with an approval gate, trust windows and an audit log.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/audit.mjs CHANGED
@@ -1,20 +1,31 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { CONFIG_DIR, AUDIT_PATH } from './config.mjs';
3
+ import { CONFIG_DIR } from './config.mjs';
4
+
5
+ /** The config directory is resolved on every call so tests and relocations stay isolated. */
6
+ function configDir() {
7
+ return process.env.ARENA_BRIDGE_HOME || CONFIG_DIR;
8
+ }
9
+
10
+ export function auditPath() {
11
+ return path.join(configDir(), 'audit.log');
12
+ }
4
13
 
5
14
  export function audit(entry) {
6
15
  try {
7
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
16
+ const dir = configDir();
17
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
8
18
  const rec = { ts: new Date().toISOString(), ...entry };
9
- fs.appendFileSync(AUDIT_PATH, JSON.stringify(rec) + '\n', { mode: 0o600 });
19
+ fs.appendFileSync(auditPath(), JSON.stringify(rec) + '\n', { mode: 0o600 });
10
20
  } catch {
11
21
  /* auditing must never break the request */
12
22
  }
13
23
  }
14
24
 
15
25
  export function tailAudit(n = 50) {
16
- if (!fs.existsSync(AUDIT_PATH)) return [];
17
- const lines = fs.readFileSync(AUDIT_PATH, 'utf8').trim().split('\n').filter(Boolean);
26
+ const file = auditPath();
27
+ if (!fs.existsSync(file)) return [];
28
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
18
29
  return lines
19
30
  .slice(-n)
20
31
  .map((l) => {
@@ -25,3 +36,76 @@ export function tailAudit(n = 50) {
25
36
  }
26
37
  });
27
38
  }
39
+
40
+ /* ------------------------------------------------------------------ *
41
+ * artifacts: screenshots, accessibility trees and other evidence
42
+ * ------------------------------------------------------------------ */
43
+
44
+ export function auditDir() {
45
+ return path.join(configDir(), 'audit');
46
+ }
47
+
48
+ export function saveArtifact(entryId, kind, name, data, meta = {}) {
49
+ try {
50
+ const dir = path.join(auditDir(), String(entryId));
51
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
+ const file = path.join(dir, path.basename(String(name)));
53
+ fs.writeFileSync(file, data, { mode: 0o600 });
54
+ const bytes = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(String(data));
55
+ return { kind, name: path.basename(String(name)), path: file, bytes, ...meta };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function readArtifact(entryId, name) {
62
+ try {
63
+ const file = path.join(auditDir(), String(entryId), path.basename(String(name)));
64
+ if (!fs.existsSync(file)) return null;
65
+ return { file, data: fs.readFileSync(file) };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function listArtifacts() {
72
+ const dir = auditDir();
73
+ if (!fs.existsSync(dir)) return [];
74
+ const out = [];
75
+ for (const entry of fs.readdirSync(dir)) {
76
+ const sub = path.join(dir, entry);
77
+ let files = [];
78
+ try {
79
+ files = fs
80
+ .readdirSync(sub)
81
+ .filter((f) => fs.statSync(path.join(sub, f)).isFile())
82
+ .map((f) => ({ name: f, bytes: fs.statSync(path.join(sub, f)).size }));
83
+ } catch {
84
+ /* ignore */
85
+ }
86
+ if (files.length) out.push({ entry, files });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ export function pruneArtifacts(days = 14) {
92
+ const dir = auditDir();
93
+ if (!fs.existsSync(dir)) return { removed: 0, freed_bytes: 0 };
94
+ const cutoff = Date.now() - Number(days) * 86400000;
95
+ let removed = 0;
96
+ let freed = 0;
97
+ for (const entry of fs.readdirSync(dir)) {
98
+ const sub = path.join(dir, entry);
99
+ try {
100
+ const st = fs.statSync(sub);
101
+ if (st.mtimeMs < cutoff) {
102
+ freed += st.size;
103
+ fs.rmSync(sub, { recursive: true, force: true });
104
+ removed++;
105
+ }
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ }
110
+ return { removed, freed_bytes: freed };
111
+ }
package/src/browser.mjs CHANGED
@@ -652,6 +652,9 @@ export class BrowserManager {
652
652
  }
653
653
  const { data } = await session.cdp.send('Page.captureScreenshot', params, tab.sessionId);
654
654
  const b64 = String(data || '');
655
+ if (ctx.artifact && b64) {
656
+ ctx.artifact('screenshot', 'page.png', Buffer.from(b64, 'base64'), { source: 'chrome_screenshot' });
657
+ }
655
658
  if (args.path) {
656
659
  const chk = checkPath(args.path, ctx.cfg, ctx.base, { write: true });
657
660
  if (!chk.ok) return { ok: false, error: chk.error, reason: chk.reason };
package/src/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { createHubServer } from './hub.mjs';
9
9
  import { runDevice } from './device.mjs';
10
10
  import { buildPrompt } from './prompt.mjs';
11
11
  import { listApprovals, decide } from './approvals.mjs';
12
- import { tailAudit } from './audit.mjs';
12
+ import { tailAudit, auditDir, listArtifacts, pruneArtifacts } from './audit.mjs';
13
13
  import { startTunnel, isPortOpen } from './tunnel.mjs';
14
14
  import { spawn, execSync } from 'node:child_process';
15
15
  import { parseDuration, trustState, saveTrust, clearTrust } from './trust.mjs';
@@ -377,10 +377,33 @@ function cmdTrust(args) {
377
377
  }
378
378
 
379
379
  function cmdAudit(args) {
380
+ const what = args._[1];
381
+ if (what === 'dir') {
382
+ console.log(auditDir());
383
+ return;
384
+ }
385
+ if (what === 'prune') {
386
+ const res = pruneArtifacts(Number(args.flags.days || 14));
387
+ console.log(`removed ${res.removed} artifact folder(s), ${res.freed_bytes} bytes`);
388
+ return;
389
+ }
390
+ if (what === 'artifacts') {
391
+ for (const item of listArtifacts()) {
392
+ const files = item.files.map((f) => `${f.name} (${f.bytes} B)`).join(', ');
393
+ console.log(`${item.entry} ${files}`);
394
+ }
395
+ return;
396
+ }
397
+ if (what && what.length > 6) {
398
+ const rec = tailAudit(500).find((e) => e.id === what);
399
+ console.log(rec ? JSON.stringify(rec, null, 2) : `no entry with id ${what}`);
400
+ return;
401
+ }
380
402
  const n = Number(args.flags.n || 30);
381
403
  for (const rec of tailAudit(n)) {
404
+ const arts = (rec.artifacts || []).map((a) => a.name).join(' ');
382
405
  console.log(
383
- `${rec.ts} ${String(rec.tool).padEnd(18)} ${String(rec.status).padEnd(16)} ${rec.duration_ms ?? ''}${
406
+ `${rec.ts} ${String(rec.tool).padEnd(18)} ${String(rec.status).padEnd(14)} ${String(rec.duration_ms ?? '').padStart(6)}ms${arts ? ' ' + arts : ''}${
384
407
  rec.error ? ' ' + rec.error : ''
385
408
  }`,
386
409
  );
@@ -440,7 +463,12 @@ async function cmdUpdate(args) {
440
463
  function cmdService(args) {
441
464
  const what = args._[1] || 'status';
442
465
  if (what === 'install') {
443
- const res = serviceInstall();
466
+ const res = serviceInstall({
467
+ bin: args.flags.bin,
468
+ domain: args.flags.domain,
469
+ authtoken: args.flags.authtoken,
470
+ port: args.flags.port,
471
+ });
444
472
  console.log(`\u2713 service installed: ${res.plist}`);
445
473
  console.log(' the bridge now starts with the system and keeps a public URL alive');
446
474
  console.log(' current URL: arena-bridge url');
@@ -454,6 +482,8 @@ function cmdService(args) {
454
482
  const st = serviceStatus();
455
483
  console.log(`service: ${st.installed ? 'installed' : 'not installed'} (${st.running ? 'running' : 'not running'})`);
456
484
  console.log(` plist: ${st.plist}`);
485
+ const current = loadConfig().publicUrl;
486
+ if (current) console.log(` current URL: ${current}`);
457
487
  if (st.detail) console.log(` ${st.detail.split('\n')[0]}`);
458
488
  if (!st.installed) console.log(' install it with: arena-bridge service install');
459
489
  }
package/src/direct.mjs CHANGED
@@ -6,6 +6,7 @@ import { buildPrompt } from './prompt.mjs';
6
6
  import { listApprovals, decide } from './approvals.mjs';
7
7
  import { checkPath } from './policy.mjs';
8
8
  import { loadConfig } from './config.mjs';
9
+ import { tailAudit, readArtifact, auditDir } from './audit.mjs';
9
10
 
10
11
  export function createDirectServer(cfg, engine) {
11
12
  const mcpCtx = {
@@ -29,7 +30,7 @@ export function createDirectServer(cfg, engine) {
29
30
  ok: true,
30
31
  mode: 'direct',
31
32
  device: cfg.device,
32
- version: '0.6.6',
33
+ version: '0.6.8',
33
34
  uptime_sec: Math.round(process.uptime()),
34
35
  });
35
36
  }
@@ -62,6 +63,29 @@ export function createDirectServer(cfg, engine) {
62
63
  });
63
64
  }
64
65
 
66
+ if (req.method === 'GET' && p === '/audit') {
67
+ const limit = Math.min(200, Number(url.searchParams.get('limit')) || 30);
68
+ return sendJson(res, 200, { ok: true, entries: tailAudit(limit), artifacts_dir: auditDir() });
69
+ }
70
+
71
+ if (req.method === 'GET' && p === '/audit/artifact') {
72
+ const entry = url.searchParams.get('entry');
73
+ const file = url.searchParams.get('file');
74
+ if (!entry || !file) {
75
+ return sendJson(res, 400, { ok: false, error: 'entry and file query parameters are required' });
76
+ }
77
+ const art = readArtifact(entry, file);
78
+ if (!art) return sendJson(res, 404, { ok: false, error: 'artifact_not_found' });
79
+ const type = /\.png$/i.test(art.file)
80
+ ? 'image/png'
81
+ : /\.jpe?g$/i.test(art.file)
82
+ ? 'image/jpeg'
83
+ : /\.json$/i.test(art.file)
84
+ ? 'application/json; charset=utf-8'
85
+ : 'text/plain; charset=utf-8';
86
+ return sendRaw(res, 200, art.data, type);
87
+ }
88
+
65
89
  if (req.method === 'GET' && p === '/approvals') {
66
90
  return sendJson(res, 200, { ok: true, approvals: listApprovals() });
67
91
  }
package/src/engine.mjs CHANGED
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { checkPath, resolveTarget, isInside } from './policy.mjs';
7
- import { audit } from './audit.mjs';
7
+ import { audit, saveArtifact, tailAudit, readArtifact, auditDir } from './audit.mjs';
8
8
  import { requestApproval } from './approvals.mjs';
9
9
  import { loadConfig, CONFIG_PATH } from './config.mjs';
10
10
  import { BrowserManager } from './browser.mjs';
@@ -393,6 +393,63 @@ export function toolCatalog() {
393
393
  return { ok: true, path: chk.abs, bytes: data.length };
394
394
  },
395
395
  },
396
+ {
397
+ name: 'audit_log',
398
+ description:
399
+ 'Read the detailed log of what happened on this machine: every tool call with its arguments, outcome and saved artifacts (screenshots, accessibility trees). Read-only.',
400
+ approval: false,
401
+ schema: S(
402
+ {
403
+ limit: { type: 'number', description: 'how many entries to return, default 30' },
404
+ tool: { type: 'string', description: 'only entries of this tool' },
405
+ since: { type: 'string', description: 'only entries newer than this ISO timestamp' },
406
+ },
407
+ [],
408
+ ),
409
+ required: [],
410
+ handler: async (args) => {
411
+ const n = Math.min(200, Number(args.limit) || 30);
412
+ let entries = tailAudit(n * 4);
413
+ if (args.tool) entries = entries.filter((e) => e.tool === args.tool);
414
+ if (args.since) {
415
+ const since = Date.parse(String(args.since));
416
+ if (!Number.isNaN(since)) entries = entries.filter((e) => Date.parse(e.ts) >= since);
417
+ }
418
+ entries = entries.slice(-n);
419
+ return { ok: true, count: entries.length, entries, artifacts_dir: auditDir() };
420
+ },
421
+ },
422
+ {
423
+ name: 'audit_artifact',
424
+ description:
425
+ 'Fetch a saved artifact of a logged call: a screenshot (PNG as base64) or an accessibility tree (JSON). Read-only.',
426
+ approval: false,
427
+ schema: S(
428
+ {
429
+ entry: { type: 'string', description: 'entry id from audit_log' },
430
+ file: { type: 'string', description: 'artifact file name, e.g. "screen.png" or "ax-tree.json"' },
431
+ },
432
+ ['entry', 'file'],
433
+ ),
434
+ required: ['entry', 'file'],
435
+ handler: async (args) => {
436
+ const art = readArtifact(args.entry, args.file);
437
+ if (!art) {
438
+ return { ok: false, error: 'artifact_not_found', hint: 'Run audit_log to see the available artifacts.' };
439
+ }
440
+ if (/\.(png|jpe?g)$/i.test(art.file)) {
441
+ return { ok: true, kind: 'image', file: art.file, bytes: art.data.length, image_base64: art.data.toString('base64') };
442
+ }
443
+ const text = art.data.toString('utf8');
444
+ let json = null;
445
+ try {
446
+ json = JSON.parse(text);
447
+ } catch {
448
+ /* not json */
449
+ }
450
+ return { ok: true, kind: json ? 'json' : 'text', file: art.file, bytes: art.data.length, content: json ?? text.slice(0, 200000) };
451
+ },
452
+ },
396
453
  {
397
454
  name: 'sysinfo',
398
455
  description: 'Read-only information about the remote machine: OS, user, uptime, memory, disks, network interfaces.',
@@ -581,6 +638,17 @@ export class Engine {
581
638
  if (!tool) return { ok: false, error: `unknown tool: ${name}` };
582
639
  const started = Date.now();
583
640
  let approval = null;
641
+ const entryId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${String(name).replace(/[^\w.-]/g, '_')}-${Math.random()
642
+ .toString(36)
643
+ .slice(2, 8)}`;
644
+ const ctx = Object.create(this);
645
+ ctx.entryId = entryId;
646
+ ctx.artifacts = [];
647
+ ctx.artifact = (kind, file, data, meta) => {
648
+ const saved = saveArtifact(entryId, kind, file, data, meta);
649
+ if (saved) ctx.artifacts.push(saved);
650
+ return saved;
651
+ };
584
652
 
585
653
  // pick up policy changes (roots, protected, requireApproval) without a restart
586
654
  try {
@@ -598,16 +666,16 @@ export class Engine {
598
666
  const trust = trustState();
599
667
  if (trustAllows(trust, tool.name, args, this.cfg)) {
600
668
  if (this.cfg.audit !== false) {
601
- audit({ tool: tool.name, args, status: 'ok', via: 'trust_window' });
669
+ audit({ id: entryId, tool: tool.name, args, status: 'ok', via: 'trust_window' });
602
670
  }
603
- const res = await tool.handler(args, this);
671
+ const res = await tool.handler(args, ctx);
604
672
  return res;
605
673
  }
606
674
  approval = await requestApproval(tool.name, args, this.cfg);
607
675
  if (!approval.approved) {
608
676
  const status = approval.rec?.status || 'denied';
609
677
  if (this.cfg.audit !== false) {
610
- audit({ tool: tool.name, args, status: 'approval_denied', approval_status: status });
678
+ audit({ id: entryId, tool: tool.name, args, status: 'approval_denied', approval_status: status });
611
679
  }
612
680
  const pending = status === 'pending' || status === 'timeout' || status === 'expired';
613
681
  return {
@@ -627,21 +695,31 @@ export class Engine {
627
695
  }
628
696
 
629
697
  try {
630
- const res = await tool.handler(args, this);
698
+ const res = await tool.handler(args, ctx);
631
699
  if (this.cfg.audit !== false) {
632
700
  audit({
701
+ id: entryId,
633
702
  tool: tool.name,
634
703
  args,
635
704
  status: res.ok ? 'ok' : 'error',
636
705
  duration_ms: Date.now() - started,
637
706
  exit_code: res.exit_code,
638
707
  error: res.ok ? undefined : res.error,
708
+ artifacts: ctx.artifacts.length ? ctx.artifacts : undefined,
639
709
  });
640
710
  }
641
711
  return res;
642
712
  } catch (e) {
643
713
  if (this.cfg.audit !== false) {
644
- audit({ tool: tool.name, args, status: 'exception', duration_ms: Date.now() - started, error: e.message });
714
+ audit({
715
+ id: entryId,
716
+ tool: tool.name,
717
+ args,
718
+ status: 'exception',
719
+ duration_ms: Date.now() - started,
720
+ error: e.message,
721
+ artifacts: ctx.artifacts.length ? ctx.artifacts : undefined,
722
+ });
645
723
  }
646
724
  return { ok: false, error: e.message };
647
725
  }
package/src/hub.mjs CHANGED
@@ -90,7 +90,7 @@ export function createHubServer(cfg) {
90
90
  return sendJson(res, 200, {
91
91
  ok: true,
92
92
  mode: 'hub',
93
- version: '0.6.6',
93
+ version: '0.6.8',
94
94
  devices: [...devices.keys()],
95
95
  });
96
96
  }
package/src/mac.mjs CHANGED
@@ -284,6 +284,21 @@ export function macTools(cfg = {}) {
284
284
  out.hint += ' No elements found — run mac_permissions: the element list comes from the macOS Accessibility API.';
285
285
  }
286
286
  if (args.include_raw && seen.json) out.raw = seen.json;
287
+ if (ctx.artifact) {
288
+ try {
289
+ if (fs.existsSync(outPath.abs)) {
290
+ ctx.artifact('screenshot', 'screen.png', fs.readFileSync(outPath.abs), { source: 'mac_see' });
291
+ }
292
+ if (seen.json) {
293
+ ctx.artifact('ax-tree', 'ax-tree.json', JSON.stringify(seen.json, null, 2), {
294
+ source: 'mac_see',
295
+ elements: seen.elements.length,
296
+ });
297
+ }
298
+ } catch {
299
+ /* artifacts are best effort */
300
+ }
301
+ }
287
302
  return out;
288
303
  },
289
304
  },
@@ -593,11 +608,18 @@ export function macTools(cfg = {}) {
593
608
  [],
594
609
  ),
595
610
  required: [],
596
- handler: async (args) => {
611
+ handler: async (args, ctx) => {
597
612
  const p = checkPeek(peek());
598
613
  if (!p.ok) return p;
599
614
  const seen = await searchSee(args);
600
615
  const matches = seen.elements.filter((e) => matchElement(e, args)).slice(0, Number(args.limit) || 25);
616
+ if (ctx.artifact && seen.json) {
617
+ ctx.artifact('ax-tree', 'ax-tree.json', JSON.stringify(seen.json, null, 2), {
618
+ source: 'mac_element',
619
+ elements: seen.elements.length,
620
+ query: args.name || args.role || null,
621
+ });
622
+ }
601
623
  return {
602
624
  ok: true,
603
625
  matches,
@@ -645,7 +667,7 @@ export function macTools(cfg = {}) {
645
667
  approval: false,
646
668
  schema: S({ app: { type: 'string' } }, []),
647
669
  required: [],
648
- handler: async (args) => {
670
+ handler: async (args, ctx) => {
649
671
  const p = checkPeek(peek());
650
672
  if (!p.ok) return p;
651
673
  const flags = menuFlags(args);
@@ -659,6 +681,9 @@ export function macTools(cfg = {}) {
659
681
  { timeout: 30 },
660
682
  );
661
683
  const json = parseJson(res.stdout);
684
+ if (ctx.artifact && json) {
685
+ ctx.artifact('menu-tree', 'menu-tree.json', JSON.stringify(json, null, 2), { source: 'mac_menu' });
686
+ }
662
687
  return {
663
688
  ok: res.exit_code === 0,
664
689
  menus: json ? (json.menus || json.data?.menus || json.data?.menu_structure || json) : undefined,
@@ -716,7 +741,7 @@ export function macTools(cfg = {}) {
716
741
  approval: false,
717
742
  schema: S({}, []),
718
743
  required: [],
719
- handler: async () => {
744
+ handler: async (args, ctx) => {
720
745
  const p = checkPeek(peek());
721
746
  if (!p.ok) return p;
722
747
  const res = await runVariants(
@@ -729,6 +754,9 @@ export function macTools(cfg = {}) {
729
754
  { timeout: 30 },
730
755
  );
731
756
  const json = parseJson(res.stdout);
757
+ if (ctx.artifact && json) {
758
+ ctx.artifact('spaces', 'spaces.json', JSON.stringify(json, null, 2), { source: 'mac_spaces' });
759
+ }
732
760
  return {
733
761
  ok: res.exit_code === 0,
734
762
  spaces: json ? (json.spaces || json.data?.spaces || json) : undefined,
@@ -744,7 +772,7 @@ export function macTools(cfg = {}) {
744
772
  approval: true,
745
773
  schema: S({ to: { type: 'string', description: 'space id or index from mac_spaces' } }, ['to']),
746
774
  required: ['to'],
747
- handler: async (args) => {
775
+ handler: async (args, ctx) => {
748
776
  const p = checkPeek(peek());
749
777
  if (!p.ok) return p;
750
778
  const res = await runVariants(
@@ -775,14 +803,16 @@ export function macTools(cfg = {}) {
775
803
  [],
776
804
  ),
777
805
  required: [],
778
- handler: async (args) => {
806
+ handler: async (args, ctx) => {
779
807
  const p = checkPeek(peek());
780
808
  if (!p.ok) return p;
781
809
  const limit = Math.min(120, Math.max(1, Number(args.timeout) || 20));
782
810
  const deadline = Date.now() + limit * 1000;
783
811
  let scanned = 0;
812
+ let last = null;
784
813
  while (Date.now() < deadline) {
785
814
  const seen = await searchSee(args);
815
+ last = seen;
786
816
  scanned = seen.elements.length;
787
817
  const hit = seen.elements.find((e) => matchElement(e, args));
788
818
  if (args.disappear) {
@@ -792,6 +822,12 @@ export function macTools(cfg = {}) {
792
822
  }
793
823
  await new Promise((r) => setTimeout(r, 2000));
794
824
  }
825
+ if (ctx.artifact && last?.json) {
826
+ ctx.artifact('ax-tree', 'ax-tree.json', JSON.stringify(last.json, null, 2), {
827
+ source: 'mac_wait',
828
+ elements: scanned,
829
+ });
830
+ }
795
831
  return {
796
832
  ok: false,
797
833
  found: false,
@@ -808,7 +844,7 @@ export function macTools(cfg = {}) {
808
844
  approval: false,
809
845
  schema: S({}, []),
810
846
  required: [],
811
- handler: async () => {
847
+ handler: async (args, ctx) => {
812
848
  const p = checkPeek(peek());
813
849
  if (!p.ok) {
814
850
  return {
package/src/mcp.mjs CHANGED
@@ -48,7 +48,7 @@ export async function handleMcp(req, res, ctx) {
48
48
  mcpResponse(id, {
49
49
  protocolVersion: PROTOCOL_VERSION,
50
50
  capabilities: { tools: { listChanged: false } },
51
- serverInfo: { name: 'arena-bridge', version: '0.6.6' },
51
+ serverInfo: { name: 'arena-bridge', version: '0.6.8' },
52
52
  instructions:
53
53
  'Tools operate the user\'s own machine. Dangerous operations may return ' +
54
54
  'PROTECTED and require a local human approval.',
package/src/prompt.mjs CHANGED
@@ -97,6 +97,13 @@ ${tools.join('\n')}
97
97
  9. \`mac_spaces\` lists the macOS virtual desktops; \`mac_space_switch\` moves the agent to another
98
98
  Space so the user keeps the screen they are looking at.
99
99
 
100
+ ## Work log with evidence
101
+ Every call is logged with its arguments, result and duration. Screenshots taken on the desktop and
102
+ the accessibility trees that were read are stored next to the log.
103
+ - \`audit_log\` — the recent calls (filter by tool or time).
104
+ - \`audit_artifact\` — fetch a saved artifact: a screenshot (base64 PNG) or an accessibility tree (JSON).
105
+ Use them when you need to look back at what was on the screen or what the interface contained.
106
+
100
107
  ## Approvals
101
108
  ${trustLine}
102
109
  - When a call answers \`APPROVAL_PENDING\` it carries an \`approval_id\`: tell the user to run
package/src/service.mjs CHANGED
@@ -21,9 +21,14 @@ function cliEntry() {
21
21
  return path.join(here, '..', 'bin', 'arena-bridge.mjs');
22
22
  }
23
23
 
24
- function plistXml() {
24
+ function plistXml({ bin, domain, authtoken, port } = {}) {
25
25
  const cli = cliEntry();
26
26
  const logs = logDir();
27
+ const extra = [];
28
+ if (bin) extra.push('--bin', String(bin));
29
+ if (domain) extra.push('--domain', String(domain));
30
+ if (authtoken) extra.push('--authtoken', String(authtoken));
31
+ if (port) extra.push('--port', String(port));
27
32
  return `<?xml version="1.0" encoding="UTF-8"?>
28
33
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
29
34
  <plist version="1.0">
@@ -34,6 +39,7 @@ function plistXml() {
34
39
  <string>${process.execPath}</string>
35
40
  <string>${cli}</string>
36
41
  <string>tunnel</string>
42
+ ${extra.map((a) => ` <string>${a}</string>`).join('\n')}
37
43
  </array>
38
44
  <key>RunAtLoad</key><true/>
39
45
  <key>KeepAlive</key><true/>
@@ -63,12 +69,12 @@ export function serviceStatus() {
63
69
  return { installed, running, plist: plistPath(), detail: out.trim().slice(0, 600) };
64
70
  }
65
71
 
66
- export function serviceInstall() {
72
+ export function serviceInstall(options = {}) {
67
73
  ensureDirs();
68
74
  fs.mkdirSync(logDir(), { recursive: true });
69
75
  const dir = path.dirname(plistPath());
70
76
  fs.mkdirSync(dir, { recursive: true });
71
- fs.writeFileSync(plistPath(), plistXml());
77
+ fs.writeFileSync(plistPath(), plistXml(options));
72
78
  launchctl(['bootout', `gui/${process.getuid()}/${LABEL}`], { quiet: true });
73
79
  const out = launchctl(['bootstrap', `gui/${process.getuid()}`, plistPath()], { quiet: true });
74
80
  launchctl(['enable', `gui/${process.getuid()}/${LABEL}`], { quiet: true });