@wenrwa/marketplace-cli 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/__tests__/bridge-changes-report.test.js +25 -0
  2. package/dist/__tests__/bridge-changes-reporter.test.js +98 -0
  3. package/dist/__tests__/bridge-read-transcript-frame.test.js +22 -0
  4. package/dist/__tests__/bridge-save-env.test.d.ts +1 -0
  5. package/dist/__tests__/bridge-save-env.test.js +343 -0
  6. package/dist/__tests__/bridge-server-phantom-takeover.test.d.ts +1 -0
  7. package/dist/__tests__/bridge-server-phantom-takeover.test.js +84 -0
  8. package/dist/__tests__/bridge-server.test.js +9 -11
  9. package/dist/__tests__/secrets-egress.test.d.ts +1 -0
  10. package/dist/__tests__/secrets-egress.test.js +237 -0
  11. package/dist/__tests__/secrets-redact.test.d.ts +1 -0
  12. package/dist/__tests__/secrets-redact.test.js +318 -0
  13. package/dist/__tests__/term-snapshot.test.js +136 -0
  14. package/dist/bridge/changesReport.js +10 -3
  15. package/dist/bridge/changesReporter.js +0 -0
  16. package/dist/bridge/contextWriter.js +5 -1
  17. package/dist/bridge/envSaver.d.ts +63 -0
  18. package/dist/bridge/envSaver.js +256 -0
  19. package/dist/bridge/hiveopSettings.js +5 -0
  20. package/dist/bridge/launchSupervisor.js +4 -1
  21. package/dist/bridge/localContextWriter.js +6 -2
  22. package/dist/bridge/paneClient.d.ts +32 -0
  23. package/dist/bridge/paneClient.js +30 -0
  24. package/dist/bridge/realPty.js +5 -1
  25. package/dist/bridge/relayClient.d.ts +0 -18
  26. package/dist/bridge/relayClient.js +21 -6
  27. package/dist/bridge/repoEnv.d.ts +34 -0
  28. package/dist/bridge/repoEnv.js +186 -0
  29. package/dist/bridge/server.js +69 -26
  30. package/dist/bridge/sessionManager.d.ts +4 -0
  31. package/dist/bridge/sessionManager.js +28 -8
  32. package/dist/bridge/termSnapshot.js +114 -2
  33. package/dist/bridge/workGraphPointer.d.ts +25 -0
  34. package/dist/bridge/workGraphPointer.js +36 -1
  35. package/dist/commands/bridge.js +4 -0
  36. package/dist/secrets/console.d.ts +4 -0
  37. package/dist/secrets/console.js +34 -0
  38. package/dist/secrets/redact.d.ts +79 -0
  39. package/dist/secrets/redact.js +637 -0
  40. package/hooks/opencode-plugin.mjs +55 -2
  41. package/hooks/pre-tool-use.js +96 -0
  42. package/package.json +1 -1
@@ -1,21 +1,3 @@
1
- /**
2
- * ht-05 — Terminal Bridge relay mode (dial-out).
3
- *
4
- * Instead of listening on localhost for browser connections (server.ts),
5
- * this DIALS OUT to the platform relay over a single outbound wss
6
- * connection, authenticates with a per-container relay token, and serves
7
- * the same v1 frame protocol (see PROTOCOL.md) through the existing
8
- * SessionManager. Multi-session multiplexing is unchanged: every frame
9
- * already carries a sessionId, so one outbound socket serves every session
10
- * exactly like the local server serves them over N inbound sockets.
11
- *
12
- * PTYs live in the SessionManager, not the socket — a relay drop never
13
- * kills a session. This module owns only: dialing out, the auth frame,
14
- * frame routing through SessionManager, and reconnect-with-backoff. It
15
- * does no scrollback bookkeeping of its own (SessionManager already does
16
- * that) — on re-pair the pane sends `attach` again and gets a fresh
17
- * `replay`, exactly as it would against the local server.
18
- */
19
1
  import { type OpencodeLaunchInput } from './agentLaunch';
20
2
  import type { PreparedLaunch } from './opencodeBinary';
21
3
  import { SessionManager } from './sessionManager';
@@ -56,6 +56,7 @@ exports.startRelayClient = startRelayClient;
56
56
  * that) — on re-pair the pane sends `attach` again and gets a fresh
57
57
  * `replay`, exactly as it would against the local server.
58
58
  */
59
+ const redact_1 = require("../secrets/redact");
59
60
  const agentLaunch_1 = require("./agentLaunch");
60
61
  const resumeDetector_1 = require("./resumeDetector");
61
62
  const fs = __importStar(require("fs"));
@@ -112,6 +113,7 @@ const diskPreflight_1 = require("./diskPreflight");
112
113
  // checkoutParkAndRemove.ts's doc comment for the full safety design.
113
114
  const checkoutParkAndRemove_1 = require("./checkoutParkAndRemove");
114
115
  const attachmentWriter_1 = require("./attachmentWriter");
116
+ const envSaver_1 = require("./envSaver");
115
117
  const PROTOCOL_VERSION = 1;
116
118
  /**
117
119
  * fcct-27 — typed refusal when `resolveLaunch` names a binary this machine does
@@ -137,6 +139,8 @@ exports.SESSION_ADDRESSED_FRAMES = new Set([
137
139
  'browser-input', 'browser-frame-ack', 'browser-resize', 'browser-sleep',
138
140
  // Terminal attach writes next to a session — same consent boundary.
139
141
  'attach-file',
142
+ // Paste a key → .env writes into a session's folder — same boundary.
143
+ 'save-env',
140
144
  'browser-close', 'browser-reload',
141
145
  ]);
142
146
  /** cvt-07: what this bridge advertises in its auth frame. `browser-tabs` = a
@@ -1566,6 +1570,13 @@ function startRelayClient(opts) {
1566
1570
  // Terminal attach — shared verbatim with the local server.
1567
1571
  send(socket, (0, attachmentWriter_1.handleAttachFrame)(frame, opts.sessions));
1568
1572
  break;
1573
+ case 'save-env':
1574
+ // Paste a key → .env — shared verbatim with the local server.
1575
+ // Never logged: the frame carries the key itself.
1576
+ void (0, envSaver_1.handleSaveEnvFrame)(frame, opts.sessions)
1577
+ .then((reply) => send(socket, reply))
1578
+ .catch(() => send(socket, { t: 'save-env-result', rid: frame.rid, sessionId: frame.sessionId, ok: false, error: "Couldn't save key" }));
1579
+ break;
1569
1580
  case 'read-transcript': {
1570
1581
  // wte-10 (docs/prd/work-this-epic.md D8): read ONE Claude Code
1571
1582
  // agent's session transcript, live. Server-initiated (no pane,
@@ -1591,12 +1602,16 @@ function startRelayClient(opts) {
1591
1602
  break;
1592
1603
  }
1593
1604
  const result = opts.readTranscript({ workspaceId, agentId: frame.agentId, engine: frame.engine, opencodeSessionId: frame.opencodeSessionId });
1594
- send(socket, {
1595
- t: 'transcript-content',
1596
- requestId,
1597
- workspaceId,
1598
- ...(result.ok ? { ok: true, content: result.content } : { ok: false, reason: result.reason }),
1599
- });
1605
+ if (!result.ok) {
1606
+ send(socket, { t: 'transcript-content', requestId, workspaceId, ok: false, reason: result.reason });
1607
+ break;
1608
+ }
1609
+ // Secret masking (2026-10-17): the transcript holds every Bash
1610
+ // command and tool result verbatim — mask before it leaves. Sliced
1611
+ // so a multi-MB transcript never stalls live terminal frames.
1612
+ void (0, redact_1.redactInSlices)(result.content)
1613
+ .then((content) => send(socket, { t: 'transcript-content', requestId, workspaceId, ok: true, content }))
1614
+ .catch(() => send(socket, { t: 'transcript-content', requestId, workspaceId, ok: false, reason: 'transcript-unavailable' }));
1600
1615
  break;
1601
1616
  }
1602
1617
  case 'browser-open': {
@@ -0,0 +1,34 @@
1
+ export declare const DOTENV_FILENAME = ".env";
2
+ /** A larger file is not a hand-kept key file; refuse to read it into env. */
3
+ export declare const MAX_DOTENV_BYTES: number;
4
+ /**
5
+ * HiveOp's own plumbing — refused on SAVE as well (a pasted key can never be
6
+ * filed under a name the bridge owns).
7
+ */
8
+ export declare function isHiveOpOwnedEnvName(name: string): boolean;
9
+ /**
10
+ * True when a `.env` entry with this name is never LOADED into a spawn env.
11
+ * Saving one is still allowed unless HiveOp owns it (isHiveOpOwnedEnvName):
12
+ * e.g. ANTHROPIC_API_KEY may live in the user's .env for their own app, but
13
+ * it must not reach `claude` (it would switch it off the subscription).
14
+ */
15
+ export declare function isReservedEnvName(name: string): boolean;
16
+ /** Parse dotenv text. Later lines win. Malformed lines are skipped. */
17
+ export declare function parseDotenv(text: string): Record<string, string>;
18
+ /** Drop reserved names (and anything with a NUL — env can't carry one). */
19
+ export declare function filterRepoEnv(env: Record<string, string>): Record<string, string>;
20
+ export interface RepoEnvDeps {
21
+ lstat: (p: string) => {
22
+ isFile(): boolean;
23
+ size: number;
24
+ } | null;
25
+ readFile: (p: string) => string;
26
+ }
27
+ export declare const realRepoEnvDeps: RepoEnvDeps;
28
+ /**
29
+ * The usable env from `<cwd>/.env`, reserved names removed. `{}` when there is
30
+ * no cwd, no file, the file is a symlink/dir/too large, or it can't be read.
31
+ */
32
+ export declare function loadRepoEnv(cwd: string | undefined, deps?: RepoEnvDeps): Record<string, string>;
33
+ /** Spawn env: inherited < repo .env < session env. */
34
+ export declare function mergeSpawnEnv(inherited: Record<string, string | undefined>, repoEnv: Record<string, string>, sessionEnv: Record<string, string> | undefined): Record<string, string | undefined>;
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.realRepoEnvDeps = exports.MAX_DOTENV_BYTES = exports.DOTENV_FILENAME = void 0;
37
+ exports.isHiveOpOwnedEnvName = isHiveOpOwnedEnvName;
38
+ exports.isReservedEnvName = isReservedEnvName;
39
+ exports.parseDotenv = parseDotenv;
40
+ exports.filterRepoEnv = filterRepoEnv;
41
+ exports.loadRepoEnv = loadRepoEnv;
42
+ exports.mergeSpawnEnv = mergeSpawnEnv;
43
+ /**
44
+ * Paste a key → saved to .env (2026-10-17, owner-approved) — the READ half.
45
+ *
46
+ * The repo's own `.env` is the one place a user's keys live (a June decision
47
+ * removed server-side workspace secrets; nothing here may add one). When the
48
+ * bridge spawns a PTY it loads `<session cwd>/.env` into that PTY's env, so an
49
+ * agent started after a key was saved can use it as an ordinary env var.
50
+ *
51
+ * Precedence, lowest first:
52
+ * inherited process env < repo .env < the session's own env
53
+ * The session env carries HiveOp's WENRWA_* / HT_* / git credential stamps, so
54
+ * it always wins. On top of that, RESERVED names are never taken from `.env`
55
+ * at all — a repo file must not be able to steer the bridge's own plumbing
56
+ * (WENRWA_*), Claude Code's auth (ANTHROPIC_*, CLAUDE_* — a key saved as
57
+ * ANTHROPIC_API_KEY would otherwise switch `claude` off the subscription), or
58
+ * the process loader (PATH, NODE_OPTIONS, LD_PRELOAD, …).
59
+ *
60
+ * Deliberately a SIMPLE dotenv: `NAME=value` lines, optional `export `,
61
+ * single/double quotes, `#` comments. No `${VAR}` expansion, no multi-line
62
+ * values — what is in the file is exactly what the agent gets.
63
+ *
64
+ * Never logs a value. Never throws: any read problem means "no repo env".
65
+ */
66
+ const fs = __importStar(require("fs"));
67
+ const path = __importStar(require("path"));
68
+ exports.DOTENV_FILENAME = '.env';
69
+ /** A larger file is not a hand-kept key file; refuse to read it into env. */
70
+ exports.MAX_DOTENV_BYTES = 64 * 1024;
71
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
72
+ /** Exact names `.env` may never set. */
73
+ const RESERVED_NAMES = new Set([
74
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'PWD', 'OLDPWD', 'SHLVL', 'TMPDIR',
75
+ 'NODE_OPTIONS', 'NODE_PATH', 'BASH_ENV', 'ENV', 'PROMPT_COMMAND', 'ZDOTDIR', 'IFS',
76
+ 'HT_AUTONOMOUS', 'MARKETPLACE_API_URL', 'SSH_AUTH_SOCK',
77
+ ]);
78
+ /** Name prefixes `.env` may never set. */
79
+ const RESERVED_PREFIXES = ['WENRWA_', 'HIVEOP_', 'ANTHROPIC_', 'CLAUDE_', 'LD_', 'DYLD_', 'GIT_', 'NPM_CONFIG_', 'XDG_'];
80
+ /**
81
+ * HiveOp's own plumbing — refused on SAVE as well (a pasted key can never be
82
+ * filed under a name the bridge owns).
83
+ */
84
+ function isHiveOpOwnedEnvName(name) {
85
+ const upper = name.toUpperCase();
86
+ return upper.startsWith('WENRWA_') || upper.startsWith('HIVEOP_') || upper === 'HT_AUTONOMOUS' || upper === 'MARKETPLACE_API_URL';
87
+ }
88
+ /**
89
+ * True when a `.env` entry with this name is never LOADED into a spawn env.
90
+ * Saving one is still allowed unless HiveOp owns it (isHiveOpOwnedEnvName):
91
+ * e.g. ANTHROPIC_API_KEY may live in the user's .env for their own app, but
92
+ * it must not reach `claude` (it would switch it off the subscription).
93
+ */
94
+ function isReservedEnvName(name) {
95
+ const upper = name.toUpperCase();
96
+ if (RESERVED_NAMES.has(upper))
97
+ return true;
98
+ return RESERVED_PREFIXES.some((p) => upper.startsWith(p));
99
+ }
100
+ function unquote(raw) {
101
+ const v = raw.trim();
102
+ if (v.length >= 2 && v[0] === "'") {
103
+ const end = v.indexOf("'", 1);
104
+ return end === -1 ? v.slice(1) : v.slice(1, end);
105
+ }
106
+ if (v.length >= 2 && v[0] === '"') {
107
+ let out = '';
108
+ for (let i = 1; i < v.length; i++) {
109
+ const c = v[i];
110
+ if (c === '\\' && i + 1 < v.length) {
111
+ const n = v[++i];
112
+ out += n === 'n' ? '\n' : n === 'r' ? '\r' : n === 't' ? '\t' : n;
113
+ continue;
114
+ }
115
+ if (c === '"')
116
+ return out;
117
+ out += c;
118
+ }
119
+ return out;
120
+ }
121
+ // Unquoted: an inline comment starts at ` #`.
122
+ const hash = v.search(/\s#/);
123
+ return (hash === -1 ? v : v.slice(0, hash)).trim();
124
+ }
125
+ /** Parse dotenv text. Later lines win. Malformed lines are skipped. */
126
+ function parseDotenv(text) {
127
+ const out = {};
128
+ for (const rawLine of text.split(/\r?\n/)) {
129
+ const line = rawLine.replace(/^/, '').trim();
130
+ if (!line || line.startsWith('#'))
131
+ continue;
132
+ const body = line.startsWith('export ') ? line.slice(7).trimStart() : line;
133
+ const eq = body.indexOf('=');
134
+ if (eq <= 0)
135
+ continue;
136
+ const name = body.slice(0, eq).trim();
137
+ if (!NAME_RE.test(name))
138
+ continue;
139
+ out[name] = unquote(body.slice(eq + 1));
140
+ }
141
+ return out;
142
+ }
143
+ /** Drop reserved names (and anything with a NUL — env can't carry one). */
144
+ function filterRepoEnv(env) {
145
+ const out = {};
146
+ for (const [k, v] of Object.entries(env)) {
147
+ if (isReservedEnvName(k) || v.includes('\0'))
148
+ continue;
149
+ out[k] = v;
150
+ }
151
+ return out;
152
+ }
153
+ exports.realRepoEnvDeps = {
154
+ lstat: (p) => {
155
+ try {
156
+ return fs.lstatSync(p);
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ },
162
+ readFile: (p) => fs.readFileSync(p, 'utf8'),
163
+ };
164
+ /**
165
+ * The usable env from `<cwd>/.env`, reserved names removed. `{}` when there is
166
+ * no cwd, no file, the file is a symlink/dir/too large, or it can't be read.
167
+ */
168
+ function loadRepoEnv(cwd, deps = exports.realRepoEnvDeps) {
169
+ if (!cwd)
170
+ return {};
171
+ try {
172
+ const p = path.join(cwd, exports.DOTENV_FILENAME);
173
+ const st = deps.lstat(p);
174
+ // lstat: a symlinked .env is not followed.
175
+ if (!st || !st.isFile() || st.size > exports.MAX_DOTENV_BYTES)
176
+ return {};
177
+ return filterRepoEnv(parseDotenv(deps.readFile(p)));
178
+ }
179
+ catch {
180
+ return {};
181
+ }
182
+ }
183
+ /** Spawn env: inherited < repo .env < session env. */
184
+ function mergeSpawnEnv(inherited, repoEnv, sessionEnv) {
185
+ return { ...inherited, ...filterRepoEnv(repoEnv), ...(sessionEnv ?? {}) };
186
+ }
@@ -63,7 +63,9 @@ const sessionAnchor_1 = require("./sessionAnchor");
63
63
  // the SAME activity records fcct-24's tail already parses below.
64
64
  const sessionTouchTracker_1 = require("./sessionTouchTracker");
65
65
  const attachmentWriter_1 = require("./attachmentWriter");
66
+ const envSaver_1 = require("./envSaver");
66
67
  const screenshotAttach_1 = require("./screenshotAttach");
68
+ const paneClient_1 = require("./paneClient");
67
69
  const PROTOCOL_VERSION = 1;
68
70
  function send(ws, frame) {
69
71
  if (ws.readyState === ws_1.WebSocket.OPEN)
@@ -393,6 +395,8 @@ function startBridgeServer(opts) {
393
395
  ...(opts.writeLocalFile ? ['local-file-write'] : []),
394
396
  // Terminal attach — this bridge answers `attach-file` frames.
395
397
  'terminal-attach',
398
+ // Paste a key → .env — this bridge answers `save-env` frames.
399
+ 'save-env',
396
400
  // Screenshot drop — answers `attach-screenshot` (macOS only).
397
401
  ...((opts.screenshotDeps ?? screenshotAttach_1.realScreenshotDeps).platform === 'darwin' ? ['attach-screenshot'] : []),
398
402
  ],
@@ -586,6 +590,11 @@ function startBridgeServer(opts) {
586
590
  });
587
591
  }, opts.changesDetectIntervalMs ?? 5_000);
588
592
  }
593
+ // Who holds each session right now (socket + the pane's own ids). Read by
594
+ // the SUPERSEDED attachment's sink when SessionManager fires `takeover`
595
+ // at it, so the takeover can say where the new attach is — and stay
596
+ // silent when it is this same socket or this same pane redialing.
597
+ const attachOwner = new Map();
589
598
  wss.on('connection', (ws, req) => {
590
599
  if (req.headers.origin !== opts.allowedOrigin) {
591
600
  ws.close(4403, 'origin not allowed');
@@ -605,29 +614,52 @@ function startBridgeServer(opts) {
605
614
  // SessionManager#attach before its first internal await, so takeover
606
615
  // ordering is unaffected — only the point at which `replay` is
607
616
  // available moves to a .then().
608
- const attachTo = (sessionId, size) => opts.sessions
609
- .attach(sessionId, (frame) => {
610
- if (frame.t === 'data') {
611
- // lws-08 — scan this session's live output for a dev-server-ready
612
- // banner BEFORE forwarding the data frame (order is cosmetic, but
613
- // this keeps the dev-server frame visible even if a caller stops
614
- // reading after the first message in a test).
615
- if (!devServerWatchers.has(sessionId))
616
- devServerWatchers.set(sessionId, (0, devServerDetect_1.createDevServerWatcher)());
617
- const matches = devServerWatchers.get(sessionId).feed(frame.data);
618
- for (const m of matches) {
619
- send(ws, { t: 'dev-server', sessionId, framework: m.framework, port: m.port, url: m.url });
617
+ const attachTo = (sessionId, size, rawClient) => {
618
+ const me = { ws, client: (0, paneClient_1.parsePaneClient)(rawClient) };
619
+ const prevOwner = attachOwner.get(sessionId);
620
+ // Set BEFORE attach: SessionManager fires the previous sink's takeover
621
+ // synchronously inside attach(), and that sink reads the new owner here.
622
+ attachOwner.set(sessionId, me);
623
+ return opts.sessions
624
+ .attach(sessionId, (frame) => {
625
+ if (frame.t === 'data') {
626
+ // lws-08 — scan this session's live output for a dev-server-ready
627
+ // banner BEFORE forwarding the data frame (order is cosmetic, but
628
+ // this keeps the dev-server frame visible even if a caller stops
629
+ // reading after the first message in a test).
630
+ if (!devServerWatchers.has(sessionId))
631
+ devServerWatchers.set(sessionId, (0, devServerDetect_1.createDevServerWatcher)());
632
+ const matches = devServerWatchers.get(sessionId).feed(frame.data);
633
+ for (const m of matches) {
634
+ send(ws, { t: 'dev-server', sessionId, framework: m.framework, port: m.port, url: m.url });
635
+ }
636
+ send(ws, { t: 'data', sessionId, data: frame.data });
620
637
  }
621
- send(ws, { t: 'data', sessionId, data: frame.data });
622
- }
623
- else if (frame.t === 'takeover') {
624
- send(ws, { t: 'takeover', sessionId });
625
- }
626
- }, size)
627
- .then((handle) => {
628
- handles.set(sessionId, handle);
629
- return handle;
630
- });
638
+ else if (frame.t === 'takeover') {
639
+ const next = attachOwner.get(sessionId);
640
+ // The same socket re-attaching is not a takeover of itself.
641
+ if (next && next !== me && next.ws === ws)
642
+ return;
643
+ const where = next && next !== me ? (0, paneClient_1.takeoverWhere)(me.client, next.client) : undefined;
644
+ // The same pane redialing: this socket is its ghost — no notice.
645
+ if (where === 'self')
646
+ return;
647
+ send(ws, where ? { t: 'takeover', sessionId, by: where } : { t: 'takeover', sessionId });
648
+ }
649
+ }, size)
650
+ .then((handle) => {
651
+ handles.set(sessionId, handle);
652
+ return handle;
653
+ }, (err) => {
654
+ if (attachOwner.get(sessionId) === me) {
655
+ if (prevOwner)
656
+ attachOwner.set(sessionId, prevOwner);
657
+ else
658
+ attachOwner.delete(sessionId);
659
+ }
660
+ throw err;
661
+ });
662
+ };
631
663
  ws.on('message', (raw) => {
632
664
  let frame;
633
665
  try {
@@ -774,7 +806,7 @@ function startBridgeServer(opts) {
774
806
  send(ws, { t: 'error', message });
775
807
  break;
776
808
  }
777
- attachTo(sessionId).then(() => {
809
+ attachTo(sessionId, undefined, frame.client).then(() => {
778
810
  send(ws, { t: 'opened', sessionId, ...(cwd ? { cwd } : {}) });
779
811
  send(ws, {
780
812
  t: 'data',
@@ -821,7 +853,7 @@ function startBridgeServer(opts) {
821
853
  console.error('[bridge] local context pointer refresh failed (non-fatal):', err);
822
854
  });
823
855
  }
824
- attachTo(sessionId).then(() => {
856
+ attachTo(sessionId, undefined, frame.client).then(() => {
825
857
  send(ws, {
826
858
  t: 'opened',
827
859
  sessionId,
@@ -853,7 +885,7 @@ function startBridgeServer(opts) {
853
885
  send(ws, { t: 'error', message });
854
886
  return;
855
887
  }
856
- attachTo(sessionId)
888
+ attachTo(sessionId, undefined, frame.client)
857
889
  .then(() => {
858
890
  send(ws, { t: 'opened', sessionId, ...(cwd ? { cwd } : {}), ...(note ? { note } : {}) });
859
891
  })
@@ -875,7 +907,7 @@ function startBridgeServer(opts) {
875
907
  case 'attach': {
876
908
  // lws-07 (ADR-0033 port): every replay this bridge produces is a
877
909
  // serialized TermSnapshot — the flag is unconditional here.
878
- attachTo(frame.sessionId, (0, sessionManager_1.validSize)(frame.cols, frame.rows))
910
+ attachTo(frame.sessionId, (0, sessionManager_1.validSize)(frame.cols, frame.rows), frame.client)
879
911
  .then((handle) => {
880
912
  // Carry the session's cwd on REATTACH. `opened` announces it
881
913
  // only at creation, so a pane that reloads came back with no
@@ -915,6 +947,7 @@ function startBridgeServer(opts) {
915
947
  // once closed, cwdOf() may no longer resolve it.
916
948
  const closedCwd = opts.sessions.cwdOf(frame.sessionId);
917
949
  handles.delete(frame.sessionId);
950
+ attachOwner.delete(frame.sessionId);
918
951
  opts.sessions.close(frame.sessionId);
919
952
  devServerWatchers.delete(frame.sessionId);
920
953
  changesWorkspaceOf.delete(frame.sessionId); // tchp-12 — no orphan polling for a closed session
@@ -966,6 +999,13 @@ function startBridgeServer(opts) {
966
999
  .then((reply) => send(ws, reply))
967
1000
  .catch(() => send(ws, { t: 'file-attached', rid: frame.rid, sessionId: frame.sessionId, ok: false, reason: 'error' }));
968
1001
  break;
1002
+ case 'save-env':
1003
+ // Paste a key → .env — a FILE write in the session's folder, never
1004
+ // PTY input. Shared handler with relayClient.ts (both lanes).
1005
+ void (0, envSaver_1.handleSaveEnvFrame)(frame, opts.sessions)
1006
+ .then((reply) => send(ws, reply))
1007
+ .catch(() => send(ws, { t: 'save-env-result', rid: frame.rid, sessionId: frame.sessionId, ok: false, error: "Couldn't save key" }));
1008
+ break;
969
1009
  default:
970
1010
  send(ws, { t: 'error', message: 'unknown frame type' });
971
1011
  }
@@ -978,6 +1018,9 @@ function startBridgeServer(opts) {
978
1018
  for (const handle of handles.values())
979
1019
  handle.detach();
980
1020
  handles.clear();
1021
+ for (const [sid, o] of attachOwner)
1022
+ if (o.ws === ws)
1023
+ attachOwner.delete(sid);
981
1024
  });
982
1025
  });
983
1026
  return new Promise((resolve, reject) => {
@@ -98,6 +98,9 @@ export interface SessionManagerOptions {
98
98
  * gate. Off (laptop) → only an explicit `command: 'claude'` is gated and
99
99
  * plain shells are never delayed. */
100
100
  everyOpenIsAgentLaunch?: boolean;
101
+ /** Secret masking: idle time before a held output tail is released
102
+ * (redact.ts). Default 30ms; tests pass 0. */
103
+ redactIdleMs?: number;
101
104
  }
102
105
  /**
103
106
  * lws-07 (ADR-0033 port): validates a client-supplied size pair for the
@@ -124,6 +127,7 @@ export declare class SessionManager {
124
127
  private launchSupervisor;
125
128
  private launchGate;
126
129
  private everyOpenIsAgentLaunch;
130
+ private redactIdleMs;
127
131
  /** twr-16: cumulative count of attach-pipeline wedges (any stage timeout,
128
132
  * or a serialize-wedge whose recovery retry ALSO timed out) since this
129
133
  * SessionManager was created. Surfaced via attachFailures() into the
@@ -23,6 +23,7 @@ const crypto_1 = __importDefault(require("crypto"));
23
23
  const termSnapshot_1 = require("./termSnapshot");
24
24
  const launchSupervisor_1 = require("./launchSupervisor");
25
25
  const launchGate_1 = require("./launchGate");
26
+ const redact_1 = require("../secrets/redact");
26
27
  /** Default settle window (lws-07): after a resize-on-attach, how long we give
27
28
  * the PTY's SIGWINCH-triggered repaint to land in the snapshot before
28
29
  * serializing. Small — this only needs to cover one render tick, not a
@@ -78,6 +79,7 @@ class SessionManager {
78
79
  launchSupervisor;
79
80
  launchGate;
80
81
  everyOpenIsAgentLaunch;
82
+ redactIdleMs;
81
83
  /** twr-16: cumulative count of attach-pipeline wedges (any stage timeout,
82
84
  * or a serialize-wedge whose recovery retry ALSO timed out) since this
83
85
  * SessionManager was created. Surfaced via attachFailures() into the
@@ -97,6 +99,7 @@ class SessionManager {
97
99
  this.launchSupervisor = opts.launchSupervisor ?? {};
98
100
  this.launchGate = opts.launchGate === false ? null : new launchGate_1.LaunchGate(opts.launchGate ?? {});
99
101
  this.everyOpenIsAgentLaunch = opts.everyOpenIsAgentLaunch ?? false;
102
+ this.redactIdleMs = opts.redactIdleMs;
100
103
  }
101
104
  /** twr-16: see attachFailureCount. */
102
105
  attachFailures() {
@@ -140,6 +143,7 @@ class SessionManager {
140
143
  const id = opts.id ?? crypto_1.default.randomBytes(8).toString('hex');
141
144
  const prior = this.sessions.get(id);
142
145
  if (prior) {
146
+ prior.redactor.dispose();
143
147
  prior.pty.kill();
144
148
  prior.snapshot.dispose();
145
149
  this.sessions.delete(id);
@@ -164,7 +168,20 @@ class SessionManager {
164
168
  ? this.launchGate.launch(spawnOne, { cols: opts.cols, rows: opts.rows })
165
169
  : spawnOne({ cols: opts.cols, rows: opts.rows });
166
170
  const snapshot = this.snapshotFactory(opts.cols, opts.rows, { scrollback: this.snapshotScrollback });
167
- const session = {
171
+ // eslint-disable-next-line prefer-const
172
+ let session;
173
+ // Masked output fans out to the snapshot and the live sink. `session` is
174
+ // read at emit time, so a rebuilt snapshot (twr-16) or a new sink is used.
175
+ const redactor = (0, redact_1.createStreamRedactor)({
176
+ idleMs: this.redactIdleMs,
177
+ emit: (data) => {
178
+ // Fire-and-forget: serialize() flushes the snapshot's internal write
179
+ // queue before reading it back, so no caller needs to await this.
180
+ session.snapshot.write(data).catch(() => { });
181
+ session.sink?.({ t: 'data', data });
182
+ },
183
+ });
184
+ session = {
168
185
  pty,
169
186
  snapshot,
170
187
  cols: opts.cols,
@@ -174,17 +191,19 @@ class SessionManager {
174
191
  exited: false,
175
192
  cwd: opts.cwd,
176
193
  engine: isOpencode ? 'opencode' : isClaude ? 'claude' : undefined,
194
+ redactor,
177
195
  };
178
196
  pty.onData((data) => {
179
- // Single write path to the PTY itself is untouched (ADR-0028) — this is
180
- // purely a second, read-only consumer of the same output bytes the live
181
- // sink already gets. Fire-and-forget: serialize() flushes the snapshot's
182
- // internal write queue before reading it back, so no caller needs to
183
- // await this.
184
- session.snapshot.write(data).catch(() => { });
185
- session.sink?.({ t: 'data', data });
197
+ // Single write path to the PTY itself is untouched (ADR-0028) — output
198
+ // is only READ here, masked, and fanned out by the redactor above.
199
+ session.redactor.push(data);
186
200
  });
187
201
  pty.onExit((code) => {
202
+ // Release any held tail before the session stops listening.
203
+ try {
204
+ session.redactor.dispose();
205
+ }
206
+ catch { /* never break teardown */ }
188
207
  session.exited = true;
189
208
  session.sink = null;
190
209
  session.snapshot.dispose();
@@ -340,6 +359,7 @@ class SessionManager {
340
359
  const session = this.sessions.get(sessionId);
341
360
  if (!session)
342
361
  return;
362
+ session.redactor.dispose();
343
363
  session.pty.kill();
344
364
  session.snapshot.dispose();
345
365
  this.sessions.delete(sessionId);