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,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy an administrator sets and the operator cannot loosen.
|
|
3
|
+
*
|
|
4
|
+
* `data/settings.json` belongs to whoever is sitting at the machine, and that is right for almost
|
|
5
|
+
* everything in it. It is wrong for the part that decides what this tool may run, because the
|
|
6
|
+
* person at the machine is also the person under time pressure to get a run finished, and because
|
|
7
|
+
* a company deploying this to a fleet needs to state a floor that a local edit cannot go under.
|
|
8
|
+
* Without that, "the allowlist is enforced" is a sentence about one machine on one afternoon.
|
|
9
|
+
*
|
|
10
|
+
* So a deployment may ship `policy.lock.json` beside the configuration. Every field is optional
|
|
11
|
+
* and every field can only *tighten*:
|
|
12
|
+
*
|
|
13
|
+
* maxMode `confirm` forbids unattended runs outright.
|
|
14
|
+
* allowedPrograms a ceiling: the effective list is the intersection with the operator's,
|
|
15
|
+
* and an operator who empties their own list gets the lock's rather than
|
|
16
|
+
* the gate switched off, because empty means "no allowlist" and that is
|
|
17
|
+
* the one direction a lock must never permit.
|
|
18
|
+
* denyPatterns a floor: merged in, so a lock pattern cannot be deleted locally.
|
|
19
|
+
*
|
|
20
|
+
* The runner never writes this file. There is no API route that edits it and no UI that shows it
|
|
21
|
+
* as editable; it is placed by whoever administers the machine, and on a machine that has none
|
|
22
|
+
* nothing changes, so an existing install keeps working exactly as before.
|
|
23
|
+
*
|
|
24
|
+
* The honest limit, again: a local administrator can delete the file, and this is not protection
|
|
25
|
+
* against the machine's owner. It is protection against the ordinary drift — a setting widened for
|
|
26
|
+
* one task and never put back — and it gives a security team a single artefact to point at.
|
|
27
|
+
*/
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
import { readFile } from 'node:fs/promises';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
export const POLICY_LOCK_FILE = 'policy.lock.json';
|
|
32
|
+
export const PolicyLockSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
maxMode: z.enum(['confirm', 'unattended']).optional(),
|
|
35
|
+
allowedPrograms: z.array(z.string()).optional(),
|
|
36
|
+
denyPatterns: z.array(z.string()).optional(),
|
|
37
|
+
/** `true` treats the claim `none-accepted` as `none`: unattended runs need real isolation here. */
|
|
38
|
+
requireIsolation: z.boolean().optional(),
|
|
39
|
+
/** `false` switches the Desktop mirror off, whatever the settings say. */
|
|
40
|
+
allowDesktopMirror: z.boolean().optional(),
|
|
41
|
+
/** `false` keeps `.env` files out of every mirror and every attachment. */
|
|
42
|
+
allowEnvFiles: z.boolean().optional(),
|
|
43
|
+
/** A ceiling on `execution.passEnv`: the only variables a step may be given beyond the fixed set. */
|
|
44
|
+
passEnv: z.array(z.string()).optional(),
|
|
45
|
+
/** What `npm run update` must insist on: signed commits, and one remote no flag can override. */
|
|
46
|
+
update: z
|
|
47
|
+
.object({
|
|
48
|
+
requireSigned: z.boolean().optional(),
|
|
49
|
+
remote: z.string().optional(),
|
|
50
|
+
})
|
|
51
|
+
.strict()
|
|
52
|
+
.optional(),
|
|
53
|
+
})
|
|
54
|
+
.strict();
|
|
55
|
+
function lowerSet(values) {
|
|
56
|
+
return new Set(values.map((v) => v.trim().toLowerCase()).filter(Boolean));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A ceiling list: the operator's entries that the lock also permits. An empty operator list is
|
|
60
|
+
* treated as "unset" rather than as "allow nothing", because for `allowedPrograms` an empty list
|
|
61
|
+
* turns the gate off — the loosest setting there is — and a ceiling that could be escaped by
|
|
62
|
+
* clearing the field would not be a ceiling.
|
|
63
|
+
*/
|
|
64
|
+
function intersect(operator, ceiling) {
|
|
65
|
+
if (operator.length === 0)
|
|
66
|
+
return [...ceiling];
|
|
67
|
+
const allowed = lowerSet(ceiling);
|
|
68
|
+
return operator.filter((v) => allowed.has(v.trim().toLowerCase()));
|
|
69
|
+
}
|
|
70
|
+
/** Applies a lock to a policy, returning the tightened policy and what it changed. */
|
|
71
|
+
export function applyPolicyLock(policy, lock) {
|
|
72
|
+
if (!lock)
|
|
73
|
+
return { policy, outcome: { applied: false, changes: [] } };
|
|
74
|
+
const changes = [];
|
|
75
|
+
const next = { ...policy };
|
|
76
|
+
if (lock.maxMode === 'confirm' && next.mode !== 'confirm') {
|
|
77
|
+
next.mode = 'confirm';
|
|
78
|
+
changes.push('mode forced to confirm (the lock forbids unattended runs)');
|
|
79
|
+
}
|
|
80
|
+
if (lock.allowedPrograms) {
|
|
81
|
+
const before = next.allowedPrograms;
|
|
82
|
+
next.allowedPrograms = intersect(before, lock.allowedPrograms);
|
|
83
|
+
if (before.length === 0)
|
|
84
|
+
changes.push(`allowlist enforced with the lock's ${next.allowedPrograms.length} programs (it was switched off locally)`);
|
|
85
|
+
else if (next.allowedPrograms.length !== before.length) {
|
|
86
|
+
changes.push(`allowlist narrowed from ${before.length} to ${next.allowedPrograms.length} programs`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (lock.denyPatterns?.length) {
|
|
90
|
+
const have = lowerSet(next.denyPatterns);
|
|
91
|
+
const added = lock.denyPatterns.filter((p) => !have.has(p.trim().toLowerCase()));
|
|
92
|
+
if (added.length) {
|
|
93
|
+
next.denyPatterns = [...next.denyPatterns, ...added];
|
|
94
|
+
changes.push(`${added.length} deny pattern(s) added by the lock`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (lock.requireIsolation && next.isolation === 'none-accepted') {
|
|
98
|
+
next.isolation = 'none';
|
|
99
|
+
changes.push('isolation "none-accepted" treated as "none" (the lock requires real isolation for unattended runs)');
|
|
100
|
+
}
|
|
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
|
+
if (lock.passEnv && next.passEnv) {
|
|
112
|
+
// Not `intersect`: an empty operator list here means "nothing extra", the tightest setting.
|
|
113
|
+
const allowed = lowerSet(lock.passEnv);
|
|
114
|
+
const kept = next.passEnv.filter((v) => allowed.has(v.trim().toLowerCase()));
|
|
115
|
+
if (kept.length !== next.passEnv.length) {
|
|
116
|
+
changes.push(`passEnv narrowed from ${next.passEnv.length} to ${kept.length} variable(s) by the lock`);
|
|
117
|
+
next.passEnv = kept;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { policy: next, outcome: { applied: true, changes, maxMode: lock.maxMode } };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Reads `policy.lock.json` from a directory, or null when there is none.
|
|
124
|
+
*
|
|
125
|
+
* A file that is present but malformed is an error and not a shrug: a deployment that meant to
|
|
126
|
+
* lock something down and mistyped it must not silently run unlocked, because the one thing worse
|
|
127
|
+
* than no lock is a lock everybody believes in.
|
|
128
|
+
*/
|
|
129
|
+
export async function readPolicyLock(baseDir) {
|
|
130
|
+
return await readPolicyLockAt(join(baseDir, POLICY_LOCK_FILE));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Where a machine-wide lock lives: `%ProgramData%\copilot-operator\policy.lock.json`, a folder an
|
|
134
|
+
* administrator can protect and the operator cannot edit. A lock beside the install (the form
|
|
135
|
+
* this started with) is honoured too, but the install folder is the operator's own, so a company
|
|
136
|
+
* that means "not on this machine" puts the file here. Null where there is no ProgramData.
|
|
137
|
+
*/
|
|
138
|
+
export function machineLockPath(env = process.env) {
|
|
139
|
+
const base = env.ProgramData ?? env.PROGRAMDATA;
|
|
140
|
+
return base ? join(base, 'copilot-operator', POLICY_LOCK_FILE) : null;
|
|
141
|
+
}
|
|
142
|
+
/** Every lock that applies: the machine-wide one, then the install's own. Each is malformed-fatal. */
|
|
143
|
+
export async function readPolicyLocks(baseDir, env = process.env) {
|
|
144
|
+
const out = [];
|
|
145
|
+
const machine = machineLockPath(env);
|
|
146
|
+
if (machine) {
|
|
147
|
+
const lock = await readPolicyLockAt(machine);
|
|
148
|
+
if (lock)
|
|
149
|
+
out.push(lock);
|
|
150
|
+
}
|
|
151
|
+
const local = await readPolicyLock(baseDir);
|
|
152
|
+
if (local)
|
|
153
|
+
out.push(local);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
async function readPolicyLockAt(path) {
|
|
157
|
+
let text;
|
|
158
|
+
try {
|
|
159
|
+
text = await readFile(path, 'utf8');
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
let value;
|
|
165
|
+
try {
|
|
166
|
+
value = JSON.parse(text);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
throw new Error(`${path} is not valid JSON: ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
const parsed = PolicyLockSchema.safeParse(value);
|
|
172
|
+
if (!parsed.success) {
|
|
173
|
+
throw new Error(`${path} is not a valid policy lock:\n${parsed.error.issues.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n')}`);
|
|
174
|
+
}
|
|
175
|
+
return parsed.data;
|
|
176
|
+
}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run configuration: `run.yaml` for the terminal, `data/settings.json` for the API.
|
|
3
|
+
*
|
|
4
|
+
* Both are the same shape. Defaults are chosen so that a minimal file (a task and nothing
|
|
5
|
+
* else) already produces a safe run: confirm mode on, env files excluded, destructive
|
|
6
|
+
* commands denied.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { parse as parseYaml } from 'yaml';
|
|
11
|
+
import { applyPolicyLock, readPolicyLocks } from './lockedPolicy.js';
|
|
12
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
/** Expands a leading `~` and resolves relative paths against a base directory. */
|
|
15
|
+
export function expandPath(p, baseDir) {
|
|
16
|
+
const expanded = p.startsWith('~') ? join(homedir(), p.slice(1).replace(/^[\\/]/, '')) : p;
|
|
17
|
+
return isAbsolute(expanded) ? resolve(expanded) : resolve(baseDir, expanded);
|
|
18
|
+
}
|
|
19
|
+
/** Text given inline or as a file path. */
|
|
20
|
+
const TextOrFile = z.union([z.string(), z.object({ file: z.string().min(1) })]);
|
|
21
|
+
/*
|
|
22
|
+
* GIT_WRITE_NOTE — why the deny list knows about git.
|
|
23
|
+
*
|
|
24
|
+
* Version control here is the runner's job: it cuts the branch before a task and makes the
|
|
25
|
+
* commit after it, and level 1 tells Copilot in plain words that git is not its business. That
|
|
26
|
+
* was prose, and prose is advice. It broke: a task with a badly written check — "nothing has
|
|
27
|
+
* been pushed", tested by looking for the word origin in `git remote -v` — could not pass in a
|
|
28
|
+
* repository that legitimately had a remote. The failure went back to the chat, Copilot spent
|
|
29
|
+
* fifteen iterations proving the check was wrong, and then ran `git remote remove origin` to
|
|
30
|
+
* make it pass. It did pass. The repository lost its remote.
|
|
31
|
+
*
|
|
32
|
+
* That is the shape of the problem and it is not about one bad check: a failing check applies
|
|
33
|
+
* pressure toward making it pass, and the cheapest way to satisfy a claim about a repository is
|
|
34
|
+
* often to change the repository. So the rule stops being advice. A step that would change
|
|
35
|
+
* anything in git is refused before it reaches the shell, whatever the chat has concluded.
|
|
36
|
+
*
|
|
37
|
+
* Read-only git stays allowed, and deliberately so — auditing a repository is real work a task
|
|
38
|
+
* may be given. The patterns therefore match on the subcommand's own position, after git's
|
|
39
|
+
* global options, rather than anywhere in the line: `git log --grep=commit` is a question and
|
|
40
|
+
* `git commit` is a change, and a regex that cannot tell them apart would make the audit
|
|
41
|
+
* impossible in order to make the audit safe.
|
|
42
|
+
*
|
|
43
|
+
* The runner's own git does not pass through here. It runs through `execFile` in `vcs/git.ts`
|
|
44
|
+
* with an argument list and no shell, so nothing in this list can stop the branch and the
|
|
45
|
+
* commit that are supposed to happen.
|
|
46
|
+
*/
|
|
47
|
+
/** One of git's own options, before the subcommand: `-C <path>`, `--no-pager`, `-c k=v`. */
|
|
48
|
+
const GIT_GLOBAL_OPTION = '(?:--no-pager|--paginate|--bare|--literal-pathspecs|--exec-path=\\S*|-c\\s+\\S+|' +
|
|
49
|
+
'-C\\s+(?:"[^"]*"|\'[^\']*\'|\\S+)|--git-dir[= ]\\S+|--work-tree[= ]\\S+)';
|
|
50
|
+
/** Subcommands that have no read-only form at all. */
|
|
51
|
+
const GIT_ALWAYS_WRITES = 'commit|push|reset|rebase|merge|cherry-pick|revert|clean|checkout|switch|restore|stash|' +
|
|
52
|
+
'am|apply|init|clone|filter-branch|filter-repo|update-ref|update-index|gc|prune|repack|' +
|
|
53
|
+
'submodule|worktree|mv|rm|add|pull';
|
|
54
|
+
/** Subcommands that read by default and write with these arguments. */
|
|
55
|
+
const GIT_SOMETIMES_WRITES = 'remote\\s+(?:add|remove|rm|rename|set-url|set-head|set-branches|prune)\\b|' +
|
|
56
|
+
'branch\\s+(?:-[dDmMfu]\\b|--(?:delete|move|copy|force|set-upstream-to|unset-upstream))|' +
|
|
57
|
+
'tag\\s+(?:-d\\b|-f\\b|--(?:delete|force))|' +
|
|
58
|
+
'config\\s+(?:--unset|--unset-all|--replace-all|--add|--rename-section|--remove-section|--edit)|' +
|
|
59
|
+
// Setting a value in the plain form — `git config <key> <value>`, with or without a scope — and
|
|
60
|
+
// git 2.46's subcommands. A key set this way (core.hooksPath, core.fsmonitor, a filter) is code
|
|
61
|
+
// the runner's own git would then run; see src/vcs/git.ts.
|
|
62
|
+
'config\\s+(?:--(?:local|global|system|worktree)\\s+|--file\\s+\\S+\\s+)?[A-Za-z][\\w.-]*\\s+[^\\s|;]|' +
|
|
63
|
+
'config\\s+(?:set|unset|rename-section|remove-section|edit)\\b|' +
|
|
64
|
+
'reflog\\s+(?:delete|expire)|' +
|
|
65
|
+
'notes\\s+(?:add|append|edit|remove|copy|prune)';
|
|
66
|
+
/** Which directories of one project are copied to the Desktop, and how. */
|
|
67
|
+
const ProjectMirrorSelection = z.object({
|
|
68
|
+
includeDirs: z.array(z.string()).default([]),
|
|
69
|
+
excludeDirs: z.array(z.string()).default([]),
|
|
70
|
+
respectGitignore: z.boolean().default(true),
|
|
71
|
+
includeEnvFiles: z.boolean().default(false),
|
|
72
|
+
});
|
|
73
|
+
export const RunConfigSchema = z.object({
|
|
74
|
+
copilot: z
|
|
75
|
+
.object({
|
|
76
|
+
url: z.string().default('https://m365.cloud.microsoft/chat'),
|
|
77
|
+
profileDir: z.string().default('~/AppData/Local/copilot-operator/edge-profile'),
|
|
78
|
+
channel: z.enum(['msedge', 'chrome', 'chromium']).default('msedge'),
|
|
79
|
+
/** Word that ends a task when Copilot writes it. */
|
|
80
|
+
stopMarker: z.string().default('Край'),
|
|
81
|
+
/** Short label; becomes the session name when run from the terminal. */
|
|
82
|
+
label: z.string().default('run'),
|
|
83
|
+
/**
|
|
84
|
+
* The model a new session starts on, by the exact name the chat's picker shows.
|
|
85
|
+
*
|
|
86
|
+
* Not an enum, and not validated against a list: the list belongs to Microsoft, differs
|
|
87
|
+
* per tenant and changes without notice. An empty value means the chat is left on
|
|
88
|
+
* whatever it is already set to, which is the behaviour this project had before models
|
|
89
|
+
* could be chosen at all.
|
|
90
|
+
*/
|
|
91
|
+
defaultModel: z.string().default(''),
|
|
92
|
+
/**
|
|
93
|
+
* The model a new session's independent review starts on. Empty means the session's own
|
|
94
|
+
* model reviews its own work, which is the weaker choice: a fresh conversation removes
|
|
95
|
+
* attachment to the work, not the model's blind spots. Kept apart from `defaultModel`
|
|
96
|
+
* because the two are chosen for opposite reasons — one for the work, one for being
|
|
97
|
+
* different from whatever did the work.
|
|
98
|
+
*/
|
|
99
|
+
defaultReviewModel: z.string().default(''),
|
|
100
|
+
replyTimeoutSec: z.number().int().positive().default(900),
|
|
101
|
+
signInTimeoutSec: z.number().int().positive().default(900),
|
|
102
|
+
humanWaitSec: z.number().int().positive().default(900),
|
|
103
|
+
headless: z.boolean().default(false),
|
|
104
|
+
/**
|
|
105
|
+
* Whether a failure dump keeps the full HTML of the signed-in page. Off since 2026-09-27: the
|
|
106
|
+
* page carries the titles of the account's other conversations and its sign-in state, and
|
|
107
|
+
* runs/ is a folder on disk. The screenshot and the URL are always kept.
|
|
108
|
+
*/
|
|
109
|
+
keepFailurePage: z.boolean().default(false),
|
|
110
|
+
})
|
|
111
|
+
.prefault({}),
|
|
112
|
+
/** Level 1: the base prompt about how the runner works. Shipped with the project; editable in the UI. */
|
|
113
|
+
level1File: z.string().default('prompts/level1.md'),
|
|
114
|
+
/** Level 2: the user's project, domain and team instructions for this task. */
|
|
115
|
+
level2: TextOrFile.optional(),
|
|
116
|
+
/** The task. Required for a terminal run; the API supplies it per task. */
|
|
117
|
+
task: TextOrFile.optional(),
|
|
118
|
+
execution: z
|
|
119
|
+
.object({
|
|
120
|
+
mode: z.enum(['confirm', 'unattended']).default('confirm'),
|
|
121
|
+
/**
|
|
122
|
+
* The shell a step or a check runs in when it names none of its own.
|
|
123
|
+
*
|
|
124
|
+
* A preference rather than an instruction. A machine without PowerShell 7 falls through to
|
|
125
|
+
* Windows PowerShell and then to `cmd` rather than failing every command with a spawn
|
|
126
|
+
* error over a default nobody chose for this particular task. A shell that a reply or a
|
|
127
|
+
* plan does name for itself is honoured or refused and never exchanged — see `exec/shells.ts`.
|
|
128
|
+
*/
|
|
129
|
+
defaultShell: z.enum(['pwsh', 'powershell', 'cmd']).default('pwsh'),
|
|
130
|
+
cwd: z.string().default('.'),
|
|
131
|
+
commandTimeoutSec: z.number().int().positive().default(300),
|
|
132
|
+
idleTimeoutSec: z.number().int().positive().default(60),
|
|
133
|
+
longCommandTimeoutSec: z.number().int().positive().default(14_400),
|
|
134
|
+
longIdleTimeoutSec: z.number().int().positive().default(900),
|
|
135
|
+
maxStepTimeoutSec: z.number().int().positive().default(28_800),
|
|
136
|
+
stopOnFailure: z.boolean().default(false),
|
|
137
|
+
/**
|
|
138
|
+
* What a queue of tasks does when one of them does not end with a summary.
|
|
139
|
+
*
|
|
140
|
+
* False, the default, makes the queue a chain: the rest stay queued. True treats the
|
|
141
|
+
* tasks as independent. Sessions carry their own copy of this choice; this is the value
|
|
142
|
+
* a session created from the terminal starts with.
|
|
143
|
+
*/
|
|
144
|
+
continueOnFailure: z.boolean().default(false),
|
|
145
|
+
/**
|
|
146
|
+
* What the operator has arranged to contain this runner. An assertion, recorded as one.
|
|
147
|
+
*
|
|
148
|
+
* Every other setting here is a rule about what a command may say; this one is about the only
|
|
149
|
+
* thing that contains a command once it runs. A process cannot see the boundary it is inside,
|
|
150
|
+
* so it cannot check this — it can only record what was claimed, note the account it actually
|
|
151
|
+
* holds, and say when the two disagree. See `exec/isolation.ts`.
|
|
152
|
+
*
|
|
153
|
+
* `none` is the default because it is the truth on a machine where nobody has arranged
|
|
154
|
+
* anything, and a default that flattered the situation would be worse than none. Its one
|
|
155
|
+
* consequence: an unattended run will not start while it is `none`, because nobody watching
|
|
156
|
+
* and nothing containing is the combination this runner refuses to be.
|
|
157
|
+
*/
|
|
158
|
+
isolation: z.enum(['none', 'none-accepted', 'separate-account', 'sandbox', 'vm']).default('none'),
|
|
159
|
+
/**
|
|
160
|
+
* The external programs a command may start. The one gate in this runner that names what is
|
|
161
|
+
* allowed rather than what is not: for a tool whose whole job is to build and test software,
|
|
162
|
+
* the toolchain is a finite, nameable set, where the things that could go wrong are not. A
|
|
163
|
+
* command whose head is a program not on this list is refused and sent back to the chat with
|
|
164
|
+
* the reason, so a legitimate tool this project happens to need is a one-line addition here,
|
|
165
|
+
* not a hole to be left open for everyone. See `exec/programs.ts` for how a head is found and
|
|
166
|
+
* why a cmdlet, an alias or a keyword is never mistaken for a program.
|
|
167
|
+
*
|
|
168
|
+
* This is not a boundary — allowing `node` allows `node -e`, and it sits on top of
|
|
169
|
+
* `dangerous.ts`, never instead of it. It raises the floor against the unknown binary and the
|
|
170
|
+
* living-off-the-land tool the deny list has not caught up with. An empty list turns it off.
|
|
171
|
+
*
|
|
172
|
+
* The default covers the common JavaScript, .NET, Java, Python, Go and Rust toolchains, the
|
|
173
|
+
* shells, git, and the handful of Windows utilities a build or test legitimately reaches for.
|
|
174
|
+
* A project that needs more adds it; a machine that wants none clears the list.
|
|
175
|
+
*/
|
|
176
|
+
/**
|
|
177
|
+
* Environment variables a command step is given beyond the fixed set in `stepEnv.ts`, by name.
|
|
178
|
+
* Steps no longer inherit the bot's environment; a project that needs, say, DATABASE_URL names
|
|
179
|
+
* it here. The bot's own keys (COP_*) are never passed whatever is listed.
|
|
180
|
+
*/
|
|
181
|
+
passEnv: z.array(z.string()).default([]),
|
|
182
|
+
allowedPrograms: z
|
|
183
|
+
.array(z.string())
|
|
184
|
+
.default([
|
|
185
|
+
// shells (a step may wrap one; the inner text is still screened by dangerous.ts)
|
|
186
|
+
'pwsh', 'powershell', 'cmd',
|
|
187
|
+
// JavaScript / TypeScript
|
|
188
|
+
'node', 'npm', 'npx', 'pnpm', 'pnpx', 'yarn', 'corepack', 'deno', 'bun',
|
|
189
|
+
'tsc', 'tsx', 'ts-node', 'vite', 'next', 'eslint', 'prettier',
|
|
190
|
+
'jest', 'vitest', 'mocha', 'playwright', 'cypress',
|
|
191
|
+
// .NET
|
|
192
|
+
'dotnet',
|
|
193
|
+
// Java / JVM
|
|
194
|
+
'java', 'javac', 'mvn', 'mvnw', 'gradle', 'gradlew',
|
|
195
|
+
// Python
|
|
196
|
+
'python', 'python3', 'py', 'pip', 'pip3', 'pytest', 'poetry', 'uv', 'ruff', 'black', 'mypy',
|
|
197
|
+
// Go / Rust
|
|
198
|
+
'go', 'gofmt', 'cargo', 'rustc', 'rustup',
|
|
199
|
+
// version control
|
|
200
|
+
'git',
|
|
201
|
+
// ordinary Windows inspection and housekeeping a build or test reaches for
|
|
202
|
+
'where', 'findstr', 'tasklist', 'taskkill', 'netstat', 'robocopy', 'xcopy', 'tar', 'curl', 'docker', 'docker-compose',
|
|
203
|
+
]),
|
|
204
|
+
denyPatterns: z
|
|
205
|
+
.array(z.string())
|
|
206
|
+
.default([
|
|
207
|
+
'Remove-Item[^|]*-Recurse',
|
|
208
|
+
'\\bformat\\s+[a-zA-Z]:',
|
|
209
|
+
'\\breg\\s+(add|delete)\\b',
|
|
210
|
+
'Stop-Computer|Restart-Computer|shutdown\\b',
|
|
211
|
+
'Set-ExecutionPolicy\\s+Unrestricted',
|
|
212
|
+
'diskpart|bcdedit|vssadmin',
|
|
213
|
+
'Disable-WindowsOptionalFeature',
|
|
214
|
+
'net\\s+user\\s+\\w+\\s+/add',
|
|
215
|
+
// git that changes something. See GIT_WRITE_NOTE below.
|
|
216
|
+
`\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_ALWAYS_WRITES})\\b`,
|
|
217
|
+
`\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_SOMETIMES_WRITES})`,
|
|
218
|
+
]),
|
|
219
|
+
})
|
|
220
|
+
.prefault({}),
|
|
221
|
+
report: z
|
|
222
|
+
.object({
|
|
223
|
+
fileName: z.string().default('iteration-{n}.txt'),
|
|
224
|
+
maxReportBytes: z.number().int().positive().default(8 * 1024 * 1024),
|
|
225
|
+
maxOutputChars: z.number().int().positive().default(200_000),
|
|
226
|
+
uploadRetries: z.number().int().nonnegative().default(2),
|
|
227
|
+
redactPatterns: z.array(z.string()).default([]),
|
|
228
|
+
})
|
|
229
|
+
.prefault({}),
|
|
230
|
+
/**
|
|
231
|
+
* The project being worked on, remembered once instead of typed into every session.
|
|
232
|
+
*
|
|
233
|
+
* It is a default rather than a lock: a new session starts pointed at it, and any session
|
|
234
|
+
* can then be pointed somewhere else. Nothing here changes what an existing session does,
|
|
235
|
+
* because a setting that quietly redirected running work would be the worst kind.
|
|
236
|
+
*/
|
|
237
|
+
project: z
|
|
238
|
+
.object({
|
|
239
|
+
/** Absolute path to the project folder. Empty means no default has been chosen. */
|
|
240
|
+
rootDir: z.string().default(''),
|
|
241
|
+
/**
|
|
242
|
+
* What to call it. Empty means the folder's own name, which is what this used to be
|
|
243
|
+
* always — fine until the Desktop mirror started naming a folder per project and the
|
|
244
|
+
* plan brief started listing them, where "rules-tests" is a path and "the test suite"
|
|
245
|
+
* is what the operator calls it. The other projects have had a name from the start; the
|
|
246
|
+
* default one had nowhere to put it.
|
|
247
|
+
*/
|
|
248
|
+
name: z.string().trim().default(''),
|
|
249
|
+
/**
|
|
250
|
+
* The other folders this operator works in, each with a short name: a front end, a back
|
|
251
|
+
* end, the test suite. They are not defaults — a new session still starts on `rootDir` —
|
|
252
|
+
* and they are not sessions. They exist for two readers: every folder field in the app
|
|
253
|
+
* offers them with a button, and the plan brief lists them by absolute path, so a chat
|
|
254
|
+
* model writing a plan across three repositories is told where they are instead of asking
|
|
255
|
+
* for each one and being given a path from memory.
|
|
256
|
+
*/
|
|
257
|
+
others: z
|
|
258
|
+
.array(z.object({
|
|
259
|
+
name: z.string().trim().min(1),
|
|
260
|
+
rootDir: z.string().trim().min(1),
|
|
261
|
+
/** Which of this project's directories go to the Desktop. See `mirror` below. */
|
|
262
|
+
mirror: ProjectMirrorSelection.optional(),
|
|
263
|
+
}))
|
|
264
|
+
.default([]),
|
|
265
|
+
/**
|
|
266
|
+
* Keep a copy of every project's selected directories on the Desktop, one folder per
|
|
267
|
+
* project under `copilot-operator-context`, refreshed before every run.
|
|
268
|
+
*
|
|
269
|
+
* The Desktop is the way into OneDrive, and OneDrive is the way into the chat's file
|
|
270
|
+
* picker. This is the switch for it: on, every project listed here is mirrored, with its
|
|
271
|
+
* own selection; off, the project folders are removed from the Desktop, because a stale
|
|
272
|
+
* copy of a code base sitting in the cloud is worse than none.
|
|
273
|
+
*/
|
|
274
|
+
mirrorToDesktop: z.boolean().default(false),
|
|
275
|
+
/** The default project's own selection. */
|
|
276
|
+
mirror: ProjectMirrorSelection.optional(),
|
|
277
|
+
})
|
|
278
|
+
.prefault({}),
|
|
279
|
+
/**
|
|
280
|
+
* Defaults for the project mirror. Which project and which directories is a property of a
|
|
281
|
+
* session; these are the mechanics shared by all of them.
|
|
282
|
+
*/
|
|
283
|
+
projectMirror: z
|
|
284
|
+
.object({
|
|
285
|
+
enabled: z.boolean().default(false),
|
|
286
|
+
rootDir: z.string().optional(),
|
|
287
|
+
includeDirs: z.array(z.string()).default([]),
|
|
288
|
+
excludeDirs: z.array(z.string()).default([]),
|
|
289
|
+
targetDir: z.string().optional(),
|
|
290
|
+
separator: z.string().default('--'),
|
|
291
|
+
txtMode: z.enum(['append', 'replace', 'none']).default('append'),
|
|
292
|
+
respectGitignore: z.boolean().default(true),
|
|
293
|
+
ignoreDirs: z.array(z.string()).default([]),
|
|
294
|
+
includeEnvFiles: z.boolean().default(false),
|
|
295
|
+
maxFileBytes: z.number().int().positive().default(2 * 1024 * 1024),
|
|
296
|
+
attachToFirstMessage: z.boolean().default(true),
|
|
297
|
+
maxAttachedFiles: z.number().int().positive().default(20),
|
|
298
|
+
})
|
|
299
|
+
.prefault({}),
|
|
300
|
+
pacing: z
|
|
301
|
+
.object({
|
|
302
|
+
enabled: z.boolean().default(true),
|
|
303
|
+
settleMs: z.number().int().nonnegative().default(1000),
|
|
304
|
+
maxMessagesPerHour: z.number().int().positive().default(60),
|
|
305
|
+
})
|
|
306
|
+
.prefault({}),
|
|
307
|
+
limits: z
|
|
308
|
+
.object({
|
|
309
|
+
maxIterations: z.number().int().positive().default(30),
|
|
310
|
+
maxRunMinutes: z.number().int().positive().default(120),
|
|
311
|
+
maxFormatRetries: z.number().int().nonnegative().default(2),
|
|
312
|
+
maxMessageChars: z.number().int().positive().default(100_000),
|
|
313
|
+
/**
|
|
314
|
+
* How many times a task may be sent back because its checks did not pass.
|
|
315
|
+
*
|
|
316
|
+
* Three, because the first round is usually a real mistake worth fixing, the second is
|
|
317
|
+
* the fix not working, and a third failure means the task is wrong rather than the work.
|
|
318
|
+
* Past that the loop is no longer productive and the operator should read it.
|
|
319
|
+
*/
|
|
320
|
+
maxCheckRounds: z.number().int().positive().default(3),
|
|
321
|
+
/**
|
|
322
|
+
* How many times one command may be run inside a single task before it is refused.
|
|
323
|
+
*
|
|
324
|
+
* Three, and the third is generous. A command that returned the same thing twice will
|
|
325
|
+
* return it a third time; the model repeating it is not being thorough, it is stuck, and
|
|
326
|
+
* the loop costs a message, a reply and a report each time round. Observed: fifteen
|
|
327
|
+
* consecutive iterations of `git remote -v`, each one concluding that the answer had not
|
|
328
|
+
* changed. Refusing the fourth is what turns "stuck" into "tried something else", and the
|
|
329
|
+
* refusal says so in as many words.
|
|
330
|
+
*/
|
|
331
|
+
maxCommandRepeats: z.number().int().positive().default(3),
|
|
332
|
+
/**
|
|
333
|
+
* How many iterations may pass with nothing but refused repeats before the task is ended.
|
|
334
|
+
*
|
|
335
|
+
* Two. The first is the model meeting the refusal and reacting to it, which is the whole
|
|
336
|
+
* point of the refusal; the second means it did not react, and a third would be the same
|
|
337
|
+
* again. The task then ends as `blocked` rather than running to `maxIterations`, because
|
|
338
|
+
* "it stopped repeating itself and said what was in the way" is a result and "it ran out
|
|
339
|
+
* of turns" is not.
|
|
340
|
+
*/
|
|
341
|
+
maxStalledIterations: z.number().int().positive().default(2),
|
|
342
|
+
/**
|
|
343
|
+
* How many times a task may be sent back because an independent review found problems.
|
|
344
|
+
*
|
|
345
|
+
* Two. The first round is the review finding what the implementer missed, which is the
|
|
346
|
+
* whole point; the second is the fix being checked. A third failing round means the two
|
|
347
|
+
* conversations disagree about what the task means, and that is a question for a person
|
|
348
|
+
* rather than another lap — the task then ends `blocked` with what is still outstanding.
|
|
349
|
+
*/
|
|
350
|
+
maxReviewRounds: z.number().int().positive().default(2),
|
|
351
|
+
/** How many iterations one review may take before it is abandoned as inconclusive. */
|
|
352
|
+
maxReviewIterations: z.number().int().positive().default(12),
|
|
353
|
+
/**
|
|
354
|
+
* How many times a task that ends `blocked` is run again, in a fresh conversation, before
|
|
355
|
+
* that verdict is accepted.
|
|
356
|
+
*
|
|
357
|
+
* The runner cannot tell why a task blocked: the prompt, the machine, or the chat itself
|
|
358
|
+
* — a long conversation whose early turns Copilot can no longer see, which is how a task
|
|
359
|
+
* once asked for "the original task text" ninety seconds after receiving it. What it can
|
|
360
|
+
* do is mechanical: run the same task again in a new chat, contract and all. A cause in
|
|
361
|
+
* the chat goes away; a cause in the prompt or the machine blocks again, in nearly the
|
|
362
|
+
* same words, and after this many tries the task stays blocked with both attempts on
|
|
363
|
+
* its record. Zero turns it off.
|
|
364
|
+
*/
|
|
365
|
+
retryBlockedInFreshChat: z.number().int().nonnegative().default(2),
|
|
366
|
+
})
|
|
367
|
+
.prefault({}),
|
|
368
|
+
runsDir: z.string().default('./runs'),
|
|
369
|
+
/**
|
|
370
|
+
* Days a session's run folder is kept before the API removes it at start; 0 keeps everything.
|
|
371
|
+
* Off by default because nothing here should delete an operator's records without their say —
|
|
372
|
+
* but a company with a retention rule sets it once and the records follow the rule. See
|
|
373
|
+
* `src/session/retention.ts`.
|
|
374
|
+
*/
|
|
375
|
+
runsRetentionDays: z.number().int().min(0).default(0),
|
|
376
|
+
/** Sessions, level-2 presets and the edited level-1 contract live here. */
|
|
377
|
+
dataDir: z.string().default('./data'),
|
|
378
|
+
});
|
|
379
|
+
async function textOf(value, baseDir) {
|
|
380
|
+
if (value === undefined)
|
|
381
|
+
return '';
|
|
382
|
+
if (typeof value === 'string')
|
|
383
|
+
return value;
|
|
384
|
+
return await readFile(expandPath(value.file, baseDir), 'utf8');
|
|
385
|
+
}
|
|
386
|
+
/** Turns parsed config plus a base directory into absolute paths and loaded texts. */
|
|
387
|
+
export async function resolveConfig(cfg, configPath, baseDir) {
|
|
388
|
+
if (cfg.projectMirror.enabled) {
|
|
389
|
+
if (!cfg.projectMirror.rootDir)
|
|
390
|
+
throw new Error('projectMirror.enabled is true but projectMirror.rootDir is missing.');
|
|
391
|
+
if (cfg.projectMirror.includeDirs.length === 0) {
|
|
392
|
+
throw new Error('projectMirror.enabled is true but includeDirs is empty, so nothing would be copied. ' +
|
|
393
|
+
'List the directories to include, or use ["."] for the whole project.');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/*
|
|
397
|
+
* The administrator's floor, applied here because this is the one function both the terminal's
|
|
398
|
+
* `run.yaml` and the API's `data/settings.json` pass through. Applying it anywhere further in
|
|
399
|
+
* would mean applying it twice and trusting both; applying it further out would mean one of the
|
|
400
|
+
* two entrances missing it, which is how a gate comes to be enforced on the surface somebody
|
|
401
|
+
* happened to test. See `lockedPolicy.ts` for what a lock may and may not do.
|
|
402
|
+
*/
|
|
403
|
+
// The machine-wide lock first, the install's own second; each only tightens, so the order is
|
|
404
|
+
// for the record, not the result. See `readPolicyLocks`.
|
|
405
|
+
const locks = await readPolicyLocks(baseDir);
|
|
406
|
+
let policy = {
|
|
407
|
+
mode: cfg.execution.mode,
|
|
408
|
+
allowedPrograms: cfg.execution.allowedPrograms,
|
|
409
|
+
denyPatterns: cfg.execution.denyPatterns,
|
|
410
|
+
isolation: cfg.execution.isolation,
|
|
411
|
+
passEnv: cfg.execution.passEnv,
|
|
412
|
+
mirrorToDesktop: cfg.project.mirrorToDesktop,
|
|
413
|
+
projectMirrorEnabled: cfg.projectMirror.enabled,
|
|
414
|
+
includeEnvFiles: cfg.project.mirror?.includeEnvFiles ?? cfg.project.others.some((o) => o.mirror?.includeEnvFiles === true),
|
|
415
|
+
mirrorIncludeEnvFiles: cfg.projectMirror.includeEnvFiles,
|
|
416
|
+
};
|
|
417
|
+
const outcome = { applied: false, changes: [] };
|
|
418
|
+
for (const lock of locks) {
|
|
419
|
+
const applied = applyPolicyLock(policy, lock);
|
|
420
|
+
policy = applied.policy;
|
|
421
|
+
outcome.applied = true;
|
|
422
|
+
outcome.changes.push(...applied.outcome.changes);
|
|
423
|
+
if (applied.outcome.maxMode === 'confirm' || (applied.outcome.maxMode && !outcome.maxMode))
|
|
424
|
+
outcome.maxMode = applied.outcome.maxMode;
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
...cfg,
|
|
428
|
+
execution: {
|
|
429
|
+
...cfg.execution,
|
|
430
|
+
mode: policy.mode,
|
|
431
|
+
allowedPrograms: policy.allowedPrograms,
|
|
432
|
+
denyPatterns: policy.denyPatterns,
|
|
433
|
+
isolation: policy.isolation ?? cfg.execution.isolation,
|
|
434
|
+
passEnv: policy.passEnv ?? cfg.execution.passEnv,
|
|
435
|
+
},
|
|
436
|
+
project: lockedProject(cfg.project, policy),
|
|
437
|
+
projectMirror: {
|
|
438
|
+
...cfg.projectMirror,
|
|
439
|
+
enabled: policy.projectMirrorEnabled ?? cfg.projectMirror.enabled,
|
|
440
|
+
includeEnvFiles: policy.mirrorIncludeEnvFiles ?? cfg.projectMirror.includeEnvFiles,
|
|
441
|
+
},
|
|
442
|
+
configPath,
|
|
443
|
+
baseDir,
|
|
444
|
+
policyLock: outcome,
|
|
445
|
+
resolved: {
|
|
446
|
+
profileDir: expandPath(cfg.copilot.profileDir, baseDir),
|
|
447
|
+
cwd: expandPath(cfg.execution.cwd, baseDir),
|
|
448
|
+
runsDir: expandPath(cfg.runsDir, baseDir),
|
|
449
|
+
dataDir: expandPath(cfg.dataDir, baseDir),
|
|
450
|
+
level1Path: expandPath(cfg.level1File, baseDir),
|
|
451
|
+
level2Text: await textOf(cfg.level2, baseDir),
|
|
452
|
+
taskText: await textOf(cfg.task, baseDir),
|
|
453
|
+
mirrorRootDir: cfg.projectMirror.rootDir ? expandPath(cfg.projectMirror.rootDir, baseDir) : undefined,
|
|
454
|
+
mirrorTargetDir: cfg.projectMirror.targetDir ? expandPath(cfg.projectMirror.targetDir, baseDir) : undefined,
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/** The project section with what the lock decided: the Desktop mirror switch and the .env selections. */
|
|
459
|
+
function lockedProject(project, policy) {
|
|
460
|
+
const envOff = policy.includeEnvFiles === false;
|
|
461
|
+
const fix = (m) => (m && envOff ? { ...m, includeEnvFiles: false } : m);
|
|
462
|
+
return {
|
|
463
|
+
...project,
|
|
464
|
+
mirrorToDesktop: policy.mirrorToDesktop ?? project.mirrorToDesktop,
|
|
465
|
+
mirror: fix(project.mirror),
|
|
466
|
+
others: project.others.map((o) => ({ ...o, mirror: fix(o.mirror) })),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function formatIssues(issues) {
|
|
470
|
+
return issues.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n');
|
|
471
|
+
}
|
|
472
|
+
/** Loads a YAML run config from disk. */
|
|
473
|
+
export async function loadConfig(configPath) {
|
|
474
|
+
const abs = resolve(configPath);
|
|
475
|
+
const raw = await readFile(abs, 'utf8');
|
|
476
|
+
const parsed = RunConfigSchema.safeParse(parseYaml(raw) ?? {});
|
|
477
|
+
if (!parsed.success)
|
|
478
|
+
throw new Error(`${abs} is not a valid run config:\n${formatIssues(parsed.error.issues)}`);
|
|
479
|
+
return await resolveConfig(parsed.data, abs, dirname(abs));
|
|
480
|
+
}
|
|
481
|
+
/** Loads settings from a plain object, as the API does from `data/settings.json`. */
|
|
482
|
+
export async function loadConfigObject(value, baseDir, label = 'settings') {
|
|
483
|
+
const parsed = RunConfigSchema.safeParse(value ?? {});
|
|
484
|
+
if (!parsed.success)
|
|
485
|
+
throw new Error(`${label} is not valid:\n${formatIssues(parsed.error.issues)}`);
|
|
486
|
+
return await resolveConfig(parsed.data, label, baseDir);
|
|
487
|
+
}
|