copilot-operator 0.1.22 → 0.1.25
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/dist/src/api/operator.controller.js +2 -2
- package/dist/src/api/operator.service.js +330 -35
- package/dist/src/exec/confinement.js +85 -7
- package/dist/src/exec/programs.js +12 -3
- package/dist/src/exec/reportFile.js +5 -3
- package/dist/src/exec/shells.js +13 -2
- package/dist/src/log/runLog.js +6 -1
- package/dist/src/orchestrator/contract.js +29 -7
- package/dist/src/orchestrator/review.js +16 -3
- package/dist/src/orchestrator/taskRunner.js +391 -54
- package/dist/src/plan/schema.js +11 -1
- package/dist/src/plan/systemGuide.js +36 -4
- package/dist/src/protocol/parser.js +33 -5
- package/dist/src/protocol/reporter.js +2 -0
- package/dist/src/session/compose.js +18 -9
- package/dist/src/session/exportRecord.js +12 -3
- package/dist/src/session/exports.js +46 -9
- package/dist/src/session/handoff.js +5 -2
- package/dist/src/session/interruption.js +3 -1
- package/dist/src/session/runLog.js +42 -1
- package/dist/src/session/runName.js +3 -1
- package/dist/src/session/store.js +7 -2
- package/dist/src/transport/copilotTransport.js +89 -62
- package/dist/src/transport/modelMatch.js +37 -0
- package/dist/src/vcs/artifacts.js +20 -7
- package/dist/src/vcs/commitHygiene.js +7 -2
- package/dist/src/vcs/contentIntegrity.js +65 -6
- package/dist/src/vcs/git.js +44 -7
- package/dist/src/vcs/prepareFromRemote.js +46 -9
- package/dist/src/vcs/remoteAuth.js +92 -0
- package/dist/src/vcs/runPreflight.js +100 -31
- package/dist/src/vcs/scope.js +18 -2
- package/dist/src/vcs/snapshot.js +61 -16
- package/dist/src/vcs/syncCommand.js +3 -11
- package/dist/src/vcs/taskVcs.js +173 -24
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-7e35e4fdefadb05d.js +1 -0
- package/dist/web/_next/static/chunks/867-52e371cb1ec49089.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/{page-cdc730adea753446.js → page-62c8fbe84baa436a.js} +1 -1
- package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/{page-185755ada66e2930.js → page-2d3bbc68b27fed2c.js} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +10 -10
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +10 -10
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +10 -10
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +10 -10
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +10 -10
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +10 -10
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +10 -10
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +10 -10
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +10 -10
- package/package.json +3 -2
- package/prompts/level1.md +10 -3
- package/dist/web/_next/static/chunks/549-5f90780046c1c24f.js +0 -1
- package/dist/web/_next/static/chunks/867-79f07bebe3084811.js +0 -1
- package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +0 -1
- /package/dist/web/_next/static/{9Ze8aagkvT-WLfD4KCKKS → beP1HgHJZvBVRTsoU1sZv}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{9Ze8aagkvT-WLfD4KCKKS → beP1HgHJZvBVRTsoU1sZv}/_ssgManifest.js +0 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* 2. Enter does not submit the composer. The Send button has to be clicked.
|
|
11
11
|
* 3. A message cannot consist of an attachment alone; Send stays disabled without text.
|
|
12
12
|
*/
|
|
13
|
+
import { pageModelFor } from './modelMatch.js';
|
|
13
14
|
import { chromium } from 'playwright';
|
|
14
15
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
15
16
|
import { join } from 'node:path';
|
|
@@ -76,65 +77,65 @@ export class SendRejectedError extends Error {
|
|
|
76
77
|
* impossible — the runner notes it before the click and waits for it to move, so a copy that
|
|
77
78
|
* did not happen reads as a failure rather than as the previous answer.
|
|
78
79
|
*/
|
|
79
|
-
export const CLIPBOARD_GUARD = `(() => {
|
|
80
|
-
const state = { text: '', seq: 0 };
|
|
81
|
-
try {
|
|
82
|
-
Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
|
|
83
|
-
} catch (e) {
|
|
84
|
-
window.__copClipboard = state;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const remember = (text) => {
|
|
88
|
-
if (typeof text !== 'string' || text.length === 0) return;
|
|
89
|
-
state.text = text;
|
|
90
|
-
state.seq += 1;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const clip = navigator.clipboard;
|
|
94
|
-
if (clip) {
|
|
95
|
-
try {
|
|
96
|
-
Object.defineProperty(clip, 'writeText', {
|
|
97
|
-
configurable: true,
|
|
98
|
-
writable: true,
|
|
99
|
-
value: (text) => {
|
|
100
|
-
remember(String(text));
|
|
101
|
-
return Promise.resolve();
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
} catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
Object.defineProperty(clip, 'write', {
|
|
108
|
-
configurable: true,
|
|
109
|
-
writable: true,
|
|
110
|
-
value: async (items) => {
|
|
111
|
-
for (const item of items || []) {
|
|
112
|
-
try {
|
|
113
|
-
if (item && item.types && item.types.indexOf('text/plain') >= 0) {
|
|
114
|
-
remember(await (await item.getType('text/plain')).text());
|
|
115
|
-
}
|
|
116
|
-
} catch (e) { /* one unreadable item is not a reason to drop the rest */ }
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
} catch (e) { /* as above */ }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// The old way: select text in a hidden field, then execCommand('copy'). The selection is
|
|
124
|
-
// exactly the text being copied, so it is taken and the command is never run.
|
|
125
|
-
try {
|
|
126
|
-
const realExec = document.execCommand.bind(document);
|
|
127
|
-
document.execCommand = (command, ...rest) => {
|
|
128
|
-
if (String(command).toLowerCase() === 'copy') {
|
|
129
|
-
const selected = String(window.getSelection() || '');
|
|
130
|
-
if (selected.length > 0) {
|
|
131
|
-
remember(selected);
|
|
132
|
-
return true;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return realExec(command, ...rest);
|
|
136
|
-
};
|
|
137
|
-
} catch (e) { /* as above */ }
|
|
80
|
+
export const CLIPBOARD_GUARD = `(() => {
|
|
81
|
+
const state = { text: '', seq: 0 };
|
|
82
|
+
try {
|
|
83
|
+
Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
|
|
84
|
+
} catch (e) {
|
|
85
|
+
window.__copClipboard = state;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const remember = (text) => {
|
|
89
|
+
if (typeof text !== 'string' || text.length === 0) return;
|
|
90
|
+
state.text = text;
|
|
91
|
+
state.seq += 1;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const clip = navigator.clipboard;
|
|
95
|
+
if (clip) {
|
|
96
|
+
try {
|
|
97
|
+
Object.defineProperty(clip, 'writeText', {
|
|
98
|
+
configurable: true,
|
|
99
|
+
writable: true,
|
|
100
|
+
value: (text) => {
|
|
101
|
+
remember(String(text));
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
} catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
Object.defineProperty(clip, 'write', {
|
|
109
|
+
configurable: true,
|
|
110
|
+
writable: true,
|
|
111
|
+
value: async (items) => {
|
|
112
|
+
for (const item of items || []) {
|
|
113
|
+
try {
|
|
114
|
+
if (item && item.types && item.types.indexOf('text/plain') >= 0) {
|
|
115
|
+
remember(await (await item.getType('text/plain')).text());
|
|
116
|
+
}
|
|
117
|
+
} catch (e) { /* one unreadable item is not a reason to drop the rest */ }
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
} catch (e) { /* as above */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The old way: select text in a hidden field, then execCommand('copy'). The selection is
|
|
125
|
+
// exactly the text being copied, so it is taken and the command is never run.
|
|
126
|
+
try {
|
|
127
|
+
const realExec = document.execCommand.bind(document);
|
|
128
|
+
document.execCommand = (command, ...rest) => {
|
|
129
|
+
if (String(command).toLowerCase() === 'copy') {
|
|
130
|
+
const selected = String(window.getSelection() || '');
|
|
131
|
+
if (selected.length > 0) {
|
|
132
|
+
remember(selected);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return realExec(command, ...rest);
|
|
137
|
+
};
|
|
138
|
+
} catch (e) { /* as above */ }
|
|
138
139
|
})();`;
|
|
139
140
|
export class CopilotTransport {
|
|
140
141
|
opts;
|
|
@@ -290,7 +291,7 @@ export class CopilotTransport {
|
|
|
290
291
|
'sign-in': 'The profile is signed out. Run "cop login".',
|
|
291
292
|
unknown: 'The page is not the Microsoft 365 Copilot chat.',
|
|
292
293
|
};
|
|
293
|
-
throw new Error(`${explain[where] ?? explain.unknown}
|
|
294
|
+
throw new Error(`${explain[where] ?? explain.unknown}
|
|
294
295
|
Current URL: ${url}`);
|
|
295
296
|
}
|
|
296
297
|
/**
|
|
@@ -446,8 +447,15 @@ Current URL: ${url}`);
|
|
|
446
447
|
*/
|
|
447
448
|
async nameChat(chatId, name) {
|
|
448
449
|
const row = this.p.locator(`${Sidebar.conversationLink}[href*="${chatId}"]`).first();
|
|
449
|
-
|
|
450
|
+
// Waited for, like everything below: a new chat's row appears in the sidebar a moment after the
|
|
451
|
+
// reply, and `count()` read it as missing and returned without a word (live run 2026-10-03).
|
|
452
|
+
try {
|
|
453
|
+
await row.waitFor({ state: 'attached', timeout: 15_000 });
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
this.emit('chat-name-failed', { chatId, name, error: 'the conversation did not appear in the sidebar within 15 s' });
|
|
450
457
|
return false;
|
|
458
|
+
}
|
|
451
459
|
/*
|
|
452
460
|
* Every step here waits for what it is about to click.
|
|
453
461
|
*
|
|
@@ -868,7 +876,26 @@ Current URL: ${url}`);
|
|
|
868
876
|
}
|
|
869
877
|
if (!target) {
|
|
870
878
|
await this.closeMenu();
|
|
871
|
-
|
|
879
|
+
/*
|
|
880
|
+
* Not under that name: the line-up is read from the page, and the same model under its new name is
|
|
881
|
+
* chosen when exactly one fits (see `modelMatch.ts`). The list read goes back to the caller as well,
|
|
882
|
+
* so the saved catalogue follows the page (live run 2026-10-04: "GPT 5.6 Think deeper" had become
|
|
883
|
+
* "GPT 5.6 Sol Think deeper", and every run went on the default).
|
|
884
|
+
*/
|
|
885
|
+
const read = await this.listModels().catch(() => null);
|
|
886
|
+
const options = read?.options ?? [];
|
|
887
|
+
const match = pageModelFor(name, options);
|
|
888
|
+
if (match && match.name.toLowerCase() !== name.toLowerCase()) {
|
|
889
|
+
const again = await this.selectModel(match.name);
|
|
890
|
+
return { ...again, matched: match.name, options };
|
|
891
|
+
}
|
|
892
|
+
const offered = options.filter((o) => !o.disabled).map((o) => o.name);
|
|
893
|
+
return {
|
|
894
|
+
ok: false,
|
|
895
|
+
current: before,
|
|
896
|
+
reason: `The picker does not offer "${name}" any more${offered.length > 0 ? `; it offers ${offered.map((o) => `"${o}"`).join(', ')}` : ''}.`,
|
|
897
|
+
...(options.length > 0 ? { options } : {}),
|
|
898
|
+
};
|
|
872
899
|
}
|
|
873
900
|
/*
|
|
874
901
|
* Already the one in force: close the menu and say so, without clicking.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const words = (name) => name.toLowerCase().split(/\s+/).filter(Boolean);
|
|
2
|
+
const isVersion = (w) => /^v?\d+(?:\.\d+)*$/.test(w);
|
|
3
|
+
const versionOf = (ws) => (ws.find(isVersion) ?? '').replace(/^v/, '').split('.').map(Number).filter((n) => !Number.isNaN(n));
|
|
4
|
+
function newer(a, b) {
|
|
5
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
6
|
+
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
7
|
+
if (d !== 0)
|
|
8
|
+
return d;
|
|
9
|
+
}
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
export function pageModelFor(wanted, options) {
|
|
13
|
+
const offered = options.filter((o) => !o.disabled && o.name.trim());
|
|
14
|
+
const exact = offered.find((o) => o.name.trim().toLowerCase() === wanted.trim().toLowerCase());
|
|
15
|
+
if (exact)
|
|
16
|
+
return exact;
|
|
17
|
+
const want = words(wanted);
|
|
18
|
+
if (want.length === 0)
|
|
19
|
+
return null;
|
|
20
|
+
/** The options whose words hold all of `need`, same first word, fewest words added; null when two tie. */
|
|
21
|
+
const closest = (need, strip) => {
|
|
22
|
+
const fits = offered
|
|
23
|
+
.map((o) => ({ o, ws: strip(words(o.name)) }))
|
|
24
|
+
.filter(({ ws }) => ws[0] === need[0] && need.every((w) => ws.includes(w)))
|
|
25
|
+
.map(({ o, ws }) => ({ o, extra: ws.length - need.length }));
|
|
26
|
+
if (fits.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
const least = Math.min(...fits.map((f) => f.extra));
|
|
29
|
+
const best = fits.filter((f) => f.extra === least);
|
|
30
|
+
if (best.length === 1)
|
|
31
|
+
return best[0].o;
|
|
32
|
+
// The same name in several versions: the newest, which is what the line-up moved on to.
|
|
33
|
+
const byVersion = [...best].sort((a, b) => newer(versionOf(words(b.o.name)), versionOf(words(a.o.name))));
|
|
34
|
+
return newer(versionOf(words(byVersion[0].o.name)), versionOf(words(byVersion[1].o.name))) > 0 ? byVersion[0].o : null;
|
|
35
|
+
};
|
|
36
|
+
return closest(want, (ws) => ws) ?? closest(want.filter((w) => !isVersion(w)), (ws) => ws.filter((w) => !isVersion(w)));
|
|
37
|
+
}
|
|
@@ -46,13 +46,19 @@ function excludeLine(pattern) {
|
|
|
46
46
|
return `/${pattern.replace(/^\/+/, '')}`;
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
49
|
+
* The runner's scratch folder in the project: helper scripts and temporary copies the chat needs while
|
|
50
|
+
* it works, never committed. Found live on 2026-10-03: the write-then-run pattern the contract teaches
|
|
51
|
+
* left `rewrite-report.ps1` and `tmp-report.txt` in the project root, and the task's commit swept them
|
|
52
|
+
* in with the work. The contract now sends such files here, and the folder is excluded from git.
|
|
53
|
+
*/
|
|
54
|
+
export const SCRATCH_DIR = '.cop-tmp';
|
|
55
|
+
/**
|
|
56
|
+
* Writes the session's artifact patterns, and the runner's scratch folder, into `.git/info/exclude`,
|
|
57
|
+
* once each. Returns the files matching the patterns that git already tracks, which an exclude does
|
|
58
|
+
* not stop from being committed.
|
|
51
59
|
*/
|
|
52
60
|
export async function excludeArtifacts(dir, session) {
|
|
53
61
|
const patterns = artifactPatterns(session.vcs);
|
|
54
|
-
if (patterns.length === 0)
|
|
55
|
-
return { tracked: [] };
|
|
56
62
|
const where = await git(dir, ['rev-parse', '--git-path', 'info/exclude']);
|
|
57
63
|
if (!where.ok || !where.stdout)
|
|
58
64
|
return { problem: "the repository's exclude file could not be found", tracked: [] };
|
|
@@ -61,16 +67,23 @@ export async function excludeArtifacts(dir, session) {
|
|
|
61
67
|
const before = await readFile(file, 'utf8').catch(() => '');
|
|
62
68
|
const have = new Set(before.split(/\r?\n/).map((l) => l.trim()));
|
|
63
69
|
const add = patterns.map(excludeLine).filter((l) => !have.has(l));
|
|
64
|
-
|
|
70
|
+
const scratch = `/${SCRATCH_DIR}/`;
|
|
71
|
+
const blocks = [];
|
|
72
|
+
if (!have.has(scratch))
|
|
73
|
+
blocks.push(`# copilot-operator: the runner's scratch folder — helper scripts and temporary files, never committed`, scratch);
|
|
74
|
+
if (add.length > 0)
|
|
75
|
+
blocks.push(`# copilot-operator: artifacts of session "${session.name.replace(/[\r\n]+/g, ' ')}" (${session.id}) — kept with the run, never committed`, ...add);
|
|
76
|
+
if (blocks.length > 0) {
|
|
65
77
|
try {
|
|
66
78
|
await mkdir(dirname(file), { recursive: true });
|
|
67
|
-
|
|
68
|
-
await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${block}`, 'utf8');
|
|
79
|
+
await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${[...blocks, ''].join('\n')}`, 'utf8');
|
|
69
80
|
}
|
|
70
81
|
catch (e) {
|
|
71
82
|
return { problem: `the exclude file could not be written: ${e.message}`, tracked: [] };
|
|
72
83
|
}
|
|
73
84
|
}
|
|
85
|
+
if (patterns.length === 0)
|
|
86
|
+
return { tracked: [] };
|
|
74
87
|
const tracked = (await git(resolve(top), ['ls-files'])).stdout.split('\n').filter((p) => p && inScope(p, patterns));
|
|
75
88
|
return { tracked };
|
|
76
89
|
}
|
|
@@ -35,6 +35,8 @@ const GENERATED_DIRS = {
|
|
|
35
35
|
'.mypy_cache': 'a type-checker cache',
|
|
36
36
|
'.venv': 'a Python virtual environment',
|
|
37
37
|
venv: 'a Python virtual environment',
|
|
38
|
+
tmp: 'a scratch folder (temporary files go in .cop-tmp, which is never committed)',
|
|
39
|
+
temp: 'a scratch folder (temporary files go in .cop-tmp, which is never committed)',
|
|
38
40
|
};
|
|
39
41
|
/** File names, or endings, that a tool writes and nobody reads. */
|
|
40
42
|
const GENERATED_FILES = [
|
|
@@ -42,6 +44,8 @@ const GENERATED_FILES = [
|
|
|
42
44
|
[(n) => n.endsWith('.log'), 'a log file'],
|
|
43
45
|
[(n) => n === '.DS_Store' || n === 'Thumbs.db' || n === 'desktop.ini', "the operating system's folder metadata"],
|
|
44
46
|
[(n) => n.endsWith('.pyc'), 'compiled Python'],
|
|
47
|
+
// The chat's own scratch copies, seen committed in a live run: tmp-report.txt, a .bak of a file it edited.
|
|
48
|
+
[(n) => /^(?:tmp|temp|scratch)(?:[-_.]|$)/i.test(n) || /\.(?:tmp|bak|orig|swp)$/i.test(n), 'a temporary copy (temporary files go in .cop-tmp, which is never committed)'],
|
|
45
49
|
];
|
|
46
50
|
/** `.env` and its variants, except the ones that exist to be committed. */
|
|
47
51
|
function isSecretsFile(name) {
|
|
@@ -96,7 +100,8 @@ export function findSuspicious(paths) {
|
|
|
96
100
|
export function suspiciousDetail(found) {
|
|
97
101
|
const list = found.map((s) => `${s.path} (${s.reason})`).join('; ');
|
|
98
102
|
return (`${found.length} file(s) that look like tool output or secrets are in the working tree and would be committed ` +
|
|
99
|
-
`with this task: ${list}. Add
|
|
100
|
-
'
|
|
103
|
+
`with this task: ${list}. Add what is installed, built or generated to .gitignore, and take it out of the index if git ` +
|
|
104
|
+
'already tracks it; delete a temporary copy or scratch folder, or move it into .cop-tmp, which is never committed. ' +
|
|
105
|
+
'If one of them truly belongs in the repository, leave ' +
|
|
101
106
|
'it and say why in your summary: it will be committed and marked.');
|
|
102
107
|
}
|
|
@@ -22,6 +22,11 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
22
22
|
import { join } from 'node:path';
|
|
23
23
|
import { gitBytes } from './git.js';
|
|
24
24
|
import { looksGenerated } from './commitHygiene.js';
|
|
25
|
+
const endingsOf = (bytes) => {
|
|
26
|
+
const text = bytes.toString('latin1');
|
|
27
|
+
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
28
|
+
return { crlf, lf: (text.match(/\n/g) ?? []).length - crlf };
|
|
29
|
+
};
|
|
25
30
|
/** Written files larger than this are almost always generated output. */
|
|
26
31
|
export const OVERSIZED_BYTES = 1024 * 1024;
|
|
27
32
|
/** Files that are binary by nature: their bytes are not text and are not looked into. */
|
|
@@ -98,8 +103,13 @@ export function traitsOf(path, bytes) {
|
|
|
98
103
|
t['control-chars'] = `${control} control character(s)${text.includes('\u001B[') ? ', including terminal colour codes' : ''}`;
|
|
99
104
|
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
100
105
|
const lf = (text.match(/(?<!\r)\n/g) ?? []).length;
|
|
101
|
-
if (crlf > 0 && lf > 0)
|
|
102
|
-
|
|
106
|
+
if (crlf > 0 && lf > 0) {
|
|
107
|
+
// The usual cause, said where it is the case: a here-string written with Set-Content/Out-File, which add a CRLF of their own at the end (live run 2026-10-04).
|
|
108
|
+
const lastOnly = crlf === 1 && text.endsWith('\r\n');
|
|
109
|
+
t['mixed-line-endings'] =
|
|
110
|
+
`${crlf} CRLF and ${lf} LF line ending(s) in one file` +
|
|
111
|
+
(lastOnly ? ' — only the last line ends in CRLF, as Set-Content and Out-File add one: write with -NoNewline and end the text with its own newline' : '');
|
|
112
|
+
}
|
|
103
113
|
for (const [re, what] of SECRETS) {
|
|
104
114
|
if (re.test(text)) {
|
|
105
115
|
t.secret = `what looks like ${what}`;
|
|
@@ -114,7 +124,7 @@ export function traitsOf(path, bytes) {
|
|
|
114
124
|
* A BOM in a new file counts only for formats it breaks; in a changed file, whenever the file had
|
|
115
125
|
* none. Size counts only when the file crossed the line in this task.
|
|
116
126
|
*/
|
|
117
|
-
export function newProblems(path, now, before) {
|
|
127
|
+
export function newProblems(path, now, before, endings = { normalized: false }) {
|
|
118
128
|
const ext = extensionOf(path);
|
|
119
129
|
if (BINARY_EXTENSIONS.has(ext))
|
|
120
130
|
return [];
|
|
@@ -126,13 +136,61 @@ export function newProblems(path, now, before) {
|
|
|
126
136
|
continue;
|
|
127
137
|
if (kind === 'bom' && !before && !BOM_BREAKS.has(ext))
|
|
128
138
|
continue;
|
|
139
|
+
if (kind === 'mixed-line-endings' && endings.normalized)
|
|
140
|
+
continue;
|
|
129
141
|
out.push({ path, kind, detail });
|
|
130
142
|
}
|
|
143
|
+
/*
|
|
144
|
+
* A whole file turned from one ending to the other is a change on every line, and a mixed-endings
|
|
145
|
+
* test cannot see it: each version is consistent on its own. Found live on 2026-10-03, a task told to
|
|
146
|
+
* keep a JSON file's formatting rewrote it from LF to CRLF and it was committed that way. Not where git
|
|
147
|
+
* converts endings on commit.
|
|
148
|
+
*/
|
|
149
|
+
if (!endings.normalized && !has['mixed-line-endings']) {
|
|
150
|
+
const n = endingsOf(now);
|
|
151
|
+
const only = n.crlf > 0 && n.lf === 0 ? 'crlf' : n.lf > 0 && n.crlf === 0 ? 'lf' : null;
|
|
152
|
+
if (only && before) {
|
|
153
|
+
const b = endingsOf(before);
|
|
154
|
+
const was = b.crlf > 0 && b.lf === 0 ? 'crlf' : b.lf > 0 && b.crlf === 0 ? 'lf' : null;
|
|
155
|
+
if (was && was !== only)
|
|
156
|
+
out.push({ path, kind: 'line-endings-changed', detail: `every line ending changed from ${was.toUpperCase()} to ${only.toUpperCase()}` });
|
|
157
|
+
}
|
|
158
|
+
/*
|
|
159
|
+
* A new file is not held to the repository's style: every file PowerShell's Set-Content writes ends
|
|
160
|
+
* its lines in CRLF, so on a repository without line-ending conversion that cost nearly every task a
|
|
161
|
+
* round. A file changed from one ending to the other is the change that misleads a diff.
|
|
162
|
+
*/
|
|
163
|
+
}
|
|
131
164
|
return out;
|
|
132
165
|
}
|
|
166
|
+
/** Whether git converts line endings in this repository on commit, and the style its files have. */
|
|
167
|
+
export async function lineEndingRule(dir) {
|
|
168
|
+
const auto = (await gitBytes(dir, ['config', '--get', 'core.autocrlf'])).stdout.toString('utf8').trim().toLowerCase();
|
|
169
|
+
if (auto === 'true' || auto === 'input')
|
|
170
|
+
return { normalized: true };
|
|
171
|
+
const eol = await gitBytes(dir, ['ls-files', '--eol']);
|
|
172
|
+
let lf = 0;
|
|
173
|
+
let crlf = 0;
|
|
174
|
+
for (const line of eol.stdout.toString('utf8').split('\n')) {
|
|
175
|
+
// i/lf w/lf attr/text=auto path
|
|
176
|
+
const m = /^i\/(\S*)\s+w\/\S*\s+attr\/(\S*)/.exec(line);
|
|
177
|
+
if (!m)
|
|
178
|
+
continue;
|
|
179
|
+
if (/(^|\s)(text|eol=)/.test(m[2] ?? '') && !/-text/.test(m[2] ?? ''))
|
|
180
|
+
return { normalized: true };
|
|
181
|
+
if (m[1] === 'lf')
|
|
182
|
+
lf += 1;
|
|
183
|
+
else if (m[1] === 'crlf')
|
|
184
|
+
crlf += 1;
|
|
185
|
+
}
|
|
186
|
+
const total = lf + crlf;
|
|
187
|
+
const style = total >= 3 && lf / total >= 0.8 ? 'lf' : total >= 3 && crlf / total >= 0.8 ? 'crlf' : undefined;
|
|
188
|
+
return { normalized: false, ...(style ? { style } : {}) };
|
|
189
|
+
}
|
|
133
190
|
/** The problems the task added to these working-tree paths, compared with `baseCommit`. */
|
|
134
191
|
export async function scanChanges(dir, paths, baseCommit, limit = 400) {
|
|
135
192
|
const found = [];
|
|
193
|
+
const endings = await lineEndingRule(dir);
|
|
136
194
|
for (const rel of paths.slice(0, limit)) {
|
|
137
195
|
if (rel.endsWith('/') || looksGenerated(rel))
|
|
138
196
|
continue;
|
|
@@ -159,7 +217,7 @@ export async function scanChanges(dir, paths, baseCommit, limit = 400) {
|
|
|
159
217
|
const shown = await gitBytes(dir, ['show', `${baseCommit}:${rel.replace(/\\/g, '/')}`]);
|
|
160
218
|
before = shown.ok ? shown.stdout : null;
|
|
161
219
|
}
|
|
162
|
-
found.push(...newProblems(rel, now, before));
|
|
220
|
+
found.push(...newProblems(rel, now, before, endings));
|
|
163
221
|
}
|
|
164
222
|
return found;
|
|
165
223
|
}
|
|
@@ -168,7 +226,8 @@ export function integrityDetail(found) {
|
|
|
168
226
|
const list = found.slice(0, 20).map((f) => `${f.path}: ${f.detail}`).join('; ');
|
|
169
227
|
return (`${found.length} problem(s) in the text of files this task changed, none of them there before it: ${list}` +
|
|
170
228
|
`${found.length > 20 ? `; and ${found.length - 20} more` : ''}. Write the files again as UTF-8 without a byte-order mark ` +
|
|
171
|
-
'(in PowerShell 5.1
|
|
172
|
-
'
|
|
229
|
+
'(in PowerShell 5.1: $f = [IO.File]; $f::WriteAllText($path, $text, (New-Object Text.UTF8Encoding($false))) — not Set-Content -Encoding utf8), ' +
|
|
230
|
+
'the line endings the file and the repository already use (Set-Content adds a Windows line ending after the last line: use -NoNewline and end the here-string with an empty line), ' +
|
|
231
|
+
'no terminal colour codes, and no credentials — load those from the environment. ' +
|
|
173
232
|
'If one of these is intended, leave it and say why in your summary: it will be committed and the finding kept on the task.');
|
|
174
233
|
}
|
package/dist/src/vcs/git.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* commit message cannot turn into another command.
|
|
17
17
|
*/
|
|
18
18
|
import { execFile } from 'node:child_process';
|
|
19
|
-
import { existsSync } from 'node:fs';
|
|
19
|
+
import { existsSync, statSync } from 'node:fs';
|
|
20
20
|
import { readFile, stat } from 'node:fs/promises';
|
|
21
21
|
import { createHash } from 'node:crypto';
|
|
22
22
|
import { join } from 'node:path';
|
|
@@ -44,19 +44,56 @@ function noHooksDir() {
|
|
|
44
44
|
return process.env.SystemRoot ? join(process.env.SystemRoot, 'System32', 'copilot-operator-no-hooks') : '/nonexistent';
|
|
45
45
|
}
|
|
46
46
|
/** Runs one git command in a directory. Never throws: the caller decides what a failure means. */
|
|
47
|
-
export function git(cwd, args, timeoutMs = 60_000, env
|
|
47
|
+
export function git(cwd, args, timeoutMs = 60_000, env,
|
|
48
|
+
/**
|
|
49
|
+
* `windowsHide: false` only for a fetch the operator is sitting at, whose password window must be seen.
|
|
50
|
+
* `killTree`: on the timeout, stop git and everything it started. The plain timeout stops git.exe
|
|
51
|
+
* alone, and a fetch's ssh and its password window outlived it (found 2026-10-03: the window stayed
|
|
52
|
+
* on screen after the fetch had been given up).
|
|
53
|
+
*/
|
|
54
|
+
opts = {}) {
|
|
48
55
|
return new Promise((resolve) => {
|
|
49
|
-
|
|
56
|
+
let timedOut = false;
|
|
57
|
+
let timer;
|
|
58
|
+
const child = execFile('git', [...SAFE_GIT, ...args], { cwd, timeout: opts.killTree ? 0 : timeoutMs, windowsHide: opts.windowsHide ?? true, maxBuffer: 8 * 1024 * 1024, ...(env ? { env } : {}) }, (error, stdout, stderr) => {
|
|
59
|
+
if (timer)
|
|
60
|
+
clearTimeout(timer);
|
|
50
61
|
const code = error?.code;
|
|
51
62
|
resolve({
|
|
52
|
-
ok: !error,
|
|
63
|
+
ok: !error && !timedOut,
|
|
53
64
|
stdout: (stdout ?? '').trim(),
|
|
54
|
-
stderr: (stderr ?? '').trim(),
|
|
55
|
-
code: typeof code === 'number' ? code : error ? 1 : 0,
|
|
65
|
+
stderr: `${(stderr ?? '').trim()}${timedOut ? `${stderr ? '\n' : ''}git gave up after ${Math.round(timeoutMs / 1000)} s` : ''}`.trim(),
|
|
66
|
+
code: typeof code === 'number' ? code : error || timedOut ? 1 : 0,
|
|
56
67
|
});
|
|
57
68
|
});
|
|
69
|
+
if (opts.killTree && child.pid) {
|
|
70
|
+
const pid = child.pid;
|
|
71
|
+
timer = setTimeout(() => {
|
|
72
|
+
timedOut = true;
|
|
73
|
+
if (process.platform === 'win32')
|
|
74
|
+
execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }, () => undefined);
|
|
75
|
+
else
|
|
76
|
+
child.kill('SIGKILL');
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
}
|
|
58
79
|
});
|
|
59
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* When this repository last fetched successfully, or undefined when that cannot be told.
|
|
83
|
+
*
|
|
84
|
+
* Read from FETCH_HEAD, but only when it holds something: git empties it at the start of every fetch,
|
|
85
|
+
* so a fetch that failed — a passphrase not typed, a network down — leaves an empty file with a fresh
|
|
86
|
+
* time, and that time read as "last fetched a minute ago" (seen live on 2026-10-03).
|
|
87
|
+
*/
|
|
88
|
+
export function lastFetchedAt(gitDir) {
|
|
89
|
+
try {
|
|
90
|
+
const s = statSync(join(gitDir, 'FETCH_HEAD'));
|
|
91
|
+
return s.size > 0 ? s.mtime.toISOString() : undefined;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
60
97
|
/** Everything the runner needs to know about a repository before it touches it. */
|
|
61
98
|
export async function repoState(dir) {
|
|
62
99
|
const empty = { isRepo: false, branch: null, head: null, dirty: false, changed: [] };
|
|
@@ -241,7 +278,7 @@ export async function updateFromRemote(dir, branch) {
|
|
|
241
278
|
const current = await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
242
279
|
const moved = current.ok && current.stdout === branch
|
|
243
280
|
? await git(dir, ['merge', '--ff-only', '--quiet', to])
|
|
244
|
-
: await git(dir, ['update-ref', `refs/heads/${branch}`, to, from]);
|
|
281
|
+
: await git(dir, ['update-ref', '-m', `copilot-operator: brought up to date with ${tracking}`, `refs/heads/${branch}`, to, from]);
|
|
245
282
|
if (!moved.ok)
|
|
246
283
|
return { branch, remote: tracking, outcome: 'failed', from, to, detail: (moved.stderr || moved.stdout).split(/\r?\n/)[0] };
|
|
247
284
|
return { branch, remote: tracking, outcome: 'updated', from, to };
|
|
@@ -29,8 +29,8 @@ import { rm } from 'node:fs/promises';
|
|
|
29
29
|
import { tmpdir } from 'node:os';
|
|
30
30
|
import { join, resolve } from 'node:path';
|
|
31
31
|
import { createHash } from 'node:crypto';
|
|
32
|
-
import { branchNameFrom, freeBranchName, git, porcelainPaths, RUNNER_EMAIL } from './git.js';
|
|
33
|
-
|
|
32
|
+
import { branchNameFrom, freeBranchName, git, lastFetchedAt, porcelainPaths, RUNNER_EMAIL } from './git.js';
|
|
33
|
+
import { closePasswordWindows, describeFetchFailure, fetchCommandFor, operatorFetchEnv, QUIET_FETCH_ENV } from './remoteAuth.js';
|
|
34
34
|
const short = (c) => (c ?? '').slice(0, 8);
|
|
35
35
|
const lines = (s) => s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
36
36
|
function stamp(now = new Date()) {
|
|
@@ -62,7 +62,9 @@ const empty = (repoDir, problem) => ({
|
|
|
62
62
|
});
|
|
63
63
|
/**
|
|
64
64
|
* What preparing the folder would do. Fetches the remote when `fetch` is true (the preview); reads
|
|
65
|
-
* only when it is false (the preparation, which must compare against what the preview fetched
|
|
65
|
+
* only when it is false (the preparation, which must compare against what the preview fetched, or a
|
|
66
|
+
* preview after the operator fetched in their own terminal). With `askpass`, the fetch may ask for a
|
|
67
|
+
* password through that program (see `remoteAuth.ts`); without it, it may not ask at all.
|
|
66
68
|
*/
|
|
67
69
|
export async function planPrepare(folder, opts) {
|
|
68
70
|
const inside = await git(folder, ['rev-parse', '--is-inside-work-tree']);
|
|
@@ -84,13 +86,30 @@ export async function planPrepare(folder, opts) {
|
|
|
84
86
|
if (remotes.length === 0)
|
|
85
87
|
return empty(repoDir, 'The repository has no remote, so there is no remote main branch to prepare it from.');
|
|
86
88
|
const remote = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
89
|
+
const fetchCommand = fetchCommandFor(repoDir, remote);
|
|
90
|
+
// Read before fetching: a fetch that fails empties FETCH_HEAD, and with it the time of the last one that worked.
|
|
91
|
+
const lastBefore = gitDir ? lastFetchedAt(gitDir) : undefined;
|
|
87
92
|
let fetched;
|
|
88
93
|
if (opts.fetch) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
/*
|
|
95
|
+
* With the password window: five minutes for the operator to type, and the window not hidden. One
|
|
96
|
+
* connection only — `remote set-head --auto` would open a second and ask for the password again —
|
|
97
|
+
* so the remote's main is read from what was fetched, below.
|
|
98
|
+
*/
|
|
99
|
+
const asked = !!opts.askpass;
|
|
100
|
+
const env = asked ? operatorFetchEnv(opts.askpass) : QUIET_FETCH_ENV;
|
|
101
|
+
const args = [...(asked ? [] : ['-c', 'credential.interactive=false']), 'fetch', '--quiet', '--no-tags', '--prune', remote];
|
|
102
|
+
const started = new Date(Date.now() - 1000);
|
|
103
|
+
const f = await git(repoDir, args, asked ? 300_000 : 120_000, env, { windowsHide: !asked, killTree: true });
|
|
104
|
+
// Given up on with the window still open: closed, so it is not left on screen asking for nothing.
|
|
105
|
+
if (!f.ok && asked && /gave up after/.test(f.stderr))
|
|
106
|
+
await closePasswordWindows(opts.askpass, started);
|
|
92
107
|
if (f.ok)
|
|
93
|
-
|
|
108
|
+
fetched = { ok: true, asked };
|
|
109
|
+
else {
|
|
110
|
+
const why = describeFetchFailure(f.stderr || f.stdout);
|
|
111
|
+
fetched = { ok: false, detail: why.detail, auth: why.auth, asked };
|
|
112
|
+
}
|
|
94
113
|
}
|
|
95
114
|
let target = '';
|
|
96
115
|
const remoteHead = await git(repoDir, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`]);
|
|
@@ -102,14 +121,21 @@ export async function planPrepare(folder, opts) {
|
|
|
102
121
|
if ((await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${name}`])).ok)
|
|
103
122
|
target = `${remote}/${name}`;
|
|
104
123
|
}
|
|
124
|
+
const last = fetched && !fetched.ok ? lastBefore : gitDir ? lastFetchedAt(gitDir) : undefined;
|
|
105
125
|
if (!target) {
|
|
106
126
|
return {
|
|
107
127
|
...empty(repoDir, fetched && !fetched.ok ? `The remote could not be fetched (${fetched.detail}), and no main branch of ${remote} is known here.` : `No main branch of ${remote} is known here.`),
|
|
108
128
|
...(fetched ? { fetched } : {}),
|
|
129
|
+
fetchCommand,
|
|
109
130
|
};
|
|
110
131
|
}
|
|
111
132
|
if (fetched && !fetched.ok) {
|
|
112
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
...empty(repoDir, `The remote could not be fetched, so the folder would be set to an old copy of ${target}: ${fetched.detail}. Nothing was changed.`),
|
|
135
|
+
fetched,
|
|
136
|
+
fetchCommand,
|
|
137
|
+
...(last ? { lastFetched: last } : {}),
|
|
138
|
+
};
|
|
113
139
|
}
|
|
114
140
|
const targetCommit = (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${target}^{commit}`])).stdout;
|
|
115
141
|
if (!targetCommit)
|
|
@@ -144,6 +170,8 @@ export async function planPrepare(folder, opts) {
|
|
|
144
170
|
localBranch,
|
|
145
171
|
localHead,
|
|
146
172
|
...(fetched ? { fetched } : {}),
|
|
173
|
+
...(last ? { lastFetched: last } : {}),
|
|
174
|
+
fetchCommand,
|
|
147
175
|
changed,
|
|
148
176
|
untracked,
|
|
149
177
|
mainOnlyCommits,
|
|
@@ -191,8 +219,17 @@ async function keepUncommitted(dir, head, branch, message) {
|
|
|
191
219
|
}
|
|
192
220
|
}
|
|
193
221
|
/** Does it, after checking the folder is still what the preview showed. */
|
|
194
|
-
export async function prepareFromRemote(folder, fingerprint,
|
|
222
|
+
export async function prepareFromRemote(folder, fingerprint,
|
|
223
|
+
/** `names`: the saved-branch names the preview showed, used when still free, so Confirm makes what was shown. */
|
|
224
|
+
opts = {}) {
|
|
195
225
|
const plan = await planPrepare(folder, { fetch: false, prefix: opts.prefix });
|
|
226
|
+
// The names the operator saw, not ones stamped a minute later (live run 2026-10-03).
|
|
227
|
+
const prefix = opts.prefix || 'cop/';
|
|
228
|
+
const free = async (name) => !!name && name.startsWith(`${prefix}saved/`) && !(await git(plan.repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`])).ok;
|
|
229
|
+
if (plan.savedBranch && (await free(opts.names?.savedBranch)))
|
|
230
|
+
plan.savedBranch = opts.names.savedBranch;
|
|
231
|
+
if (plan.savedMainBranch && (await free(opts.names?.savedMainBranch)))
|
|
232
|
+
plan.savedMainBranch = opts.names.savedMainBranch;
|
|
196
233
|
if (!plan.ok)
|
|
197
234
|
return { ok: false, problem: plan.problem ?? 'the folder could not be read.' };
|
|
198
235
|
const repoDir = plan.repoDir;
|