copilot-operator 0.1.2 → 0.1.4

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 (53) hide show
  1. package/README.md +13 -3
  2. package/dist/src/api/operator.controller.js +6 -1
  3. package/dist/src/api/operator.service.js +57 -22
  4. package/dist/src/cli.js +1 -0
  5. package/dist/src/config/schema.js +25 -0
  6. package/dist/src/exec/authorizer.js +20 -10
  7. package/dist/src/exec/commandHeads.js +799 -0
  8. package/dist/src/exec/policy.js +1 -1
  9. package/dist/src/exec/policyManifest.js +16 -2
  10. package/dist/src/exec/programs.js +22 -82
  11. package/dist/src/exec/reportFile.js +5 -2
  12. package/dist/src/orchestrator/review.js +4 -3
  13. package/dist/src/orchestrator/taskRunner.js +33 -7
  14. package/dist/src/plan/schema.js +7 -0
  15. package/dist/src/plan/systemGuide.js +30 -6
  16. package/dist/src/protocol/reporter.js +8 -6
  17. package/dist/src/session/exports.js +128 -1
  18. package/dist/src/session/store.js +2 -0
  19. package/dist/src/vcs/taskVcs.js +33 -0
  20. package/dist/web/404.html +2 -2
  21. package/dist/web/_next/static/chunks/867-f0e3e152f6f67e45.js +1 -0
  22. package/dist/web/_next/static/chunks/89-382e9b29486d2747.js +1 -0
  23. package/dist/web/_next/static/chunks/app/defaults/page-1747c931adc746fd.js +1 -0
  24. package/dist/web/_next/static/chunks/app/history/page-8d4f817e36986e3f.js +1 -0
  25. package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +1 -0
  26. package/dist/web/_next/static/chunks/app/sessions/view/page-185f5fcc383e6619.js +1 -0
  27. package/dist/web/appearance.html +2 -2
  28. package/dist/web/appearance.txt +10 -10
  29. package/dist/web/defaults.html +2 -2
  30. package/dist/web/defaults.txt +10 -10
  31. package/dist/web/history.html +2 -2
  32. package/dist/web/history.txt +10 -10
  33. package/dist/web/import.html +2 -2
  34. package/dist/web/import.txt +10 -10
  35. package/dist/web/index.html +2 -2
  36. package/dist/web/index.txt +10 -10
  37. package/dist/web/level1.html +2 -2
  38. package/dist/web/level1.txt +10 -10
  39. package/dist/web/presets.html +2 -2
  40. package/dist/web/presets.txt +10 -10
  41. package/dist/web/sessions/view.html +2 -2
  42. package/dist/web/sessions/view.txt +10 -10
  43. package/dist/web/system.html +2 -2
  44. package/dist/web/system.txt +10 -10
  45. package/package.json +1 -1
  46. package/dist/web/_next/static/chunks/867-5b104743e24a4e2f.js +0 -1
  47. package/dist/web/_next/static/chunks/955-cf291b8a60315c58.js +0 -1
  48. package/dist/web/_next/static/chunks/app/defaults/page-559af121c43127a1.js +0 -1
  49. package/dist/web/_next/static/chunks/app/history/page-0df90ab9998815db.js +0 -1
  50. package/dist/web/_next/static/chunks/app/page-050cf6f71187e8af.js +0 -1
  51. package/dist/web/_next/static/chunks/app/sessions/view/page-4d31690c22c8b331.js +0 -1
  52. /package/dist/web/_next/static/{eWzbORCbQ1h_2IeLH_njb → p9lexLS0u8W5N6VzLavHO}/_buildManifest.js +0 -0
  53. /package/dist/web/_next/static/{eWzbORCbQ1h_2IeLH_njb → p9lexLS0u8W5N6VzLavHO}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -82,7 +82,11 @@ file run. What `Start-Process` is asked to start is judged like any other progra
82
82
  `Invoke-WebRequest`, `Invoke-RestMethod`, `curl`, `wget`, `Start-BitsTransfer`, the .NET web
83
83
  clients, `ftp`/`scp`, a package named by URL or git address, `npx` of a tool not installed in the
84
84
  project, `docker pull` — is neither refused nor run: it waits on the approval screen, in every
85
- mode, unattended included, and "run the rest without asking" does not answer it. A check that
85
+ mode, unattended included, and "run the rest without asking" does not answer it. That is the
86
+ default, and the one stop an unattended run still makes; `execution.networkFetch` (Settings →
87
+ Execution) may instead **refuse** it back to the chat with the reason, so the run never waits, or
88
+ **run** it unread on a machine where that is acceptable. The choice applies only to unattended
89
+ runs, and each task's policy manifest records which was in force. A check that
86
90
  fetches is refused, since a check runs with nobody asked. Requests to this machine (`localhost`,
87
91
  `127.0.0.1`) and the project's own package managers are not held. `npm install` runs with
88
92
  `ignore-scripts`: a package's install script is code fetched and executed unread, and it does not
@@ -128,8 +132,14 @@ Six rules decide what a step may do, and each is recorded with the run that it g
128
132
  - **Only the declared toolchain runs.** `execution.allowedPrograms` names the programs a command
129
133
  may start — the shells, the JavaScript, .NET, Java, Python, Go and Rust tools, `git`, and a few
130
134
  Windows utilities. A command that starts anything else is refused and sent back with the reason.
131
- An empty list turns the gate off. This is a floor against the unknown binary, not a boundary:
132
- allowing `node` allows `node -e`.
135
+ The programs are read the way PowerShell reads a line (`src/exec/commandHeads.ts`): strings,
136
+ operators, array values, method arguments and hashtable keys are not programs, and a command in a
137
+ script block, a subexpression, a string's `$( )` or after an assignment is still found. A line that
138
+ cannot be read with certainty — an unclosed quote or bracket, a program called through a variable
139
+ (`& $exe`) — is refused rather than guessed at. `cmd` steps are read by `cmd`'s own rules. A refused
140
+ step is reported to the chat as refused and never executed, with no exit code, apart from a
141
+ command that ran and failed. An empty list turns the gate off. This is a floor against the unknown
142
+ binary, not a boundary: allowing `node` allows `node -e`.
133
143
  - **Autonomy is graded.** An unattended run carries strictly more restrictions than a watched one,
134
144
  because the thing that makes a watched run safe is a person reading each line. Unattended
135
145
  requires a non-empty allowlist, and refuses an allowed program used to evaluate a string
@@ -251,7 +251,12 @@ let OperatorController = class OperatorController {
251
251
  */
252
252
  async exportBundle(res, body) {
253
253
  const pairs = (body?.tasks ?? [])
254
- .map((x) => ({ sessionId: (x?.sessionId ?? '').trim(), taskId: (x?.taskId ?? '').trim() }))
254
+ .map((x) => ({
255
+ sessionId: (x?.sessionId ?? '').trim(),
256
+ taskId: (x?.taskId ?? '').trim(),
257
+ // One attempt of the task rather than the task as it is now; see `exportBundle`.
258
+ ...(Number.isInteger(x?.attempt) ? { attempt: x.attempt } : {}),
259
+ }))
255
260
  .filter((x) => x.sessionId && x.taskId);
256
261
  try {
257
262
  const { fileName, content } = await this.ops.exportBundle(pairs);
@@ -23,7 +23,7 @@ import { newId, tidyVcsPlan } from '../session/model.js';
23
23
  import { runSession, openBrowser } from '../orchestrator/taskRunner.js';
24
24
  import { buildExport } from '../session/exportRecord.js';
25
25
  import { buildDebugExport } from '../session/debugExport.js';
26
- import { buildPlanExport, buildDomainExport, buildBotExport, buildBundleExport, exportFileName } from '../session/exports.js';
26
+ import { buildPlanExport, buildDomainExport, buildBotExport, buildBundleExport, exportFileName, exportMachine, taskAtAttempt, withTask, writeAttemptRecord } from '../session/exports.js';
27
27
  import { suggestRunName } from '../session/runName.js';
28
28
  import { mirrorAllProjects, removeProjectMirrors, contextRoot } from '../context/desktopMirror.js';
29
29
  import { describeMirror } from '../context/projectMirror.js';
@@ -36,7 +36,7 @@ function sameFolder(a, b) {
36
36
  return a.trim() !== '' && norm(a) === norm(b);
37
37
  }
38
38
  import { importPlan, plannedSessionSignature, taskSignature } from '../plan/importPlan.js';
39
- import { vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
39
+ import { commitInterrupted, vcsPreflight, restorePreview, restoreToBase, sessionBranches } from '../vcs/taskVcs.js';
40
40
  import { gitAvailable, repoUnusableReason } from '../vcs/git.js';
41
41
  import { makeAuthorizer } from '../exec/authorizer.js';
42
42
  import { listSelectableDirs, collectFiles, findSelectionConflicts, describeConflicts, DEFAULT_IGNORE_DIRS } from '../context/projectMirror.js';
@@ -81,6 +81,9 @@ let OperatorService = class OperatorService {
81
81
  level: 'warn',
82
82
  message: `"${r.title}" was left unfinished by an earlier run and has been marked aborted`,
83
83
  });
84
+ // What it had changed goes onto its branch, and its record into its run folder, as a
85
+ // finished task's would. See `commitInterrupted` and `writeAttemptRecord`.
86
+ await this.settleRecovered(r.sessionId, r.taskId).catch((e) => console.warn(`[api] could not settle "${r.title}": ${e.message}`));
84
87
  }
85
88
  if (recovered.length > 0) {
86
89
  console.log(`[api] closed ${recovered.length} task(s) left unfinished by an earlier run`);
@@ -88,6 +91,31 @@ let OperatorService = class OperatorService {
88
91
  })();
89
92
  return this.ready;
90
93
  }
94
+ /**
95
+ * Closes the loose ends of one task the previous process left unfinished: commits its work on its
96
+ * branch, says so on the task, and writes the attempt's plan, work and runner into its run folder.
97
+ */
98
+ async settleRecovered(sessionId, taskId) {
99
+ const session = await this.store.getSession(sessionId);
100
+ const task = session?.tasks.find((t) => t.id === taskId);
101
+ if (!session || !task)
102
+ return;
103
+ const vcs = await commitInterrupted(session, task, task.reason ?? 'interrupted', this.bus);
104
+ const settled = vcs?.commit
105
+ ? await this.store.updateSession(sessionId, (s) => {
106
+ const t = s.tasks.find((x) => x.id === taskId);
107
+ if (!t)
108
+ return;
109
+ t.vcs = vcs;
110
+ t.reason = `${t.reason ?? ''} What it had changed is committed on ${vcs.branch} as ${vcs.commit?.slice(0, 8)}.`.trim();
111
+ })
112
+ : session;
113
+ const ended = settled.tasks.find((t) => t.id === taskId);
114
+ if (!ended)
115
+ return;
116
+ const cfg = await this.settings.load();
117
+ await writeAttemptRecord(settled, ended, cfg.resolved.runsDir, exportMachine(cfg));
118
+ }
91
119
  /** Runs the startup work now, so it does not wait for the first request. */
92
120
  bootstrap() {
93
121
  return this.init();
@@ -498,12 +526,7 @@ let OperatorService = class OperatorService {
498
526
  ? buildPlanExport(scope)
499
527
  : kind === 'domain'
500
528
  ? await buildDomainExport(scope, cfg.resolved.runsDir)
501
- : await buildBotExport(scope, cfg.resolved.runsDir, {
502
- node: process.versions.node,
503
- platform: process.platform,
504
- cwd: cfg.resolved.cwd,
505
- limits: { ...cfg.limits, execution: { commandTimeoutSec: cfg.execution.commandTimeoutSec, idleTimeoutSec: cfg.execution.idleTimeoutSec, mode: cfg.execution.mode } },
506
- });
529
+ : await buildBotExport(scope, cfg.resolved.runsDir, exportMachine(cfg));
507
530
  return { fileName: exportFileName(kind, scope.label), content: JSON.stringify(document, null, 2) };
508
531
  }
509
532
  /**
@@ -525,29 +548,39 @@ let OperatorService = class OperatorService {
525
548
  set.add(taskId);
526
549
  wanted.set(sessionId, set);
527
550
  }
551
+ /*
552
+ * A pair may name one attempt of its task — the button beside an earlier attempt does. That
553
+ * task is then exported as it stood for that attempt (`taskAtAttempt`): its text, its outcome,
554
+ * its run folder, and only the attempts before it.
555
+ */
528
556
  const sessions = [];
529
557
  for (const sessionId of wanted.keys()) {
530
- const session = await this.store.getSession(sessionId);
531
- if (session)
532
- sessions.push(session);
558
+ let session = await this.store.getSession(sessionId);
559
+ if (!session)
560
+ continue;
561
+ for (const pair of pairs.filter((x) => x.sessionId === sessionId && x.attempt !== undefined)) {
562
+ const task = session.tasks.find((t) => t.id === pair.taskId);
563
+ const asItWas = task ? taskAtAttempt(task, pair.attempt) : null;
564
+ if (task && !asItWas)
565
+ throw new Error(`"${task.title}" has no attempt ${pair.attempt}.`);
566
+ if (asItWas)
567
+ session = withTask(session, asItWas);
568
+ }
569
+ sessions.push(session);
533
570
  }
534
571
  const found = sessions.flatMap((s) => s.tasks.filter((t) => wanted.get(s.id)?.has(t.id)));
535
572
  if (found.length === 0)
536
573
  throw new Error('None of the chosen tasks still exists.');
537
574
  // Named after what was chosen rather than after a run, because a selection is not a run: it
538
575
  // may be one task of one, or a task from each of three.
576
+ const one = pairs.length === 1 ? pairs[0] : undefined;
539
577
  const label = found.length === 1 && sessions.length === 1
540
- ? `${sessions[0].name}-${found[0].title}`
578
+ ? `${sessions[0].name}-${found[0].title}${one?.attempt !== undefined ? `-attempt-${one.attempt}` : ''}`
541
579
  : sessions.length === 1
542
580
  ? `${sessions[0].name}-${found.length}-tasks`
543
581
  : `${found.length}-tasks-${sessions.length}-sessions`;
544
582
  const cfg = await this.settings.load();
545
- const document = await buildBundleExport({ sessions, taskFilter: (s, t) => !!wanted.get(s.id)?.has(t.id), label }, cfg.resolved.runsDir, {
546
- node: process.versions.node,
547
- platform: process.platform,
548
- cwd: cfg.resolved.cwd,
549
- limits: { ...cfg.limits, execution: { commandTimeoutSec: cfg.execution.commandTimeoutSec, idleTimeoutSec: cfg.execution.idleTimeoutSec, mode: cfg.execution.mode } },
550
- });
583
+ const document = await buildBundleExport({ sessions, taskFilter: (s, t) => !!wanted.get(s.id)?.has(t.id), label }, cfg.resolved.runsDir, exportMachine(cfg));
551
584
  return { fileName: exportFileName('bundle', label), content: JSON.stringify(document, null, 2) };
552
585
  }
553
586
  // --- running --------------------------------------------------------------------------
@@ -618,6 +651,8 @@ let OperatorService = class OperatorService {
618
651
  allowedPrograms: cfg.execution.allowedPrograms,
619
652
  isolation: cfg.execution.isolation,
620
653
  lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
654
+ // Read once per run like the rest of the policy: a change in Settings applies from the next start.
655
+ networkFetch: cfg.execution.networkFetch,
621
656
  };
622
657
  /*
623
658
  * An unattended run that cannot legally execute anything is stopped here, where the operator
@@ -716,7 +751,7 @@ let OperatorService = class OperatorService {
716
751
  r.controller.abort();
717
752
  for (const [id, w] of this.waiting) {
718
753
  if (w.approval.sessionId === sessionId) {
719
- w.resolve({ action: 'abort', reason: 'stopped by the operator' });
754
+ w.resolve({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
720
755
  this.waiting.delete(id);
721
756
  }
722
757
  }
@@ -1273,8 +1308,8 @@ let OperatorService = class OperatorService {
1273
1308
  const decision = action === 'run' || action === 'run-all'
1274
1309
  ? { action: 'run' }
1275
1310
  : action === 'skip'
1276
- ? { action: 'skip', reason: 'skipped by the operator' }
1277
- : { action: 'abort', reason: 'aborted by the operator' };
1311
+ ? { action: 'skip', reason: 'skipped by the operator', by: 'operator' }
1312
+ : { action: 'abort', reason: 'aborted by the operator', by: 'operator' };
1278
1313
  w.resolve(decision);
1279
1314
  this.bus.publish({ sessionId: w.approval.sessionId, taskId: w.approval.taskId, type: 'approval-decided', level: 'info',
1280
1315
  message: `step ${w.approval.stepId}: ${action}`, data: { approvalId, action } });
@@ -1357,7 +1392,7 @@ let OperatorService = class OperatorService {
1357
1392
  ...(held ? { network: held.network } : {}),
1358
1393
  };
1359
1394
  if (signal.aborted) {
1360
- resolvePromise({ action: 'abort', reason: 'stopped by the operator' });
1395
+ resolvePromise({ action: 'abort', reason: 'stopped by the operator', by: 'operator' });
1361
1396
  return;
1362
1397
  }
1363
1398
  this.waiting.set(approval.id, { approval, resolve: resolvePromise });
package/dist/src/cli.js CHANGED
@@ -484,6 +484,7 @@ program
484
484
  allowedPrograms: cfg.execution.allowedPrograms,
485
485
  isolation: cfg.execution.isolation,
486
486
  lockedToConfirm: cfg.policyLock?.maxMode === 'confirm',
487
+ networkFetch: cfg.execution.networkFetch,
487
488
  };
488
489
  const authorizer = cfg.execution.mode === 'unattended'
489
490
  ? unattendedAuthorizer(policy)
@@ -117,7 +117,32 @@ export const RunConfigSchema = z.object({
117
117
  task: TextOrFile.optional(),
118
118
  execution: z
119
119
  .object({
120
+ /*
121
+ * For the terminal, the mode `cop run` uses without `--unattended`. For the API it is the
122
+ * operator's standing choice from Settings: `unattended` means an unattended start is not
123
+ * questioned again with "are you sure" — they said so once, on the Settings page, and asking
124
+ * at every press is exactly the interruption they switched off. Every start still names its
125
+ * own mode; this never turns a step-by-step press into an unattended run.
126
+ */
120
127
  mode: z.enum(['confirm', 'unattended']).default('confirm'),
128
+ /**
129
+ * What an unattended run does with a step that fetches from the network. See `network.ts`.
130
+ *
131
+ * ask it waits on the approval screen for the operator, as it did before this setting
132
+ * existed — the one question an unattended run still asks.
133
+ * refuse it is not run, and the chat is told why at once and what to do instead, so the
134
+ * run never stops to wait for anybody. The terminal's unattended form has always
135
+ * done this, because it has nobody to ask.
136
+ * run it runs like every other step, with nobody having read it.
137
+ *
138
+ * `ask` is the default because the hold exists for a reason — a security team found
139
+ * model-written steps staging and running code on a work laptop — and a run that can fetch
140
+ * and then execute what arrived, unread, is the thing it was built to prevent. `run` is the
141
+ * operator's to choose on a machine where that is acceptable, and it is recorded in every
142
+ * task's policy manifest as exactly that. A step-by-step run is unaffected: every step
143
+ * there, a fetch included, is put in front of a person anyway.
144
+ */
145
+ networkFetch: z.enum(['ask', 'refuse', 'run']).default('ask'),
121
146
  /**
122
147
  * The shell a step or a check runs in when it names none of its own.
123
148
  *
@@ -6,11 +6,14 @@ import { stdin, stdout } from 'node:process';
6
6
  /**
7
7
  * Runs the static rules, then, unless unattended, asks the human through `ask`.
8
8
  *
9
- * A step that fetches from the network is the exception to "unless unattended": it is asked about
10
- * in every mode, with `held` saying why, because the runs nobody is watching are exactly the ones
11
- * in which a download followed by a second step that runs it would otherwise go unseen. `cfg` is
12
- * read on every call, not captured, so a run switched to unattended half-way is judged as one from
13
- * its next step on.
9
+ * A step that fetches from the network is the exception to "unless unattended": by default it is
10
+ * asked about in every mode, with `held` saying why, because the runs nobody is watching are
11
+ * exactly the ones in which a download followed by a second step that runs it would otherwise go
12
+ * unseen. The operator may choose otherwise for unattended runs (`cfg.networkFetch`): `refuse`
13
+ * answers the chat at once instead of waiting for anybody, `run` treats the fetch like any other
14
+ * step. Only for unattended runs — a watched run shows the fetch like every other step, and the
15
+ * person reading it is the whole point of watching. `cfg` is read on every call, not captured, so
16
+ * a run switched to unattended half-way is judged as one from its next step on.
14
17
  */
15
18
  export function makeAuthorizer(cfg, ask) {
16
19
  return {
@@ -20,8 +23,13 @@ export function makeAuthorizer(cfg, ask) {
20
23
  return blocked;
21
24
  const cwd = ctx.confinement?.cwd;
22
25
  const network = networkFetchReason(step.cmd, cwd) ?? (cwd ? scriptNetworkReason(step.cmd, cwd) : null);
23
- if (network)
26
+ if (network) {
27
+ if (cfg.mode === 'unattended' && cfg.networkFetch === 'refuse')
28
+ return { action: 'skip', reason: networkFetchRefusal(network) };
29
+ if (cfg.mode === 'unattended' && cfg.networkFetch === 'run')
30
+ return { action: 'run' };
24
31
  return await ask(step, ctx, { network });
32
+ }
25
33
  if (cfg.mode === 'unattended')
26
34
  return { action: 'run' };
27
35
  return await ask(step, ctx);
@@ -39,9 +47,9 @@ export function terminalAuthorizer(cfg, print) {
39
47
  try {
40
48
  const answer = (await rl.question(' [Enter] run [s] skip [q] abort > ')).trim().toLowerCase();
41
49
  if (answer === 'q')
42
- return { action: 'abort', reason: 'aborted by the operator' };
50
+ return { action: 'abort', reason: 'aborted by the operator', by: 'operator' };
43
51
  if (answer === 's')
44
- return { action: 'skip', reason: 'skipped by the operator' };
52
+ return { action: 'skip', reason: 'skipped by the operator', by: 'operator' };
45
53
  return { action: 'run' };
46
54
  }
47
55
  finally {
@@ -53,8 +61,10 @@ export function terminalAuthorizer(cfg, print) {
53
61
  * Everything runs after the static rules. Only for runs the user explicitly marked so.
54
62
  *
55
63
  * A step held for a person (a network fetch) is refused here, with the reason, because this form
56
- * has nobody to ask. The web UI does not use it: its unattended runs keep the approval screen for
57
- * exactly those steps.
64
+ * has nobody to ask — `ask` and `refuse` come to the same thing at a terminal nobody is at. Only
65
+ * `networkFetch: run` lets one through, and that is decided in `makeAuthorizer` before this is
66
+ * reached. The web UI does not use it: its unattended runs keep the approval screen for exactly
67
+ * those steps.
58
68
  */
59
69
  export function unattendedAuthorizer(cfg) {
60
70
  return makeAuthorizer({ ...cfg, mode: 'unattended' }, async (_step, _ctx, held) => held ? { action: 'skip', reason: networkFetchRefusal(held.network) } : { action: 'run' });