@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,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
|
-
|
|
1595
|
-
t: 'transcript-content',
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
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
|
+
}
|
package/dist/bridge/server.js
CHANGED
|
@@ -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) =>
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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) —
|
|
180
|
-
//
|
|
181
|
-
|
|
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);
|