copilot-operator 0.1.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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A log, on the Desktop, with Explorer already pointing at it.
3
+ *
4
+ * The operator does not read these logs in a browser. They run the bot, and then they hand
5
+ * what came out of it to a separate chat that orchestrates the whole effort. Offering the log
6
+ * as a page in a new tab was answering a question nobody had: getting it from that tab into
7
+ * the chat meant save-as, then remembering where the browser put it, then finding it again,
8
+ * then dragging it. Four steps, every one of them somewhere else on the screen.
9
+ *
10
+ * So the log is written as a file instead, and Explorer is opened on it with the file already
11
+ * selected. One press, and the thing the operator wants to drag is under the cursor. The
12
+ * browser is not asked to be a file manager, because it is not one.
13
+ *
14
+ * The files go into one folder under the Desktop rather than loose on it. A Desktop that
15
+ * slowly fills with task logs is a worse outcome than the tab ever was, and a folder that is
16
+ * always the same one is also a place the operator learns, so the second log is found without
17
+ * being revealed at all.
18
+ *
19
+ * Nothing here overwrites. A log that was saved once may already have been dragged into a
20
+ * chat, attached to a message or renamed by somebody; writing over it because the same button
21
+ * was pressed twice would destroy a file the bot has no claim on. Every save earns its own
22
+ * name, and the timestamp in that name is what tells two saves of one attempt apart.
23
+ *
24
+ * Revealing is a Windows idea and this module says so plainly anywhere else, in the manner of
25
+ * `folderPicker.ts`: the file is still written, and the caller is told where, so the feature
26
+ * degrades to something useful rather than to an error.
27
+ */
28
+ import { spawn } from 'node:child_process';
29
+ import { existsSync } from 'node:fs';
30
+ import { mkdir, writeFile } from 'node:fs/promises';
31
+ import { extname, join } from 'node:path';
32
+ import { resolveDesktopDir } from '../context/contextFiles.js';
33
+ /** The one folder under the Desktop that every saved log lands in. */
34
+ export const LOGS_FOLDER = 'copilot-operator-logs';
35
+ /** The folder every saved log goes into. */
36
+ export function logsDir(env = process.env) {
37
+ return join(resolveDesktopDir(env), LOGS_FOLDER);
38
+ }
39
+ /**
40
+ * One part of a file name, made safe for NTFS.
41
+ *
42
+ * Session names and task titles are prose written by a human, so they carry colons, slashes
43
+ * and quotation marks as a matter of course — every one of them illegal in a Windows file
44
+ * name. A name Windows refuses is a save that fails for a reason the operator cannot act on,
45
+ * so the illegal characters become dashes rather than a refusal. The trailing dots and spaces
46
+ * go too: Windows silently drops them, which would leave the path the caller was told about
47
+ * and the file actually on disk disagreeing.
48
+ */
49
+ export function sanitiseNamePart(part, limit = 60) {
50
+ const cleaned = part
51
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
52
+ .replace(/\s+/g, ' ')
53
+ .trim()
54
+ .slice(0, limit)
55
+ .replace(/[. ]+$/, '');
56
+ return cleaned.length > 0 ? cleaned : 'unnamed';
57
+ }
58
+ /** The timestamp in a file name: sortable, readable, and legal, so local time with no colons. */
59
+ function stampOf(at) {
60
+ const two = (n) => String(n).padStart(2, '0');
61
+ return (`${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())}` +
62
+ `-${two(at.getHours())}${two(at.getMinutes())}${two(at.getSeconds())}`);
63
+ }
64
+ /**
65
+ * The name a saved log is given: the session, the task, the attempt and when it was saved.
66
+ *
67
+ * All four are there because the operator ends up with a folder of these and has to tell them
68
+ * apart by sight, in the drag-and-drop list of a chat, without opening any of them.
69
+ */
70
+ export function logFileName(naming, at = new Date()) {
71
+ const parts = [sanitiseNamePart(naming.session, 40), sanitiseNamePart(naming.task, 60)];
72
+ if (naming.attempt !== undefined)
73
+ parts.push(`attempt ${naming.attempt}`);
74
+ // A task's own file keeps its extension; the consolidated log is text and says so.
75
+ const extension = naming.file ? extname(naming.file) || '.txt' : '.txt';
76
+ if (naming.file) {
77
+ const stem = naming.file.slice(0, naming.file.length - extname(naming.file).length);
78
+ parts.push(sanitiseNamePart(stem, 50));
79
+ }
80
+ parts.push(stampOf(at));
81
+ return `${parts.join(' - ')}${extension}`;
82
+ }
83
+ /**
84
+ * The asked-for name, or the first free variation of it.
85
+ *
86
+ * Two saves inside the same second collide on the timestamp, and so does a folder restored
87
+ * from a backup. Windows' own ` (2)` suffix is used because the operator has seen it before
88
+ * and knows what it means.
89
+ */
90
+ export function freeName(dir, fileName, taken = existsSync) {
91
+ if (!taken(join(dir, fileName)))
92
+ return fileName;
93
+ const extension = extname(fileName);
94
+ const stem = fileName.slice(0, fileName.length - extension.length);
95
+ for (let n = 2; n < 1000; n += 1) {
96
+ const candidate = `${stem} (${n})${extension}`;
97
+ if (!taken(join(dir, candidate)))
98
+ return candidate;
99
+ }
100
+ // A thousand collisions is not a folder anyone is still reading, so stop guessing politely.
101
+ return `${stem} (${Date.now()})${extension}`;
102
+ }
103
+ /**
104
+ * Explorer, opened on the folder with the file selected.
105
+ *
106
+ * `explorer.exe` exits with a non-zero code when it has done exactly what was asked, which is
107
+ * long-standing and documented behaviour rather than a bug to work around, so the exit code is
108
+ * ignored entirely. Only a failure to start the process at all means the reveal did not
109
+ * happen. The arguments go through verbatim because Explorer parses `/select,"<path>"` itself
110
+ * and Node's own quoting of an argument with spaces in it would leave it parsing something
111
+ * else.
112
+ *
113
+ * `windowsHide` is deliberately not set, unlike everywhere else in this codebase that starts a
114
+ * process: it asks Windows to start the child with its window hidden, and a hidden window is
115
+ * the exact opposite of the point here. Explorer is a windowed program with no console of its
116
+ * own, so nothing flashes for leaving it off.
117
+ */
118
+ export const revealInExplorer = (path) => new Promise((settle, fail) => {
119
+ const child = spawn('explorer.exe', [`/select,"${path}"`], {
120
+ windowsVerbatimArguments: true,
121
+ detached: true,
122
+ stdio: 'ignore',
123
+ });
124
+ child.on('error', fail);
125
+ // Explorer outlives the API process; nothing here waits for it to close.
126
+ child.on('spawn', () => {
127
+ child.unref();
128
+ settle();
129
+ });
130
+ });
131
+ /**
132
+ * Writes the text to the Desktop folder and opens Explorer on it.
133
+ *
134
+ * Failing to reveal is not failing to save. The file is on disk either way and the caller is
135
+ * told where, because a path the operator can paste into an address bar is still most of what
136
+ * they wanted.
137
+ */
138
+ export async function saveAndReveal(text, naming, options = {}) {
139
+ const env = options.env ?? process.env;
140
+ const platform = options.platform ?? process.platform;
141
+ const dir = logsDir(env);
142
+ await mkdir(dir, { recursive: true });
143
+ const fileName = freeName(dir, logFileName(naming, options.now ?? new Date()));
144
+ const path = join(dir, fileName);
145
+ await writeFile(path, text, 'utf8');
146
+ if (platform !== 'win32') {
147
+ return { path, dir, fileName, revealed: false, note: 'Opening the folder is Windows-only. The file was saved.' };
148
+ }
149
+ const reveal = options.reveal ?? revealInExplorer;
150
+ try {
151
+ await reveal(path);
152
+ return { path, dir, fileName, revealed: true };
153
+ }
154
+ catch (e) {
155
+ return { path, dir, fileName, revealed: false, note: `The file was saved, but Explorer did not open: ${e.message}` };
156
+ }
157
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Who is allowed to drive this API.
3
+ *
4
+ * The controller used to say it plainly: "There is no authentication because there is no network:
5
+ * this API runs on the operator's own machine and drives that machine's browser." Every clause of
6
+ * that is true and the conclusion does not follow, because "no network" is not the same as "no
7
+ * callers". Two of them were always reachable.
8
+ *
9
+ * A web page. Any site the operator visits in their ordinary browser can issue requests to
10
+ * `http://127.0.0.1:4000`. CORS stops that page *reading* the answer, which is not the point: a
11
+ * request that starts a session has done its damage before anybody reads anything. And an origin
12
+ * check alone is not enough either, because DNS rebinding exists — the attacker's own domain is
13
+ * made to resolve to 127.0.0.1, and from the browser's point of view the request is then
14
+ * same-origin. The answer to that is the `Host` header, which still carries the attacker's name.
15
+ *
16
+ * Another process on the machine. Anything running locally can POST to the port and queue a task
17
+ * that runs commands. A token in a file readable by the same user does not stop a process running
18
+ * *as* that user — nothing at this layer can — but it does stop everything that cannot read that
19
+ * file: another account, a container, a curious script, and any of the browser cases above.
20
+ *
21
+ * So three checks, cheapest first, and all of them before Nest's routing sees the request:
22
+ *
23
+ * host the request must be addressed to this loopback port by name. Defeats rebinding.
24
+ * origin a browser always sets it on a cross-origin request and a page cannot forge it, so
25
+ * an origin that is not the configured UI is refused outright rather than merely
26
+ * prevented from reading the reply.
27
+ * token a per-install secret, in `Authorization: Bearer`, `x-cop-token`, or — for the two
28
+ * kinds of request that cannot carry a header at all, `EventSource` and a plain
29
+ * `<a download>` — the `token` query parameter.
30
+ *
31
+ * `/api/health` is deliberately open: `scripts/dev.mjs` polls it to find out whether the API has
32
+ * finished starting, it says nothing but the time, and requiring a token to ask "are you up" would
33
+ * make the start-up race harder to debug for no gain.
34
+ *
35
+ * The web interface, when this process serves it (an install from npm, `cop start`): its pages are
36
+ * static files and answer without a token — the host check still applies, so a rebound domain gets
37
+ * nothing — and every page hands the browser the token as a cookie scoped to `/api`, HttpOnly (no
38
+ * script reads it) and SameSite=Strict (no other site's page sends it). The page's own requests
39
+ * then carry it by themselves, the stream and the downloads included, and no URL ever holds it.
40
+ *
41
+ * The honest limit, since this is the file somebody will quote: none of this contains a process
42
+ * already running as the operator. It raises the floor from "anything on this machine, and quite a
43
+ * few things off it" to "something that can read a file in the install". The boundary is still the
44
+ * account the runner runs in.
45
+ */
46
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
47
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
48
+ import { join } from 'node:path';
49
+ /** The one path that answers without a token. */
50
+ export const OPEN_PATHS = ['/api/health'];
51
+ export const TOKEN_FILE = 'api-token';
52
+ /**
53
+ * Whether the `Host` header names this loopback port.
54
+ *
55
+ * The header is the attacker's own domain under DNS rebinding, which is exactly why it is worth
56
+ * reading: the address resolves to 127.0.0.1 but it is still called `evil.example`, and a check
57
+ * that only looked at the socket would see nothing wrong at all.
58
+ */
59
+ export function isLoopbackHost(host, port) {
60
+ if (!host)
61
+ return false;
62
+ let hostname = host;
63
+ let hostPort;
64
+ if (host.startsWith('[')) {
65
+ // `[::1]:4000`
66
+ const close = host.indexOf(']');
67
+ if (close < 0)
68
+ return false;
69
+ hostname = host.slice(1, close);
70
+ const rest = host.slice(close + 1);
71
+ if (rest.startsWith(':'))
72
+ hostPort = rest.slice(1);
73
+ else if (rest.length > 0)
74
+ return false;
75
+ }
76
+ else {
77
+ const colon = host.lastIndexOf(':');
78
+ if (colon >= 0) {
79
+ hostname = host.slice(0, colon);
80
+ hostPort = host.slice(colon + 1);
81
+ }
82
+ }
83
+ const loopback = hostname === '127.0.0.1' || hostname === '::1' || hostname.toLowerCase() === 'localhost';
84
+ if (!loopback)
85
+ return false;
86
+ return hostPort === undefined || hostPort === String(port);
87
+ }
88
+ /**
89
+ * Whether a request's `Origin` is one the UI is served from.
90
+ *
91
+ * An absent origin is allowed through to the token check: it means the caller is not a browser —
92
+ * `curl`, the CLI, a script — and for those the token is the whole of the answer. A *present*
93
+ * origin that does not match is refused, because only a browser sets it and only a page on another
94
+ * site would set it to anything else.
95
+ */
96
+ export function originAllowed(origin, allowed) {
97
+ if (!origin)
98
+ return true;
99
+ const o = origin.trim().toLowerCase().replace(/\/$/, '');
100
+ return allowed.some((a) => a.trim().toLowerCase().replace(/\/$/, '') === o);
101
+ }
102
+ /** Constant-time comparison, so a wrong token cannot be found one character at a time. */
103
+ export function tokenMatches(presented, expected) {
104
+ if (!presented || !expected)
105
+ return false;
106
+ const a = Buffer.from(presented, 'utf8');
107
+ const b = Buffer.from(expected, 'utf8');
108
+ if (a.length !== b.length)
109
+ return false;
110
+ return timingSafeEqual(a, b);
111
+ }
112
+ /** The cookie the served interface is given the token in. */
113
+ export const TOKEN_COOKIE = 'cop_token';
114
+ /** Whether a path is the web interface rather than the API. */
115
+ export function isInterfacePath(path) {
116
+ return path !== '/api' && !path.startsWith('/api/');
117
+ }
118
+ /** The `Set-Cookie` value that hands a served page the token. */
119
+ export function tokenCookie(token) {
120
+ return `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/api`;
121
+ }
122
+ /** The token a request presents, from any of the places one can be carried. */
123
+ export function presentedToken(req) {
124
+ const cookie = req.headers.cookie;
125
+ if (typeof cookie === 'string') {
126
+ for (const part of cookie.split(';')) {
127
+ const [name, ...rest] = part.trim().split('=');
128
+ if (name === TOKEN_COOKIE && rest.join('=').trim())
129
+ return rest.join('=').trim();
130
+ }
131
+ }
132
+ const auth = req.headers.authorization;
133
+ if (typeof auth === 'string' && /^bearer\s+/i.test(auth))
134
+ return auth.replace(/^bearer\s+/i, '').trim();
135
+ const header = req.headers['x-cop-token'];
136
+ if (typeof header === 'string' && header.trim())
137
+ return header.trim();
138
+ const q = req.query?.token;
139
+ if (typeof q === 'string' && q.trim())
140
+ return q.trim();
141
+ return undefined;
142
+ }
143
+ /**
144
+ * The install's token, read from `<dataDir>/api-token` and created there if it is not there yet.
145
+ *
146
+ * Generated rather than configured, so a machine that nobody has thought about is not a machine
147
+ * with a default password. Written with owner-only permissions where the platform honours them;
148
+ * on Windows that is a best effort and the file's protection is really the profile's, which is the
149
+ * same protection the session store and the Edge profile already rely on.
150
+ */
151
+ export async function ensureApiToken(dataDir) {
152
+ const path = join(dataDir, TOKEN_FILE);
153
+ try {
154
+ const existing = (await readFile(path, 'utf8')).trim();
155
+ if (existing)
156
+ return existing;
157
+ }
158
+ catch {
159
+ // Not there yet, or unreadable: make one.
160
+ }
161
+ const token = randomBytes(32).toString('hex');
162
+ await mkdir(dataDir, { recursive: true });
163
+ await writeFile(path, `${token}\n`, { encoding: 'utf8', mode: 0o600 });
164
+ return token;
165
+ }
166
+ /**
167
+ * The decision, with no Express in it, so the rules can be exercised directly by a check rather
168
+ * than through a running server. `localApiGuard` is the thin wrapper that puts it on the wire.
169
+ */
170
+ export function judgeRequest(req, opts) {
171
+ const host = typeof req.headers.host === 'string' ? req.headers.host : undefined;
172
+ if (!isLoopbackHost(host, opts.port)) {
173
+ return {
174
+ ok: false,
175
+ status: 421,
176
+ reason: 'host',
177
+ detail: `this API answers only to 127.0.0.1:${opts.port}; the request was addressed to "${host ?? '(none)'}"`,
178
+ };
179
+ }
180
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
181
+ if (!originAllowed(origin, opts.allowedOrigins)) {
182
+ return { ok: false, status: 403, reason: 'origin', detail: `requests from ${origin} are not accepted by this API` };
183
+ }
184
+ if (OPEN_PATHS.includes(req.path))
185
+ return { ok: true };
186
+ // The interface's own files: answered to the right host with no token, since they are how the
187
+ // browser gets one. Only when this process serves them; see `localApiGuard`.
188
+ if (opts.servesInterface && isInterfacePath(req.path))
189
+ return { ok: true };
190
+ const presented = presentedToken({ headers: req.headers, query: (req.query ?? {}) });
191
+ if (!tokenMatches(presented, opts.token)) {
192
+ return {
193
+ ok: false,
194
+ status: 401,
195
+ reason: 'token',
196
+ detail: 'this API needs the token kept in data/api-token (.copilot-operator/data/api-token when installed from npm), ' +
197
+ 'as an Authorization: Bearer header, ' +
198
+ 'an x-cop-token header, or a token query parameter. Open the interface from `npx cop start` (or ' +
199
+ '`npm start` in a clone) and it is given the token.',
200
+ };
201
+ }
202
+ return { ok: true };
203
+ }
204
+ /** The guard as Express middleware, mounted before Nest's routing so it covers every route. */
205
+ export function localApiGuard(opts) {
206
+ return (req, res, next) => {
207
+ /*
208
+ * A CORS preflight carries no credentials and is answered by the CORS layer; refusing it here
209
+ * would turn every cross-origin refusal into an opaque network error in the page rather than
210
+ * the 401 the UI can actually say something about.
211
+ */
212
+ if (req.method === 'OPTIONS') {
213
+ next();
214
+ return;
215
+ }
216
+ const verdict = judgeRequest({ path: req.path, headers: req.headers, query: req.query }, opts);
217
+ if (verdict.ok) {
218
+ if (opts.servesInterface && isInterfacePath(req.path))
219
+ res.setHeader('Set-Cookie', tokenCookie(opts.token));
220
+ next();
221
+ return;
222
+ }
223
+ res.status(verdict.status).json({ error: verdict.reason, message: verdict.detail });
224
+ };
225
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Settings for the API: the same shape as `run.yaml`, kept as `data/settings.json`.
3
+ *
4
+ * The UI edits this file through the API. Anything not present falls back to the schema
5
+ * defaults, so an empty file is a valid, safe configuration.
6
+ */
7
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
8
+ import { existsSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { loadConfigObject } from '../config/schema.js';
11
+ export class Settings {
12
+ projectRoot;
13
+ dataDir;
14
+ installDefaults;
15
+ path;
16
+ constructor(projectRoot, dataDir,
17
+ /**
18
+ * Defaults that depend on how the program is installed, applied under whatever the file says.
19
+ * A package keeps its runs in the project's records folder and reads its contract from the
20
+ * package; a checkout's own defaults (`./runs`, `prompts/level1.md`) need nothing here.
21
+ */
22
+ installDefaults = {}) {
23
+ this.projectRoot = projectRoot;
24
+ this.dataDir = dataDir;
25
+ this.installDefaults = installDefaults;
26
+ this.path = join(dataDir, 'settings.json');
27
+ }
28
+ /** The raw object as saved, or `{}` when there is no file yet. */
29
+ async raw() {
30
+ if (!existsSync(this.path))
31
+ return {};
32
+ try {
33
+ return JSON.parse(await readFile(this.path, 'utf8'));
34
+ }
35
+ catch {
36
+ return {};
37
+ }
38
+ }
39
+ /** Parsed, defaulted and resolved against the project root. */
40
+ async load() {
41
+ const raw = await this.raw();
42
+ return await loadConfigObject({ ...this.installDefaults, ...raw, dataDir: this.dataDir }, this.projectRoot, this.path);
43
+ }
44
+ /** Validates before saving, so a bad edit is rejected rather than stored. */
45
+ async save(value) {
46
+ const resolved = await loadConfigObject({ ...this.installDefaults, ...value, dataDir: this.dataDir }, this.projectRoot, this.path);
47
+ await mkdir(dirname(this.path), { recursive: true });
48
+ await writeFile(this.path, JSON.stringify(value, null, 2), 'utf8');
49
+ return resolved;
50
+ }
51
+ /** The schema defaults, for the UI to show what "not set" means. */
52
+ async defaults() {
53
+ const resolved = await loadConfigObject({}, this.projectRoot, 'defaults');
54
+ const { configPath: _c, baseDir: _b, resolved: _r, ...cfg } = resolved;
55
+ return cfg;
56
+ }
57
+ }