copilot-operator 0.1.3 → 0.1.5

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 (49) hide show
  1. package/README.md +8 -2
  2. package/dist/src/api/operator.controller.js +6 -1
  3. package/dist/src/api/operator.service.js +69 -22
  4. package/dist/src/exec/authorizer.js +2 -2
  5. package/dist/src/exec/commandHeads.js +799 -0
  6. package/dist/src/exec/policy.js +1 -1
  7. package/dist/src/exec/programs.js +22 -82
  8. package/dist/src/exec/reportFile.js +5 -2
  9. package/dist/src/orchestrator/review.js +4 -3
  10. package/dist/src/orchestrator/taskRunner.js +33 -8
  11. package/dist/src/plan/brief.js +1248 -1221
  12. package/dist/src/plan/schema.js +19 -0
  13. package/dist/src/plan/systemGuide.js +40 -0
  14. package/dist/src/protocol/reporter.js +8 -6
  15. package/dist/src/session/exports.js +131 -1
  16. package/dist/src/session/store.js +2 -0
  17. package/dist/src/vcs/taskVcs.js +153 -11
  18. package/dist/web/404.html +2 -2
  19. package/dist/web/_next/static/chunks/867-0cd2c3cfd0ff0a45.js +1 -0
  20. package/dist/web/_next/static/chunks/89-382e9b29486d2747.js +1 -0
  21. package/dist/web/_next/static/chunks/app/defaults/page-1747c931adc746fd.js +1 -0
  22. package/dist/web/_next/static/chunks/app/history/page-83543937cecde5de.js +1 -0
  23. package/dist/web/_next/static/chunks/app/sessions/view/page-4631bdf5113a73b2.js +1 -0
  24. package/dist/web/appearance.html +2 -2
  25. package/dist/web/appearance.txt +10 -10
  26. package/dist/web/defaults.html +2 -2
  27. package/dist/web/defaults.txt +10 -10
  28. package/dist/web/history.html +2 -2
  29. package/dist/web/history.txt +10 -10
  30. package/dist/web/import.html +2 -2
  31. package/dist/web/import.txt +10 -10
  32. package/dist/web/index.html +2 -2
  33. package/dist/web/index.txt +10 -10
  34. package/dist/web/level1.html +2 -2
  35. package/dist/web/level1.txt +10 -10
  36. package/dist/web/presets.html +2 -2
  37. package/dist/web/presets.txt +10 -10
  38. package/dist/web/sessions/view.html +2 -2
  39. package/dist/web/sessions/view.txt +10 -10
  40. package/dist/web/system.html +2 -2
  41. package/dist/web/system.txt +10 -10
  42. package/package.json +4 -3
  43. package/dist/web/_next/static/chunks/867-55627c5122ff6336.js +0 -1
  44. package/dist/web/_next/static/chunks/89-5585315f51e4c481.js +0 -1
  45. package/dist/web/_next/static/chunks/app/defaults/page-4ff25cafe6de779c.js +0 -1
  46. package/dist/web/_next/static/chunks/app/history/page-2232949a2ff8559e.js +0 -1
  47. package/dist/web/_next/static/chunks/app/sessions/view/page-9dda275f4b1b0423.js +0 -1
  48. /package/dist/web/_next/static/{QwjyyBjkO5xlKFU5O6Wux → 7TE2rEuHqGdGx14If8TEb}/_buildManifest.js +0 -0
  49. /package/dist/web/_next/static/{QwjyyBjkO5xlKFU5O6Wux → 7TE2rEuHqGdGx14If8TEb}/_ssgManifest.js +0 -0
@@ -92,7 +92,7 @@ confinement) {
92
92
  // The allowlist after the known-bad floor and the operator's deny list, so their precise
93
93
  // messages win, and this catches the long tail neither of them was ever going to enumerate: a
94
94
  // program that is simply not part of this project's toolchain. See `programs.ts`.
95
- const offlist = programRefusal(command, allowedPrograms);
95
+ const offlist = programRefusal(command, allowedPrograms, shell);
96
96
  if (offlist)
97
97
  return offlist;
98
98
  const trap = findShellExecuteTrap(command, shell, env);
@@ -29,6 +29,7 @@
29
29
  * before this existed starts failing on an upgrade.
30
30
  */
31
31
  import { extname } from 'node:path';
32
+ import { scanCommandHeads } from './commandHeads.js';
32
33
  /**
33
34
  * Windows executable extensions. A token carrying one of these names a program by file, whatever
34
35
  * the stem, so it is always checked; a token with any other extension (`config.json`, `app.ts`)
@@ -55,6 +56,7 @@ const SHELL_WORDS = new Set([
55
56
  'gi', 'gp', 'sp', 'si', 'gm', 'gu', 'gv', 'sv', 'select', 'where', 'foreach-object', 'where-object',
56
57
  'sort', 'group', 'measure', 'ft', 'fl', 'fw', 'fh', 'oh', 'out-host', 'tee',
57
58
  'sls', 'gcm', 'gmo', 'ipmo', 'man', 'help', 'more', 'h', 'history', 'r',
59
+ '%', '?', // ForEach-Object and Where-Object, as `ls | % { $_.Name }` writes them
58
60
  'gsv', 'gps', 'ps', 'kill', 'sleep', 'start', 'saps', 'spps', 'sasv', 'spsv',
59
61
  'iwr', 'irm', 'wget', 'curl', // PowerShell aliases for Invoke-WebRequest/Invoke-RestMethod; the raw .exe forms are allowlisted by name if wanted, and iwr|iex is caught by dangerous.ts
60
62
  // cmd built-ins
@@ -62,87 +64,13 @@ const SHELL_WORDS = new Set([
62
64
  'pushd', 'popd', 'ver', 'vol', 'date', 'time', 'exist', 'not', 'defined', 'errorlevel',
63
65
  ]);
64
66
  /**
65
- * The heads of every statement in a command line: the first token after the start and after each
66
- * separator that begins a new command (`|`, `;`, `&`, `&&`, `||`, a newline, a `(` sub-expression
67
- * or a `{` script block). Quote- and depth-aware so a separator inside `'...'`, `"..."` or an
68
- * argument list does not split a statement, and a back-tick before a newline continues it.
69
- *
70
- * Best effort by design. The point is to find the programs a line would obviously start, not to
71
- * be a PowerShell parser; a construction it cannot read yields no head for that fragment, and the
72
- * fragment then rests on `dangerous.ts` and the human, which is where the honest limit was always
73
- * going to be anyway.
67
+ * The name at the head of every command a line invokes. Read by `commandHeads.ts`, which follows
68
+ * PowerShell's command and expression modes (or `cmd`'s rules for a `cmd` step), so strings,
69
+ * operators, array values, method arguments and hashtable keys are not mistaken for programs, and
70
+ * a command nested in a script block, a subexpression or after an assignment is not missed.
74
71
  */
75
- export function commandHeads(command) {
76
- const heads = [];
77
- const boundaries = new Set(['|', ';', '&', '\n', '(', '{']);
78
- let expectStart = true;
79
- let quote = null;
80
- let i = 0;
81
- const readToken = (from) => {
82
- let j = from;
83
- let text = '';
84
- let q = null;
85
- while (j < command.length) {
86
- const ch = command[j];
87
- if (q) {
88
- if (ch === q)
89
- q = null;
90
- else
91
- text += ch;
92
- j += 1;
93
- continue;
94
- }
95
- if (ch === "'" || ch === '"') {
96
- q = ch;
97
- j += 1;
98
- continue;
99
- }
100
- if (/\s/.test(ch) || boundaries.has(ch) || ch === ')' || ch === '}')
101
- break;
102
- text += ch;
103
- j += 1;
104
- }
105
- return { text, end: j };
106
- };
107
- while (i < command.length) {
108
- const ch = command[i];
109
- if (quote) {
110
- if (ch === quote)
111
- quote = null;
112
- i += 1;
113
- continue;
114
- }
115
- if (ch === "'" || ch === '"') {
116
- quote = ch;
117
- i += 1;
118
- continue;
119
- }
120
- if (expectStart && !/\s/.test(ch) && !boundaries.has(ch) && ch !== ')' && ch !== '}') {
121
- const { text, end } = readToken(i);
122
- i = end;
123
- // A dot-source — `. .\script.ps1` — runs the file that follows in the current scope. The
124
- // dot is not what runs; the next token is, and it is the one the allowlist has to see.
125
- // (`& .\script.ps1` already works this way because `&` is a boundary.)
126
- if (text === '.')
127
- continue;
128
- if (text)
129
- heads.push(text);
130
- expectStart = false;
131
- continue;
132
- }
133
- if (boundaries.has(ch)) {
134
- // A back-tick immediately before a newline is a line continuation, not a new statement.
135
- if (ch === '\n' && command[i - 1] === '`') {
136
- i += 1;
137
- continue;
138
- }
139
- expectStart = true;
140
- i += 1;
141
- continue;
142
- }
143
- i += 1;
144
- }
145
- return heads;
72
+ export function commandHeads(command, shell) {
73
+ return scanCommandHeads(command, shell).heads;
146
74
  }
147
75
  /**
148
76
  * The external-program name a statement head invokes, normalised for the allowlist, or null when
@@ -273,12 +201,24 @@ export function inlineCodeRefusal(command) {
273
201
  * is a legitimate tool this project happens to need, and the fix is one line of config — not a
274
202
  * reason to weaken the gate for everyone.
275
203
  */
276
- export function programRefusal(command, allowedPrograms) {
204
+ export function programRefusal(command, allowedPrograms, shell = 'pwsh') {
277
205
  if (!allowedPrograms || allowedPrograms.length === 0)
278
206
  return null;
279
207
  const allowed = new Set(allowedPrograms.map((p) => p.trim().toLowerCase().replace(/\.(exe|com|bat|cmd|ps1|msi)$/i, '')).filter(Boolean));
208
+ /*
209
+ * Fail-closed: a line that cannot be read with certainty is a line whose programs are not known,
210
+ * and an allowlist that cannot name the programs has nothing to check them against. What makes
211
+ * a line uncertain — an unclosed string or bracket, a program chosen through a variable — is
212
+ * never needed by honest work, so refusing it costs a rewrite and guessing could cost the gate.
213
+ */
214
+ const scan = scanCommandHeads(command, shell);
215
+ if (scan.uncertain) {
216
+ return (`refused: the runner could not tell which programs this line starts (${scan.uncertain}). ` +
217
+ 'Write it so every program is named literally — close each quote and bracket, and call a program by its ' +
218
+ 'name or a literal path rather than through a variable or an expression.');
219
+ }
280
220
  // What `Start-Process` is asked to start is a program this line starts, as much as a head is.
281
- for (const head of [...commandHeads(command), ...startProcessTargets(command)]) {
221
+ for (const head of [...scan.heads, ...startProcessTargets(command)]) {
282
222
  const program = externalProgram(head);
283
223
  if (program && !allowed.has(program)) {
284
224
  return (`refused: "${program}" is not in execution.allowedPrograms. This machine runs only the project's ` +
@@ -42,8 +42,11 @@ export function clip(text, maxChars) {
42
42
  return `${text.slice(0, half)}\n\n[... ${dropped} characters omitted ...]\n\n${text.slice(-half)}`;
43
43
  }
44
44
  function sectionFor(r, maxOutputChars) {
45
- const head = `--- step ${r.id} (${r.shell}, ${r.outcome}, exit ${r.exitCode}, ` +
46
- `${(r.durationMs / 1000).toFixed(1)}s)\n$ ${r.command}\n`;
45
+ // A refused step never ran, so it is not given an exit code or a duration that could be read as
46
+ // a command that ran and failed; the header says what happened instead.
47
+ const head = r.outcome === 'refused'
48
+ ? `--- step ${r.id} (${r.shell}, REFUSED by the runner, never executed, no exit code)\n$ ${r.command}\n`
49
+ : `--- step ${r.id} (${r.shell}, ${r.outcome}, exit ${r.exitCode}, ` + `${(r.durationMs / 1000).toFixed(1)}s)\n$ ${r.command}\n`;
47
50
  const out = clip(r.stdout, maxOutputChars);
48
51
  const err = r.stderr.trim().length > 0 ? `[stderr]\n${clip(r.stderr, maxOutputChars)}\n` : '';
49
52
  const note = r.truncated ? `[note] output was truncated; the full stream is in ${r.logPath}\n` : '';
@@ -426,7 +426,7 @@ ${machineNote}` : contract);
426
426
  confinement: { roots: deps.roots, cwd: deps.cwd },
427
427
  });
428
428
  if (decision.action !== 'run') {
429
- results.push(refused(step.id, decision.reason, step.cmd, step.shell));
429
+ results.push(refused(step.id, decision.reason, step.cmd, step.shell, decision.by === 'operator' ? 'operator' : 'runner'));
430
430
  if (decision.action === 'abort')
431
431
  break;
432
432
  continue;
@@ -525,7 +525,7 @@ export function refusedChecksMessage(refused) {
525
525
  ].join('\n');
526
526
  }
527
527
  /** A step the review did not run, in the shape the reporter expects. */
528
- function refused(id, reason, command = '(not run)', requested) {
528
+ function refused(id, reason, command = '(not run)', requested, by = 'runner') {
529
529
  return {
530
530
  id,
531
531
  // The shell it would have run in, rather than the one this file used to name: a report line
@@ -537,7 +537,8 @@ function refused(id, reason, command = '(not run)', requested) {
537
537
  requestedShell: requested ?? null,
538
538
  command,
539
539
  exitCode: -4,
540
- outcome: 'aborted',
540
+ // Who declined it, as in `refusedResult` in the task runner: a rule, or a person.
541
+ outcome: by === 'operator' ? 'aborted' : 'refused',
541
542
  durationMs: 0,
542
543
  stdout: '',
543
544
  stderr: `[policy] step not executed: ${reason}\n`,
@@ -41,6 +41,7 @@ import { composeOpening, READ_ONLY_NOTE } from '../session/compose.js';
41
41
  import { mirrorProject, describeMirror } from '../context/projectMirror.js';
42
42
  import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
43
43
  import { prepareForTask, commitTaskResult, repoDirOf } from '../vcs/taskVcs.js';
44
+ import { exportMachine, writeAttemptRecord } from '../session/exports.js';
44
45
  /** Why a task that was reported as done is being closed as failed. */
45
46
  function checksFailedReason(outcomes) {
46
47
  const failed = outcomes.filter((o) => !o.passed);
@@ -110,14 +111,18 @@ function blockedReason(tried, needed) {
110
111
  .filter(Boolean)
111
112
  .join(' — ');
112
113
  }
113
- /** A step that was refused never reaches the shell, but Copilot still has to hear about it. */
114
- function refusedResult(step, reason) {
114
+ /**
115
+ * A step that was not run never reaches the shell, but Copilot still has to hear about it — and
116
+ * hear who declined it: the runner (`refused`: a rule, so rewrite the step) or the operator
117
+ * (`aborted`: a person stopped or skipped it).
118
+ */
119
+ function refusedResult(step, reason, by = 'runner') {
115
120
  return {
116
121
  id: step.id,
117
122
  shell: effectiveShell(step.shell),
118
123
  command: describeStep(step),
119
124
  exitCode: -4,
120
- outcome: 'aborted',
125
+ outcome: by === 'operator' ? 'aborted' : 'refused',
121
126
  durationMs: 0,
122
127
  stdout: '',
123
128
  stderr: `[policy] step not executed: ${reason}\n`,
@@ -445,6 +450,26 @@ export async function runTask(transport, session, task, deps) {
445
450
  });
446
451
  sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
447
452
  await log.close();
453
+ /*
454
+ * An attempt that did not end done leaves its plan, work and runner views in its own folder,
455
+ * written after the transcript is closed so they carry all of it. See `writeAttemptRecord`.
456
+ * A failure to write them is reported and changes nothing about the outcome.
457
+ */
458
+ if (status !== 'done') {
459
+ const now = await store.getSession(session.id);
460
+ const ended = now?.tasks.find((x) => x.id === task.id);
461
+ if (now && ended) {
462
+ await writeAttemptRecord(now, ended, cfg.resolved.runsDir, exportMachine(cfg))
463
+ .then((dir) => {
464
+ if (dir) {
465
+ bus.publish({ sessionId: session.id, taskId: task.id, type: 'attempt-record', level: 'info',
466
+ message: `plan, work and runner of this attempt saved in ${dir}`, data: { dir } });
467
+ }
468
+ })
469
+ .catch((e) => bus.publish({ sessionId: session.id, taskId: task.id, type: 'attempt-record', level: 'warn',
470
+ message: `could not save the plan, work and runner of this attempt: ${e.message}` }));
471
+ }
472
+ }
448
473
  return { status, iterations, summary, reason };
449
474
  };
450
475
  await setTask((t) => {
@@ -542,7 +567,7 @@ export async function runTask(transport, session, task, deps) {
542
567
  // --- version control: a branch of this task's own, before anything is touched -------
543
568
  const prepared = await prepareForTask(session, task, bus, async (mutate) => {
544
569
  await store.updateSession(session.id, mutate);
545
- });
570
+ }, () => store.listSessions());
546
571
  if (prepared.vcs.branch || prepared.vcs.problem) {
547
572
  await setTask((t) => {
548
573
  t.vcs = prepared.vcs;
@@ -1181,7 +1206,7 @@ export async function runTask(transport, session, task, deps) {
1181
1206
  let shellRefused = 0;
1182
1207
  for (const step of reply.steps) {
1183
1208
  if (signal?.aborted) {
1184
- results.push(refusedResult(step, 'stopped by the operator'));
1209
+ results.push(refusedResult(step, 'stopped by the operator', 'operator'));
1185
1210
  aborted = true;
1186
1211
  break;
1187
1212
  }
@@ -1233,13 +1258,13 @@ export async function runTask(transport, session, task, deps) {
1233
1258
  t.status = 'running';
1234
1259
  });
1235
1260
  if (decision.action === 'abort') {
1236
- results.push(refusedResult(step, decision.reason));
1261
+ results.push(refusedResult(step, decision.reason, decision.by === 'operator' ? 'operator' : 'runner'));
1237
1262
  aborted = true;
1238
1263
  break;
1239
1264
  }
1240
1265
  if (decision.action === 'skip') {
1241
- sink.event('step-skipped', { id: step.id, reason: decision.reason }, `step ${step.id} skipped: ${decision.reason}`, 'warn');
1242
- results.push(refusedResult(step, decision.reason));
1266
+ sink.event('step-skipped', { id: step.id, reason: decision.reason, by: decision.by ?? 'runner' }, `step ${step.id} skipped: ${decision.reason}`, 'warn');
1267
+ results.push(refusedResult(step, decision.reason, decision.by === 'operator' ? 'operator' : 'runner'));
1243
1268
  continue;
1244
1269
  }
1245
1270
  const long = step.expect === 'long';