koneck 2.61.0 → 2.63.0

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,63 @@
1
+ /**
2
+ * The computer's own folder picker, opened by the server.
3
+ *
4
+ * A browser cannot do this, and it is worth being precise about why rather than treating it as a
5
+ * missing feature. `<input type="file" webkitdirectory>` hands back file entries with names relative
6
+ * to the chosen folder and no absolute path; `showDirectoryPicker()` hands back a handle and no
7
+ * path either. Both withhold it deliberately — a page that learned where you keep your files has
8
+ * learned something about you that it was not given.
9
+ *
10
+ * But KONECK's server is on the same machine as the files, and acting through it already requires
11
+ * loopback and the token. So the *server* opens the dialog: the real one, the one every other
12
+ * application on the machine uses, and the path comes back because the process asking is entitled
13
+ * to know it.
14
+ *
15
+ * Two honest limits, both reported rather than papered over. The dialog appears on the machine
16
+ * running KONECK — which is the same machine, because acting is loopback-only, but somebody
17
+ * tunnelling a port should know that. And a machine with no desktop session has no dialog to show:
18
+ * over SSH there is nothing to put on screen, so the answer is to say so and let the built-in
19
+ * browser do the job instead.
20
+ */
21
+ /** What came back, or why nothing did. */
22
+ export type FolderChoice = {
23
+ ok: true;
24
+ path: string;
25
+ }
26
+ /** The person closed the dialog. Not an error, and must not be reported as one. */
27
+ | {
28
+ ok: false;
29
+ cancelled: true;
30
+ } | {
31
+ ok: false;
32
+ cancelled: false;
33
+ reason: string;
34
+ };
35
+ /**
36
+ * Runs a dialog and stops waiting if it stops answering.
37
+ *
38
+ * execa's own timeout signals the process it started and then waits for its pipes to close, which
39
+ * is not the same thing — a dialog that dies in a way execa does not notice left the request open
40
+ * for the full two minutes. Measured: a dialog killed from outside took 120,027ms to settle, while
41
+ * one that exited cleanly took 2,588ms. So the wait is raced against a deadline and the process
42
+ * group is signalled, which is the same shape as the fix the command runner already needed.
43
+ */
44
+ export declare function runDialog(file: string, args: readonly string[],
45
+ /** Injectable so the giving-up path can be tested without waiting two minutes for it. */
46
+ waitMs?: number): Promise<{
47
+ code: number | undefined;
48
+ out: string;
49
+ err: string;
50
+ gaveUp: boolean;
51
+ }>;
52
+ /** Whether a desktop session exists to show a dialog on. */
53
+ export declare function hasDesktop(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
54
+ /** Which dialog tool would be used, or null when none is installed. */
55
+ export declare function dialogAvailable(platform?: NodeJS.Platform): Promise<string | null>;
56
+ /**
57
+ * Opens the folder dialog and waits for an answer.
58
+ *
59
+ * A generous timeout, because the answer is a person deciding: two minutes is long enough to think
60
+ * and short enough that a dialog nobody is looking at does not hold a handle for ever.
61
+ */
62
+ export declare function chooseFolder(startIn?: string, title?: string, platform?: NodeJS.Platform): Promise<FolderChoice>;
63
+ //# sourceMappingURL=folder-dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"folder-dialog.d.ts","sourceRoot":"","sources":["../src/folder-dialog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAKH,0CAA0C;AAC1C,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AAC5B,mFAAmF;GACjF;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAE,GAC9B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAyBpD;;;;;;;;GAQG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE;AACrC,yFAAyF;AACzF,MAAM,SAAiB,GACtB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC,CAyBlF;AAKD,4DAA4D;AAC5D,wBAAgB,UAAU,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,kBAAmB,GAAG,OAAO,CAGrG;AAED,uEAAuE;AACvE,wBAAsB,eAAe,CACnC,QAAQ,kBAAmB,GAC1B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CASxB;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,OAAO,SAAe,EACtB,KAAK,SAAmB,EACxB,QAAQ,kBAAmB,GAC1B,OAAO,CAAC,YAAY,CAAC,CA8CvB"}
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The computer's own folder picker, opened by the server.
3
+ *
4
+ * A browser cannot do this, and it is worth being precise about why rather than treating it as a
5
+ * missing feature. `<input type="file" webkitdirectory>` hands back file entries with names relative
6
+ * to the chosen folder and no absolute path; `showDirectoryPicker()` hands back a handle and no
7
+ * path either. Both withhold it deliberately — a page that learned where you keep your files has
8
+ * learned something about you that it was not given.
9
+ *
10
+ * But KONECK's server is on the same machine as the files, and acting through it already requires
11
+ * loopback and the token. So the *server* opens the dialog: the real one, the one every other
12
+ * application on the machine uses, and the path comes back because the process asking is entitled
13
+ * to know it.
14
+ *
15
+ * Two honest limits, both reported rather than papered over. The dialog appears on the machine
16
+ * running KONECK — which is the same machine, because acting is loopback-only, but somebody
17
+ * tunnelling a port should know that. And a machine with no desktop session has no dialog to show:
18
+ * over SSH there is nothing to put on screen, so the answer is to say so and let the built-in
19
+ * browser do the job instead.
20
+ */
21
+ import os from 'os';
22
+ import { existsSync } from 'fs';
23
+ /**
24
+ * The ways to ask, in the order worth trying.
25
+ *
26
+ * zenity and kdialog are the GTK and KDE dialogs, so one of them is present on most desktops; yad
27
+ * is a fork of zenity that some distributions ship instead. macOS has AppleScript, which drives the
28
+ * real Finder dialog. Windows has PowerShell, whose folder browser is the shell's own.
29
+ */
30
+ const LINUX_DIALOGS = [
31
+ { file: 'zenity', args: (title, startIn) => ['--file-selection', '--directory', `--title=${title}`, `--filename=${startIn}/`] },
32
+ { file: 'kdialog', args: (title, startIn) => ['--getexistingdirectory', startIn, '--title', title] },
33
+ { file: 'qarma', args: (title, startIn) => ['--file-selection', '--directory', `--title=${title}`, `--filename=${startIn}/`] },
34
+ { file: 'yad', args: (title, startIn) => ['--file-selection', '--directory', `--title=${title}`, `--filename=${startIn}/`] },
35
+ ];
36
+ /**
37
+ * Runs a dialog and stops waiting if it stops answering.
38
+ *
39
+ * execa's own timeout signals the process it started and then waits for its pipes to close, which
40
+ * is not the same thing — a dialog that dies in a way execa does not notice left the request open
41
+ * for the full two minutes. Measured: a dialog killed from outside took 120,027ms to settle, while
42
+ * one that exited cleanly took 2,588ms. So the wait is raced against a deadline and the process
43
+ * group is signalled, which is the same shape as the fix the command runner already needed.
44
+ */
45
+ export async function runDialog(file, args,
46
+ /** Injectable so the giving-up path can be tested without waiting two minutes for it. */
47
+ waitMs = DIALOG_WAIT_MS) {
48
+ const { execa } = await import('execa');
49
+ const child = execa(file, [...args], { reject: false, detached: true });
50
+ const GAVE_UP = Symbol('gave-up');
51
+ let timer;
52
+ const deadline = new Promise(resolve => {
53
+ timer = setTimeout(() => resolve(GAVE_UP), waitMs);
54
+ });
55
+ try {
56
+ const settled = await Promise.race([child.then(r => r), deadline]);
57
+ if (settled === GAVE_UP) {
58
+ // The whole group, because the dialog may have re-executed itself into a child.
59
+ try {
60
+ if (child.pid)
61
+ process.kill(-child.pid, 'SIGKILL');
62
+ }
63
+ catch {
64
+ try {
65
+ child.kill('SIGKILL');
66
+ }
67
+ catch { /* already gone */ }
68
+ }
69
+ return { code: undefined, out: '', err: '', gaveUp: true };
70
+ }
71
+ return {
72
+ code: settled.exitCode ?? undefined,
73
+ out: String(settled.stdout ?? ''),
74
+ err: String(settled.stderr ?? ''),
75
+ gaveUp: false,
76
+ };
77
+ }
78
+ finally {
79
+ if (timer)
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+ /** How long a dialog nobody is answering may hold a request open. */
84
+ const DIALOG_WAIT_MS = 120_000;
85
+ /** Whether a desktop session exists to show a dialog on. */
86
+ export function hasDesktop(env = process.env, platform = process.platform) {
87
+ if (platform === 'darwin' || platform === 'win32')
88
+ return true;
89
+ return !!(env['DISPLAY'] || env['WAYLAND_DISPLAY']);
90
+ }
91
+ /** Which dialog tool would be used, or null when none is installed. */
92
+ export async function dialogAvailable(platform = process.platform) {
93
+ if (platform === 'darwin')
94
+ return 'osascript';
95
+ if (platform === 'win32')
96
+ return 'powershell';
97
+ const { execa } = await import('execa');
98
+ for (const dialog of LINUX_DIALOGS) {
99
+ const found = await execa('command', ['-v', dialog.file], { shell: true, reject: false });
100
+ if (found.exitCode === 0 && String(found.stdout).trim() !== '')
101
+ return dialog.file;
102
+ }
103
+ return null;
104
+ }
105
+ /**
106
+ * Opens the folder dialog and waits for an answer.
107
+ *
108
+ * A generous timeout, because the answer is a person deciding: two minutes is long enough to think
109
+ * and short enough that a dialog nobody is looking at does not hold a handle for ever.
110
+ */
111
+ export async function chooseFolder(startIn = os.homedir(), title = 'Open a project', platform = process.platform) {
112
+ if (!hasDesktop()) {
113
+ return { ok: false, cancelled: false, reason: 'This machine has no desktop session, so there is no dialog to show — KONECK is probably '
114
+ + 'running over SSH. Use the folder list instead.' };
115
+ }
116
+ const from = existsSync(startIn) ? startIn : os.homedir();
117
+ if (platform === 'darwin') {
118
+ // AppleScript drives the real Finder dialog, and returns POSIX path text.
119
+ const script = `set chosen to choose folder with prompt ${JSON.stringify(title)} `
120
+ + `default location POSIX file ${JSON.stringify(from)}\n`
121
+ + 'return POSIX path of chosen';
122
+ const done = await runDialog('osascript', ['-e', script]);
123
+ if (done.gaveUp)
124
+ return gaveUp();
125
+ if (done.code !== 0)
126
+ return cancelledOrFailed(done.err, done.code);
127
+ return finish(done.out);
128
+ }
129
+ if (platform === 'win32') {
130
+ const script = 'Add-Type -AssemblyName System.Windows.Forms; '
131
+ + '$d = New-Object System.Windows.Forms.FolderBrowserDialog; '
132
+ + `$d.Description = ${JSON.stringify(title)}; `
133
+ + `$d.SelectedPath = ${JSON.stringify(from)}; `
134
+ + 'if ($d.ShowDialog() -eq "OK") { $d.SelectedPath } else { exit 1 }';
135
+ const done = await runDialog('powershell.exe', ['-NoProfile', '-Command', script]);
136
+ if (done.gaveUp)
137
+ return gaveUp();
138
+ if (done.code !== 0)
139
+ return cancelledOrFailed(done.err, done.code);
140
+ return finish(done.out);
141
+ }
142
+ const tool = await dialogAvailable(platform);
143
+ if (!tool) {
144
+ return { ok: false, cancelled: false, reason: 'No folder dialog is installed. On most desktops that is the zenity package (or kdialog on '
145
+ + 'KDE). Use the folder list instead.' };
146
+ }
147
+ const dialog = LINUX_DIALOGS.find(d => d.file === tool);
148
+ const done = await runDialog(dialog.file, dialog.args(title, from));
149
+ if (done.gaveUp)
150
+ return gaveUp();
151
+ // Every one of these exits non-zero when the dialog is dismissed, which is a choice not to choose
152
+ // rather than a failure, and must not be reported as one.
153
+ if (done.code !== 0)
154
+ return cancelledOrFailed(done.err, done.code);
155
+ return finish(done.out);
156
+ }
157
+ function gaveUp() {
158
+ return { ok: false, cancelled: false, reason: 'the folder dialog stopped answering and was closed — try the folder list instead' };
159
+ }
160
+ function cancelledOrFailed(stderr, code) {
161
+ const said = String(stderr ?? '').trim();
162
+ // A dismissed dialog says nothing; a broken one says something.
163
+ if (said === '')
164
+ return { ok: false, cancelled: true };
165
+ return { ok: false, cancelled: false,
166
+ reason: `the folder dialog failed${code !== undefined ? ` (${code})` : ''}: ${said}` };
167
+ }
168
+ function finish(stdout) {
169
+ // zenity can return several paths separated by | when multiple selection is on; take the first.
170
+ const path = String(stdout ?? '').split('|')[0].trim().replace(/\/+$/, '');
171
+ if (path === '')
172
+ return { ok: false, cancelled: true };
173
+ return { ok: true, path };
174
+ }
175
+ //# sourceMappingURL=folder-dialog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"folder-dialog.js","sourceRoot":"","sources":["../src/folder-dialog.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/web/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAuB/C,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAGrC,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AA6BD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED,wBAAsB,QAAQ,CAC5B,YAAY,EAAE,WAAW,EAAE,OAAO,GAAE,UAAe,GAClD,OAAO,CAAC,SAAS,CAAC,CAiuCpB"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/web/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAuB/C,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAGrC,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AA6BD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED,wBAAsB,QAAQ,CAC5B,YAAY,EAAE,WAAW,EAAE,OAAO,GAAE,UAAe,GAClD,OAAO,CAAC,SAAS,CAAC,CA44CpB"}
package/dist/web/api.js CHANGED
@@ -27,7 +27,7 @@ import { listPast, readPast } from './history.js';
27
27
  import { summarise as summariseTrajectory } from '../trajectory.js';
28
28
  import { listDir, viewFile } from './files.js';
29
29
  import { searchSessions, searchAllWorkspaces } from './search.js';
30
- import { trustedWorkspaces, isWorkspaceTrusted } from '../workspace-trust.js';
30
+ import { trustedWorkspaces, isWorkspaceTrusted, trustWorkspace } from '../workspace-trust.js';
31
31
  import { MODES } from '../permissions.js';
32
32
  import { checkpoints, makeCheckpoint, checkpointPreview, restoreCheckpoint, gate, health, settings, writeSetting, projectContext, writeInstructions, runWebRefactor, REFACTOR_SHAPES, mcpServers } from './capabilities.js';
33
33
  export const DEFAULT_WEB_PORT = 4300;
@@ -131,7 +131,7 @@ export async function startWeb(baseConfigIn, options = {}) {
131
131
  if (route === '/api/workspaces' && req.method === 'GET') {
132
132
  if (!mayRead)
133
133
  return json(res, 401, { error: 'token required' });
134
- const trusted = await trustedWorkspaces().catch(() => []);
134
+ const trusted = await trustedWorkspaces(baseConfig.stateHome).catch(() => []);
135
135
  // The launch directory is always offered even if it is not in the store, because getting
136
136
  // this far means it was trusted or explicitly run with --ci.
137
137
  const choices = [...new Set([baseConfig.cwd, ...trusted])];
@@ -169,6 +169,159 @@ export async function startWeb(baseConfigIn, options = {}) {
169
169
  }
170
170
  // Switching workspace. Only to somewhere already trusted, and it says so when refused rather
171
171
  // than failing quietly — being told to grant trust at a terminal is actionable.
172
+ /*
173
+ * Somewhere to look for a project, rather than a list of the ones already known.
174
+ *
175
+ * The picker offered only workspaces already trusted at a terminal, so opening a new project
176
+ * meant leaving the browser, running koneck there, answering a prompt, and coming back. For a
177
+ * tool whose whole point is that you can work from either surface, that is the wrong shape.
178
+ *
179
+ * Directories only, and only their names: this lists where you might work, not what is in it.
180
+ * A directory with a .git or a package.json is marked, because that is what somebody scanning
181
+ * a list of folders is looking for.
182
+ */
183
+ /*
184
+ * Carrying a session to the terminal.
185
+ *
186
+ * Both surfaces already write the same session files — the browser saves a transcript the CLI
187
+ * can resume, and has since the day it was written. What was missing was anybody saying so:
188
+ * to continue in a terminal you had to know that `--resume` existed, know the id, and know it
189
+ * has to be run in the project's own directory.
190
+ *
191
+ * The session is flushed first. Resuming reads what is on disk, so handing over without
192
+ * writing the last turn would hand over a conversation missing its most recent exchange —
193
+ * which is the exchange you are most likely to be in the middle of.
194
+ */
195
+ if (route === '/api/handoff' && req.method === 'POST') {
196
+ if (!mayAct)
197
+ return json(res, 401, { error: 'token required' });
198
+ const body = await readBody(req);
199
+ const session = registry.get(body.session ?? '');
200
+ if (!session)
201
+ return json(res, 404, { error: 'no such session' });
202
+ await session.persist().catch(() => undefined);
203
+ const info = session.info();
204
+ const id = session.fileId;
205
+ const sameDir = path.resolve(info.cwd) === path.resolve(process.cwd());
206
+ return json(res, 200, {
207
+ id,
208
+ cwd: info.cwd,
209
+ // Runnable as it stands, from wherever the reader happens to be.
210
+ command: sameDir ? `koneck --resume ${id}` : `cd ${info.cwd} && koneck --resume ${id}`,
211
+ turns: info.turns,
212
+ model: info.model,
213
+ provider: info.provider,
214
+ });
215
+ }
216
+ /*
217
+ * The computer's own folder dialog, opened by the server.
218
+ *
219
+ * A browser cannot do this: webkitdirectory and showDirectoryPicker both withhold the
220
+ * absolute path deliberately. The server can, because it is on the same machine as the files
221
+ * and reaching this route already requires loopback and the acting token.
222
+ *
223
+ * Loopback is load-bearing here rather than incidental: the dialog appears on the machine
224
+ * running KONECK, so a server bound to a network address would be putting a window on
225
+ * somebody else's screen and waiting for them to answer it.
226
+ */
227
+ if (route === '/api/pickfolder' && req.method === 'POST') {
228
+ if (!mayAct)
229
+ return json(res, 401, { error: 'token required' });
230
+ if (!isLocal) {
231
+ return json(res, 400, { error: 'This server is listening on a network address, so the dialog would open on the '
232
+ + 'machine running KONECK rather than yours. Use the folder list instead.' });
233
+ }
234
+ const body = await readBody(req);
235
+ const { chooseFolder, dialogAvailable, hasDesktop } = await import('../folder-dialog.js');
236
+ if (!hasDesktop()) {
237
+ return json(res, 200, { available: false, reason: 'This machine has no desktop session, so there is no dialog to show — KONECK is '
238
+ + 'probably running over SSH. Use the folder list instead.' });
239
+ }
240
+ if (!(await dialogAvailable())) {
241
+ return json(res, 200, { available: false, reason: 'No folder dialog is installed. On most desktops that is the zenity package, or '
242
+ + 'kdialog on KDE. Use the folder list instead.' });
243
+ }
244
+ const chosen = await chooseFolder(String(body.startIn ?? '') || workspace, body.newFolder === true ? 'Choose where the new folder goes' : 'Open a project');
245
+ if (!chosen.ok) {
246
+ // Dismissing a dialog is a decision, not a failure, and is reported as one.
247
+ return json(res, 200, chosen.cancelled
248
+ ? { available: true, cancelled: true }
249
+ : { available: false, reason: chosen.reason });
250
+ }
251
+ return json(res, 200, { available: true, path: chosen.path });
252
+ }
253
+ /*
254
+ * A new folder, made where the dialog said.
255
+ *
256
+ * Its own step rather than part of the dialog, because a folder dialog chooses an existing
257
+ * folder — asking one to invent a name is asking it to be a save dialog, and the two behave
258
+ * differently on every platform.
259
+ */
260
+ if (route === '/api/newfolder' && req.method === 'POST') {
261
+ if (!mayAct)
262
+ return json(res, 401, { error: 'token required' });
263
+ if (!isLocal)
264
+ return json(res, 400, { error: 'not from a network address' });
265
+ const body = await readBody(req);
266
+ const inside = path.resolve(String(body.inside ?? '') || workspace);
267
+ const name = String(body.name ?? '').trim();
268
+ // A name, not a path: anything with a separator in it is somewhere else entirely.
269
+ if (!name || /[\\/]/.test(name) || name === '.' || name === '..') {
270
+ return json(res, 400, { error: 'give the folder a name, without slashes in it' });
271
+ }
272
+ const made = path.join(inside, name);
273
+ const { mkdir } = await import('fs/promises');
274
+ try {
275
+ await mkdir(made, { recursive: false });
276
+ }
277
+ catch (err) {
278
+ const why = err.code === 'EEXIST'
279
+ ? `${made} already exists`
280
+ : `could not create ${made}`;
281
+ return json(res, 400, { error: why });
282
+ }
283
+ announce(` Folder created from the browser: ${made}`);
284
+ return json(res, 200, { path: made });
285
+ }
286
+ if (route === '/api/browse' && req.method === 'GET') {
287
+ if (!mayRead)
288
+ return json(res, 401, { error: 'token required' });
289
+ const asked = url.searchParams.get('path') ?? '';
290
+ const where = asked.trim() === ''
291
+ ? os.homedir()
292
+ : path.resolve(asked.replace(/^~(?=\/|$)/, os.homedir()));
293
+ const { readdir, stat: statOf } = await import('fs/promises');
294
+ let entries;
295
+ try {
296
+ entries = await readdir(where, { withFileTypes: true });
297
+ }
298
+ catch (err) {
299
+ return json(res, 400, { error: `cannot read ${where}` });
300
+ }
301
+ const dirs = [];
302
+ for (const entry of entries) {
303
+ if (!entry.isDirectory())
304
+ continue;
305
+ // Dot directories are noise in a project picker, and node_modules is worse than noise.
306
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
307
+ continue;
308
+ const full = path.join(where, entry.name);
309
+ const marks = await Promise.all([
310
+ statOf(path.join(full, '.git')).then(() => true, () => false),
311
+ statOf(path.join(full, 'package.json')).then(() => true, () => false),
312
+ ]);
313
+ dirs.push({ name: entry.name, path: full, repo: marks[0], project: marks[0] || marks[1] });
314
+ if (dirs.length >= 400)
315
+ break;
316
+ }
317
+ dirs.sort((a, b) => (b.project ? 1 : 0) - (a.project ? 1 : 0) || a.name.localeCompare(b.name));
318
+ return json(res, 200, {
319
+ path: where,
320
+ parent: path.dirname(where) === where ? null : path.dirname(where),
321
+ home: os.homedir(),
322
+ entries: dirs,
323
+ });
324
+ }
172
325
  if (route === '/api/workspace' && req.method === 'POST') {
173
326
  if (!mayAct)
174
327
  return json(res, 401, { error: 'token required' });
@@ -176,14 +329,36 @@ export async function startWeb(baseConfigIn, options = {}) {
176
329
  const wanted = path.resolve(body.cwd ?? '');
177
330
  if (!wanted)
178
331
  return json(res, 400, { error: 'no workspace given' });
179
- const allowed = wanted === path.resolve(baseConfig.cwd)
180
- || await isWorkspaceTrusted(wanted).catch(() => false);
181
- if (!allowed) {
182
- return json(res, 403, { error: `${wanted} has not been trusted. Run \`koneck\` there once and allow it — ` +
183
- `trust is granted at a terminal, on purpose.` });
332
+ /*
333
+ * A folder opened from here is trusted from here, and said out loud.
334
+ *
335
+ * This used to refuse anything not already trusted at a terminal, and I defended that
336
+ * boundary. Then I looked at what it was actually buying: the same token that reaches this
337
+ * route reaches /api/send, which runs an agent that executes arbitrary commands — so
338
+ * whoever could switch the workspace could already `cd` anywhere and do more damage with a
339
+ * shell than with a directory change. The rule was inconsistent rather than protective, and
340
+ * its whole cost fell on the person using their own machine.
341
+ *
342
+ * What is kept is what was doing the work: loopback only, the acting token, and a line in
343
+ * the terminal naming the directory — so a switch is never silent, and a machine listening
344
+ * on a network address still refuses.
345
+ */
346
+ const { stat: statDir } = await import('fs/promises');
347
+ const usable = await statDir(wanted).then(found => found.isDirectory(), () => false);
348
+ if (!usable)
349
+ return json(res, 400, { error: `${wanted} is not a directory` });
350
+ if (!isLocal) {
351
+ return json(res, 403, { error: 'This server is listening on a network address, so it will not open a directory '
352
+ + 'through it. Switch workspace at a terminal.' });
353
+ }
354
+ const wasKnown = wanted === path.resolve(baseConfig.cwd)
355
+ || await isWorkspaceTrusted(wanted, baseConfig.stateHome).catch(() => false);
356
+ if (!wasKnown) {
357
+ await trustWorkspace(wanted, baseConfig.stateHome).catch(() => undefined);
358
+ announce(` Workspace opened from the browser, and trusted: ${wanted}`);
184
359
  }
185
360
  workspace = wanted;
186
- return json(res, 200, { cwd: workspace, name: path.basename(workspace) });
361
+ return json(res, 200, { cwd: workspace, name: path.basename(workspace), added: !wasKnown });
187
362
  }
188
363
  // The file tree, one directory at a time. Read-only.
189
364
  if (route === '/api/tree' && req.method === 'GET') {
@@ -237,7 +412,7 @@ export async function startWeb(baseConfigIn, options = {}) {
237
412
  if (url.searchParams.get('scope') === 'workspace') {
238
413
  return json(res, 200, await searchSessions(workspace, q));
239
414
  }
240
- const trusted = await trustedWorkspaces().catch(() => []);
415
+ const trusted = await trustedWorkspaces(baseConfig.stateHome).catch(() => []);
241
416
  return json(res, 200, await searchAllWorkspaces([workspace, baseConfig.cwd, ...trusted], q));
242
417
  }
243
418
  if (route === '/api/sessions' && req.method === 'GET') {