copilot-operator 0.1.12 → 0.1.15
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/README.md +9 -14
- package/dist/src/api/operator.controller.js +31 -42
- package/dist/src/api/operator.service.js +692 -364
- package/dist/src/api/saveToDesktop.js +1 -1
- package/dist/src/api/security.js +40 -10
- package/dist/src/api/server.js +75 -12
- package/dist/src/api/settings.js +156 -15
- package/dist/src/cli.js +108 -97
- package/dist/src/config/handWritten.js +36 -0
- package/dist/src/config/layout.js +13 -4
- package/dist/src/config/lockedPolicy.js +56 -22
- package/dist/src/config/schema.js +13 -84
- package/dist/src/context/desktopDir.js +44 -0
- package/dist/src/exec/authorizer.js +9 -1
- package/dist/src/exec/checks.js +240 -23
- package/dist/src/exec/dangerous.js +29 -5
- package/dist/src/exec/network.js +105 -28
- package/dist/src/exec/redaction.js +52 -9
- package/dist/src/exec/reportFile.js +4 -5
- package/dist/src/exec/runner.js +146 -13
- package/dist/src/exec/shells.js +41 -6
- package/dist/src/exec/workDir.js +4 -4
- package/dist/src/orchestrator/derivedChecks.js +1 -0
- package/dist/src/orchestrator/review.js +25 -3
- package/dist/src/orchestrator/taskRunner.js +482 -150
- package/dist/src/plan/brief.js +56 -44
- package/dist/src/plan/importPlan.js +18 -7
- package/dist/src/plan/schema.js +90 -31
- package/dist/src/plan/script.js +79 -90
- package/dist/src/plan/systemGuide.js +39 -182
- package/dist/src/protocol/parser.js +5 -1
- package/dist/src/session/debugExport.js +1 -1
- package/dist/src/session/exportRecord.js +1 -1
- package/dist/src/session/exports.js +52 -9
- package/dist/src/session/store.js +215 -80
- package/dist/src/vcs/git.js +77 -2
- package/dist/src/vcs/scope.js +29 -6
- package/dist/src/vcs/taskVcs.js +149 -23
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
- package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
- package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
- package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +18 -10
- package/prompts/level1.md +2 -1
- package/prompts/organisation.bg.md +0 -1
- package/prompts/organisation.en.md +0 -1
- package/dist/src/context/contextFiles.js +0 -295
- package/dist/src/context/desktopMirror.js +0 -135
- package/dist/src/context/projectMirror.js +0 -432
- package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
- package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
- package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-cc99401da5173b36.js +0 -1
- /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_ssgManifest.js +0 -0
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* commands denied.
|
|
7
7
|
*/
|
|
8
8
|
import { z } from 'zod';
|
|
9
|
-
import { readFile } from 'node:fs/promises';
|
|
10
9
|
import { parse as parseYaml } from 'yaml';
|
|
10
|
+
import { readHandWritten } from './handWritten.js';
|
|
11
11
|
import { applyPolicyLock, readPolicyLocks } from './lockedPolicy.js';
|
|
12
12
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
@@ -70,13 +70,6 @@ const GIT_SOMETIMES_WRITES = 'remote\\s+(?:add|remove|rm|rename|set-url|set-head
|
|
|
70
70
|
'config\\s+(?:set|unset|rename-section|remove-section|edit)\\b|' +
|
|
71
71
|
'reflog\\s+(?:delete|expire)|' +
|
|
72
72
|
'notes\\s+(?:add|append|edit|remove|copy|prune)';
|
|
73
|
-
/** Which directories of one project are copied to the Desktop, and how. */
|
|
74
|
-
const ProjectMirrorSelection = z.object({
|
|
75
|
-
includeDirs: z.array(z.string()).default([]),
|
|
76
|
-
excludeDirs: z.array(z.string()).default([]),
|
|
77
|
-
respectGitignore: z.boolean().default(true),
|
|
78
|
-
includeEnvFiles: z.boolean().default(false),
|
|
79
|
-
});
|
|
80
73
|
export const RunConfigSchema = z.object({
|
|
81
74
|
copilot: z
|
|
82
75
|
.object({
|
|
@@ -243,7 +236,12 @@ export const RunConfigSchema = z.object({
|
|
|
243
236
|
'Set-ExecutionPolicy\\s+Unrestricted',
|
|
244
237
|
'diskpart|bcdedit|vssadmin',
|
|
245
238
|
'Disable-WindowsOptionalFeature',
|
|
246
|
-
|
|
239
|
+
// An account made, or put into a group, however the line spells it. `net user bob p /add`
|
|
240
|
+
// gives the password before `/add` and is the ordinary form; the pattern that wanted
|
|
241
|
+
// `/add` straight after the name missed it, as it missed `net.exe`, `net1`, `localgroup`
|
|
242
|
+
// and the cmdlets that do the same thing.
|
|
243
|
+
'\\bnet1?(?:\\.exe)?\\s+(?:user|localgroup|group)\\b[^|;&\\n]*\\s/add\\b',
|
|
244
|
+
'\\b(?:New-LocalUser|Add-LocalGroupMember|New-ADUser|Add-ADGroupMember)\\b',
|
|
247
245
|
// git that changes something. See GIT_WRITE_NOTE below.
|
|
248
246
|
`\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_ALWAYS_WRITES})(?![\\w-])`,
|
|
249
247
|
`\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_SOMETIMES_WRITES})`,
|
|
@@ -272,9 +270,8 @@ export const RunConfigSchema = z.object({
|
|
|
272
270
|
rootDir: z.string().default(''),
|
|
273
271
|
/**
|
|
274
272
|
* What to call it. Empty means the folder's own name, which is what this used to be
|
|
275
|
-
* always — fine until the
|
|
276
|
-
*
|
|
277
|
-
* is what the operator calls it. The other projects have had a name from the start; the
|
|
273
|
+
* always — fine until the plan brief started listing the projects, where "rules-tests"
|
|
274
|
+
* is a path and "the test suite" is what the operator calls it. The other projects have had a name from the start; the
|
|
278
275
|
* default one had nowhere to put it.
|
|
279
276
|
*/
|
|
280
277
|
name: z.string().trim().default(''),
|
|
@@ -290,47 +287,8 @@ export const RunConfigSchema = z.object({
|
|
|
290
287
|
.array(z.object({
|
|
291
288
|
name: z.string().trim().min(1),
|
|
292
289
|
rootDir: z.string().trim().min(1),
|
|
293
|
-
/** Which of this project's directories go to the Desktop. See `mirror` below. */
|
|
294
|
-
mirror: ProjectMirrorSelection.optional(),
|
|
295
|
-
/** `false` keeps this project off the Desktop while the switch below is on. Absent means on. */
|
|
296
|
-
desktop: z.boolean().optional(),
|
|
297
290
|
}))
|
|
298
291
|
.default([]),
|
|
299
|
-
/**
|
|
300
|
-
* Keep a copy of every project's selected directories on the Desktop, one folder per
|
|
301
|
-
* project under `copilot-operator-context`, refreshed before every run.
|
|
302
|
-
*
|
|
303
|
-
* The Desktop is the way into OneDrive, and OneDrive is the way into the chat's file
|
|
304
|
-
* picker. This is the switch for it: on, every project listed here is mirrored, with its
|
|
305
|
-
* own selection; off, the project folders are removed from the Desktop, because a stale
|
|
306
|
-
* copy of a code base sitting in the cloud is worse than none.
|
|
307
|
-
*/
|
|
308
|
-
mirrorToDesktop: z.boolean().default(false),
|
|
309
|
-
/** The default project's own selection. */
|
|
310
|
-
mirror: ProjectMirrorSelection.optional(),
|
|
311
|
-
/** `false` keeps the default project off the Desktop while the switch is on. Absent means on. */
|
|
312
|
-
desktop: z.boolean().optional(),
|
|
313
|
-
})
|
|
314
|
-
.prefault({}),
|
|
315
|
-
/**
|
|
316
|
-
* Defaults for the project mirror. Which project and which directories is a property of a
|
|
317
|
-
* session; these are the mechanics shared by all of them.
|
|
318
|
-
*/
|
|
319
|
-
projectMirror: z
|
|
320
|
-
.object({
|
|
321
|
-
enabled: z.boolean().default(false),
|
|
322
|
-
rootDir: z.string().optional(),
|
|
323
|
-
includeDirs: z.array(z.string()).default([]),
|
|
324
|
-
excludeDirs: z.array(z.string()).default([]),
|
|
325
|
-
targetDir: z.string().optional(),
|
|
326
|
-
separator: z.string().default('--'),
|
|
327
|
-
txtMode: z.enum(['append', 'replace', 'none']).default('append'),
|
|
328
|
-
respectGitignore: z.boolean().default(true),
|
|
329
|
-
ignoreDirs: z.array(z.string()).default([]),
|
|
330
|
-
includeEnvFiles: z.boolean().default(false),
|
|
331
|
-
maxFileBytes: z.number().int().positive().default(2 * 1024 * 1024),
|
|
332
|
-
attachToFirstMessage: z.boolean().default(true),
|
|
333
|
-
maxAttachedFiles: z.number().int().positive().default(20),
|
|
334
292
|
})
|
|
335
293
|
.prefault({}),
|
|
336
294
|
pacing: z
|
|
@@ -434,18 +392,11 @@ async function textOf(value, baseDir) {
|
|
|
434
392
|
return '';
|
|
435
393
|
if (typeof value === 'string')
|
|
436
394
|
return value;
|
|
437
|
-
|
|
395
|
+
// A file the run.yaml names is written by hand as the run.yaml is: see `handWritten.ts`.
|
|
396
|
+
return await readHandWritten(expandPath(value.file, baseDir));
|
|
438
397
|
}
|
|
439
398
|
/** Turns parsed config plus a base directory into absolute paths and loaded texts. */
|
|
440
399
|
export async function resolveConfig(cfg, configPath, baseDir) {
|
|
441
|
-
if (cfg.projectMirror.enabled) {
|
|
442
|
-
if (!cfg.projectMirror.rootDir)
|
|
443
|
-
throw new Error('projectMirror.enabled is true but projectMirror.rootDir is missing.');
|
|
444
|
-
if (cfg.projectMirror.includeDirs.length === 0) {
|
|
445
|
-
throw new Error('projectMirror.enabled is true but includeDirs is empty, so nothing would be copied. ' +
|
|
446
|
-
'List the directories to include, or use ["."] for the whole project.');
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
400
|
/*
|
|
450
401
|
* The administrator's floor, applied here because this is the one function both the terminal's
|
|
451
402
|
* `run.yaml` and the API's `data/settings.json` pass through. Applying it anywhere further in
|
|
@@ -462,10 +413,6 @@ export async function resolveConfig(cfg, configPath, baseDir) {
|
|
|
462
413
|
denyPatterns: cfg.execution.denyPatterns,
|
|
463
414
|
isolation: cfg.execution.isolation,
|
|
464
415
|
passEnv: cfg.execution.passEnv,
|
|
465
|
-
mirrorToDesktop: cfg.project.mirrorToDesktop,
|
|
466
|
-
projectMirrorEnabled: cfg.projectMirror.enabled,
|
|
467
|
-
includeEnvFiles: cfg.project.mirror?.includeEnvFiles ?? cfg.project.others.some((o) => o.mirror?.includeEnvFiles === true),
|
|
468
|
-
mirrorIncludeEnvFiles: cfg.projectMirror.includeEnvFiles,
|
|
469
416
|
};
|
|
470
417
|
const outcome = { applied: false, changes: [] };
|
|
471
418
|
for (const lock of locks) {
|
|
@@ -486,12 +433,6 @@ export async function resolveConfig(cfg, configPath, baseDir) {
|
|
|
486
433
|
isolation: policy.isolation ?? cfg.execution.isolation,
|
|
487
434
|
passEnv: policy.passEnv ?? cfg.execution.passEnv,
|
|
488
435
|
},
|
|
489
|
-
project: lockedProject(cfg.project, policy),
|
|
490
|
-
projectMirror: {
|
|
491
|
-
...cfg.projectMirror,
|
|
492
|
-
enabled: policy.projectMirrorEnabled ?? cfg.projectMirror.enabled,
|
|
493
|
-
includeEnvFiles: policy.mirrorIncludeEnvFiles ?? cfg.projectMirror.includeEnvFiles,
|
|
494
|
-
},
|
|
495
436
|
configPath,
|
|
496
437
|
baseDir,
|
|
497
438
|
policyLock: outcome,
|
|
@@ -503,29 +444,17 @@ export async function resolveConfig(cfg, configPath, baseDir) {
|
|
|
503
444
|
level1Path: expandPath(cfg.level1File, baseDir),
|
|
504
445
|
level2Text: await textOf(cfg.level2, baseDir),
|
|
505
446
|
taskText: await textOf(cfg.task, baseDir),
|
|
506
|
-
mirrorRootDir: cfg.projectMirror.rootDir ? expandPath(cfg.projectMirror.rootDir, baseDir) : undefined,
|
|
507
|
-
mirrorTargetDir: cfg.projectMirror.targetDir ? expandPath(cfg.projectMirror.targetDir, baseDir) : undefined,
|
|
508
447
|
},
|
|
509
448
|
};
|
|
510
449
|
}
|
|
511
|
-
/** The project section with what the lock decided: the Desktop mirror switch and the .env selections. */
|
|
512
|
-
function lockedProject(project, policy) {
|
|
513
|
-
const envOff = policy.includeEnvFiles === false;
|
|
514
|
-
const fix = (m) => (m && envOff ? { ...m, includeEnvFiles: false } : m);
|
|
515
|
-
return {
|
|
516
|
-
...project,
|
|
517
|
-
mirrorToDesktop: policy.mirrorToDesktop ?? project.mirrorToDesktop,
|
|
518
|
-
mirror: fix(project.mirror),
|
|
519
|
-
others: project.others.map((o) => ({ ...o, mirror: fix(o.mirror) })),
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
450
|
function formatIssues(issues) {
|
|
523
451
|
return issues.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n');
|
|
524
452
|
}
|
|
525
453
|
/** Loads a YAML run config from disk. */
|
|
526
454
|
export async function loadConfig(configPath) {
|
|
527
455
|
const abs = resolve(configPath);
|
|
528
|
-
|
|
456
|
+
// Written by hand, in whatever encoding its editor chose: see `handWritten.ts`.
|
|
457
|
+
const raw = await readHandWritten(abs);
|
|
529
458
|
const parsed = RunConfigSchema.safeParse(parseYaml(raw) ?? {});
|
|
530
459
|
if (!parsed.success)
|
|
531
460
|
throw new Error(`${abs} is not a valid run config:\n${formatIssues(parsed.error.issues)}`);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Desktop is, and whether it is backed up by OneDrive.
|
|
3
|
+
*
|
|
4
|
+
* Used by "save the log to the Desktop" and by `cop doctor`. What else once lived next to this — the
|
|
5
|
+
* project copied to a Desktop folder and its files attached to the chat, and the bridge to Context
|
|
6
|
+
* Picker — was removed on 2026-09-30 at the operator's request; the bot no longer hands the chat any
|
|
7
|
+
* of the project's files.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join, resolve, sep } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
/**
|
|
13
|
+
* The Desktop folder.
|
|
14
|
+
*
|
|
15
|
+
* Candidates are tried in order and the first that exists wins, which is what makes this
|
|
16
|
+
* correct under OneDrive's Known Folder Move: when the Desktop is backed up, the real
|
|
17
|
+
* Desktop is `<OneDrive>\Desktop` and `%USERPROFILE%\Desktop` may not exist at all. No
|
|
18
|
+
* registry read is needed for that.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveDesktopDir(env = process.env) {
|
|
21
|
+
const candidates = [
|
|
22
|
+
env.OneDriveCommercial ? join(env.OneDriveCommercial, 'Desktop') : null,
|
|
23
|
+
env.OneDrive ? join(env.OneDrive, 'Desktop') : null,
|
|
24
|
+
join(env.USERPROFILE ?? homedir(), 'Desktop'),
|
|
25
|
+
].filter((c) => Boolean(c));
|
|
26
|
+
for (const c of candidates) {
|
|
27
|
+
if (existsSync(c))
|
|
28
|
+
return c;
|
|
29
|
+
}
|
|
30
|
+
return candidates[candidates.length - 1];
|
|
31
|
+
}
|
|
32
|
+
/** Whether the resolved Desktop is inside OneDrive, so what is saved there also reaches the cloud. */
|
|
33
|
+
export function desktopIsSynced(env = process.env) {
|
|
34
|
+
return isInsideOneDrive(resolveDesktopDir(env), env);
|
|
35
|
+
}
|
|
36
|
+
/** Whether a folder sits inside a local OneDrive root. */
|
|
37
|
+
export function isInsideOneDrive(dir, env = process.env) {
|
|
38
|
+
const roots = [env.OneDriveCommercial, env.OneDrive, env.OneDriveConsumer].filter((r) => Boolean(r && r.trim()));
|
|
39
|
+
const target = resolve(dir).toLowerCase();
|
|
40
|
+
return roots.some((r) => {
|
|
41
|
+
const root = resolve(r).toLowerCase();
|
|
42
|
+
return target === root || target.startsWith(root + sep);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -14,10 +14,18 @@ import { stdin, stdout } from 'node:process';
|
|
|
14
14
|
* step. Only for unattended runs — a watched run shows the fetch like every other step, and the
|
|
15
15
|
* person reading it is the whole point of watching. `cfg` is read on every call, not captured, so
|
|
16
16
|
* a run switched to unattended half-way is judged as one from its next step on.
|
|
17
|
+
*
|
|
18
|
+
* `signal` is the run's Stop, and it is asked first, before any rule and before the mode: a step
|
|
19
|
+
* proposed after Stop is aborted in every mode. It used to be asked only inside `ask`, which an
|
|
20
|
+
* unattended run never reaches, so in a run started unattended — or switched to it by "run the rest
|
|
21
|
+
* without asking" — a Stop that landed between the runner's own look at the signal and this answer
|
|
22
|
+
* let one more step run.
|
|
17
23
|
*/
|
|
18
|
-
export function makeAuthorizer(cfg, ask) {
|
|
24
|
+
export function makeAuthorizer(cfg, ask, signal) {
|
|
19
25
|
return {
|
|
20
26
|
async authorize(step, ctx) {
|
|
27
|
+
if (signal?.aborted)
|
|
28
|
+
return { action: 'abort', reason: 'stopped by the operator', by: 'operator' };
|
|
21
29
|
const blocked = staticCheck(step, cfg, undefined, ctx.confinement);
|
|
22
30
|
if (blocked)
|
|
23
31
|
return blocked;
|
package/dist/src/exec/checks.js
CHANGED
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
import { readFile } from 'node:fs/promises';
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { join, resolve } from 'node:path';
|
|
19
|
+
import { createContext, Script } from 'node:vm';
|
|
19
20
|
import { isWithin } from './confinement.js';
|
|
20
|
-
import { runStep } from './runner.js';
|
|
21
|
+
import { MAX_CAPTURE_CHARS, runStep } from './runner.js';
|
|
22
|
+
import { findRedactions, mergeRedactions, redactSecrets } from './redaction.js';
|
|
21
23
|
import { resolveShell } from './shells.js';
|
|
22
24
|
import { repoState, workingTreePaths } from '../vcs/git.js';
|
|
23
25
|
import { findSuspicious, suspiciousDetail } from '../vcs/commitHygiene.js';
|
|
@@ -34,7 +36,13 @@ export const CONTENT_CLEAN_CHECK = { name: 'the text written is clean: encoding,
|
|
|
34
36
|
/** The checks the runner adds by itself before a commit, rather than ones a plan wrote. */
|
|
35
37
|
export const RUNNER_CHECK_KINDS = new Set(['commit-clean', 'content-clean']);
|
|
36
38
|
const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
37
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* How much of a command's output travels back to the chat with a failure.
|
|
41
|
+
*
|
|
42
|
+
* Only what travels: a verdict is never taken on this shortened text. An output check is a claim
|
|
43
|
+
* about everything the command printed, and a marker printed after the first few thousand
|
|
44
|
+
* characters — the FAIL line at the end of a test run — is exactly the one it exists to see.
|
|
45
|
+
*/
|
|
38
46
|
const OUTPUT_KEPT = 4000;
|
|
39
47
|
function shorten(text, max = OUTPUT_KEPT) {
|
|
40
48
|
const trimmed = text.trim();
|
|
@@ -44,6 +52,149 @@ function shorten(text, max = OUTPUT_KEPT) {
|
|
|
44
52
|
function needsCommand(check) {
|
|
45
53
|
return check.expect === 'exit-zero' || check.expect === 'exit-nonzero' || check.expect.startsWith('output-');
|
|
46
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* How long a check's pattern may spend on the output, all of it together, before the check is given
|
|
57
|
+
* up as undecidable.
|
|
58
|
+
*
|
|
59
|
+
* The pattern is written by whoever wrote the check, often a model, and some patterns cost a
|
|
60
|
+
* backtracking engine quadratic time or worse: `.*passed` on one line of 150,000 characters runs for
|
|
61
|
+
* half a minute, and on two full streams for minutes. It runs on the server's event loop, which is
|
|
62
|
+
* also the interface, Stop, the live log and the chat's timers, and every one of them stood still
|
|
63
|
+
* with it. A pattern that suits the output it is tried on is done in milliseconds; one that needs
|
|
64
|
+
* seconds is not going to finish.
|
|
65
|
+
*/
|
|
66
|
+
const PATTERN_BUDGET_MS = 5_000;
|
|
67
|
+
/** Compiles the check's pattern inside its context. `source` is a value there, never code. */
|
|
68
|
+
const COMPILE_PATTERN = new Script('re = new RegExp(source, "m")');
|
|
69
|
+
/** Whether any of `texts` matches it. */
|
|
70
|
+
const TRY_PATTERN = new Script('texts.some((t) => re.test(t))');
|
|
71
|
+
/**
|
|
72
|
+
* A check's regular expression, run on a clock.
|
|
73
|
+
*
|
|
74
|
+
* V8 cannot be told to give up on a regular expression, but a script run in a `vm` context can be
|
|
75
|
+
* interrupted when its time is up, and a regular expression running inside one is interrupted with
|
|
76
|
+
* it. So the pattern is compiled and tried there, each try against what is left of one budget. A try
|
|
77
|
+
* that runs out of it, or that the engine abandons by itself, leaves the pattern undecided: whether
|
|
78
|
+
* the output matches is then not known, and the check says so rather than guessing either way.
|
|
79
|
+
*/
|
|
80
|
+
class TimedPattern {
|
|
81
|
+
context;
|
|
82
|
+
leftMs = PATTERN_BUDGET_MS;
|
|
83
|
+
/** Set once any text tried has matched. */
|
|
84
|
+
matched = false;
|
|
85
|
+
/** Why the pattern could not be tried to the end, once it could not. */
|
|
86
|
+
stopped = null;
|
|
87
|
+
/** Throws, with the engine's own message, when the source is not a regular expression. */
|
|
88
|
+
constructor(source) {
|
|
89
|
+
this.context = createContext({ source, texts: [] });
|
|
90
|
+
COMPILE_PATTERN.runInContext(this.context);
|
|
91
|
+
}
|
|
92
|
+
/** Whether the answer is already known: it matched, or it cannot be found out. */
|
|
93
|
+
get settled() {
|
|
94
|
+
return this.matched || this.stopped !== null;
|
|
95
|
+
}
|
|
96
|
+
/** Tries the texts unless the answer is already known; true once any text tried has matched. */
|
|
97
|
+
tryOn(texts) {
|
|
98
|
+
if (this.settled || texts.length === 0)
|
|
99
|
+
return this.matched;
|
|
100
|
+
if (this.leftMs <= 0) {
|
|
101
|
+
this.stopped = this.overtime();
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const started = Date.now();
|
|
105
|
+
this.context.texts = texts;
|
|
106
|
+
try {
|
|
107
|
+
this.matched = TRY_PATTERN.runInContext(this.context, { timeout: this.leftMs }) === true;
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
this.stopped =
|
|
111
|
+
e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT'
|
|
112
|
+
? this.overtime()
|
|
113
|
+
: `the pattern could not be run to the end (${e.message})`;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
this.leftMs -= Date.now() - started;
|
|
117
|
+
this.context.texts = [];
|
|
118
|
+
}
|
|
119
|
+
return this.matched;
|
|
120
|
+
}
|
|
121
|
+
overtime() {
|
|
122
|
+
return (`the pattern ran for more than ${PATTERN_BUDGET_MS / 1000} s on the output and was stopped ` +
|
|
123
|
+
'(a pattern such as `.*x` can take minutes on one long line)');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* For output-contains and output-omits: whether the text appears anywhere in either stream.
|
|
128
|
+
*
|
|
129
|
+
* Exact, in bounded memory: each piece is searched together with the last `value.length - 1`
|
|
130
|
+
* characters of its stream before it, so a text that arrived cut in two is still found whole.
|
|
131
|
+
*/
|
|
132
|
+
function watchForText(value) {
|
|
133
|
+
const carried = { out: '', err: '' };
|
|
134
|
+
let found = value === '';
|
|
135
|
+
return {
|
|
136
|
+
found: () => found,
|
|
137
|
+
see: (text, stream) => {
|
|
138
|
+
if (found)
|
|
139
|
+
return;
|
|
140
|
+
const joined = carried[stream] + text;
|
|
141
|
+
if (joined.includes(value))
|
|
142
|
+
found = true;
|
|
143
|
+
else
|
|
144
|
+
carried[stream] = value.length > 1 ? joined.slice(1 - value.length) : '';
|
|
145
|
+
},
|
|
146
|
+
end: () => undefined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* For output-matches: the pattern on every line of the output that is not wholly in the part kept.
|
|
151
|
+
*
|
|
152
|
+
* The part kept is tried afterwards as one text, as it always was, so a pattern written to span lines
|
|
153
|
+
* still can there. Past it there is no text, only a stream, and a line is the unit a pattern with `^`
|
|
154
|
+
* and `$` is written against, so each line is tried as it ends. A line longer than MAX_CAPTURE_CHARS
|
|
155
|
+
* is tried as far as that and the rest of it passed over, so memory stays bounded whatever a program
|
|
156
|
+
* prints; `partLine` says when that happened.
|
|
157
|
+
*/
|
|
158
|
+
function watchForPattern(pattern) {
|
|
159
|
+
const held = { out: '', err: '' };
|
|
160
|
+
const received = { out: 0, err: 0 };
|
|
161
|
+
const skipping = { out: false, err: false };
|
|
162
|
+
let partLine = false;
|
|
163
|
+
// A line that ends inside the part kept is in the text tried afterwards; trying it here as well
|
|
164
|
+
// would only spend the budget twice.
|
|
165
|
+
const pastKept = (end) => end > MAX_CAPTURE_CHARS;
|
|
166
|
+
return {
|
|
167
|
+
partLine: () => partLine,
|
|
168
|
+
see: (text, stream) => {
|
|
169
|
+
const at = received[stream];
|
|
170
|
+
received[stream] += text.length;
|
|
171
|
+
if (pattern.settled)
|
|
172
|
+
return;
|
|
173
|
+
const ended = [];
|
|
174
|
+
let start = 0;
|
|
175
|
+
for (let nl = text.indexOf('\n'); nl >= 0; nl = text.indexOf('\n', start)) {
|
|
176
|
+
if (skipping[stream])
|
|
177
|
+
skipping[stream] = false;
|
|
178
|
+
else if (pastKept(at + nl))
|
|
179
|
+
ended.push(held[stream] + text.slice(start, nl));
|
|
180
|
+
held[stream] = '';
|
|
181
|
+
start = nl + 1;
|
|
182
|
+
}
|
|
183
|
+
if (!skipping[stream]) {
|
|
184
|
+
held[stream] += text.slice(start);
|
|
185
|
+
if (held[stream].length > MAX_CAPTURE_CHARS) {
|
|
186
|
+
ended.push(held[stream]);
|
|
187
|
+
held[stream] = '';
|
|
188
|
+
skipping[stream] = true;
|
|
189
|
+
partLine = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
pattern.tryOn(ended);
|
|
193
|
+
},
|
|
194
|
+
// The last line of a stream need not end in a newline; it has ended all the same.
|
|
195
|
+
end: () => pattern.tryOn(['out', 'err'].filter((s) => held[s] !== '' && pastKept(received[s])).map((s) => held[s])),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
47
198
|
/**
|
|
48
199
|
* One check, decided.
|
|
49
200
|
*
|
|
@@ -52,8 +203,30 @@ function needsCommand(check) {
|
|
|
52
203
|
* a gate that opens when it breaks, which is the one behaviour a gate must never have.
|
|
53
204
|
*/
|
|
54
205
|
export async function runCheck(check, index, opts) {
|
|
55
|
-
|
|
56
|
-
|
|
206
|
+
/*
|
|
207
|
+
* What an outcome says is quoted to the chat by more than one caller — the checks message, the
|
|
208
|
+
* reviewer's brief ("Last result"), the message that turns down a reviewer's check — and it
|
|
209
|
+
* repeats the value the check looked for and the output it saw. So it is redacted where it is
|
|
210
|
+
* made, once, rather than at each place that happens to quote it — with the operator's own
|
|
211
|
+
* patterns as well as the built-in shapes, which until 2026-10-01 only the checks file got, so a
|
|
212
|
+
* company's key format reached the chat in every message beside it. What was taken out is kept
|
|
213
|
+
* on the outcome, for the event that says so.
|
|
214
|
+
*/
|
|
215
|
+
const patterns = opts.redactPatterns ?? [];
|
|
216
|
+
const said = (outcome) => {
|
|
217
|
+
const found = mergeRedactions([
|
|
218
|
+
findRedactions(outcome.detail, patterns),
|
|
219
|
+
outcome.output !== undefined ? findRedactions(outcome.output, patterns) : [],
|
|
220
|
+
]);
|
|
221
|
+
return {
|
|
222
|
+
...outcome,
|
|
223
|
+
detail: redactSecrets(outcome.detail, patterns),
|
|
224
|
+
...(outcome.output !== undefined ? { output: redactSecrets(outcome.output, patterns) } : {}),
|
|
225
|
+
...(found.length > 0 ? { redactions: found } : {}),
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
const fail = (detail, extra = {}) => said({ check, passed: false, detail, ...extra });
|
|
229
|
+
const pass = (detail, extra = {}) => said({ check, passed: true, detail, ...extra });
|
|
57
230
|
if (check.expect === 'commit-clean') {
|
|
58
231
|
const dir = (opts.repoDir ?? '').trim();
|
|
59
232
|
if (!dir)
|
|
@@ -111,6 +284,21 @@ export async function runCheck(check, index, opts) {
|
|
|
111
284
|
const refused = opts.deny?.(command, shell, cwd);
|
|
112
285
|
if (refused)
|
|
113
286
|
return fail(`the command was refused before it ran: ${refused}`, { refusedBeforeRunning: true });
|
|
287
|
+
// A pattern that is not one is known before anything runs, and running the command anyway would
|
|
288
|
+
// spend a test suite's minutes on a verdict that cannot be taken.
|
|
289
|
+
const value = check.value ?? '';
|
|
290
|
+
let pattern = null;
|
|
291
|
+
if (check.expect === 'output-matches') {
|
|
292
|
+
try {
|
|
293
|
+
pattern = new TimedPattern(value);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
return fail(`the pattern is not a valid regular expression: ${e.message}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const text = watchForText(value);
|
|
300
|
+
const lines = pattern ? watchForPattern(pattern) : null;
|
|
301
|
+
const watch = lines ?? (check.expect.startsWith('output-') ? text : null);
|
|
114
302
|
let result;
|
|
115
303
|
try {
|
|
116
304
|
result = await runStep({
|
|
@@ -124,13 +312,16 @@ export async function runCheck(check, index, opts) {
|
|
|
124
312
|
hardTimeoutMs: opts.timeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS,
|
|
125
313
|
logPath: join(opts.logDir, `check-${index + 1}.txt`),
|
|
126
314
|
passEnv: opts.passEnv,
|
|
127
|
-
}, { signal: opts.signal, tracker: opts.tracker });
|
|
315
|
+
}, { signal: opts.signal, tracker: opts.tracker, onOutput: watch?.see });
|
|
128
316
|
}
|
|
129
317
|
catch (e) {
|
|
130
318
|
return fail(`the check could not be run: ${e.message}`);
|
|
131
319
|
}
|
|
132
|
-
|
|
133
|
-
|
|
320
|
+
watch?.end();
|
|
321
|
+
// What the runner kept: shortened, it is the copy that is reported, and whole it is the one text
|
|
322
|
+
// a pattern is tried on. The verdict on a text is taken by the watch, which saw all of it.
|
|
323
|
+
const output = `${result.stdout}${result.stderr ? `\n${result.stderr}` : ''}`.trim();
|
|
324
|
+
const seen = { exitCode: result.exitCode, output: shorten(output), shell: result.shell, shellPath: result.shellPath };
|
|
134
325
|
// The shell was there when the run began and would not start when it was wanted. Nothing
|
|
135
326
|
// about the work has been learned, so this is reported as what it is rather than as a
|
|
136
327
|
// command that failed.
|
|
@@ -150,24 +341,30 @@ export async function runCheck(check, index, opts) {
|
|
|
150
341
|
? pass(`the command failed, as expected (exit ${result.exitCode})`, seen)
|
|
151
342
|
: fail('expected the command to fail, but it succeeded', seen);
|
|
152
343
|
case 'output-contains':
|
|
153
|
-
return
|
|
344
|
+
return text.found()
|
|
154
345
|
? pass(`the output contains "${check.value}"`, seen)
|
|
155
346
|
: fail(`expected the output to contain "${check.value}", and it does not`, seen);
|
|
156
347
|
case 'output-omits':
|
|
157
|
-
return
|
|
348
|
+
return text.found()
|
|
158
349
|
? fail(`expected the output not to contain "${check.value}", but it does`, seen)
|
|
159
350
|
: pass(`the output does not contain "${check.value}"`, seen);
|
|
160
351
|
case 'output-matches': {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return fail(`the
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
352
|
+
// The lines past the part kept were tried as they came; the part kept is tried now, as one
|
|
353
|
+
// text. A pattern that cannot be run is a check that fails, never one that passes.
|
|
354
|
+
if (pattern?.tryOn([output]))
|
|
355
|
+
return pass(`the output matches /${check.value}/`, seen);
|
|
356
|
+
if (pattern?.stopped)
|
|
357
|
+
return fail(`whether the output matches /${check.value}/ cannot be decided: ${pattern.stopped}`, seen);
|
|
358
|
+
/*
|
|
359
|
+
* Said plainly when it is not the whole truth. Past what the runner keeps, the output was
|
|
360
|
+
* tried a line at a time, so a match spanning lines there, or in a line longer than the
|
|
361
|
+
* runner keeps, was not looked for; the chat is told so, rather than told the text is absent.
|
|
362
|
+
*/
|
|
363
|
+
const how = result.truncated
|
|
364
|
+
? ` (past the first ${MAX_CAPTURE_CHARS} characters of each stream the output was tried a line at a time, so a match ` +
|
|
365
|
+
`spanning lines there was not looked for${lines?.partLine() ? `, nor one past the first ${MAX_CAPTURE_CHARS} characters of a longer line` : ''})`
|
|
366
|
+
: '';
|
|
367
|
+
return fail(`expected the output to match /${check.value}/, and it does not${how}`, seen);
|
|
171
368
|
}
|
|
172
369
|
default:
|
|
173
370
|
return fail(`unknown check kind "${check.expect}"`, seen);
|
|
@@ -238,8 +435,15 @@ export function describeCheck(check) {
|
|
|
238
435
|
* Written as an instruction rather than as a complaint: it says what was asked, what happened,
|
|
239
436
|
* and that the task is not over. The full output is attached as a file by the caller, the same
|
|
240
437
|
* way step output is, because a chat message is the wrong place for a compiler's opinion.
|
|
438
|
+
*
|
|
439
|
+
* It quotes the checks themselves — the command, the value looked for, the detail that repeats
|
|
440
|
+
* it — and a plan may well carry a token in one of them, so it leaves here with every
|
|
441
|
+
* secret-shaped string already redacted (see `redaction.ts`). It used to be redacted only where a
|
|
442
|
+
* caller remembered to, which for this message was nowhere. The operator's own patterns are applied
|
|
443
|
+
* on top when the caller passes them (`report.redactPatterns`; this module does not read the
|
|
444
|
+
* settings): left to the caller, they were applied to the file beside this message and not to it.
|
|
241
445
|
*/
|
|
242
|
-
export function failureMessage(outcomes, round, maxRounds) {
|
|
446
|
+
export function failureMessage(outcomes, round, maxRounds, extraPatterns = []) {
|
|
243
447
|
const failed = outcomes.filter((o) => !o.passed);
|
|
244
448
|
const lines = [
|
|
245
449
|
'## The task is not finished yet',
|
|
@@ -271,10 +475,23 @@ export function failureMessage(outcomes, round, maxRounds) {
|
|
|
271
475
|
lines.push(`Fix what is failing and continue. Send steps as usual; do not report the task as done again until you ` +
|
|
272
476
|
`have reason to believe these checks will pass. This is attempt ${round} of ${maxRounds}: after that the ` +
|
|
273
477
|
'task is closed as failed and the operator reads it.');
|
|
274
|
-
return lines.join('\n');
|
|
478
|
+
return redactSecrets(lines.join('\n'), extraPatterns);
|
|
479
|
+
}
|
|
480
|
+
/** The same, as the plain text file that travels with the message, redacted here for the same reason. */
|
|
481
|
+
export function failureReport(outcomes, extraPatterns = []) {
|
|
482
|
+
return redactSecrets(reportText(outcomes), extraPatterns);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* What a round's checks file and message had taken out, by name, for the event that says so: what
|
|
486
|
+
* each outcome's own words lost where they were made (see `runCheck`), and what the checks' words
|
|
487
|
+
* — the name, the command, the value, the file — lose in the file. The file quotes everything the
|
|
488
|
+
* message does, so it stands for both, and a secret the two of them carry is counted once.
|
|
489
|
+
*/
|
|
490
|
+
export function failureRedactions(outcomes, extraPatterns = []) {
|
|
491
|
+
return mergeRedactions([...outcomes.map((o) => o.redactions ?? []), findRedactions(reportText(outcomes), extraPatterns)]);
|
|
275
492
|
}
|
|
276
|
-
/** The
|
|
277
|
-
|
|
493
|
+
/** The checks file before it is redacted. What its outcomes say already is, where it was made. */
|
|
494
|
+
function reportText(outcomes) {
|
|
278
495
|
const parts = ['CHECKS THAT DID NOT PASS', '='.repeat(60), ''];
|
|
279
496
|
for (const o of outcomes.filter((x) => !x.passed)) {
|
|
280
497
|
parts.push(`CHECK : ${o.check.name}`);
|
|
@@ -97,10 +97,20 @@ export const DANGEROUS_TECHNIQUES = [
|
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
name: 'run-from-temp',
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
/*
|
|
101
|
+
* The system's Temp, however it is spelled: `%TEMP%` and `%TMP%`, `$env:TEMP`, `$env:TMP` and
|
|
102
|
+
* `${env:TEMP}`, Temp under `%LOCALAPPDATA%`, `%SystemRoot%`, `%windir%` or their `$env:` forms,
|
|
103
|
+
* any `AppData\Local\Temp`, `C:\Temp`, `C:\tmp` and `C:\Windows\Temp` on any drive, and a `/tmp/`
|
|
104
|
+
* that starts a path. Matching the one literal spelling `\AppData\Local\Temp` was matching one
|
|
105
|
+
* of several.
|
|
106
|
+
*
|
|
107
|
+
* Only those. A folder of the project's own called `temp` or `tmp` — `.\temp\seed.js`,
|
|
108
|
+
* `test/tmp/fixture.test.js` — is where a project keeps its seed scripts and fixtures, and
|
|
109
|
+
* refusing it refused honest work in a rule nobody can switch off (found on 2026-10-01, when any
|
|
110
|
+
* path segment named temp counted). The variable names end at a word boundary, so
|
|
111
|
+
* `$env:TEMPLATE_DIR` is not Temp either.
|
|
112
|
+
*/
|
|
113
|
+
pattern: /(%(?:TEMP|TMP)%|\$\{?env:(?:TEMP|TMP)\b\}?|(?:%(?:LOCALAPPDATA|SystemRoot|windir)%|\$\{?env:(?:LOCALAPPDATA|SystemRoot|windir)\b\}?)[\\\/]Temp[\\\/]|AppData[\\\/]Local[\\\/]Temp[\\\/]|\b[A-Za-z]:[\\\/](?:(?:Windows[\\\/])?Temp|tmp)[\\\/]|(?:^|[\s"'=(])\/tmp\/)[^\s"';|]*\.(exe|js|jse|vbs|vbe|ps1|bat|cmd|hta|wsf|scr|dll|msi)\b/i,
|
|
104
114
|
why: 'this runner never executes anything out of Temp, and it downloads nothing: the code a task runs is written into the project by a command step. A binary or script run from Temp is the shape of a dropper.',
|
|
105
115
|
},
|
|
106
116
|
{
|
|
@@ -122,8 +132,22 @@ export const DANGEROUS_TECHNIQUES = [
|
|
|
122
132
|
* asks the shell to "open" something. Asking is how a `.hta`, a `.js` or a `.lnk` ends up run by
|
|
123
133
|
* `mshta`, the script host or whatever a shortcut points at, under this runner as the parent,
|
|
124
134
|
* and how a page ends up in the signed-in browser with the bot's process behind it.
|
|
135
|
+
*
|
|
136
|
+
* `Invoke-Item` is matched wherever it is written, with or without an argument, since the name
|
|
137
|
+
* never turns up in prose. `ii` is an ordinary word — "phase ii", "Type II error", a list's
|
|
138
|
+
* "(ii)" — and matching it anywhere refused honest lines (found on 2026-10-01), so it is matched
|
|
139
|
+
* only where PowerShell runs it as a command: after a pipe, at the start of a statement or a
|
|
140
|
+
* block (`;`, `&`, `(`, `{`, a new line, an assignment) with something to open, or at the start
|
|
141
|
+
* of a nested `pwsh`/`powershell`'s command string, which is a command line of its own: after its
|
|
142
|
+
* `-Command` (`-c` and the other short forms, given on the line or in an `-ArgumentList`), or,
|
|
143
|
+
* with no `-File` before it, as its first word after its switches, which Windows PowerShell runs
|
|
144
|
+
* as a command. Within that string, the pipe, statement and block cases above find the rest.
|
|
145
|
+
*
|
|
146
|
+
* Not anywhere after the shell's name: matched so, it refused `Write-Host "Configuring
|
|
147
|
+
* PowerShell, part ii"`, where PowerShell is a word in a message, and `pwsh -File
|
|
148
|
+
* .\migrate.ps1 -Phase ii`, where the words after the script are its own parameters.
|
|
125
149
|
*/
|
|
126
|
-
pattern: /\b(
|
|
150
|
+
pattern: /\bInvoke-Item\b|\|\s*ii(?![\w.\-:\\\/])|(?:^|[;&({\n]|\$[\w:.{}\[\]]+\s*=)\s*[.&]?\s*ii(?:\s+[^\s;|&)}]|\()|\b(?:pwsh|powershell)(?:\.exe)?\b(?:(?:(?![\s'"]-f)[^|;\n])*?[\s'"]-(?:c|co|com|comm|comma|comman|command)\b['"]?(?:\s*,\s*|\s+)|(?:\s+-(?!f)\w+(?::\S+)?(?:\s+(?![-'"])[\w.]+)?)*\s+)["']?ii(?![\w.\-:\\\/])|\b(Start-Process|saps|start)\s+(?:-\w+\s+)*(?:-FilePath\s+)?["']?(?:[^\s"']+\.(hta|vbs|vbe|js|jse|wsf|wsh|lnk|url|scr|cpl|msc|reg|inf|msi|msp|chm|pif)\b|https?:\/\/|ms-\w+:|file:\/\/)/i,
|
|
127
151
|
why: 'a file opened through its registered handler, or a URL opened in the browser, runs whatever Windows has registered for it under this runner. Start the program itself, by name, with the file as its argument.',
|
|
128
152
|
},
|
|
129
153
|
{
|