@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.
- package/dist/__tests__/bridge-changes-report.test.js +25 -0
- package/dist/__tests__/bridge-changes-reporter.test.js +98 -0
- package/dist/__tests__/bridge-read-transcript-frame.test.js +22 -0
- package/dist/__tests__/bridge-save-env.test.d.ts +1 -0
- package/dist/__tests__/bridge-save-env.test.js +343 -0
- package/dist/__tests__/bridge-server-phantom-takeover.test.d.ts +1 -0
- package/dist/__tests__/bridge-server-phantom-takeover.test.js +84 -0
- package/dist/__tests__/bridge-server.test.js +9 -11
- package/dist/__tests__/secrets-egress.test.d.ts +1 -0
- package/dist/__tests__/secrets-egress.test.js +237 -0
- package/dist/__tests__/secrets-redact.test.d.ts +1 -0
- package/dist/__tests__/secrets-redact.test.js +318 -0
- package/dist/__tests__/term-snapshot.test.js +136 -0
- package/dist/bridge/changesReport.js +10 -3
- package/dist/bridge/changesReporter.js +0 -0
- package/dist/bridge/contextWriter.js +5 -1
- package/dist/bridge/envSaver.d.ts +63 -0
- package/dist/bridge/envSaver.js +256 -0
- package/dist/bridge/hiveopSettings.js +5 -0
- package/dist/bridge/launchSupervisor.js +4 -1
- package/dist/bridge/localContextWriter.js +6 -2
- package/dist/bridge/paneClient.d.ts +32 -0
- package/dist/bridge/paneClient.js +30 -0
- package/dist/bridge/realPty.js +5 -1
- package/dist/bridge/relayClient.d.ts +0 -18
- package/dist/bridge/relayClient.js +21 -6
- package/dist/bridge/repoEnv.d.ts +34 -0
- package/dist/bridge/repoEnv.js +186 -0
- package/dist/bridge/server.js +69 -26
- package/dist/bridge/sessionManager.d.ts +4 -0
- package/dist/bridge/sessionManager.js +28 -8
- package/dist/bridge/termSnapshot.js +114 -2
- package/dist/bridge/workGraphPointer.d.ts +25 -0
- package/dist/bridge/workGraphPointer.js +36 -1
- package/dist/commands/bridge.js +4 -0
- package/dist/secrets/console.d.ts +4 -0
- package/dist/secrets/console.js +34 -0
- package/dist/secrets/redact.d.ts +79 -0
- package/dist/secrets/redact.js +637 -0
- package/hooks/opencode-plugin.mjs +55 -2
- package/hooks/pre-tool-use.js +96 -0
- 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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
107
|
-
? `${contextWriter_1.POINTERS_START_MARKER}\n${
|
|
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
|
+
}
|
package/dist/bridge/realPty.js
CHANGED
|
@@ -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
|
-
|
|
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),
|