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.
- package/LICENSE +21 -0
- package/README.md +556 -0
- package/dist/src/api/app.module.js +18 -0
- package/dist/src/api/dataAcl.js +109 -0
- package/dist/src/api/folderPicker.js +110 -0
- package/dist/src/api/main.js +132 -0
- package/dist/src/api/operator.controller.js +975 -0
- package/dist/src/api/operator.service.js +1971 -0
- package/dist/src/api/saveToDesktop.js +157 -0
- package/dist/src/api/security.js +225 -0
- package/dist/src/api/settings.js +57 -0
- package/dist/src/cli.js +531 -0
- package/dist/src/config/layout.js +66 -0
- package/dist/src/config/lockedPolicy.js +176 -0
- package/dist/src/config/schema.js +487 -0
- package/dist/src/context/contextFiles.js +295 -0
- package/dist/src/context/desktopMirror.js +113 -0
- package/dist/src/context/projectMirror.js +364 -0
- package/dist/src/exec/authorizer.js +61 -0
- package/dist/src/exec/checks.js +285 -0
- package/dist/src/exec/confinement.js +224 -0
- package/dist/src/exec/dangerous.js +161 -0
- package/dist/src/exec/environment.js +80 -0
- package/dist/src/exec/isolation.js +166 -0
- package/dist/src/exec/network.js +242 -0
- package/dist/src/exec/policy.js +201 -0
- package/dist/src/exec/policyManifest.js +84 -0
- package/dist/src/exec/processes.js +329 -0
- package/dist/src/exec/programs.js +290 -0
- package/dist/src/exec/redaction.js +93 -0
- package/dist/src/exec/reportFile.js +154 -0
- package/dist/src/exec/runner.js +245 -0
- package/dist/src/exec/scriptFiles.js +81 -0
- package/dist/src/exec/shellExecuteTrap.js +221 -0
- package/dist/src/exec/shells.js +298 -0
- package/dist/src/exec/stepEnv.js +72 -0
- package/dist/src/exec/winps.js +17 -0
- package/dist/src/exec/workDir.js +94 -0
- package/dist/src/log/runLog.js +43 -0
- package/dist/src/orchestrator/derivedChecks.js +95 -0
- package/dist/src/orchestrator/review.js +590 -0
- package/dist/src/orchestrator/taskRunner.js +1582 -0
- package/dist/src/plan/brief.js +1785 -0
- package/dist/src/plan/importPlan.js +202 -0
- package/dist/src/plan/schema.js +483 -0
- package/dist/src/plan/script.js +1012 -0
- package/dist/src/plan/systemGuide.js +1851 -0
- package/dist/src/protocol/parser.js +224 -0
- package/dist/src/protocol/replySchema.js +207 -0
- package/dist/src/protocol/reporter.js +75 -0
- package/dist/src/protocol/reviewSchema.js +205 -0
- package/dist/src/session/compose.js +67 -0
- package/dist/src/session/debugExport.js +165 -0
- package/dist/src/session/events.js +34 -0
- package/dist/src/session/exportRecord.js +261 -0
- package/dist/src/session/exports.js +484 -0
- package/dist/src/session/model.js +22 -0
- package/dist/src/session/retention.js +34 -0
- package/dist/src/session/runName.js +36 -0
- package/dist/src/session/store.js +440 -0
- package/dist/src/session/story.js +198 -0
- package/dist/src/transport/acceptance.js +34 -0
- package/dist/src/transport/chatSession.js +73 -0
- package/dist/src/transport/copilotTransport.js +1208 -0
- package/dist/src/transport/edgeCrash.js +59 -0
- package/dist/src/transport/locators.js +261 -0
- package/dist/src/transport/profileLock.js +102 -0
- package/dist/src/util/pacing.js +89 -0
- package/dist/src/vcs/commitHygiene.js +102 -0
- package/dist/src/vcs/git.js +286 -0
- package/dist/src/vcs/taskVcs.js +343 -0
- package/dist/web/404.html +15 -0
- package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
- package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
- package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
- package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
- package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
- package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
- package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
- package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
- package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
- package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
- package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
- package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
- package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
- package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
- package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
- package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
- package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
- package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
- package/dist/web/appearance.html +15 -0
- package/dist/web/appearance.txt +26 -0
- package/dist/web/defaults.html +15 -0
- package/dist/web/defaults.txt +26 -0
- package/dist/web/history.html +15 -0
- package/dist/web/history.txt +26 -0
- package/dist/web/import.html +15 -0
- package/dist/web/import.txt +26 -0
- package/dist/web/index.html +15 -0
- package/dist/web/index.txt +26 -0
- package/dist/web/level1.html +15 -0
- package/dist/web/level1.txt +26 -0
- package/dist/web/presets.html +18 -0
- package/dist/web/presets.txt +26 -0
- package/dist/web/sessions/view.html +15 -0
- package/dist/web/sessions/view.txt +26 -0
- package/dist/web/system.html +15 -0
- package/dist/web/system.txt +26 -0
- package/package.json +111 -0
- package/prompts/level1.md +535 -0
- package/prompts/organisation.bg.md +44 -0
- package/prompts/organisation.en.md +44 -0
- package/prompts/persona.bg.md +17 -0
- package/prompts/persona.en.md +17 -0
- package/prompts/review1.md +277 -0
- package/prompts/work.bg.md +19 -0
- package/prompts/work.en.md +19 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who, besides the operator, can read or change a folder the bot keeps its records in.
|
|
3
|
+
*
|
|
4
|
+
* `data/` holds the API token, the settings (the deny list, the allowlist, the isolation claim),
|
|
5
|
+
* every session and the level-1 contract that is sent to Copilot; `runs/` holds every step's raw
|
|
6
|
+
* output. Checked on 2026-09-27: under `C:\Projects` a folder inherits `Authenticated Users:
|
|
7
|
+
* Modify` — every account on the machine could read the token and rewrite what the bot will run.
|
|
8
|
+
* A token that any local account can read guards nothing, which `security.ts` had already said in
|
|
9
|
+
* as many words.
|
|
10
|
+
*
|
|
11
|
+
* So at start each folder is narrowed to the account the API runs as, plus SYSTEM (which backup
|
|
12
|
+
* and the machine's own protection need), with inheritance from the parent cut; and then it is
|
|
13
|
+
* read back, by SID so that a translated Windows gives the same answer, and the API refuses to
|
|
14
|
+
* start if anybody else is still on it. Administrators are deliberately not added: they can take
|
|
15
|
+
* ownership of anything, and granting them access by default is granting it to every elevated
|
|
16
|
+
* process.
|
|
17
|
+
*
|
|
18
|
+
* Written to work under PowerShell's Constrained Language Mode, which corporate machines commonly
|
|
19
|
+
* enforce (verified 2026-09-27): the ACL is read as SDDL through `Get-Acl`, a cmdlet, and the
|
|
20
|
+
* account's SID through `Get-CimInstance`, another — neither the .NET `DirectoryInfo` call the
|
|
21
|
+
* first version used nor `WindowsIdentity` is allowed there. `icacls` is a system executable.
|
|
22
|
+
*
|
|
23
|
+
* Windows only. Elsewhere the folder's mode is the owner's business and this does nothing.
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { winPsEnv } from '../exec/winps.js';
|
|
27
|
+
const SYSTEM_SID = 'S-1-5-18';
|
|
28
|
+
/** SDDL's two-letter names for well-known principals, as `Get-Acl` prints them. */
|
|
29
|
+
const SDDL_ALIASES = {
|
|
30
|
+
SY: 'S-1-5-18', // SYSTEM
|
|
31
|
+
BA: 'S-1-5-32-544', // Administrators
|
|
32
|
+
BU: 'S-1-5-32-545', // Users
|
|
33
|
+
AU: 'S-1-5-11', // Authenticated Users
|
|
34
|
+
WD: 'S-1-1-0', // Everyone
|
|
35
|
+
CO: 'S-1-3-0', // Creator Owner
|
|
36
|
+
CG: 'S-1-3-1', // Creator Group
|
|
37
|
+
OW: 'S-1-3-4', // Owner Rights
|
|
38
|
+
IU: 'S-1-5-4', // Interactive
|
|
39
|
+
RC: 'S-1-5-12', // Restricted Code
|
|
40
|
+
AN: 'S-1-5-7', // Anonymous
|
|
41
|
+
LS: 'S-1-5-19', // Local Service
|
|
42
|
+
NS: 'S-1-5-20', // Network Service
|
|
43
|
+
SO: 'S-1-5-32-549', // Server Operators
|
|
44
|
+
PU: 'S-1-5-32-547', // Power Users
|
|
45
|
+
RD: 'S-1-5-32-555', // Remote Desktop Users
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The SIDs an SDDL string's DACL grants anything to, aliases expanded. Only access-allowed
|
|
49
|
+
* entries count: a deny entry for somebody is not access. Exported for the check.
|
|
50
|
+
*/
|
|
51
|
+
export function principalsInSddl(sddl) {
|
|
52
|
+
const dacl = /D:([^:]*)/.exec(sddl)?.[1] ?? '';
|
|
53
|
+
const out = new Set();
|
|
54
|
+
for (const ace of dacl.matchAll(/\(([^)]*)\)/g)) {
|
|
55
|
+
const parts = ace[1].split(';');
|
|
56
|
+
const type = (parts[0] ?? '').trim().toUpperCase();
|
|
57
|
+
const sid = (parts[5] ?? '').trim();
|
|
58
|
+
if (!sid || (type !== 'A' && type !== 'OA'))
|
|
59
|
+
continue;
|
|
60
|
+
out.add(SDDL_ALIASES[sid.toUpperCase()] ?? sid);
|
|
61
|
+
}
|
|
62
|
+
return [...out];
|
|
63
|
+
}
|
|
64
|
+
/** Narrows `dir` to this account and SYSTEM, then checks nobody else is on it. */
|
|
65
|
+
export function secureDataDir(dir, env = process.env) {
|
|
66
|
+
if (process.platform !== 'win32')
|
|
67
|
+
return { ok: true };
|
|
68
|
+
const who = `${env.USERDOMAIN ?? ''}\\${env.USERNAME ?? ''}`;
|
|
69
|
+
spawnSync('icacls', [dir, '/inheritance:r', '/grant:r', `${who}:(OI)(CI)F`, '/grant:r', `*${SYSTEM_SID}:(OI)(CI)F`], {
|
|
70
|
+
stdio: 'ignore',
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
});
|
|
73
|
+
const quoted = dir.replace(/'/g, "''");
|
|
74
|
+
const user = (env.USERNAME ?? '').replace(/'/g, "''");
|
|
75
|
+
const domain = (env.USERDOMAIN ?? '').replace(/'/g, "''");
|
|
76
|
+
const read = spawnSync('powershell', [
|
|
77
|
+
'-NoProfile',
|
|
78
|
+
'-NonInteractive',
|
|
79
|
+
'-Command',
|
|
80
|
+
// The account's SID from WMI first (allowed in Constrained Language Mode), .NET second.
|
|
81
|
+
`$sid = (Get-CimInstance Win32_UserAccount -Filter "Name='${user}' AND Domain='${domain}'" -ErrorAction SilentlyContinue | Select-Object -First 1).SID; ` +
|
|
82
|
+
`if (-not $sid) { try { $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value } catch { $sid = '' } }; ` +
|
|
83
|
+
`$sddl = (Get-Acl -LiteralPath '${quoted}').Sddl; ` +
|
|
84
|
+
`@{ me = [string]$sid; sddl = [string]$sddl } | ConvertTo-Json -Compress`,
|
|
85
|
+
], { encoding: 'utf8', windowsHide: true, timeout: 30_000, env: winPsEnv(env) });
|
|
86
|
+
const unreadable = { ok: false, reason: `could not read the permissions of ${dir}` };
|
|
87
|
+
if (read.status !== 0)
|
|
88
|
+
return unreadable;
|
|
89
|
+
try {
|
|
90
|
+
const { me, sddl } = JSON.parse(read.stdout);
|
|
91
|
+
const principals = principalsInSddl(sddl);
|
|
92
|
+
// A rule that could not be read is not evidence of nobody; fail closed.
|
|
93
|
+
if (!me || principals.length === 0)
|
|
94
|
+
return unreadable;
|
|
95
|
+
const others = principals.filter((s) => s !== me && s !== SYSTEM_SID);
|
|
96
|
+
if (others.length > 0) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
reason: `${dir} can also be opened by ${others.join(', ')}. It holds the bot's records, so it must be readable by this ` +
|
|
100
|
+
`account (and SYSTEM) only. Remove the others with icacls, or move the folder (COP_DATA_DIR, runsDir) somewhere ` +
|
|
101
|
+
`private such as %LOCALAPPDATA%.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { ok: true };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return unreadable;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native "pick a folder" dialog, for the Browse button in the web UI.
|
|
3
|
+
*
|
|
4
|
+
* A browser cannot hand a page a real directory path: the file input gives file names and the
|
|
5
|
+
* File System Access API gives a handle, never `C:\Projects\my-app`. But the API is a local
|
|
6
|
+
* process on the same machine as the browser, so it can open Windows' own folder dialog and
|
|
7
|
+
* report back what was picked. That is the only way the UI can offer a picker and still end up
|
|
8
|
+
* with the absolute path the mirror needs.
|
|
9
|
+
*
|
|
10
|
+
* The dialog is Windows-only by nature. Anywhere else this reports that plainly and the user
|
|
11
|
+
* types the path, which has always worked.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
/** Nothing is interpolated into the script; the start folder goes in as an environment variable. */
|
|
16
|
+
const SCRIPT = `
|
|
17
|
+
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
|
18
|
+
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
19
|
+
$dialog.Description = 'Select the project folder'
|
|
20
|
+
$dialog.ShowNewFolderButton = $false
|
|
21
|
+
if ($env:COP_PICKER_START -and (Test-Path -LiteralPath $env:COP_PICKER_START)) {
|
|
22
|
+
$dialog.SelectedPath = $env:COP_PICKER_START
|
|
23
|
+
}
|
|
24
|
+
# An owner window that is always on top, so the dialog lands in front of the browser
|
|
25
|
+
# instead of behind it.
|
|
26
|
+
$owner = New-Object System.Windows.Forms.Form
|
|
27
|
+
$owner.TopMost = $true
|
|
28
|
+
$owner.ShowInTaskbar = $false
|
|
29
|
+
$owner.Opacity = 0
|
|
30
|
+
$owner.Show()
|
|
31
|
+
$result = $dialog.ShowDialog($owner)
|
|
32
|
+
$owner.Close()
|
|
33
|
+
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
|
|
34
|
+
[Console]::Out.Write('PICKED:' + $dialog.SelectedPath)
|
|
35
|
+
} else {
|
|
36
|
+
[Console]::Out.Write('CANCELLED')
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
/** Windows PowerShell first: it is always present and its dialog is the familiar one. */
|
|
40
|
+
function shellCandidates() {
|
|
41
|
+
return [
|
|
42
|
+
// No `-WindowStyle Hidden`: a hidden PowerShell is the first thing endpoint tooling hunts for, and
|
|
43
|
+
// the process is already started without a window (`windowsHide`), so the flag added nothing.
|
|
44
|
+
{ exe: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-STA', '-Command', SCRIPT] },
|
|
45
|
+
{ exe: 'pwsh.exe', args: ['-NoProfile', '-NonInteractive', '-STA', '-Command', SCRIPT] },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
/** Only one dialog at a time: a second one would sit behind the first with no way to reach it. */
|
|
49
|
+
let open = false;
|
|
50
|
+
export async function pickFolder(startDir, timeoutMs = 5 * 60_000) {
|
|
51
|
+
if (process.platform !== 'win32') {
|
|
52
|
+
return { ok: false, cancelled: false, reason: 'The folder dialog is Windows-only. Type the path instead.' };
|
|
53
|
+
}
|
|
54
|
+
if (open) {
|
|
55
|
+
return { ok: false, cancelled: false, reason: 'A folder dialog is already open. Finish it first.' };
|
|
56
|
+
}
|
|
57
|
+
open = true;
|
|
58
|
+
try {
|
|
59
|
+
for (const { exe, args } of shellCandidates()) {
|
|
60
|
+
const result = await run(exe, args, startDir, timeoutMs);
|
|
61
|
+
if (result.kind === 'unavailable')
|
|
62
|
+
continue;
|
|
63
|
+
return result.pick;
|
|
64
|
+
}
|
|
65
|
+
return { ok: false, cancelled: false, reason: 'PowerShell was not found, so the dialog could not be opened.' };
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
open = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function run(exe, args, startDir, timeoutMs) {
|
|
72
|
+
return new Promise((resolvePromise) => {
|
|
73
|
+
const child = spawn(exe, args, {
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
env: { ...process.env, COP_PICKER_START: startDir && existsSync(startDir) ? startDir : '' },
|
|
76
|
+
});
|
|
77
|
+
let out = '';
|
|
78
|
+
let err = '';
|
|
79
|
+
let settled = false;
|
|
80
|
+
const settle = (value) => {
|
|
81
|
+
if (settled)
|
|
82
|
+
return;
|
|
83
|
+
settled = true;
|
|
84
|
+
resolvePromise(value);
|
|
85
|
+
};
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
child.kill();
|
|
88
|
+
settle({ kind: 'done', pick: { ok: false, cancelled: false, reason: 'The folder dialog timed out.' } });
|
|
89
|
+
}, timeoutMs);
|
|
90
|
+
child.stdout.on('data', (d) => (out += d.toString()));
|
|
91
|
+
child.stderr.on('data', (d) => (err += d.toString()));
|
|
92
|
+
child.on('error', () => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
settle({ kind: 'unavailable' });
|
|
95
|
+
});
|
|
96
|
+
child.on('close', () => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
const text = out.trim();
|
|
99
|
+
if (text.startsWith('PICKED:')) {
|
|
100
|
+
settle({ kind: 'done', pick: { ok: true, path: text.slice('PICKED:'.length).trim() } });
|
|
101
|
+
}
|
|
102
|
+
else if (text === 'CANCELLED') {
|
|
103
|
+
settle({ kind: 'done', pick: { ok: false, cancelled: true } });
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
settle({ kind: 'done', pick: { ok: false, cancelled: false, reason: err.trim() || 'The folder dialog returned nothing.' } });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts the API on localhost.
|
|
3
|
+
*
|
|
4
|
+
* node dist/src/api/main.js
|
|
5
|
+
*
|
|
6
|
+
* Only 127.0.0.1 is bound, because this process drives the operator's own browser and runs
|
|
7
|
+
* commands on this machine. Exposing it on a network interface would hand that to anyone who
|
|
8
|
+
* could reach the port.
|
|
9
|
+
*/
|
|
10
|
+
import 'reflect-metadata';
|
|
11
|
+
import { NestFactory } from '@nestjs/core';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { AppModule } from './app.module.js';
|
|
14
|
+
import { OperatorService } from './operator.service.js';
|
|
15
|
+
import { ensureApiToken, localApiGuard } from './security.js';
|
|
16
|
+
import { secureDataDir } from './dataAcl.js';
|
|
17
|
+
import { pruneRuns } from '../session/retention.js';
|
|
18
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { installLayout } from '../config/layout.js';
|
|
21
|
+
const PORT = Number(process.env.COP_API_PORT ?? 4000);
|
|
22
|
+
const WEB_ORIGIN = process.env.COP_WEB_ORIGIN ?? 'http://localhost:3210';
|
|
23
|
+
async function main() {
|
|
24
|
+
const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'] });
|
|
25
|
+
// A plan pasted from a chat carries every task's text at once, which the 100 KB default
|
|
26
|
+
// refuses with a message about entity size that says nothing about plans.
|
|
27
|
+
app.useBodyParser('json', { limit: '5mb' });
|
|
28
|
+
app.enableCors({
|
|
29
|
+
origin: [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1')],
|
|
30
|
+
/*
|
|
31
|
+
* The one header the page has to be able to read.
|
|
32
|
+
*
|
|
33
|
+
* A browser hands script only a handful of response headers across origins, and
|
|
34
|
+
* `content-disposition` is not among them unless it is named here. The UI is on
|
|
35
|
+
* `localhost:3210` and this is on `127.0.0.1:4000`, so every fetch is cross-origin: without
|
|
36
|
+
* this, a download built from a blob cannot find out what the file is called, and the
|
|
37
|
+
* carefully composed `copilot-operator-bundle-2-tasks-2-sessions-<stamp>.json` arrives as
|
|
38
|
+
* whatever the client guessed. The plain `<a download>` exports never noticed, because the
|
|
39
|
+
* browser reads the header itself for those and never shows it to the page.
|
|
40
|
+
*/
|
|
41
|
+
exposedHeaders: ['content-disposition'],
|
|
42
|
+
});
|
|
43
|
+
app.setGlobalPrefix('api');
|
|
44
|
+
/*
|
|
45
|
+
* Who may drive this. Mounted before routing so it covers every route including the SSE stream,
|
|
46
|
+
* and so that no controller has to remember. See `security.ts` for why "it is only on localhost"
|
|
47
|
+
* was never the whole answer — a page in the operator's ordinary browser and any other process
|
|
48
|
+
* on the machine were both callers all along.
|
|
49
|
+
*/
|
|
50
|
+
const layout = installLayout();
|
|
51
|
+
const dataDir = layout.dataDir;
|
|
52
|
+
await mkdir(dataDir, { recursive: true });
|
|
53
|
+
if (layout.mode === 'package') {
|
|
54
|
+
/*
|
|
55
|
+
* The records folder sits inside the project, so it must be invisible to the project's git:
|
|
56
|
+
* version control refuses to branch on a tree with untracked files, and the records are not
|
|
57
|
+
* the project's work. A `.gitignore` of `*` inside the folder says so without touching the
|
|
58
|
+
* project's own .gitignore.
|
|
59
|
+
*/
|
|
60
|
+
const ignore = join(layout.homeDir, '.gitignore');
|
|
61
|
+
if (!existsSync(ignore))
|
|
62
|
+
await writeFile(ignore, '*\n', 'utf8');
|
|
63
|
+
}
|
|
64
|
+
// Before the token is read: a token any local account can read guards nothing. See `dataAcl.ts`.
|
|
65
|
+
const acl = secureDataDir(dataDir);
|
|
66
|
+
if (!acl.ok) {
|
|
67
|
+
console.error(`copilot-operator api will not start: ${acl.reason}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const token = await ensureApiToken(dataDir);
|
|
71
|
+
// The dev UI's origin, and this process's own for the interface it serves itself.
|
|
72
|
+
const allowedOrigins = [WEB_ORIGIN, WEB_ORIGIN.replace('localhost', '127.0.0.1'), `http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`];
|
|
73
|
+
app.use(localApiGuard({ token, port: PORT, allowedOrigins, servesInterface: layout.webDir !== null }));
|
|
74
|
+
if (layout.webDir) {
|
|
75
|
+
// The prebuilt interface, from the package: one process, one port, no dev server.
|
|
76
|
+
app.useStaticAssets(layout.webDir, { extensions: ['html'], index: 'index.html' });
|
|
77
|
+
}
|
|
78
|
+
await app.listen(PORT, '127.0.0.1');
|
|
79
|
+
// Close whatever the previous process left open before anyone can look at it, rather than
|
|
80
|
+
// waiting for the first request to trigger it.
|
|
81
|
+
const ops = app.get(OperatorService);
|
|
82
|
+
await ops.bootstrap();
|
|
83
|
+
/*
|
|
84
|
+
* runs/ holds every step's raw output and, on a failure, a screenshot of the signed-in page —
|
|
85
|
+
* as much the bot's records as data/ is, and until 2026-09-27 left with whatever permissions its
|
|
86
|
+
* parent folder had. Narrowed the same way, and pruned to the configured retention.
|
|
87
|
+
*/
|
|
88
|
+
const cfg = await ops.settings.load();
|
|
89
|
+
await mkdir(cfg.resolved.runsDir, { recursive: true });
|
|
90
|
+
const runsAcl = secureDataDir(cfg.resolved.runsDir);
|
|
91
|
+
if (!runsAcl.ok) {
|
|
92
|
+
console.error(`copilot-operator api will not start: ${runsAcl.reason}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
for (const [name, dir] of [['data', dataDir], ['runs', cfg.resolved.runsDir]]) {
|
|
96
|
+
if (/onedrive/i.test(dir) || /^\\\\/.test(dir)) {
|
|
97
|
+
console.warn(` warning: the ${name} folder (${dir}) is under OneDrive or on a network share; keep the bot's records on a local disk (COP_DATA_DIR, runsDir)`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const pruned = await pruneRuns(cfg.resolved.runsDir, cfg.runsRetentionDays);
|
|
101
|
+
if (pruned.length > 0)
|
|
102
|
+
console.log(` removed ${pruned.length} run folder(s) older than ${cfg.runsRetentionDays} days (runsRetentionDays)`);
|
|
103
|
+
if (layout.webDir) {
|
|
104
|
+
console.log(`copilot-operator is running for ${layout.projectRoot}`);
|
|
105
|
+
console.log(` open http://127.0.0.1:${PORT}/ in your browser`);
|
|
106
|
+
console.log(` records are kept in ${layout.homeDir}`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.log(`copilot-operator api listening on http://127.0.0.1:${PORT}/api (web origin ${WEB_ORIGIN})`);
|
|
110
|
+
console.log(` requests need the token in ${join(dataDir, 'api-token')}; npm start hands it to the UI`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/*
|
|
114
|
+
* A stray rejection must not end a run.
|
|
115
|
+
*
|
|
116
|
+
* Node's default for an unhandled rejection is to crash, which for this process means killing
|
|
117
|
+
* the browser, the queue and every session still waiting behind it — for a promise nobody was
|
|
118
|
+
* even reading any more. That happened: a losing `Promise.race` branch inside a download
|
|
119
|
+
* rejected when the window closed and took a batch of three sessions with it. The cause is
|
|
120
|
+
* fixed where it was, but the class of fault is not worth dying for, so it is logged loudly and
|
|
121
|
+
* the process carries on. A task that genuinely failed still fails, through its own error path.
|
|
122
|
+
*/
|
|
123
|
+
process.on('unhandledRejection', (reason) => {
|
|
124
|
+
console.error('[api] unhandled rejection (the run continues):', reason instanceof Error ? reason.stack : reason);
|
|
125
|
+
});
|
|
126
|
+
process.on('uncaughtException', (error) => {
|
|
127
|
+
console.error('[api] uncaught exception (the run continues):', error.stack ?? error.message);
|
|
128
|
+
});
|
|
129
|
+
main().catch((e) => {
|
|
130
|
+
console.error(e.message);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
});
|