@wenrwa/marketplace-cli 0.7.0 → 0.7.2
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/dist/__tests__/bridge-attach-file.test.d.ts +1 -0
- package/dist/__tests__/bridge-attach-file.test.js +194 -0
- package/dist/__tests__/bridge-browser-relay.test.js +81 -1
- package/dist/__tests__/bridge-browser-tabs.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-tabs.test.js +206 -0
- package/dist/bridge/attachmentWriter.d.ts +54 -0
- package/dist/bridge/attachmentWriter.js +172 -0
- package/dist/bridge/browserDriver.d.ts +8 -0
- package/dist/bridge/browserDriver.js +82 -25
- package/dist/bridge/browserHost.d.ts +54 -0
- package/dist/bridge/browserHost.js +136 -0
- package/dist/bridge/relayClient.d.ts +13 -0
- package/dist/bridge/relayClient.js +61 -2
- package/dist/bridge/server.js +8 -0
- package/dist/commands/bridge.js +12 -1
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
/**
|
|
37
|
+
* Terminal attach — `attach-file` saves an image next to the session's agent
|
|
38
|
+
* and answers `file-attached` with its absolute path. Both lanes: the local
|
|
39
|
+
* loopback server and the relay client (hosted container) share one handler,
|
|
40
|
+
* and both are exercised here end to end against a REAL temp folder so the
|
|
41
|
+
* bytes on disk are compared with the bytes sent.
|
|
42
|
+
*/
|
|
43
|
+
const ws_1 = __importStar(require("ws"));
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const server_1 = require("../bridge/server");
|
|
48
|
+
const relayClient_1 = require("../bridge/relayClient");
|
|
49
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
50
|
+
const attachmentWriter_1 = require("../bridge/attachmentWriter");
|
|
51
|
+
class FakePty {
|
|
52
|
+
written = [];
|
|
53
|
+
write(d) { this.written.push(d); }
|
|
54
|
+
resize() { }
|
|
55
|
+
kill() { }
|
|
56
|
+
onData() { }
|
|
57
|
+
onExit() { }
|
|
58
|
+
}
|
|
59
|
+
// A real (tiny) PNG: signature + IHDR + IDAT + IEND for a 1x1 pixel, plus
|
|
60
|
+
// every byte value so a lossy encoding would be caught.
|
|
61
|
+
const PNG_1PX = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8cfc0f01f0005000201a5f3b4d60000000049454e44ae426082', 'hex');
|
|
62
|
+
const PNG = Buffer.concat([PNG_1PX, Buffer.from(Array.from({ length: 256 }, (_, i) => i))]);
|
|
63
|
+
function frameQueue(ws) {
|
|
64
|
+
const frames = [];
|
|
65
|
+
const waiters = [];
|
|
66
|
+
ws.on('message', (raw) => {
|
|
67
|
+
const f = JSON.parse(String(raw));
|
|
68
|
+
const w = waiters.shift();
|
|
69
|
+
if (w)
|
|
70
|
+
w(f);
|
|
71
|
+
else
|
|
72
|
+
frames.push(f);
|
|
73
|
+
});
|
|
74
|
+
return { next: () => (frames.length ? Promise.resolve(frames.shift()) : new Promise((r) => waiters.push(r))) };
|
|
75
|
+
}
|
|
76
|
+
let tmp;
|
|
77
|
+
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'attach-'))); });
|
|
78
|
+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
79
|
+
describe('attachmentWriter — pure rules', () => {
|
|
80
|
+
it('sniffs the type from magic bytes, never the name', () => {
|
|
81
|
+
expect((0, attachmentWriter_1.sniffImageExt)(PNG)).toBe('png');
|
|
82
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]))).toBe('jpg');
|
|
83
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.from('GIF89a......'))).toBe('gif');
|
|
84
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBPVP8 ')]))).toBe('webp');
|
|
85
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'))).toBeNull();
|
|
86
|
+
expect((0, attachmentWriter_1.sniffImageExt)(Buffer.from('%PDF-1.7'))).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
it('makes a stem that needs no escaping and cannot traverse', () => {
|
|
89
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('Screenshot 2026-10-04 at 9.41.12 AM.png')).toBe('Screenshot-2026-10-04-at-9.41.12-AM');
|
|
90
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('../../etc/passwd')).toBe('passwd');
|
|
91
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('..\\..\\x.png')).toBe('x');
|
|
92
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('.hidden.png')).toBe('hidden');
|
|
93
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('日本語.png')).toBe('image');
|
|
94
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('')).toBe('image');
|
|
95
|
+
expect((0, attachmentWriter_1.safeAttachmentStem)('a'.repeat(90) + '.png')).toHaveLength(40);
|
|
96
|
+
});
|
|
97
|
+
it('writes byte-identical bytes under .hiveop/attachments with a self-ignoring .gitignore', () => {
|
|
98
|
+
const r = (0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'shot one.png', dataBase64: PNG.toString('base64') });
|
|
99
|
+
expect(r.ok).toBe(true);
|
|
100
|
+
if (!r.ok)
|
|
101
|
+
return;
|
|
102
|
+
expect(path.dirname(r.path)).toBe(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR));
|
|
103
|
+
expect(path.basename(r.path)).toMatch(/^\d{8}-\d{6}-[0-9a-f]{4}-shot-one\.png$/);
|
|
104
|
+
expect(fs.readFileSync(r.path).equals(PNG)).toBe(true);
|
|
105
|
+
expect(fs.readFileSync(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR, '.gitignore'), 'utf8')).toBe('*\n');
|
|
106
|
+
// No temp files left behind.
|
|
107
|
+
expect(fs.readdirSync(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR)).filter((f) => f.endsWith('.tmp'))).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
it('names the extension from the bytes even when the name lies', () => {
|
|
110
|
+
const r = (0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'photo.jpg', dataBase64: PNG.toString('base64') });
|
|
111
|
+
expect(r.ok && r.path.endsWith('-photo.png')).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
it('refuses non-images and oversize files without writing anything', () => {
|
|
114
|
+
expect((0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'x.png', dataBase64: Buffer.from('not an image').toString('base64') }))
|
|
115
|
+
.toEqual({ ok: false, reason: 'not-image' });
|
|
116
|
+
const big = Buffer.concat([PNG_1PX, Buffer.alloc(attachmentWriter_1.MAX_ATTACHMENT_BYTES)]);
|
|
117
|
+
expect((0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'x.png', dataBase64: big.toString('base64') }))
|
|
118
|
+
.toEqual({ ok: false, reason: 'too-large' });
|
|
119
|
+
expect(fs.existsSync(path.join(tmp, '.hiveop'))).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
it('refuses to follow a planted .hiveop symlink out of the project', () => {
|
|
122
|
+
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'outside-')));
|
|
123
|
+
try {
|
|
124
|
+
fs.mkdirSync(path.join(outside, 'attachments'));
|
|
125
|
+
fs.symlinkSync(outside, path.join(tmp, '.hiveop'));
|
|
126
|
+
expect((0, attachmentWriter_1.writeAttachment)({ root: tmp, name: 'x.png', dataBase64: PNG.toString('base64') }))
|
|
127
|
+
.toEqual({ ok: false, reason: 'error' });
|
|
128
|
+
expect(fs.readdirSync(path.join(outside, 'attachments'))).toEqual([]);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
it('refuses an unknown session (the session decides the folder, not the frame)', () => {
|
|
135
|
+
const sessions = { list: () => ['a'], cwdOf: () => tmp };
|
|
136
|
+
expect((0, attachmentWriter_1.handleAttachFrame)({ rid: 'r', sessionId: 'b', data: PNG.toString('base64') }, sessions))
|
|
137
|
+
.toEqual({ t: 'file-attached', rid: 'r', sessionId: 'b', ok: false, reason: 'no-session' });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('local bridge server — attach-file frame', () => {
|
|
141
|
+
let handle;
|
|
142
|
+
let sessions;
|
|
143
|
+
let ptys;
|
|
144
|
+
beforeEach(async () => {
|
|
145
|
+
ptys = [];
|
|
146
|
+
const factory = { spawn: () => { const p = new FakePty(); ptys.push(p); return p; } };
|
|
147
|
+
sessions = new sessionManager_1.SessionManager(factory);
|
|
148
|
+
handle = await (0, server_1.startBridgeServer)({ sessionToken: 'tok', allowedOrigin: 'https://app.hiveop.io', sessions });
|
|
149
|
+
});
|
|
150
|
+
afterEach(async () => { await handle.close(); });
|
|
151
|
+
it('writes next to the session and replies with the absolute path; nothing reaches the PTY', async () => {
|
|
152
|
+
const sessionId = sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'agent-1' });
|
|
153
|
+
const ws = new ws_1.default(`ws://127.0.0.1:${handle.port}`, { headers: { origin: 'https://app.hiveop.io' } });
|
|
154
|
+
await new Promise((r) => ws.on('open', r));
|
|
155
|
+
const q = frameQueue(ws);
|
|
156
|
+
ws.send(JSON.stringify({ v: 1, token: 'tok' }));
|
|
157
|
+
ws.send(JSON.stringify({ t: 'attach-file', rid: 'r1', sessionId, name: 'Screen Shot.png', data: PNG.toString('base64') }));
|
|
158
|
+
const reply = await q.next();
|
|
159
|
+
expect(reply).toMatchObject({ t: 'file-attached', rid: 'r1', sessionId, ok: true, bytes: PNG.length });
|
|
160
|
+
expect(reply.path.startsWith(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR) + path.sep)).toBe(true);
|
|
161
|
+
expect(fs.readFileSync(reply.path).equals(PNG)).toBe(true);
|
|
162
|
+
expect(ptys[0].written).toEqual([]);
|
|
163
|
+
ws.close();
|
|
164
|
+
});
|
|
165
|
+
it('advertises the terminal-attach capability in /discovery', async () => {
|
|
166
|
+
const res = await fetch(`http://127.0.0.1:${handle.port}/discovery`, { headers: { origin: 'https://app.hiveop.io' } });
|
|
167
|
+
const body = await res.json();
|
|
168
|
+
expect(body.capabilities).toContain('terminal-attach');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
describe('relay client (hosted container) — attach-file frame', () => {
|
|
172
|
+
it('writes next to the relay session and echoes rid + sessionId for demux', async () => {
|
|
173
|
+
const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
|
|
174
|
+
await new Promise((r) => wss.on('listening', r));
|
|
175
|
+
const port = wss.address().port;
|
|
176
|
+
const conn = new Promise((r) => wss.on('connection', r));
|
|
177
|
+
const sessions = new sessionManager_1.SessionManager({ spawn: () => new FakePty() });
|
|
178
|
+
sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'agent-H' });
|
|
179
|
+
const client = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${port}`, relayToken: 'tok', sessions });
|
|
180
|
+
const ws = await conn;
|
|
181
|
+
const q = frameQueue(ws);
|
|
182
|
+
await q.next(); // auth
|
|
183
|
+
ws.send(JSON.stringify({ t: 'attach-file', rid: 'h1', sessionId: 'agent-H', name: 'x.png', data: PNG.toString('base64') }));
|
|
184
|
+
const reply = await q.next();
|
|
185
|
+
expect(reply).toMatchObject({ t: 'file-attached', rid: 'h1', sessionId: 'agent-H', ok: true });
|
|
186
|
+
expect(fs.readFileSync(reply.path).equals(PNG)).toBe(true);
|
|
187
|
+
ws.send(JSON.stringify({ t: 'attach-file', rid: 'h2', sessionId: 'agent-H', name: 'x.txt', data: Buffer.from('hello').toString('base64') }));
|
|
188
|
+
expect(await q.next()).toMatchObject({ t: 'file-attached', rid: 'h2', ok: false, reason: 'not-image' });
|
|
189
|
+
client.close();
|
|
190
|
+
for (const c of wss.clients)
|
|
191
|
+
c.terminate();
|
|
192
|
+
await new Promise((r) => wss.close(r));
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -62,7 +62,10 @@ class FakeBrowserDriver {
|
|
|
62
62
|
}
|
|
63
63
|
async back() { }
|
|
64
64
|
async forward() { }
|
|
65
|
-
|
|
65
|
+
reloadCalls = 0;
|
|
66
|
+
async reload() {
|
|
67
|
+
this.reloadCalls += 1;
|
|
68
|
+
}
|
|
66
69
|
onFrame(cb) {
|
|
67
70
|
this.frameCb = cb;
|
|
68
71
|
}
|
|
@@ -1120,3 +1123,80 @@ describe('relay client — browser no-consumer sweep (cbs-14)', () => {
|
|
|
1120
1123
|
await closeRelay(relay.wss);
|
|
1121
1124
|
});
|
|
1122
1125
|
});
|
|
1126
|
+
describe('relay client — browser tabs (cvt-07)', () => {
|
|
1127
|
+
async function boot(extra = {}) {
|
|
1128
|
+
const relay = await startFakeRelay();
|
|
1129
|
+
const sessions = new sessionManager_1.SessionManager(makeFactory());
|
|
1130
|
+
const drivers = {};
|
|
1131
|
+
let clock = 1_000;
|
|
1132
|
+
const handle = (0, relayClient_1.startRelayClient)({
|
|
1133
|
+
relayUrl: `ws://127.0.0.1:${relay.port}`,
|
|
1134
|
+
relayToken: 'tok',
|
|
1135
|
+
sessions,
|
|
1136
|
+
nowFn: () => clock,
|
|
1137
|
+
createBrowserDriver: ({ sessionId }) => {
|
|
1138
|
+
const d = new FakeBrowserDriver();
|
|
1139
|
+
drivers[sessionId] = d;
|
|
1140
|
+
return d;
|
|
1141
|
+
},
|
|
1142
|
+
...extra,
|
|
1143
|
+
});
|
|
1144
|
+
const ws = await relay.nextConnection();
|
|
1145
|
+
const q = frameQueue(ws);
|
|
1146
|
+
const auth = await q.next();
|
|
1147
|
+
const settle = () => new Promise((r) => setTimeout(r, 20));
|
|
1148
|
+
const open = async (sessionId, url) => {
|
|
1149
|
+
ws.send(JSON.stringify({ t: 'browser-open', sessionId, url }));
|
|
1150
|
+
await settle();
|
|
1151
|
+
};
|
|
1152
|
+
return {
|
|
1153
|
+
relay, handle, ws, q, auth, drivers, open, settle,
|
|
1154
|
+
advance: (ms) => { clock += ms; },
|
|
1155
|
+
done: async () => { handle.close(); await closeRelay(relay.wss); },
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
it('advertises its capabilities in the auth frame (and nothing when it has none)', async () => {
|
|
1159
|
+
const withCaps = await boot({ capabilities: ['browser-tabs'] });
|
|
1160
|
+
expect(withCaps.auth).toEqual({ v: 1, relayToken: 'tok', caps: ['browser-tabs'] });
|
|
1161
|
+
await withCaps.done();
|
|
1162
|
+
const without = await boot();
|
|
1163
|
+
expect(without.auth).toEqual({ v: 1, relayToken: 'tok' });
|
|
1164
|
+
await without.done();
|
|
1165
|
+
});
|
|
1166
|
+
it('two pane sessions are two independent tabs: reload and close reach only their own', async () => {
|
|
1167
|
+
const t = await boot();
|
|
1168
|
+
await t.open('browser:c1:pane-a', 'https://a.example');
|
|
1169
|
+
await t.open('browser:c1:pane-b', 'https://b.example');
|
|
1170
|
+
const a = t.drivers['browser:c1:pane-a'];
|
|
1171
|
+
const b = t.drivers['browser:c1:pane-b'];
|
|
1172
|
+
expect(a.startCalls).toEqual(['https://a.example']);
|
|
1173
|
+
expect(b.startCalls).toEqual(['https://b.example']);
|
|
1174
|
+
t.ws.send(JSON.stringify({ t: 'browser-reload', sessionId: 'browser:c1:pane-a' }));
|
|
1175
|
+
await t.settle();
|
|
1176
|
+
expect(a.reloadCalls).toBe(1);
|
|
1177
|
+
expect(b.reloadCalls).toBe(0);
|
|
1178
|
+
t.ws.send(JSON.stringify({ t: 'browser-close', sessionId: 'browser:c1:pane-b' }));
|
|
1179
|
+
await t.settle();
|
|
1180
|
+
expect(b.stopCalls).toBe(1);
|
|
1181
|
+
expect(a.stopCalls).toBe(0);
|
|
1182
|
+
await t.done();
|
|
1183
|
+
});
|
|
1184
|
+
it('at the tab cap, opening one more puts the stalest tab to sleep (tab-evicted)', async () => {
|
|
1185
|
+
const t = await boot({ maxBrowserTabs: 2 });
|
|
1186
|
+
await t.open('browser:c1:p1', 'https://one.example');
|
|
1187
|
+
t.advance(1_000);
|
|
1188
|
+
await t.open('browser:c1:p2', 'https://two.example');
|
|
1189
|
+
t.advance(1_000);
|
|
1190
|
+
// p1 is being watched (an ack) — p2 is now the stalest.
|
|
1191
|
+
t.ws.send(JSON.stringify({ t: 'browser-frame-ack', sessionId: 'browser:c1:p1', seq: 1 }));
|
|
1192
|
+
await t.settle();
|
|
1193
|
+
t.advance(1_000);
|
|
1194
|
+
await t.open('browser:c1:p3', 'https://three.example');
|
|
1195
|
+
expect(t.drivers['browser:c1:p2'].sleepCalls).toEqual(['tab-evicted']);
|
|
1196
|
+
expect(t.drivers['browser:c1:p1'].sleepCalls).toEqual([]);
|
|
1197
|
+
expect(t.drivers['browser:c1:p3'].startCalls).toEqual(['https://three.example']);
|
|
1198
|
+
const evicted = await t.q.next();
|
|
1199
|
+
expect(evicted).toEqual({ t: 'browser-state', sessionId: 'browser:c1:p2', state: 'asleep', reason: 'tab-evicted' });
|
|
1200
|
+
await t.done();
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* cvt-07 — each cloud Browser pane is its own TAB in the box's one browser.
|
|
5
|
+
*
|
|
6
|
+
* Two drivers sharing one browserHost.ts against a faked multi-target CDP
|
|
7
|
+
* endpoint that behaves like Chromium's flattened sessions: every page target
|
|
8
|
+
* gets its own CDP session id, screencast frames carry that session id, and a
|
|
9
|
+
* navigation/reload repaints ONLY the target it was sent to.
|
|
10
|
+
*
|
|
11
|
+
* docs/prd/canvas-tools.md (module 7) + issues/cvt-07-cloud-browser-tabs.md.
|
|
12
|
+
*/
|
|
13
|
+
const browserDriver_1 = require("../bridge/browserDriver");
|
|
14
|
+
const browserHost_1 = require("../bridge/browserHost");
|
|
15
|
+
class FakeChildProcess {
|
|
16
|
+
killCount = 0;
|
|
17
|
+
stderr = { on: () => { } };
|
|
18
|
+
exitHandlers = [];
|
|
19
|
+
on(event, cb) {
|
|
20
|
+
if (event === 'exit')
|
|
21
|
+
this.exitHandlers.push(cb);
|
|
22
|
+
}
|
|
23
|
+
kill() {
|
|
24
|
+
this.killCount += 1;
|
|
25
|
+
}
|
|
26
|
+
emitExit(code, signal = null) {
|
|
27
|
+
for (const cb of this.exitHandlers)
|
|
28
|
+
cb(code, signal);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** A fake Chromium DevTools endpoint with real per-target state: which URL
|
|
32
|
+
* each page shows, and which CDP session drives which target. */
|
|
33
|
+
class FakeChromium {
|
|
34
|
+
sent = [];
|
|
35
|
+
pages = new Map([['page-initial', { url: 'about:blank' }]]);
|
|
36
|
+
sessions = new Map(); // cdp session id -> target id
|
|
37
|
+
nextTarget = 1;
|
|
38
|
+
nextSession = 1;
|
|
39
|
+
onMessage = null;
|
|
40
|
+
onCloseCb = null;
|
|
41
|
+
transport = {
|
|
42
|
+
send: (data) => {
|
|
43
|
+
const msg = JSON.parse(data);
|
|
44
|
+
this.sent.push(msg);
|
|
45
|
+
const result = this.handle(msg);
|
|
46
|
+
queueMicrotask(() => this.onMessage?.(JSON.stringify({ id: msg.id, result })));
|
|
47
|
+
},
|
|
48
|
+
close: () => {
|
|
49
|
+
queueMicrotask(() => this.onCloseCb?.(undefined));
|
|
50
|
+
},
|
|
51
|
+
on: (event, cb) => {
|
|
52
|
+
if (event === 'message')
|
|
53
|
+
this.onMessage = cb;
|
|
54
|
+
else if (event === 'close')
|
|
55
|
+
this.onCloseCb = cb;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
handle(msg) {
|
|
59
|
+
const target = msg.sessionId ? this.sessions.get(msg.sessionId) : undefined;
|
|
60
|
+
switch (msg.method) {
|
|
61
|
+
case 'Target.getTargets':
|
|
62
|
+
return { targetInfos: [...this.pages.keys()].map((targetId) => ({ targetId, type: 'page' })) };
|
|
63
|
+
case 'Target.createTarget': {
|
|
64
|
+
const targetId = `page-${this.nextTarget++}`;
|
|
65
|
+
this.pages.set(targetId, { url: msg.params.url });
|
|
66
|
+
return { targetId };
|
|
67
|
+
}
|
|
68
|
+
case 'Target.attachToTarget': {
|
|
69
|
+
const sessionId = `cdp-${this.nextSession++}`;
|
|
70
|
+
this.sessions.set(sessionId, msg.params.targetId);
|
|
71
|
+
return { sessionId };
|
|
72
|
+
}
|
|
73
|
+
case 'Target.closeTarget':
|
|
74
|
+
this.pages.delete(msg.params.targetId);
|
|
75
|
+
return { success: true };
|
|
76
|
+
case 'Page.navigate':
|
|
77
|
+
if (target)
|
|
78
|
+
this.pages.get(target).url = msg.params.url;
|
|
79
|
+
if (msg.sessionId)
|
|
80
|
+
this.repaint(msg.sessionId);
|
|
81
|
+
return { frameId: 'f' };
|
|
82
|
+
case 'Page.reload':
|
|
83
|
+
if (msg.sessionId)
|
|
84
|
+
this.repaint(msg.sessionId);
|
|
85
|
+
return {};
|
|
86
|
+
case 'Page.captureScreenshot':
|
|
87
|
+
return { data: `shot:${target ? this.pages.get(target)?.url : '?'}` };
|
|
88
|
+
default:
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Chromium repaints the target a command was sent to — and only it. */
|
|
93
|
+
repaint(cdpSessionId) {
|
|
94
|
+
const target = this.sessions.get(cdpSessionId);
|
|
95
|
+
const url = this.pages.get(target)?.url;
|
|
96
|
+
queueMicrotask(() => this.onMessage?.(JSON.stringify({
|
|
97
|
+
method: 'Page.screencastFrame',
|
|
98
|
+
sessionId: cdpSessionId,
|
|
99
|
+
params: { sessionId: 1, data: `paint:${url}`, metadata: { deviceWidth: 1280, deviceHeight: 800 } },
|
|
100
|
+
})));
|
|
101
|
+
}
|
|
102
|
+
calls(method) {
|
|
103
|
+
return this.sent.filter((m) => m.method === method);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
107
|
+
function setup() {
|
|
108
|
+
const chromium = new FakeChromium();
|
|
109
|
+
const proc = new FakeChildProcess();
|
|
110
|
+
let launches = 0;
|
|
111
|
+
const launchChromiumFn = () => {
|
|
112
|
+
launches += 1;
|
|
113
|
+
return { proc, wsUrl: Promise.resolve('ws://fake-devtools') };
|
|
114
|
+
};
|
|
115
|
+
const host = (0, browserHost_1.createBrowserHost)({ launchChromiumFn, wsFactory: () => chromium.transport });
|
|
116
|
+
const noTimer = { setIntervalFn: () => 'h', clearIntervalFn: () => { } };
|
|
117
|
+
const tab = () => {
|
|
118
|
+
const driver = (0, browserDriver_1.createBrowserDriver)({ host, ...noTimer });
|
|
119
|
+
const frames = [];
|
|
120
|
+
driver.onFrame((f) => {
|
|
121
|
+
frames.push(f);
|
|
122
|
+
driver.ackFrame();
|
|
123
|
+
});
|
|
124
|
+
return { driver, frames };
|
|
125
|
+
};
|
|
126
|
+
return { chromium, proc, host, tab, launches: () => launches };
|
|
127
|
+
}
|
|
128
|
+
describe('browser tabs (cvt-07)', () => {
|
|
129
|
+
it('two panes share ONE Chromium and each gets its own page target', async () => {
|
|
130
|
+
const { chromium, host, tab, launches } = setup();
|
|
131
|
+
const a = tab();
|
|
132
|
+
const b = tab();
|
|
133
|
+
await Promise.all([a.driver.start('https://a.example'), b.driver.start('https://b.example')]);
|
|
134
|
+
expect(launches()).toBe(1);
|
|
135
|
+
expect(host.leaseCount).toBe(2);
|
|
136
|
+
// The first tab claims Chromium's launch page; the second creates its own.
|
|
137
|
+
expect(chromium.calls('Target.createTarget')).toHaveLength(1);
|
|
138
|
+
expect([...chromium.pages.values()].map((p) => p.url).sort()).toEqual(['https://a.example', 'https://b.example']);
|
|
139
|
+
});
|
|
140
|
+
it('navigates independently: each pane only ever sees its own site', async () => {
|
|
141
|
+
const { tab } = setup();
|
|
142
|
+
const a = tab();
|
|
143
|
+
const b = tab();
|
|
144
|
+
await a.driver.start('https://a.example');
|
|
145
|
+
await b.driver.start('https://b.example');
|
|
146
|
+
await flush();
|
|
147
|
+
await a.driver.navigate('https://a.example/next');
|
|
148
|
+
await flush();
|
|
149
|
+
expect(a.frames.map((f) => f.data).every((d) => d.includes('a.example'))).toBe(true);
|
|
150
|
+
expect(b.frames.map((f) => f.data).every((d) => d.includes('b.example'))).toBe(true);
|
|
151
|
+
expect(a.frames.some((f) => f.data.includes('a.example/next'))).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
it('reloading one pane emits no frames for the other', async () => {
|
|
154
|
+
const { chromium, tab } = setup();
|
|
155
|
+
const a = tab();
|
|
156
|
+
const b = tab();
|
|
157
|
+
await a.driver.start('https://a.example');
|
|
158
|
+
await b.driver.start('https://b.example');
|
|
159
|
+
await flush();
|
|
160
|
+
const aBefore = a.frames.length;
|
|
161
|
+
const bBefore = b.frames.length;
|
|
162
|
+
await a.driver.reload();
|
|
163
|
+
await flush();
|
|
164
|
+
expect(a.frames.length).toBeGreaterThan(aBefore);
|
|
165
|
+
expect(b.frames.length).toBe(bBefore);
|
|
166
|
+
// The reload went to A's CDP session only.
|
|
167
|
+
const reloads = chromium.calls('Page.reload');
|
|
168
|
+
expect(reloads).toHaveLength(1);
|
|
169
|
+
expect(chromium.sessions.get(reloads[0].sessionId)).not.toBe(chromium.sessions.get(chromium.calls('Page.navigate').find((n) => n.params.url === 'https://b.example').sessionId));
|
|
170
|
+
});
|
|
171
|
+
it('closing a pane closes only its tab; the last tab closed kills Chromium', async () => {
|
|
172
|
+
const { chromium, proc, host, tab } = setup();
|
|
173
|
+
const a = tab();
|
|
174
|
+
const b = tab();
|
|
175
|
+
const bStates = [];
|
|
176
|
+
b.driver.onState((s) => bStates.push(s.state));
|
|
177
|
+
await a.driver.start('https://a.example');
|
|
178
|
+
await b.driver.start('https://b.example');
|
|
179
|
+
a.driver.sleep('closed-by-pane');
|
|
180
|
+
await flush();
|
|
181
|
+
expect(proc.killCount).toBe(0);
|
|
182
|
+
expect(host.leaseCount).toBe(1);
|
|
183
|
+
expect([...chromium.pages.values()].map((p) => p.url)).toEqual(['https://b.example']);
|
|
184
|
+
expect(bStates).not.toContain('asleep');
|
|
185
|
+
expect(bStates).not.toContain('closed');
|
|
186
|
+
b.driver.stop();
|
|
187
|
+
expect(proc.killCount).toBe(1);
|
|
188
|
+
expect(host.leaseCount).toBe(0);
|
|
189
|
+
});
|
|
190
|
+
it('a Chromium crash closes every tab, and the next pane relaunches it', async () => {
|
|
191
|
+
const { proc, tab, launches } = setup();
|
|
192
|
+
const a = tab();
|
|
193
|
+
const b = tab();
|
|
194
|
+
const states = [];
|
|
195
|
+
a.driver.onState((s) => states.push(`a:${s.state}`));
|
|
196
|
+
b.driver.onState((s) => states.push(`b:${s.state}`));
|
|
197
|
+
await a.driver.start('https://a.example');
|
|
198
|
+
await b.driver.start('https://b.example');
|
|
199
|
+
proc.emitExit(1);
|
|
200
|
+
expect(states).toContain('a:closed');
|
|
201
|
+
expect(states).toContain('b:closed');
|
|
202
|
+
const c = tab();
|
|
203
|
+
await c.driver.start('https://c.example');
|
|
204
|
+
expect(launches()).toBe(2);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export declare const ATTACHMENTS_DIR: string;
|
|
2
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
3
|
+
export type ImageExt = 'png' | 'jpg' | 'gif' | 'webp';
|
|
4
|
+
export type AttachmentReason = 'not-image' | 'too-large' | 'no-session' | 'error';
|
|
5
|
+
export type AttachmentResult = {
|
|
6
|
+
ok: true;
|
|
7
|
+
path: string;
|
|
8
|
+
bytes: number;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
reason: AttachmentReason;
|
|
12
|
+
};
|
|
13
|
+
/** The image type, read from the bytes themselves — never from the name. */
|
|
14
|
+
export declare function sniffImageExt(buf: Buffer): ImageExt | null;
|
|
15
|
+
/**
|
|
16
|
+
* A filename stem that is safe on every filesystem AND needs no shell
|
|
17
|
+
* escaping in a prompt: letters, digits, dot, dash, underscore only; no
|
|
18
|
+
* leading dot; at most 40 chars; never empty.
|
|
19
|
+
*/
|
|
20
|
+
export declare function safeAttachmentStem(name: string): string;
|
|
21
|
+
export interface AttachmentDeps {
|
|
22
|
+
mkdirp: (dir: string) => void;
|
|
23
|
+
exists: (p: string) => boolean;
|
|
24
|
+
realpath: (p: string) => string;
|
|
25
|
+
/** Real impl: temp file + rename, so Claude never reads half an image. */
|
|
26
|
+
writeFileAtomic: (p: string, data: Buffer) => void;
|
|
27
|
+
now: () => Date;
|
|
28
|
+
random: () => string;
|
|
29
|
+
}
|
|
30
|
+
export declare const realAttachmentDeps: AttachmentDeps;
|
|
31
|
+
/** Write one image under `<root>/.hiveop/attachments/` and return its absolute path. */
|
|
32
|
+
export declare function writeAttachment(input: {
|
|
33
|
+
root: string;
|
|
34
|
+
name: string;
|
|
35
|
+
dataBase64: string;
|
|
36
|
+
}, deps?: AttachmentDeps): AttachmentResult;
|
|
37
|
+
/** Where a session's attachments go: its project folder, else the bridge user's home. */
|
|
38
|
+
export declare function attachmentRootFor(sessions: {
|
|
39
|
+
list(): string[];
|
|
40
|
+
cwdOf(id: string): string | undefined;
|
|
41
|
+
}, sessionId: unknown): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The whole `attach-file` frame → `file-attached` reply, shared verbatim by
|
|
44
|
+
* the local server and the relay client so the two lanes cannot drift.
|
|
45
|
+
*/
|
|
46
|
+
export declare function handleAttachFrame(frame: {
|
|
47
|
+
rid?: unknown;
|
|
48
|
+
sessionId?: unknown;
|
|
49
|
+
name?: unknown;
|
|
50
|
+
data?: unknown;
|
|
51
|
+
}, sessions: {
|
|
52
|
+
list(): string[];
|
|
53
|
+
cwdOf(id: string): string | undefined;
|
|
54
|
+
}, deps?: AttachmentDeps): Record<string, unknown>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.realAttachmentDeps = exports.MAX_ATTACHMENT_BYTES = exports.ATTACHMENTS_DIR = void 0;
|
|
37
|
+
exports.sniffImageExt = sniffImageExt;
|
|
38
|
+
exports.safeAttachmentStem = safeAttachmentStem;
|
|
39
|
+
exports.writeAttachment = writeAttachment;
|
|
40
|
+
exports.attachmentRootFor = attachmentRootFor;
|
|
41
|
+
exports.handleAttachFrame = handleAttachFrame;
|
|
42
|
+
/**
|
|
43
|
+
* Terminal attach — save a screenshot NEXT TO the agent that will read it.
|
|
44
|
+
*
|
|
45
|
+
* Claude Code attaches an image when that image's file path lands in its
|
|
46
|
+
* prompt. So the browser ships the image bytes over the pane's own socket
|
|
47
|
+
* (`attach-file` frame — the local bridge's loopback WS, or the relay into the
|
|
48
|
+
* hosted container; same frame, same handler, both lanes) and this module
|
|
49
|
+
* writes them into the SESSION's project folder:
|
|
50
|
+
*
|
|
51
|
+
* <session cwd>/.hiveop/attachments/<stamp>-<rand>-<safe-name>.<ext>
|
|
52
|
+
*
|
|
53
|
+
* Why there:
|
|
54
|
+
* - `.hiveop/` is already the bridge-owned dir inside a checkout
|
|
55
|
+
* (`.hiveop/context.md`), excluded from the tree scanner/watcher and from
|
|
56
|
+
* the checkout inventory's "unsaved work" count — no new convention.
|
|
57
|
+
* - It is inside Claude's cwd, so a follow-up Read of the file needs no
|
|
58
|
+
* out-of-project permission prompt on either lane.
|
|
59
|
+
* - The attachments dir carries its OWN `.gitignore` (`*`), so an agent's
|
|
60
|
+
* `git add -A` never commits screenshots — without editing the user's
|
|
61
|
+
* .gitignore.
|
|
62
|
+
*
|
|
63
|
+
* The browser's name and type are hints only: the extension comes from the
|
|
64
|
+
* file's magic bytes, and anything that is not png/jpeg/gif/webp is refused.
|
|
65
|
+
* The session — never the frame — decides the folder, so a pane can only
|
|
66
|
+
* write next to a session it may already address.
|
|
67
|
+
*/
|
|
68
|
+
const fs = __importStar(require("fs"));
|
|
69
|
+
const os = __importStar(require("os"));
|
|
70
|
+
const path = __importStar(require("path"));
|
|
71
|
+
const crypto = __importStar(require("crypto"));
|
|
72
|
+
exports.ATTACHMENTS_DIR = path.join('.hiveop', 'attachments');
|
|
73
|
+
exports.MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
74
|
+
/** The image type, read from the bytes themselves — never from the name. */
|
|
75
|
+
function sniffImageExt(buf) {
|
|
76
|
+
if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
77
|
+
return 'png';
|
|
78
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
|
|
79
|
+
return 'jpg';
|
|
80
|
+
if (buf.length >= 6 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a'))
|
|
81
|
+
return 'gif';
|
|
82
|
+
if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP')
|
|
83
|
+
return 'webp';
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A filename stem that is safe on every filesystem AND needs no shell
|
|
88
|
+
* escaping in a prompt: letters, digits, dot, dash, underscore only; no
|
|
89
|
+
* leading dot; at most 40 chars; never empty.
|
|
90
|
+
*/
|
|
91
|
+
function safeAttachmentStem(name) {
|
|
92
|
+
const base = String(name ?? '').split(/[\\/]/).pop() ?? '';
|
|
93
|
+
const stem = base.replace(/\.[A-Za-z0-9]{1,5}$/, '');
|
|
94
|
+
const cleaned = stem
|
|
95
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
96
|
+
.replace(/-{2,}/g, '-')
|
|
97
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
98
|
+
.slice(0, 40)
|
|
99
|
+
.replace(/[-.]+$/g, '');
|
|
100
|
+
return cleaned || 'image';
|
|
101
|
+
}
|
|
102
|
+
function stamp(now) {
|
|
103
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
104
|
+
return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
105
|
+
}
|
|
106
|
+
exports.realAttachmentDeps = {
|
|
107
|
+
mkdirp: (dir) => fs.mkdirSync(dir, { recursive: true }),
|
|
108
|
+
exists: (p) => fs.existsSync(p),
|
|
109
|
+
realpath: (p) => fs.realpathSync(p),
|
|
110
|
+
writeFileAtomic: (p, data) => {
|
|
111
|
+
const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
112
|
+
fs.writeFileSync(tmp, data);
|
|
113
|
+
fs.renameSync(tmp, p);
|
|
114
|
+
},
|
|
115
|
+
now: () => new Date(),
|
|
116
|
+
random: () => crypto.randomBytes(2).toString('hex'),
|
|
117
|
+
};
|
|
118
|
+
/** Write one image under `<root>/.hiveop/attachments/` and return its absolute path. */
|
|
119
|
+
function writeAttachment(input, deps = exports.realAttachmentDeps) {
|
|
120
|
+
const b64 = typeof input.dataBase64 === 'string' ? input.dataBase64 : '';
|
|
121
|
+
// Checked on the ENCODED length first, so an oversized frame is refused
|
|
122
|
+
// without allocating its decoded buffer.
|
|
123
|
+
if (b64.length > Math.ceil(exports.MAX_ATTACHMENT_BYTES / 3) * 4 + 4)
|
|
124
|
+
return { ok: false, reason: 'too-large' };
|
|
125
|
+
const data = Buffer.from(b64, 'base64');
|
|
126
|
+
if (data.length > exports.MAX_ATTACHMENT_BYTES)
|
|
127
|
+
return { ok: false, reason: 'too-large' };
|
|
128
|
+
const ext = sniffImageExt(data);
|
|
129
|
+
if (!ext)
|
|
130
|
+
return { ok: false, reason: 'not-image' };
|
|
131
|
+
try {
|
|
132
|
+
const root = deps.realpath(input.root);
|
|
133
|
+
const dir = path.join(root, exports.ATTACHMENTS_DIR);
|
|
134
|
+
deps.mkdirp(dir);
|
|
135
|
+
// A checkout's `.hiveop` could be a symlink planted by the repo — refuse
|
|
136
|
+
// to follow it anywhere outside the project.
|
|
137
|
+
const realDir = deps.realpath(dir);
|
|
138
|
+
if (realDir !== dir && !realDir.startsWith(root + path.sep))
|
|
139
|
+
return { ok: false, reason: 'error' };
|
|
140
|
+
const ignore = path.join(realDir, '.gitignore');
|
|
141
|
+
if (!deps.exists(ignore))
|
|
142
|
+
deps.writeFileAtomic(ignore, Buffer.from('*\n'));
|
|
143
|
+
const file = path.join(realDir, `${stamp(deps.now())}-${deps.random()}-${safeAttachmentStem(input.name)}.${ext}`);
|
|
144
|
+
deps.writeFileAtomic(file, data);
|
|
145
|
+
return { ok: true, path: file, bytes: data.length };
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { ok: false, reason: 'error' };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Where a session's attachments go: its project folder, else the bridge user's home. */
|
|
152
|
+
function attachmentRootFor(sessions, sessionId) {
|
|
153
|
+
if (typeof sessionId !== 'string' || !sessions.list().includes(sessionId))
|
|
154
|
+
return null;
|
|
155
|
+
return sessions.cwdOf(sessionId) ?? os.homedir();
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The whole `attach-file` frame → `file-attached` reply, shared verbatim by
|
|
159
|
+
* the local server and the relay client so the two lanes cannot drift.
|
|
160
|
+
*/
|
|
161
|
+
function handleAttachFrame(frame, sessions, deps = exports.realAttachmentDeps) {
|
|
162
|
+
const head = {
|
|
163
|
+
t: 'file-attached',
|
|
164
|
+
rid: frame.rid,
|
|
165
|
+
...(typeof frame.sessionId === 'string' ? { sessionId: frame.sessionId } : {}),
|
|
166
|
+
};
|
|
167
|
+
const root = attachmentRootFor(sessions, frame.sessionId);
|
|
168
|
+
if (!root)
|
|
169
|
+
return { ...head, ok: false, reason: 'no-session' };
|
|
170
|
+
const result = writeAttachment({ root, name: typeof frame.name === 'string' ? frame.name : 'image', dataBase64: typeof frame.data === 'string' ? frame.data : '' }, deps);
|
|
171
|
+
return result.ok ? { ...head, ok: true, path: result.path, bytes: result.bytes } : { ...head, ok: false, reason: result.reason };
|
|
172
|
+
}
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
import { ChromiumLaunchHandle } from './chromiumLauncher';
|
|
39
39
|
import { CdpTransportLike } from './cdpClient';
|
|
40
|
+
import type { BrowserHost } from './browserHost';
|
|
40
41
|
import { BrowserInputEvent, BrowserSessionState } from './browserFrames';
|
|
41
42
|
/** container -> pane screencast frame, minus the wire envelope (`t`,
|
|
42
43
|
* `sessionId`) — the driver doesn't know the relay session id; the caller
|
|
@@ -228,6 +229,13 @@ export interface BrowserDriverOpts {
|
|
|
228
229
|
* log line (frames delivered, screencast vs capture split, drops) every
|
|
229
230
|
* N frames processed, never per-frame. Default 50. */
|
|
230
231
|
summaryFrameInterval?: number;
|
|
232
|
+
/** cvt-07: the box's shared Chromium (browserHost.ts). When set, this
|
|
233
|
+
* driver is ONE TAB in that process — it leases the browser connection,
|
|
234
|
+
* creates (or claims the spare) page target, and on stop()/sleep() closes
|
|
235
|
+
* only its own target; the host kills Chromium when the last tab goes.
|
|
236
|
+
* Absent → the pre-cvt-07 behaviour: this driver launches and owns its
|
|
237
|
+
* own Chromium (launchChromiumFn/wsFactory above). */
|
|
238
|
+
host?: BrowserHost;
|
|
231
239
|
}
|
|
232
240
|
/** Default real transport: wraps a `ws` WebSocket connected to the
|
|
233
241
|
* Chromium DevTools endpoint. Never exercised by tests (they inject
|
|
@@ -137,6 +137,9 @@ function createBrowserDriver(opts = {}) {
|
|
|
137
137
|
const summaryFrameInterval = opts.summaryFrameInterval ?? 50;
|
|
138
138
|
let cdp = null;
|
|
139
139
|
let childProc = null;
|
|
140
|
+
// cvt-07: set only in shared-host (tab) mode — see BrowserDriverOpts.host.
|
|
141
|
+
let hostLease = null;
|
|
142
|
+
const unsubscribers = [];
|
|
140
143
|
let pageTargetId = null;
|
|
141
144
|
let targetSessionId = null;
|
|
142
145
|
let seq = 0;
|
|
@@ -238,6 +241,19 @@ function createBrowserDriver(opts = {}) {
|
|
|
238
241
|
if (stopped)
|
|
239
242
|
return;
|
|
240
243
|
stopped = true;
|
|
244
|
+
// cvt-07: a tab never closes the shared connection or kills the shared
|
|
245
|
+
// process — it closes its own target and hands its lease back (the host
|
|
246
|
+
// kills Chromium when the last lease goes).
|
|
247
|
+
if (opts.host) {
|
|
248
|
+
for (const off of unsubscribers.splice(0))
|
|
249
|
+
off();
|
|
250
|
+
const lease = hostLease;
|
|
251
|
+
if (lease && pageTargetId) {
|
|
252
|
+
void lease.cdp.send('Target.closeTarget', { targetId: pageTargetId }).catch(() => { });
|
|
253
|
+
}
|
|
254
|
+
lease?.release();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
241
257
|
try {
|
|
242
258
|
cdp?.close();
|
|
243
259
|
}
|
|
@@ -299,28 +315,55 @@ function createBrowserDriver(opts = {}) {
|
|
|
299
315
|
}
|
|
300
316
|
async function start(url) {
|
|
301
317
|
emitState({ state: 'opening' });
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
let client;
|
|
319
|
+
if (opts.host) {
|
|
320
|
+
// cvt-07: one tab in the box's shared Chromium.
|
|
321
|
+
const lease = await opts.host.acquire();
|
|
322
|
+
if (stopped) {
|
|
323
|
+
// stop()/sleep() landed while the lease was being acquired.
|
|
324
|
+
lease.release();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
hostLease = lease;
|
|
328
|
+
client = lease.cdp;
|
|
329
|
+
cdp = client;
|
|
330
|
+
lease.onClosed((reason) => handleClosed(reason));
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
const { proc, wsUrl } = launchChromiumFn({ chromiumPath, userDataDir, width, height });
|
|
334
|
+
childProc = proc;
|
|
335
|
+
proc.on('exit', (code, signal) => {
|
|
336
|
+
handleClosed(`chromium-exit code=${code ?? 'null'} signal=${signal ?? 'null'}`);
|
|
337
|
+
});
|
|
338
|
+
proc.on('error', (err) => {
|
|
339
|
+
handleClosed(`chromium-error ${err instanceof Error ? err.message : String(err)}`);
|
|
340
|
+
});
|
|
341
|
+
const browserWsUrl = await wsUrl;
|
|
342
|
+
const transport = wsFactory(browserWsUrl);
|
|
343
|
+
client = (0, cdpClient_1.createCdpClient)(transport);
|
|
344
|
+
cdp = client;
|
|
345
|
+
client.onClose((info) => {
|
|
346
|
+
handleClosed(info?.reason ? `cdp-closed:${info.reason}` : 'cdp-closed');
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
unsubscribers.push(client.onEvent('Target.targetInfoChanged', (params) => {
|
|
318
350
|
const info = params.targetInfo;
|
|
319
351
|
if (!info || !pageTargetId || info.targetId !== pageTargetId)
|
|
320
352
|
return;
|
|
321
353
|
emitState({ state: 'open', url: info.url, title: info.title });
|
|
322
|
-
});
|
|
323
|
-
|
|
354
|
+
}));
|
|
355
|
+
// cvt-07: the tab itself went away (crash, window.close()) — only THIS
|
|
356
|
+
// tab is terminal; its siblings in the shared browser are untouched.
|
|
357
|
+
unsubscribers.push(client.onEvent('Target.detachedFromTarget', (params) => {
|
|
358
|
+
if (targetSessionId && params.sessionId === targetSessionId)
|
|
359
|
+
handleClosed('target-detached');
|
|
360
|
+
}));
|
|
361
|
+
unsubscribers.push(client.onEvent('Page.screencastFrame', (params, cdpSessionId) => {
|
|
362
|
+
// cvt-07: on a shared connection every tab's screencast arrives here —
|
|
363
|
+
// only this tab's frames are ours. (A frame without a session id can
|
|
364
|
+
// only come from a non-flattened, single-tab connection.)
|
|
365
|
+
if (cdpSessionId && cdpSessionId !== targetSessionId)
|
|
366
|
+
return;
|
|
324
367
|
// cbs-14: defensive — Page.stopScreencast (sent by pause()) should
|
|
325
368
|
// already suppress these at the source, but a frame already in
|
|
326
369
|
// flight when we asked Chromium to stop is possible; drop it rather
|
|
@@ -346,21 +389,35 @@ function createBrowserDriver(opts = {}) {
|
|
|
346
389
|
w: metadata.deviceWidth ?? width,
|
|
347
390
|
h: metadata.deviceHeight ?? height,
|
|
348
391
|
});
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
392
|
+
}));
|
|
393
|
+
if (hostLease) {
|
|
394
|
+
// cvt-07: the first tab claims the page Chromium launched with; every
|
|
395
|
+
// other tab gets a target of its own.
|
|
396
|
+
const spare = await hostLease.takeSparePage();
|
|
397
|
+
pageTargetId = spare ?? (await client.send('Target.createTarget', { url: 'about:blank' })).targetId;
|
|
354
398
|
}
|
|
355
399
|
else {
|
|
356
|
-
const
|
|
357
|
-
|
|
400
|
+
const { targetInfos } = await client.send('Target.getTargets');
|
|
401
|
+
const existingPage = targetInfos.find((t) => t.type === 'page');
|
|
402
|
+
if (existingPage) {
|
|
403
|
+
pageTargetId = existingPage.targetId;
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
407
|
+
pageTargetId = created.targetId;
|
|
408
|
+
}
|
|
358
409
|
}
|
|
359
410
|
const attached = await client.send('Target.attachToTarget', {
|
|
360
411
|
targetId: pageTargetId,
|
|
361
412
|
flatten: true,
|
|
362
413
|
});
|
|
363
414
|
targetSessionId = attached.sessionId;
|
|
415
|
+
if (stopped && hostLease) {
|
|
416
|
+
// cvt-07: closed while the tab was still being created — teardown()
|
|
417
|
+
// ran before the target existed, so close it here or it leaks.
|
|
418
|
+
void client.send('Target.closeTarget', { targetId: pageTargetId }).catch(() => { });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
364
421
|
await client.send('Page.enable', {}, targetSessionId);
|
|
365
422
|
// The launcher's --hide-scrollbars only governs the REAL window; the
|
|
366
423
|
// moment we emulate a viewport (setDeviceMetricsOverride, below/resize)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cvt-07 — the box's ONE headless Chromium, shared by every Browser pane.
|
|
3
|
+
*
|
|
4
|
+
* (docs/prd/canvas-tools.md module 7, issues/cvt-07-cloud-browser-tabs.md)
|
|
5
|
+
* Before cvt-07 each browser driver launched its own Chromium, and the arena
|
|
6
|
+
* only ever opened one session per box (`browser:<containerId>`), so two
|
|
7
|
+
* cloud Browser panes fought over one page. Now every Browser pane is its own
|
|
8
|
+
* relay session (`browser:<containerId>:<paneId>`) and its own TAB (CDP page
|
|
9
|
+
* target) in this one process: same box, same cost, one profile.
|
|
10
|
+
*
|
|
11
|
+
* This module owns only the PROCESS + the browser-level CDP connection,
|
|
12
|
+
* reference-counted by lease:
|
|
13
|
+
* - the first acquire() launches Chromium and connects; concurrent
|
|
14
|
+
* acquires during the launch share it;
|
|
15
|
+
* - each lease may claim the spare `about:blank` page Chromium starts with
|
|
16
|
+
* (so the first tab doesn't leave an idle extra renderer behind);
|
|
17
|
+
* - the last release() kills Chromium (exactly what a single driver's
|
|
18
|
+
* stop()/sleep() did before — no orphaned process, no idle memory);
|
|
19
|
+
* - a Chromium exit / CDP close tells EVERY live lease (each tab goes to
|
|
20
|
+
* its terminal state), and the next acquire() relaunches.
|
|
21
|
+
* Targets, screencasts and input stay in browserDriver.ts — one driver per tab.
|
|
22
|
+
*/
|
|
23
|
+
import { ChromiumLaunchHandle } from './chromiumLauncher';
|
|
24
|
+
import { CdpClient, CdpTransportLike } from './cdpClient';
|
|
25
|
+
export interface BrowserHostLease {
|
|
26
|
+
cdp: CdpClient;
|
|
27
|
+
/** Claims the page target Chromium opened at launch, once, for the first
|
|
28
|
+
* tab that asks. Null when it is already taken (or there is none) — the
|
|
29
|
+
* caller then creates its own target. */
|
|
30
|
+
takeSparePage(): Promise<string | null>;
|
|
31
|
+
/** Fires once if the shared process/connection dies under this lease. */
|
|
32
|
+
onClosed(cb: (reason: string) => void): void;
|
|
33
|
+
/** Gives the lease back. The last release kills Chromium. Idempotent. */
|
|
34
|
+
release(): void;
|
|
35
|
+
}
|
|
36
|
+
export interface BrowserHost {
|
|
37
|
+
acquire(): Promise<BrowserHostLease>;
|
|
38
|
+
/** Live leases (== open tabs). For tests and the summary log. */
|
|
39
|
+
readonly leaseCount: number;
|
|
40
|
+
}
|
|
41
|
+
export interface BrowserHostOpts {
|
|
42
|
+
chromiumPath?: string;
|
|
43
|
+
userDataDir?: string;
|
|
44
|
+
width?: number;
|
|
45
|
+
height?: number;
|
|
46
|
+
launchChromiumFn?: (opts: {
|
|
47
|
+
chromiumPath?: string;
|
|
48
|
+
userDataDir?: string;
|
|
49
|
+
width?: number;
|
|
50
|
+
height?: number;
|
|
51
|
+
}) => ChromiumLaunchHandle;
|
|
52
|
+
wsFactory: (url: string) => CdpTransportLike;
|
|
53
|
+
}
|
|
54
|
+
export declare function createBrowserHost(opts: BrowserHostOpts): BrowserHost;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createBrowserHost = createBrowserHost;
|
|
4
|
+
/**
|
|
5
|
+
* cvt-07 — the box's ONE headless Chromium, shared by every Browser pane.
|
|
6
|
+
*
|
|
7
|
+
* (docs/prd/canvas-tools.md module 7, issues/cvt-07-cloud-browser-tabs.md)
|
|
8
|
+
* Before cvt-07 each browser driver launched its own Chromium, and the arena
|
|
9
|
+
* only ever opened one session per box (`browser:<containerId>`), so two
|
|
10
|
+
* cloud Browser panes fought over one page. Now every Browser pane is its own
|
|
11
|
+
* relay session (`browser:<containerId>:<paneId>`) and its own TAB (CDP page
|
|
12
|
+
* target) in this one process: same box, same cost, one profile.
|
|
13
|
+
*
|
|
14
|
+
* This module owns only the PROCESS + the browser-level CDP connection,
|
|
15
|
+
* reference-counted by lease:
|
|
16
|
+
* - the first acquire() launches Chromium and connects; concurrent
|
|
17
|
+
* acquires during the launch share it;
|
|
18
|
+
* - each lease may claim the spare `about:blank` page Chromium starts with
|
|
19
|
+
* (so the first tab doesn't leave an idle extra renderer behind);
|
|
20
|
+
* - the last release() kills Chromium (exactly what a single driver's
|
|
21
|
+
* stop()/sleep() did before — no orphaned process, no idle memory);
|
|
22
|
+
* - a Chromium exit / CDP close tells EVERY live lease (each tab goes to
|
|
23
|
+
* its terminal state), and the next acquire() relaunches.
|
|
24
|
+
* Targets, screencasts and input stay in browserDriver.ts — one driver per tab.
|
|
25
|
+
*/
|
|
26
|
+
const chromiumLauncher_1 = require("./chromiumLauncher");
|
|
27
|
+
const cdpClient_1 = require("./cdpClient");
|
|
28
|
+
function createBrowserHost(opts) {
|
|
29
|
+
const launchChromiumFn = opts.launchChromiumFn ?? chromiumLauncher_1.launchChromium;
|
|
30
|
+
let running = null;
|
|
31
|
+
let launching = null;
|
|
32
|
+
const die = (r, reason) => {
|
|
33
|
+
if (r.dead)
|
|
34
|
+
return;
|
|
35
|
+
r.dead = true;
|
|
36
|
+
if (running === r)
|
|
37
|
+
running = null;
|
|
38
|
+
const leases = [...r.leases];
|
|
39
|
+
r.leases.clear();
|
|
40
|
+
for (const l of leases) {
|
|
41
|
+
l.released = true;
|
|
42
|
+
for (const cb of l.closedCbs)
|
|
43
|
+
cb(reason);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const kill = (r) => {
|
|
47
|
+
if (r.dead)
|
|
48
|
+
return;
|
|
49
|
+
r.dead = true;
|
|
50
|
+
if (running === r)
|
|
51
|
+
running = null;
|
|
52
|
+
try {
|
|
53
|
+
r.cdp.close();
|
|
54
|
+
}
|
|
55
|
+
catch { /* best-effort */ }
|
|
56
|
+
try {
|
|
57
|
+
r.proc.kill();
|
|
58
|
+
}
|
|
59
|
+
catch { /* best-effort */ }
|
|
60
|
+
};
|
|
61
|
+
async function launch() {
|
|
62
|
+
const { proc, wsUrl } = launchChromiumFn({
|
|
63
|
+
chromiumPath: opts.chromiumPath,
|
|
64
|
+
userDataDir: opts.userDataDir,
|
|
65
|
+
width: opts.width,
|
|
66
|
+
height: opts.height,
|
|
67
|
+
});
|
|
68
|
+
// Registered before awaiting wsUrl so an exit during startup is seen.
|
|
69
|
+
let r = null;
|
|
70
|
+
let earlyExit = null;
|
|
71
|
+
proc.on('exit', (code, signal) => {
|
|
72
|
+
const reason = `chromium-exit code=${code ?? 'null'} signal=${signal ?? 'null'}`;
|
|
73
|
+
if (r)
|
|
74
|
+
die(r, reason);
|
|
75
|
+
else
|
|
76
|
+
earlyExit = reason;
|
|
77
|
+
});
|
|
78
|
+
proc.on('error', (err) => {
|
|
79
|
+
const reason = `chromium-error ${err instanceof Error ? err.message : String(err)}`;
|
|
80
|
+
if (r)
|
|
81
|
+
die(r, reason);
|
|
82
|
+
else
|
|
83
|
+
earlyExit = reason;
|
|
84
|
+
});
|
|
85
|
+
const url = await wsUrl;
|
|
86
|
+
if (earlyExit)
|
|
87
|
+
throw new Error(earlyExit);
|
|
88
|
+
const cdp = (0, cdpClient_1.createCdpClient)(opts.wsFactory(url));
|
|
89
|
+
r = { proc, cdp, spareTaken: false, leases: new Set(), dead: false };
|
|
90
|
+
const self = r;
|
|
91
|
+
cdp.onClose((info) => die(self, info?.reason ? `cdp-closed:${info.reason}` : 'cdp-closed'));
|
|
92
|
+
return r;
|
|
93
|
+
}
|
|
94
|
+
async function acquire() {
|
|
95
|
+
let r = running && !running.dead ? running : null;
|
|
96
|
+
if (!r) {
|
|
97
|
+
if (!launching) {
|
|
98
|
+
launching = launch().finally(() => { launching = null; });
|
|
99
|
+
}
|
|
100
|
+
r = await launching;
|
|
101
|
+
if (r.dead)
|
|
102
|
+
throw new Error('chromium exited during launch');
|
|
103
|
+
running = r;
|
|
104
|
+
}
|
|
105
|
+
const rec = { closedCbs: [], released: false };
|
|
106
|
+
r.leases.add(rec);
|
|
107
|
+
const owner = r;
|
|
108
|
+
return {
|
|
109
|
+
cdp: owner.cdp,
|
|
110
|
+
async takeSparePage() {
|
|
111
|
+
if (owner.spareTaken || owner.dead)
|
|
112
|
+
return null;
|
|
113
|
+
owner.spareTaken = true;
|
|
114
|
+
const { targetInfos } = await owner.cdp.send('Target.getTargets');
|
|
115
|
+
return targetInfos.find((t) => t.type === 'page')?.targetId ?? null;
|
|
116
|
+
},
|
|
117
|
+
onClosed(cb) {
|
|
118
|
+
rec.closedCbs.push(cb);
|
|
119
|
+
},
|
|
120
|
+
release() {
|
|
121
|
+
if (rec.released)
|
|
122
|
+
return;
|
|
123
|
+
rec.released = true;
|
|
124
|
+
owner.leases.delete(rec);
|
|
125
|
+
if (owner.leases.size === 0)
|
|
126
|
+
kill(owner);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
acquire,
|
|
132
|
+
get leaseCount() {
|
|
133
|
+
return running && !running.dead ? running.leases.size : 0;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -27,6 +27,14 @@ export declare const SESSION_NOT_RELAY_OWNED = "SESSION_NOT_RELAY_OWNED";
|
|
|
27
27
|
* terminal it did not start — the test names each one for the same reason.
|
|
28
28
|
*/
|
|
29
29
|
export declare const SESSION_ADDRESSED_FRAMES: ReadonlySet<string>;
|
|
30
|
+
/** cvt-07: what this bridge advertises in its auth frame. `browser-tabs` = a
|
|
31
|
+
* distinct browser sessionId is a distinct TAB in one shared Chromium, and
|
|
32
|
+
* `browser-close`/`browser-reload` are understood. The relay keeps old
|
|
33
|
+
* images (no caps) on the single default session. */
|
|
34
|
+
export declare const BRIDGE_CAP_BROWSER_TABS = "browser-tabs";
|
|
35
|
+
/** cvt-07: max live browser tabs per box — the arena's Browser-pane cap (4
|
|
36
|
+
* per canvas). A fifth open evicts the tab nobody has looked at longest. */
|
|
37
|
+
export declare const DEFAULT_MAX_BROWSER_TABS = 4;
|
|
30
38
|
/** pucc-04 workspace-scoped git credential, resolved + injected by the relay. */
|
|
31
39
|
export interface InjectedGitCredential {
|
|
32
40
|
token: string;
|
|
@@ -594,6 +602,11 @@ export interface RelayClientOpts {
|
|
|
594
602
|
* idempotent pause() calls — see browserDriver.pause()'s doc comment),
|
|
595
603
|
* so a short cadence costs nothing. Default 5_000ms. */
|
|
596
604
|
browserConsumerCheckIntervalMs?: number;
|
|
605
|
+
/** cvt-07: capabilities advertised to the relay in the auth frame (e.g.
|
|
606
|
+
* BRIDGE_CAP_BROWSER_TABS). Absent → none (the pre-cvt-07 auth frame). */
|
|
607
|
+
capabilities?: readonly string[];
|
|
608
|
+
/** cvt-07: max live browser tabs (default DEFAULT_MAX_BROWSER_TABS). */
|
|
609
|
+
maxBrowserTabs?: number;
|
|
597
610
|
/** Test/observability hooks. */
|
|
598
611
|
onConnect?: () => void;
|
|
599
612
|
/**
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.SESSION_ADDRESSED_FRAMES = exports.SESSION_NOT_RELAY_OWNED = exports.LAUNCH_COMMAND_MISSING = void 0;
|
|
36
|
+
exports.DEFAULT_MAX_BROWSER_TABS = exports.BRIDGE_CAP_BROWSER_TABS = exports.SESSION_ADDRESSED_FRAMES = exports.SESSION_NOT_RELAY_OWNED = exports.LAUNCH_COMMAND_MISSING = void 0;
|
|
37
37
|
exports.assembleSessionEnv = assembleSessionEnv;
|
|
38
38
|
exports.formatHealthLine = formatHealthLine;
|
|
39
39
|
exports.formatDisconnectLine = formatDisconnectLine;
|
|
@@ -110,6 +110,7 @@ const diskPreflight_1 = require("./diskPreflight");
|
|
|
110
110
|
// hcs-05's checkoutReclaim executor for the actual delete. See
|
|
111
111
|
// checkoutParkAndRemove.ts's doc comment for the full safety design.
|
|
112
112
|
const checkoutParkAndRemove_1 = require("./checkoutParkAndRemove");
|
|
113
|
+
const attachmentWriter_1 = require("./attachmentWriter");
|
|
113
114
|
const PROTOCOL_VERSION = 1;
|
|
114
115
|
/**
|
|
115
116
|
* fcct-27 — typed refusal when `resolveLaunch` names a binary this machine does
|
|
@@ -133,7 +134,18 @@ exports.SESSION_NOT_RELAY_OWNED = 'SESSION_NOT_RELAY_OWNED';
|
|
|
133
134
|
exports.SESSION_ADDRESSED_FRAMES = new Set([
|
|
134
135
|
'attach', 'input', 'peek', 'resize', 'detach', 'close', 'read-handoff',
|
|
135
136
|
'browser-input', 'browser-frame-ack', 'browser-resize', 'browser-sleep',
|
|
137
|
+
// Terminal attach writes next to a session — same consent boundary.
|
|
138
|
+
'attach-file',
|
|
139
|
+
'browser-close', 'browser-reload',
|
|
136
140
|
]);
|
|
141
|
+
/** cvt-07: what this bridge advertises in its auth frame. `browser-tabs` = a
|
|
142
|
+
* distinct browser sessionId is a distinct TAB in one shared Chromium, and
|
|
143
|
+
* `browser-close`/`browser-reload` are understood. The relay keeps old
|
|
144
|
+
* images (no caps) on the single default session. */
|
|
145
|
+
exports.BRIDGE_CAP_BROWSER_TABS = 'browser-tabs';
|
|
146
|
+
/** cvt-07: max live browser tabs per box — the arena's Browser-pane cap (4
|
|
147
|
+
* per canvas). A fifth open evicts the tab nobody has looked at longest. */
|
|
148
|
+
exports.DEFAULT_MAX_BROWSER_TABS = 4;
|
|
137
149
|
/**
|
|
138
150
|
* pucc-14 — assemble the per-session PTY env for a workspace-scoped `open`.
|
|
139
151
|
*
|
|
@@ -504,6 +516,29 @@ function startRelayClient(opts) {
|
|
|
504
516
|
function sleepBrowserSession(sessionId, reason) {
|
|
505
517
|
browserDrivers.get(sessionId)?.sleep(reason);
|
|
506
518
|
}
|
|
519
|
+
/** cvt-07: before opening one more tab at the cap, put the tab with the
|
|
520
|
+
* stalest consumer signal to sleep (its pane shows asleep + Wake). */
|
|
521
|
+
function evictBrowserTabIfFull() {
|
|
522
|
+
const max = opts.maxBrowserTabs ?? exports.DEFAULT_MAX_BROWSER_TABS;
|
|
523
|
+
while (browserDrivers.size >= max) {
|
|
524
|
+
let victim = null;
|
|
525
|
+
let oldest = Infinity;
|
|
526
|
+
for (const sid of browserDrivers.keys()) {
|
|
527
|
+
const at = browserLastConsumerAt.get(sid) ?? -Infinity;
|
|
528
|
+
if (at < oldest) {
|
|
529
|
+
oldest = at;
|
|
530
|
+
victim = sid;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (!victim)
|
|
534
|
+
return;
|
|
535
|
+
sleepBrowserSession(victim, 'tab-evicted');
|
|
536
|
+
// A driver whose sleep didn't retire it synchronously must not wedge
|
|
537
|
+
// this loop.
|
|
538
|
+
if (browserDrivers.has(victim))
|
|
539
|
+
browserDrivers.delete(victim);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
507
542
|
// pucc-14 login_valid report. Container-level (not per session): the ONE
|
|
508
543
|
// shared login on the volume. `lastLoginValid` persists across reconnects so
|
|
509
544
|
// the poll only re-emits on a genuine change; each fresh connect force-emits
|
|
@@ -834,7 +869,11 @@ function startRelayClient(opts) {
|
|
|
834
869
|
};
|
|
835
870
|
socket.on('pong', markLinkAlive);
|
|
836
871
|
socket.on('open', () => {
|
|
837
|
-
send(socket, {
|
|
872
|
+
send(socket, {
|
|
873
|
+
v: PROTOCOL_VERSION,
|
|
874
|
+
relayToken: opts.relayToken,
|
|
875
|
+
...(opts.capabilities?.length ? { caps: [...opts.capabilities] } : {}),
|
|
876
|
+
});
|
|
838
877
|
connected = true;
|
|
839
878
|
startHeartbeat();
|
|
840
879
|
// twr-06: `attempt` reset + `onConnect` moved OFF this handler — the
|
|
@@ -1501,6 +1540,10 @@ function startRelayClient(opts) {
|
|
|
1501
1540
|
});
|
|
1502
1541
|
break;
|
|
1503
1542
|
}
|
|
1543
|
+
case 'attach-file':
|
|
1544
|
+
// Terminal attach — shared verbatim with the local server.
|
|
1545
|
+
send(socket, (0, attachmentWriter_1.handleAttachFrame)(frame, opts.sessions));
|
|
1546
|
+
break;
|
|
1504
1547
|
case 'read-transcript': {
|
|
1505
1548
|
// wte-10 (docs/prd/work-this-epic.md D8): read ONE Claude Code
|
|
1506
1549
|
// agent's session transcript, live. Server-initiated (no pane,
|
|
@@ -1588,6 +1631,7 @@ function startRelayClient(opts) {
|
|
|
1588
1631
|
}
|
|
1589
1632
|
break;
|
|
1590
1633
|
}
|
|
1634
|
+
evictBrowserTabIfFull(); // cvt-07
|
|
1591
1635
|
const driver = opts.createBrowserDriver({ sessionId });
|
|
1592
1636
|
browserDrivers.set(sessionId, driver);
|
|
1593
1637
|
browserDriverUrls.set(sessionId, url);
|
|
@@ -1721,6 +1765,21 @@ function startRelayClient(opts) {
|
|
|
1721
1765
|
// the map by the first sleep's onState handler above).
|
|
1722
1766
|
sleepBrowserSession(frame.sessionId, 'sleep');
|
|
1723
1767
|
break;
|
|
1768
|
+
case 'browser-close':
|
|
1769
|
+
// cvt-07: the pane was closed — close its tab (stop(): terminal
|
|
1770
|
+
// 'closed', and in shared-host mode only this target goes; the
|
|
1771
|
+
// host kills Chromium when the last tab closes). Safe no-op for
|
|
1772
|
+
// an unknown session.
|
|
1773
|
+
browserDrivers.get(frame.sessionId)?.stop();
|
|
1774
|
+
break;
|
|
1775
|
+
case 'browser-reload':
|
|
1776
|
+
// cvt-07: reload exactly this tab. A live consumer asked, so it
|
|
1777
|
+
// also counts as one (and un-pauses capture to show the result).
|
|
1778
|
+
if (browserLastConsumerAt.has(frame.sessionId))
|
|
1779
|
+
browserLastConsumerAt.set(frame.sessionId, now());
|
|
1780
|
+
browserDrivers.get(frame.sessionId)?.resume().catch(() => { });
|
|
1781
|
+
browserDrivers.get(frame.sessionId)?.reload().catch(() => { });
|
|
1782
|
+
break;
|
|
1724
1783
|
case 'companion-allow': {
|
|
1725
1784
|
// bws-08 — session-less tenant-boundary control frame (see
|
|
1726
1785
|
// companionFrames.ts's doc comment). Validated with the runtime
|
package/dist/bridge/server.js
CHANGED
|
@@ -62,6 +62,7 @@ const sessionAnchor_1 = require("./sessionAnchor");
|
|
|
62
62
|
// tchp-30 — per-session own-touch tracker (see its doc comment). Fed from
|
|
63
63
|
// the SAME activity records fcct-24's tail already parses below.
|
|
64
64
|
const sessionTouchTracker_1 = require("./sessionTouchTracker");
|
|
65
|
+
const attachmentWriter_1 = require("./attachmentWriter");
|
|
65
66
|
const PROTOCOL_VERSION = 1;
|
|
66
67
|
function send(ws, frame) {
|
|
67
68
|
if (ws.readyState === ws_1.WebSocket.OPEN)
|
|
@@ -389,6 +390,8 @@ function startBridgeServer(opts) {
|
|
|
389
390
|
// of failing at save time.
|
|
390
391
|
capabilities: [
|
|
391
392
|
...(opts.writeLocalFile ? ['local-file-write'] : []),
|
|
393
|
+
// Terminal attach — this bridge answers `attach-file` frames.
|
|
394
|
+
'terminal-attach',
|
|
392
395
|
],
|
|
393
396
|
});
|
|
394
397
|
return;
|
|
@@ -930,6 +933,11 @@ function startBridgeServer(opts) {
|
|
|
930
933
|
});
|
|
931
934
|
break;
|
|
932
935
|
}
|
|
936
|
+
case 'attach-file':
|
|
937
|
+
// Terminal attach — a FILE write next to the session, never PTY
|
|
938
|
+
// input. Shared handler with relayClient.ts (both lanes).
|
|
939
|
+
send(ws, (0, attachmentWriter_1.handleAttachFrame)(frame, opts.sessions));
|
|
940
|
+
break;
|
|
933
941
|
default:
|
|
934
942
|
send(ws, { t: 'error', message: 'unknown frame type' });
|
|
935
943
|
}
|
package/dist/commands/bridge.js
CHANGED
|
@@ -63,6 +63,7 @@ const sessionManager_1 = require("../bridge/sessionManager");
|
|
|
63
63
|
const server_1 = require("../bridge/server");
|
|
64
64
|
const relayClient_1 = require("../bridge/relayClient");
|
|
65
65
|
const browserDriver_1 = require("../bridge/browserDriver");
|
|
66
|
+
const browserHost_1 = require("../bridge/browserHost");
|
|
66
67
|
const browserIdle_1 = require("../bridge/browserIdle");
|
|
67
68
|
const loginStatus_1 = require("../bridge/loginStatus");
|
|
68
69
|
const realPty_1 = require("../bridge/realPty");
|
|
@@ -571,6 +572,8 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
571
572
|
// does not).
|
|
572
573
|
rm: rmCheckoutDir,
|
|
573
574
|
});
|
|
575
|
+
// cvt-07: the box's one shared Chromium, created lazily by the first tab.
|
|
576
|
+
let browserHost;
|
|
574
577
|
(0, relayClient_1.startRelayClient)({
|
|
575
578
|
relayUrl: relayMode.relayUrl,
|
|
576
579
|
relayToken: relayMode.relayToken,
|
|
@@ -866,10 +869,18 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
866
869
|
// either way. An old image without Chromium (or any other launch
|
|
867
870
|
// failure) therefore degrades to "that one browser session failed to
|
|
868
871
|
// open" — never a bridge crash, never a broken terminal pane.
|
|
872
|
+
//
|
|
873
|
+
// cvt-07: every browser session is a TAB in ONE shared Chromium
|
|
874
|
+
// (browserHost.ts) — one process, one profile, launched by the first
|
|
875
|
+
// tab and killed when the last one closes. Advertised to the relay as
|
|
876
|
+
// BRIDGE_CAP_BROWSER_TABS below so it may give each Browser pane its
|
|
877
|
+
// own session.
|
|
869
878
|
createBrowserDriver: () => {
|
|
870
879
|
const { chromiumPath, userDataDir } = buildBrowserDriverOptions();
|
|
871
|
-
|
|
880
|
+
browserHost ??= (0, browserHost_1.createBrowserHost)({ chromiumPath, userDataDir, wsFactory: (u) => (0, browserDriver_1.createDefaultTransport)(u) });
|
|
881
|
+
return (0, browserDriver_1.createBrowserDriver)({ chromiumPath, userDataDir, host: browserHost });
|
|
872
882
|
},
|
|
883
|
+
capabilities: [relayClient_1.BRIDGE_CAP_BROWSER_TABS],
|
|
873
884
|
// bws-08 — the companion port forwarder (docs/prd/
|
|
874
885
|
// browser-with-sound-in-the-container.md D10). Constructed ONCE,
|
|
875
886
|
// same injection posture as createBrowserDriver above: relayClient.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenrwa/marketplace-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "CLI tool for the Wenrwa Agent Marketplace \u2014 monitor bounties, agents, workspaces, and treasury from your terminal",
|
|
5
5
|
"author": "Wenrwa <dev@wenrwa.com>",
|
|
6
6
|
"license": "MIT",
|