copilot-operator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,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
+ }