anyagent-bridge 0.5.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.
Files changed (42) hide show
  1. package/.env.example +81 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/bin/anyagent-bridge.js +127 -0
  5. package/client/index.html +525 -0
  6. package/config.example.json +69 -0
  7. package/docs/INSTALL.md +138 -0
  8. package/docs/ROADMAP.md +168 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/WALKTHROUGH.md +82 -0
  11. package/docs/screenshots/.gitkeep +3 -0
  12. package/docs/screenshots/01-startup-banner.png +0 -0
  13. package/docs/screenshots/02-terminal-view.png +0 -0
  14. package/docs/screenshots/03-agent-running.png +0 -0
  15. package/docs/screenshots/04-mobile.png +0 -0
  16. package/package.json +57 -0
  17. package/server/auth/index.js +20 -0
  18. package/server/auth/manager.js +448 -0
  19. package/server/auth/oauth.js +154 -0
  20. package/server/auth/providers/github.js +59 -0
  21. package/server/auth/providers/google.js +44 -0
  22. package/server/auth/sessions.js +160 -0
  23. package/server/auth/store.js +135 -0
  24. package/server/auth/totp.js +140 -0
  25. package/server/index.js +1779 -0
  26. package/server/safety/audit.js +139 -0
  27. package/server/safety/clientip.js +73 -0
  28. package/server/safety/index.js +17 -0
  29. package/server/safety/manager.js +507 -0
  30. package/server/safety/redact.js +153 -0
  31. package/server/safety/sandbox.js +130 -0
  32. package/server/tunnel/adapters/cloudflare-quick.js +40 -0
  33. package/server/tunnel/adapters/cloudflared-named.js +49 -0
  34. package/server/tunnel/adapters/devtunnel.js +54 -0
  35. package/server/tunnel/adapters/tailscale.js +42 -0
  36. package/server/tunnel/base-adapter.js +185 -0
  37. package/server/tunnel/detect.js +65 -0
  38. package/server/tunnel/index.js +15 -0
  39. package/server/tunnel/manager.js +321 -0
  40. package/server/tunnel/registry.js +31 -0
  41. package/test/stage4-boot.js +98 -0
  42. package/test/stage4-smoke.js +267 -0
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Stage 4 smoke tests — zero-dependency node assertions for the safety subsystem.
3
+ * Run: node test/stage4-smoke.js
4
+ */
5
+ 'use strict';
6
+ const assert = require('assert');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+
11
+ const ROOT = path.join(__dirname, '..');
12
+ let pass = 0, fail = 0;
13
+ function t(name, fn) {
14
+ try { fn(); console.log(` ok ${name}`); pass++; }
15
+ catch (e) { console.error(` FAIL ${name}\n ${e.message}`); fail++; }
16
+ }
17
+
18
+ const { createRedactor } = require(path.join(ROOT, 'server/safety/redact'));
19
+ const { resolveClientIP, normalizeIP } = require(path.join(ROOT, 'server/safety/clientip'));
20
+ const sandbox = require(path.join(ROOT, 'server/safety/sandbox'));
21
+ const { createAuditLog, FILE_RE } = require(path.join(ROOT, 'server/safety/audit'));
22
+ const { createSafetyManager } = require(path.join(ROOT, 'server/safety'));
23
+
24
+ console.log('\n── redaction ──');
25
+ t('scrub masks an AWS key', () => {
26
+ const r = createRedactor({});
27
+ assert(r.scrub('id=AKIAIOSFODNN7EXAMPLE here').includes('[REDACTED:aws-key]'));
28
+ assert(!r.scrub('id=AKIAIOSFODNN7EXAMPLE here').includes('AKIAIOSFODNN7EXAMPLE'));
29
+ });
30
+ t('scrub masks an openai-style key', () => {
31
+ const r = createRedactor({});
32
+ const out = r.scrub('export OPENAI=sk-abcdefghijklmnopqrstuvwxyz012345');
33
+ assert(out.includes('[REDACTED:openai-key]'), out);
34
+ });
35
+ t('scrub masks a PEM private key block (multi-line)', () => {
36
+ const r = createRedactor({});
37
+ const pem = '-----BEGIN PRIVATE KEY-----\nMIIBVgIBADAN\nBgkqhki=\n-----END PRIVATE KEY-----';
38
+ const out = r.scrub('before ' + pem + ' after');
39
+ assert(out.includes('[REDACTED:private-key]'), out);
40
+ assert(!out.includes('MIIBVgIBADAN'), out);
41
+ });
42
+ t('scrub masks the bridge\'s own token by exact match', () => {
43
+ const tok = 'a'.repeat(64);
44
+ const r = createRedactor({ extraSecrets: [tok] });
45
+ assert.strictEqual(r.scrub(`url?token=${tok}`).includes(tok), false);
46
+ assert(r.scrub(`url?token=${tok}`).includes('[REDACTED:bridge-secret]'));
47
+ });
48
+ t('stream: non-secret text passes through unchanged (push+flush == input)', () => {
49
+ const r = createRedactor({});
50
+ const s = r.createStream();
51
+ const input = 'hello world\nsecond line\n$ ';
52
+ let out = '';
53
+ // feed in awkward 3-char slices
54
+ for (let i = 0; i < input.length; i += 3) out += s.push(input.slice(i, i + 3));
55
+ out += s.flush();
56
+ assert.strictEqual(out, input, JSON.stringify(out));
57
+ });
58
+ t('stream: secret split across two chunks is still redacted', () => {
59
+ const r = createRedactor({});
60
+ const s = r.createStream();
61
+ const secret = 'sk-abcdefghijklmnopqrstuvwxyz012345';
62
+ let out = '';
63
+ out += s.push('token=' + secret.slice(0, 8)); // 'token=sk-abcde'
64
+ out += s.push(secret.slice(8) + '\n'); // rest + boundary
65
+ out += s.flush();
66
+ assert(!out.includes(secret), 'raw secret leaked: ' + out);
67
+ assert(out.includes('[REDACTED:openai-key]'), out);
68
+ });
69
+ t('stream: bridge token split across chunks is redacted', () => {
70
+ const tok = 'b'.repeat(64);
71
+ const r = createRedactor({ extraSecrets: [tok] });
72
+ const s = r.createStream();
73
+ let out = '';
74
+ out += s.push('X' + tok.slice(0, 20));
75
+ out += s.push(tok.slice(20) + ' done\n');
76
+ out += s.flush();
77
+ assert(!out.includes(tok), 'token leaked');
78
+ assert(out.includes('[REDACTED:bridge-secret]'), out);
79
+ });
80
+ t('stream: a chunk ending mid-token holds it back (not emitted raw), flush redacts', () => {
81
+ const r = createRedactor({});
82
+ const s = r.createStream();
83
+ const secret = 'sk-abcdefghijklmnopqrstuvwxyz012345'; // >= 20 chars after sk- → a real match
84
+ const part = s.push('export KEY=' + secret); // stream ends mid-token, no boundary
85
+ assert(!part.includes(secret), 'partial token leaked before boundary: ' + part);
86
+ const drained = s.flush();
87
+ const all = part + drained;
88
+ assert(!all.includes(secret), 'raw secret leaked: ' + all);
89
+ assert(all.includes('[REDACTED:openai-key]'), all);
90
+ });
91
+
92
+ t('stream: a secret straddling the maxHold cut does not leak a raw fragment (H-01)', () => {
93
+ const tok = 'Z'.repeat(64); // a bridge secret
94
+ const r = createRedactor({ extraSecrets: [tok], maxHoldBytes: 256 });
95
+ const s = r.createStream();
96
+ // a long uninterrupted token-char run with the secret at the forced-cut boundary
97
+ let out = '';
98
+ out += s.push('q'.repeat(230) + tok + 'q'.repeat(230));
99
+ out += s.flush();
100
+ assert(!out.includes(tok), 'raw bridge token leaked across the maxHold boundary');
101
+ });
102
+
103
+ console.log('\n── clientip ──');
104
+ t('normalizeIP strips ::ffff: mapping', () => {
105
+ assert.strictEqual(normalizeIP('::ffff:127.0.0.1'), '127.0.0.1');
106
+ });
107
+ t('trustProxy false ignores XFF, uses socket', () => {
108
+ const req = { headers: { 'x-forwarded-for': '1.2.3.4' }, socket: { remoteAddress: '9.9.9.9' } };
109
+ assert.strictEqual(resolveClientIP(req, false), '9.9.9.9');
110
+ });
111
+ t('trustProxy true takes the rightmost (nearest) XFF entry', () => {
112
+ const req = { headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2, 3.3.3.3' }, socket: { remoteAddress: '9.9.9.9' } };
113
+ assert.strictEqual(resolveClientIP(req, true), '3.3.3.3');
114
+ });
115
+ t('trustProxy N=2 takes the 2nd-from-right XFF entry', () => {
116
+ const req = { headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2, 3.3.3.3' }, socket: { remoteAddress: '9.9.9.9' } };
117
+ assert.strictEqual(resolveClientIP(req, 2), '2.2.2.2');
118
+ });
119
+ t('no XFF falls back to socket even when trusting', () => {
120
+ const req = { headers: {}, socket: { remoteAddress: '::ffff:8.8.8.8' } };
121
+ assert.strictEqual(resolveClientIP(req, true), '8.8.8.8');
122
+ });
123
+
124
+ console.log('\n── sandbox argv ──');
125
+ const sbCfg = {
126
+ enabled: true, image: 'demo:latest', network: 'bridge', mountMode: 'rw', workdir: '/workspace',
127
+ shell: null, memory: '2g', cpus: '2', pidsLimit: 512, noNewPrivileges: true,
128
+ readOnlyRootfs: false, dropAllCaps: false, runAsHostUser: false, extraArgs: []
129
+ };
130
+ t('buildDockerArgs has run --rm -it, name, mount, limits, image, shell', () => {
131
+ const args = sandbox.buildDockerArgs({ containerName: 'aab-x-sess-1-ab', hostProjectDir: '/tmp/proj', image: 'demo:latest', passthroughNames: ['ANTHROPIC_API_KEY'], cfg: sbCfg });
132
+ const j = args.join(' ');
133
+ assert(args[0] === 'run' && args.includes('--rm') && args.includes('-it'), j);
134
+ assert(j.includes('--name aab-x-sess-1-ab'), j);
135
+ assert(j.includes('-v /tmp/proj:/workspace') && j.includes('-w /workspace'), j);
136
+ assert(j.includes('--network bridge') && j.includes('--memory 2g') && j.includes('--pids-limit 512'), j);
137
+ assert(j.includes('--security-opt no-new-privileges'), j);
138
+ assert(args[args.length - 2] === 'demo:latest' || args.includes('demo:latest'), j);
139
+ assert(j.endsWith('/bin/sh -l'), j);
140
+ });
141
+ t('passthrough uses -e NAME form (value NOT in argv)', () => {
142
+ process.env.__AAB_TEST_SECRET = 'supersecretvalue';
143
+ const names = sandbox.resolvePassthrough(['__AAB_TEST_SECRET'], process.env);
144
+ const args = sandbox.buildDockerArgs({ containerName: 'c', hostProjectDir: '/tmp/p', image: 'i', passthroughNames: names, cfg: sbCfg });
145
+ assert(args.includes('__AAB_TEST_SECRET'), 'name missing');
146
+ assert(!args.join(' ').includes('supersecretvalue'), 'secret VALUE leaked into argv');
147
+ delete process.env.__AAB_TEST_SECRET;
148
+ });
149
+ t('resolvePassthrough drops BRIDGE_* / AUTH_TOKEN even if listed', () => {
150
+ process.env.BRIDGE_AUTH_TOKEN = 'x'; process.env.AUTH_TOKEN = 'y';
151
+ const names = sandbox.resolvePassthrough(['BRIDGE_AUTH_TOKEN', 'AUTH_TOKEN', 'PATH'], process.env);
152
+ assert(!names.includes('BRIDGE_AUTH_TOKEN') && !names.includes('AUTH_TOKEN'), names.join(','));
153
+ delete process.env.BRIDGE_AUTH_TOKEN; delete process.env.AUTH_TOKEN;
154
+ });
155
+ t('buildClientEnv is minimal (no BRIDGE_*), keeps PATH + passthrough', () => {
156
+ process.env.BRIDGE_SOMETHING = 'z'; process.env.__AAB_PASS = 'ok';
157
+ const env = sandbox.buildClientEnv(['__AAB_PASS'], process.env);
158
+ assert(env.PATH !== undefined, 'PATH missing');
159
+ assert(env.BRIDGE_SOMETHING === undefined, 'BRIDGE_ leaked into client env');
160
+ assert(env.__AAB_PASS === 'ok', 'passthrough missing');
161
+ delete process.env.BRIDGE_SOMETHING; delete process.env.__AAB_PASS;
162
+ });
163
+ t('isSandboxableDir refuses HOME and base roots', () => {
164
+ const home = os.homedir();
165
+ assert.strictEqual(sandbox.isSandboxableDir(home, [home]), false);
166
+ assert.strictEqual(sandbox.isSandboxableDir(path.join(home, 'proj'), [home]), true);
167
+ assert.strictEqual(sandbox.isSandboxableDir(null, [home]), false);
168
+ });
169
+ t('readOnlyRootfs / dropAllCaps only present when opted in', () => {
170
+ const plain = sandbox.buildDockerArgs({ containerName: 'c', hostProjectDir: '/tmp/p', image: 'i', passthroughNames: [], cfg: sbCfg });
171
+ assert(!plain.includes('--read-only') && !plain.includes('--cap-drop'), 'hardening leaked by default');
172
+ const hard = sandbox.buildDockerArgs({ containerName: 'c', hostProjectDir: '/tmp/p', image: 'i', passthroughNames: [], cfg: { ...sbCfg, readOnlyRootfs: true, dropAllCaps: true } });
173
+ assert(hard.includes('--read-only') && hard.includes('--cap-drop') && hard.includes('ALL'), hard.join(' '));
174
+ });
175
+
176
+ console.log('\n── audit log ──');
177
+ t('writes JSONL, redacts secrets, flushSync drains, retention regex strict', () => {
178
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-audit-'));
179
+ const tok = 'c'.repeat(64);
180
+ const r = createRedactor({ extraSecrets: [tok] });
181
+ const log = createAuditLog({ dir, scrub: (s) => r.scrub(s), maxFileBytes: 1024 * 1024, retentionDays: 30 });
182
+ log.record({ action: 'file.write', target: `/x?token=${tok}`, actor: { type: 'token' }, status: 200 });
183
+ log.flushSync();
184
+ const files = fs.readdirSync(dir).filter(f => FILE_RE.test(f));
185
+ assert(files.length === 1, 'expected one audit file, got ' + files.length);
186
+ const body = fs.readFileSync(path.join(dir, files[0]), 'utf8').trim();
187
+ const entry = JSON.parse(body.split('\n')[0]);
188
+ assert.strictEqual(entry.action, 'file.write');
189
+ assert(!body.includes(tok), 'token leaked into audit log');
190
+ assert(body.includes('[REDACTED:bridge-secret]'), 'redaction not applied');
191
+ assert(FILE_RE.test('audit-2026-06-26.jsonl') && FILE_RE.test('audit-2026-06-26.3.jsonl'));
192
+ assert(!FILE_RE.test('notes.jsonl') && !FILE_RE.test('audit.txt'));
193
+ fs.rmSync(dir, { recursive: true, force: true });
194
+ });
195
+
196
+ console.log('\n── manager: byte-identical-when-off ──');
197
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-data-'));
198
+ const offDeps = { logger: { warn() {}, error() {}, log() {} }, dataDir, baseShell: '/bin/bash', blockedDirs: [os.homedir()], secrets: {}, isOperator: () => true, getClientIP: () => 'ip' };
199
+ t('disabled manager: getStatus() === null', () => {
200
+ const m = createSafetyManager({ enabled: false }, offDeps);
201
+ assert.strictEqual(m.getStatus(), null);
202
+ });
203
+ t('disabled manager: spawnSpecFor() === null (session keeps host spawn)', () => {
204
+ const m = createSafetyManager({ enabled: false }, offDeps);
205
+ assert.strictEqual(m.spawnSpecFor({ sessionId: 1 }, '/tmp/p', { A: 1 }), null);
206
+ });
207
+ t('disabled manager: newLiveStream() === null', () => {
208
+ const m = createSafetyManager({ enabled: false }, offDeps);
209
+ assert.strictEqual(m.newLiveStream(), null);
210
+ });
211
+ t('disabled manager: handleWsMessage() === false', () => {
212
+ const m = createSafetyManager({ enabled: false }, offDeps);
213
+ assert.strictEqual(m.handleWsMessage({ type: 'panic' }, {}), false);
214
+ });
215
+ t('disabled manager: canLaunchAgent() === true', () => {
216
+ const m = createSafetyManager({ enabled: false }, offDeps);
217
+ assert.strictEqual(m.canLaunchAgent(), true);
218
+ });
219
+ t('disabled manager: installAuditMiddleware adds nothing', () => {
220
+ const m = createSafetyManager({ enabled: false }, offDeps);
221
+ let used = 0; const app = { use() { used++; } };
222
+ m.installAuditMiddleware(app);
223
+ assert.strictEqual(used, 0);
224
+ });
225
+ t('disabled manager: registerRoutes adds nothing', () => {
226
+ const m = createSafetyManager({ enabled: false }, offDeps);
227
+ let routes = 0; const app = { get() { routes++; }, post() { routes++; } };
228
+ m.registerRoutes(app, {});
229
+ assert.strictEqual(routes, 0);
230
+ });
231
+ t('disabled manager: bootSummaryLines() === []', () => {
232
+ const m = createSafetyManager({ enabled: false }, offDeps);
233
+ assert.deepStrictEqual(m.bootSummaryLines(), []);
234
+ });
235
+
236
+ console.log('\n── manager: enabled behavior ──');
237
+ t('enabled manager (sandbox off): getStatus() shape', () => {
238
+ const m = createSafetyManager({ enabled: true, audit: { enabled: false }, sandbox: { enabled: false } }, offDeps);
239
+ const s = m.getStatus();
240
+ assert(s && s.sandbox && s.sandbox.enabled === false);
241
+ assert(s.killSwitch && s.killSwitch.locked === false);
242
+ });
243
+ t('lock gates canLaunchAgent + persists, unlock clears', () => {
244
+ const dd = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-lock-'));
245
+ const m = createSafetyManager({ enabled: true, killSwitch: { enabled: true, persistLock: true } }, { ...offDeps, dataDir: dd });
246
+ m._setLock(true);
247
+ assert.strictEqual(m.canLaunchAgent(), false);
248
+ assert(fs.existsSync(path.join(dd, 'safety-lock.json')), 'lock not persisted');
249
+ // a fresh manager on the same dataDir loads the lock
250
+ const m2 = createSafetyManager({ enabled: true, killSwitch: { enabled: true, persistLock: true } }, { ...offDeps, dataDir: dd });
251
+ assert.strictEqual(m2.locked, true, 'lock not restored across restart');
252
+ m2._setLock(false);
253
+ assert(!fs.existsSync(path.join(dd, 'safety-lock.json')), 'lock file not cleared');
254
+ fs.rmSync(dd, { recursive: true, force: true });
255
+ });
256
+ t('non-operator WS panic is refused (returns true, does not act)', () => {
257
+ const m = createSafetyManager({ enabled: true, killSwitch: { enabled: true } }, { ...offDeps, isOperator: () => false });
258
+ let sent = null;
259
+ const consumed = m.handleWsMessage({ type: 'panic' }, { principal: { type: 'session' }, ws: { send: (s) => { sent = s; } } });
260
+ assert.strictEqual(consumed, true);
261
+ assert(sent && sent.includes('Operator only'), sent);
262
+ });
263
+
264
+ fs.rmSync(dataDir, { recursive: true, force: true });
265
+
266
+ console.log(`\n──────────────\n ${pass} passed, ${fail} failed\n`);
267
+ process.exit(fail ? 1 : 0);