instar 1.3.1612 → 1.3.1613

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dashboard/console/console.js +14 -2
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +31 -7
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/AppConversationStore.d.ts +2 -0
  6. package/dist/core/AppConversationStore.d.ts.map +1 -1
  7. package/dist/core/AppConversationStore.js +8 -1
  8. package/dist/core/AppConversationStore.js.map +1 -1
  9. package/dist/core/AppRecordSignatures.d.ts +23 -2
  10. package/dist/core/AppRecordSignatures.d.ts.map +1 -1
  11. package/dist/core/AppRecordSignatures.js +32 -5
  12. package/dist/core/AppRecordSignatures.js.map +1 -1
  13. package/dist/core/LocalAttentionItems.d.ts +49 -0
  14. package/dist/core/LocalAttentionItems.d.ts.map +1 -0
  15. package/dist/core/LocalAttentionItems.js +107 -0
  16. package/dist/core/LocalAttentionItems.js.map +1 -0
  17. package/dist/core/MacDesktopHost.d.ts +3 -0
  18. package/dist/core/MacDesktopHost.d.ts.map +1 -1
  19. package/dist/core/MacDesktopHost.js +10 -0
  20. package/dist/core/MacDesktopHost.js.map +1 -1
  21. package/dist/core/MachineLockStateReader.d.ts.map +1 -1
  22. package/dist/core/MachineLockStateReader.js +6 -1
  23. package/dist/core/MachineLockStateReader.js.map +1 -1
  24. package/dist/core/PostUpdateMigrator.d.ts +6 -0
  25. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  26. package/dist/core/PostUpdateMigrator.js +33 -0
  27. package/dist/core/PostUpdateMigrator.js.map +1 -1
  28. package/dist/core/types.d.ts +12 -0
  29. package/dist/core/types.d.ts.map +1 -1
  30. package/dist/core/types.js.map +1 -1
  31. package/dist/data/standards-guard-index.json +1 -1
  32. package/dist/data/standards-guard-index.meta.json +2 -2
  33. package/dist/data/standards-registry.meta.json +1 -1
  34. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  35. package/dist/messaging/TelegramAdapter.js +7 -8
  36. package/dist/messaging/TelegramAdapter.js.map +1 -1
  37. package/dist/messaging/shared/MessageLogger.d.ts +2 -0
  38. package/dist/messaging/shared/MessageLogger.d.ts.map +1 -1
  39. package/dist/messaging/shared/MessageLogger.js.map +1 -1
  40. package/dist/messaging/slack/SlackAdapter.d.ts +19 -0
  41. package/dist/messaging/slack/SlackAdapter.d.ts.map +1 -1
  42. package/dist/messaging/slack/SlackAdapter.js +21 -0
  43. package/dist/messaging/slack/SlackAdapter.js.map +1 -1
  44. package/dist/messaging/telegram-origin/OriginForwardToHolder.d.ts +13 -0
  45. package/dist/messaging/telegram-origin/OriginForwardToHolder.d.ts.map +1 -1
  46. package/dist/messaging/telegram-origin/OriginForwardToHolder.js +16 -0
  47. package/dist/messaging/telegram-origin/OriginForwardToHolder.js.map +1 -1
  48. package/dist/messaging/telegram-origin/OriginLegacy.d.ts +8 -0
  49. package/dist/messaging/telegram-origin/OriginLegacy.d.ts.map +1 -1
  50. package/dist/messaging/telegram-origin/OriginLegacy.js +34 -6
  51. package/dist/messaging/telegram-origin/OriginLegacy.js.map +1 -1
  52. package/dist/messaging/telegram-origin/TelegramOriginBoot.d.ts +3 -0
  53. package/dist/messaging/telegram-origin/TelegramOriginBoot.d.ts.map +1 -1
  54. package/dist/messaging/telegram-origin/TelegramOriginBoot.js +54 -10
  55. package/dist/messaging/telegram-origin/TelegramOriginBoot.js.map +1 -1
  56. package/dist/messaging/telegram-origin/types.d.ts +11 -0
  57. package/dist/messaging/telegram-origin/types.d.ts.map +1 -1
  58. package/dist/messaging/telegram-origin/types.js +15 -0
  59. package/dist/messaging/telegram-origin/types.js.map +1 -1
  60. package/dist/monitoring/promptRelaySettle.d.ts +35 -0
  61. package/dist/monitoring/promptRelaySettle.d.ts.map +1 -0
  62. package/dist/monitoring/promptRelaySettle.js +58 -0
  63. package/dist/monitoring/promptRelaySettle.js.map +1 -0
  64. package/dist/native/instar-desktop-host +0 -0
  65. package/dist/scaffold/templates.d.ts.map +1 -1
  66. package/dist/scaffold/templates.js +3 -1
  67. package/dist/scaffold/templates.js.map +1 -1
  68. package/dist/server/AgentServer.d.ts +15 -0
  69. package/dist/server/AgentServer.d.ts.map +1 -1
  70. package/dist/server/AgentServer.js +78 -20
  71. package/dist/server/AgentServer.js.map +1 -1
  72. package/dist/server/CapabilityIndex.js +2 -2
  73. package/dist/server/CapabilityIndex.js.map +1 -1
  74. package/dist/server/mobileConversationRoutes.d.ts +16 -3
  75. package/dist/server/mobileConversationRoutes.d.ts.map +1 -1
  76. package/dist/server/mobileConversationRoutes.js +36 -8
  77. package/dist/server/mobileConversationRoutes.js.map +1 -1
  78. package/dist/server/mobilePairingRoutes.d.ts +1 -1
  79. package/dist/server/mobilePairingRoutes.d.ts.map +1 -1
  80. package/dist/server/mobilePairingRoutes.js +5 -2
  81. package/dist/server/mobilePairingRoutes.js.map +1 -1
  82. package/dist/server/mobileSlackRoutes.d.ts +162 -0
  83. package/dist/server/mobileSlackRoutes.d.ts.map +1 -0
  84. package/dist/server/mobileSlackRoutes.js +552 -0
  85. package/dist/server/mobileSlackRoutes.js.map +1 -0
  86. package/dist/server/routes.d.ts +2 -0
  87. package/dist/server/routes.d.ts.map +1 -1
  88. package/dist/server/routes.js +59 -3
  89. package/dist/server/routes.js.map +1 -1
  90. package/package.json +1 -1
  91. package/scripts/build-locked-proof.mjs +125 -0
  92. package/scripts/lib/locked-proof-build.mjs +71 -0
  93. package/scripts/locked-proof/configure-proof-agent.mjs +68 -0
  94. package/scripts/locked-proof/create-proof-user.sh +89 -0
  95. package/scripts/locked-proof/driver.mjs +457 -0
  96. package/scripts/locked-proof/lib.mjs +267 -0
  97. package/scripts/locked-proof/pty-feed.py +59 -0
  98. package/scripts/locked-proof/remove-proof-user.sh +18 -0
  99. package/scripts/locked-proof/reset-attempts.py +52 -0
  100. package/scripts/locked-proof/stage-proof-agents.sh +68 -0
  101. package/src/data/builtin-manifest.json +64 -64
  102. package/src/data/standards-guard-index.json +1 -1
  103. package/src/data/standards-guard-index.meta.json +2 -2
  104. package/src/data/standards-registry.meta.json +1 -1
  105. package/src/scaffold/templates.ts +3 -1
  106. package/upgrades/1.3.1613.md +108 -0
  107. package/upgrades/side-effects/console-partial-survives-reload.eli16.md +5 -0
  108. package/upgrades/side-effects/console-partial-survives-reload.md +34 -0
  109. package/upgrades/side-effects/desktop-tests-stop-touching-tcc.eli16.md +13 -0
  110. package/upgrades/side-effects/desktop-tests-stop-touching-tcc.md +65 -0
  111. package/upgrades/side-effects/held-expiry-one-row-per-topic.eli16.md +5 -0
  112. package/upgrades/side-effects/held-expiry-one-row-per-topic.md +37 -0
  113. package/upgrades/side-effects/locked-proof-driver.md +72 -0
  114. package/upgrades/side-effects/locked-proof-test-build.md +64 -0
  115. package/upgrades/side-effects/locked-security-item-no-telegram.md +78 -0
  116. package/upgrades/side-effects/mobile-native-conversations-inc4.md +115 -0
  117. package/upgrades/side-effects/origin-config-unknown-not-missing.eli16.md +16 -0
  118. package/upgrades/side-effects/origin-config-unknown-not-missing.md +38 -0
  119. package/upgrades/side-effects/prompt-gate-settle-before-relay.eli16.md +11 -0
  120. package/upgrades/side-effects/prompt-gate-settle-before-relay.md +57 -0
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+ // safe-git-allow: no git at all; process signals and file reads go through sudo against the throwaway proof user only.
3
+ /**
4
+ * Locked-Mac proof driver (docs/specs/desktop-locked-screen.md, P12 + P15).
5
+ *
6
+ * node scripts/locked-proof/driver.mjs expected # print the expected-results table (commit it before any run)
7
+ * node scripts/locked-proof/driver.mjs calibrate --execute [opts] # one normal unlock: measures the typing pace
8
+ * node scripts/locked-proof/driver.mjs run --execute [--cases 12.1,P15] [opts]
9
+ *
10
+ * Options:
11
+ * --agent-home DIR the operator-side agent home holding the vault (secret-get.mjs) [required for calibrate/run]
12
+ * --proof-dir DIR proof agent A's project directory (default /Users/instarproof/.instar/agents/locked-proof)
13
+ * --proof-dir-b DIR proof agent B's directory (default .../locked-proof-b)
14
+ * --port N / --port-b N proof agents' ports (default 4098 / 4097)
15
+ * --playwright DIR a playwright-core install to drive the real viewer page with system Chrome
16
+ * --out DIR results + calibration folder (default ./locked-proof-results)
17
+ * --notify-topic N also send person steps to this Telegram topic through the agent's relay script
18
+ * --headed show the viewer browser
19
+ *
20
+ * Without --execute nothing touches the Mac: `calibrate` and `run` print what they would do.
21
+ * Who types: this driver, through the real viewer page, with the throwaway user's password
22
+ * read once from the vault (key locked_proof_user_password) into memory. It is never printed,
23
+ * logged, written to a results file, or passed on a command line.
24
+ */
25
+ import { execFileSync, spawnSync } from 'node:child_process';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { createRequire } from 'node:module';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { BLOCKING, PROOF_USER, TERMINAL, VAULT_KEY, assertProofTarget, renderExpected, runCase, selectCases } from './lib.mjs';
31
+
32
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
33
+ const LOCK_PY = 'import ctypes; ctypes.CDLL("/System/Library/PrivateFrameworks/login.framework/Versions/Current/login").SACLockScreenImmediate()';
34
+
35
+ function opt(name, fallback) {
36
+ const i = process.argv.indexOf(`--${name}`);
37
+ return i >= 0 ? process.argv[i + 1] : fallback;
38
+ }
39
+ const flag = name => process.argv.includes(`--${name}`);
40
+
41
+ function sh(cmd, args, { allowFail = false, input } = {}) {
42
+ const r = spawnSync(cmd, args, { encoding: 'utf8', input, timeout: 30_000 });
43
+ if (r.status !== 0 && !allowFail) throw new Error(`${cmd} ${args[0] ?? ''} failed: ${(r.stderr || '').trim().slice(0, 300)}`);
44
+ return r.stdout ?? '';
45
+ }
46
+ const sudo = (args, o) => sh('sudo', ['-n', ...args], o);
47
+ /** The login name owning a pid, or '' when it is gone. */
48
+ export function ownerOf(pid) { return sh('ps', ['-o', 'user=', '-p', String(pid)], { allowFail: true }).trim(); }
49
+
50
+ /** The proof Mac, the proof user, its two agents and the real viewer page. */
51
+ export class RealTarget {
52
+ constructor(o) {
53
+ this.o = o;
54
+ this.uid = sh('id', ['-u', PROOF_USER]).trim();
55
+ this.store = `/Users/${PROOF_USER}/.instar/desktop-helper/locked`;
56
+ this.events = new Set();
57
+ this.pids = {};
58
+ this.viewers = [];
59
+ this.stopper = null;
60
+ /** Every pid this run SIGSTOPped and has not resumed or killed. */
61
+ this.stopped = new Set();
62
+ }
63
+
64
+ now() { return Date.now(); }
65
+ sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
66
+
67
+ agentConfig(dir) { return JSON.parse(sudo(['cat', path.join(dir, '.instar', 'config.json')])); }
68
+
69
+ async init() {
70
+ this.a = { port: this.o.port, dir: this.o.proofDir, ...this.pick(this.agentConfig(this.o.proofDir)) };
71
+ this.b = { port: this.o.portB, dir: this.o.proofDirB, ...this.pick(this.agentConfig(this.o.proofDirB)) };
72
+ for (const agent of [this.a, this.b]) {
73
+ const health = await this.api(agent, 'GET', '/health');
74
+ // The real owner of the process answering on this port, not a constant.
75
+ const pid = Number(sudo(['lsof', '-nP', '-t', `-iTCP:${agent.port}`, '-sTCP:LISTEN'], { allowFail: true }).trim().split('\n')[0]) || null;
76
+ assertProofTarget({ user: pid ? ownerOf(pid) : '', version: health.version });
77
+ assertProofTarget({ user: sh('stat', ['-f', '%Su', agent.dir]).trim(), version: health.version });
78
+ }
79
+ // Read once, kept in memory only. stdout is captured, stderr discarded (it names lengths).
80
+ const value = execFileSync(process.execPath, [path.join(this.o.agentHome, '.instar/scripts/secret-get.mjs'), VAULT_KEY],
81
+ { cwd: this.o.agentHome, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).replace(/\n$/, '');
82
+ if (!value) throw new Error(`vault key ${VAULT_KEY} is empty`);
83
+ Object.defineProperty(this, 'password', { value, enumerable: false });
84
+ const calFile = path.join(this.o.out, 'calibration.json');
85
+ this.cal = fs.existsSync(calFile) ? JSON.parse(fs.readFileSync(calFile, 'utf8')) : null;
86
+ const { chromium } = createRequire(path.join(this.o.playwright, 'package.json'))('playwright-core');
87
+ this.browser = await chromium.launch({ channel: 'chrome', headless: !this.o.headed });
88
+ }
89
+
90
+ pick(config) { return { token: config.authToken, pin: config.dashboardPin }; }
91
+
92
+ async api(agent, method, route, body, headers = {}) {
93
+ const r = await fetch(`http://127.0.0.1:${agent.port}${route}`, { method,
94
+ headers: { Authorization: `Bearer ${agent.token}`, 'Content-Type': 'application/json', 'X-Instar-Request': '1', ...headers },
95
+ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(5_000) });
96
+ const text = await r.text();
97
+ let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
98
+ return { status: r.status, ...(json ?? {}) };
99
+ }
100
+
101
+ async operatorSession(agent) {
102
+ if (agent.session) return agent.session;
103
+ const r = await fetch(`http://127.0.0.1:${agent.port}/dashboard/unlock`, { method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pin: agent.pin }) });
105
+ agent.session = (await r.json()).operatorSessionToken;
106
+ return agent.session;
107
+ }
108
+
109
+ readStore() {
110
+ const raw = sudo(['cat', path.join(this.store, 'locked-state.json')], { allowFail: true });
111
+ try { return JSON.parse(raw); } catch { return {}; }
112
+ }
113
+
114
+ processes() {
115
+ return sudo(['ps', '-U', PROOF_USER, '-o', 'pid=,lstart=,command='], { allowFail: true }).split('\n')
116
+ .map(line => line.match(/^\s*(\d+)\s+(\w{3} \w{3} +\d+ [\d:]+ \d{4})\s+(.*)$/)).filter(Boolean)
117
+ .map(m => ({ pid: Number(m[1]), start: m[2], command: m[3] }));
118
+ }
119
+
120
+ async snapshot() {
121
+ const state = this.readStore();
122
+ const markers = state.markers ?? {};
123
+ const procs = this.processes();
124
+ const helper = procs.filter(p => p.command.includes('instar-desktop-host'));
125
+ const guard = helper.find(p => p.command.includes('relock-guard'));
126
+ const typist = helper.find(p => p.command.includes('input-stream'));
127
+ const lockState = await this.readLockState();
128
+ const now = this.now();
129
+ for (const [key, m] of Object.entries(markers)) {
130
+ if (spawnSync('sudo', ['-n', 'test', '-e', path.join(this.store, `lease-${key}`)]).status === 0) this.events.add('lease-written');
131
+ if (spawnSync('sudo', ['-n', 'test', '-e', path.join(this.store, `hb-${key}`)]).status === 0) this.events.add('guard-acked');
132
+ if (typeof m.deadlineMs === 'number' && m.deadlineMs < now) this.events.add('deadline-passed');
133
+ if (this.pids.firstGuard && m.guardPid && m.guardPid !== this.pids.firstGuard) this.events.add('takeover');
134
+ }
135
+ if (guard) { this.events.add('guard-spawned'); this.pids.firstGuard ??= guard.pid; this.pids.guard = guard.pid; }
136
+ if (typist) this.pids.typist = typist.pid;
137
+ if (sh('stat', ['-f', '%Su', '/dev/console']).trim() === PROOF_USER) this.events.add('proof-user-console');
138
+ return { now, markers, dirty: state.dirty !== undefined && state.dirty !== null, lockState, typistRunning: !!typist, guardPid: guard?.pid, events: [...this.events],
139
+ processesGone: !Object.values(markers).every(m => TERMINAL.includes(m.state)) ? undefined : !guard && !typist, procs };
140
+ }
141
+
142
+ /** Agent A's lock reading; while A is down or suspended, agent B's (same user, same Mac). */
143
+ async readLockState() {
144
+ for (const agent of [this.a, this.b]) {
145
+ try {
146
+ const st = await this.api(agent, 'GET', '/desktop/locked/status');
147
+ if (typeof st.lockState === 'string') return st.lockState;
148
+ } catch { /* this agent is down or stopped; try the other */ }
149
+ }
150
+ return 'unknown';
151
+ }
152
+
153
+ settled(snap, c) {
154
+ const markers = Object.values(snap.markers);
155
+ const closed = markers.length > 0 && markers.every(m => TERMINAL.includes(m.state));
156
+ if (!closed) return false;
157
+ if (snap.typistRunning || snap.guardPid) return false;
158
+ return c.final === 'not-judged' || snap.lockState !== 'unknown';
159
+ }
160
+
161
+ calibration() { return this.cal; }
162
+
163
+ async prepare(c) {
164
+ await this.closeViewers();
165
+ this.events.clear();
166
+ this.pids = {};
167
+ const until = this.now() + 180_000;
168
+ while (Object.values(this.readStore().markers ?? {}).some(m => BLOCKING.includes(m.state))) {
169
+ if (this.now() > until) throw new Error('a duty from the previous case is still open');
170
+ await this.sleep(1_000);
171
+ }
172
+ // Attempt counter reset under the helper's own lock (plan 4.4), audited.
173
+ sudo(['-u', PROOF_USER, 'python3', path.join(HERE, 'reset-attempts.py'), '--execute', this.store]);
174
+ fs.appendFileSync(path.join(this.o.out, 'attempt-resets.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), before: c.id })}\n`);
175
+ if (await this.readLockState() !== 'locked-in-session') await this.act('lock-now', { case: c });
176
+ await this.sleep(2_000);
177
+ }
178
+
179
+ async viewer(agent) {
180
+ const context = await this.browser.newContext();
181
+ const page = await context.newPage();
182
+ await page.goto(`http://127.0.0.1:${agent.port}/dashboard/desktop.html`);
183
+ await page.fill('#pin', agent.pin);
184
+ await page.click('#unlockButton');
185
+ await page.waitForSelector('#requestButton:not([disabled])', { timeout: 20_000 });
186
+ const v = { agent, context, page };
187
+ this.viewers.push(v);
188
+ return v;
189
+ }
190
+
191
+ async openGrant(c) {
192
+ const v = await this.viewer(this.a);
193
+ await v.page.click('#requestButton');
194
+ await v.page.waitForSelector('#control:not([hidden])', { timeout: 30_000 });
195
+ this.main = v;
196
+ this.mainOpenedAt = this.now();
197
+ return v;
198
+ }
199
+
200
+ /** Types the password through the real viewer box, as an owner would. */
201
+ async typePassword(c) {
202
+ const page = this.main.page;
203
+ await page.waitForSelector('#lockedPassword:not([hidden])', { timeout: 30_000 });
204
+ if (c.timing === 'deadline' && this.cal) {
205
+ // Land Enter just after (before-enter case) or just before (after-enter case) the 10-minute ceiling.
206
+ const opened = this.mainOpenedAt ?? this.now();
207
+ const enterAt = opened + 600_000 + (c.inject[0].at === 'before-enter' ? 1_500 : -1_500);
208
+ const startAt = enterAt - this.cal.reserveMs - this.cal.perCharMs * this.password.length;
209
+ await this.sleep(Math.max(0, startAt - this.now()));
210
+ }
211
+ await page.fill('#lockedPasswordInput', this.password);
212
+ await page.press('#lockedPasswordInput', 'Enter');
213
+ const until = this.now() + 60_000;
214
+ while (this.now() < until) {
215
+ if (await page.isVisible('#leaveUnlockedButton')) return { sent: true };
216
+ const text = (await page.textContent('#status').catch(() => '')) ?? '';
217
+ const refused = refusalFromStatus(text);
218
+ if (refused) return { refused };
219
+ await this.sleep(200);
220
+ }
221
+ return { sent: false, refused: 'no-result-within-60s' };
222
+ }
223
+
224
+ /** The proof user's listening process on the port, and only that user's (lsof -a ANDs the filters). */
225
+ serverPid(agent) {
226
+ const pid = Number(sudo(['lsof', '-a', '-u', PROOF_USER, '-nP', '-t', `-iTCP:${agent.port}`, '-sTCP:LISTEN'], { allowFail: true }).trim().split('\n')[0]) || null;
227
+ return pid && ownerOf(pid) === PROOF_USER ? pid : null;
228
+ }
229
+
230
+ /** Signals only a process the proof user owns, checked immediately before each signal. */
231
+ kill(pid, sig) {
232
+ if (!pid) return { pid: null, sig, skipped: 'no-pid' };
233
+ const owner = ownerOf(pid);
234
+ if (owner !== PROOF_USER) throw new Error(`refusing to signal pid ${pid}: owned by "${owner || 'nobody'}", not ${PROOF_USER}`);
235
+ sudo(['kill', `-${sig}`, String(pid)], { allowFail: true });
236
+ if (sig === 'STOP') this.stopped.add(pid);
237
+ if (sig === 'CONT' || sig === 'KILL') this.stopped.delete(pid);
238
+ return { pid, sig };
239
+ }
240
+
241
+ /**
242
+ * After every case, thrown or not: resume everything this run stopped, stop the
243
+ * guard-stopper, then lock the proof session. Errors here are collected, never thrown.
244
+ */
245
+ async recover() {
246
+ const problems = [];
247
+ if (this.stopper) { clearInterval(this.stopper); this.stopper = null; }
248
+ for (const pid of [...this.stopped]) {
249
+ try { this.kill(pid, 'CONT'); } catch (err) { problems.push(String(err instanceof Error ? err.message : err)); this.stopped.delete(pid); }
250
+ }
251
+ try { if (await this.readLockState() !== 'locked-in-session') await this.act('lock-now', {}); }
252
+ catch (err) { problems.push(`lock-now: ${err instanceof Error ? err.message : String(err)}`); }
253
+ return problems;
254
+ }
255
+
256
+ asProof(args) { return sudo(['launchctl', 'asuser', this.uid, 'sudo', '-u', PROOF_USER, '-H', ...args], { allowFail: true }); }
257
+
258
+ async act(action, { step } = {}) {
259
+ const helper = () => this.processes().filter(p => p.command.includes('instar-desktop-host'));
260
+ switch (action) {
261
+ case 'stop-server': return this.kill(this.pids.server = this.serverPid(this.a), 'STOP');
262
+ case 'cont-server': return this.kill(this.pids.server, 'CONT');
263
+ case 'kill-server': return this.kill(this.serverPid(this.a), 'KILL');
264
+ case 'start-server': {
265
+ this.asProof([path.join(this.o.proofInstall, 'node_modules/.bin/instar'), 'server', 'start', '--dir', this.a.dir]);
266
+ const until = this.now() + 60_000;
267
+ while (this.now() < until && !this.serverPid(this.a)) await this.sleep(1_000);
268
+ return { started: !!this.serverPid(this.a) };
269
+ }
270
+ case 'kill-tmux': return { out: this.asProof(['tmux', 'kill-server']) };
271
+ case 'launchd-kill': {
272
+ const labels = sudo(['launchctl', 'print', `gui/${this.uid}`], { allowFail: true }).match(/ai\.instar\.lockedproof\.[\w.-]+/g) ?? [];
273
+ for (const label of new Set(labels)) sudo(['launchctl', 'kill', 'SIGKILL', `gui/${this.uid}/${label}`], { allowFail: true });
274
+ return { labels: [...new Set(labels)] };
275
+ }
276
+ case 'kill-guard': return this.kill(this.pids.killedGuard = helper().find(p => p.command.includes('relock-guard'))?.pid, 'KILL');
277
+ case 'stop-guard': return this.kill(this.pids.stoppedGuard = helper().find(p => p.command.includes('relock-guard'))?.pid, 'STOP');
278
+ case 'cont-old-guard': return this.kill(this.pids.stoppedGuard, 'CONT');
279
+ case 'kill-helper': return this.kill(helper().find(p => !p.command.includes('relock-guard') && !p.command.includes('input-stream'))?.pid, 'KILL');
280
+ case 'stop-typist': return this.kill(this.pids.stoppedTypist = helper().find(p => p.command.includes('input-stream'))?.pid, 'STOP');
281
+ case 'cont-typist': return this.kill(this.pids.stoppedTypist, 'CONT');
282
+ case 'stop-new-guards':
283
+ this.stopper = setInterval(() => {
284
+ for (const p of helper().filter(x => x.command.includes('relock-guard'))) this.kill(p.pid, 'STOP');
285
+ }, 50);
286
+ return { watching: true };
287
+ case 'cont-guards':
288
+ if (this.stopper) clearInterval(this.stopper);
289
+ this.stopper = null;
290
+ for (const p of helper().filter(x => x.command.includes('relock-guard'))) this.kill(p.pid, 'CONT');
291
+ return { released: true };
292
+ case 'revoke': {
293
+ const requestId = Object.values(this.readStore().markers ?? {}).find(m => m.requestId)?.requestId;
294
+ const session = await this.operatorSession(this.a);
295
+ return requestId ? this.api(this.a, 'POST', `/desktop/requests/${encodeURIComponent(requestId)}/revoke`, {}, { 'X-Instar-Operator-Session': session })
296
+ : { error: 'no-request-id' };
297
+ }
298
+ case 'stop-viewer': await this.main.page.click('#stopButton'); return { clicked: 'stop' };
299
+ case 'leave-unlocked': await this.main.page.click('#leaveUnlockedButton'); return { clicked: 'leave-unlocked' };
300
+ case 'drop-transport': await this.main.context.setOffline(true); return { offline: true };
301
+ case 'wait-deadline': return { timedByTyping: true };
302
+ case 'type-letter': await this.main.page.focus('#surface'); await this.main.page.keyboard.press('a'); return { pressed: 'a' };
303
+ case 'grant-other-agent': {
304
+ const v = await this.viewer(this.b);
305
+ await v.page.click('#requestButton');
306
+ await this.sleep(5_000);
307
+ const text = (await v.page.textContent('#status').catch(() => '')) ?? '';
308
+ const opened = await v.page.isVisible('#control');
309
+ this.events.add('other-grant-tried');
310
+ return { opened, refused: /relock/i.test(text) ? 'relock-pending' : refusalFromStatus(text) ?? null, status: text.slice(0, 200) };
311
+ }
312
+ case 'open-next-grant': {
313
+ await this.main.page.click('#stopButton').catch(() => {});
314
+ const v = await this.viewer(this.a);
315
+ await v.page.click('#requestButton');
316
+ await v.page.waitForSelector('#control:not([hidden])', { timeout: 30_000 }).catch(() => {});
317
+ this.events.add('next-grant-open');
318
+ return { opened: await v.page.isVisible('#control') };
319
+ }
320
+ case 'logout-proof-user': return { out: sudo(['launchctl', 'bootout', `gui/${this.uid}`], { allowFail: true }) };
321
+ case 'lock-now': return { out: this.asProof(['python3', '-c', LOCK_PY]) };
322
+ default: throw new Error(`unknown action ${action}${step ? ` (case step ${step.at})` : ''}`);
323
+ }
324
+ }
325
+
326
+ async askPerson(text) {
327
+ process.stdout.write(`\n>>> PERSON STEP: ${text}\n\n`);
328
+ if (this.o.notifyTopic) {
329
+ sh('bash', [path.join(this.o.agentHome, '.instar/scripts/telegram-reply.sh'), String(this.o.notifyTopic)],
330
+ { input: `Locked-Mac proof: ${text}`, allowFail: true });
331
+ }
332
+ }
333
+
334
+ async extraEvidence(c, ev) {
335
+ const enters = new Map();
336
+ let closedAt = null;
337
+ for (const s of ev.samples) {
338
+ for (const [key, m] of Object.entries(s.markers ?? {})) {
339
+ if (typeof m.enterPendingAt === 'number') enters.set(`${key}:${m.enterPendingAt}`, m.enterPendingAt);
340
+ if (closedAt === null && TERMINAL.includes(m.state)) closedAt = s.t;
341
+ }
342
+ }
343
+ const watchStarts = new Set(ev.samples.flatMap(s => Object.values(s.markers ?? {}).map(m => m.watchStart).filter(Boolean)));
344
+ const finalMarkers = Object.values(ev.final.markers ?? {});
345
+ const procs = this.processes();
346
+ const out = {
347
+ submissions: enters.size,
348
+ submissionsAfterClose: closedAt === null ? 0 : [...enters.values()].filter(t => t > closedAt).length,
349
+ guardReplaced: finalMarkers.some(m => (m.guardReplacements ?? 0) > 0),
350
+ watchStartChanged: watchStarts.size > 1,
351
+ producerGone: finalMarkers.some(m => m.producerGone === true),
352
+ oldGuardGone: !this.pids.stoppedGuard || !procs.some(p => p.pid === this.pids.stoppedGuard),
353
+ typistGone: !procs.some(p => p.command.includes('input-stream')),
354
+ grantAlive: this.main ? await this.main.page.isVisible('#control').catch(() => false) : false,
355
+ loginwindowLines: sh('log', ['show', '--style', 'compact', '--start', localStamp(ev.startedAt),
356
+ '--predicate', 'process == "loginwindow"'], { allowFail: true }).split('\n').filter(l => /authenticat/i.test(l)).length,
357
+ };
358
+ try {
359
+ const list = await this.api(this.a, 'GET', '/attention');
360
+ const item = (list.items ?? []).find(i => String(i.id).startsWith('agent:desktop-relock'));
361
+ const failedAt = ev.samples.find(s => Object.values(s.markers ?? {}).some(m => m.state === 'relock-failed'))?.t;
362
+ out.securityItemRaised = !!item;
363
+ if (item && failedAt) out.securityItemMs = Date.parse(item.createdAt) - failedAt;
364
+ } catch { out.securityItemRaised = false; }
365
+ return out;
366
+ }
367
+
368
+ async closeViewers() {
369
+ for (const v of this.viewers.splice(0)) await v.context.close().catch(() => {});
370
+ this.main = null;
371
+ }
372
+
373
+ async close() {
374
+ const problems = await this.recover();
375
+ if (problems.length) process.stderr.write(`[locked-proof] recovery problems: ${problems.join('; ')}\n`);
376
+ await this.closeViewers();
377
+ await this.browser?.close();
378
+ }
379
+ }
380
+
381
+ /** `log show --start` reads local time as YYYY-MM-DD HH:MM:SS. */
382
+ function localStamp(ms) {
383
+ const d = new Date(ms);
384
+ const p = n => String(n).padStart(2, '0');
385
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
386
+ }
387
+
388
+ const STATUS_REASONS = [
389
+ ['finishing a relock', 'relock-pending'], ['Too many tries in this session', 'attempts-per-request'],
390
+ ['Too many tries today', 'attempts-per-day'], ['connection changed', 'seal-stale'],
391
+ ['Stopped before the password finished', 'aborted'], ['Clearing the password field', 'field-dirty'],
392
+ ['Password entry from a browser is off', 'browser-box-off'], ['backup connection', 'tunnel-not-first-tier'],
393
+ ['did not accept that', 'refused-other'],
394
+ ];
395
+ export function refusalFromStatus(text) {
396
+ const hit = STATUS_REASONS.find(([needle]) => text.includes(needle));
397
+ return hit ? hit[1] : null;
398
+ }
399
+
400
+ async function main() {
401
+ const command = process.argv[2];
402
+ if (command === 'expected') { process.stdout.write(renderExpected()); return; }
403
+ if (command !== 'run' && command !== 'calibrate') {
404
+ process.stderr.write('usage: driver.mjs expected | calibrate --execute ... | run --execute [--cases ...] ...\n');
405
+ process.exit(2);
406
+ }
407
+ const o = {
408
+ agentHome: opt('agent-home'), proofDir: opt('proof-dir', `/Users/${PROOF_USER}/.instar/agents/locked-proof`),
409
+ proofDirB: opt('proof-dir-b', `/Users/${PROOF_USER}/.instar/agents/locked-proof-b`),
410
+ proofInstall: opt('proof-install', `/Users/${PROOF_USER}/.instar/proof-install`),
411
+ port: Number(opt('port', '4098')), portB: Number(opt('port-b', '4097')), playwright: opt('playwright'),
412
+ out: path.resolve(opt('out', 'locked-proof-results')), notifyTopic: opt('notify-topic'), headed: flag('headed'),
413
+ };
414
+ const cases = command === 'calibrate' ? [] : selectCases(opt('cases')?.split(',').map(s => s.trim()).filter(Boolean));
415
+ if (!flag('execute')) {
416
+ process.stdout.write(`dry run (no --execute): would ${command} ${command === 'run' ? `${cases.length} case(s): ${cases.map(c => c.id).join(', ')}` : ''}\n`);
417
+ process.stdout.write(`against ${o.proofDir} (:${o.port}) and ${o.proofDirB} (:${o.portB}) as ${PROOF_USER}; results in ${o.out}\n`);
418
+ return;
419
+ }
420
+ if (process.platform !== 'darwin') throw new Error('the proof runs on a Mac');
421
+ if (!o.agentHome || !o.playwright) throw new Error('--agent-home and --playwright are required with --execute');
422
+ fs.mkdirSync(o.out, { recursive: true });
423
+ const target = new RealTarget(o);
424
+ await target.init();
425
+ try {
426
+ if (command === 'calibrate') {
427
+ await target.prepare({ id: 'calibrate' });
428
+ await target.openGrant({});
429
+ const t0 = target.now();
430
+ const typed = target.typePassword({ inject: [] });
431
+ let typistAt = null; let enterAt = null;
432
+ while (target.now() - t0 < 60_000 && enterAt === null) {
433
+ const snap = await target.snapshot();
434
+ if (snap.typistRunning && typistAt === null) typistAt = snap.now;
435
+ const m = Object.values(snap.markers).find(x => typeof x.enterPendingAt === 'number');
436
+ if (m) enterAt = m.enterPendingAt;
437
+ await target.sleep(50);
438
+ }
439
+ const result = await typed;
440
+ if (typistAt === null || enterAt === null) throw new Error(`calibration incomplete: ${JSON.stringify(result)}`);
441
+ const cal = { reserveMs: typistAt - t0, perCharMs: (enterAt - typistAt) / target.password.length, length: target.password.length, at: new Date().toISOString() };
442
+ fs.writeFileSync(path.join(o.out, 'calibration.json'), `${JSON.stringify(cal, null, 2)}\n`);
443
+ process.stdout.write(`calibration: ${JSON.stringify(cal)}\n`);
444
+ return;
445
+ }
446
+ const results = path.join(o.out, 'results.jsonl');
447
+ for (const c of cases) {
448
+ const ev = await runCase(c, target, { log: line => process.stdout.write(`${line}\n`) });
449
+ fs.appendFileSync(results, `${JSON.stringify(ev)}\n`);
450
+ process.stdout.write(`${c.id} ${ev.verdict.pass ? 'PASS' : `FAIL: ${ev.verdict.reasons.join('; ')}`}\n`);
451
+ }
452
+ } finally { await target.close(); }
453
+ }
454
+
455
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
456
+ main().catch(err => { process.stderr.write(`[locked-proof] ${err instanceof Error ? err.message : String(err)}\n`); process.exit(1); });
457
+ }