zerogterm 0.7.0-alpha2 → 0.8.0-alpha
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/LICENSE +21 -21
- package/README.md +244 -21
- package/bin/zerogterm.cjs +23 -23
- package/dist/main/main/ai-protocol.js +225 -0
- package/dist/main/main/ai-service.js +148 -0
- package/dist/main/main/command-history-store.js +231 -0
- package/dist/main/main/main.js +228 -9
- package/dist/main/main/port-forward-protocol.js +161 -0
- package/dist/main/main/port-forward-service.js +230 -0
- package/dist/main/main/port-forward-store.js +133 -0
- package/dist/main/main/preload.cjs +30 -1
- package/dist/main/main/secret-store.js +156 -0
- package/dist/main/main/session-service.js +22 -4
- package/dist/main/main/shell-catalog.js +63 -2
- package/dist/main/main/ssh-inventory.js +11 -0
- package/dist/main/main/workspace-store.js +213 -0
- package/dist/main/main/wsl-home.js +62 -0
- package/dist/main/shared/endpoints.js +73 -0
- package/dist/main/shared/version.js +32 -0
- package/dist/renderer/assets/index-BPS6JAQV.js +142 -0
- package/dist/renderer/assets/index-p0gJDyuE.css +1 -0
- package/dist/renderer/index.html +7 -4
- package/package.json +32 -2
- package/dist/renderer/assets/index-BjXtNztF.css +0 -1
- package/dist/renderer/assets/index-Ogyjvi0e.js +0 -19
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { FORWARD_SETTLE_MS, buildForwardArgs, describeForward, readForwardOutcome } from './port-forward-protocol.js';
|
|
5
|
+
import { detectPrompt } from './sftp-protocol.js';
|
|
6
|
+
// node-pty is a native addon with no ESM entry point, and this file is compiled
|
|
7
|
+
// as a module. Same shim as session-service.ts and sftp-service.ts.
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
/** A ceiling on live tunnels, so a stuck panel cannot spawn clients forever. */
|
|
10
|
+
const MAX_FORWARDS = 16;
|
|
11
|
+
/** Nothing at all from the client within this long means the host never answered. */
|
|
12
|
+
const FIRST_BYTE_MS = 20000;
|
|
13
|
+
/** Kept small: this is a debug log, not a terminal, and only the tail matters. */
|
|
14
|
+
const BUFFER_CHARS = 8000;
|
|
15
|
+
/** How the client's own line endings behave, as with the transfer side. */
|
|
16
|
+
const TERMINATOR = '\n';
|
|
17
|
+
/**
|
|
18
|
+
* The `ssh -N` processes behind shared ports.
|
|
19
|
+
*
|
|
20
|
+
* One process per tunnel. It cannot be otherwise: a forward cannot be added to
|
|
21
|
+
* the connection a terminal pane already holds without either typing `~C`
|
|
22
|
+
* escapes into the pty the user is typing in, or ControlMaster, which Windows
|
|
23
|
+
* OpenSSH does not have. One process each also makes closing exact — kill that
|
|
24
|
+
* process and that tunnel is gone, with nothing else disturbed.
|
|
25
|
+
*
|
|
26
|
+
* The pty is not for a remote tty; `-N` runs no remote command. It is so `ssh`
|
|
27
|
+
* has a terminal to ask for a password on, the same reason the transfer side
|
|
28
|
+
* uses one, which is why prompt handling here is `detectPrompt` unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export class PortForwardService {
|
|
31
|
+
forwards = new Map();
|
|
32
|
+
spawnPty;
|
|
33
|
+
onEvent;
|
|
34
|
+
settleMs;
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.spawnPty = options.spawnPty ?? ((file, args, ptyOptions) => require('node-pty').spawn(file, args, ptyOptions));
|
|
37
|
+
this.onEvent = options.onEvent ?? (() => undefined);
|
|
38
|
+
this.settleMs = options.settleMs ?? FORWARD_SETTLE_MS;
|
|
39
|
+
}
|
|
40
|
+
list() {
|
|
41
|
+
return [...this.forwards.values()].map((forward) => ({ ...forward.info }));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Open a tunnel, resolving once it is actually listening.
|
|
45
|
+
*
|
|
46
|
+
* A password or host-key question arrives as an event while this is still
|
|
47
|
+
* pending, and is answered with answerPrompt() — the same shape as the
|
|
48
|
+
* transfer panel, because it is the same client asking.
|
|
49
|
+
*/
|
|
50
|
+
async open(request) {
|
|
51
|
+
// Built before anything is spawned, so an invalid request is a sentence
|
|
52
|
+
// rather than a process that dies a moment later.
|
|
53
|
+
const { file, args, spec } = buildForwardArgs(request);
|
|
54
|
+
const existing = this.findConflict(request);
|
|
55
|
+
if (existing) {
|
|
56
|
+
throw new Error(`Port ${request.listenPort} is already shared ${existing.direction === 'local' ? 'from' : 'to'} ${existing.target}.`);
|
|
57
|
+
}
|
|
58
|
+
if (this.forwards.size >= MAX_FORWARDS) {
|
|
59
|
+
throw new Error('Too many shared ports. Close one and try again.');
|
|
60
|
+
}
|
|
61
|
+
const id = request.id ?? `forward:${randomUUID()}`;
|
|
62
|
+
// Logged because which client got picked, and with which spec, is the first
|
|
63
|
+
// thing worth knowing when a tunnel does not work on someone else's machine.
|
|
64
|
+
console.log('[zerog] forward open', { file, args });
|
|
65
|
+
const info = { ...request, id, status: 'connecting' };
|
|
66
|
+
const pty = this.spawnPty(file, args, {
|
|
67
|
+
name: 'xterm-256color',
|
|
68
|
+
cols: 120,
|
|
69
|
+
rows: 30,
|
|
70
|
+
cwd: homedir(),
|
|
71
|
+
env: process.env
|
|
72
|
+
});
|
|
73
|
+
const forward = { info, pty, buffer: '', settle: null, settleTimer: undefined, firstByteTimer: undefined, closed: false };
|
|
74
|
+
this.forwards.set(id, forward);
|
|
75
|
+
pty.onData((data) => this.receive(forward, data));
|
|
76
|
+
pty.onExit(() => this.handleExit(forward));
|
|
77
|
+
this.emitStatus(forward);
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
forward.settle = { resolve, reject };
|
|
80
|
+
forward.firstByteTimer = setTimeout(() => {
|
|
81
|
+
this.fail(forward, `${describeForward(request)} did not answer.`);
|
|
82
|
+
}, FIRST_BYTE_MS);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
answerPrompt(forwardId, answer) {
|
|
86
|
+
const forward = this.forwards.get(forwardId);
|
|
87
|
+
if (!forward)
|
|
88
|
+
throw new Error('That shared port is no longer open.');
|
|
89
|
+
if (/[\r\n]/.test(answer))
|
|
90
|
+
throw new Error('An answer must be a single line.');
|
|
91
|
+
lastQuestion.delete(forwardId);
|
|
92
|
+
forward.pty.write(`${answer}${TERMINATOR}`);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Close a tunnel.
|
|
96
|
+
*
|
|
97
|
+
* There is no graceful handshake to attempt: `ssh -N` is doing nothing but
|
|
98
|
+
* holding the forward open, so killing it is the clean way to let it go.
|
|
99
|
+
*/
|
|
100
|
+
close(forwardId) {
|
|
101
|
+
const forward = this.forwards.get(forwardId);
|
|
102
|
+
if (!forward)
|
|
103
|
+
return;
|
|
104
|
+
this.destroy(forward, 'Closed.');
|
|
105
|
+
}
|
|
106
|
+
closeAll() {
|
|
107
|
+
for (const id of [...this.forwards.keys()])
|
|
108
|
+
this.close(id);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A tunnel that already binds what this request wants.
|
|
112
|
+
*
|
|
113
|
+
* Checked here rather than only in the renderer because a second listener on
|
|
114
|
+
* the same port cannot work, and spawning a client to find that out gives the
|
|
115
|
+
* user an ssh error instead of a sentence. A local forward's port is bound on
|
|
116
|
+
* this machine and a remote forward's on the far host, so the two only
|
|
117
|
+
* conflict with their own kind — and a remote one only per host.
|
|
118
|
+
*/
|
|
119
|
+
findConflict(request) {
|
|
120
|
+
return this.list().find((candidate) => {
|
|
121
|
+
if (candidate.id === request.id)
|
|
122
|
+
return false;
|
|
123
|
+
if (candidate.listenPort !== request.listenPort)
|
|
124
|
+
return false;
|
|
125
|
+
if (candidate.direction !== request.direction)
|
|
126
|
+
return false;
|
|
127
|
+
return request.direction === 'local' || candidate.target === request.target;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
receive(forward, data) {
|
|
131
|
+
if (forward.closed)
|
|
132
|
+
return;
|
|
133
|
+
clearTimeout(forward.firstByteTimer);
|
|
134
|
+
forward.firstByteTimer = undefined;
|
|
135
|
+
forward.buffer = (forward.buffer + data).slice(-BUFFER_CHARS);
|
|
136
|
+
const question = detectPrompt(forward.buffer);
|
|
137
|
+
if (question) {
|
|
138
|
+
// Only once per question: a rejected password is followed by the very same
|
|
139
|
+
// prompt text, and the user has to be given the field again.
|
|
140
|
+
if (lastQuestion.get(forward.info.id) !== question.text) {
|
|
141
|
+
lastQuestion.set(forward.info.id, question.text);
|
|
142
|
+
this.onEvent({ type: 'prompt', forwardId: forward.info.id, prompt: question });
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const outcome = readForwardOutcome(forward.buffer, forward.info.direction);
|
|
147
|
+
if (!outcome)
|
|
148
|
+
return;
|
|
149
|
+
if (outcome.kind === 'failed') {
|
|
150
|
+
this.fail(forward, outcome.reason);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (outcome.kind === 'open') {
|
|
154
|
+
this.succeed(forward);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Authenticated but not yet listening. Give the client a moment to say it
|
|
158
|
+
// started, and take silence as success rather than failing a tunnel that an
|
|
159
|
+
// ssh build words differently from the one this was written against.
|
|
160
|
+
if (!forward.settleTimer) {
|
|
161
|
+
forward.settleTimer = setTimeout(() => {
|
|
162
|
+
if (!forward.closed && forward.info.status === 'connecting')
|
|
163
|
+
this.succeed(forward);
|
|
164
|
+
}, this.settleMs);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
succeed(forward) {
|
|
168
|
+
clearTimeout(forward.settleTimer);
|
|
169
|
+
forward.settleTimer = undefined;
|
|
170
|
+
forward.info.status = 'open';
|
|
171
|
+
delete forward.info.message;
|
|
172
|
+
this.emitStatus(forward);
|
|
173
|
+
const settle = forward.settle;
|
|
174
|
+
forward.settle = null;
|
|
175
|
+
settle?.resolve({ ...forward.info });
|
|
176
|
+
}
|
|
177
|
+
fail(forward, reason) {
|
|
178
|
+
const settle = forward.settle;
|
|
179
|
+
forward.settle = null;
|
|
180
|
+
forward.info.status = 'error';
|
|
181
|
+
forward.info.message = reason;
|
|
182
|
+
this.emitStatus(forward);
|
|
183
|
+
this.destroy(forward, reason, { keepStatus: true });
|
|
184
|
+
if (settle)
|
|
185
|
+
settle.reject(new Error(reason));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The client exited on its own.
|
|
189
|
+
*
|
|
190
|
+
* With ExitOnForwardFailure this is how a forward that could not bind reports
|
|
191
|
+
* itself, so the buffer is worth one more read before falling back to a
|
|
192
|
+
* generic message.
|
|
193
|
+
*/
|
|
194
|
+
handleExit(forward) {
|
|
195
|
+
if (forward.closed)
|
|
196
|
+
return;
|
|
197
|
+
const outcome = readForwardOutcome(forward.buffer, forward.info.direction);
|
|
198
|
+
const reason = outcome?.kind === 'failed'
|
|
199
|
+
? outcome.reason
|
|
200
|
+
: forward.info.status === 'open'
|
|
201
|
+
? 'The connection closed.'
|
|
202
|
+
: `Could not share port ${forward.info.listenPort}.`;
|
|
203
|
+
this.fail(forward, reason);
|
|
204
|
+
}
|
|
205
|
+
destroy(forward, message, options = {}) {
|
|
206
|
+
if (forward.closed)
|
|
207
|
+
return;
|
|
208
|
+
forward.closed = true;
|
|
209
|
+
clearTimeout(forward.settleTimer);
|
|
210
|
+
clearTimeout(forward.firstByteTimer);
|
|
211
|
+
try {
|
|
212
|
+
forward.pty.kill();
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
/* already gone */
|
|
216
|
+
}
|
|
217
|
+
this.forwards.delete(forward.info.id);
|
|
218
|
+
lastQuestion.delete(forward.info.id);
|
|
219
|
+
if (!options.keepStatus) {
|
|
220
|
+
forward.info.status = 'idle';
|
|
221
|
+
this.emitStatus(forward);
|
|
222
|
+
}
|
|
223
|
+
this.onEvent({ type: 'closed', forwardId: forward.info.id, message });
|
|
224
|
+
}
|
|
225
|
+
emitStatus(forward) {
|
|
226
|
+
this.onEvent({ type: 'status', forward: { ...forward.info } });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** The last question each tunnel asked, so it is only surfaced once. */
|
|
230
|
+
const lastQuestion = new Map();
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
const SCHEMA_VERSION = 1;
|
|
5
|
+
const MAX_FORWARDS = 64;
|
|
6
|
+
/**
|
|
7
|
+
* Main-process-only, best-effort memory of which ports were shared.
|
|
8
|
+
*
|
|
9
|
+
* Stores where a tunnel went and how wide it was bound — never a credential, a
|
|
10
|
+
* cwd, or command arguments — the same guarantee SessionHistoryStore and
|
|
11
|
+
* WorkspaceStore make, and for the same reason: this is plain JSON in the user's
|
|
12
|
+
* profile.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here reopens a tunnel. Restored forwards come back as rows waiting to
|
|
15
|
+
* be clicked, so launching the app never dials out to a host on its own.
|
|
16
|
+
*/
|
|
17
|
+
export class PortForwardStore {
|
|
18
|
+
filePath;
|
|
19
|
+
file = { version: SCHEMA_VERSION, forwards: [] };
|
|
20
|
+
loaded = false;
|
|
21
|
+
writeQueue = Promise.resolve();
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.filePath = options.filePath;
|
|
24
|
+
}
|
|
25
|
+
async load() {
|
|
26
|
+
await this.ensureLoaded();
|
|
27
|
+
return normalizeFile(this.file) ?? { version: SCHEMA_VERSION, forwards: [] };
|
|
28
|
+
}
|
|
29
|
+
async save(input) {
|
|
30
|
+
await this.ensureLoaded();
|
|
31
|
+
// Validated on the way in as well as out: the renderer is the only caller,
|
|
32
|
+
// but this is an IPC boundary, and a stored bad value would come back on
|
|
33
|
+
// every launch from then on.
|
|
34
|
+
const normalized = normalizeFile(input);
|
|
35
|
+
if (!normalized)
|
|
36
|
+
throw new Error('Invalid shared port list.');
|
|
37
|
+
this.file = normalized;
|
|
38
|
+
await this.persist();
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
async ensureLoaded() {
|
|
42
|
+
if (this.loaded)
|
|
43
|
+
return;
|
|
44
|
+
this.loaded = true;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
47
|
+
const normalized = normalizeFile(parsed);
|
|
48
|
+
if (normalized)
|
|
49
|
+
this.file = normalized;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
this.file = { version: SCHEMA_VERSION, forwards: [] };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async persist() {
|
|
56
|
+
const snapshot = this.file;
|
|
57
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
58
|
+
try {
|
|
59
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
60
|
+
const temp = join(dirname(this.filePath), `.port-forwards.tmp-${process.pid}-${randomUUID()}`);
|
|
61
|
+
await writeFile(temp, JSON.stringify(snapshot, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
62
|
+
await rename(temp, this.filePath);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Remembering a port must never affect terminal operation.
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await this.writeQueue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Strip control characters and cap length; the file is user-editable. */
|
|
72
|
+
function safeText(value, limit = 256) {
|
|
73
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, limit);
|
|
74
|
+
}
|
|
75
|
+
function port(value) {
|
|
76
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535)
|
|
77
|
+
return undefined;
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function normalizeForward(value) {
|
|
81
|
+
if (!value || typeof value !== 'object')
|
|
82
|
+
return undefined;
|
|
83
|
+
const item = value;
|
|
84
|
+
if (typeof item.id !== 'string' || typeof item.target !== 'string')
|
|
85
|
+
return undefined;
|
|
86
|
+
if (item.direction !== 'local' && item.direction !== 'remote')
|
|
87
|
+
return undefined;
|
|
88
|
+
// A missing or unrecognised bind reads as loopback, never as the wide one: a
|
|
89
|
+
// hand-edited or truncated file must not be able to widen a forward silently.
|
|
90
|
+
const bind = item.bind === 'all' ? 'all' : 'loopback';
|
|
91
|
+
const listenPort = port(item.listenPort);
|
|
92
|
+
const destinationPort = port(item.destinationPort);
|
|
93
|
+
const id = safeText(item.id, 64);
|
|
94
|
+
const target = safeText(item.target, 256);
|
|
95
|
+
if (!id || !target || listenPort === undefined || destinationPort === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
const forward = { id, target, direction: item.direction, listenPort, destinationPort, bind };
|
|
98
|
+
// Left off entirely when absent, so the protocol's own default applies rather
|
|
99
|
+
// than an empty string reaching the forward spec.
|
|
100
|
+
const destinationHost = typeof item.destinationHost === 'string' ? safeText(item.destinationHost, 253) : '';
|
|
101
|
+
if (destinationHost)
|
|
102
|
+
forward.destinationHost = destinationHost;
|
|
103
|
+
return forward;
|
|
104
|
+
}
|
|
105
|
+
export function normalizeFile(value) {
|
|
106
|
+
if (!value || typeof value !== 'object')
|
|
107
|
+
return undefined;
|
|
108
|
+
const item = value;
|
|
109
|
+
if (item.version !== SCHEMA_VERSION)
|
|
110
|
+
return undefined;
|
|
111
|
+
if (!Array.isArray(item.forwards))
|
|
112
|
+
return undefined;
|
|
113
|
+
const forwards = [];
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
for (const raw of item.forwards) {
|
|
116
|
+
const forward = normalizeForward(raw);
|
|
117
|
+
if (!forward || seen.has(forward.id))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(forward.id);
|
|
120
|
+
forwards.push(forward);
|
|
121
|
+
if (forwards.length >= MAX_FORWARDS)
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
return { version: SCHEMA_VERSION, forwards };
|
|
125
|
+
}
|
|
126
|
+
/** What is worth remembering about a live tunnel: everything but how it is doing. */
|
|
127
|
+
export function forgetStatus(forward) {
|
|
128
|
+
const { status, message, ...rest } = forward;
|
|
129
|
+
return rest;
|
|
130
|
+
}
|
|
131
|
+
export function defaultPortForwardPath(userDataPath) {
|
|
132
|
+
return join(userDataPath, 'port-forwards.json');
|
|
133
|
+
}
|
|
@@ -4,6 +4,23 @@ const api = {
|
|
|
4
4
|
listSessions: () => ipcRenderer.invoke('sessions:list'),
|
|
5
5
|
listHistory: () => ipcRenderer.invoke('sessions:history'),
|
|
6
6
|
removeHistory: (entryId) => ipcRenderer.invoke('sessions:historyRemove', entryId),
|
|
7
|
+
listCommandHistory: () => ipcRenderer.invoke('commands:list'),
|
|
8
|
+
recordCommand: (record) => ipcRenderer.invoke('commands:record', record),
|
|
9
|
+
pickCommand: (id) => ipcRenderer.invoke('commands:pick', id),
|
|
10
|
+
clearCommandHistory: () => ipcRenderer.invoke('commands:clear'),
|
|
11
|
+
listForwards: () => ipcRenderer.invoke('forwards:list'),
|
|
12
|
+
openForward: (request) => ipcRenderer.invoke('forwards:open', request),
|
|
13
|
+
closeForward: (id) => ipcRenderer.invoke('forwards:close', id),
|
|
14
|
+
answerForwardPrompt: (id, answer) => ipcRenderer.invoke('forwards:answerPrompt', id, answer),
|
|
15
|
+
loadForwards: () => ipcRenderer.invoke('forwards:load'),
|
|
16
|
+
saveForwards: (file) => ipcRenderer.invoke('forwards:save', file),
|
|
17
|
+
onForwardEvent: (callback) => {
|
|
18
|
+
const listener = (_event, payload) => callback(payload);
|
|
19
|
+
ipcRenderer.on('forwards:event', listener);
|
|
20
|
+
return () => ipcRenderer.removeListener('forwards:event', listener);
|
|
21
|
+
},
|
|
22
|
+
loadWorkspaces: () => ipcRenderer.invoke('workspaces:load'),
|
|
23
|
+
saveWorkspaces: (file) => ipcRenderer.invoke('workspaces:save', file),
|
|
7
24
|
listBackends: () => ipcRenderer.invoke('sessions:backends'),
|
|
8
25
|
listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
|
|
9
26
|
createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
|
|
@@ -27,9 +44,17 @@ const api = {
|
|
|
27
44
|
ipcRenderer.on('terminal:status', listener);
|
|
28
45
|
return () => ipcRenderer.removeListener('terminal:status', listener);
|
|
29
46
|
},
|
|
30
|
-
requestAiCommand: () => ipcRenderer.invoke('ai:suggest'),
|
|
47
|
+
requestAiCommand: (config, request) => ipcRenderer.invoke('ai:suggest', config, request),
|
|
48
|
+
listAiModels: (baseUrl) => ipcRenderer.invoke('ai:models', baseUrl),
|
|
49
|
+
testAiEndpoint: (config) => ipcRenderer.invoke('ai:test', config),
|
|
50
|
+
cancelAiRequest: () => ipcRenderer.invoke('ai:cancel'),
|
|
51
|
+
aiApiKeyStatus: () => ipcRenderer.invoke('aiKey:status'),
|
|
52
|
+
saveAiApiKey: (key) => ipcRenderer.invoke('aiKey:save', key),
|
|
53
|
+
clearAiApiKey: () => ipcRenderer.invoke('aiKey:clear'),
|
|
31
54
|
listLocalDirectory: (path) => ipcRenderer.invoke('fs:listLocal', path),
|
|
55
|
+
appVersion: () => ipcRenderer.invoke('app:version'),
|
|
32
56
|
localHome: () => ipcRenderer.invoke('fs:localHome'),
|
|
57
|
+
wslHome: (distribution) => ipcRenderer.invoke('fs:wslHome', distribution),
|
|
33
58
|
createLocalDirectory: (path) => ipcRenderer.invoke('fs:mkdirLocal', path),
|
|
34
59
|
renameLocalEntry: (from, to) => ipcRenderer.invoke('fs:renameLocal', from, to),
|
|
35
60
|
removeLocalEntry: (path, kind) => ipcRenderer.invoke('fs:removeLocal', path, kind),
|
|
@@ -47,6 +72,10 @@ const api = {
|
|
|
47
72
|
ipcRenderer.on('sftp:event', listener);
|
|
48
73
|
return () => ipcRenderer.removeListener('sftp:event', listener);
|
|
49
74
|
},
|
|
75
|
+
speechApiKeyStatus: () => ipcRenderer.invoke('speechKey:status'),
|
|
76
|
+
saveSpeechApiKey: (key) => ipcRenderer.invoke('speechKey:save', key),
|
|
77
|
+
clearSpeechApiKey: () => ipcRenderer.invoke('speechKey:clear'),
|
|
78
|
+
readSpeechApiKey: () => ipcRenderer.invoke('speechKey:read'),
|
|
50
79
|
openExternal: (url) => ipcRenderer.invoke('links:openExternal', url),
|
|
51
80
|
onLinkRefused: (callback) => {
|
|
52
81
|
const listener = (_event, reason) => callback(reason);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Named secrets, encrypted at rest by the operating system.
|
|
2
|
+
//
|
|
3
|
+
// The renderer keeps its settings in localStorage, which is a plain file on
|
|
4
|
+
// disk: fine for a font choice, wrong for an API key. So keys live here
|
|
5
|
+
// instead, in the main process, encrypted through Electron's safeStorage —
|
|
6
|
+
// DPAPI on Windows, Keychain on macOS, libsecret or kwallet on Linux. The
|
|
7
|
+
// ciphertext file is useless to another user account on the same machine, and
|
|
8
|
+
// nothing readable is written anywhere the renderer can reach.
|
|
9
|
+
//
|
|
10
|
+
// safeStorage can report that encryption is unavailable, which happens on a
|
|
11
|
+
// Linux box with no keyring configured. When it does, storing is refused
|
|
12
|
+
// rather than quietly downgraded to plaintext: a key the user believes is
|
|
13
|
+
// protected must not be written in the clear. The caller is told, and can
|
|
14
|
+
// offer to hold the key in memory for the session instead.
|
|
15
|
+
//
|
|
16
|
+
// The crypto is injected so this module can be tested without Electron.
|
|
17
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { randomUUID } from 'node:crypto';
|
|
20
|
+
const SCHEMA_VERSION = 1;
|
|
21
|
+
/** Thrown when the platform cannot encrypt, so nothing was written. */
|
|
22
|
+
export class EncryptionUnavailableError extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super('This system has no secret store available, so the key cannot be saved safely.');
|
|
25
|
+
this.name = 'EncryptionUnavailableError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class SecretStore {
|
|
29
|
+
filePath;
|
|
30
|
+
crypto;
|
|
31
|
+
/** Serialised so two saves in flight cannot interleave read-modify-write. */
|
|
32
|
+
writeQueue = Promise.resolve();
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.filePath = options.filePath;
|
|
35
|
+
this.crypto = options.crypto;
|
|
36
|
+
}
|
|
37
|
+
/** Can this platform encrypt at all? Asked before offering to store a key. */
|
|
38
|
+
encryptionAvailable() {
|
|
39
|
+
try {
|
|
40
|
+
return this.crypto.isEncryptionAvailable();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Is a secret stored under this name? Does not decrypt it. */
|
|
47
|
+
async has(name) {
|
|
48
|
+
const secrets = await this.read();
|
|
49
|
+
return typeof secrets[name] === 'string';
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The stored secret, or null when there is none.
|
|
53
|
+
*
|
|
54
|
+
* A value that will not decrypt is treated as absent: that is what a file
|
|
55
|
+
* copied from another machine or another user account looks like, and there
|
|
56
|
+
* is nothing to do with it but ask for the key again.
|
|
57
|
+
*/
|
|
58
|
+
async get(name) {
|
|
59
|
+
const stored = (await this.read())[name];
|
|
60
|
+
if (typeof stored !== 'string')
|
|
61
|
+
return null;
|
|
62
|
+
try {
|
|
63
|
+
const value = this.crypto.decryptString(Buffer.from(stored, 'base64'));
|
|
64
|
+
return value || null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Store a secret, replacing any previous one of that name. */
|
|
71
|
+
async set(name, value) {
|
|
72
|
+
if (!value) {
|
|
73
|
+
await this.clear(name);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!this.encryptionAvailable())
|
|
77
|
+
throw new EncryptionUnavailableError();
|
|
78
|
+
const encrypted = this.crypto.encryptString(value).toString('base64');
|
|
79
|
+
await this.update((secrets) => ({ ...secrets, [name]: encrypted }));
|
|
80
|
+
}
|
|
81
|
+
/** Forget a secret. Returns whether there was one. */
|
|
82
|
+
async clear(name) {
|
|
83
|
+
let existed = false;
|
|
84
|
+
await this.update((secrets) => {
|
|
85
|
+
existed = typeof secrets[name] === 'string';
|
|
86
|
+
const next = { ...secrets };
|
|
87
|
+
delete next[name];
|
|
88
|
+
return next;
|
|
89
|
+
});
|
|
90
|
+
return existed;
|
|
91
|
+
}
|
|
92
|
+
async read() {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
95
|
+
if (!isSecretsFile(parsed))
|
|
96
|
+
return {};
|
|
97
|
+
return parsed.secrets;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// No file yet, or one this version cannot read: no secrets stored.
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async update(change) {
|
|
105
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
106
|
+
const secrets = change(await this.read());
|
|
107
|
+
const snapshot = { version: SCHEMA_VERSION, secrets };
|
|
108
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
109
|
+
if (Object.keys(secrets).length === 0) {
|
|
110
|
+
// Nothing left to keep: remove the file rather than leaving an empty
|
|
111
|
+
// one behind, so "no key stored" looks the same as never having one.
|
|
112
|
+
await unlink(this.filePath).catch(() => undefined);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const temp = join(dirname(this.filePath), `.secrets.tmp-${process.pid}-${randomUUID()}`);
|
|
116
|
+
// 0600, and written whole then renamed, so a crash cannot leave a
|
|
117
|
+
// half-written key behind for the next read to find.
|
|
118
|
+
await writeFile(temp, JSON.stringify(snapshot, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
119
|
+
try {
|
|
120
|
+
await rename(temp, this.filePath);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
// The temp file holds the same ciphertext under the same 0600 as the
|
|
124
|
+
// destination, so this is tidiness rather than a leak — but an orphan
|
|
125
|
+
// per failed write, each with a key the user may since have rotated,
|
|
126
|
+
// is not something to leave lying around.
|
|
127
|
+
await unlink(temp).catch(() => undefined);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
await this.writeQueue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function isSecretsFile(value) {
|
|
135
|
+
if (typeof value !== 'object' || value === null)
|
|
136
|
+
return false;
|
|
137
|
+
const file = value;
|
|
138
|
+
if (file.version !== SCHEMA_VERSION)
|
|
139
|
+
return false;
|
|
140
|
+
if (typeof file.secrets !== 'object' || file.secrets === null)
|
|
141
|
+
return false;
|
|
142
|
+
return Object.values(file.secrets).every((entry) => typeof entry === 'string');
|
|
143
|
+
}
|
|
144
|
+
export function defaultSecretsPath(userDataPath) {
|
|
145
|
+
return join(userDataPath, 'secrets.json');
|
|
146
|
+
}
|
|
147
|
+
/** The name the speech server key is stored under. */
|
|
148
|
+
export const SPEECH_API_KEY = 'speech.serverApiKey';
|
|
149
|
+
/**
|
|
150
|
+
* The name the AI endpoint key is stored under.
|
|
151
|
+
*
|
|
152
|
+
* Separate from the speech key: the two point at different services often
|
|
153
|
+
* enough — a local Ollama and a hosted transcription server, say — that sharing
|
|
154
|
+
* one key would be wrong more often than right.
|
|
155
|
+
*/
|
|
156
|
+
export const AI_API_KEY = 'ai.serverApiKey';
|
|
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { defaultShellBackend, findOpenSshTool, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
|
|
6
|
+
import { WSL_HOME, defaultShellBackend, findOpenSshTool, isLocalShellBackend, ptyStartDirectory, resolveShellBackend, withWslStartDirectory } from './shell-catalog.js';
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const require = createRequire(import.meta.url);
|
|
9
9
|
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
|
|
@@ -136,6 +136,14 @@ function loadPty() {
|
|
|
136
136
|
export class ScreenService {
|
|
137
137
|
ptys = new Map();
|
|
138
138
|
sshSessions = new Map();
|
|
139
|
+
/**
|
|
140
|
+
* The argv each fallback local session was started with.
|
|
141
|
+
*
|
|
142
|
+
* Held apart from SessionInfo because it is not session metadata anyone else
|
|
143
|
+
* needs, and because the reported cwd moves as the shell does — a reattach
|
|
144
|
+
* must not rebuild the arguments from a directory that has since changed.
|
|
145
|
+
*/
|
|
146
|
+
launchArgs = new Map();
|
|
139
147
|
fallbackLocalSessions = new Map();
|
|
140
148
|
onEvent;
|
|
141
149
|
/** Injectable so tests can exercise routing and teardown without a real shell. */
|
|
@@ -195,14 +203,20 @@ export class ScreenService {
|
|
|
195
203
|
? resolveShellBackend(request.backend, request.wslDistribution)
|
|
196
204
|
: defaultShellBackend();
|
|
197
205
|
const backend = shell.backend;
|
|
198
|
-
|
|
206
|
+
// A WSL shell starts where its own `--cd` argument says, not where the pty
|
|
207
|
+
// was launched from, so its directory is a Linux path inside the distro
|
|
208
|
+
// rather than the Windows one this side would otherwise report. Every other
|
|
209
|
+
// backend does start where the pty does.
|
|
210
|
+
const requestedCwd = request.cwd ?? (backend === 'wsl' ? WSL_HOME : homedir());
|
|
211
|
+
const launch = request.cwd ? withWslStartDirectory(shell, request.cwd) : shell;
|
|
199
212
|
if (!(await this.available())) {
|
|
200
213
|
const fallback = { id: `local:${safeName}`, name: safeName, kind: 'local', host: 'local', cwd: requestedCwd, status: 'detached', lastSeen: new Date().toISOString(), persistence: 'process', backend, scope: 'local', source: 'active', wslDistribution: shell.wslDistribution };
|
|
201
214
|
this.fallbackLocalSessions.set(fallback.id, fallback);
|
|
215
|
+
this.launchArgs.set(fallback.id, launch.args);
|
|
202
216
|
this.onEvent?.('created', fallback, true);
|
|
203
217
|
return fallback;
|
|
204
218
|
}
|
|
205
|
-
await execFileAsync('screen', ['-dmS', safeName,
|
|
219
|
+
await execFileAsync('screen', ['-dmS', safeName, launch.executable, ...launch.args], { cwd: ptyStartDirectory(requestedCwd) });
|
|
206
220
|
const session = { id: `local:${safeName}`, name: safeName, kind: 'local', host: 'local', cwd: requestedCwd, status: 'detached', lastSeen: new Date().toISOString(), persistence: 'screen', backend: 'screen', scope: 'local', source: 'active', screenName: safeName, wslDistribution: shell.wslDistribution };
|
|
207
221
|
this.onEvent?.('created', session, true);
|
|
208
222
|
return session;
|
|
@@ -252,7 +266,10 @@ export class ScreenService {
|
|
|
252
266
|
const shell = isLocalShellBackend(fallback.backend)
|
|
253
267
|
? resolveShellBackend(fallback.backend, fallback.wslDistribution)
|
|
254
268
|
: defaultShellBackend();
|
|
255
|
-
this
|
|
269
|
+
// The arguments this session was created with, so a WSL pane reattaches
|
|
270
|
+
// to the same directory rather than losing its `--cd`.
|
|
271
|
+
const args = this.launchArgs.get(id) ?? shell.args;
|
|
272
|
+
this.spawnCommand(id, shell.executable, args, onData, onExit, ptyStartDirectory(fallback.cwd), size);
|
|
256
273
|
this.onEvent?.('attached', fallback, true);
|
|
257
274
|
return { ...fallback };
|
|
258
275
|
}
|
|
@@ -384,6 +401,7 @@ export class ScreenService {
|
|
|
384
401
|
this.detach(sessionId);
|
|
385
402
|
this.sshSessions.delete(sessionId);
|
|
386
403
|
this.fallbackLocalSessions.delete(sessionId);
|
|
404
|
+
this.launchArgs.delete(sessionId);
|
|
387
405
|
this.onEvent?.('closed', session, false);
|
|
388
406
|
}
|
|
389
407
|
}
|