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
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 {
|
|
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 {
|
|
27
|
-
import {
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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() ? '' : ' —
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
126
|
-
* lock something down and mistyped it must not silently run unlocked,
|
|
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
|
|
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
|
|
187
|
+
text = await readHandWritten(path);
|
|
160
188
|
}
|
|
161
|
-
catch {
|
|
162
|
-
|
|
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
|
|
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
|
|
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
|
}
|