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.
@@ -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
- const requestedCwd = request.cwd ?? homedir();
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, shell.executable, ...shell.args], { cwd: requestedCwd });
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.spawnCommand(id, shell.executable, shell.args, onData, onExit, fallback.cwd, size);
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
  }