@jhizzard/termdeck 1.8.1 → 1.10.0
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/package.json +2 -1
- package/packages/cli/assets/supervise/com.jhizzard.termdeck-supervise.plist +38 -0
- package/packages/cli/assets/supervise/termdeck-supervise.service +27 -0
- package/packages/cli/assets/supervise/termdeck-supervise.sh +146 -0
- package/packages/cli/assets/supervise/termdeck-supervise.timer +14 -0
- package/packages/cli/src/doctor.js +11 -0
- package/packages/cli/src/index.js +15 -2
- package/packages/cli/src/init-bridge.js +1270 -0
- package/packages/cli/src/init-mnestra.js +104 -13
- package/packages/cli/src/init-rumen.js +7 -0
- package/packages/cli/src/init.js +1 -0
- package/packages/client/public/app.js +135 -9
- package/packages/client/public/index.html +1 -0
- package/packages/client/public/input-guard.js +192 -0
- package/packages/client/public/style.css +63 -0
- package/packages/server/src/agent-adapters/web-chat-grok.js +22 -22
- package/packages/server/src/health.js +21 -3
- package/packages/server/src/index.js +6 -1
- package/packages/server/src/preflight.js +14 -5
- package/packages/server/src/setup/rumen/functions/inbox-promote/index.ts +105 -0
- package/packages/server/src/setup/rumen/functions/inbox-promote/tsconfig.json +14 -0
- package/packages/server/src/setup/supabase-url.js +101 -1
- package/packages/stack-installer/assets/hooks/README.md +25 -15
- package/packages/stack-installer/assets/hooks/memory-pre-compact.js +35 -7
- package/packages/stack-installer/assets/hooks/memory-session-end.js +121 -27
|
@@ -72,8 +72,9 @@ const HELP = [
|
|
|
72
72
|
' --skip-verify Skip the final memory_status_aggregation() sanity call',
|
|
73
73
|
'',
|
|
74
74
|
'What this does:',
|
|
75
|
-
' 1. Prompts for Supabase URL, service_role key,
|
|
76
|
-
' string, OpenAI API key, and (optional)
|
|
75
|
+
' 1. Prompts for Supabase URL, service_role key, Postgres connection',
|
|
76
|
+
' string (Shared Pooler; IPv4-safe), OpenAI API key, and (optional)',
|
|
77
|
+
' Anthropic API key — or reuses',
|
|
77
78
|
' saved values if a complete set already exists in secrets.env.',
|
|
78
79
|
' 2. Writes ~/.termdeck/secrets.env IMMEDIATELY (merge-aware) so a later',
|
|
79
80
|
' pg connect or migration failure does not lose what you typed in.',
|
|
@@ -157,6 +158,11 @@ function inputsFromEnv() {
|
|
|
157
158
|
|
|
158
159
|
const dbErr = urlHelper.looksLikePostgresUrl(required.DATABASE_URL);
|
|
159
160
|
if (dbErr) throw new Error(`DATABASE_URL: ${dbErr}`);
|
|
161
|
+
// Sprint 75 T2 (part B): warn-only — a direct-endpoint URL passes
|
|
162
|
+
// validation (warn ≠ reject) but gets the IPv4 trap warning printed once.
|
|
163
|
+
for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(required.DATABASE_URL))) {
|
|
164
|
+
process.stdout.write(` ${line}\n`);
|
|
165
|
+
}
|
|
160
166
|
|
|
161
167
|
const srErr = urlHelper.looksLikeServiceRole(required.SUPABASE_SERVICE_ROLE_KEY);
|
|
162
168
|
if (srErr) throw new Error(`SUPABASE_SERVICE_ROLE_KEY: ${srErr}`);
|
|
@@ -188,7 +194,7 @@ TermDeck Mnestra Setup
|
|
|
188
194
|
|
|
189
195
|
This wizard configures TermDeck's Tier 2 memory layer (Mnestra) by:
|
|
190
196
|
1. Asking for your Supabase URL and service_role key
|
|
191
|
-
2. Asking for a
|
|
197
|
+
2. Asking for a Postgres connection string (Shared Pooler)
|
|
192
198
|
3. Asking for an OpenAI API key (embeddings)
|
|
193
199
|
4. Asking for an Anthropic API key (optional, summaries)
|
|
194
200
|
5. Writing ~/.termdeck/secrets.env (before any database work, so a
|
|
@@ -253,6 +259,12 @@ async function collectInputs({ yes, reset }) {
|
|
|
253
259
|
process.stdout.write(
|
|
254
260
|
`Found saved secrets in ~/.termdeck/secrets.env (project ${ref}, db ${masked}).\n`
|
|
255
261
|
);
|
|
262
|
+
// Sprint 75 T2 (part B): highest-value warn site — an operator whose
|
|
263
|
+
// EARLIER install stored a direct-endpoint URL (the Brad case) would
|
|
264
|
+
// otherwise sail through reuse with zero feedback. Warn-only.
|
|
265
|
+
for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(found.inputs.databaseUrl))) {
|
|
266
|
+
process.stdout.write(` ${line}\n`);
|
|
267
|
+
}
|
|
256
268
|
const reuse = yes ? true : await prompts.confirm(' Reuse saved secrets?', { defaultYes: true });
|
|
257
269
|
if (reuse) {
|
|
258
270
|
process.stdout.write(' Reusing saved secrets. Skipping prompts.\n\n');
|
|
@@ -284,11 +296,19 @@ async function collectInputs({ yes, reset }) {
|
|
|
284
296
|
);
|
|
285
297
|
|
|
286
298
|
process.stdout.write(
|
|
287
|
-
'?
|
|
288
|
-
|
|
289
|
-
'
|
|
299
|
+
'? Postgres connection string (Shared Pooler)\n' +
|
|
300
|
+
' (Supabase dashboard → Connect (green button) → Transaction pooler →\n' +
|
|
301
|
+
' toggle ON "Use IPv4 connection (Shared Pooler)" — the OFF default shows an\n' +
|
|
302
|
+
' IPv6-only URL that hangs on IPv4-only hosts)\n' +
|
|
303
|
+
' postgres://postgres.<project-ref>:PW@aws-<n>-<region>.pooler.supabase.com:6543/postgres '
|
|
290
304
|
);
|
|
291
305
|
const databaseUrl = await promptSecretWithValidation(urlHelper.looksLikePostgresUrl);
|
|
306
|
+
// Sprint 75 T2 (part B): warn-only endpoint-shape feedback. The validator
|
|
307
|
+
// above ACCEPTS direct URLs (IPv6-capable hosts use them legitimately);
|
|
308
|
+
// this prints the IPv4 trap warning without changing acceptance.
|
|
309
|
+
for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(databaseUrl))) {
|
|
310
|
+
process.stdout.write(` ${line}\n`);
|
|
311
|
+
}
|
|
292
312
|
|
|
293
313
|
process.stdout.write('? OpenAI API key (starts sk-proj- or sk-): ');
|
|
294
314
|
const openaiKey = await promptSecretWithValidation(urlHelper.looksLikeOpenAiKey);
|
|
@@ -713,13 +733,34 @@ function refreshBundledHookIfNewer(opts = {}) {
|
|
|
713
733
|
// actually run after upgrading the package.
|
|
714
734
|
|
|
715
735
|
const SETTINGS_JSON_PATH = path.join(require('os').homedir(), '.claude', 'settings.json');
|
|
716
|
-
|
|
736
|
+
|
|
737
|
+
// Sprint 75 T2 — hook commands are written into ~/.claude/settings.json with
|
|
738
|
+
// ABSOLUTE paths. The pre-1.10 literal `node ~/.claude/hooks/...` shape relied
|
|
739
|
+
// on shell tilde expansion — it worked on macOS/Linux only by luck of how the
|
|
740
|
+
// harness invokes hook commands, and is a hard break on Windows (audit item 4).
|
|
741
|
+
// Computed at CALL time (not require time) from os.homedir() so a process that
|
|
742
|
+
// re-points HOME (tests, sandboxed installs) gets the right path. The path is
|
|
743
|
+
// double-quoted so a home dir containing spaces (`/Users/First Last/`) still
|
|
744
|
+
// produces a command the harness shell can execute. Lockstep twin lives in
|
|
745
|
+
// packages/stack-installer/src/index.js (`_hookCommandFor`) — INSTALLER-
|
|
746
|
+
// PITFALLS Class N: change both or neither.
|
|
747
|
+
function _hookCommandFor(filename) {
|
|
748
|
+
return `node "${path.join(require('os').homedir(), '.claude', 'hooks', filename)}"`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// True when an entry's command still carries the legacy tilde shape and
|
|
752
|
+
// should be rewritten to the absolute form.
|
|
753
|
+
function _isTildeHookCommand(command) {
|
|
754
|
+
return typeof command === 'string' && command.includes('~/');
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const HOOK_COMMAND = _hookCommandFor('memory-session-end.js');
|
|
717
758
|
const HOOK_TIMEOUT_SECONDS = 30;
|
|
718
759
|
|
|
719
760
|
// Sprint 64 T3 — PreCompact hook (Investigation 2 of CRITICAL-READ-FIRST-
|
|
720
761
|
// 2026-05-07.md). Lives alongside the SessionEnd hook; refreshes via the
|
|
721
762
|
// same Sprint 51.6 T3 version-stamp gate.
|
|
722
|
-
const PRECOMPACT_HOOK_COMMAND = '
|
|
763
|
+
const PRECOMPACT_HOOK_COMMAND = _hookCommandFor('memory-pre-compact.js');
|
|
723
764
|
const PRECOMPACT_HOOK_TIMEOUT_SECONDS = 30;
|
|
724
765
|
|
|
725
766
|
function _isSessionEndHookEntry(entry) {
|
|
@@ -734,7 +775,8 @@ function _isSessionEndHookEntry(entry) {
|
|
|
734
775
|
// `packages/stack-installer/src/index.js:451` byte-for-byte (modulo
|
|
735
776
|
// constants pulled from this file's scope).
|
|
736
777
|
function _mergeSessionEndHookEntry(settings, opts = {}) {
|
|
737
|
-
|
|
778
|
+
// Command computed at call time (Sprint 75 T2) — see _hookCommandFor.
|
|
779
|
+
const command = opts.command || _hookCommandFor('memory-session-end.js');
|
|
738
780
|
const timeout = opts.timeout != null ? opts.timeout : HOOK_TIMEOUT_SECONDS;
|
|
739
781
|
const entry = { type: 'command', command, timeout };
|
|
740
782
|
|
|
@@ -759,10 +801,29 @@ function _mergeSessionEndHookEntry(settings, opts = {}) {
|
|
|
759
801
|
|
|
760
802
|
if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
|
|
761
803
|
|
|
804
|
+
// Sprint 75 T2 — rewrite a stale literal-`~` command (written by installers
|
|
805
|
+
// ≤ v1.9.x) to the absolute form. The "already wired?" predicate matches by
|
|
806
|
+
// hook FILENAME substring, so without this rewrite a legacy entry would be
|
|
807
|
+
// reported already-installed and keep its `~` forever. Idempotent: absolute
|
|
808
|
+
// commands (and user-custom commands without `~/`) are never touched.
|
|
809
|
+
let tildeMigrated = false;
|
|
810
|
+
for (const group of settings.hooks.SessionEnd) {
|
|
811
|
+
if (!group || !Array.isArray(group.hooks)) continue;
|
|
812
|
+
for (const e of group.hooks) {
|
|
813
|
+
if (_isSessionEndHookEntry(e) && _isTildeHookCommand(e.command)) {
|
|
814
|
+
e.command = command;
|
|
815
|
+
tildeMigrated = true;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
762
820
|
for (const group of settings.hooks.SessionEnd) {
|
|
763
821
|
if (!group || !Array.isArray(group.hooks)) continue;
|
|
764
822
|
if (group.hooks.some(_isSessionEndHookEntry)) {
|
|
765
|
-
|
|
823
|
+
const status = tildeMigrated ? 'migrated-tilde-path'
|
|
824
|
+
: migrated ? 'migrated-from-stop'
|
|
825
|
+
: 'already-installed';
|
|
826
|
+
return { settings, status };
|
|
766
827
|
}
|
|
767
828
|
}
|
|
768
829
|
|
|
@@ -788,17 +849,30 @@ function _isPreCompactHookEntry(entry) {
|
|
|
788
849
|
}
|
|
789
850
|
|
|
790
851
|
function _mergePreCompactHookEntry(settings, opts = {}) {
|
|
791
|
-
|
|
852
|
+
// Command computed at call time (Sprint 75 T2) — see _hookCommandFor.
|
|
853
|
+
const command = opts.command || _hookCommandFor('memory-pre-compact.js');
|
|
792
854
|
const timeout = opts.timeout != null ? opts.timeout : PRECOMPACT_HOOK_TIMEOUT_SECONDS;
|
|
793
855
|
const entry = { type: 'command', command, timeout };
|
|
794
856
|
|
|
795
857
|
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
796
858
|
if (!Array.isArray(settings.hooks.PreCompact)) settings.hooks.PreCompact = [];
|
|
797
859
|
|
|
860
|
+
// Sprint 75 T2 — same stale literal-`~` rewrite as the SessionEnd merge.
|
|
861
|
+
let tildeMigrated = false;
|
|
862
|
+
for (const group of settings.hooks.PreCompact) {
|
|
863
|
+
if (!group || !Array.isArray(group.hooks)) continue;
|
|
864
|
+
for (const e of group.hooks) {
|
|
865
|
+
if (_isPreCompactHookEntry(e) && _isTildeHookCommand(e.command)) {
|
|
866
|
+
e.command = command;
|
|
867
|
+
tildeMigrated = true;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
798
872
|
for (const group of settings.hooks.PreCompact) {
|
|
799
873
|
if (!group || !Array.isArray(group.hooks)) continue;
|
|
800
874
|
if (group.hooks.some(_isPreCompactHookEntry)) {
|
|
801
|
-
return { settings, status: 'already-installed' };
|
|
875
|
+
return { settings, status: tildeMigrated ? 'migrated-tilde-path' : 'already-installed' };
|
|
802
876
|
}
|
|
803
877
|
}
|
|
804
878
|
|
|
@@ -934,10 +1008,14 @@ function runSettingsJsonMigration({ dryRun = false } = {}) {
|
|
|
934
1008
|
ok(r.backup ? `installed (SessionEnd; backup: ${path.basename(r.backup)})` : 'installed (SessionEnd)');
|
|
935
1009
|
} else if (r.status === 'migrated-from-stop') {
|
|
936
1010
|
ok(r.backup ? `migrated Stop → SessionEnd (was firing on every turn; backup: ${path.basename(r.backup)})` : 'migrated Stop → SessionEnd (was firing on every turn)');
|
|
1011
|
+
} else if (r.status === 'migrated-tilde-path') {
|
|
1012
|
+
ok(r.backup ? `rewrote legacy ~ command to absolute path (backup: ${path.basename(r.backup)})` : 'rewrote legacy ~ command to absolute path');
|
|
937
1013
|
} else if (r.status === 'would-installed') {
|
|
938
1014
|
ok('would install (SessionEnd) (dry-run)');
|
|
939
1015
|
} else if (r.status === 'would-migrated-from-stop') {
|
|
940
1016
|
ok('would migrate Stop → SessionEnd (dry-run)');
|
|
1017
|
+
} else if (r.status === 'would-migrated-tilde-path') {
|
|
1018
|
+
ok('would rewrite legacy ~ command to absolute path (dry-run)');
|
|
941
1019
|
} else if (r.status === 'malformed') {
|
|
942
1020
|
ok(`(skipped: settings.json malformed: ${r.error})`);
|
|
943
1021
|
} else {
|
|
@@ -964,8 +1042,12 @@ function runSettingsJsonMigration({ dryRun = false } = {}) {
|
|
|
964
1042
|
ok('already wired (PreCompact)');
|
|
965
1043
|
} else if (r.status === 'installed') {
|
|
966
1044
|
ok(r.backup ? `installed (PreCompact; backup: ${path.basename(r.backup)})` : 'installed (PreCompact)');
|
|
1045
|
+
} else if (r.status === 'migrated-tilde-path') {
|
|
1046
|
+
ok(r.backup ? `rewrote legacy ~ command to absolute path (backup: ${path.basename(r.backup)})` : 'rewrote legacy ~ command to absolute path');
|
|
967
1047
|
} else if (r.status === 'would-installed') {
|
|
968
1048
|
ok('would install (PreCompact) (dry-run)');
|
|
1049
|
+
} else if (r.status === 'would-migrated-tilde-path') {
|
|
1050
|
+
ok('would rewrite legacy ~ command to absolute path (dry-run)');
|
|
969
1051
|
} else if (r.status === 'malformed') {
|
|
970
1052
|
ok(`(skipped: settings.json malformed: ${r.error})`);
|
|
971
1053
|
} else {
|
|
@@ -1162,7 +1244,10 @@ async function main(argv) {
|
|
|
1162
1244
|
} catch (err) {
|
|
1163
1245
|
fail(err.message);
|
|
1164
1246
|
process.stderr.write(
|
|
1165
|
-
'\nDouble-check the connection string
|
|
1247
|
+
'\nDouble-check the connection string: Supabase dashboard → Connect → Transaction pooler →\n' +
|
|
1248
|
+
'toggle ON "Use IPv4 connection (Shared Pooler)". If the connect HUNG (timeout rather than\n' +
|
|
1249
|
+
'auth error), the URL is probably the IPv6-only db.<project-ref> endpoint and this host has\n' +
|
|
1250
|
+
'no IPv6 route — use the Shared Pooler URL.\n'
|
|
1166
1251
|
);
|
|
1167
1252
|
printResumeHint();
|
|
1168
1253
|
return 3;
|
|
@@ -1233,3 +1318,9 @@ module.exports._isSessionEndHookEntry = _isSessionEndHookEntry;
|
|
|
1233
1318
|
module.exports.SETTINGS_JSON_PATH = SETTINGS_JSON_PATH;
|
|
1234
1319
|
module.exports.HOOK_COMMAND = HOOK_COMMAND;
|
|
1235
1320
|
module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
|
|
1321
|
+
// Sprint 75 T2 — absolute-path hook-command builders (lockstep twin in
|
|
1322
|
+
// packages/stack-installer/src/index.js; exported for tests).
|
|
1323
|
+
module.exports._hookCommandFor = _hookCommandFor;
|
|
1324
|
+
module.exports._isTildeHookCommand = _isTildeHookCommand;
|
|
1325
|
+
module.exports._mergePreCompactHookEntry = _mergePreCompactHookEntry;
|
|
1326
|
+
module.exports.migrateSettingsJsonPreCompactEntry = migrateSettingsJsonPreCompactEntry;
|
|
@@ -212,6 +212,13 @@ function preflight() {
|
|
|
212
212
|
ok();
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// Sprint 75 T2 (part B): warn-only endpoint-shape feedback — the same
|
|
216
|
+
// stored URL feeds the Edge Function secrets, so a direct-endpoint URL
|
|
217
|
+
// (IPv6-only) carried over from an earlier install gets flagged here too.
|
|
218
|
+
for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(secrets.DATABASE_URL))) {
|
|
219
|
+
process.stdout.write(` ${line}\n`);
|
|
220
|
+
}
|
|
221
|
+
|
|
215
222
|
// OPENAI_API_KEY is optional: when present, Rumen's Relate phase generates
|
|
216
223
|
// real embeddings for semantic+keyword hybrid search. When absent, Rumen
|
|
217
224
|
// falls back to keyword-only matching (still works, but loses cross-project
|
package/packages/cli/src/init.js
CHANGED
|
@@ -102,6 +102,7 @@ const HELP = [
|
|
|
102
102
|
'Sub-mode wizards (callable independently for advanced users):',
|
|
103
103
|
' termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)',
|
|
104
104
|
' termdeck init --rumen Deploy Tier 3 async learning (Rumen)',
|
|
105
|
+
' termdeck init --bridge Scaffold the Tier 5 Web-Chat Bridge (named tunnel + supervisor)',
|
|
105
106
|
' termdeck init --project Scaffold a new project with CLAUDE.md + orchestration docs',
|
|
106
107
|
'',
|
|
107
108
|
'Get a Personal Access Token at: https://supabase.com/dashboard/account/tokens',
|
|
@@ -361,6 +361,80 @@
|
|
|
361
361
|
}, { capture: true });
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
// ===== termdeck#12 input guard (Sprint 73 T3) =====
|
|
365
|
+
// Runaway-typed-input defense at the single chokepoint every byte of
|
|
366
|
+
// human browser input flows through (the onData handler registered in
|
|
367
|
+
// createTerminalPanel). xterm@5.5.0 reconstructs composition input from
|
|
368
|
+
// its hidden textarea and can re-emit the accumulated buffer-so-far once
|
|
369
|
+
// per word boundary on IME/mobile/remote keyboards — the #12 cumulative-
|
|
370
|
+
// prefix runaway (~110 typed chars became a 3,042-char PTY stream).
|
|
371
|
+
// Detection logic lives in input-guard.js (UMD, unit-tested from node);
|
|
372
|
+
// these helpers wire it to panel state and the operator surface.
|
|
373
|
+
// Suppression is loud (console.error + panel toast), never silent;
|
|
374
|
+
// oversize chunks can be sent anyway from the toast.
|
|
375
|
+
function shouldSuppressPanelInput(entry, id, data) {
|
|
376
|
+
if (!entry._inputGuard) entry._inputGuard = InputGuard.createGuard();
|
|
377
|
+
const result = InputGuard.check(entry._inputGuard, data, Date.now());
|
|
378
|
+
if (result.verdict === 'pass') return false;
|
|
379
|
+
console.error(
|
|
380
|
+
`[input-guard] suppressed ${result.reason} input on panel ${id} ` +
|
|
381
|
+
`(chunk ${data.length} chars, chain ${result.chainLength}, ` +
|
|
382
|
+
`total suppressed ${result.suppressedCount} chunks / ${result.suppressedChars} chars):`,
|
|
383
|
+
JSON.stringify(data.slice(0, 120))
|
|
384
|
+
);
|
|
385
|
+
showInputGuardToast(entry, id, result, data);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function showInputGuardToast(entry, id, result, data) {
|
|
390
|
+
if (!entry || !entry.el) return;
|
|
391
|
+
|
|
392
|
+
// One toast per panel: repeated suppressions update the counter line
|
|
393
|
+
// (a runaway fires per word boundary — stacking toasts would bury the
|
|
394
|
+
// panel) and refresh the held chunk + auto-dismiss timer.
|
|
395
|
+
const existing = entry.el.querySelector('.input-guard-toast');
|
|
396
|
+
if (existing) {
|
|
397
|
+
const counter = existing.querySelector('.t-meta');
|
|
398
|
+
if (counter) counter.textContent = `${result.suppressedCount} chunks (${result.suppressedChars} chars) suppressed so far.`;
|
|
399
|
+
if (result.reason === 'oversize') existing._heldChunk = data;
|
|
400
|
+
clearTimeout(existing._autoTimer);
|
|
401
|
+
existing._autoTimer = setTimeout(() => existing.remove(), 60000);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const toast = document.createElement('div');
|
|
406
|
+
toast.className = 'input-guard-toast';
|
|
407
|
+
const why = result.reason === 'oversize'
|
|
408
|
+
? 'a single typed chunk was implausibly large'
|
|
409
|
+
: 'the keyboard started re-sending the whole buffer-so-far per keystroke (xterm composition runaway — termdeck#12)';
|
|
410
|
+
toast.innerHTML = `
|
|
411
|
+
<button class="t-dismiss" aria-label="Dismiss">×</button>
|
|
412
|
+
<div class="t-title">Input guard — runaway typing suppressed</div>
|
|
413
|
+
<div class="t-body">Blocked because ${why}. Check the terminal's input line before pressing Enter; clear it if it shows repeated text.${result.reason === 'oversize' ? ' If this was intentional, send it below.' : ''}</div>
|
|
414
|
+
<div class="t-meta">${result.suppressedCount} chunks (${result.suppressedChars} chars) suppressed so far.</div>
|
|
415
|
+
${result.reason === 'oversize' ? '<button class="t-send-anyway">Send anyway</button>' : ''}
|
|
416
|
+
`;
|
|
417
|
+
if (result.reason === 'oversize') toast._heldChunk = data;
|
|
418
|
+
entry.el.appendChild(toast);
|
|
419
|
+
|
|
420
|
+
toast.querySelector('.t-dismiss').addEventListener('click', () => {
|
|
421
|
+
clearTimeout(toast._autoTimer);
|
|
422
|
+
toast.remove();
|
|
423
|
+
});
|
|
424
|
+
const sendBtn = toast.querySelector('.t-send-anyway');
|
|
425
|
+
if (sendBtn) {
|
|
426
|
+
sendBtn.addEventListener('click', () => {
|
|
427
|
+
const live = state.sessions.get(id);
|
|
428
|
+
if (toast._heldChunk && live && live.ws && live.ws.readyState === WebSocket.OPEN) {
|
|
429
|
+
live.ws.send(JSON.stringify({ type: 'input', data: toast._heldChunk }));
|
|
430
|
+
}
|
|
431
|
+
clearTimeout(toast._autoTimer);
|
|
432
|
+
toast.remove();
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
toast._autoTimer = setTimeout(() => toast.remove(), 60000);
|
|
436
|
+
}
|
|
437
|
+
|
|
364
438
|
// ===== Create Terminal Panel =====
|
|
365
439
|
function createTerminalPanel(sessionData) {
|
|
366
440
|
const id = sessionData.id;
|
|
@@ -592,10 +666,20 @@
|
|
|
592
666
|
}
|
|
593
667
|
};
|
|
594
668
|
|
|
595
|
-
// Terminal input → WebSocket
|
|
669
|
+
// Terminal input → WebSocket. Registered ONCE per Terminal instance and
|
|
670
|
+
// never re-registered: xterm's onData ADDS listeners, and the
|
|
671
|
+
// pre-Sprint-73 reconnect path stacked one leaked handler (closed over
|
|
672
|
+
// its dead socket) per reconnect — the termdeck#12 cause-B family. The
|
|
673
|
+
// handler dereferences entry.ws at event time, so reconnectSession just
|
|
674
|
+
// swaps entry.ws and this same registration follows it.
|
|
675
|
+
// shouldSuppressPanelInput is the #12 runaway chokepoint — every byte
|
|
676
|
+
// of human browser input to this PTY flows through this closure.
|
|
596
677
|
terminal.onData((data) => {
|
|
597
|
-
|
|
598
|
-
|
|
678
|
+
const entry = state.sessions.get(id);
|
|
679
|
+
if (!entry || entry._mounting || !entry.ws) return;
|
|
680
|
+
if (shouldSuppressPanelInput(entry, id, data)) return;
|
|
681
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
682
|
+
entry.ws.send(JSON.stringify({ type: 'input', data }));
|
|
599
683
|
}
|
|
600
684
|
});
|
|
601
685
|
|
|
@@ -608,6 +692,49 @@
|
|
|
608
692
|
panel.classList.remove('active-input');
|
|
609
693
|
});
|
|
610
694
|
|
|
695
|
+
// termdeck#12 (Sprint 73 T3) — two defenses on xterm's hidden textarea:
|
|
696
|
+
//
|
|
697
|
+
// (a) Paste tracking for the input guard: a DOM `paste` event stamps
|
|
698
|
+
// the guard so the following chunk is exempt — pastes are
|
|
699
|
+
// deliberate bulk input, and without the stamp a large
|
|
700
|
+
// un-bracketed paste would false-trip the oversize detector.
|
|
701
|
+
//
|
|
702
|
+
// (b) Idle-clear of the textarea. xterm@5.5.0 clears its helper
|
|
703
|
+
// textarea only on non-composition Enter/Ctrl+C keydowns
|
|
704
|
+
// (Terminal.ts:1066-1068); on composition keyboards (every keydown
|
|
705
|
+
// = keyCode 229: mobile/IME/dictation/remote bridges) that clear
|
|
706
|
+
// never fires, the textarea accumulates the whole message, and
|
|
707
|
+
// CompositionHelper's replace/substring reconstruction can re-emit
|
|
708
|
+
// the accumulated buffer once per word boundary — the #12
|
|
709
|
+
// cumulative-prefix runaway. Clearing after a short typing lull
|
|
710
|
+
// (never mid-composition; composition state tracked via the public
|
|
711
|
+
// compositionstart/end events) bounds what those paths can
|
|
712
|
+
// reconstruct to a single typing burst. xterm's own deferred
|
|
713
|
+
// textarea reads are setTimeout(0) — far inside the 250ms debounce
|
|
714
|
+
// — so the clear cannot race them.
|
|
715
|
+
const guardTa = terminal.textarea;
|
|
716
|
+
if (guardTa) {
|
|
717
|
+
let composing = false;
|
|
718
|
+
let idleClearTimer = null;
|
|
719
|
+
const armIdleClear = () => {
|
|
720
|
+
if (idleClearTimer) clearTimeout(idleClearTimer);
|
|
721
|
+
idleClearTimer = setTimeout(() => {
|
|
722
|
+
idleClearTimer = null;
|
|
723
|
+
if (!composing && guardTa.value) guardTa.value = '';
|
|
724
|
+
}, 250);
|
|
725
|
+
};
|
|
726
|
+
guardTa.addEventListener('compositionstart', () => { composing = true; });
|
|
727
|
+
guardTa.addEventListener('compositionend', () => { composing = false; armIdleClear(); });
|
|
728
|
+
guardTa.addEventListener('keydown', armIdleClear);
|
|
729
|
+
guardTa.addEventListener('input', armIdleClear);
|
|
730
|
+
guardTa.addEventListener('paste', () => {
|
|
731
|
+
const entry = state.sessions.get(id);
|
|
732
|
+
if (!entry) return;
|
|
733
|
+
if (!entry._inputGuard) entry._inputGuard = InputGuard.createGuard();
|
|
734
|
+
InputGuard.notePaste(entry._inputGuard, Date.now());
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
611
738
|
// Store reference
|
|
612
739
|
state.sessions.set(id, {
|
|
613
740
|
session: sessionData,
|
|
@@ -2443,12 +2570,11 @@
|
|
|
2443
2570
|
}
|
|
2444
2571
|
};
|
|
2445
2572
|
|
|
2446
|
-
//
|
|
2447
|
-
entry.
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
});
|
|
2573
|
+
// No input re-wiring (Sprint 73 T3, termdeck#12): the single onData
|
|
2574
|
+
// handler registered at panel creation dereferences entry.ws at event
|
|
2575
|
+
// time, so the `entry.ws = ws` assignment in onopen above is all a
|
|
2576
|
+
// reconnect needs. Pre-Sprint-73 this function re-ran terminal.onData()
|
|
2577
|
+
// here, leaking one handler (closed over its dead socket) per reconnect.
|
|
2452
2578
|
}
|
|
2453
2579
|
|
|
2454
2580
|
function halfPanel(id) {
|
|
@@ -393,6 +393,7 @@
|
|
|
393
393
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
394
394
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
395
395
|
<script src="launcher-resolver.js" defer></script>
|
|
396
|
+
<script src="input-guard.js" defer></script>
|
|
396
397
|
<script src="app.js" defer></script>
|
|
397
398
|
</body>
|
|
398
399
|
</html>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// TermDeck input guard — extracted Sprint 73 T3 (termdeck#12, second half)
|
|
2
|
+
//
|
|
3
|
+
// Pure state-machine: given a stream of xterm `onData` chunks for one panel,
|
|
4
|
+
// decide per chunk whether it is plausible human/protocol input (`pass`) or a
|
|
5
|
+
// runaway re-emission of the input buffer (`suppress`). Lives in its own file
|
|
6
|
+
// so the same code runs in the browser (via <script src="input-guard.js">)
|
|
7
|
+
// AND under `node --test` (via `require('.../input-guard')`) — the
|
|
8
|
+
// launcher-resolver.js pattern.
|
|
9
|
+
//
|
|
10
|
+
// Why this exists (termdeck#12, the "input box accumulates buffer-so-far per
|
|
11
|
+
// keystroke" half): on composition-style keyboards — Android/iOS soft
|
|
12
|
+
// keyboards, IMEs, dictation, remote-access keyboard bridges — every keydown
|
|
13
|
+
// reaches xterm as keyCode 229 and xterm@5.5.0 reconstructs the typed data
|
|
14
|
+
// from its hidden helper <textarea>:
|
|
15
|
+
//
|
|
16
|
+
// 1. The textarea is cleared ONLY on a non-composition Enter/Ctrl+C keydown
|
|
17
|
+
// (xterm src/browser/Terminal.ts:1066-1068). Composition keydowns return
|
|
18
|
+
// early, so mid-message the textarea accumulates the entire buffer-so-far.
|
|
19
|
+
// 2. CompositionHelper._handleAnyTextareaChanges computes the keystroke as
|
|
20
|
+
// `newValue.replace(oldValue, '')` (CompositionHelper.ts:191). When the
|
|
21
|
+
// keyboard REWRITES text (autocorrect / auto-space / predictive commit —
|
|
22
|
+
// routine at word boundaries) `oldValue` is no longer a substring, the
|
|
23
|
+
// replace matches nothing, and the ENTIRE accumulated buffer is emitted
|
|
24
|
+
// as one data event.
|
|
25
|
+
// 3. CompositionHelper._finalizeComposition emits
|
|
26
|
+
// `textarea.value.substring(start[, end])` spans with offsets captured at
|
|
27
|
+
// composition boundaries (CompositionHelper.ts:134,163,168) — stale
|
|
28
|
+
// offsets re-emit accumulated tails per word commit.
|
|
29
|
+
//
|
|
30
|
+
// Net effect observed in #12: a ~110-char message became a 3,042-char PTY
|
|
31
|
+
// stream of cumulative prefixes ("i think" / "i think there" / "i think there
|
|
32
|
+
// is" …). Each individual chunk was small (~5-110 chars) — so a single-chunk
|
|
33
|
+
// size cap can NOT catch the primary shape. Detection keys on the structure:
|
|
34
|
+
// consecutive multi-char chunks where each strictly extends the previous one
|
|
35
|
+
// as a prefix. Legit input never looks like that: typed keys arrive as 1-char
|
|
36
|
+
// deltas, IME commits arrive as sibling words (not superstrings), pastes
|
|
37
|
+
// arrive as one chunk (and are exempted via the DOM `paste` event and/or
|
|
38
|
+
// bracketed-paste markers), and terminal protocol replies are ESC-prefixed.
|
|
39
|
+
//
|
|
40
|
+
// xterm itself is loaded from CDN, version-pinned (index.html) and not
|
|
41
|
+
// patchable in a zero-build client — but every byte of human browser input
|
|
42
|
+
// reaches the PTY through exactly one chokepoint TermDeck owns: the
|
|
43
|
+
// `terminal.onData` handler in app.js. This module is that chokepoint's
|
|
44
|
+
// brain. See tests/input-guard contract: packages/server/tests/input-guard.test.js.
|
|
45
|
+
|
|
46
|
+
(function (root, factory) {
|
|
47
|
+
if (typeof module === 'object' && module.exports) {
|
|
48
|
+
module.exports = factory();
|
|
49
|
+
} else {
|
|
50
|
+
root.InputGuard = factory();
|
|
51
|
+
}
|
|
52
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
53
|
+
|
|
54
|
+
const DEFAULTS = {
|
|
55
|
+
// A single non-paste, non-protocol chunk this large is not human typing.
|
|
56
|
+
// Largest legit non-paste chunks are IME phrase commits (CJK conversion,
|
|
57
|
+
// dictation segments) — realistically well under this. Suppressed chunks
|
|
58
|
+
// are held for an explicit "send anyway" so a false positive costs one
|
|
59
|
+
// click, while a true positive saves the session.
|
|
60
|
+
oversizeChunkChars: 512,
|
|
61
|
+
|
|
62
|
+
// Chunks shorter than this never participate in chain detection: 1-2
|
|
63
|
+
// chars is normal typing, 3 covers short escape sequences. Chain
|
|
64
|
+
// candidates start at 4 chars.
|
|
65
|
+
chainMinChunkChars: 4,
|
|
66
|
+
|
|
67
|
+
// Consecutive strictly-growing prefix-chained chunks before tripping.
|
|
68
|
+
// 3 means: chunk B extending chunk A passes (a single predictive-commit
|
|
69
|
+
// rewrite can legitimately look like that once), but a third consecutive
|
|
70
|
+
// extension is the #12 runaway. Brad's payload chains 20+ deep.
|
|
71
|
+
prefixChainTripCount: 3,
|
|
72
|
+
|
|
73
|
+
// Consecutive IDENTICAL multi-char chunks before tripping. Belt-and-
|
|
74
|
+
// suspenders for the stacked-handler / stuck-repeat shape. High threshold
|
|
75
|
+
// because short identical runs are legit ("ha ha ha ha" via IME commits).
|
|
76
|
+
repeatChainTripCount: 8,
|
|
77
|
+
|
|
78
|
+
// Chain links must arrive within this window of each other. Runaway
|
|
79
|
+
// emissions arrive at typing cadence; a long pause breaks the chain.
|
|
80
|
+
chainWindowMs: 5000,
|
|
81
|
+
|
|
82
|
+
// Grace period after a DOM `paste` event during which any chunk passes.
|
|
83
|
+
// Pastes are deliberate; they also reset chain state.
|
|
84
|
+
pasteGraceMs: 1500,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function createGuard(opts) {
|
|
88
|
+
return {
|
|
89
|
+
cfg: Object.assign({}, DEFAULTS, opts || {}),
|
|
90
|
+
lastPasteAt: 0,
|
|
91
|
+
prevChunk: '',
|
|
92
|
+
prevChunkAt: 0,
|
|
93
|
+
prefixChainLen: 0,
|
|
94
|
+
repeatChainLen: 0,
|
|
95
|
+
suppressedCount: 0,
|
|
96
|
+
suppressedChars: 0,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Record a DOM `paste` event on the panel's textarea (timestamp from the
|
|
101
|
+
// caller so the module stays clock-free and testable).
|
|
102
|
+
function notePaste(guard, now) {
|
|
103
|
+
guard.lastPasteAt = now;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resetChains(guard) {
|
|
107
|
+
guard.prevChunk = '';
|
|
108
|
+
guard.prevChunkAt = 0;
|
|
109
|
+
guard.prefixChainLen = 0;
|
|
110
|
+
guard.repeatChainLen = 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Classify one onData chunk. Returns { verdict: 'pass' } or
|
|
114
|
+
// { verdict: 'suppress', reason: 'prefix-chain'|'repeat-chain'|'oversize',
|
|
115
|
+
// chainLength, suppressedCount, suppressedChars }.
|
|
116
|
+
function check(guard, data, now) {
|
|
117
|
+
const cfg = guard.cfg;
|
|
118
|
+
|
|
119
|
+
// Bracketed paste (xterm wraps pastes in \x1b[200~ … \x1b[201~ when the
|
|
120
|
+
// app enabled DECSET 2004): deliberate bulk input — pass and reset
|
|
121
|
+
// chains. Checked before the generic ESC pass-through so the reset fires.
|
|
122
|
+
if (data.startsWith('\x1b[200~')) {
|
|
123
|
+
resetChains(guard);
|
|
124
|
+
return { verdict: 'pass' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Terminal protocol traffic (cursor/function keys, mouse tracking
|
|
128
|
+
// reports, DA/DSR query replies) is ESC-prefixed and must NEVER be
|
|
129
|
+
// suppressed or held — dropping a query reply can hang a TUI. The #12
|
|
130
|
+
// runaway is plain text reconstructed from the textarea, which cannot
|
|
131
|
+
// contain ESC. Protocol chunks don't touch chain state either (mouse
|
|
132
|
+
// wheel bursts emit many near-identical chunks legitimately).
|
|
133
|
+
if (data.charCodeAt(0) === 0x1b) {
|
|
134
|
+
return { verdict: 'pass' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// DOM-paste grace: a `paste` event just fired on this panel's textarea
|
|
138
|
+
// (un-bracketed paste path). Deliberate bulk input — pass and reset
|
|
139
|
+
// chains. lastPasteAt === 0 means "never pasted", not "pasted at epoch".
|
|
140
|
+
if (guard.lastPasteAt > 0 && (now - guard.lastPasteAt) <= cfg.pasteGraceMs) {
|
|
141
|
+
resetChains(guard);
|
|
142
|
+
return { verdict: 'pass' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Small chunks are normal typing / control chars: always pass, and leave
|
|
146
|
+
// chain state alone (runaway prefix emissions can interleave with real
|
|
147
|
+
// keystroke deltas; a 1-char delta must not amnesty the chain).
|
|
148
|
+
if (data.length < cfg.chainMinChunkChars) {
|
|
149
|
+
return { verdict: 'pass' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Multi-char plain-text chunk: update chain state.
|
|
153
|
+
const withinWindow = guard.prevChunk && (now - guard.prevChunkAt) <= cfg.chainWindowMs;
|
|
154
|
+
if (withinWindow && data.length > guard.prevChunk.length && data.startsWith(guard.prevChunk)) {
|
|
155
|
+
guard.prefixChainLen += 1;
|
|
156
|
+
guard.repeatChainLen = 1;
|
|
157
|
+
} else if (withinWindow && data === guard.prevChunk) {
|
|
158
|
+
guard.repeatChainLen += 1;
|
|
159
|
+
// An identical re-emission keeps a prefix chain alive but doesn't grow it.
|
|
160
|
+
} else {
|
|
161
|
+
guard.prefixChainLen = 1;
|
|
162
|
+
guard.repeatChainLen = 1;
|
|
163
|
+
}
|
|
164
|
+
guard.prevChunk = data;
|
|
165
|
+
guard.prevChunkAt = now;
|
|
166
|
+
|
|
167
|
+
let reason = null;
|
|
168
|
+
if (guard.prefixChainLen >= cfg.prefixChainTripCount) {
|
|
169
|
+
reason = 'prefix-chain';
|
|
170
|
+
} else if (guard.repeatChainLen >= cfg.repeatChainTripCount) {
|
|
171
|
+
reason = 'repeat-chain';
|
|
172
|
+
} else if (data.length >= cfg.oversizeChunkChars) {
|
|
173
|
+
reason = 'oversize';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (reason) {
|
|
177
|
+
guard.suppressedCount += 1;
|
|
178
|
+
guard.suppressedChars += data.length;
|
|
179
|
+
return {
|
|
180
|
+
verdict: 'suppress',
|
|
181
|
+
reason,
|
|
182
|
+
chainLength: reason === 'repeat-chain' ? guard.repeatChainLen : guard.prefixChainLen,
|
|
183
|
+
suppressedCount: guard.suppressedCount,
|
|
184
|
+
suppressedChars: guard.suppressedChars,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { verdict: 'pass' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { DEFAULTS, createGuard, notePaste, check };
|
|
192
|
+
});
|