@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,6 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const termSnapshot_1 = require("../bridge/termSnapshot");
4
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
5
+ const { Terminal } = require('@xterm/headless');
6
+ /** A fresh client terminal at (cols, rows) with `data` replayed into it —
7
+ * what a reattaching pane sees. */
8
+ async function replay(data, cols, rows) {
9
+ const t = new Terminal({ cols, rows, allowProposedApi: true });
10
+ await new Promise((r) => t.write(data, () => r()));
11
+ return t;
12
+ }
13
+ function screenRows(t) {
14
+ const b = t.buffer.active;
15
+ const out = [];
16
+ for (let y = b.viewportY; y < b.viewportY + t.rows; y++)
17
+ out.push(b.getLine(y)?.translateToString(true) ?? '');
18
+ return out;
19
+ }
4
20
  describe('TermSnapshot', () => {
5
21
  it('write then immediately serialize includes the written content', async () => {
6
22
  const snap = (0, termSnapshot_1.createTermSnapshot)(80, 24);
@@ -77,4 +93,124 @@ describe('TermSnapshot', () => {
77
93
  // without callers ever having held a reference to xterm internals).
78
94
  expect(Object.keys(snap).sort()).toEqual(['dispose', 'resize', 'serialize', 'write']);
79
95
  });
96
+ describe('reattach after the terminal shrank (OpenCode garble)', () => {
97
+ /** A full-screen TUI frame on the alt screen, every row full width, with
98
+ * a coloured span — then the pane shrinks. */
99
+ async function wideFrameThenShrink(snap) {
100
+ await snap.write('\x1b[?1049h\x1b[H');
101
+ for (let r = 1; r <= 6; r++) {
102
+ const row = `${r}`.repeat(10) + '\x1b[31m' + 'R'.repeat(10) + '\x1b[0m' + `${r}`.repeat(20);
103
+ await snap.write(`\x1b[${r};1H${row}`);
104
+ }
105
+ snap.resize(20, 4);
106
+ }
107
+ it('clips every row to the new cols — no row overflows into the next', async () => {
108
+ const snap = (0, termSnapshot_1.createTermSnapshot)(40, 6);
109
+ await wideFrameThenShrink(snap);
110
+ const out = await snap.serialize();
111
+ const client = await replay(out, 20, 4);
112
+ expect(client.buffer.active.type).toBe('alternate');
113
+ // The live 20x4 screen after the shrink shows rows 3..6, first 20 cells.
114
+ const live = ['3', '4', '5', '6'].map((d) => d.repeat(10) + 'R'.repeat(10));
115
+ expect(screenRows(client)).toEqual(live);
116
+ // SGR state survives the clip: the red span is still red, the digits not.
117
+ const line = client.buffer.active.getLine(client.buffer.active.viewportY);
118
+ expect(line.getCell(12).getFgColor()).toBe(1);
119
+ expect(line.getCell(2).getFgColor()).not.toBe(1);
120
+ client.dispose();
121
+ snap.dispose();
122
+ });
123
+ it('replays to exactly the live screen of an emulator that saw the same bytes and shrink', async () => {
124
+ const bytes = ['\x1b[?1049h\x1b[H'];
125
+ for (let r = 1; r <= 6; r++)
126
+ bytes.push(`\x1b[${r};1H` + `${r}abcdefghi`.repeat(4));
127
+ const snap = (0, termSnapshot_1.createTermSnapshot)(40, 6);
128
+ const live = new Terminal({ cols: 40, rows: 6, allowProposedApi: true });
129
+ for (const chunk of bytes) {
130
+ await snap.write(chunk);
131
+ await new Promise((r) => live.write(chunk, () => r()));
132
+ }
133
+ snap.resize(20, 4);
134
+ live.resize(20, 4);
135
+ const client = await replay(await snap.serialize(), 20, 4);
136
+ // What the live pane displays: each row's first `cols` cells.
137
+ const b = live.buffer.active;
138
+ const liveScreen = [];
139
+ for (let y = b.viewportY; y < b.viewportY + live.rows; y++) {
140
+ liveScreen.push(b.getLine(y).translateToString(true, 0, live.cols));
141
+ }
142
+ expect(screenRows(client)).toEqual(liveScreen);
143
+ expect(client.buffer.active.length).toBe(4);
144
+ [client, live].forEach((t) => t.dispose());
145
+ snap.dispose();
146
+ });
147
+ it('never splits a wide character that straddles the new right edge', async () => {
148
+ const snap = (0, termSnapshot_1.createTermSnapshot)(40, 3);
149
+ await snap.write('\x1b[?1049h\x1b[H');
150
+ await snap.write('\x1b[1;1H' + 'a'.repeat(19) + '漢' + 'b'.repeat(19));
151
+ await snap.write('\x1b[2;1H' + 'c'.repeat(40));
152
+ snap.resize(20, 3);
153
+ const out = await snap.serialize();
154
+ const client = await replay(out, 20, 3);
155
+ const rows = screenRows(client);
156
+ expect(rows[0]).toBe('a'.repeat(19));
157
+ expect(rows[1]).toBe('c'.repeat(20));
158
+ client.dispose();
159
+ snap.dispose();
160
+ });
161
+ it('a soft-wrapped row of the wider frame does not glue onto its clipped predecessor', async () => {
162
+ const snap = (0, termSnapshot_1.createTermSnapshot)(30, 4);
163
+ await snap.write('\x1b[?1049h\x1b[H');
164
+ // 45 chars at 30 cols: row 0 full, row 1 is a soft wrap of it.
165
+ await snap.write('x'.repeat(30) + 'y'.repeat(15));
166
+ snap.resize(20, 4);
167
+ const out = await snap.serialize();
168
+ const client = await replay(out, 20, 4);
169
+ expect(screenRows(client).slice(0, 3)).toEqual(['x'.repeat(20), 'y'.repeat(15), '']);
170
+ client.dispose();
171
+ snap.dispose();
172
+ });
173
+ });
174
+ describe('terminal modes survive a reattach', () => {
175
+ it('re-emits mouse tracking + SGR encoding, bracketed paste, app cursor keys, alt screen and a hidden cursor', async () => {
176
+ const snap = (0, termSnapshot_1.createTermSnapshot)(40, 6);
177
+ await snap.write('\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[?1h\x1b[?25l');
178
+ await snap.write('\x1b[1;1Hopencode');
179
+ const out = await snap.serialize();
180
+ const client = await replay(out, 40, 6);
181
+ expect(client.buffer.active.type).toBe('alternate');
182
+ expect(client.modes.mouseTrackingMode).toBe('drag');
183
+ expect(client._core.coreMouseService.activeEncoding).toBe('SGR');
184
+ expect(client.modes.bracketedPasteMode).toBe(true);
185
+ expect(client.modes.applicationCursorKeysMode).toBe(true);
186
+ expect(client._core.coreService.isCursorHidden).toBe(true);
187
+ expect(screenRows(client)[0]).toBe('opencode');
188
+ // Modes come AFTER the screen content, so the replayed content can't
189
+ // be misread under them.
190
+ expect(out.lastIndexOf('opencode')).toBeLessThan(out.indexOf('\x1b[?1006h'));
191
+ client.dispose();
192
+ snap.dispose();
193
+ });
194
+ it('re-emits any-motion tracking (?1003) and SGR-pixels (?1016)', async () => {
195
+ const snap = (0, termSnapshot_1.createTermSnapshot)(40, 6);
196
+ await snap.write('\x1b[?1003h\x1b[?1016h');
197
+ const client = await replay(await snap.serialize(), 40, 6);
198
+ expect(client.modes.mouseTrackingMode).toBe('any');
199
+ expect(client._core.coreMouseService.activeEncoding).toBe('SGR_PIXELS');
200
+ client.dispose();
201
+ snap.dispose();
202
+ });
203
+ it('emits nothing for modes the app turned back off', async () => {
204
+ const snap = (0, termSnapshot_1.createTermSnapshot)(40, 6);
205
+ await snap.write('\x1b[?1000h\x1b[?1006h\x1b[?25l\x1b[?2004h');
206
+ await snap.write('\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?2004l');
207
+ const out = await snap.serialize();
208
+ expect(out).not.toMatch(/\x1b\[\?(1000|1006|25|2004)[hl]/);
209
+ const client = await replay(out, 40, 6);
210
+ expect(client.modes.mouseTrackingMode).toBe('none');
211
+ expect(client._core.coreMouseService.activeEncoding).toBe('DEFAULT');
212
+ client.dispose();
213
+ snap.dispose();
214
+ });
215
+ });
80
216
  });
@@ -68,6 +68,7 @@ exports.buildChangesReport = buildChangesReport;
68
68
  const crypto_1 = require("crypto");
69
69
  const os = __importStar(require("os"));
70
70
  const path = __importStar(require("path"));
71
+ const redact_1 = require("../secrets/redact");
71
72
  const DEFAULT_MAX_PATCH_BYTES = 64_000;
72
73
  const GENERATED_BASENAMES = new Set([
73
74
  'package-lock.json',
@@ -96,15 +97,21 @@ class GitStepError extends Error {
96
97
  super(label);
97
98
  }
98
99
  }
99
- /** Cuts `patch` to the last full line that fits within `maxBytes` (UTF-8 byte length). */
100
+ /**
101
+ * Cuts `patch` to the last full line that fits within `maxBytes` (UTF-8 byte
102
+ * length), then masks any secret in it (2026-10-17: a committed .env line is
103
+ * as visible in the Changes panel as in a terminal). Masking is
104
+ * length-preserving ASCII, so the byte budget is unchanged; `contentHash` is
105
+ * still taken from the unmasked full patch by the caller.
106
+ */
100
107
  function truncatePatch(patch, maxBytes) {
101
108
  const buf = Buffer.from(patch, 'utf8');
102
109
  if (buf.length <= maxBytes)
103
- return { text: patch, truncated: false };
110
+ return { text: (0, redact_1.redact)(patch), truncated: false };
104
111
  const slice = buf.subarray(0, maxBytes).toString('utf8');
105
112
  const lastNewline = slice.lastIndexOf('\n');
106
113
  const text = lastNewline >= 0 ? slice.slice(0, lastNewline + 1) : '';
107
- return { text, truncated: true };
114
+ return { text: (0, redact_1.redact)(text), truncated: true };
108
115
  }
109
116
  function parseNameStatus(stdout) {
110
117
  const entries = [];
Binary file
@@ -176,12 +176,16 @@ function writeContextFile(input, claudeMdPath, seedPath, workspaceRoot, deps) {
176
176
  const browser = (0, workGraphPointer_1.browserSection)(input);
177
177
  if (browser)
178
178
  pointers.push(browser);
179
+ const keys = (0, workGraphPointer_1.keysSection)(input);
180
+ if (keys)
181
+ pointers.push(keys);
182
+ const sections = (0, workGraphPointer_1.withSecretsSection)(pointers);
179
183
  // wmr-07: a pointers-only refresh keeps the region's existing file content
180
184
  // rather than replacing it with the (absent) `content` argument.
181
185
  const fileContent = input.pointersOnly
182
186
  ? splitRegion(existingRegion).rest
183
187
  : (input.content ?? '').trim();
184
- const block = pointerBlock(pointers);
188
+ const block = pointerBlock(sections);
185
189
  const parts = [block, fileContent].filter((part) => part.length > 0);
186
190
  const content = parts.join('\n\n');
187
191
  const isEmpty = content.trim().length === 0;
@@ -0,0 +1,63 @@
1
+ export declare const SAVE_ENV_NAME_RE: RegExp;
2
+ export declare const MAX_SAVE_ENV_VALUE_BYTES = 4096;
3
+ export declare const MAX_SAVE_ENV_ENTRIES = 32;
4
+ export interface EnvEntry {
5
+ name: string;
6
+ value: string;
7
+ }
8
+ export type SaveEnvValidation = {
9
+ ok: true;
10
+ entries: EnvEntry[];
11
+ } | {
12
+ ok: false;
13
+ error: string;
14
+ };
15
+ /** Validate the frame's entries. Error text is user-facing and never quotes a value. */
16
+ export declare function validateEnvEntries(raw: unknown): SaveEnvValidation;
17
+ /** Serialize one value so repoEnv.parseDotenv reads back exactly the same string. */
18
+ export declare function formatDotenvValue(value: string): string;
19
+ /**
20
+ * Upsert entries into dotenv text: the first line for a NAME is replaced in
21
+ * place, later duplicates of it are dropped, new names are appended. Every
22
+ * other line (comments, unrelated keys) is preserved byte for byte.
23
+ */
24
+ export declare function upsertDotenv(existing: string, entries: EnvEntry[]): string;
25
+ export interface EnvSaverDeps {
26
+ realpath: (p: string) => string;
27
+ /** null when the path does not exist. */
28
+ lstat: (p: string) => {
29
+ isFile(): boolean;
30
+ isDirectory(): boolean;
31
+ isSymbolicLink(): boolean;
32
+ } | null;
33
+ readFile: (p: string) => string;
34
+ /** Temp file (mode 0600) + rename, then chmod 0600. */
35
+ writeFileAtomic0600: (p: string, data: string) => void;
36
+ appendFile: (p: string, data: string) => void;
37
+ /** Exit code of `git <args>` run in `cwd`; 128 (or any non 0/1) when git is unusable. */
38
+ git: (args: string[], cwd: string) => Promise<number>;
39
+ }
40
+ export declare const realEnvSaverDeps: EnvSaverDeps;
41
+ export type SaveEnvResult = {
42
+ ok: true;
43
+ names: string[];
44
+ gitignoreAdded: boolean;
45
+ } | {
46
+ ok: false;
47
+ error: string;
48
+ };
49
+ /** Write entries into `<root>/.env`. `root` is the session's cwd. */
50
+ export declare function saveEnvEntries(root: string, entries: EnvEntry[], deps?: EnvSaverDeps): Promise<SaveEnvResult>;
51
+ /**
52
+ * The whole `save-env` frame → `save-env-result` reply, shared verbatim by the
53
+ * local server and the relay client so the two lanes cannot drift. The reply
54
+ * carries names only — never a value.
55
+ */
56
+ export declare function handleSaveEnvFrame(frame: {
57
+ rid?: unknown;
58
+ sessionId?: unknown;
59
+ entries?: unknown;
60
+ }, sessions: {
61
+ list(): string[];
62
+ cwdOf(id: string): string | undefined;
63
+ }, deps?: EnvSaverDeps): Promise<Record<string, unknown>>;
@@ -0,0 +1,256 @@
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.realEnvSaverDeps = exports.MAX_SAVE_ENV_ENTRIES = exports.MAX_SAVE_ENV_VALUE_BYTES = exports.SAVE_ENV_NAME_RE = void 0;
37
+ exports.validateEnvEntries = validateEnvEntries;
38
+ exports.formatDotenvValue = formatDotenvValue;
39
+ exports.upsertDotenv = upsertDotenv;
40
+ exports.saveEnvEntries = saveEnvEntries;
41
+ exports.handleSaveEnvFrame = handleSaveEnvFrame;
42
+ /**
43
+ * Paste a key → saved to .env (2026-10-17, owner-approved) — the WRITE half.
44
+ *
45
+ * The pane intercepts a paste that is a key, asks "Save to .env?", and on Save
46
+ * sends `{t:'save-env', rid, sessionId, entries:[{name,value}]}` over its own
47
+ * socket (local loopback, or the relay into the hosted container — same frame,
48
+ * same handler, both lanes, like `attach-file`). This module writes
49
+ * `<session cwd>/.env`:
50
+ *
51
+ * - The folder comes from the SESSION (cwdOf), never the frame. A session
52
+ * with no project folder is refused — never falls back to $HOME.
53
+ * - `.env` that git TRACKS is refused (writing a key into it would commit it).
54
+ * - `.env` is added to `.gitignore` first when git does not already ignore it.
55
+ * - NAME=value replaces an existing NAME line (no duplicates); others are kept.
56
+ * - File mode 0600, written via temp file + rename.
57
+ * - A symlinked `.env`/`.gitignore` is refused (no writes outside the root).
58
+ *
59
+ * Values are NEVER logged, echoed in a reply, or kept in memory past the write.
60
+ */
61
+ const fs = __importStar(require("fs"));
62
+ const path = __importStar(require("path"));
63
+ const crypto = __importStar(require("crypto"));
64
+ const child_process_1 = require("child_process");
65
+ const repoEnv_1 = require("./repoEnv");
66
+ exports.SAVE_ENV_NAME_RE = /^[A-Z_][A-Z0-9_]{0,63}$/;
67
+ exports.MAX_SAVE_ENV_VALUE_BYTES = 4096;
68
+ exports.MAX_SAVE_ENV_ENTRIES = 32;
69
+ /** Validate the frame's entries. Error text is user-facing and never quotes a value. */
70
+ function validateEnvEntries(raw) {
71
+ if (!Array.isArray(raw) || raw.length === 0)
72
+ return { ok: false, error: 'Nothing to save' };
73
+ if (raw.length > exports.MAX_SAVE_ENV_ENTRIES)
74
+ return { ok: false, error: 'Too many keys' };
75
+ const byName = new Map();
76
+ for (const e of raw) {
77
+ const name = e?.name;
78
+ const value = e?.value;
79
+ if (typeof name !== 'string' || !exports.SAVE_ENV_NAME_RE.test(name))
80
+ return { ok: false, error: 'Invalid name' };
81
+ if ((0, repoEnv_1.isHiveOpOwnedEnvName)(name))
82
+ return { ok: false, error: `${name} is reserved` };
83
+ if (typeof value !== 'string' || value.length === 0)
84
+ return { ok: false, error: `${name}: empty value` };
85
+ if (/[\r\n\0]/.test(value))
86
+ return { ok: false, error: `${name}: value has a line break` };
87
+ if (Buffer.byteLength(value, 'utf8') > exports.MAX_SAVE_ENV_VALUE_BYTES)
88
+ return { ok: false, error: `${name}: value too long` };
89
+ byName.set(name, value);
90
+ }
91
+ return { ok: true, entries: [...byName].map(([name, value]) => ({ name, value })) };
92
+ }
93
+ /** Serialize one value so repoEnv.parseDotenv reads back exactly the same string. */
94
+ function formatDotenvValue(value) {
95
+ if (/^[A-Za-z0-9_\-./:+=@,~%^]+$/.test(value))
96
+ return value;
97
+ if (!value.includes("'"))
98
+ return `'${value}'`;
99
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
100
+ }
101
+ function lineName(line) {
102
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
103
+ return m ? m[1] : null;
104
+ }
105
+ /**
106
+ * Upsert entries into dotenv text: the first line for a NAME is replaced in
107
+ * place, later duplicates of it are dropped, new names are appended. Every
108
+ * other line (comments, unrelated keys) is preserved byte for byte.
109
+ */
110
+ function upsertDotenv(existing, entries) {
111
+ const pending = new Map(entries.map((e) => [e.name, e.value]));
112
+ const written = new Set();
113
+ const lines = existing.length > 0 ? existing.split('\n') : [];
114
+ // A trailing newline yields one empty final element — keep it out of the loop.
115
+ const hadTrailingNewline = existing.endsWith('\n');
116
+ if (hadTrailingNewline)
117
+ lines.pop();
118
+ const out = [];
119
+ for (const line of lines) {
120
+ const name = lineName(line);
121
+ if (name && pending.has(name)) {
122
+ if (written.has(name))
123
+ continue; // a duplicate — never keep two
124
+ out.push(`${name}=${formatDotenvValue(pending.get(name))}`);
125
+ written.add(name);
126
+ continue;
127
+ }
128
+ out.push(line);
129
+ }
130
+ for (const [name, value] of pending) {
131
+ if (!written.has(name))
132
+ out.push(`${name}=${formatDotenvValue(value)}`);
133
+ }
134
+ return out.join('\n') + '\n';
135
+ }
136
+ exports.realEnvSaverDeps = {
137
+ realpath: (p) => fs.realpathSync(p),
138
+ lstat: (p) => {
139
+ try {
140
+ return fs.lstatSync(p);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ },
146
+ readFile: (p) => fs.readFileSync(p, 'utf8'),
147
+ writeFileAtomic0600: (p, data) => {
148
+ const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
149
+ fs.writeFileSync(tmp, data, { mode: 0o600 });
150
+ try {
151
+ fs.chmodSync(tmp, 0o600);
152
+ fs.renameSync(tmp, p);
153
+ }
154
+ catch (err) {
155
+ try {
156
+ fs.unlinkSync(tmp);
157
+ }
158
+ catch { /* best effort */ }
159
+ throw err;
160
+ }
161
+ fs.chmodSync(p, 0o600);
162
+ },
163
+ appendFile: (p, data) => fs.appendFileSync(p, data),
164
+ git: (args, cwd) => new Promise((resolve) => {
165
+ (0, child_process_1.execFile)('git', args, { cwd, timeout: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, (err) => {
166
+ if (!err)
167
+ return resolve(0);
168
+ const code = err.code;
169
+ resolve(typeof code === 'number' ? code : 128);
170
+ });
171
+ }),
172
+ };
173
+ /** Write entries into `<root>/.env`. `root` is the session's cwd. */
174
+ async function saveEnvEntries(root, entries, deps = exports.realEnvSaverDeps) {
175
+ let realRoot;
176
+ try {
177
+ realRoot = deps.realpath(root);
178
+ }
179
+ catch {
180
+ return { ok: false, error: 'Project folder not found' };
181
+ }
182
+ const rootStat = deps.lstat(realRoot);
183
+ if (!rootStat || !rootStat.isDirectory())
184
+ return { ok: false, error: 'Project folder not found' };
185
+ const envPath = path.join(realRoot, repoEnv_1.DOTENV_FILENAME);
186
+ const ignorePath = path.join(realRoot, '.gitignore');
187
+ // Containment: both files are direct children of the resolved root, and
188
+ // neither may be a symlink (which could point anywhere).
189
+ if (path.dirname(envPath) !== realRoot || path.dirname(ignorePath) !== realRoot) {
190
+ return { ok: false, error: 'Bad project folder' };
191
+ }
192
+ const envStat = deps.lstat(envPath);
193
+ if (envStat && (envStat.isSymbolicLink() || !envStat.isFile()))
194
+ return { ok: false, error: '.env is not a plain file' };
195
+ const ignoreStat = deps.lstat(ignorePath);
196
+ if (ignoreStat && (ignoreStat.isSymbolicLink() || !ignoreStat.isFile()))
197
+ return { ok: false, error: '.gitignore is not a plain file' };
198
+ // Tracked .env → refuse: the key would be committed with the next commit.
199
+ const tracked = await deps.git(['ls-files', '--error-unmatch', '--', repoEnv_1.DOTENV_FILENAME], realRoot);
200
+ if (tracked === 0)
201
+ return { ok: false, error: '.env is tracked by git' };
202
+ // Ignore it BEFORE the key lands, so it is never briefly committable.
203
+ // check-ignore: 0 = ignored, 1 = not ignored, 128 = not a repo / error.
204
+ let gitignoreAdded = false;
205
+ try {
206
+ const ignored = await deps.git(['check-ignore', '-q', '--', repoEnv_1.DOTENV_FILENAME], realRoot);
207
+ if (ignored !== 0) {
208
+ const current = ignoreStat ? deps.readFile(ignorePath) : '';
209
+ const sep = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
210
+ deps.appendFile(ignorePath, `${sep}${repoEnv_1.DOTENV_FILENAME}\n`);
211
+ gitignoreAdded = true;
212
+ }
213
+ }
214
+ catch {
215
+ return { ok: false, error: "Couldn't update .gitignore" };
216
+ }
217
+ try {
218
+ const existing = envStat ? deps.readFile(envPath) : '';
219
+ deps.writeFileAtomic0600(envPath, upsertDotenv(existing, entries));
220
+ }
221
+ catch {
222
+ return { ok: false, error: "Couldn't write .env" };
223
+ }
224
+ return { ok: true, names: entries.map((e) => e.name), gitignoreAdded };
225
+ }
226
+ /**
227
+ * The whole `save-env` frame → `save-env-result` reply, shared verbatim by the
228
+ * local server and the relay client so the two lanes cannot drift. The reply
229
+ * carries names only — never a value.
230
+ */
231
+ async function handleSaveEnvFrame(frame, sessions, deps = exports.realEnvSaverDeps) {
232
+ const head = {
233
+ t: 'save-env-result',
234
+ ...(typeof frame.rid === 'string' ? { rid: frame.rid } : {}),
235
+ ...(typeof frame.sessionId === 'string' ? { sessionId: frame.sessionId } : {}),
236
+ };
237
+ const sessionId = frame.sessionId;
238
+ if (typeof sessionId !== 'string' || !sessions.list().includes(sessionId)) {
239
+ return { ...head, ok: false, error: 'Terminal session not found' };
240
+ }
241
+ const root = sessions.cwdOf(sessionId);
242
+ if (!root)
243
+ return { ...head, ok: false, error: 'No project folder' };
244
+ const valid = validateEnvEntries(frame.entries);
245
+ if (!valid.ok)
246
+ return { ...head, ok: false, error: valid.error };
247
+ try {
248
+ const result = await saveEnvEntries(root, valid.entries, deps);
249
+ return result.ok
250
+ ? { ...head, ok: true, names: result.names, gitignoreAdded: result.gitignoreAdded }
251
+ : { ...head, ok: false, error: result.error };
252
+ }
253
+ catch {
254
+ return { ...head, ok: false, error: "Couldn't save key" };
255
+ }
256
+ }
@@ -81,6 +81,11 @@ function hookCommand(hooksDir, file) {
81
81
  function buildHiveOpSettingsContent(hooksDir) {
82
82
  const obj = {
83
83
  hooks: {
84
+ // Secret guard (2026-10-17): blocks a Bash command carrying a literal
85
+ // secret and tells the agent to reference it as $NAME instead.
86
+ PreToolUse: [
87
+ { matcher: 'Bash', hooks: [{ type: 'command', command: hookCommand(hooksDir, 'pre-tool-use.js') }] },
88
+ ],
84
89
  PostToolUse: [
85
90
  {
86
91
  matcher: 'Read|Edit|Write|Update|Create|MultiEdit|NotebookEdit|Bash',
@@ -6,6 +6,7 @@ exports.firstMeaningfulLine = firstMeaningfulLine;
6
6
  exports.retryArgsFor = retryArgsFor;
7
7
  exports.friendlyStartFailure = friendlyStartFailure;
8
8
  exports.superviseLaunch = superviseLaunch;
9
+ const redact_1 = require("../secrets/redact");
9
10
  /** How long after launch an exit still counts as "couldn't start". */
10
11
  exports.DEFAULT_LAUNCH_WINDOW_MS = 10_000;
11
12
  /** Cap on how much early output is kept for matching/logging. */
@@ -127,7 +128,9 @@ function superviseLaunch(factory, spawnOpts, opts = {}) {
127
128
  return;
128
129
  }
129
130
  const raw = stripAnsi(captured).trim();
130
- log(`[bridge] claude exited at startup (code ${code}, args: ${args.join(' ')}): ${raw || '(no output)'}`);
131
+ // The captured startup output can hold anything the shell printed, so it
132
+ // is masked before it reaches bridge.log (secret masking, 2026-10-17).
133
+ log((0, redact_1.redact)(`[bridge] claude exited at startup (code ${code}, args: ${args.join(' ')}): ${raw || '(no output)'}`));
131
134
  const nextArgs = retried ? null : retryArgsFor(args, captured);
132
135
  if (nextArgs) {
133
136
  retried = true;
@@ -97,14 +97,18 @@ function writeLocalContext(input, deps) {
97
97
  const browser = (0, workGraphPointer_1.browserSection)(input);
98
98
  if (browser)
99
99
  pointers.push(browser);
100
+ const keys = (0, workGraphPointer_1.keysSection)(input);
101
+ if (keys)
102
+ pointers.push(keys);
103
+ const sections = (0, workGraphPointer_1.withSecretsSection)(pointers);
100
104
  // wmr-07 — the whole local file is bridge-owned, so "preserve the pushed
101
105
  // content" means keeping what is already on disk below the pointer fence.
102
106
  const existingBody = existed ? deps.readFile(contextPath) : '';
103
107
  const fileContent = input.pointersOnly
104
108
  ? splitPointers(existingBody).rest
105
109
  : (input.content ?? '').trim();
106
- const block = pointers.length > 0
107
- ? `${contextWriter_1.POINTERS_START_MARKER}\n${pointers.join('\n\n')}\n${contextWriter_1.POINTERS_END_MARKER}`
110
+ const block = sections.length > 0
111
+ ? `${contextWriter_1.POINTERS_START_MARKER}\n${sections.join('\n\n')}\n${contextWriter_1.POINTERS_END_MARKER}`
108
112
  : '';
109
113
  const content = [block, fileContent].filter((part) => part.length > 0).join('\n\n');
110
114
  const isEmpty = content.trim().length === 0;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Who is attaching a terminal pane — so last-attach-wins can tell a real
3
+ * second viewer from the same pane coming back.
4
+ *
5
+ * Prod 2026-10-17: panes showed "Session attached elsewhere" with nobody
6
+ * else attached. On this (loopback) lane a second mount of the same agent in
7
+ * one page, or a pane re-attaching on its own socket, got the same takeover a
8
+ * second device does. The arena sends three ids with each open/attach (the
9
+ * backend relay's pane-client.ts is the hosted twin of this file):
10
+ *
11
+ * browser — one per browser profile (localStorage)
12
+ * tab — one per page load
13
+ * mount — one per terminal pane instance, stable across its reconnects
14
+ *
15
+ * All optional: an old client sends none and gets today's plain takeover.
16
+ */
17
+ export interface PaneClient {
18
+ browser?: string;
19
+ tab?: string;
20
+ mount?: string;
21
+ }
22
+ /** Where the NEW attachment is, from the superseded one's point of view.
23
+ * 'self' = the same pane instance redialing: evict the ghost silently. */
24
+ export type TakeoverWhere = 'self' | 'page' | 'tab' | 'device';
25
+ /** Accept only short opaque ids; anything else is treated as absent. */
26
+ export declare function parsePaneClient(raw: unknown): PaneClient | undefined;
27
+ /**
28
+ * Compare the superseded pane's identity with the new one's. Undefined when
29
+ * either side did not say who it is (old client) — the caller keeps the
30
+ * plain takeover it always sent.
31
+ */
32
+ export declare function takeoverWhere(prev: PaneClient | undefined, next: PaneClient | undefined): TakeoverWhere | undefined;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parsePaneClient = parsePaneClient;
4
+ exports.takeoverWhere = takeoverWhere;
5
+ const ID = /^[A-Za-z0-9_-]{1,64}$/;
6
+ /** Accept only short opaque ids; anything else is treated as absent. */
7
+ function parsePaneClient(raw) {
8
+ if (!raw || typeof raw !== 'object')
9
+ return undefined;
10
+ const r = raw;
11
+ const pick = (v) => (typeof v === 'string' && ID.test(v) ? v : undefined);
12
+ const out = { browser: pick(r.browser), tab: pick(r.tab), mount: pick(r.mount) };
13
+ return out.browser || out.tab || out.mount ? out : undefined;
14
+ }
15
+ /**
16
+ * Compare the superseded pane's identity with the new one's. Undefined when
17
+ * either side did not say who it is (old client) — the caller keeps the
18
+ * plain takeover it always sent.
19
+ */
20
+ function takeoverWhere(prev, next) {
21
+ if (!prev || !next)
22
+ return undefined;
23
+ if (prev.mount && next.mount && prev.mount === next.mount)
24
+ return 'self';
25
+ if (prev.tab && next.tab && prev.tab === next.tab)
26
+ return 'page';
27
+ if (prev.browser && next.browser)
28
+ return prev.browser === next.browser ? 'tab' : 'device';
29
+ return undefined;
30
+ }
@@ -41,6 +41,7 @@ exports.realPtyFactory = realPtyFactory;
41
41
  */
42
42
  const os = __importStar(require("os"));
43
43
  const envSanitize_1 = require("./envSanitize");
44
+ const repoEnv_1 = require("./repoEnv");
44
45
  const fs = __importStar(require("fs"));
45
46
  const path = __importStar(require("path"));
46
47
  /**
@@ -86,7 +87,10 @@ function realPtyFactory() {
86
87
  // Sanitized: the launcher's Claude-session markers must not leak into
87
88
  // the agent (see envSanitize.ts — a bridge started from inside a Claude
88
89
  // Code session was silently disabling transcript saving in every agent).
89
- env: { ...(0, envSanitize_1.sanitizeInheritedEnv)(process.env), ...(env ?? {}) },
90
+ // Paste a key → .env: the session folder's `.env` is loaded too —
91
+ // above the inherited env, below the session's own (WENRWA_* etc.),
92
+ // with reserved names never taken from the file (repoEnv.ts).
93
+ env: (0, repoEnv_1.mergeSpawnEnv)((0, envSanitize_1.sanitizeInheritedEnv)(process.env), (0, repoEnv_1.loadRepoEnv)(cwd), env),
90
94
  });
91
95
  return {
92
96
  write: (data) => proc.write(data),