@clastres/groundcontrol 0.1.10 → 0.1.12
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/daemon.mjs +344 -230
- package/package.json +2 -3
- package/test/approvals.mjs +121 -0
- package/test/codex.mjs +94 -0
- package/test/providers.mjs +7 -4
- package/test/review-fixes.mjs +294 -0
- package/test/grokbot.mjs +0 -86
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "See and command your coding agents from your iPhone. Pairs the Ground Control app with Claude Code, Codex
|
|
3
|
+
"version": "0.1.12",
|
|
4
|
+
"description": "See and command your coding agents from your iPhone. Pairs the Ground Control app with Claude Code, Codex and Kimi sessions running on this Mac, end to end encrypted.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"groundcontrol": "bin/groundcontrol.mjs"
|
|
7
7
|
},
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
"claude-code",
|
|
22
22
|
"codex",
|
|
23
23
|
"kimi",
|
|
24
|
-
"grok",
|
|
25
24
|
"ai",
|
|
26
25
|
"agents",
|
|
27
26
|
"cli",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// A held permission request must clear when it is answered on the Mac, and
|
|
2
|
+
// must NOT clear while it is still genuinely waiting on you. That balance has
|
|
3
|
+
// been wrong twice, in both directions, so it gets a test.
|
|
4
|
+
//
|
|
5
|
+
// The fast path is the hook's socket closing, and in practice it almost never
|
|
6
|
+
// fires: 182 of 189 settlements in a real daemon log came from this function
|
|
7
|
+
// instead. So the status reading is the mechanism, not the safety net.
|
|
8
|
+
//
|
|
9
|
+
// Wrong the first way: any status other than "waiting" counted as answered.
|
|
10
|
+
// Claude Code only writes "waiting" for the prompt it draws itself, and a
|
|
11
|
+
// permission request raised from inside a running turn never gets one, so
|
|
12
|
+
// every gated command inside a turn was declared answered six seconds after
|
|
13
|
+
// it was asked. The card vanished off the phone mid tap and the session
|
|
14
|
+
// stayed stopped on a question nobody could answer from there.
|
|
15
|
+
//
|
|
16
|
+
// Wrong the second way: requiring "waiting" to have been seen at all. That is
|
|
17
|
+
// correct and it never lets go, so a request genuinely answered on the Mac
|
|
18
|
+
// leaves a phantom card until the twelve hour hold cap.
|
|
19
|
+
//
|
|
20
|
+
// Right: hold through busy and shell, let go on idle. A session cannot report
|
|
21
|
+
// idle while a hook is blocking a tool call.
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
27
|
+
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
28
|
+
const start = src.indexOf('const ANSWERED_GRACE_MS');
|
|
29
|
+
const end = src.indexOf('/// Sitting back down at the Mac clears the phone card.');
|
|
30
|
+
if (start < 0 || end < 0 || end < start) throw new Error('could not locate releaseAnsweredOnMac in daemon.mjs');
|
|
31
|
+
|
|
32
|
+
let failures = 0;
|
|
33
|
+
const check = (label, cond, detail = '') => {
|
|
34
|
+
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
35
|
+
if (!cond) failures++;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const settled = [];
|
|
39
|
+
const pendingAsks = new Map();
|
|
40
|
+
const lastRawStatus = new Map();
|
|
41
|
+
const settleAsk = (ask, _decision, outcome, note) => {
|
|
42
|
+
pendingAsks.delete(ask.id);
|
|
43
|
+
settled.push({ id: ask.id, outcome, note });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const { releaseAnsweredOnMac, ANSWERED_GRACE_MS } = new Function(
|
|
47
|
+
'pendingAsks', 'lastRawStatus', 'settleAsk',
|
|
48
|
+
`${src.slice(start, end)}\n return { releaseAnsweredOnMac, ANSWERED_GRACE_MS };`,
|
|
49
|
+
)(pendingAsks, lastRawStatus, settleAsk);
|
|
50
|
+
|
|
51
|
+
/// A held request, aged so the grace period is behind it unless asked otherwise.
|
|
52
|
+
function hold(id, sessionId, { ageMs = ANSWERED_GRACE_MS + 1000, sawWaiting = false } = {}) {
|
|
53
|
+
const ask = { id, sessionId, at: Date.now() - ageMs, sawWaiting };
|
|
54
|
+
pendingAsks.set(id, ask);
|
|
55
|
+
return ask;
|
|
56
|
+
}
|
|
57
|
+
const reset = () => { pendingAsks.clear(); lastRawStatus.clear(); settled.length = 0; };
|
|
58
|
+
const outcomeOf = (id) => settled.find((s) => s.id === id)?.outcome;
|
|
59
|
+
|
|
60
|
+
// The bug Carlos hit: a gated command inside a running turn. Claude never says
|
|
61
|
+
// waiting, it says busy for as long as the hook blocks.
|
|
62
|
+
reset();
|
|
63
|
+
hold('a1', 's-busy');
|
|
64
|
+
lastRawStatus.set('s-busy', 'busy');
|
|
65
|
+
releaseAnsweredOnMac();
|
|
66
|
+
check('a request held inside a running turn is not declared answered', pendingAsks.has('a1'), outcomeOf('a1') || 'still held');
|
|
67
|
+
|
|
68
|
+
// Same, for a session running a shell command.
|
|
69
|
+
reset();
|
|
70
|
+
hold('a2', 's-shell');
|
|
71
|
+
lastRawStatus.set('s-shell', 'shell');
|
|
72
|
+
releaseAnsweredOnMac();
|
|
73
|
+
check('nor one held while a shell command runs', pendingAsks.has('a2'));
|
|
74
|
+
|
|
75
|
+
// The other failure mode: the turn ended, so whatever was holding it is gone.
|
|
76
|
+
reset();
|
|
77
|
+
hold('a3', 's-idle');
|
|
78
|
+
lastRawStatus.set('s-idle', 'idle');
|
|
79
|
+
releaseAnsweredOnMac();
|
|
80
|
+
check('an idle session releases the request', outcomeOf('a3') === 'gone', outcomeOf('a3') || 'still held');
|
|
81
|
+
|
|
82
|
+
// A busy session that later goes idle has to release, or the card is phantom
|
|
83
|
+
// until the twelve hour cap.
|
|
84
|
+
reset();
|
|
85
|
+
const a4 = hold('a4', 's-turn');
|
|
86
|
+
lastRawStatus.set('s-turn', 'busy');
|
|
87
|
+
releaseAnsweredOnMac();
|
|
88
|
+
const heldDuringTurn = pendingAsks.has('a4');
|
|
89
|
+
lastRawStatus.set('s-turn', 'idle');
|
|
90
|
+
releaseAnsweredOnMac();
|
|
91
|
+
check('held through the turn, released when it ends', heldDuringTurn && outcomeOf('a4') === 'gone');
|
|
92
|
+
check('the ask is not left in the pending map', !pendingAsks.has('a4') && a4.id === 'a4');
|
|
93
|
+
|
|
94
|
+
// Claude's own prompt does write waiting, and dropping out of it is the
|
|
95
|
+
// original signal, which still has to work.
|
|
96
|
+
reset();
|
|
97
|
+
hold('a5', 's-prompt');
|
|
98
|
+
lastRawStatus.set('s-prompt', 'waiting');
|
|
99
|
+
releaseAnsweredOnMac();
|
|
100
|
+
const heldWhileWaiting = pendingAsks.has('a5');
|
|
101
|
+
lastRawStatus.set('s-prompt', 'busy');
|
|
102
|
+
releaseAnsweredOnMac();
|
|
103
|
+
check('a prompt seen waiting releases as soon as it stops waiting',
|
|
104
|
+
heldWhileWaiting && outcomeOf('a5') === 'gone', outcomeOf('a5') || 'still held');
|
|
105
|
+
|
|
106
|
+
// The grace period covers the gap between the hook firing and Claude Code
|
|
107
|
+
// getting round to writing its status.
|
|
108
|
+
reset();
|
|
109
|
+
hold('a6', 's-fresh', { ageMs: 1000 });
|
|
110
|
+
lastRawStatus.set('s-fresh', 'idle');
|
|
111
|
+
releaseAnsweredOnMac();
|
|
112
|
+
check('a request younger than the grace period is left alone', pendingAsks.has('a6'));
|
|
113
|
+
|
|
114
|
+
// A session the daemon cannot see at all is not evidence of anything.
|
|
115
|
+
reset();
|
|
116
|
+
hold('a7', 's-unknown');
|
|
117
|
+
releaseAnsweredOnMac();
|
|
118
|
+
check('an unknown session releases nothing', pendingAsks.has('a7'));
|
|
119
|
+
|
|
120
|
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
121
|
+
process.exit(failures ? 1 : 0);
|
package/test/codex.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Codex writes one rollout file per run, and two of the ways it does that used
|
|
2
|
+
// to put holes in the app.
|
|
3
|
+
//
|
|
4
|
+
// A helper thread (the review subagent) gets its own rollout carrying the
|
|
5
|
+
// *parent's* session_id, so the daemon reported two sessions under one id. The
|
|
6
|
+
// phone keys every pilot on that id, so it drew one and left a card sized gap
|
|
7
|
+
// where the other should have been, on the crew list and on the island both.
|
|
8
|
+
//
|
|
9
|
+
// And a plain `codex exec` starts a fresh rollout every time, so asking Codex
|
|
10
|
+
// to review something three times in one folder put three identical pilots on
|
|
11
|
+
// the island. Only the freshest one in a directory is worth talking to.
|
|
12
|
+
//
|
|
13
|
+
// This runs the real reader out of daemon.mjs against a synthetic ~/.codex.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
20
|
+
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
21
|
+
const start = src.indexOf('// ---------- Codex session discovery ----------');
|
|
22
|
+
const end = src.indexOf('// ---------- Kimi session discovery ----------');
|
|
23
|
+
if (start < 0 || end < 0 || end < start) throw new Error('could not locate the Codex block in daemon.mjs');
|
|
24
|
+
|
|
25
|
+
let failures = 0;
|
|
26
|
+
const check = (label, cond, detail = '') => {
|
|
27
|
+
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
28
|
+
if (!cond) failures++;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-codex-'));
|
|
32
|
+
const day = path.join(tmp, 'sessions', '2026', '09', '05');
|
|
33
|
+
fs.mkdirSync(day, { recursive: true });
|
|
34
|
+
|
|
35
|
+
/// One rollout: the session_meta line, then a finished turn so the reader has
|
|
36
|
+
/// something to call a last message.
|
|
37
|
+
function rollout(name, meta, { text = 'hello', minutesAgo = 1 } = {}) {
|
|
38
|
+
const file = path.join(day, `rollout-${name}.jsonl`);
|
|
39
|
+
const at = new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
|
40
|
+
fs.writeFileSync(file, [
|
|
41
|
+
JSON.stringify({ timestamp: at, type: 'session_meta', payload: { timestamp: at, ...meta } }),
|
|
42
|
+
JSON.stringify({ timestamp: at, type: 'event_msg', payload: { type: 'task_started' } }),
|
|
43
|
+
JSON.stringify({ timestamp: at, type: 'event_msg', payload: { type: 'agent_message', message: text } }),
|
|
44
|
+
JSON.stringify({ timestamp: at, type: 'event_msg', payload: { type: 'task_complete' } }),
|
|
45
|
+
'',
|
|
46
|
+
].join('\n'));
|
|
47
|
+
const when = Date.now() - minutesAgo * 60_000;
|
|
48
|
+
fs.utimesSync(file, when / 1000, when / 1000);
|
|
49
|
+
return file;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PROJECT = '/tmp/project-one';
|
|
53
|
+
const OTHER = '/tmp/project-two';
|
|
54
|
+
|
|
55
|
+
// The pair that broke it: a run and the review helper it spawned, same second,
|
|
56
|
+
// same id, two files.
|
|
57
|
+
rollout('a-parent', { session_id: 'id-parent', id: 'id-parent', cwd: PROJECT, thread_source: 'user', source: 'exec' },
|
|
58
|
+
{ text: 'the older run', minutesAgo: 9 });
|
|
59
|
+
rollout('a-helper', { session_id: 'id-parent', id: 'id-child', parent_thread_id: 'id-parent', cwd: PROJECT, thread_source: 'subagent', source: { subagent: 'review' } },
|
|
60
|
+
{ text: 'reviewing', minutesAgo: 8 });
|
|
61
|
+
// A second, unrelated run in the same folder, more recent than both.
|
|
62
|
+
rollout('b-newer', { session_id: 'id-newer', id: 'id-newer', cwd: PROJECT, thread_source: 'user', source: 'exec' },
|
|
63
|
+
{ text: 'the newest run', minutesAgo: 2 });
|
|
64
|
+
// A different folder, which must survive on its own.
|
|
65
|
+
rollout('c-other', { session_id: 'id-other', id: 'id-other', cwd: OTHER, thread_source: 'user', source: 'exec' },
|
|
66
|
+
{ text: 'elsewhere', minutesAgo: 3 });
|
|
67
|
+
// Older than the active window: gone from the fleet entirely.
|
|
68
|
+
rollout('d-stale', { session_id: 'id-stale', id: 'id-stale', cwd: '/tmp/project-three', thread_source: 'user', source: 'exec' },
|
|
69
|
+
{ text: 'yesterday', minutesAgo: 60 * 5 });
|
|
70
|
+
|
|
71
|
+
// tailLines, memoScan and scanMemo all live inside the extracted block, so the
|
|
72
|
+
// only things handed in are the modules and the two maps the daemon shares.
|
|
73
|
+
const block = src.slice(start, end)
|
|
74
|
+
.replace(/const CODEX_DIR = .*/, `const CODEX_DIR = ${JSON.stringify(tmp)};`);
|
|
75
|
+
|
|
76
|
+
const { readCodexSessions } = new Function('fs', 'os', 'path', 'transcriptCache', 'headlessActive',
|
|
77
|
+
`${block}\n return { readCodexSessions };`)(fs, os, path, new Map(), new Set());
|
|
78
|
+
|
|
79
|
+
const sessions = readCodexSessions();
|
|
80
|
+
const ids = sessions.map((s) => s.id);
|
|
81
|
+
const cwds = sessions.map((s) => s.cwd);
|
|
82
|
+
|
|
83
|
+
check('the helper thread is not a pilot', !ids.includes('id-child') && ids.filter((i) => i === 'id-parent').length <= 1, ids.join(', '));
|
|
84
|
+
check('no id appears twice', new Set(ids).size === ids.length, ids.join(', '));
|
|
85
|
+
check('one pilot per folder', new Set(cwds).size === cwds.length, cwds.join(', '));
|
|
86
|
+
check('the folder keeps its freshest run', ids.includes('id-newer') && !ids.includes('id-parent'), ids.join(', '));
|
|
87
|
+
check('a second folder still flies', ids.includes('id-other'));
|
|
88
|
+
check('a stale rollout is off the island', !ids.includes('id-stale'));
|
|
89
|
+
check('the surviving pilot carries its own last message',
|
|
90
|
+
sessions.find((s) => s.id === 'id-newer')?.lastMessage?.text === 'the newest run');
|
|
91
|
+
|
|
92
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
93
|
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
94
|
+
process.exit(failures ? 1 : 0);
|
package/test/providers.mjs
CHANGED
|
@@ -20,11 +20,14 @@ const check = (label, cond, detail = '') => {
|
|
|
20
20
|
if (!cond) failures++;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
// Find every
|
|
24
|
-
//
|
|
23
|
+
// Find every session object literal and read it, counting braces so a nested
|
|
24
|
+
// object cannot end the block early. Most readers push one straight onto the
|
|
25
|
+
// list; codex builds one first, because it keeps only the freshest rollout per
|
|
26
|
+
// folder and may have to replace a row it already added.
|
|
25
27
|
const blocks = [];
|
|
26
28
|
lines.forEach((line, i) => {
|
|
27
|
-
|
|
29
|
+
const t = line.trim();
|
|
30
|
+
if (!/sessions\.push\(\{\s*$/.test(t) && !/^const session = \{\s*$/.test(t)) return;
|
|
28
31
|
let depth = 1;
|
|
29
32
|
const body = [];
|
|
30
33
|
for (let j = i + 1; j < lines.length && depth > 0; j++) {
|
|
@@ -48,7 +51,7 @@ for (const b of blocks) {
|
|
|
48
51
|
// heard of still renders, uppercased, but a rename would silently drop a brand
|
|
49
52
|
// name that used to be there, so the known set is pinned.
|
|
50
53
|
const providers = new Set(blocks.map((b) => (b.body.match(/provider:\s*'([^']+)'/) || [])[1]).filter(Boolean));
|
|
51
|
-
for (const known of ['claude', 'codex', 'kimi', 'kimicode'
|
|
54
|
+
for (const known of ['claude', 'codex', 'kimi', 'kimicode']) {
|
|
52
55
|
check(`${known} is still a provider the daemon emits`, providers.has(known));
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// The fixes from the 2026-09-22 review, each pinned to the failure it fixes.
|
|
2
|
+
// Same approach as the other files here: lift the real function out of
|
|
3
|
+
// daemon.mjs and run it against fakes, so what is tested is what ships.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
11
|
+
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
12
|
+
|
|
13
|
+
/// The source between two markers, which must both exist and be in order.
|
|
14
|
+
function slice(from, to) {
|
|
15
|
+
const start = src.indexOf(from);
|
|
16
|
+
const end = src.indexOf(to, start + 1);
|
|
17
|
+
if (start < 0 || end < 0) throw new Error(`could not locate ${from.slice(0, 50)} in daemon.mjs`);
|
|
18
|
+
return src.slice(start, end);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let failures = 0;
|
|
22
|
+
const check = (label, cond, detail = '') => {
|
|
23
|
+
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
24
|
+
if (!cond) failures++;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ---------- phone text never reaches a shell ----------
|
|
28
|
+
{
|
|
29
|
+
const calls = [];
|
|
30
|
+
const { injectPrompt } = new Function(
|
|
31
|
+
'terminalApps', 'execFileSync', 'execSync', 'sleep', 'osascript', 'escapeAS',
|
|
32
|
+
`${slice('// Returns the method used, or null if no terminal path was found.', 'const headlessActive = new Set();')}
|
|
33
|
+
return { injectPrompt };`,
|
|
34
|
+
)(
|
|
35
|
+
() => ({ tmuxPanes: new Map([['/dev/ttys009', '%3']]), terminal: false, iterm: false }),
|
|
36
|
+
(file, args) => { calls.push({ file, args }); return Buffer.from(''); },
|
|
37
|
+
(cmd) => { calls.push({ shell: cmd }); return Buffer.from(''); },
|
|
38
|
+
async () => {},
|
|
39
|
+
async () => 'notfound',
|
|
40
|
+
(s) => s,
|
|
41
|
+
);
|
|
42
|
+
const text = 'rename `foo` to $(whoami) and keep $HOME';
|
|
43
|
+
const method = await injectPrompt('ttys009', text);
|
|
44
|
+
check('a tmux session is typed into through tmux', method === 'tmux', `got ${method}`);
|
|
45
|
+
check('nothing goes through a shell', calls.every((c) => !c.shell), JSON.stringify(calls.filter((c) => c.shell)));
|
|
46
|
+
const typed = calls.find((c) => c.args?.includes('-l'));
|
|
47
|
+
check('the prompt arrives as one literal argument, backticks and all',
|
|
48
|
+
typed?.file === 'tmux' && typed.args.at(-1) === text, JSON.stringify(typed));
|
|
49
|
+
const dashed = [];
|
|
50
|
+
await new Function(
|
|
51
|
+
'terminalApps', 'execFileSync', 'execSync', 'sleep', 'osascript', 'escapeAS',
|
|
52
|
+
`${slice('// Returns the method used, or null if no terminal path was found.', 'const headlessActive = new Set();')}
|
|
53
|
+
return injectPrompt;`,
|
|
54
|
+
)(
|
|
55
|
+
() => ({ tmuxPanes: new Map([['/dev/ttys009', '%3']]), terminal: false, iterm: false }),
|
|
56
|
+
(file, args) => { dashed.push(args); return Buffer.from(''); },
|
|
57
|
+
() => { throw new Error('shell'); },
|
|
58
|
+
async () => {}, async () => 'notfound', (s) => s,
|
|
59
|
+
)('ttys009', '-t is not a flag here');
|
|
60
|
+
check('a prompt starting with a dash is not read as a tmux option',
|
|
61
|
+
dashed[0]?.indexOf('--') === dashed[0]?.length - 2, JSON.stringify(dashed[0]));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------- sends while reconnecting do not throw ----------
|
|
65
|
+
{
|
|
66
|
+
const run = (ws) => new Function('ws', 'seal',
|
|
67
|
+
`${slice('/// Everything bound for the phone goes through here.', 'function sendSnapshot(')}
|
|
68
|
+
return toPhone;`)(ws, (o) => JSON.stringify(o));
|
|
69
|
+
const connecting = { readyState: 0, send() { throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); } };
|
|
70
|
+
let threw = false;
|
|
71
|
+
let sent;
|
|
72
|
+
try { sent = run(connecting)({ type: 'transcript' }); } catch { threw = true; }
|
|
73
|
+
check('a send while the next socket is still connecting does not throw', !threw && sent === false);
|
|
74
|
+
check('a send with no socket at all is dropped quietly', run(null)({ type: 'x' }) === false);
|
|
75
|
+
const out = [];
|
|
76
|
+
check('an open socket still gets the message', run({ readyState: 1, send: (d) => out.push(d) })({ type: 'x' }) === true && out.length === 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- self update asks npm a question it answers ----------
|
|
80
|
+
{
|
|
81
|
+
const requests = [];
|
|
82
|
+
// The registry's real behaviour: the abbreviated document only exists for
|
|
83
|
+
// the package root, so /latest with that accept header is a 406.
|
|
84
|
+
const fakeFetch = async (url, opts) => {
|
|
85
|
+
requests.push({ url, accept: opts?.headers?.accept });
|
|
86
|
+
if (/\/latest$/.test(url) && String(opts?.headers?.accept).includes('vnd.npm.install-v1')) {
|
|
87
|
+
return { ok: false, status: 406, json: async () => ({}) };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, status: 200, json: async () => ({ name: '@clastres/groundcontrol', version: '0.1.12' }) };
|
|
90
|
+
};
|
|
91
|
+
const latest = new Function('fetch', 'PACKAGE',
|
|
92
|
+
`${slice('async function latestPublishedVersion() {', '/// Re-install the service from the newly published version.')}
|
|
93
|
+
return latestPublishedVersion;`)(fakeFetch, '@clastres/groundcontrol');
|
|
94
|
+
let version = null;
|
|
95
|
+
let error = null;
|
|
96
|
+
try { version = await latest(); } catch (e) { error = e.message; }
|
|
97
|
+
check('the update check gets a version back', version === '0.1.12', error || '');
|
|
98
|
+
check('it asks the latest endpoint of the right package',
|
|
99
|
+
requests[0]?.url === 'https://registry.npmjs.org/@clastres/groundcontrol/latest', requests[0]?.url);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------- a takeover backs off instead of restarting ----------
|
|
103
|
+
{
|
|
104
|
+
const backoff = new Function(`${slice('/// How long to stay off the relay', 'function connect() {')} return takeoverBackoffMs;`)();
|
|
105
|
+
const mins = [1, 2, 3, 4, 5, 6, 9].map((n) => backoff(n) / 60_000);
|
|
106
|
+
check('the first takeover waits a minute, then doubles', mins[0] === 1 && mins[1] === 2 && mins[2] === 4 && mins[3] === 8, mins.join(','));
|
|
107
|
+
check('and never waits more than fifteen minutes', mins.slice(4).every((m) => m === 15), mins.join(','));
|
|
108
|
+
const closeHandler = slice(" ws.on('close', (code) => {", " ws.on('error', () => {});");
|
|
109
|
+
check('the service copy no longer exits on a takeover',
|
|
110
|
+
/if \(!runsFromServiceDir\) \{[\s\S]*process\.exit\(0\);[\s\S]*\}[\s\S]*setTimeout\(connect, wait\)/.test(closeHandler));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------- replayed and stale commands are refused ----------
|
|
114
|
+
{
|
|
115
|
+
const fresh = new Function(`${slice('// Replay protection.', 'let lastPongAt = Date.now();')} return freshCommand;`)();
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const frame = (tag) => `${tag.padEnd(16, 'A')}restOfTheCiphertext==`;
|
|
118
|
+
check('a new command is accepted', fresh(frame('n1'), { type: 'prompt', at: now }, now));
|
|
119
|
+
check('the same frame again is a replay', !fresh(frame('n1'), { type: 'prompt', at: now }, now + 1000));
|
|
120
|
+
check('a stamped command from twenty minutes ago is stale', !fresh(frame('n2'), { type: 'prompt', at: now - 20 * 60_000 }, now));
|
|
121
|
+
check('a clock a little ahead is fine', fresh(frame('n3'), { type: 'prompt', at: now + 60_000 }, now));
|
|
122
|
+
check('an older app with no stamp still gets through once', fresh(frame('n4'), { type: 'watch' }, now));
|
|
123
|
+
check('but not twice', !fresh(frame('n4'), { type: 'watch' }, now));
|
|
124
|
+
|
|
125
|
+
// The relay can respell a frame: base64 decoding skips whitespace and takes
|
|
126
|
+
// either alphabet. Every spelling must still count as the same frame.
|
|
127
|
+
const key = crypto.randomBytes(32);
|
|
128
|
+
const open = new Function('key', 'crypto', 'Buffer',
|
|
129
|
+
`${slice('function open(b64) {', '// ---------- environment hardening ----------')} return open;`)(key, crypto, Buffer);
|
|
130
|
+
const seal = (obj) => {
|
|
131
|
+
const nonce = crypto.randomBytes(12);
|
|
132
|
+
const c = crypto.createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
|
|
133
|
+
const ct = Buffer.concat([c.update(JSON.stringify(obj)), c.final()]);
|
|
134
|
+
return Buffer.concat([nonce, ct, c.getAuthTag()]).toString('base64');
|
|
135
|
+
};
|
|
136
|
+
const real = seal({ type: 'prompt', sessionId: 's', text: 'hi', at: now });
|
|
137
|
+
const spellings = [` ${real}`, `\n${real}`, `${real.slice(0, 8)} ${real.slice(8)}`, real.replace(/\+/g, '-').replace(/\//g, '_')];
|
|
138
|
+
check('a real frame is accepted', fresh(real, open(real), now));
|
|
139
|
+
for (const alt of spellings) {
|
|
140
|
+
let decoded = null;
|
|
141
|
+
try { decoded = open(alt); } catch {}
|
|
142
|
+
check(`respelled ${JSON.stringify(alt.slice(0, 3))}... still decrypts, and is still a replay`, decoded?.text === 'hi' && !fresh(alt, decoded, now + 5));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------- the pairing key stays out of the log ----------
|
|
147
|
+
{
|
|
148
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-scrub-'));
|
|
149
|
+
const LOG_FILE = path.join(dir, 'daemon.log');
|
|
150
|
+
const qrRow = '█ ▄▄▄▄▄ █ █▄▀▀█▄█ ▀█ ▄▀█ █▀ ▄█▄▄ █▀▄█ ▄▄▄▄▄ █';
|
|
151
|
+
fs.writeFileSync(LOG_FILE, [
|
|
152
|
+
'[daemon] connected to relay',
|
|
153
|
+
'▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄',
|
|
154
|
+
qrRow,
|
|
155
|
+
' Pairing code (paste into the app):',
|
|
156
|
+
' groundcontrol://pair#eyJ2IjoxLCJrIjoic2VjcmV0In0',
|
|
157
|
+
'[daemon] snapshot -> 2 session(s): a:idle, b:busy',
|
|
158
|
+
'',
|
|
159
|
+
].join('\n'), { mode: 0o644 });
|
|
160
|
+
new Function('fs', 'path', 'LOG_FILE', 'CONF_DIR',
|
|
161
|
+
`${slice('/// Versions up to 0.1.11 printed the pairing code', "if (subcommand === 'qr') process.exit(0);")}
|
|
162
|
+
scrubLogSecrets();`)(fs, path, LOG_FILE, dir);
|
|
163
|
+
const after = fs.readFileSync(LOG_FILE, 'utf8');
|
|
164
|
+
check('the pairing code is gone from the log', !after.includes('eyJ2IjoxLCJrIjoic2VjcmV0In0'));
|
|
165
|
+
check('so is the QR that encodes it', !after.includes(qrRow) && !after.includes('▄▄▄▄▄▄▄▄'));
|
|
166
|
+
check('everything else in the log is kept', after.includes('[daemon] connected to relay') && after.includes('b:busy'));
|
|
167
|
+
check('the log is readable by its owner only', (fs.statSync(LOG_FILE).mode & 0o777) === 0o600,
|
|
168
|
+
(fs.statSync(LOG_FILE).mode & 0o777).toString(8));
|
|
169
|
+
let writes = 0;
|
|
170
|
+
const spyFs = { ...fs, writeFileSync: (...a) => { writes++; return fs.writeFileSync(...a); } };
|
|
171
|
+
new Function('fs', 'path', 'LOG_FILE', 'CONF_DIR',
|
|
172
|
+
`${slice('/// Versions up to 0.1.11 printed the pairing code', "if (subcommand === 'qr') process.exit(0);")}
|
|
173
|
+
scrubLogSecrets();`)(spyFs, path, LOG_FILE, dir);
|
|
174
|
+
check('a log already scrubbed is not rewritten on the next start', writes === 0, `${writes} write(s)`);
|
|
175
|
+
const print = slice("if (subcommand === 'qr' || process.stdout.isTTY) {", "if (runsFromServiceDir) scrubLogSecrets();");
|
|
176
|
+
check('a daemon writing to a file prints no pairing code', /else \{\s*console\.log\(' Pairing code hidden here/.test(print));
|
|
177
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------- watch leases ----------
|
|
181
|
+
{
|
|
182
|
+
const opened = [];
|
|
183
|
+
const fakeFs = {
|
|
184
|
+
watch(file, cb) {
|
|
185
|
+
const w = { file, cb, closed: false, close() { this.closed = true; } };
|
|
186
|
+
opened.push(w);
|
|
187
|
+
return w;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
const sent = [];
|
|
191
|
+
const api = new Function('fs', 'findTranscript', 'readMessages', 'toPhone',
|
|
192
|
+
`${slice('const WATCH_LEASE_MS', 'async function handleCommand(cmd) {')}
|
|
193
|
+
return { watchTranscript, unwatchTranscript, renewWatch, expireWatches, holderOf, watchers, WATCH_LEASE_MS, LEGACY_LEASE_MS };`,
|
|
194
|
+
)(fakeFs, (id) => `/tmp/${id}.jsonl`, () => [], (m) => sent.push(m));
|
|
195
|
+
const { watchTranscript, unwatchTranscript, renewWatch, expireWatches, holderOf, watchers, WATCH_LEASE_MS, LEGACY_LEASE_MS } = api;
|
|
196
|
+
const a = holderOf({ client: 'phone-a' });
|
|
197
|
+
const b = holderOf({ client: 'phone-b' });
|
|
198
|
+
|
|
199
|
+
check('the first watch starts one', watchTranscript('s1', a) === 'created' && opened.length === 1);
|
|
200
|
+
check('a second phone shares it', watchTranscript('s1', b) === 'renewed' && opened.length === 1);
|
|
201
|
+
unwatchTranscript('s1', a);
|
|
202
|
+
check('one phone closing the chat keeps it streaming for the other', watchers.has('s1') && !opened[0].closed);
|
|
203
|
+
unwatchTranscript('s1', b);
|
|
204
|
+
check('the last one closing stops it', !watchers.has('s1') && opened[0].closed);
|
|
205
|
+
|
|
206
|
+
watchTranscript('s2', a);
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
expireWatches(now + WATCH_LEASE_MS - 1000);
|
|
209
|
+
check('a lease inside its window stays', watchers.has('s2'));
|
|
210
|
+
expireWatches(now + WATCH_LEASE_MS + 1000);
|
|
211
|
+
check('a phone that vanished without an unwatch is let go', !watchers.has('s2') && opened[1].closed);
|
|
212
|
+
|
|
213
|
+
watchTranscript('s3');
|
|
214
|
+
expireWatches(now + WATCH_LEASE_MS + 1000);
|
|
215
|
+
check('an older app, which never renews, keeps its watch well past that', watchers.has('s3'));
|
|
216
|
+
renewWatch('s3', 'legacy', now + LEGACY_LEASE_MS - 5000);
|
|
217
|
+
expireWatches(now + LEGACY_LEASE_MS + 1000);
|
|
218
|
+
check('asking for the log renews it', watchers.has('s3'));
|
|
219
|
+
expireWatches(now + 2 * LEGACY_LEASE_MS);
|
|
220
|
+
check('and it still ends', !watchers.has('s3'));
|
|
221
|
+
|
|
222
|
+
renewWatch('s4', a);
|
|
223
|
+
check('a renewal never starts a watch by itself', !watchers.has('s4'));
|
|
224
|
+
|
|
225
|
+
// An older app with the chat still open lets its lease lapse while the
|
|
226
|
+
// session sits idle, then asks for the log when the session moves again.
|
|
227
|
+
watchTranscript('s5', 'legacy', now);
|
|
228
|
+
expireWatches(now + LEGACY_LEASE_MS + 1000);
|
|
229
|
+
check('an idle legacy chat lapses', !watchers.has('s5'));
|
|
230
|
+
renewWatch('s5', 'legacy', now + LEGACY_LEASE_MS + 60_000);
|
|
231
|
+
check('and asking for the log after that resumes the stream', watchers.has('s5'));
|
|
232
|
+
unwatchTranscript('s5', 'legacy');
|
|
233
|
+
watchTranscript('s6', 'legacy', now);
|
|
234
|
+
unwatchTranscript('s6', 'legacy');
|
|
235
|
+
renewWatch('s6', 'legacy', now + 1000);
|
|
236
|
+
check('a chat that was closed is not reopened by a later fetch', !watchers.has('s6'));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------- the approvals socket comes back after a local takeover ----------
|
|
240
|
+
{
|
|
241
|
+
const run = (inoOnDisk) => {
|
|
242
|
+
const log = { restarted: 0, closed: 0 };
|
|
243
|
+
const api = new Function('fs', 'ASK_SOCKET', 'bustHostCache', 'startApprovalServer', 'console', 'log',
|
|
244
|
+
`let approvalServer = { close() { log.closed++; } };
|
|
245
|
+
let approvalSocketIno = 41;
|
|
246
|
+
let approvalServerUp = true;
|
|
247
|
+
${slice('/// A second copy started on this Mac unlinks the approvals socket', "// Claude Code's own status for each session")}
|
|
248
|
+
return () => { ensureApprovalSocket(); return approvalServerUp; };`,
|
|
249
|
+
)({ statSync: () => { if (inoOnDisk === null) throw new Error('ENOENT'); return { ino: inoOnDisk }; } },
|
|
250
|
+
'/tmp/approvals.sock', () => {}, () => { log.restarted++; }, { log() {} }, log);
|
|
251
|
+
const upAfter = api();
|
|
252
|
+
return { ...log, upAfter };
|
|
253
|
+
};
|
|
254
|
+
const same = run(41);
|
|
255
|
+
check('our own socket is left alone', same.restarted === 0 && same.closed === 0 && same.upAfter);
|
|
256
|
+
const replaced = run(99);
|
|
257
|
+
check('a socket another copy bound is taken back', replaced.restarted === 1 && replaced.closed === 1);
|
|
258
|
+
const gone = run(null);
|
|
259
|
+
check('so is a socket file that disappeared', gone.restarted === 1);
|
|
260
|
+
const onOpen = slice(" ws.on('open', () => {", " ws.on('pong'");
|
|
261
|
+
check('and it is checked every time the relay is ours again', onOpen.includes('ensureApprovalSocket()'));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------- prompts are acknowledged when they start ----------
|
|
265
|
+
{
|
|
266
|
+
const sent = [];
|
|
267
|
+
const { promptAccepted, failureText } = new Function('toPhone',
|
|
268
|
+
`${slice('/// Tell the phone a headless run has started.', '// ---------- launching a new pilot ----------')}
|
|
269
|
+
return { promptAccepted, failureText };`)((m) => sent.push(m));
|
|
270
|
+
promptAccepted('abc');
|
|
271
|
+
check('a started run tells the phone ok at once', sent[0]?.type === 'promptResult' && sent[0].ok === true && sent[0].sessionId === 'abc');
|
|
272
|
+
check('a failure says why', failureText('codex', 1, 'warn: x\nerror: not logged in\n') === 'codex stopped: warn: x error: not logged in');
|
|
273
|
+
check('and falls back to the exit code', failureText('kimi', 2, '') === 'kimi exited with code 2');
|
|
274
|
+
for (const provider of ['claude --resume', 'spec.bin']) {
|
|
275
|
+
const at = src.indexOf(provider === 'spec.bin' ? 'const child = spawn(spec.bin' : "const child = spawn(bin, ['--resume'");
|
|
276
|
+
const block = src.slice(at, at + 1600);
|
|
277
|
+
check(`the ${provider === 'spec.bin' ? 'codex and kimi' : 'claude'} path acks on spawn`, /child\.on\('spawn', \(\) => promptAccepted\(/.test(block));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------- turning approvals off lets go of what is held ----------
|
|
282
|
+
{
|
|
283
|
+
const released = [];
|
|
284
|
+
const releaseAsksIfBack = new Function('pendingAsks', 'approvalConfig', 'releaseAllAsks', 'macIdleSeconds', 'settleAsk',
|
|
285
|
+
`${slice('function releaseAsksIfBack() {', '// ---------- self update ----------')} return releaseAsksIfBack;`,
|
|
286
|
+
)(new Map([['a1', {}]]), () => ({ mode: 'off' }), (note) => released.push(note), () => 0, () => {});
|
|
287
|
+
releaseAsksIfBack();
|
|
288
|
+
check('approvals off from the CLI hands held requests back', released[0] === 'remote approvals were turned off');
|
|
289
|
+
const phoneOff = slice('if (cmd.enabled === false) {', '} else {');
|
|
290
|
+
check('approvals off from the phone does too', phoneOff.includes("releaseAllAsks('remote approvals were turned off')"));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
|
|
294
|
+
process.exit(failures ? 1 : 0);
|
package/test/grokbot.mjs
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// Runs the real Grok Bot reader out of daemon.mjs against whatever replica this
|
|
2
|
-
// Mac has. The block is extracted from source rather than copied, so the test
|
|
3
|
-
// cannot drift from what the daemon ships.
|
|
4
|
-
//
|
|
5
|
-
// The daemon is one long script that connects to the relay on import, so there
|
|
6
|
-
// is nothing to import: pulling the section out by its comment markers and
|
|
7
|
-
// evaluating it with its handful of helpers injected is what keeps this honest.
|
|
8
|
-
//
|
|
9
|
-
// On a Mac without Grok Bot installed the roster assertions are skipped and the
|
|
10
|
-
// machine independent ones still run.
|
|
11
|
-
import fs from 'node:fs';
|
|
12
|
-
import os from 'node:os';
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
-
|
|
16
|
-
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
17
|
-
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
18
|
-
const start = src.indexOf('// ---------- Grok Bot session discovery ----------');
|
|
19
|
-
const end = src.indexOf('/// The phone-facing shape of a held request.');
|
|
20
|
-
if (start < 0 || end < 0 || end < start) throw new Error('could not locate the Grok Bot block in daemon.mjs');
|
|
21
|
-
const block = src.slice(start, end);
|
|
22
|
-
|
|
23
|
-
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
24
|
-
const isoOf = (t) => { try { return t ? new Date(t).toISOString() : null; } catch { return null; } };
|
|
25
|
-
const memoScan = (_key, _file, fn) => fn();
|
|
26
|
-
const transcriptCache = new Map();
|
|
27
|
-
|
|
28
|
-
const load = (source, cache) => new Function('fs', 'os', 'path', 'readJson', 'isoOf', 'memoScan', 'transcriptCache',
|
|
29
|
-
`${source}\n return { readGrokBotSessions, readGrokBotMessages, b32decode, GROKBOT_DIR };`)(
|
|
30
|
-
fs, os, path, readJson, isoOf, memoScan, cache);
|
|
31
|
-
|
|
32
|
-
const { readGrokBotSessions, readGrokBotMessages, b32decode, GROKBOT_DIR } = load(block, transcriptCache);
|
|
33
|
-
|
|
34
|
-
let failures = 0;
|
|
35
|
-
const check = (label, cond, detail = '') => {
|
|
36
|
-
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
37
|
-
if (!cond) failures++;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
// A store key the app really writes, so a change to the encoding is caught here
|
|
41
|
-
// rather than by an empty island.
|
|
42
|
-
const known = 'onqw4zbomnwgszlooqxhg3djmnss4y3mnfsw45bnnvsxiyjomfrwg33vnz2c243mn52a';
|
|
43
|
-
check('b32decode reads a real store key', b32decode(known) === 'sand.client.slice.client-meta.account-slot', b32decode(known));
|
|
44
|
-
check('b32decode rejects junk', b32decode('!!!!') === '');
|
|
45
|
-
|
|
46
|
-
// A Mac without Grok Bot must return an empty list rather than throw.
|
|
47
|
-
check('missing install returns empty, does not throw',
|
|
48
|
-
load(block.replace(/const GROKBOT_DIR = .*/, "const GROKBOT_DIR = '/nope/not/here';"), new Map())
|
|
49
|
-
.readGrokBotSessions().length === 0);
|
|
50
|
-
|
|
51
|
-
const sessions = fs.existsSync(GROKBOT_DIR) ? readGrokBotSessions() : [];
|
|
52
|
-
if (!sessions.length) {
|
|
53
|
-
console.log(`\nskipped the roster checks: no Bots found in ${GROKBOT_DIR}`);
|
|
54
|
-
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
55
|
-
process.exit(failures ? 1 : 0);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
console.log(`\n${sessions.length} bot(s) found\n`);
|
|
59
|
-
for (const s of sessions) {
|
|
60
|
-
check(`${s.name}: id is a uuid`, /^[0-9a-f-]{36}$/.test(s.id));
|
|
61
|
-
check(`${s.name}: status is one the app understands`, ['idle', 'busy', 'waiting'].includes(s.status));
|
|
62
|
-
check(`${s.name}: has a name`, !!s.name);
|
|
63
|
-
check(`${s.name}: startedAt set`, s.startedAt > 0);
|
|
64
|
-
check(`${s.name}: updatedAt set`, s.updatedAt > 0);
|
|
65
|
-
check(`${s.name}: read only flagged`, s.readOnly === true);
|
|
66
|
-
// Every field the Swift Session struct decodes as non-optional must be here,
|
|
67
|
-
// or the phone drops the whole snapshot, not just this row.
|
|
68
|
-
for (const k of ['id', 'name', 'cwd', 'project', 'status', 'updatedAt', 'startedAt']) {
|
|
69
|
-
check(`${s.name}: ${k} present for Swift decode`, s[k] !== undefined && s[k] !== null);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const file = transcriptCache.get(s.id);
|
|
73
|
-
check(`${s.name}: transcript located`, !!file);
|
|
74
|
-
if (!file) continue;
|
|
75
|
-
const msgs = readGrokBotMessages(file);
|
|
76
|
-
check(`${s.name}: transcript has messages`, msgs.length > 0);
|
|
77
|
-
check(`${s.name}: roles are user/assistant only`, msgs.every((m) => m.role === 'user' || m.role === 'assistant'));
|
|
78
|
-
check(`${s.name}: every message has a timestamp`, msgs.every((m) => !!m.ts));
|
|
79
|
-
check(`${s.name}: lastMessage matches transcript tail`, s.lastMessage?.text === msgs[msgs.length - 1]?.text);
|
|
80
|
-
// The app stores a ProseMirror doc beside every typed turn. Leaking it into
|
|
81
|
-
// the transcript would put raw JSON on the phone.
|
|
82
|
-
check(`${s.name}: no raw richText leaked into text`, msgs.every((m) => !m.text.includes('"type":"doc"')));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
86
|
-
process.exit(failures ? 1 : 0);
|