copilot-operator 0.1.13 → 0.1.16
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.
- package/README.md +9 -14
- package/dist/src/api/operator.controller.js +42 -42
- package/dist/src/api/operator.service.js +743 -367
- package/dist/src/api/saveToDesktop.js +1 -1
- package/dist/src/api/security.js +40 -10
- package/dist/src/api/server.js +75 -12
- package/dist/src/api/settings.js +156 -15
- package/dist/src/cli.js +108 -97
- package/dist/src/config/handWritten.js +36 -0
- package/dist/src/config/layout.js +13 -4
- package/dist/src/config/lockedPolicy.js +56 -22
- package/dist/src/config/schema.js +13 -84
- package/dist/src/context/desktopDir.js +44 -0
- package/dist/src/exec/authorizer.js +9 -1
- package/dist/src/exec/checks.js +240 -23
- package/dist/src/exec/dangerous.js +29 -5
- package/dist/src/exec/network.js +105 -28
- package/dist/src/exec/redaction.js +52 -9
- package/dist/src/exec/reportFile.js +4 -5
- package/dist/src/exec/runner.js +146 -13
- package/dist/src/exec/shells.js +41 -6
- package/dist/src/exec/workDir.js +4 -4
- package/dist/src/orchestrator/derivedChecks.js +1 -0
- package/dist/src/orchestrator/review.js +25 -3
- package/dist/src/orchestrator/taskRunner.js +476 -163
- package/dist/src/plan/brief.js +37 -44
- package/dist/src/plan/importPlan.js +15 -7
- package/dist/src/plan/schema.js +64 -29
- package/dist/src/plan/script.js +0 -15
- package/dist/src/plan/systemGuide.js +33 -177
- package/dist/src/protocol/parser.js +5 -1
- package/dist/src/session/debugExport.js +1 -1
- package/dist/src/session/exportRecord.js +1 -1
- package/dist/src/session/exports.js +49 -9
- package/dist/src/session/store.js +215 -80
- package/dist/src/transport/copilotTransport.js +39 -1
- package/dist/src/vcs/scope.js +29 -6
- package/dist/src/vcs/taskVcs.js +78 -10
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
- package/dist/web/_next/static/chunks/549-7fd4fe96682299f5.js +1 -0
- package/dist/web/_next/static/chunks/867-631dc4314840fed6.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-fd819bbfa7f33982.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
- package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +19 -10
- package/prompts/level1.md +2 -1
- package/prompts/organisation.bg.md +0 -1
- package/prompts/organisation.en.md +0 -1
- package/dist/src/context/contextFiles.js +0 -295
- package/dist/src/context/desktopMirror.js +0 -173
- package/dist/src/context/projectMirror.js +0 -432
- package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
- package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
- package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +0 -1
- /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → GhdY7nVcYi-WV3j7saVPK}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → GhdY7nVcYi-WV3j7saVPK}/_ssgManifest.js +0 -0
|
@@ -29,7 +29,7 @@ import { spawn } from 'node:child_process';
|
|
|
29
29
|
import { existsSync } from 'node:fs';
|
|
30
30
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
31
31
|
import { extname, join } from 'node:path';
|
|
32
|
-
import { resolveDesktopDir } from '../context/
|
|
32
|
+
import { resolveDesktopDir } from '../context/desktopDir.js';
|
|
33
33
|
/** The one folder under the Desktop that every saved log lands in. */
|
|
34
34
|
export const LOGS_FOLDER = 'copilot-operator-logs';
|
|
35
35
|
/** The folder every saved log goes into. */
|
package/dist/src/api/security.js
CHANGED
|
@@ -33,15 +33,23 @@
|
|
|
33
33
|
* make the start-up race harder to debug for no gain.
|
|
34
34
|
*
|
|
35
35
|
* The web interface, when this process serves it (an install from npm, `cop start`): its pages are
|
|
36
|
-
* static files and
|
|
36
|
+
* static files and are read without a token — the host check still applies, so a rebound domain gets
|
|
37
37
|
* nothing — and every page hands the browser the token as a cookie scoped to `/api`, HttpOnly (no
|
|
38
38
|
* script reads it) and SameSite=Strict (no other site's page sends it). The page's own requests
|
|
39
39
|
* then carry it by themselves, the stream and the downloads included, and no URL ever holds it.
|
|
40
|
+
* Only a read is a page: a request that writes needs the token whatever its path says, so a path the
|
|
41
|
+
* guard calls a page and the router calls a route can at worst show something, never start a run.
|
|
40
42
|
*
|
|
41
43
|
* The honest limit, since this is the file somebody will quote: none of this contains a process
|
|
42
44
|
* 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"
|
|
44
|
-
*
|
|
45
|
+
* few things off it" to "something that can read a file in the install" — and, wherever the
|
|
46
|
+
* interface is served, to anything on this machine that can open that port and ask for a page, since
|
|
47
|
+
* the page is how a browser is given the token. In a package install that is this process's port.
|
|
48
|
+
* In a clone it is the Next.js dev server `npm start` runs on 127.0.0.1:3210, which this guard does
|
|
49
|
+
* not stand in front of: the token is built into that page's script (`NEXT_PUBLIC_COP_TOKEN`, see
|
|
50
|
+
* scripts/dev.mjs), as plain text that anything running in the page can read, where a package's
|
|
51
|
+
* HttpOnly cookie is out of a script's reach. A clone's API port serving no page of its own closes
|
|
52
|
+
* one way to the token, not every way. The boundary is still the account the runner runs in.
|
|
45
53
|
*/
|
|
46
54
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
47
55
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
@@ -111,9 +119,30 @@ export function tokenMatches(presented, expected) {
|
|
|
111
119
|
}
|
|
112
120
|
/** The cookie the served interface is given the token in. */
|
|
113
121
|
export const TOKEN_COOKIE = 'cop_token';
|
|
114
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Whether a path is the web interface rather than the API.
|
|
124
|
+
*
|
|
125
|
+
* Compared in lower case, because that is how the router compares it: Express matches a route
|
|
126
|
+
* without regard to case, so `/API/sessions` is the sessions route. Compared as written, it was a
|
|
127
|
+
* page to this test and needed no token, and a request with none listed the sessions and started
|
|
128
|
+
* a run (found on 2026-10-01).
|
|
129
|
+
*/
|
|
115
130
|
export function isInterfacePath(path) {
|
|
116
|
-
|
|
131
|
+
const p = path.toLowerCase();
|
|
132
|
+
return p !== '/api' && !p.startsWith('/api/');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether a request is a read of the served interface: a page or one of its files.
|
|
136
|
+
*
|
|
137
|
+
* The interface is static files, and static files are only ever read, so a request that writes is
|
|
138
|
+
* not the interface whatever its path is. Keeping the no-token exemption to GET and HEAD means the
|
|
139
|
+
* next disagreement between this guard and the router about what a path is — a spelling, an
|
|
140
|
+
* encoding, a prefix nobody thought of — can at worst answer a read, never start a run.
|
|
141
|
+
* An absent method is a GET, as it is for any request that names none.
|
|
142
|
+
*/
|
|
143
|
+
export function isInterfaceRequest(method, path) {
|
|
144
|
+
const m = (method ?? 'GET').toUpperCase();
|
|
145
|
+
return (m === 'GET' || m === 'HEAD') && isInterfacePath(path);
|
|
117
146
|
}
|
|
118
147
|
/** The `Set-Cookie` value that hands a served page the token. */
|
|
119
148
|
export function tokenCookie(token) {
|
|
@@ -181,11 +210,12 @@ export function judgeRequest(req, opts) {
|
|
|
181
210
|
if (!originAllowed(origin, opts.allowedOrigins)) {
|
|
182
211
|
return { ok: false, status: 403, reason: 'origin', detail: `requests from ${origin} are not accepted by this API` };
|
|
183
212
|
}
|
|
184
|
-
|
|
213
|
+
// In lower case, as the router matches it: `/API/health` is the health route too.
|
|
214
|
+
if (OPEN_PATHS.includes(req.path.toLowerCase()))
|
|
185
215
|
return { ok: true };
|
|
186
|
-
// The interface's own files:
|
|
216
|
+
// The interface's own files: read from the right host with no token, since they are how the
|
|
187
217
|
// browser gets one. Only when this process serves them; see `localApiGuard`.
|
|
188
|
-
if (opts.servesInterface &&
|
|
218
|
+
if (opts.servesInterface && isInterfaceRequest(req.method, req.path))
|
|
189
219
|
return { ok: true };
|
|
190
220
|
const presented = presentedToken({ headers: req.headers, query: (req.query ?? {}) });
|
|
191
221
|
if (!tokenMatches(presented, opts.token)) {
|
|
@@ -213,9 +243,9 @@ export function localApiGuard(opts) {
|
|
|
213
243
|
next();
|
|
214
244
|
return;
|
|
215
245
|
}
|
|
216
|
-
const verdict = judgeRequest({ path: req.path, headers: req.headers, query: req.query }, opts);
|
|
246
|
+
const verdict = judgeRequest({ method: req.method, path: req.path, headers: req.headers, query: req.query }, opts);
|
|
217
247
|
if (verdict.ok) {
|
|
218
|
-
if (opts.servesInterface &&
|
|
248
|
+
if (opts.servesInterface && isInterfaceRequest(req.method, req.path))
|
|
219
249
|
res.setHeader('Set-Cookie', tokenCookie(opts.token));
|
|
220
250
|
next();
|
|
221
251
|
return;
|
package/dist/src/api/server.js
CHANGED
|
@@ -1,18 +1,45 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
1
7
|
/**
|
|
2
8
|
* The API server: routes, the guard in front of them, the data folder's permissions, recovery of
|
|
3
9
|
* whatever the previous process left running. See `main.ts` for the process around it.
|
|
4
10
|
*/
|
|
5
11
|
import 'reflect-metadata';
|
|
6
|
-
import {
|
|
12
|
+
import { Catch, ConflictException } from '@nestjs/common';
|
|
13
|
+
import { BaseExceptionFilter, NestFactory } from '@nestjs/core';
|
|
7
14
|
import { join } from 'node:path';
|
|
8
15
|
import { AppModule } from './app.module.js';
|
|
9
16
|
import { OperatorService } from './operator.service.js';
|
|
17
|
+
import { SettingsUnusableError } from './settings.js';
|
|
18
|
+
import { PolicyLockUnusableError } from '../config/lockedPolicy.js';
|
|
10
19
|
import { ensureApiToken, localApiGuard } from './security.js';
|
|
11
20
|
import { secureDataDir } from './dataAcl.js';
|
|
12
21
|
import { pruneRuns } from '../session/retention.js';
|
|
13
22
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
14
23
|
import { existsSync } from 'node:fs';
|
|
15
24
|
import { installLayout } from '../config/layout.js';
|
|
25
|
+
/**
|
|
26
|
+
* A settings file, or a policy lock, that cannot be used, answered the same way on every route: 409,
|
|
27
|
+
* with the message that names the file and says what to do (see `SettingsUnusableError` and
|
|
28
|
+
* `PolicyLockUnusableError`).
|
|
29
|
+
*
|
|
30
|
+
* Here, once, because the routes that meet it are most of them: every one whose work reads the
|
|
31
|
+
* settings, and reading the settings reads the locks. Left to the default it was "Internal server
|
|
32
|
+
* error" wherever a route did not wrap its errors, which included the Defaults page, the Project
|
|
33
|
+
* page and the model picker; a broken lock was that on the System page as well, until 2026-10-01.
|
|
34
|
+
*/
|
|
35
|
+
let SettingsUnusableFilter = class SettingsUnusableFilter extends BaseExceptionFilter {
|
|
36
|
+
catch(e, host) {
|
|
37
|
+
super.catch(new ConflictException(e.message), host);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
SettingsUnusableFilter = __decorate([
|
|
41
|
+
Catch(SettingsUnusableError, PolicyLockUnusableError)
|
|
42
|
+
], SettingsUnusableFilter);
|
|
16
43
|
/**
|
|
17
44
|
* Everything `main` used to do, as a function that returns the running server instead of owning the
|
|
18
45
|
* process. `main.ts` calls it and turns a failure into an exit code; the end-to-end checks call it
|
|
@@ -43,6 +70,7 @@ export async function startApi(opts = {}) {
|
|
|
43
70
|
exposedHeaders: ['content-disposition'],
|
|
44
71
|
});
|
|
45
72
|
app.setGlobalPrefix('api');
|
|
73
|
+
app.useGlobalFilters(new SettingsUnusableFilter(app.getHttpAdapter()));
|
|
46
74
|
/*
|
|
47
75
|
* Who may drive this. Mounted before routing so it covers every route including the SSE stream,
|
|
48
76
|
* and so that no controller has to remember. See `security.ts` for why "it is only on localhost"
|
|
@@ -76,17 +104,26 @@ export async function startApi(opts = {}) {
|
|
|
76
104
|
// The prebuilt interface, from the package: one process, one port, no dev server.
|
|
77
105
|
app.useStaticAssets(webDir, { extensions: ['html'], index: 'index.html' });
|
|
78
106
|
}
|
|
107
|
+
/*
|
|
108
|
+
* The settings, read before the port is open and before anything the last process left is
|
|
109
|
+
* recovered. A file the API cannot use stops it here, with the reason; read after the recovery,
|
|
110
|
+
* it stopped the API once the tasks were already marked aborted but not yet settled (their record
|
|
111
|
+
* goes in the runs folder the file names), and nothing comes back to settle them.
|
|
112
|
+
*/
|
|
113
|
+
const ops = app.get(OperatorService);
|
|
114
|
+
const cfg = await ops.settings.load().catch(async (e) => {
|
|
115
|
+
await app.close();
|
|
116
|
+
throw e;
|
|
117
|
+
});
|
|
79
118
|
await app.listen(PORT, '127.0.0.1');
|
|
80
119
|
// Close whatever the previous process left open before anyone can look at it, rather than
|
|
81
120
|
// waiting for the first request to trigger it.
|
|
82
|
-
const ops = app.get(OperatorService);
|
|
83
121
|
await ops.bootstrap();
|
|
84
122
|
/*
|
|
85
123
|
* runs/ holds every step's raw output and, on a failure, a screenshot of the signed-in page —
|
|
86
124
|
* as much the bot's records as data/ is, and until 2026-09-27 left with whatever permissions its
|
|
87
125
|
* parent folder had. Narrowed the same way, and pruned to the configured retention.
|
|
88
126
|
*/
|
|
89
|
-
const cfg = await ops.settings.load();
|
|
90
127
|
await mkdir(cfg.resolved.runsDir, { recursive: true });
|
|
91
128
|
const runsAcl = secureDataDir(cfg.resolved.runsDir);
|
|
92
129
|
if (!runsAcl.ok) {
|
|
@@ -101,14 +138,40 @@ export async function startApi(opts = {}) {
|
|
|
101
138
|
const pruned = await pruneRuns(cfg.resolved.runsDir, cfg.runsRetentionDays);
|
|
102
139
|
if (pruned.length > 0)
|
|
103
140
|
log(` removed ${pruned.length} run folder(s) older than ${cfg.runsRetentionDays} days (runsRetentionDays)`);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
141
|
+
const lines = startupLines({
|
|
142
|
+
served: webDir !== null,
|
|
143
|
+
mode: layout.mode,
|
|
144
|
+
port: PORT,
|
|
145
|
+
webOrigin: WEB_ORIGIN,
|
|
146
|
+
projectRoot: layout.projectRoot,
|
|
147
|
+
homeDir: layout.homeDir,
|
|
148
|
+
tokenFile: join(dataDir, 'api-token'),
|
|
149
|
+
});
|
|
150
|
+
for (const line of lines)
|
|
151
|
+
log(line);
|
|
113
152
|
return { app, port: PORT, token, dataDir, close: () => app.close() };
|
|
114
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* What the start-up tells the operator to open. Said by what is served, not by what the layout
|
|
156
|
+
* found, so the lines match the guard's choice in `startApi`.
|
|
157
|
+
*
|
|
158
|
+
* A clone's API serves no page, and `cop start` run in a clone ends up here as well as `npm start`
|
|
159
|
+
* does, so a clone's lines name the dev server that holds its interface. Without that, the only
|
|
160
|
+
* address in sight was this port, which answers a browser with a 401.
|
|
161
|
+
*/
|
|
162
|
+
export function startupLines(o) {
|
|
163
|
+
if (o.served) {
|
|
164
|
+
return [
|
|
165
|
+
`copilot-operator is running for ${o.projectRoot}`,
|
|
166
|
+
` open http://127.0.0.1:${o.port}/ in your browser`,
|
|
167
|
+
` records are kept in ${o.homeDir}`,
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
const lines = [
|
|
171
|
+
`copilot-operator api listening on http://127.0.0.1:${o.port}/api (web origin ${o.webOrigin})`,
|
|
172
|
+
` requests need the token in ${o.tokenFile}; npm start hands it to the UI`,
|
|
173
|
+
];
|
|
174
|
+
if (o.mode === 'checkout')
|
|
175
|
+
lines.push(` this port serves no web interface: a clone's is the dev server npm start runs, at ${o.webOrigin}`);
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
package/dist/src/api/settings.js
CHANGED
|
@@ -4,10 +4,83 @@
|
|
|
4
4
|
* The UI edits this file through the API. Anything not present falls back to the schema
|
|
5
5
|
* defaults, so an empty file is a valid, safe configuration.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import { existsSync } from 'node:fs';
|
|
7
|
+
import { mkdir } from 'node:fs/promises';
|
|
9
8
|
import { join, dirname } from 'node:path';
|
|
10
|
-
import { loadConfigObject } from '../config/schema.js';
|
|
9
|
+
import { loadConfigObject, RunConfigSchema } from '../config/schema.js';
|
|
10
|
+
import { readHandWritten } from '../config/handWritten.js';
|
|
11
|
+
import { writeFileAtomically } from '../session/store.js';
|
|
12
|
+
/**
|
|
13
|
+
* A settings file that is there and cannot be used: it cannot be read, it is not JSON, it is not an
|
|
14
|
+
* object of settings, or the schema refuses what it holds.
|
|
15
|
+
*
|
|
16
|
+
* Its own class because it is not the fault of the request that meets it, and every request that
|
|
17
|
+
* reads the settings meets it, not only the saves. As a plain error it was "Internal server error"
|
|
18
|
+
* on the Defaults page, the Project page and the model picker, the very pages the operator opens to
|
|
19
|
+
* find out what is wrong. The server answers this one the same way on every route, with this
|
|
20
|
+
* message (see `server.ts`), and `cop doctor` says it too, since the API will not start on it.
|
|
21
|
+
*/
|
|
22
|
+
export class SettingsUnusableError extends Error {
|
|
23
|
+
path;
|
|
24
|
+
problem;
|
|
25
|
+
constructor(path,
|
|
26
|
+
/** What is wrong, said of the file: "is not valid JSON (…)". */
|
|
27
|
+
problem) {
|
|
28
|
+
// What every refusal says, so the way out is in the same message.
|
|
29
|
+
super(`${path} ${problem}. Mend it, or delete it to start again from the defaults; nothing has been saved over it.`);
|
|
30
|
+
this.path = path;
|
|
31
|
+
this.problem = problem;
|
|
32
|
+
this.name = 'SettingsUnusableError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The text of a settings file as settings: `{}` when it is empty, an error when it is not an object
|
|
37
|
+
* of settings. The one reading of the file, for the API and for `cop doctor`, so the two cannot
|
|
38
|
+
* disagree about whether it is broken.
|
|
39
|
+
*
|
|
40
|
+
* A file that is there but cannot be read as settings is an error, never `{}`. Read as empty, it
|
|
41
|
+
* was written over by the next save of one setting — choosing a default model rewrote it as
|
|
42
|
+
* `{copilot: {defaultModel}}` — and everything else the operator had set was gone, without a word
|
|
43
|
+
* or a copy, while the program ran on the defaults in the meantime: another runs folder, no project,
|
|
44
|
+
* the stock limits.
|
|
45
|
+
*/
|
|
46
|
+
export function parseSettings(text, path) {
|
|
47
|
+
// A file saved by hand in Notepad or Windows PowerShell may start with a byte-order mark, which
|
|
48
|
+
// JSON.parse refuses; it is not part of the settings, and the file is not broken. Read through
|
|
49
|
+
// `readHandWritten`, the mark has gone already and said how the file is encoded, UTF-16 included;
|
|
50
|
+
// a text handed here with the mark still on it is read the same.
|
|
51
|
+
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
52
|
+
if (body.trim() === '')
|
|
53
|
+
return {};
|
|
54
|
+
let value;
|
|
55
|
+
try {
|
|
56
|
+
value = JSON.parse(body);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
throw new SettingsUnusableError(path, `is not valid JSON (${e.message})`);
|
|
60
|
+
}
|
|
61
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
62
|
+
throw new SettingsUnusableError(path, 'does not hold an object of settings');
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Refuses settings the schema does not allow, as a problem of the file they were read from. For
|
|
68
|
+
* what is on disk, never for a request's own edit, which is the request's problem (see `save`).
|
|
69
|
+
*/
|
|
70
|
+
export function checkSettings(value, path) {
|
|
71
|
+
const parsed = RunConfigSchema.safeParse(value);
|
|
72
|
+
if (parsed.success)
|
|
73
|
+
return;
|
|
74
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');
|
|
75
|
+
throw new SettingsUnusableError(path, `holds settings that are not allowed (${issues})`);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The settings of this install, with the defaults its layout implies: the one way they are built,
|
|
79
|
+
* for the API and for `cop doctor`, so the doctor reads them as the API that it vouches for will.
|
|
80
|
+
*/
|
|
81
|
+
export function settingsOf(layout) {
|
|
82
|
+
return new Settings(layout.projectRoot, layout.dataDir, layout.mode === 'package' ? { runsDir: layout.runsDir, level1File: join(layout.promptsDir, 'level1.md') } : {});
|
|
83
|
+
}
|
|
11
84
|
export class Settings {
|
|
12
85
|
projectRoot;
|
|
13
86
|
dataDir;
|
|
@@ -25,27 +98,95 @@ export class Settings {
|
|
|
25
98
|
this.installDefaults = installDefaults;
|
|
26
99
|
this.path = join(dataDir, 'settings.json');
|
|
27
100
|
}
|
|
28
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* The raw object as saved, or `{}` when there is no file yet (or an empty one). A file that is
|
|
103
|
+
* there and is not settings is refused by name (see `parseSettings`) until the operator mends it
|
|
104
|
+
* or deletes it.
|
|
105
|
+
*/
|
|
29
106
|
async raw() {
|
|
30
|
-
|
|
31
|
-
return {};
|
|
107
|
+
let text;
|
|
32
108
|
try {
|
|
33
|
-
|
|
109
|
+
text = await readHandWritten(this.path);
|
|
34
110
|
}
|
|
35
|
-
catch {
|
|
36
|
-
|
|
111
|
+
catch (e) {
|
|
112
|
+
if (e.code === 'ENOENT')
|
|
113
|
+
return {};
|
|
114
|
+
throw new SettingsUnusableError(this.path, `could not be read (${e.message})`);
|
|
37
115
|
}
|
|
116
|
+
return parseSettings(text, this.path);
|
|
38
117
|
}
|
|
39
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Parsed, defaulted and resolved against the project root.
|
|
120
|
+
*
|
|
121
|
+
* A file whose values the schema refuses stops here with the reason, as it always has; it is the
|
|
122
|
+
* file's problem, not the request's, so it is refused as one (see `SettingsUnusableError`).
|
|
123
|
+
* Anything else that stops the load is not this file's, and goes on as it is: a policy lock that
|
|
124
|
+
* does not parse is a `PolicyLockUnusableError`, answered by name on every route as this one is.
|
|
125
|
+
*/
|
|
40
126
|
async load() {
|
|
41
|
-
const
|
|
42
|
-
|
|
127
|
+
const value = { ...this.installDefaults, ...(await this.raw()), dataDir: this.dataDir };
|
|
128
|
+
try {
|
|
129
|
+
return await loadConfigObject(value, this.projectRoot, this.path);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
checkSettings(value, this.path);
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
43
135
|
}
|
|
44
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* Validates before saving, so a bad edit is rejected rather than stored.
|
|
138
|
+
*
|
|
139
|
+
* A file on disk that cannot be read is not written over either, even by a whole set of
|
|
140
|
+
* settings: the page that sends one built it on a copy read before the file broke, and whatever
|
|
141
|
+
* the operator has put in the file since, by hand, would go without a word, which is the silent
|
|
142
|
+
* replacement `raw` refuses, by another route.
|
|
143
|
+
*/
|
|
45
144
|
async save(value) {
|
|
46
|
-
|
|
145
|
+
return await this.write(() => value, 'replaces');
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Changes part of the settings: `change` is given what the file holds and returns all of it, as
|
|
149
|
+
* it is to be saved.
|
|
150
|
+
*
|
|
151
|
+
* For a save of one setting (the default model, the review model, the project), which must keep
|
|
152
|
+
* every other. The read and the write are one turn on the file, so two such saves at once each
|
|
153
|
+
* change what the other left: read before the turn, both read the same file, and the one written
|
|
154
|
+
* second put back the setting the first had just changed, though both answered 200. `change` is
|
|
155
|
+
* synchronous so it cannot wait for the file's turn from inside it; it may throw to refuse, and
|
|
156
|
+
* then nothing is written.
|
|
157
|
+
*/
|
|
158
|
+
async update(change) {
|
|
159
|
+
return await this.write(change, 'keeps');
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The one way the file is written: read, changed, validated and replaced in the file's turn (see
|
|
163
|
+
* `writeFileAtomically`). The read refuses a file that cannot be read, so nothing is ever saved
|
|
164
|
+
* over one. The write is atomic, because a crash in the middle of a plain write is one way a
|
|
165
|
+
* settings file stops parsing.
|
|
166
|
+
*/
|
|
167
|
+
async write(next, rest) {
|
|
47
168
|
await mkdir(dirname(this.path), { recursive: true });
|
|
48
|
-
|
|
169
|
+
let resolved;
|
|
170
|
+
await writeFileAtomically(this.path, async () => {
|
|
171
|
+
const raw = await this.raw();
|
|
172
|
+
const value = next(raw);
|
|
173
|
+
try {
|
|
174
|
+
resolved = await loadConfigObject({ ...this.installDefaults, ...value, dataDir: this.dataDir }, this.projectRoot, this.path);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
/*
|
|
178
|
+
* A save of one setting carries every other over from the file, so a value the schema
|
|
179
|
+
* refuses that was in the file already is refused again here. That is the file's problem,
|
|
180
|
+
* as `load` says of it, not the request's: it was answered as a bad request that blamed the
|
|
181
|
+
* new default model, without the way out, while a read of the same file named it. A whole
|
|
182
|
+
* set replaces what the file holds, and what the schema refuses in it is the request's own.
|
|
183
|
+
*/
|
|
184
|
+
if (rest === 'keeps')
|
|
185
|
+
checkSettings({ ...this.installDefaults, ...raw, dataDir: this.dataDir }, this.path);
|
|
186
|
+
throw e;
|
|
187
|
+
}
|
|
188
|
+
return JSON.stringify(value, null, 2);
|
|
189
|
+
});
|
|
49
190
|
return resolved;
|
|
50
191
|
}
|
|
51
192
|
/** The schema defaults, for the UI to show what "not set" means. */
|