@wenrwa/marketplace-cli 0.8.4 → 0.8.6
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-changes-reporter.test.js +98 -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/__tests__/term-snapshot.test.js +136 -0
- package/dist/bridge/changesReport.js +10 -3
- package/dist/bridge/changesReporter.js +0 -0
- 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/realPty.js +5 -1
- package/dist/bridge/relayClient.d.ts +0 -18
- package/dist/bridge/relayClient.js +21 -6
- 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 +4 -0
- package/dist/bridge/sessionManager.js +28 -8
- package/dist/bridge/termSnapshot.js +114 -2
- package/dist/bridge/workGraphPointer.d.ts +25 -0
- package/dist/bridge/workGraphPointer.js +36 -1
- package/dist/commands/bridge.js +4 -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',
|
|
@@ -1,4 +1,37 @@
|
|
|
1
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
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
/**
|
|
4
37
|
* tchp-02 — per-session working-tree change detector (`changes` frame).
|
|
@@ -703,4 +736,69 @@ describe('execGit — opts.env (tchp-21)', () => {
|
|
|
703
736
|
expect(res.code).toBe(0);
|
|
704
737
|
expect(res.stdout).toBe('true');
|
|
705
738
|
});
|
|
739
|
+
// Not-a-repo model (live report 2026-10-11): a plain folder used to burn
|
|
740
|
+
// three "failures", get disabled, and leave the panel on "Waiting for
|
|
741
|
+
// changes…" forever.
|
|
742
|
+
describe('a folder that is not a git repo', () => {
|
|
743
|
+
const notGitRun = () => jest.fn().mockImplementation(async (_cmd, args) => ({ stdout: '', code: 128, args }));
|
|
744
|
+
it('emits ONE changes-untracked frame and keeps polling (never disabled)', async () => {
|
|
745
|
+
const run = notGitRun();
|
|
746
|
+
const build = jest.fn();
|
|
747
|
+
const { reporter, sent } = makeReporter({ run, buildChangesReport: build, now: () => 7 });
|
|
748
|
+
const sessions = [{ sessionId: 's1', workspaceId: 'w1', cwd: '/plain' }];
|
|
749
|
+
for (let i = 0; i < 5; i++)
|
|
750
|
+
await reporter.tick(sessions);
|
|
751
|
+
expect(build).not.toHaveBeenCalled();
|
|
752
|
+
expect(sent).toEqual([
|
|
753
|
+
{ t: 'changes-untracked', reason: 'not-git', workspaceId: 'w1', agentId: 's1', sessionId: 's1', ts: 7 },
|
|
754
|
+
]);
|
|
755
|
+
// still probing every tick — a later `git init` must be noticed
|
|
756
|
+
const statusCalls = run.mock.calls.filter((c) => c[1].includes('status'));
|
|
757
|
+
expect(statusCalls).toHaveLength(5);
|
|
758
|
+
});
|
|
759
|
+
it('replays the untracked notice to a fresh connection', async () => {
|
|
760
|
+
const { reporter, sent } = makeReporter({ run: notGitRun() });
|
|
761
|
+
const sessions = [{ sessionId: 's1', workspaceId: 'w1', cwd: '/plain' }];
|
|
762
|
+
await reporter.tick(sessions);
|
|
763
|
+
reporter.requestReplay();
|
|
764
|
+
await reporter.tick(sessions);
|
|
765
|
+
expect(sent).toHaveLength(2);
|
|
766
|
+
expect(sent[1]).toBe(sent[0]);
|
|
767
|
+
});
|
|
768
|
+
it('a later git init switches to a real report immediately', async () => {
|
|
769
|
+
let isRepo = false;
|
|
770
|
+
const run = jest.fn().mockImplementation(async () => (isRepo ? statusOk('?? a.R\n') : { stdout: '', code: 128 }));
|
|
771
|
+
const build = jest.fn().mockResolvedValue(fakeReport('sha-1'));
|
|
772
|
+
const { reporter, sent } = makeReporter({ run, buildChangesReport: build });
|
|
773
|
+
const sessions = [{ sessionId: 's1', workspaceId: 'w1', cwd: '/plain' }];
|
|
774
|
+
await reporter.tick(sessions);
|
|
775
|
+
isRepo = true;
|
|
776
|
+
await reporter.tick(sessions);
|
|
777
|
+
expect(sent.map((f) => f.t)).toEqual(['changes-untracked', 'changes']);
|
|
778
|
+
});
|
|
779
|
+
it('a non-128 git failure is still an ordinary failure (no untracked frame)', async () => {
|
|
780
|
+
const run = jest.fn().mockResolvedValue({ stdout: '', code: 1 });
|
|
781
|
+
const { reporter, sent } = makeReporter({ run });
|
|
782
|
+
const sessions = [{ sessionId: 's1', workspaceId: 'w1', cwd: '/x' }];
|
|
783
|
+
for (let i = 0; i < 4; i++)
|
|
784
|
+
await reporter.tick(sessions);
|
|
785
|
+
expect(sent).toEqual([]);
|
|
786
|
+
expect(run).toHaveBeenCalledTimes(3); // 3 strikes then disabled
|
|
787
|
+
});
|
|
788
|
+
it('real git: a temp plain folder is reported as not-git', async () => {
|
|
789
|
+
const fs = await Promise.resolve().then(() => __importStar(require('fs')));
|
|
790
|
+
const os = await Promise.resolve().then(() => __importStar(require('os')));
|
|
791
|
+
const path = await Promise.resolve().then(() => __importStar(require('path')));
|
|
792
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plain-'));
|
|
793
|
+
try {
|
|
794
|
+
const { reporter, sent } = makeReporter({ run: changesReporter_1.execGit });
|
|
795
|
+
await reporter.tick([{ sessionId: 's1', workspaceId: 'w1', cwd: dir }]);
|
|
796
|
+
expect(sent).toHaveLength(1);
|
|
797
|
+
expect(sent[0]).toMatchObject({ t: 'changes-untracked', reason: 'not-git' });
|
|
798
|
+
}
|
|
799
|
+
finally {
|
|
800
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
});
|
|
706
804
|
});
|
|
@@ -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
|
+
});
|
|
@@ -489,13 +489,14 @@ describe('bridge WebSocket server', () => {
|
|
|
489
489
|
});
|
|
490
490
|
// ── lws-07 (ADR-0033 port): attach carries cols/rows; replay frames say snapshot:true ────
|
|
491
491
|
describe('attach size + replay snapshot flag', () => {
|
|
492
|
-
// Re-attaching on the SAME connection
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
492
|
+
// Re-attaching on the SAME connection supersedes its own prior
|
|
493
|
+
// attachment, but the bridge no longer tells a socket it was taken over
|
|
494
|
+
// by itself (phantom "attached elsewhere", 2026-10-17) — the replay is
|
|
495
|
+
// the next frame.
|
|
496
496
|
const nextReplay = async (q) => {
|
|
497
|
-
const
|
|
498
|
-
|
|
497
|
+
const f = await q.next();
|
|
498
|
+
expect(f.t).toBe('replay');
|
|
499
|
+
return f;
|
|
499
500
|
};
|
|
500
501
|
it('attach with cols/rows resizes before replay (size differs from tracked geometry); replay marks snapshot:true', async () => {
|
|
501
502
|
const { ws, q } = await openAndAuth(handle.port);
|
|
@@ -649,12 +650,9 @@ describe('bridge WebSocket server — attach deadline (twr-16)', () => {
|
|
|
649
650
|
const opened = await q.next();
|
|
650
651
|
// A size differing from the tracked geometry enters the resize→settle
|
|
651
652
|
// branch the deadline stalls in. Re-attaching on the SAME connection
|
|
652
|
-
//
|
|
653
|
-
// `takeover` frame precedes the error on this socket — pull both and
|
|
654
|
-
// match by type (same convention as the size-handling tests above).
|
|
653
|
+
// sends no self-takeover, so the error is the next frame.
|
|
655
654
|
ws.send(JSON.stringify({ t: 'attach', sessionId: opened.sessionId, cols: 120, rows: 40 }));
|
|
656
|
-
const
|
|
657
|
-
const err = [a, b].find((f) => f.t === 'error');
|
|
655
|
+
const err = await q.next();
|
|
658
656
|
expect(err).toMatchObject({ t: 'error', sessionId: opened.sessionId });
|
|
659
657
|
expect(String(err.message)).toContain('stage=settle');
|
|
660
658
|
ws.close();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|