zerogterm 0.7.0-alpha2 → 0.7.0-alpha3

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,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
  }
@@ -15,8 +15,69 @@
15
15
  // absolute path, which is what node-pty needs on Windows — it does not apply
16
16
  // PATHEXT, so a bare `bash` fails there with "File not found:".
17
17
  import { statSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
18
19
  /** Distro names reach `wsl.exe -d <name>`; a leading '-' would read as a flag. */
19
20
  const WSL_DISTRIBUTION = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/;
21
+ /**
22
+ * Where a WSL shell should start.
23
+ *
24
+ * `wsl.exe` inherits the Windows directory it was launched from, so without this
25
+ * a new WSL pane opens in `/mnt/c/Users/<name>` — the Windows home seen through
26
+ * the mount, which is almost never where anyone wants to be working. `--cd`
27
+ * takes a Linux path, and `~` is the distro user's own home.
28
+ *
29
+ * Passed as a separate argv element, never through a shell, so the tilde reaches
30
+ * `wsl.exe` literally rather than being expanded on the way.
31
+ */
32
+ export const WSL_HOME = '~';
33
+ /**
34
+ * `--cd` needs WSL 0.51.2 or newer — Windows 11, or the Store build on Windows
35
+ * 10. On an older inbox WSL the flag is rejected and the pane shows that, which
36
+ * is visible rather than silent. Judged worth it: the flag is four years old,
37
+ * Windows 10 is out of support, and the alternative was a capability probe on
38
+ * every session for a configuration this project does not target.
39
+ */
40
+ function wslStartArgs(startDirectory = WSL_HOME) {
41
+ return ['--cd', startDirectory];
42
+ }
43
+ /**
44
+ * Point a resolved WSL shell at a directory other than the distro's home.
45
+ *
46
+ * Kept separate from resolveShellBackend because that is also what a reattach
47
+ * uses, where the right answer is the home directory again — a fresh shell, not
48
+ * wherever the last one wandered to.
49
+ */
50
+ export function withWslStartDirectory(shell, startDirectory) {
51
+ if (shell.backend !== 'wsl')
52
+ return shell;
53
+ const args = [...shell.args];
54
+ const at = args.indexOf('--cd');
55
+ if (at >= 0 && at + 1 < args.length)
56
+ args[at + 1] = startDirectory;
57
+ else
58
+ args.push(...wslStartArgs(startDirectory));
59
+ return { ...shell, args };
60
+ }
61
+ /**
62
+ * A directory a pty can actually be started in.
63
+ *
64
+ * A session reports the directory its *shell* is in, which for WSL is a path
65
+ * inside the distro — `~`, or `/home/name` once the shell says so. node-pty
66
+ * hands its cwd straight to the OS, and neither of those is a directory Windows
67
+ * can start a process in. Where the shell itself begins is settled by its
68
+ * arguments, so anything unusable here falls back to the home directory rather
69
+ * than failing the spawn.
70
+ */
71
+ export function ptyStartDirectory(cwd, options = {}) {
72
+ const home = options.home ?? homedir();
73
+ if (!cwd)
74
+ return home;
75
+ const windows = (options.platform ?? process.platform) === 'win32';
76
+ const usable = windows
77
+ ? /^[A-Za-z]:[\\/]/.test(cwd) || cwd.startsWith('\\\\')
78
+ : cwd.startsWith('/');
79
+ return usable ? cwd : home;
80
+ }
20
81
  const LOCAL_SHELL_BACKENDS = [
21
82
  'bash', 'zsh', 'fish', 'sh', 'powershell', 'pwsh', 'cmd', 'wsl'
22
83
  ];
@@ -155,7 +216,7 @@ function shellCandidates(windows) {
155
216
  { backend: 'powershell', label: 'Windows PowerShell', command: 'powershell.exe' },
156
217
  { backend: 'pwsh', label: 'PowerShell 7', command: 'pwsh.exe' },
157
218
  { backend: 'cmd', label: 'Command Prompt', command: 'cmd.exe' },
158
- { backend: 'wsl', label: 'WSL', command: 'wsl.exe' },
219
+ { backend: 'wsl', label: 'WSL', command: 'wsl.exe', args: wslStartArgs() },
159
220
  // Git for Windows ships bash; useful, but not a login shell for the OS.
160
221
  { backend: 'bash', label: 'Git Bash', command: 'bash.exe' }
161
222
  ];
@@ -234,5 +295,5 @@ export function resolveShellBackend(backend, distribution, options = {}) {
234
295
  if (!WSL_DISTRIBUTION.test(trimmed)) {
235
296
  throw new Error('WSL distribution names may contain letters, numbers, spaces, _, ., and - only.');
236
297
  }
237
- return { ...match, args: ['-d', trimmed], label: `WSL · ${trimmed}`, wslDistribution: trimmed };
298
+ return { ...match, args: ['-d', trimmed, ...wslStartArgs()], label: `WSL · ${trimmed}`, wslDistribution: trimmed };
238
299
  }
@@ -2,6 +2,17 @@ import { homedir } from 'node:os';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
4
4
  const HOST = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,253}$/;
5
+ /**
6
+ * Is this a hostname ZeroG will put in front of an SSH client?
7
+ *
8
+ * Exported so the port-forwarding side vets the far end of a tunnel by the same
9
+ * rule that vets a HostName here, rather than carrying a second copy of it that
10
+ * can drift. The leading-alphanumeric requirement is the load-bearing part: the
11
+ * value ends up in an argv element, and one starting with '-' reads as an option.
12
+ */
13
+ export function isSshHostName(value) {
14
+ return HOST.test(value);
15
+ }
5
16
  // Must start alphanumeric like TOKEN/HOST above: the user is concatenated into
6
17
  // `user@host`, so a leading '-' makes the whole destination look like an option
7
18
  // to ssh's getopt (`-Fevil.cfg@host` reads an attacker-chosen config file).
@@ -0,0 +1,213 @@
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_WORKSPACES = 32;
6
+ const MAX_MEMBERS = 4;
7
+ /**
8
+ * Main-process-only, best-effort workspace persistence.
9
+ *
10
+ * Never stores cwd, command arguments, or credentials — the same guarantee
11
+ * SessionHistoryStore makes, and for the same reason: this file sits in plain
12
+ * JSON in the user's profile.
13
+ */
14
+ export class WorkspaceStore {
15
+ filePath;
16
+ file = { version: SCHEMA_VERSION, workspaces: [] };
17
+ loaded = false;
18
+ writeQueue = Promise.resolve();
19
+ constructor(options) {
20
+ this.filePath = options.filePath;
21
+ }
22
+ async load() {
23
+ await this.ensureLoaded();
24
+ return normalizeFile(this.file) ?? { version: SCHEMA_VERSION, workspaces: [] };
25
+ }
26
+ async save(input) {
27
+ await this.ensureLoaded();
28
+ // Validate on the way in as well as out. The renderer is the only caller,
29
+ // but this is an IPC boundary and a stored bad value would come back on
30
+ // every launch from then on.
31
+ const normalized = normalizeFile(input);
32
+ if (!normalized)
33
+ throw new Error('Invalid workspace layout.');
34
+ this.file = normalized;
35
+ await this.persist();
36
+ return normalized;
37
+ }
38
+ async ensureLoaded() {
39
+ if (this.loaded)
40
+ return;
41
+ this.loaded = true;
42
+ try {
43
+ const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
44
+ const normalized = normalizeFile(parsed);
45
+ if (normalized)
46
+ this.file = normalized;
47
+ }
48
+ catch {
49
+ // Missing, unreadable, or corrupt: start from an empty set rather than
50
+ // leaving the app unable to open a window.
51
+ this.file = { version: SCHEMA_VERSION, workspaces: [] };
52
+ }
53
+ }
54
+ async persist() {
55
+ const snapshot = this.file;
56
+ this.writeQueue = this.writeQueue.then(async () => {
57
+ try {
58
+ await mkdir(dirname(this.filePath), { recursive: true });
59
+ const temp = join(dirname(this.filePath), `.workspaces.tmp-${process.pid}-${randomUUID()}`);
60
+ await writeFile(temp, JSON.stringify(snapshot, null, 2), { encoding: 'utf8', mode: 0o600 });
61
+ await rename(temp, this.filePath);
62
+ }
63
+ catch {
64
+ // Layout memory must never affect terminal operation.
65
+ }
66
+ });
67
+ await this.writeQueue;
68
+ }
69
+ }
70
+ const LAYOUTS = ['stack', 'split-v', 'split-h', 'grid'];
71
+ const SPLITS = ['split-v', 'split-h', 'grid'];
72
+ /** Strip control characters and cap length; the file is user-editable. */
73
+ function safeText(value, limit = 256) {
74
+ return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, limit);
75
+ }
76
+ function optionalText(value, limit = 256) {
77
+ if (typeof value !== 'string')
78
+ return undefined;
79
+ const text = safeText(value, limit);
80
+ return text ? text : undefined;
81
+ }
82
+ function normalizeMember(value) {
83
+ if (!value || typeof value !== 'object')
84
+ return undefined;
85
+ const item = value;
86
+ if (typeof item.sessionId !== 'string' || typeof item.name !== 'string')
87
+ return undefined;
88
+ if (item.kind !== 'local' && item.kind !== 'ssh')
89
+ return undefined;
90
+ const sessionId = safeText(item.sessionId);
91
+ const name = safeText(item.name, 64);
92
+ if (!sessionId || !name)
93
+ return undefined;
94
+ const member = { sessionId, kind: item.kind, name };
95
+ const host = optionalText(item.host);
96
+ const screenName = optionalText(item.screenName, 64);
97
+ const sshTarget = optionalText(item.sshTarget);
98
+ const backend = optionalText(item.backend, 32);
99
+ if (host)
100
+ member.host = host;
101
+ if (screenName)
102
+ member.screenName = screenName;
103
+ if (sshTarget)
104
+ member.sshTarget = sshTarget;
105
+ if (backend)
106
+ member.backend = backend;
107
+ return member;
108
+ }
109
+ function normalizeView(value, memberIds) {
110
+ const item = (value && typeof value === 'object' ? value : {});
111
+ const layout = typeof item.layout === 'string' && LAYOUTS.includes(item.layout) ? item.layout : 'stack';
112
+ const lastSplit = typeof item.lastSplit === 'string' && SPLITS.includes(item.lastSplit) ? item.lastSplit : 'split-v';
113
+ // A view may only name sessions the workspace actually holds, so a hand-edited
114
+ // file cannot point a pane at something that is not there.
115
+ const member = (candidate) => {
116
+ const text = optionalText(candidate);
117
+ return text && memberIds.has(text) ? text : undefined;
118
+ };
119
+ const view = { layout, lastSplit, maximizedSessionId: member(item.maximizedSessionId) ?? null };
120
+ const active = member(item.activeSessionId);
121
+ const focused = member(item.focusedSessionId);
122
+ if (active)
123
+ view.activeSessionId = active;
124
+ if (focused)
125
+ view.focusedSessionId = focused;
126
+ const browsers = normalizeBrowsers(item.browsers, memberIds);
127
+ if (browsers)
128
+ view.browsers = browsers;
129
+ return view;
130
+ }
131
+ /**
132
+ * Directory browser state, keyed only by panes the workspace holds.
133
+ *
134
+ * The same rule the pane references above follow: a hand-edited file may not
135
+ * carry state for a session this workspace does not own. A malformed entry is
136
+ * dropped rather than repaired — a pane with no entry shows no browser, which is
137
+ * the safe reading of a file we cannot trust.
138
+ */
139
+ function normalizeBrowsers(value, memberIds) {
140
+ if (!value || typeof value !== 'object' || Array.isArray(value))
141
+ return undefined;
142
+ const out = {};
143
+ for (const [id, raw] of Object.entries(value)) {
144
+ if (!memberIds.has(id) || !raw || typeof raw !== 'object')
145
+ continue;
146
+ const entry = raw;
147
+ if (typeof entry.open !== 'boolean')
148
+ continue;
149
+ const ratio = typeof entry.ratio === 'number' && Number.isFinite(entry.ratio) && entry.ratio > 0 && entry.ratio < 100
150
+ ? entry.ratio
151
+ : undefined;
152
+ out[id] = ratio === undefined ? { open: entry.open } : { open: entry.open, ratio };
153
+ }
154
+ return Object.keys(out).length ? out : undefined;
155
+ }
156
+ function normalizeWorkspace(value) {
157
+ if (!value || typeof value !== 'object')
158
+ return undefined;
159
+ const item = value;
160
+ if (typeof item.id !== 'string' || typeof item.name !== 'string')
161
+ return undefined;
162
+ const id = safeText(item.id, 64);
163
+ const name = safeText(item.name, 64);
164
+ if (!id || !name)
165
+ return undefined;
166
+ const rawMembers = Array.isArray(item.members) ? item.members : [];
167
+ const members = [];
168
+ const seen = new Set();
169
+ for (const raw of rawMembers) {
170
+ const member = normalizeMember(raw);
171
+ // One pane per session: a duplicated id would render the same terminal
172
+ // twice and the two panes would fight over the pty size.
173
+ if (!member || seen.has(member.sessionId))
174
+ continue;
175
+ seen.add(member.sessionId);
176
+ members.push(member);
177
+ if (members.length >= MAX_MEMBERS)
178
+ break;
179
+ }
180
+ return { id, name, members, view: normalizeView(item.view, seen) };
181
+ }
182
+ export function normalizeFile(value) {
183
+ if (!value || typeof value !== 'object')
184
+ return undefined;
185
+ const item = value;
186
+ if (item.version !== SCHEMA_VERSION)
187
+ return undefined;
188
+ if (!Array.isArray(item.workspaces))
189
+ return undefined;
190
+ const workspaces = [];
191
+ const seen = new Set();
192
+ for (const raw of item.workspaces) {
193
+ const workspace = normalizeWorkspace(raw);
194
+ if (!workspace || seen.has(workspace.id))
195
+ continue;
196
+ seen.add(workspace.id);
197
+ workspaces.push(workspace);
198
+ if (workspaces.length >= MAX_WORKSPACES)
199
+ break;
200
+ }
201
+ const file = { version: SCHEMA_VERSION, workspaces };
202
+ // An active id naming nothing would leave the app with no workspace on
203
+ // screen, so it falls back to the first rather than being kept.
204
+ const active = optionalText(item.activeWorkspaceId, 64);
205
+ if (active && seen.has(active))
206
+ file.activeWorkspaceId = active;
207
+ else if (workspaces.length)
208
+ file.activeWorkspaceId = workspaces[0].id;
209
+ return file;
210
+ }
211
+ export function defaultWorkspacePath(userDataPath) {
212
+ return join(userDataPath, 'workspaces.json');
213
+ }
@@ -0,0 +1,62 @@
1
+ // What "~" means inside a WSL distribution.
2
+ //
3
+ // A WSL pane's shell reports its directory as `~` unless shell integration is
4
+ // installed, and `~` is a symbol rather than a path: it cannot be turned into
5
+ // the `\wsl.localhost\...` share path a listing needs. Asking the distro is the
6
+ // only way to find out, and it is asked once per distribution and cached.
7
+ //
8
+ // Deliberately not done by typing `pwd` into the user's pane. That would put a
9
+ // command they did not run into their shell history, which is the rule
10
+ // cwd-tracker.ts states and keeps. This is a separate, short-lived process.
11
+ import { execFile } from 'node:child_process';
12
+ /**
13
+ * Distribution names, as they arrive from a session record.
14
+ *
15
+ * The first character must be alphanumeric: a name beginning with a dash would
16
+ * be read by `wsl.exe` as another option rather than as the distribution, and
17
+ * nothing that reaches an argv list should be able to become a flag.
18
+ */
19
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
20
+ /**
21
+ * The home directory out of the command's output.
22
+ *
23
+ * Anything that is not a single absolute POSIX path is rejected rather than
24
+ * repaired: this value becomes part of a path that is listed, and a distro that
25
+ * answered something surprising is a distro to give up on.
26
+ */
27
+ export function parseWslHome(output) {
28
+ const text = output.trim();
29
+ if (!text || text.length > 4096)
30
+ return null;
31
+ if (!text.startsWith('/'))
32
+ return null;
33
+ if (/[\r\n\0]/.test(text))
34
+ return null;
35
+ return text;
36
+ }
37
+ /**
38
+ * Ask a distribution where its home directory is.
39
+ *
40
+ * Null on anything unexpected — a distro that is not installed, a name this
41
+ * process will not pass on, a slow or silent answer. The caller shows the pane's
42
+ * "not said where it is yet" state, which is the honest outcome.
43
+ */
44
+ export async function wslHomeDirectory(distribution, run = execFile) {
45
+ if (!NAME.test(distribution))
46
+ return null;
47
+ return new Promise((resolve) => {
48
+ // argv, so the distribution name is an argument rather than part of a
49
+ // command line. `printf %s` rather than `echo` because it adds no newline
50
+ // and has no flags to be confused by a path.
51
+ const child = run('wsl.exe', ['-d', distribution, '--', 'sh', '-c', 'printf %s "$HOME"'], { timeout: 5000, windowsHide: true }, (error, stdout) => {
52
+ if (error) {
53
+ resolve(null);
54
+ return;
55
+ }
56
+ // WSL writes UTF-16 in some configurations; the NULs that leaves are
57
+ // stripped rather than treated as a malformed answer.
58
+ resolve(parseWslHome(String(stdout).split('\0').join('')));
59
+ });
60
+ child.on('error', () => resolve(null));
61
+ });
62
+ }
@@ -0,0 +1,73 @@
1
+ // What counts as an endpoint ZeroG will make a request to, and what is worth
2
+ // saying about one before it is used.
3
+ //
4
+ // Shared rather than owned by the speech side because the AI suggestion path now
5
+ // asks the same three questions of the same kind of URL, and the answers must
6
+ // match: a settings panel that warns a key travels in the clear for one feature
7
+ // and not the other would be worse than either behaviour on its own.
8
+ //
9
+ // The host is the operator's choice — loopback, the LAN, or a hosted service —
10
+ // and nothing here refuses one on that basis. What is enforced is the shape: an
11
+ // http(s) URL with a host, so a typo or a file:// path cannot become a request.
12
+ // See SECURITY.md.
13
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
14
+ /** The endpoint as a URL, or null when it is not one a request can be made to. */
15
+ export function endpointOf(url) {
16
+ try {
17
+ const parsed = new URL(url);
18
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
19
+ return null;
20
+ // http and https are special schemes, so a missing host is a parse failure
21
+ // rather than an empty hostname — every spelling of `http://` throws above.
22
+ // The check is kept anyway: this is the one gate before a request is made,
23
+ // and it should not rest on a parser detail holding everywhere.
24
+ if (!parsed.hostname)
25
+ return null;
26
+ return parsed;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function hostnameOf(url) {
33
+ const parsed = endpointOf(url);
34
+ return parsed === null ? null : parsed.hostname.toLowerCase();
35
+ }
36
+ /**
37
+ * Can a request be made to this URL?
38
+ *
39
+ * Any reachable host is allowed — loopback, LAN, or the public internet — but it
40
+ * has to be an http(s) URL naming a host. A `file://` path, a `ws://` URL or a
41
+ * half-typed address is refused here rather than turning into a request that
42
+ * fails obscurely later.
43
+ */
44
+ export function isSupportedEndpoint(url) {
45
+ return hostnameOf(url) !== null;
46
+ }
47
+ /**
48
+ * Does this endpoint keep the payload on this machine?
49
+ *
50
+ * Nothing is refused on this basis; the settings panel uses it to say plainly
51
+ * when recorded speech or terminal output is about to leave the machine.
52
+ * `*.localhost` resolves to loopback by specification, so it counts.
53
+ */
54
+ export function isLoopbackEndpoint(url) {
55
+ const hostname = hostnameOf(url);
56
+ if (hostname === null)
57
+ return false;
58
+ return LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith('.localhost');
59
+ }
60
+ /**
61
+ * Would a key sent to this endpoint travel in the clear?
62
+ *
63
+ * True for plain http to anywhere but this machine. Nothing is blocked on this
64
+ * basis — a self-hosted server on the LAN commonly has no certificate, and
65
+ * refusing to authenticate to it would break the ordinary case — but it is worth
66
+ * saying out loud next to the field where the key is typed.
67
+ */
68
+ export function sendsKeyInClear(url) {
69
+ const parsed = endpointOf(url);
70
+ if (parsed === null)
71
+ return false;
72
+ return parsed.protocol === 'http:' && !isLoopbackEndpoint(url);
73
+ }