@wenrwa/marketplace-cli 0.8.5 → 0.8.7
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-changes-report.test.js +25 -0
- package/dist/__tests__/bridge-process-cpu.test.d.ts +1 -0
- package/dist/__tests__/bridge-process-cpu.test.js +135 -0
- package/dist/__tests__/bridge-read-transcript-frame.test.js +22 -0
- package/dist/__tests__/bridge-save-env.test.d.ts +1 -0
- package/dist/__tests__/bridge-save-env.test.js +343 -0
- package/dist/__tests__/bridge-server-phantom-takeover.test.d.ts +1 -0
- package/dist/__tests__/bridge-server-phantom-takeover.test.js +84 -0
- package/dist/__tests__/bridge-server.test.js +9 -11
- package/dist/__tests__/secrets-egress.test.d.ts +1 -0
- package/dist/__tests__/secrets-egress.test.js +237 -0
- package/dist/__tests__/secrets-redact.test.d.ts +1 -0
- package/dist/__tests__/secrets-redact.test.js +318 -0
- package/dist/bridge/changesReport.js +10 -3
- package/dist/bridge/contextWriter.js +5 -1
- package/dist/bridge/envSaver.d.ts +63 -0
- package/dist/bridge/envSaver.js +256 -0
- package/dist/bridge/hiveopSettings.js +5 -0
- package/dist/bridge/launchSupervisor.js +4 -1
- package/dist/bridge/localContextWriter.js +6 -2
- package/dist/bridge/paneClient.d.ts +32 -0
- package/dist/bridge/paneClient.js +30 -0
- package/dist/bridge/processCpu.d.ts +29 -0
- package/dist/bridge/processCpu.js +160 -0
- package/dist/bridge/realPty.js +6 -1
- package/dist/bridge/relayClient.d.ts +9 -18
- package/dist/bridge/relayClient.js +32 -7
- package/dist/bridge/repoEnv.d.ts +34 -0
- package/dist/bridge/repoEnv.js +186 -0
- package/dist/bridge/server.js +69 -26
- package/dist/bridge/sessionManager.d.ts +10 -0
- package/dist/bridge/sessionManager.js +33 -8
- package/dist/bridge/workGraphPointer.d.ts +25 -0
- package/dist/bridge/workGraphPointer.js +36 -1
- package/dist/commands/bridge.js +8 -0
- package/dist/secrets/console.d.ts +4 -0
- package/dist/secrets/console.js +34 -0
- package/dist/secrets/redact.d.ts +79 -0
- package/dist/secrets/redact.js +637 -0
- package/hooks/opencode-plugin.mjs +55 -2
- package/hooks/pre-tool-use.js +96 -0
- package/package.json +1 -1
|
@@ -58,6 +58,31 @@ function expectEveryCallUsedCwd(calls) {
|
|
|
58
58
|
}
|
|
59
59
|
describe('buildChangesReport', () => {
|
|
60
60
|
// 1. tracer: feature branch, one modified file → merge-base baseline
|
|
61
|
+
it('secret masking (2026-10-17): a secret in a diff line is masked in the patch; the hash is of the real text', async () => {
|
|
62
|
+
const value = ['ef1bd6d5', '7de1', '4da2', 'b722', '4414d9461a4e'].join('-');
|
|
63
|
+
const patch = [
|
|
64
|
+
'diff --git a/.env b/.env',
|
|
65
|
+
'--- a/.env',
|
|
66
|
+
'+++ b/.env',
|
|
67
|
+
'@@ -0,0 +1 @@',
|
|
68
|
+
`+RAILWAY_API_TOKEN=${value}`,
|
|
69
|
+
'',
|
|
70
|
+
].join('\n');
|
|
71
|
+
const { run } = makeRun([
|
|
72
|
+
...baselineRulesFeatureBranch('base111'),
|
|
73
|
+
{ match: isStatusPorcelain, result: ok(' M .env\n') },
|
|
74
|
+
{ match: isNameStatus, result: ok('M\t.env\n') },
|
|
75
|
+
{ match: isNumstat, result: ok('1\t0\t.env\n') },
|
|
76
|
+
{ match: isFilePatch, result: ok(patch) },
|
|
77
|
+
]);
|
|
78
|
+
const res = await (0, changesReport_1.buildChangesReport)(CWD, { run });
|
|
79
|
+
expect(res.ok).toBe(true);
|
|
80
|
+
if (!res.ok)
|
|
81
|
+
return;
|
|
82
|
+
const f = res.report.files[0];
|
|
83
|
+
expect(f.patch).toBe(patch.replace(value, '*'.repeat(value.length)));
|
|
84
|
+
expect(f.contentHash).toBe((0, crypto_1.createHash)('sha256').update(patch, 'utf8').digest('hex'));
|
|
85
|
+
});
|
|
61
86
|
it('tracer: feature branch with one modified file yields a merge-base baseline and an M entry', async () => {
|
|
62
87
|
const patch = [
|
|
63
88
|
'diff --git a/src/foo.ts b/src/foo.ts',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const processCpu_1 = require("../bridge/processCpu");
|
|
4
|
+
const relayClient_1 = require("../bridge/relayClient");
|
|
5
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
6
|
+
/**
|
|
7
|
+
* Activity-based sleep — the bridge tells the backend which sessions are
|
|
8
|
+
* burning CPU with no output (a silent build), so a box is never suspended
|
|
9
|
+
* mid-work; an idle dev server (alive, ~0 CPU) never holds a box awake.
|
|
10
|
+
*/
|
|
11
|
+
const stat = (pid, ppid, utime, stime = 0, cutime = 0, cstime = 0, comm = 'node') => `${pid} (${comm}) S ${ppid} ${pid} ${pid} 0 -1 4194560 100 0 0 0 ${utime} ${stime} ${cutime} ${cstime} 20 0 1 0 100 1000 100`;
|
|
12
|
+
describe('parseProcStat', () => {
|
|
13
|
+
it('reads ppid and utime+stime+cutime+cstime', () => {
|
|
14
|
+
expect((0, processCpu_1.parseProcStat)(stat(10, 1, 5, 6, 7, 8))).toEqual({ ppid: 1, ticks: 26 });
|
|
15
|
+
});
|
|
16
|
+
it('survives a command name with spaces and parentheses', () => {
|
|
17
|
+
expect((0, processCpu_1.parseProcStat)(stat(10, 3, 1, 1, 0, 0, 'tmux: (server) x'))).toEqual({ ppid: 3, ticks: 2 });
|
|
18
|
+
});
|
|
19
|
+
it('rejects garbage', () => {
|
|
20
|
+
expect((0, processCpu_1.parseProcStat)('nonsense')).toBeNull();
|
|
21
|
+
expect((0, processCpu_1.parseProcStat)('1 (x) S 0')).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('treeTicks', () => {
|
|
25
|
+
it('sums the root and every descendant, nothing else', () => {
|
|
26
|
+
const t = new Map([
|
|
27
|
+
[100, { ppid: 1, ticks: 10 }], // pty shell
|
|
28
|
+
[101, { ppid: 100, ticks: 20 }], // claude
|
|
29
|
+
[102, { ppid: 101, ticks: 300 }], // npm test
|
|
30
|
+
[200, { ppid: 1, ticks: 999 }], // another session
|
|
31
|
+
]);
|
|
32
|
+
expect((0, processCpu_1.treeTicks)(t, 100)).toBe(330);
|
|
33
|
+
expect((0, processCpu_1.treeTicks)(t, 200)).toBe(999);
|
|
34
|
+
expect((0, processCpu_1.treeTicks)(t, 404)).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('createCpuBusyTracker', () => {
|
|
38
|
+
const tracker = (tables, times) => {
|
|
39
|
+
let i = 0;
|
|
40
|
+
let j = 0;
|
|
41
|
+
return (0, processCpu_1.createCpuBusyTracker)({ readTable: () => tables[i++], now: () => times[j++], clkTck: 100, thresholdPct: 20 });
|
|
42
|
+
};
|
|
43
|
+
it('first sight only sets a baseline; then ≥20% of a core is busy, an idle tree is not', () => {
|
|
44
|
+
const tr = tracker([
|
|
45
|
+
new Map([[100, { ppid: 1, ticks: 0 }], [200, { ppid: 1, ticks: 0 }]]),
|
|
46
|
+
// 30s later: session A's tree used 15s CPU (50%), B's 1s (3%: an idle dev server)
|
|
47
|
+
new Map([[100, { ppid: 1, ticks: 0 }], [101, { ppid: 100, ticks: 1500 }], [200, { ppid: 1, ticks: 100 }]]),
|
|
48
|
+
], [0, 30_000]);
|
|
49
|
+
const sessions = [{ id: 'A', pid: 100 }, { id: 'B', pid: 200 }];
|
|
50
|
+
expect(tr.sample(sessions)).toEqual([]);
|
|
51
|
+
expect(tr.sample(sessions)).toEqual(['A']);
|
|
52
|
+
});
|
|
53
|
+
it('a reaped child lowering the total never reads as negative or busy', () => {
|
|
54
|
+
const tr = tracker([new Map([[100, { ppid: 1, ticks: 5000 }]]), new Map([[100, { ppid: 1, ticks: 10 }]])], [0, 30_000]);
|
|
55
|
+
tr.sample([{ id: 'A', pid: 100 }]);
|
|
56
|
+
expect(tr.sample([{ id: 'A', pid: 100 }])).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
it('a session with no pid, or a pid no longer running, is skipped', () => {
|
|
59
|
+
const tr = tracker([new Map(), new Map()], [0, 30_000]);
|
|
60
|
+
expect(tr.sample([{ id: 'A', pid: undefined }, { id: 'B', pid: 9 }])).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
it('a respawned PTY (new pid) starts a fresh baseline', () => {
|
|
63
|
+
const tr = tracker([new Map([[100, { ppid: 1, ticks: 0 }]]), new Map([[300, { ppid: 1, ticks: 9000 }]])], [0, 30_000]);
|
|
64
|
+
tr.sample([{ id: 'A', pid: 100 }]);
|
|
65
|
+
expect(tr.sample([{ id: 'A', pid: 300 }])).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('`sessions` frame carries busy only when a tracker is wired', () => {
|
|
69
|
+
class FakePty {
|
|
70
|
+
pid;
|
|
71
|
+
constructor(pid) {
|
|
72
|
+
this.pid = pid;
|
|
73
|
+
}
|
|
74
|
+
write() { }
|
|
75
|
+
resize() { }
|
|
76
|
+
kill() { }
|
|
77
|
+
onData() { }
|
|
78
|
+
onExit() { }
|
|
79
|
+
}
|
|
80
|
+
let nextPid = 100;
|
|
81
|
+
const factory = { spawn: () => new FakePty(nextPid++) };
|
|
82
|
+
const fakeSocket = () => {
|
|
83
|
+
const handlers = new Map();
|
|
84
|
+
const sock = {
|
|
85
|
+
readyState: 1,
|
|
86
|
+
sent: [],
|
|
87
|
+
send(raw) { sock.sent.push(JSON.parse(raw)); },
|
|
88
|
+
close() { handlers.get('close')?.(1006); },
|
|
89
|
+
terminate() { handlers.get('close')?.(1006); },
|
|
90
|
+
on(event, cb) { handlers.set(event, cb); },
|
|
91
|
+
fire(event, ...a) { handlers.get(event)?.(...a); },
|
|
92
|
+
};
|
|
93
|
+
return sock;
|
|
94
|
+
};
|
|
95
|
+
const start = (cpuBusyTracker) => {
|
|
96
|
+
const socket = fakeSocket();
|
|
97
|
+
const sessions = new sessionManager_1.SessionManager(factory);
|
|
98
|
+
const intervals = [];
|
|
99
|
+
const handle = (0, relayClient_1.startRelayClient)({
|
|
100
|
+
relayUrl: 'ws://relay.test',
|
|
101
|
+
relayToken: 'tok',
|
|
102
|
+
sessions,
|
|
103
|
+
wsFactory: () => socket,
|
|
104
|
+
reportSessions: true,
|
|
105
|
+
cpuBusyTracker,
|
|
106
|
+
setSessionsIntervalFn: (cb) => { intervals.push(cb); return intervals.length; },
|
|
107
|
+
clearSessionsIntervalFn: () => { },
|
|
108
|
+
setTimeoutFn: () => 0,
|
|
109
|
+
clearTimeoutFn: () => { },
|
|
110
|
+
});
|
|
111
|
+
return { socket, sessions, handle, tick: () => intervals.forEach((cb) => cb()) };
|
|
112
|
+
};
|
|
113
|
+
it('with a tracker: the frame lists the busy ids, and the tracker sees each session pid', () => {
|
|
114
|
+
const seen = [];
|
|
115
|
+
const { socket, sessions, handle, tick } = start({ sample: (s) => { seen.push(s); return ['agt-1']; } });
|
|
116
|
+
socket.fire('open');
|
|
117
|
+
sessions.open({ cols: 80, rows: 24, id: 'agt-1' });
|
|
118
|
+
tick();
|
|
119
|
+
expect(socket.sent.filter((f) => f.t === 'sessions').at(-1)).toEqual({ t: 'sessions', ids: ['agt-1'], busy: ['agt-1'] });
|
|
120
|
+
expect(seen.at(-1)).toEqual([{ id: 'agt-1', pid: expect.any(Number) }]);
|
|
121
|
+
handle.close();
|
|
122
|
+
});
|
|
123
|
+
it('a tracker that throws still sends the inventory, without busy', () => {
|
|
124
|
+
const { socket, handle } = start({ sample: () => { throw new Error('proc gone'); } });
|
|
125
|
+
socket.fire('open');
|
|
126
|
+
expect(socket.sent.filter((f) => f.t === 'sessions').at(-1)).toEqual({ t: 'sessions', ids: [] });
|
|
127
|
+
handle.close();
|
|
128
|
+
});
|
|
129
|
+
it('no tracker (local bridge, non-Linux) → byte-identical frame', () => {
|
|
130
|
+
const { socket, handle } = start(null);
|
|
131
|
+
socket.fire('open');
|
|
132
|
+
expect(socket.sent.filter((f) => f.t === 'sessions').at(-1)).toEqual({ t: 'sessions', ids: [] });
|
|
133
|
+
handle.close();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -95,6 +95,28 @@ describe('relay client — read-transcript frame (wte-10)', () => {
|
|
|
95
95
|
handle.close();
|
|
96
96
|
await closeRelay(relay.wss);
|
|
97
97
|
});
|
|
98
|
+
it('secret masking (2026-10-17): a secret in the transcript never leaves the box', async () => {
|
|
99
|
+
const value = ['ef1bd6d5', '7de1', '4da2', 'b722', '4414d9461a4e'].join('-');
|
|
100
|
+
const relay = await startFakeRelay();
|
|
101
|
+
const sessions = new sessionManager_1.SessionManager(makeFactory());
|
|
102
|
+
const content = `{"type":"tool_use","input":{"command":"RAILWAY_API_TOKEN=${value} $R up --ci"}}\n`;
|
|
103
|
+
const handle = (0, relayClient_1.startRelayClient)({
|
|
104
|
+
relayUrl: `ws://127.0.0.1:${relay.port}`,
|
|
105
|
+
relayToken: 'tok',
|
|
106
|
+
sessions,
|
|
107
|
+
readTranscript: () => ({ ok: true, content }),
|
|
108
|
+
});
|
|
109
|
+
const ws = await relay.nextConnection();
|
|
110
|
+
const q = frameQueue(ws);
|
|
111
|
+
await q.next(); // auth
|
|
112
|
+
ws.send(JSON.stringify({ t: 'read-transcript', requestId: 'req-9', workspaceId: 'ws-7', agentId: 'agent-A' }));
|
|
113
|
+
const reply = await q.next();
|
|
114
|
+
expect(reply.ok).toBe(true);
|
|
115
|
+
expect(reply.content).toBe(content.replace(value, '*'.repeat(value.length)));
|
|
116
|
+
expect(() => JSON.parse(reply.content)).not.toThrow();
|
|
117
|
+
handle.close();
|
|
118
|
+
await closeRelay(relay.wss);
|
|
119
|
+
});
|
|
98
120
|
it('replies transcript-content with a typed reason when the reader refuses', async () => {
|
|
99
121
|
const relay = await startFakeRelay();
|
|
100
122
|
const sessions = new sessionManager_1.SessionManager(makeFactory());
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
* Paste a key → saved to .env (2026-10-17). The `save-env` frame writes
|
|
38
|
+
* `<session cwd>/.env` on both lanes (local loopback server + relay client
|
|
39
|
+
* share one handler), and every PTY spawn loads that file back into env
|
|
40
|
+
* below the session's own vars. Runs against REAL temp folders and real git.
|
|
41
|
+
*/
|
|
42
|
+
const ws_1 = __importStar(require("ws"));
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const os = __importStar(require("os"));
|
|
45
|
+
const path = __importStar(require("path"));
|
|
46
|
+
const child_process_1 = require("child_process");
|
|
47
|
+
const server_1 = require("../bridge/server");
|
|
48
|
+
const relayClient_1 = require("../bridge/relayClient");
|
|
49
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
50
|
+
const envSaver_1 = require("../bridge/envSaver");
|
|
51
|
+
const repoEnv_1 = require("../bridge/repoEnv");
|
|
52
|
+
const workGraphPointer_1 = require("../bridge/workGraphPointer");
|
|
53
|
+
const contextWriter_1 = require("../bridge/contextWriter");
|
|
54
|
+
const localContextWriter_1 = require("../bridge/localContextWriter");
|
|
55
|
+
const KEY = 'sk-ant-api03-' + 'A'.repeat(40);
|
|
56
|
+
const KEY2 = 'npm_' + 'b'.repeat(36);
|
|
57
|
+
class FakePty {
|
|
58
|
+
written = [];
|
|
59
|
+
write(d) { this.written.push(d); }
|
|
60
|
+
resize() { }
|
|
61
|
+
kill() { }
|
|
62
|
+
onData() { }
|
|
63
|
+
onExit() { }
|
|
64
|
+
}
|
|
65
|
+
function frameQueue(ws, want) {
|
|
66
|
+
const frames = [];
|
|
67
|
+
const waiters = [];
|
|
68
|
+
ws.on('message', (raw) => {
|
|
69
|
+
const f = JSON.parse(String(raw));
|
|
70
|
+
if (f.t !== want)
|
|
71
|
+
return;
|
|
72
|
+
const w = waiters.shift();
|
|
73
|
+
if (w)
|
|
74
|
+
w(f);
|
|
75
|
+
else
|
|
76
|
+
frames.push(f);
|
|
77
|
+
});
|
|
78
|
+
return { next: () => (frames.length ? Promise.resolve(frames.shift()) : new Promise((r) => waiters.push(r))) };
|
|
79
|
+
}
|
|
80
|
+
const git = (cwd, ...args) => (0, child_process_1.execFileSync)('git', args, { cwd, stdio: 'pipe' }).toString();
|
|
81
|
+
let tmp;
|
|
82
|
+
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'save-env-'))); });
|
|
83
|
+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
84
|
+
const initRepo = (dir) => {
|
|
85
|
+
git(dir, 'init', '-q');
|
|
86
|
+
git(dir, 'config', 'user.email', 't@t');
|
|
87
|
+
git(dir, 'config', 'user.name', 't');
|
|
88
|
+
};
|
|
89
|
+
describe('validateEnvEntries', () => {
|
|
90
|
+
it('accepts a good entry and dedupes by name (last wins)', () => {
|
|
91
|
+
expect((0, envSaver_1.validateEnvEntries)([{ name: 'A', value: '1' }, { name: 'A', value: '2' }]))
|
|
92
|
+
.toEqual({ ok: true, entries: [{ name: 'A', value: '2' }] });
|
|
93
|
+
});
|
|
94
|
+
it.each([
|
|
95
|
+
[[], 'Nothing to save'],
|
|
96
|
+
[[{ name: 'lower', value: 'x' }], 'Invalid name'],
|
|
97
|
+
[[{ name: '1A', value: 'x' }], 'Invalid name'],
|
|
98
|
+
[[{ name: 'A'.repeat(65), value: 'x' }], 'Invalid name'],
|
|
99
|
+
[[{ name: 'A', value: '' }], 'A: empty value'],
|
|
100
|
+
[[{ name: 'A', value: 'x\ny' }], 'A: value has a line break'],
|
|
101
|
+
[[{ name: 'A', value: 'x\0y' }], 'A: value has a line break'],
|
|
102
|
+
[[{ name: 'A', value: 'x'.repeat(4097) }], 'A: value too long'],
|
|
103
|
+
[[{ name: 'WENRWA_CONNECT_TOKEN', value: 'x' }], 'WENRWA_CONNECT_TOKEN is reserved'],
|
|
104
|
+
[[{ name: 'HIVEOP_CONTEXT_FILE', value: 'x' }], 'HIVEOP_CONTEXT_FILE is reserved'],
|
|
105
|
+
])('refuses %j', (entries, error) => {
|
|
106
|
+
expect((0, envSaver_1.validateEnvEntries)(entries)).toEqual({ ok: false, error });
|
|
107
|
+
});
|
|
108
|
+
it('saves ANTHROPIC_API_KEY (it is only kept out of the spawn env)', () => {
|
|
109
|
+
expect((0, envSaver_1.validateEnvEntries)([{ name: 'ANTHROPIC_API_KEY', value: KEY }])).toEqual({ ok: true, entries: [{ name: 'ANTHROPIC_API_KEY', value: KEY }] });
|
|
110
|
+
});
|
|
111
|
+
it('never quotes the value in an error', () => {
|
|
112
|
+
const r = (0, envSaver_1.validateEnvEntries)([{ name: 'A', value: `${KEY}\n` }]);
|
|
113
|
+
expect(r.ok).toBe(false);
|
|
114
|
+
expect(JSON.stringify(r)).not.toContain(KEY);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('dotenv round trip', () => {
|
|
118
|
+
it.each([KEY, 'has space', "it's", 'both \' and "', 'back\\slash"q', ' padded ', 'a#b', 'x #y'])('formats %j so it parses back exactly', (v) => {
|
|
119
|
+
expect((0, repoEnv_1.parseDotenv)(`K=${(0, envSaver_1.formatDotenvValue)(v)}\n`).K).toBe(v);
|
|
120
|
+
});
|
|
121
|
+
it('replaces an existing line in place, drops duplicates, keeps everything else', () => {
|
|
122
|
+
const before = '# keys\nFOO=old\nBAR=keep\nexport FOO=older\n';
|
|
123
|
+
expect((0, envSaver_1.upsertDotenv)(before, [{ name: 'FOO', value: 'new' }, { name: 'NEW', value: 'n' }]))
|
|
124
|
+
.toBe('# keys\nFOO=new\nBAR=keep\nNEW=n\n');
|
|
125
|
+
});
|
|
126
|
+
it('appends to a file without a trailing newline', () => {
|
|
127
|
+
expect((0, envSaver_1.upsertDotenv)('A=1', [{ name: 'B', value: '2' }])).toBe('A=1\nB=2\n');
|
|
128
|
+
});
|
|
129
|
+
it('parses export, quotes and comments without expansion', () => {
|
|
130
|
+
expect((0, repoEnv_1.parseDotenv)('export A="x\\ny"\nB=\'$HOME\'\nC=${A} # note\n#D=1\nbad line\n'))
|
|
131
|
+
.toEqual({ A: 'x\ny', B: '$HOME', C: '${A}' });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('saveEnvEntries — real folder, real git', () => {
|
|
135
|
+
it('creates .env (0600) and adds it to .gitignore', async () => {
|
|
136
|
+
initRepo(tmp);
|
|
137
|
+
const r = await (0, envSaver_1.saveEnvEntries)(tmp, [{ name: 'ANTHROPIC_KEY_X', value: KEY }]);
|
|
138
|
+
expect(r).toEqual({ ok: true, names: ['ANTHROPIC_KEY_X'], gitignoreAdded: true });
|
|
139
|
+
expect(fs.readFileSync(path.join(tmp, '.env'), 'utf8')).toBe(`ANTHROPIC_KEY_X=${KEY}\n`);
|
|
140
|
+
expect(fs.statSync(path.join(tmp, '.env')).mode & 0o777).toBe(0o600);
|
|
141
|
+
expect(fs.readFileSync(path.join(tmp, '.gitignore'), 'utf8')).toBe('.env\n');
|
|
142
|
+
expect(git(tmp, 'status', '--porcelain')).not.toContain('.env\n');
|
|
143
|
+
});
|
|
144
|
+
it('replaces an existing NAME, never duplicates, and leaves .gitignore alone when already ignored', async () => {
|
|
145
|
+
initRepo(tmp);
|
|
146
|
+
fs.writeFileSync(path.join(tmp, '.gitignore'), 'node_modules\n*.env\n.env\n');
|
|
147
|
+
fs.writeFileSync(path.join(tmp, '.env'), 'NPM_TOKEN=old\nOTHER=1\n', { mode: 0o644 });
|
|
148
|
+
const r = await (0, envSaver_1.saveEnvEntries)(tmp, [{ name: 'NPM_TOKEN', value: KEY2 }]);
|
|
149
|
+
expect(r).toMatchObject({ ok: true, gitignoreAdded: false });
|
|
150
|
+
expect(fs.readFileSync(path.join(tmp, '.env'), 'utf8')).toBe(`NPM_TOKEN=${KEY2}\nOTHER=1\n`);
|
|
151
|
+
expect(fs.statSync(path.join(tmp, '.env')).mode & 0o777).toBe(0o600);
|
|
152
|
+
expect(fs.readFileSync(path.join(tmp, '.gitignore'), 'utf8')).toBe('node_modules\n*.env\n.env\n');
|
|
153
|
+
});
|
|
154
|
+
it('appends .env to a .gitignore with no trailing newline', async () => {
|
|
155
|
+
initRepo(tmp);
|
|
156
|
+
fs.writeFileSync(path.join(tmp, '.gitignore'), 'dist');
|
|
157
|
+
await (0, envSaver_1.saveEnvEntries)(tmp, [{ name: 'A', value: 'b' }]);
|
|
158
|
+
expect(fs.readFileSync(path.join(tmp, '.gitignore'), 'utf8')).toBe('dist\n.env\n');
|
|
159
|
+
});
|
|
160
|
+
it('refuses when git TRACKS .env, and writes nothing', async () => {
|
|
161
|
+
initRepo(tmp);
|
|
162
|
+
fs.writeFileSync(path.join(tmp, '.env'), 'PUBLIC=1\n');
|
|
163
|
+
git(tmp, 'add', '.env');
|
|
164
|
+
git(tmp, 'commit', '-qm', 'x');
|
|
165
|
+
const r = await (0, envSaver_1.saveEnvEntries)(tmp, [{ name: 'SECRET', value: KEY }]);
|
|
166
|
+
expect(r).toEqual({ ok: false, error: '.env is tracked by git' });
|
|
167
|
+
expect(fs.readFileSync(path.join(tmp, '.env'), 'utf8')).toBe('PUBLIC=1\n');
|
|
168
|
+
expect(fs.existsSync(path.join(tmp, '.gitignore'))).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
it('refuses a symlinked .env (no write outside the folder)', async () => {
|
|
171
|
+
initRepo(tmp);
|
|
172
|
+
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'outside-')));
|
|
173
|
+
try {
|
|
174
|
+
fs.writeFileSync(path.join(outside, 'target'), 'X=1\n');
|
|
175
|
+
fs.symlinkSync(path.join(outside, 'target'), path.join(tmp, '.env'));
|
|
176
|
+
const r = await (0, envSaver_1.saveEnvEntries)(tmp, [{ name: 'SECRET', value: KEY }]);
|
|
177
|
+
expect(r).toEqual({ ok: false, error: '.env is not a plain file' });
|
|
178
|
+
expect(fs.readFileSync(path.join(outside, 'target'), 'utf8')).toBe('X=1\n');
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
it('works in a folder that is not a git repo (still ignores .env for later)', async () => {
|
|
185
|
+
const r = await (0, envSaver_1.saveEnvEntries)(tmp, [{ name: 'A', value: 'b' }]);
|
|
186
|
+
expect(r).toMatchObject({ ok: true, gitignoreAdded: true });
|
|
187
|
+
expect(fs.readFileSync(path.join(tmp, '.env'), 'utf8')).toBe('A=b\n');
|
|
188
|
+
});
|
|
189
|
+
it('refuses a missing folder', async () => {
|
|
190
|
+
expect(await (0, envSaver_1.saveEnvEntries)(path.join(tmp, 'nope'), [{ name: 'A', value: 'b' }]))
|
|
191
|
+
.toEqual({ ok: false, error: 'Project folder not found' });
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe('handleSaveEnvFrame — the session decides the folder', () => {
|
|
195
|
+
const sessionsAt = (cwd) => ({ list: () => ['s1'], cwdOf: () => cwd });
|
|
196
|
+
it('writes into the session cwd; a frame-supplied path is ignored', async () => {
|
|
197
|
+
const reply = await (0, envSaver_1.handleSaveEnvFrame)({ rid: 'r1', sessionId: 's1', entries: [{ name: 'A', value: KEY }], path: '/etc', cwd: '/etc' }, sessionsAt(tmp));
|
|
198
|
+
expect(reply).toMatchObject({ t: 'save-env-result', rid: 'r1', sessionId: 's1', ok: true, names: ['A'] });
|
|
199
|
+
expect(fs.readFileSync(path.join(tmp, '.env'), 'utf8')).toBe(`A=${KEY}\n`);
|
|
200
|
+
expect(JSON.stringify(reply)).not.toContain(KEY);
|
|
201
|
+
});
|
|
202
|
+
it('refuses an unknown session', async () => {
|
|
203
|
+
expect(await (0, envSaver_1.handleSaveEnvFrame)({ rid: 'r', sessionId: 'other', entries: [{ name: 'A', value: 'b' }] }, sessionsAt(tmp)))
|
|
204
|
+
.toMatchObject({ ok: false, error: 'Terminal session not found' });
|
|
205
|
+
});
|
|
206
|
+
it('refuses a session with no project folder (never falls back to $HOME)', async () => {
|
|
207
|
+
expect(await (0, envSaver_1.handleSaveEnvFrame)({ rid: 'r', sessionId: 's1', entries: [{ name: 'A', value: 'b' }] }, sessionsAt(undefined)))
|
|
208
|
+
.toMatchObject({ ok: false, error: 'No project folder' });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
describe('spawn env — repo .env is loaded below HiveOp vars', () => {
|
|
212
|
+
it('session env beats .env beats inherited; reserved names never come from .env', () => {
|
|
213
|
+
fs.writeFileSync(path.join(tmp, '.env'), [
|
|
214
|
+
'OPENAI_API_KEY=from-file',
|
|
215
|
+
'SHARED=from-file',
|
|
216
|
+
'WENRWA_WORKSPACE_ID=evil',
|
|
217
|
+
'WENRWA_CONNECT_TOKEN=evil',
|
|
218
|
+
'ANTHROPIC_API_KEY=would-switch-billing',
|
|
219
|
+
'PATH=/evil',
|
|
220
|
+
'NODE_OPTIONS=--require /evil.js',
|
|
221
|
+
].join('\n'));
|
|
222
|
+
const env = (0, repoEnv_1.mergeSpawnEnv)({ PATH: '/usr/bin', SHARED: 'inherited', WENRWA_CONNECT_TOKEN: 'inherited-token' }, (0, repoEnv_1.loadRepoEnv)(tmp), { WENRWA_WORKSPACE_ID: 'ws-real' });
|
|
223
|
+
expect(env.OPENAI_API_KEY).toBe('from-file');
|
|
224
|
+
expect(env.SHARED).toBe('from-file');
|
|
225
|
+
expect(env.WENRWA_WORKSPACE_ID).toBe('ws-real');
|
|
226
|
+
expect(env.WENRWA_CONNECT_TOKEN).toBe('inherited-token');
|
|
227
|
+
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
|
228
|
+
expect(env.PATH).toBe('/usr/bin');
|
|
229
|
+
expect(env.NODE_OPTIONS).toBeUndefined();
|
|
230
|
+
});
|
|
231
|
+
it('even a reserved name passed straight to mergeSpawnEnv is dropped', () => {
|
|
232
|
+
expect((0, repoEnv_1.mergeSpawnEnv)({}, { WENRWA_AGENT_ID: 'x', OK: '1' }, {})).toEqual({ OK: '1' });
|
|
233
|
+
});
|
|
234
|
+
it('no cwd, no file, or a symlinked file → nothing loaded', () => {
|
|
235
|
+
expect((0, repoEnv_1.loadRepoEnv)(undefined)).toEqual({});
|
|
236
|
+
expect((0, repoEnv_1.loadRepoEnv)(tmp)).toEqual({});
|
|
237
|
+
fs.writeFileSync(path.join(tmp, 'real'), 'A=1\n');
|
|
238
|
+
fs.symlinkSync(path.join(tmp, 'real'), path.join(tmp, '.env'));
|
|
239
|
+
expect((0, repoEnv_1.loadRepoEnv)(tmp)).toEqual({});
|
|
240
|
+
});
|
|
241
|
+
it('a saved key is what the next spawn sees', async () => {
|
|
242
|
+
await (0, envSaver_1.saveEnvEntries)(tmp, [{ name: 'STRIPE_SECRET_KEY', value: 'sk_live_' + 'z'.repeat(24) }]);
|
|
243
|
+
expect((0, repoEnv_1.mergeSpawnEnv)({}, (0, repoEnv_1.loadRepoEnv)(tmp), {}).STRIPE_SECRET_KEY).toBe('sk_live_' + 'z'.repeat(24));
|
|
244
|
+
});
|
|
245
|
+
it('reserved-name rules', () => {
|
|
246
|
+
for (const n of ['WENRWA_X', 'HIVEOP_CONTEXT_FILE', 'CLAUDE_CONFIG_DIR', 'ANTHROPIC_BASE_URL', 'GIT_SSH_COMMAND', 'LD_PRELOAD', 'PATH', 'HT_AUTONOMOUS']) {
|
|
247
|
+
expect((0, repoEnv_1.isReservedEnvName)(n)).toBe(true);
|
|
248
|
+
}
|
|
249
|
+
for (const n of ['GITHUB_TOKEN', 'NPM_TOKEN', 'OPENAI_API_KEY', 'STRIPE_SECRET_KEY', 'SECRET']) {
|
|
250
|
+
expect((0, repoEnv_1.isReservedEnvName)(n)).toBe(false);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
describe('local bridge server — save-env frame', () => {
|
|
255
|
+
let handle;
|
|
256
|
+
let sessions;
|
|
257
|
+
let ptys;
|
|
258
|
+
let logged;
|
|
259
|
+
let spies;
|
|
260
|
+
beforeEach(async () => {
|
|
261
|
+
ptys = [];
|
|
262
|
+
sessions = new sessionManager_1.SessionManager({ spawn: () => { const p = new FakePty(); ptys.push(p); return p; } });
|
|
263
|
+
handle = await (0, server_1.startBridgeServer)({ sessionToken: 'tok', allowedOrigin: 'https://app.hiveop.io', sessions });
|
|
264
|
+
logged = [];
|
|
265
|
+
spies = ['log', 'error', 'warn', 'info', 'debug'].map((m) => jest.spyOn(console, m).mockImplementation((...a) => { logged.push(a.map(String).join(' ')); }));
|
|
266
|
+
});
|
|
267
|
+
afterEach(async () => { spies.forEach((s) => s.mockRestore()); await handle.close(); });
|
|
268
|
+
it('writes the session .env, replies with names only, nothing reaches the PTY or the log', async () => {
|
|
269
|
+
initRepo(tmp);
|
|
270
|
+
const sessionId = sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'agent-1' });
|
|
271
|
+
const ws = new ws_1.default(`ws://127.0.0.1:${handle.port}`, { headers: { origin: 'https://app.hiveop.io' } });
|
|
272
|
+
await new Promise((r) => ws.on('open', r));
|
|
273
|
+
const q = frameQueue(ws, 'save-env-result');
|
|
274
|
+
ws.send(JSON.stringify({ v: 1, token: 'tok' }));
|
|
275
|
+
ws.send(JSON.stringify({ t: 'save-env', rid: 'r1', sessionId, entries: [{ name: 'OPENAI_API_KEY', value: KEY }] }));
|
|
276
|
+
const reply = await q.next();
|
|
277
|
+
expect(reply).toEqual({ t: 'save-env-result', rid: 'r1', sessionId, ok: true, names: ['OPENAI_API_KEY'], gitignoreAdded: true });
|
|
278
|
+
expect(fs.readFileSync(path.join(tmp, '.env'), 'utf8')).toBe(`OPENAI_API_KEY=${KEY}\n`);
|
|
279
|
+
expect(ptys[0].written).toEqual([]);
|
|
280
|
+
expect(logged.join('\n')).not.toContain(KEY);
|
|
281
|
+
ws.close();
|
|
282
|
+
});
|
|
283
|
+
it('advertises the save-env capability in /discovery', async () => {
|
|
284
|
+
const res = await fetch(`http://127.0.0.1:${handle.port}/discovery`, { headers: { origin: 'https://app.hiveop.io' } });
|
|
285
|
+
expect((await res.json()).capabilities).toContain('save-env');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
describe('relay client (hosted container) — save-env frame', () => {
|
|
289
|
+
it('is session-addressed (relay-owned check applies)', () => {
|
|
290
|
+
expect(relayClient_1.SESSION_ADDRESSED_FRAMES.has('save-env')).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
it('writes the relay session .env and echoes rid + sessionId for demux', async () => {
|
|
293
|
+
const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
|
|
294
|
+
await new Promise((r) => wss.on('listening', r));
|
|
295
|
+
const port = wss.address().port;
|
|
296
|
+
const conn = new Promise((r) => wss.on('connection', r));
|
|
297
|
+
const sessions = new sessionManager_1.SessionManager({ spawn: () => new FakePty() });
|
|
298
|
+
sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'agent-H' });
|
|
299
|
+
const client = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${port}`, relayToken: 'tok', sessions });
|
|
300
|
+
const ws = await conn;
|
|
301
|
+
const q = frameQueue(ws, 'save-env-result');
|
|
302
|
+
ws.send(JSON.stringify({ t: 'save-env', rid: 'h1', sessionId: 'agent-H', entries: [{ name: 'GITHUB_TOKEN', value: 'ghp_' + 'x'.repeat(36) }] }));
|
|
303
|
+
expect(await q.next()).toMatchObject({ t: 'save-env-result', rid: 'h1', sessionId: 'agent-H', ok: true, names: ['GITHUB_TOKEN'] });
|
|
304
|
+
expect(fs.readFileSync(path.join(tmp, '.env'), 'utf8')).toBe(`GITHUB_TOKEN=ghp_${'x'.repeat(36)}\n`);
|
|
305
|
+
ws.send(JSON.stringify({ t: 'save-env', rid: 'h2', sessionId: 'agent-H', entries: [{ name: 'bad name', value: 'x' }] }));
|
|
306
|
+
expect(await q.next()).toMatchObject({ rid: 'h2', ok: false, error: 'Invalid name' });
|
|
307
|
+
client.close();
|
|
308
|
+
for (const c of wss.clients)
|
|
309
|
+
c.terminate();
|
|
310
|
+
await new Promise((r) => wss.close(r));
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
describe('agents are told where keys live (both lanes)', () => {
|
|
314
|
+
const LINE = 'Keys live in the repo .env; use them as env vars.';
|
|
315
|
+
it('hosted CLAUDE.md region carries the line once; absent without HiveOp tools', () => {
|
|
316
|
+
const files = new Map();
|
|
317
|
+
const deps = {
|
|
318
|
+
reposBaseDir: '/data/repos',
|
|
319
|
+
exists: (p) => files.has(p),
|
|
320
|
+
readFile: (p) => files.get(p) ?? '',
|
|
321
|
+
writeFile: (p, c) => { files.set(p, c); },
|
|
322
|
+
mkdirp: () => { },
|
|
323
|
+
};
|
|
324
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'x', workGraphAvailable: true }, deps);
|
|
325
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', pointersOnly: true, workGraphAvailable: true }, deps);
|
|
326
|
+
const out = files.get('/data/repos/ws-1/CLAUDE.md');
|
|
327
|
+
expect(workGraphPointer_1.KEYS_POINTER).toBe(`## Keys\n${LINE}`);
|
|
328
|
+
expect(out.split(LINE).length - 1).toBe(1);
|
|
329
|
+
files.clear();
|
|
330
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'x' }, deps);
|
|
331
|
+
expect(files.get('/data/repos/ws-1/CLAUDE.md')).not.toContain(LINE);
|
|
332
|
+
});
|
|
333
|
+
it('local .hiveop/context.md carries the line', () => {
|
|
334
|
+
(0, localContextWriter_1.writeLocalContext)({ projectRoot: tmp, content: 'x', workGraphAvailable: true }, {
|
|
335
|
+
exists: (p) => fs.existsSync(p),
|
|
336
|
+
readFile: (p) => fs.readFileSync(p, 'utf-8'),
|
|
337
|
+
mkdirp: (d) => fs.mkdirSync(d, { recursive: true }),
|
|
338
|
+
writeFileAtomic: (p, c) => fs.writeFileSync(p, c, 'utf-8'),
|
|
339
|
+
remove: (p) => fs.rmSync(p, { force: true }),
|
|
340
|
+
});
|
|
341
|
+
expect(fs.readFileSync(path.join(tmp, localContextWriter_1.LOCAL_CONTEXT_RELATIVE_PATH), 'utf-8')).toContain(LINE);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const ws_1 = __importDefault(require("ws"));
|
|
7
|
+
const server_1 = require("../bridge/server");
|
|
8
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
9
|
+
/**
|
|
10
|
+
* Phantom "Session attached elsewhere" on the loopback lane (prod 2026-10-17).
|
|
11
|
+
* The bridge's last-attach-wins told every superseded attachment the same
|
|
12
|
+
* thing, whether the new attach was the same socket re-attaching, the same
|
|
13
|
+
* pane redialing, another pane in the same page, or another browser.
|
|
14
|
+
*/
|
|
15
|
+
class FakePty {
|
|
16
|
+
dataCb = null;
|
|
17
|
+
write() { }
|
|
18
|
+
resize() { }
|
|
19
|
+
kill() { }
|
|
20
|
+
onData(cb) { this.dataCb = cb; }
|
|
21
|
+
onExit() { }
|
|
22
|
+
emitData(d) { this.dataCb?.(d); }
|
|
23
|
+
}
|
|
24
|
+
const ORIGIN = 'https://app.hiveop.io';
|
|
25
|
+
const TOKEN = 'session-token-123';
|
|
26
|
+
const client = async (port) => {
|
|
27
|
+
const ws = new ws_1.default(`ws://127.0.0.1:${port}`, { headers: { origin: ORIGIN } });
|
|
28
|
+
const frames = [];
|
|
29
|
+
ws.on('message', (raw) => frames.push(JSON.parse(String(raw))));
|
|
30
|
+
await new Promise((r) => ws.on('open', r));
|
|
31
|
+
ws.send(JSON.stringify({ v: 1, token: TOKEN }));
|
|
32
|
+
return { ws, frames };
|
|
33
|
+
};
|
|
34
|
+
const settle = (ms = 40) => new Promise((r) => setTimeout(r, ms));
|
|
35
|
+
const types = (frames) => frames.map((f) => f.t);
|
|
36
|
+
describe('bridge — phantom takeover', () => {
|
|
37
|
+
let handle;
|
|
38
|
+
let sessionId;
|
|
39
|
+
let owner;
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
const factory = { spawn: () => new FakePty() };
|
|
42
|
+
handle = await (0, server_1.startBridgeServer)({ sessionToken: TOKEN, allowedOrigin: ORIGIN, sessions: new sessionManager_1.SessionManager(factory) });
|
|
43
|
+
owner = await client(handle.port);
|
|
44
|
+
owner.ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, client: { browser: 'b1', tab: 't1', mount: 'm1' } }));
|
|
45
|
+
await settle();
|
|
46
|
+
sessionId = owner.frames.find((f) => f.t === 'opened').sessionId;
|
|
47
|
+
owner.frames.length = 0;
|
|
48
|
+
});
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
owner.ws.close();
|
|
51
|
+
await handle.close();
|
|
52
|
+
});
|
|
53
|
+
it('a socket re-attaching its own session is not told it was taken over', async () => {
|
|
54
|
+
owner.ws.send(JSON.stringify({ t: 'attach', sessionId, cols: 80, rows: 24, client: { browser: 'b1', tab: 't1', mount: 'm1' } }));
|
|
55
|
+
await settle();
|
|
56
|
+
expect(types(owner.frames)).toEqual(['replay']);
|
|
57
|
+
});
|
|
58
|
+
it('the same pane redialing on a new socket leaves its ghost socket silent', async () => {
|
|
59
|
+
const again = await client(handle.port);
|
|
60
|
+
again.ws.send(JSON.stringify({ t: 'attach', sessionId, cols: 80, rows: 24, client: { browser: 'b1', tab: 't1', mount: 'm1' } }));
|
|
61
|
+
await settle();
|
|
62
|
+
expect(types(owner.frames)).not.toContain('takeover');
|
|
63
|
+
expect(types(again.frames)).toContain('replay');
|
|
64
|
+
again.ws.close();
|
|
65
|
+
});
|
|
66
|
+
it.each([
|
|
67
|
+
['another pane in the same page', { browser: 'b1', tab: 't1', mount: 'm2' }, 'page'],
|
|
68
|
+
['another tab', { browser: 'b1', tab: 't2', mount: 'm3' }, 'tab'],
|
|
69
|
+
['another browser', { browser: 'b2', tab: 't3', mount: 'm4' }, 'device'],
|
|
70
|
+
])('a real second viewer (%s) is named in the takeover', async (_label, who, where) => {
|
|
71
|
+
const other = await client(handle.port);
|
|
72
|
+
other.ws.send(JSON.stringify({ t: 'attach', sessionId, cols: 80, rows: 24, client: who }));
|
|
73
|
+
await settle();
|
|
74
|
+
expect(owner.frames.find((f) => f.t === 'takeover')).toEqual({ t: 'takeover', sessionId, by: where });
|
|
75
|
+
other.ws.close();
|
|
76
|
+
});
|
|
77
|
+
it('an old client that names nobody still gets the plain takeover', async () => {
|
|
78
|
+
const other = await client(handle.port);
|
|
79
|
+
other.ws.send(JSON.stringify({ t: 'attach', sessionId, cols: 80, rows: 24 }));
|
|
80
|
+
await settle();
|
|
81
|
+
expect(owner.frames.find((f) => f.t === 'takeover')).toEqual({ t: 'takeover', sessionId });
|
|
82
|
+
other.ws.close();
|
|
83
|
+
});
|
|
84
|
+
});
|