copilot-operator 0.1.13 → 0.1.15

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.
Files changed (77) hide show
  1. package/README.md +9 -14
  2. package/dist/src/api/operator.controller.js +31 -42
  3. package/dist/src/api/operator.service.js +663 -367
  4. package/dist/src/api/saveToDesktop.js +1 -1
  5. package/dist/src/api/security.js +40 -10
  6. package/dist/src/api/server.js +75 -12
  7. package/dist/src/api/settings.js +156 -15
  8. package/dist/src/cli.js +108 -97
  9. package/dist/src/config/handWritten.js +36 -0
  10. package/dist/src/config/layout.js +13 -4
  11. package/dist/src/config/lockedPolicy.js +56 -22
  12. package/dist/src/config/schema.js +13 -84
  13. package/dist/src/context/desktopDir.js +44 -0
  14. package/dist/src/exec/authorizer.js +9 -1
  15. package/dist/src/exec/checks.js +240 -23
  16. package/dist/src/exec/dangerous.js +29 -5
  17. package/dist/src/exec/network.js +105 -28
  18. package/dist/src/exec/redaction.js +52 -9
  19. package/dist/src/exec/reportFile.js +4 -5
  20. package/dist/src/exec/runner.js +146 -13
  21. package/dist/src/exec/shells.js +41 -6
  22. package/dist/src/exec/workDir.js +4 -4
  23. package/dist/src/orchestrator/derivedChecks.js +1 -0
  24. package/dist/src/orchestrator/review.js +25 -3
  25. package/dist/src/orchestrator/taskRunner.js +476 -163
  26. package/dist/src/plan/brief.js +37 -44
  27. package/dist/src/plan/importPlan.js +15 -7
  28. package/dist/src/plan/schema.js +64 -29
  29. package/dist/src/plan/script.js +0 -15
  30. package/dist/src/plan/systemGuide.js +2 -177
  31. package/dist/src/protocol/parser.js +5 -1
  32. package/dist/src/session/debugExport.js +1 -1
  33. package/dist/src/session/exportRecord.js +1 -1
  34. package/dist/src/session/exports.js +49 -9
  35. package/dist/src/session/store.js +215 -80
  36. package/dist/src/vcs/scope.js +29 -6
  37. package/dist/src/vcs/taskVcs.js +78 -10
  38. package/dist/web/404.html +2 -2
  39. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
  40. package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
  41. package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
  42. package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
  43. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
  44. package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
  45. package/dist/web/appearance.html +2 -2
  46. package/dist/web/appearance.txt +12 -12
  47. package/dist/web/defaults.html +2 -2
  48. package/dist/web/defaults.txt +12 -12
  49. package/dist/web/history.html +2 -2
  50. package/dist/web/history.txt +12 -12
  51. package/dist/web/import.html +2 -2
  52. package/dist/web/import.txt +12 -12
  53. package/dist/web/index.html +2 -2
  54. package/dist/web/index.txt +12 -12
  55. package/dist/web/level1.html +2 -2
  56. package/dist/web/level1.txt +12 -12
  57. package/dist/web/presets.html +2 -2
  58. package/dist/web/presets.txt +12 -12
  59. package/dist/web/sessions/view.html +2 -2
  60. package/dist/web/sessions/view.txt +12 -12
  61. package/dist/web/system.html +2 -2
  62. package/dist/web/system.txt +12 -12
  63. package/package.json +18 -10
  64. package/prompts/level1.md +2 -1
  65. package/prompts/organisation.bg.md +0 -1
  66. package/prompts/organisation.en.md +0 -1
  67. package/dist/src/context/contextFiles.js +0 -295
  68. package/dist/src/context/desktopMirror.js +0 -173
  69. package/dist/src/context/projectMirror.js +0 -432
  70. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
  71. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
  72. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
  73. package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +0 -1
  74. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
  75. package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +0 -1
  76. /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
  77. /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → IwNZiBrEMAcXEUFj2miiU}/_ssgManifest.js +0 -0
package/dist/src/cli.js CHANGED
@@ -4,13 +4,12 @@
4
4
  *
5
5
  * cop login sign in once; the Edge profile is reused afterwards
6
6
  * cop doctor [run.yaml] check the machine before trusting a run to it
7
- * cop mirror <run.yaml> refresh the Desktop folder without touching the chat
8
7
  * cop run <run.yaml> the loop
9
8
  * cop chat print the last run's conversation link
10
9
  */
11
10
  import { Command } from 'commander';
12
11
  import { existsSync, readFileSync } from 'node:fs';
13
- import { mkdir, rm } from 'node:fs/promises';
12
+ import { rm } from 'node:fs/promises';
14
13
  import { join } from 'node:path';
15
14
  import { installLayout } from './config/layout.js';
16
15
  import { spawn, spawnSync } from 'node:child_process';
@@ -20,31 +19,67 @@ import { loadConfig, expandPath } from './config/schema.js';
20
19
  import { CopilotTransport } from './transport/copilotTransport.js';
21
20
  import { runSession } from './orchestrator/taskRunner.js';
22
21
  import { SessionStore } from './session/store.js';
22
+ import { settingsOf, SettingsUnusableError } from './api/settings.js';
23
+ import { PolicyLockUnusableError } from './config/lockedPolicy.js';
23
24
  import { EventBus } from './session/events.js';
24
25
  import { terminalAuthorizer, unattendedAuthorizer } from './exec/authorizer.js';
25
26
  import { unattendedPrecondition } from './exec/policy.js';
26
- import { mirrorProject, describeMirror, listSelectableDirs } from './context/projectMirror.js';
27
- import { isOwnCheckout } from './exec/workDir.js';
28
- import { defaultExportDir, desktopIsSynced, resolveDesktopDir } from './context/contextFiles.js';
27
+ import { botRootDir, isOwnCheckout } from './exec/workDir.js';
28
+ import { desktopIsSynced, resolveDesktopDir } from './context/desktopDir.js';
29
29
  import { Url } from './transport/locators.js';
30
30
  import { findEdgeUsingProfile } from './transport/profileLock.js';
31
31
  import { assessIsolation, readIsolationSignals } from './exec/isolation.js';
32
+ /**
33
+ * The version of the copilot-operator that is running, from its own package.json.
34
+ *
35
+ * It was a literal, '0.1.0', and stayed that through thirteen releases, so a bug report's
36
+ * `cop --version` named a version that never had the bug. Read where the code is — `src/` under
37
+ * tsx, `dist/src/` in a clone, `node_modules/copilot-operator/dist/src/` once installed — so it
38
+ * cannot drift from what npm installed. botRootDir() falls back to the working directory when it
39
+ * finds no package.json of ours, and that one is the user's project's: its version is not ours to
40
+ * print, so the name is checked and anything else says "unknown".
41
+ */
42
+ function ownVersion() {
43
+ try {
44
+ const pkg = JSON.parse(readFileSync(join(botRootDir(), 'package.json'), 'utf8'));
45
+ if (pkg.name === 'copilot-operator' && typeof pkg.version === 'string' && pkg.version)
46
+ return pkg.version;
47
+ }
48
+ catch {
49
+ // No readable package.json: say so rather than guess.
50
+ }
51
+ return 'unknown';
52
+ }
32
53
  const program = new Command();
33
54
  program
34
55
  .name('cop')
35
56
  .description('Runs a task loop with Microsoft 365 Copilot on a Windows machine.')
36
- .version('0.1.0');
57
+ .version(ownVersion());
37
58
  const DEFAULT_PROFILE = expandPath('~/AppData/Local/copilot-operator/edge-profile', process.cwd());
38
59
  program
39
60
  .command('start')
40
- .description('start copilot-operator for this project: the API and the web interface, on 127.0.0.1')
61
+ .description("start copilot-operator for this project on 127.0.0.1: the API, and the web interface the package ships (a clone's interface comes from npm start)")
41
62
  .option('--port <port>', 'the port to listen on', '4000')
42
- .option('--open', 'open the interface in the default browser once it is up')
63
+ .option('--open', 'open the interface in the default browser once it is up (an installed package; not in a clone)')
43
64
  .action(async (opts) => {
44
65
  process.env.COP_API_PORT = String(Number(opts.port) || 4000);
66
+ /*
67
+ * A clone's API serves no web interface (see `installLayout`): its interface is the dev server
68
+ * `npm start` runs, which builds the token into its page. So `cop start` in a clone is the API
69
+ * alone, and it says so before anything else rather than leave the operator looking for a page;
70
+ * and --open is not followed there, because the page it would open on this port answers a
71
+ * browser with 401.
72
+ */
73
+ const clone = installLayout().mode === 'checkout';
74
+ if (clone) {
75
+ console.log("cop start in a clone of copilot-operator starts the API only: a clone's web interface comes from npm start, which runs the API and the interface together.");
76
+ if (opts.open) {
77
+ console.log('--open is not followed in a clone: this port serves no page there and would answer the browser with 401. npm run dev starts both and opens the interface.');
78
+ }
79
+ }
45
80
  // The API runs in this process; importing it starts it. See src/api/main.ts.
46
81
  await import('./api/main.js');
47
- if (opts.open) {
82
+ if (opts.open && !clone) {
48
83
  const url = `http://127.0.0.1:${process.env.COP_API_PORT}/`;
49
84
  const deadline = Date.now() + 30_000;
50
85
  while (Date.now() < deadline) {
@@ -178,7 +213,7 @@ program
178
213
  replyTimeoutMs: 60_000,
179
214
  signInTimeoutMs: 15 * 60_000,
180
215
  humanWaitMs: 15 * 60_000,
181
- onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
216
+ onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
182
217
  `),
183
218
  });
184
219
  await transport.open();
@@ -252,7 +287,8 @@ program
252
287
  say(ping.status === 0, ping.status === 0
253
288
  ? `npm reaches ${registry}`
254
289
  : `npm cannot reach ${registry} — behind a proxy, set it for npm (npm config set proxy / https-proxy) or HTTPS_PROXY; every plan starts with npm install`);
255
- const dataDir = installLayout().dataDir;
290
+ const layout = installLayout();
291
+ const dataDir = layout.dataDir;
256
292
  const readJson = (file) => {
257
293
  try {
258
294
  return JSON.parse(readFileSync(join(dataDir, file), 'utf8'));
@@ -261,36 +297,63 @@ program
261
297
  return null;
262
298
  }
263
299
  };
264
- const settings = readJson('settings.json');
265
300
  /*
266
- * Where the bot is running, which is the only thing that actually contains a command once it
267
- * runs. Reported here rather than left to the README, because a machine that ignored the
268
- * recommendation looked exactly like one that had followed it. The claim is the operator's;
269
- * `doctor` only says what it can see and where the two disagree. See `exec/isolation.ts`.
301
+ * The settings, read the way the API reads them: a file that is there and cannot be used is the
302
+ * problem to report, since the API will not start on it, not a file to take for empty. Taken for
303
+ * empty, doctor said "isolation: none" and "no default model is set" of a file that may say
304
+ * otherwise, and never that it was broken. The checks below that read it wait until it is mended.
305
+ *
306
+ * Read by the API's own `Settings` and loaded as the API loads them before it opens its port,
307
+ * which reads the policy locks too. Parsed and checked here on its own, the file was all doctor
308
+ * looked at: a lock that did not parse stopped `npm start` while doctor said "Ready.".
270
309
  */
271
- const posture = assessIsolation(settings?.execution?.isolation ?? 'none', readIsolationSignals());
272
- say(posture.warnings.length === 0, `isolation: ${posture.claim}, running as ${posture.signals.user}${posture.signals.elevated === true ? ' (ELEVATED)' : ''}`);
273
- for (const concern of posture.warnings)
274
- say(false, ` ${concern}`);
275
- const models = readJson('models.json');
276
- const copilot = settings?.copilot ?? {};
277
- const wanted = [
278
- ['default model', (copilot.defaultModel ?? '').trim()],
279
- ['default review model', (copilot.defaultReviewModel ?? '').trim()],
280
- ].filter(([, name]) => name !== '');
281
- if (wanted.length === 0) {
282
- console.log(' note no default model is set; sessions leave the chat on whatever it shows');
310
+ const store = settingsOf(layout);
311
+ let settings = null;
312
+ try {
313
+ const value = await store.raw();
314
+ await store.load();
315
+ settings = value;
283
316
  }
284
- else if (!models?.options?.length) {
285
- console.log(` note ${wanted.map(([w, n]) => `${w} "${n}"`).join(', ')} set, but the picker has never been read here — read it from the Settings page before trusting them`);
317
+ catch (e) {
318
+ if (e instanceof SettingsUnusableError)
319
+ say(false, `${e.path} ${e.problem}; the API will not start until it is mended or deleted`);
320
+ // Not the operator's to delete: an administrator placed it (see `PolicyLockUnusableError`).
321
+ else if (e instanceof PolicyLockUnusableError)
322
+ say(false, `${e.path} ${e.problem}; the API will not start until it is mended`);
323
+ else
324
+ say(false, `the settings in ${join(dataDir, 'settings.json')} could not be loaded (${e.message}); the API will not start until that is put right`);
286
325
  }
287
- else {
288
- const names = new Set(models.options.map((o) => o.name));
289
- for (const [what, name] of wanted) {
290
- say(names.has(name), names.has(name) ? `${what} "${name}" is in the picker read on this machine` : `${what} "${name}" is not in the picker read on this machine (${models.options.length} option(s)) — the run would fall back to whatever the chat shows`);
326
+ if (settings) {
327
+ /*
328
+ * Where the bot is running, which is the only thing that actually contains a command once it
329
+ * runs. Reported here rather than left to the README, because a machine that ignored the
330
+ * recommendation looked exactly like one that had followed it. The claim is the operator's;
331
+ * `doctor` only says what it can see and where the two disagree. See `exec/isolation.ts`.
332
+ */
333
+ const posture = assessIsolation(settings.execution?.isolation ?? 'none', readIsolationSignals());
334
+ say(posture.warnings.length === 0, `isolation: ${posture.claim}, running as ${posture.signals.user}${posture.signals.elevated === true ? ' (ELEVATED)' : ''}`);
335
+ for (const concern of posture.warnings)
336
+ say(false, ` ${concern}`);
337
+ const models = readJson('models.json');
338
+ const copilot = settings.copilot ?? {};
339
+ const wanted = [
340
+ ['default model', (copilot.defaultModel ?? '').trim()],
341
+ ['default review model', (copilot.defaultReviewModel ?? '').trim()],
342
+ ].filter(([, name]) => name !== '');
343
+ if (wanted.length === 0) {
344
+ console.log(' note no default model is set; sessions leave the chat on whatever it shows');
345
+ }
346
+ else if (!models?.options?.length) {
347
+ console.log(` note ${wanted.map(([w, n]) => `${w} "${n}"`).join(', ')} set, but the picker has never been read here — read it from the Settings page before trusting them`);
291
348
  }
292
- if (wanted.length === 2 && wanted[0][1] === wanted[1][1]) {
293
- console.log(' note the review runs on the same model as the work; a different one catches more');
349
+ else {
350
+ const names = new Set(models.options.map((o) => o.name));
351
+ for (const [what, name] of wanted) {
352
+ say(names.has(name), names.has(name) ? `${what} "${name}" is in the picker read on this machine` : `${what} "${name}" is not in the picker read on this machine (${models.options.length} option(s)) — the run would fall back to whatever the chat shows`);
353
+ }
354
+ if (wanted.length === 2 && wanted[0][1] === wanted[1][1]) {
355
+ console.log(' note the review runs on the same model as the work; a different one catches more');
356
+ }
294
357
  }
295
358
  }
296
359
  const edgeUsers = findEdgeUsingProfile(DEFAULT_PROFILE);
@@ -309,7 +372,7 @@ program
309
372
  const desktop = resolveDesktopDir();
310
373
  say(existsSync(desktop), `Desktop at ${desktop}`);
311
374
  console.log(` ${desktopIsSynced() ? 'ok ' : 'note'} Desktop ${desktopIsSynced() ? 'is' : 'is NOT'} backed up by OneDrive` +
312
- `${desktopIsSynced() ? '' : ' — the mirror folder will stay on this machine only'}`);
375
+ `${desktopIsSynced() ? '' : ' — logs saved there stay on this machine only'}`);
313
376
  if (configPath) {
314
377
  try {
315
378
  const cfg = await loadConfig(configPath);
@@ -322,9 +385,6 @@ program
322
385
  const profileExists = existsSync(cfg.resolved.profileDir);
323
386
  console.log(` ${profileExists ? 'ok ' : 'note'} browser profile ${cfg.resolved.profileDir}` +
324
387
  `${profileExists ? '' : ' — run "cop login" first'}`);
325
- if (cfg.projectMirror.enabled) {
326
- say(existsSync(cfg.resolved.mirrorRootDir), `project root ${cfg.resolved.mirrorRootDir}`);
327
- }
328
388
  }
329
389
  catch (e) {
330
390
  say(false, e.message);
@@ -334,17 +394,6 @@ program
334
394
  console.log(problems.length === 0 ? 'Ready.' : `${problems.length} problem(s) to fix first.`);
335
395
  process.exitCode = problems.length === 0 ? 0 : 1;
336
396
  });
337
- program
338
- .command('dirs')
339
- .description('list the directories of a project that can be selected for the mirror')
340
- .argument('<projectRoot>')
341
- .action(async (root) => {
342
- const dirs = await listSelectableDirs(root);
343
- if (dirs.length === 0)
344
- console.log('(no selectable directories found)');
345
- for (const d of dirs)
346
- console.log(d);
347
- });
348
397
  program
349
398
  .command('models')
350
399
  .description('read the chat\'s model picker and print what it offers right now')
@@ -394,38 +443,6 @@ program
394
443
  await transport.close();
395
444
  }
396
445
  });
397
- program
398
- .command('mirror')
399
- .description('refresh the Desktop folder from the project, without touching the chat')
400
- .argument('<config>', 'path to run.yaml')
401
- .action(async (configPath) => {
402
- const cfg = await loadConfig(configPath);
403
- if (!cfg.projectMirror.enabled) {
404
- console.log('projectMirror.enabled is false in this config; nothing to do.');
405
- return;
406
- }
407
- const targetDir = cfg.resolved.mirrorTargetDir ?? defaultExportDir();
408
- await mkdir(targetDir, { recursive: true });
409
- const result = await mirrorProject({
410
- rootDir: cfg.resolved.mirrorRootDir,
411
- includeDirs: cfg.projectMirror.includeDirs,
412
- excludeDirs: cfg.projectMirror.excludeDirs,
413
- targetDir,
414
- separator: cfg.projectMirror.separator,
415
- txtMode: cfg.projectMirror.txtMode,
416
- respectGitignore: cfg.projectMirror.respectGitignore,
417
- ignoreDirs: cfg.projectMirror.ignoreDirs,
418
- includeEnvFiles: cfg.projectMirror.includeEnvFiles,
419
- maxFileBytes: cfg.projectMirror.maxFileBytes,
420
- });
421
- console.log(`${targetDir}`);
422
- console.log(describeMirror(result));
423
- for (const s of result.skipped)
424
- console.log(` skipped ${s.relPath}: ${s.reason}`);
425
- if (!desktopIsSynced()) {
426
- console.log('note: the Desktop is not backed up by OneDrive, so these copies stay local.');
427
- }
428
- });
429
446
  program
430
447
  .command('run')
431
448
  .description('run one task from a run.yaml: creates a session, runs it, prints the summary')
@@ -456,14 +473,8 @@ program
456
473
  }
457
474
  const store = new SessionStore(cfg.resolved.dataDir, cfg.resolved.level1Path);
458
475
  await store.init();
459
- const session = await store.createSession(cfg.copilot.label, {
460
- enabled: cfg.projectMirror.enabled,
461
- rootDir: cfg.resolved.mirrorRootDir ?? '',
462
- includeDirs: cfg.projectMirror.includeDirs,
463
- excludeDirs: cfg.projectMirror.excludeDirs,
464
- respectGitignore: cfg.projectMirror.respectGitignore,
465
- includeEnvFiles: cfg.projectMirror.includeEnvFiles,
466
- });
476
+ // No project folder of its own: its commands run in `execution.cwd`. See `workingDirFor`.
477
+ const session = await store.createSession(cfg.copilot.label);
467
478
  await store.updateSession(session.id, (s) => {
468
479
  s.onFailure = cfg.execution.continueOnFailure ? 'continue' : 'stop';
469
480
  });
@@ -495,14 +506,14 @@ program
495
506
  console.log('');
496
507
  console.log(`task "${done?.title}": ${done?.status} after ${done?.iterations ?? 0} iteration(s)`);
497
508
  if (done?.summary)
498
- console.log(`
499
- Summary:
509
+ console.log(`
510
+ Summary:
500
511
  ${done.summary}`);
501
512
  if (done?.reason)
502
- console.log(`
513
+ console.log(`
503
514
  Reason: ${done.reason}`);
504
515
  if (final?.chat)
505
- console.log(`
516
+ console.log(`
506
517
  chat: ${final.chat.name} — ${final.chat.url}`);
507
518
  if (done?.runId)
508
519
  console.log(`log: ${join(cfg.resolved.runsDir, done.runId, 'task-log.txt')}`);
@@ -522,8 +533,8 @@ program
522
533
  return;
523
534
  }
524
535
  const c = withChat.chat;
525
- console.log(`${c.name}
526
- ${c.url}
536
+ console.log(`${c.name}
537
+ ${c.url}
527
538
  session ${withChat.id}, ${withChat.tasks.length} task(s), created ${c.createdAt}`);
528
539
  });
529
540
  program.parseAsync(process.argv).catch((e) => {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The text of a file a person writes by hand to configure this program: `data/settings.json`, a
3
+ * `policy.lock.json`, a `run.yaml` and the files it names.
4
+ *
5
+ * Windows writes such a file in more than one encoding, and each is the file its writer meant.
6
+ * Notepad and Windows PowerShell's `Set-Content -Encoding utf8` put a UTF-8 byte-order mark in
7
+ * front of it; Windows PowerShell 5.1's `>` and `Out-File`, without an `-Encoding`, write UTF-16,
8
+ * little-endian, with its own mark. Read as UTF-8 and nothing else, the first was refused as "not
9
+ * valid JSON" over a character nobody can see, and the second over two replacement characters and
10
+ * a NUL between every letter, of a file that looks right in Notepad. So the mark, where there is
11
+ * one, says how the rest is encoded, and is not part of the text. A file without one is UTF-8, as
12
+ * everything this program writes is.
13
+ */
14
+ import { readFile } from 'node:fs/promises';
15
+ import { readFileSync } from 'node:fs';
16
+ export function decodeHandWritten(bytes) {
17
+ if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
18
+ return bytes.subarray(3).toString('utf8');
19
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
20
+ return bytes.subarray(2).toString('utf16le');
21
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
22
+ // Node decodes UTF-16 only little-endian, so a big-endian file has each pair of bytes swapped
23
+ // first, in a copy. A last odd byte is half a character and cannot be one.
24
+ const body = Buffer.from(bytes.subarray(2, bytes.length - (bytes.length % 2)));
25
+ return body.swap16().toString('utf16le');
26
+ }
27
+ return bytes.toString('utf8');
28
+ }
29
+ /** `readFile(path, 'utf8')` for a hand-written file: the same errors, the text by its mark. */
30
+ export async function readHandWritten(path) {
31
+ return decodeHandWritten(await readFile(path));
32
+ }
33
+ /** The same, for the command line's synchronous checks. */
34
+ export function readHandWrittenSync(path) {
35
+ return decodeHandWritten(readFileSync(path));
36
+ }
@@ -43,8 +43,6 @@ function isInside(dir, root) {
43
43
  }
44
44
  export function installLayout(env = process.env, cwd = process.cwd(), botRoot = botRootDir()) {
45
45
  const promptsDir = join(botRoot, 'prompts');
46
- const shippedWeb = join(botRoot, 'dist', 'web');
47
- const webDir = existsSync(join(shippedWeb, 'index.html')) ? shippedWeb : null;
48
46
  const packaged = projectOfPackage(botRoot);
49
47
  if (packaged) {
50
48
  // A dev dependency serves the project around it; a global or npx install serves wherever it
@@ -52,6 +50,7 @@ export function installLayout(env = process.env, cwd = process.cwd(), botRoot =
52
50
  const owner = isInside(cwd, packaged) ? packaged : cwd;
53
51
  const projectRoot = resolve(env.COP_PROJECT_ROOT ?? owner);
54
52
  const homeDir = join(projectRoot, HOME_FOLDER);
53
+ const shippedWeb = join(botRoot, 'dist', 'web');
55
54
  return {
56
55
  mode: 'package',
57
56
  botRoot,
@@ -60,7 +59,7 @@ export function installLayout(env = process.env, cwd = process.cwd(), botRoot =
60
59
  homeDir,
61
60
  dataDir: resolve(env.COP_DATA_DIR ?? join(homeDir, 'data')),
62
61
  runsDir: join(homeDir, 'runs'),
63
- webDir,
62
+ webDir: existsSync(join(shippedWeb, 'index.html')) ? shippedWeb : null,
64
63
  };
65
64
  }
66
65
  const projectRoot = resolve(env.COP_PROJECT_ROOT ?? cwd);
@@ -72,6 +71,16 @@ export function installLayout(env = process.env, cwd = process.cwd(), botRoot =
72
71
  homeDir: projectRoot,
73
72
  dataDir: resolve(env.COP_DATA_DIR ?? join(projectRoot, 'data')),
74
73
  runsDir: join(projectRoot, 'runs'),
75
- webDir,
74
+ /*
75
+ * A clone's API never serves the prebuilt interface, even with one built in it. `build:package`
76
+ * (run by every `npm pack`) and `check:ui` both leave `dist/web/index.html` behind, and serving
77
+ * it made the API of `npm start` hand the token, as a cookie, to anything on the machine that
78
+ * asked for `/`: a second way to the token beside the dev server, and one nobody chose. A
79
+ * clone's interface is the dev server, and `npm start` builds the token into its page, so that
80
+ * port is as open to this machine as a package's is; see the honest limit in `api/security.ts`.
81
+ * The browser checks that want the built interface on the API's own port pass it to `startApi`
82
+ * by name.
83
+ */
84
+ webDir: null,
76
85
  };
77
86
  }
@@ -26,8 +26,8 @@
26
26
  * one task and never put back — and it gives a security team a single artefact to point at.
27
27
  */
28
28
  import { z } from 'zod';
29
- import { readFile } from 'node:fs/promises';
30
29
  import { join } from 'node:path';
30
+ import { readHandWritten } from './handWritten.js';
31
31
  export const POLICY_LOCK_FILE = 'policy.lock.json';
32
32
  export const PolicyLockSchema = z
33
33
  .object({
@@ -36,9 +36,12 @@ export const PolicyLockSchema = z
36
36
  denyPatterns: z.array(z.string()).optional(),
37
37
  /** `true` treats the claim `none-accepted` as `none`: unattended runs need real isolation here. */
38
38
  requireIsolation: z.boolean().optional(),
39
- /** `false` switches the Desktop mirror off, whatever the settings say. */
39
+ /**
40
+ * Still read, so a lock written for an earlier version is not refused; they lock nothing now.
41
+ * The project was once copied to the Desktop and attached to the chat, and these switched that,
42
+ * and its `.env` files, off. That feature was removed on 2026-09-30.
43
+ */
40
44
  allowDesktopMirror: z.boolean().optional(),
41
- /** `false` keeps `.env` files out of every mirror and every attachment. */
42
45
  allowEnvFiles: z.boolean().optional(),
43
46
  /** A ceiling on `execution.passEnv`: the only variables a step may be given beyond the fixed set. */
44
47
  passEnv: z.array(z.string()).optional(),
@@ -98,16 +101,6 @@ export function applyPolicyLock(policy, lock) {
98
101
  next.isolation = 'none';
99
102
  changes.push('isolation "none-accepted" treated as "none" (the lock requires real isolation for unattended runs)');
100
103
  }
101
- if (lock.allowDesktopMirror === false && (next.mirrorToDesktop || next.projectMirrorEnabled)) {
102
- next.mirrorToDesktop = false;
103
- next.projectMirrorEnabled = false;
104
- changes.push('the Desktop mirror switched off by the lock');
105
- }
106
- if (lock.allowEnvFiles === false && (next.includeEnvFiles || next.mirrorIncludeEnvFiles)) {
107
- next.includeEnvFiles = false;
108
- next.mirrorIncludeEnvFiles = false;
109
- changes.push('.env files kept out of every mirror by the lock');
110
- }
111
104
  if (lock.passEnv && next.passEnv) {
112
105
  // Not `intersect`: an empty operator list here means "nothing extra", the tightest setting.
113
106
  const allowed = lowerSet(lock.passEnv);
@@ -122,9 +115,9 @@ export function applyPolicyLock(policy, lock) {
122
115
  /**
123
116
  * Reads `policy.lock.json` from a directory, or null when there is none.
124
117
  *
125
- * A file that is present but malformed is an error and not a shrug: a deployment that meant to
126
- * lock something down and mistyped it must not silently run unlocked, because the one thing worse
127
- * than no lock is a lock everybody believes in.
118
+ * A file that is present but malformed, or that cannot be read, is an error and not a shrug: a
119
+ * deployment that meant to lock something down and mistyped it must not silently run unlocked,
120
+ * because the one thing worse than no lock is a lock everybody believes in.
128
121
  */
129
122
  export async function readPolicyLock(baseDir) {
130
123
  return await readPolicyLockAt(join(baseDir, POLICY_LOCK_FILE));
@@ -139,7 +132,7 @@ export function machineLockPath(env = process.env) {
139
132
  const base = env.ProgramData ?? env.PROGRAMDATA;
140
133
  return base ? join(base, 'copilot-operator', POLICY_LOCK_FILE) : null;
141
134
  }
142
- /** Every lock that applies: the machine-wide one, then the install's own. Each is malformed-fatal. */
135
+ /** Every lock that applies: the machine-wide one, then the install's own. Each is fatal when it is there and unusable. */
143
136
  export async function readPolicyLocks(baseDir, env = process.env) {
144
137
  const out = [];
145
138
  const machine = machineLockPath(env);
@@ -153,24 +146,65 @@ export async function readPolicyLocks(baseDir, env = process.env) {
153
146
  out.push(local);
154
147
  return out;
155
148
  }
149
+ /**
150
+ * A policy lock that is there and cannot be used: it cannot be read, it is not JSON, or it is not a
151
+ * lock.
152
+ *
153
+ * Its own class for the reason `SettingsUnusableError` has one (see `api/settings.ts`). Every
154
+ * configuration passes through `resolveConfig`, which reads the locks, so every request that reads
155
+ * the settings meets a broken one, not only the saves; as a plain error it was "Internal server
156
+ * error" wherever a route did not wrap its errors — the System page, the Defaults page, the Project
157
+ * page and the model picker among them, the pages the operator opens to find out what is wrong. The
158
+ * server answers this one the same way on every route, with this message (see `server.ts`).
159
+ */
160
+ export class PolicyLockUnusableError extends Error {
161
+ path;
162
+ problem;
163
+ constructor(path,
164
+ /** What is wrong, said of the file: "is not valid JSON: …". */
165
+ problem) {
166
+ // The way out goes in the same message, as it does for the settings file. Unlike that file,
167
+ // this one is not the operator's to delete in passing: it is somebody's decision about this
168
+ // machine, and the one thing a broken lock must not do is read as no lock at all.
169
+ super(`${path} ${problem}\nNothing that reads the settings runs until it is mended: a policy lock that cannot be read as one ` +
170
+ 'is never taken for no lock. Whoever administers this machine placed it; mend it there, or remove it if no lock was meant.');
171
+ this.path = path;
172
+ this.problem = problem;
173
+ this.name = 'PolicyLockUnusableError';
174
+ }
175
+ }
176
+ /**
177
+ * Whether a failed read means the file is not there. Only then is there no lock: ENOENT, or
178
+ * ENOTDIR, where a part of the path is a file and so nothing can be below it.
179
+ */
180
+ function lockIsAbsent(e) {
181
+ const code = e.code;
182
+ return code === 'ENOENT' || code === 'ENOTDIR';
183
+ }
156
184
  async function readPolicyLockAt(path) {
157
185
  let text;
158
186
  try {
159
- text = await readFile(path, 'utf8');
187
+ text = await readHandWritten(path);
160
188
  }
161
- catch {
162
- return null;
189
+ catch (e) {
190
+ // Any other failure is a lock that is there and cannot be read, and is refused as a broken
191
+ // one is. Every failure was taken for no lock: a lock whose folder an administrator protected
192
+ // and left this account no read on (EPERM), a folder where the file should be (EISDIR), a
193
+ // file another program held for a moment (EBUSY), and the "confirm only" it held was gone.
194
+ if (lockIsAbsent(e))
195
+ return null;
196
+ throw new PolicyLockUnusableError(path, `could not be read (${e.message})`);
163
197
  }
164
198
  let value;
165
199
  try {
166
200
  value = JSON.parse(text);
167
201
  }
168
202
  catch (e) {
169
- throw new Error(`${path} is not valid JSON: ${e.message}`);
203
+ throw new PolicyLockUnusableError(path, `is not valid JSON: ${e.message}`);
170
204
  }
171
205
  const parsed = PolicyLockSchema.safeParse(value);
172
206
  if (!parsed.success) {
173
- throw new Error(`${path} is not a valid policy lock:\n${parsed.error.issues.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n')}`);
207
+ throw new PolicyLockUnusableError(path, `is not a valid policy lock:\n${parsed.error.issues.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n')}`);
174
208
  }
175
209
  return parsed.data;
176
210
  }