mirror of
https://github.com/microsoft/vscode.git
synced 2026-10-28 13:26:52 +08:00
Merge branch 'main' into fix-typos-and-grammar
This commit is contained in:
560 files changed
+38820
-5130
No files matched your search
@@ -149,3 +149,9 @@ The fixes can be run using `npx eslint --fix` in the VS Code repo.
|
||||
## Tests nested inside tests
|
||||
|
||||
`local/code-no-nested-test` reports a `test(...)` or `suite(...)` declared inside the body of another test. Mocha only collects tests while a suite runs, so a test registered from within a running test is silently dropped and never runs. This is almost always a misplaced closing brace. Only Mocha's globals are checked: a local helper named `test` is ignored, and so is anything imported from `node:test`, which runs a nested test as a subtest. Run its tests with `node --test .eslint-plugin-local/tests/code-no-nested-test.test.ts`.
|
||||
|
||||
## Polling a timer in a loop
|
||||
|
||||
`local/code-no-timeout-polling-loop` reports loops in unit tests that wait for a state change by sleeping on a timer, for example `for (let i = 0; i < 100 && !done; i++) { await timeout(1); }`, `while (!done) { await new Promise(r => setTimeout(r, 5)); }`, or a fixed number of `await timeout(0)` ticks. Such a loop's time budget depends on timer clamping (about 4ms for nested timers in Electron) and machine load, so it is flaky on slow or oversubscribed machines. A bounded loop that runs out also continues silently with stale state, so the test fails later at an unrelated assertion. Await a signal for the state change instead: a `DeferredPromise`, `Event.toPromise(...)`, `waitForState(...)`, or the promise of the operation itself. For code driven by timers, use `runWithFakedTimers`.
|
||||
|
||||
Only loops that do nothing but wait are reported: the body consists solely of awaited sleeps (`timeout`, `sleep`, `delay`, `wait`, the awaited `setTimeout` of `timers/promises`, or a `new Promise` whose executor only resolves on a `setTimeout`), exit guards such as `if (done) { break; }`, and declarations or assignments of reads such as `const state = await db.read();`. Calls count as reads only by name (`get…`, `is…`, `has…`, `find`, `filter` and similar); any other call counts as work. Loops that do work between sleeps or before a `continue`, such as streaming mock data or a parameterized test, are not reported. Loops within a `runWithFakedTimers` callback, where time is virtual, are not reported either, unless the callback passes `useFakeTimers: false`. The rule is enabled for `src/vs/**/*.test.ts`. Files that already polled when it was introduced are listed in its `exclude` option and should be removed from the list once they are fixed. Run its tests with `node --test .eslint-plugin-local/tests/code-no-timeout-polling-loop.test.ts`.
|
||||
@@ -0,0 +1,257 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as eslint from 'eslint';
|
||||
import type * as ESTree from 'estree';
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
|
||||
const sleepFunctions = new Set(['timeout', 'sleep', 'delay', 'wait']);
|
||||
|
||||
type LoopNode = TSESTree.ForStatement | TSESTree.ForOfStatement | TSESTree.ForInStatement | TSESTree.WhileStatement | TSESTree.DoWhileStatement;
|
||||
|
||||
function unwrap(node: TSESTree.Expression): TSESTree.Expression {
|
||||
while (node.type === 'TSAsExpression' || node.type === 'TSNonNullExpression' || node.type === 'TSSatisfiesExpression') {
|
||||
node = node.expression;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function isFunction(node: TSESTree.Node): node is TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | TSESTree.FunctionDeclaration {
|
||||
return node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression' || node.type === 'FunctionDeclaration';
|
||||
}
|
||||
|
||||
function isCallTo(node: TSESTree.Node, name: string): node is TSESTree.CallExpression {
|
||||
return node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === name;
|
||||
}
|
||||
|
||||
/** The single expression of `() => expr` or `() => { expr; }`. */
|
||||
function onlyExpression(fn: TSESTree.Node): TSESTree.Node | undefined {
|
||||
if (!isFunction(fn)) {
|
||||
return undefined;
|
||||
}
|
||||
if (fn.body.type !== 'BlockStatement') {
|
||||
return fn.body;
|
||||
}
|
||||
return fn.body.body.length === 1 && fn.body.body[0].type === 'ExpressionStatement' ? fn.body.body[0].expression : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* An executor that does nothing but resolve on a timer, such as
|
||||
* `r => setTimeout(r, n)` or `function (r) { setTimeout(() => r(), n); }`.
|
||||
* Executors that also subscribe to a signal or do work are not sleeps.
|
||||
*/
|
||||
function isTimerOnlyExecutor(executor: TSESTree.Node): boolean {
|
||||
const resolve = isFunction(executor) ? executor.params[0] : undefined;
|
||||
const call = onlyExpression(executor);
|
||||
if (resolve?.type !== 'Identifier' || !call || !isCallTo(call, 'setTimeout')) {
|
||||
return false;
|
||||
}
|
||||
const callback = call.arguments[0];
|
||||
if (callback?.type === 'Identifier') {
|
||||
return callback.name === resolve.name;
|
||||
}
|
||||
const resolveCall = callback && onlyExpression(callback);
|
||||
return !!resolveCall && isCallTo(resolveCall, resolve.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* `timeout(n)`, `sleep(n)`, `new Promise(r => setTimeout(r, n))` and the
|
||||
* awaited `setTimeout(n)` of `timers/promises`.
|
||||
*/
|
||||
function isTimerSleep(node: TSESTree.Expression): boolean {
|
||||
node = unwrap(node);
|
||||
if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
|
||||
return sleepFunctions.has(node.callee.name) || node.callee.name === 'setTimeout';
|
||||
}
|
||||
if (node.type === 'NewExpression' && node.callee.type === 'Identifier' && node.callee.name === 'Promise') {
|
||||
const executor = node.arguments[0];
|
||||
return !!executor && isTimerOnlyExecutor(executor);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Method names that read state, such as `getX`, `isX`, `find` or `Date.now`, matched by camelCase prefix. */
|
||||
const readPrefixes = ['get', 'read', 'list', 'find', 'filter', 'has', 'is', 'some', 'every', 'includes', 'at', 'indexOf', 'map', 'keys', 'values', 'entries', 'count', 'query', 'peek', 'lookup', 'stat', 'exists', 'parse', 'stringify', 'join', 'slice', 'now', 'toString'];
|
||||
|
||||
function isReadName(name: string): boolean {
|
||||
return readPrefixes.some(prefix => name === prefix || (name.startsWith(prefix) && /[A-Z]/.test(name.charAt(prefix.length))));
|
||||
}
|
||||
|
||||
function calleeName(callee: TSESTree.Expression): string | undefined {
|
||||
if (callee.type === 'Identifier') {
|
||||
return callee.name;
|
||||
}
|
||||
if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') {
|
||||
return callee.property.name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether evaluating `node` only reads state. This is conservative: calls
|
||||
* count as reads only by name, and anything unknown counts as work.
|
||||
*/
|
||||
function isRead(node: TSESTree.Node | null | undefined): boolean {
|
||||
if (!node) {
|
||||
return true;
|
||||
}
|
||||
switch (node.type) {
|
||||
case 'Literal':
|
||||
case 'Identifier':
|
||||
case 'ThisExpression':
|
||||
case 'ArrowFunctionExpression':
|
||||
case 'FunctionExpression':
|
||||
return true;
|
||||
case 'TemplateLiteral':
|
||||
return node.expressions.every(isRead);
|
||||
case 'MemberExpression':
|
||||
return isRead(node.object) && (!node.computed || isRead(node.property));
|
||||
case 'ChainExpression':
|
||||
case 'TSAsExpression':
|
||||
case 'TSNonNullExpression':
|
||||
case 'TSSatisfiesExpression':
|
||||
return isRead(node.expression);
|
||||
case 'AwaitExpression':
|
||||
case 'SpreadElement':
|
||||
return isRead(node.argument);
|
||||
case 'UnaryExpression':
|
||||
return node.operator !== 'delete' && isRead(node.argument);
|
||||
case 'BinaryExpression':
|
||||
case 'LogicalExpression':
|
||||
return isRead(node.left) && isRead(node.right);
|
||||
case 'ConditionalExpression':
|
||||
return isRead(node.test) && isRead(node.consequent) && isRead(node.alternate);
|
||||
case 'ArrayExpression':
|
||||
return node.elements.every(isRead);
|
||||
case 'ObjectExpression':
|
||||
return node.properties.every(property => property.type === 'SpreadElement' ? isRead(property) : isRead(property.value));
|
||||
case 'CallExpression': {
|
||||
const name = calleeName(node.callee);
|
||||
const receiver = node.callee.type === 'MemberExpression' ? node.callee.object : undefined;
|
||||
return !!name && isReadName(name) && isRead(receiver) && node.arguments.every(isRead);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAwaitedSleep(statement: TSESTree.Statement): statement is TSESTree.ExpressionStatement & { expression: TSESTree.AwaitExpression } {
|
||||
return statement.type === 'ExpressionStatement' && statement.expression.type === 'AwaitExpression' && isTimerSleep(statement.expression.argument);
|
||||
}
|
||||
|
||||
function isExit(statement: TSESTree.Statement): boolean {
|
||||
return statement.type === 'BreakStatement' || statement.type === 'ContinueStatement' || statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement';
|
||||
}
|
||||
|
||||
/** `const state = read();`, `state = await read();` */
|
||||
function isReadStatement(statement: TSESTree.Statement): boolean {
|
||||
if (statement.type === 'VariableDeclaration') {
|
||||
return statement.declarations.every(declaration => isRead(declaration.init));
|
||||
}
|
||||
return statement.type === 'ExpressionStatement' && statement.expression.type === 'AssignmentExpression' && statement.expression.operator === '=' && statement.expression.left.type === 'Identifier' && isRead(statement.expression.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* A branch that ends the iteration. Work before a `break`, `return` or `throw`
|
||||
* runs once on the way out, but work before a `continue` runs on every
|
||||
* iteration, so such a loop drives an operation instead of only waiting.
|
||||
*/
|
||||
function isExitBranch(statement: TSESTree.Statement): boolean {
|
||||
if (statement.type !== 'BlockStatement') {
|
||||
return isExit(statement);
|
||||
}
|
||||
const last = statement.body.at(-1);
|
||||
if (!last || !isExit(last)) {
|
||||
return false;
|
||||
}
|
||||
return last.type !== 'ContinueStatement' || statement.body.slice(0, -1).every(isReadStatement);
|
||||
}
|
||||
|
||||
/** `if (done) { break; }`, `if (Date.now() > deadline) { throw ... }` */
|
||||
function isGuard(statement: TSESTree.Statement): boolean {
|
||||
return statement.type === 'IfStatement' && isExitBranch(statement.consequent) && (!statement.alternate || isExitBranch(statement.alternate));
|
||||
}
|
||||
|
||||
function bodyStatements(loop: LoopNode): TSESTree.Statement[] {
|
||||
return loop.body.type === 'BlockStatement' ? loop.body.body : [loop.body];
|
||||
}
|
||||
|
||||
/**
|
||||
* The sleep of a loop that does nothing but sleep and check state: its body
|
||||
* consists only of awaited timer sleeps, exit guards and reads. Conditions
|
||||
* are not classified, since they are the state being polled.
|
||||
*/
|
||||
function pollingSleep(loop: LoopNode): TSESTree.Statement | undefined {
|
||||
const statements = bodyStatements(loop);
|
||||
const sleep = statements.find(isAwaitedSleep);
|
||||
const onlyWaits = statements.every(statement => isAwaitedSleep(statement) || isGuard(statement) || isReadStatement(statement) || statement.type === 'EmptyStatement');
|
||||
return onlyWaits ? sleep : undefined;
|
||||
}
|
||||
|
||||
/** `runWithFakedTimers({ useFakeTimers: false }, ...)` runs on real time. */
|
||||
function disablesFakeTimers(call: TSESTree.CallExpression): boolean {
|
||||
const options = call.arguments[0];
|
||||
return options?.type === 'ObjectExpression' && options.properties.some(property => property.type === 'Property'
|
||||
&& !property.computed
|
||||
&& property.key.type === 'Identifier' && property.key.name === 'useFakeTimers'
|
||||
&& property.value.type === 'Literal' && property.value.value === false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Code within a `runWithFakedTimers` callback runs on virtual time, which
|
||||
* makes polling deterministic. A callback that opts out of fake timers still
|
||||
* runs on the virtual time of an enclosing callback, if any.
|
||||
*/
|
||||
function isInFakedTimers(node: TSESTree.Node): boolean {
|
||||
for (let current: TSESTree.Node | undefined = node.parent; current; current = current.parent) {
|
||||
const call = current.parent;
|
||||
if (isFunction(current) && call?.type === 'CallExpression' && call.arguments.includes(current as TSESTree.CallExpressionArgument)) {
|
||||
if (call.callee.type === 'Identifier' && call.callee.name === 'runWithFakedTimers' && !disablesFakeTimers(call)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default new class NoTimeoutPollingLoop implements eslint.Rule.RuleModule {
|
||||
|
||||
readonly meta: eslint.Rule.RuleMetaData = {
|
||||
type: 'problem',
|
||||
messages: {
|
||||
polling: 'Do not wait for a state change by polling a timer in a loop. The time budget depends on timer clamping and machine load, so the test is flaky on slow machines, and a bounded loop that runs out continues with stale state and fails somewhere else. Await a signal for the state change instead, e.g. a `DeferredPromise`, `Event.toPromise(...)`, `waitForState(...)` or the promise of the operation itself, or use `runWithFakedTimers` for code that is driven by timers.',
|
||||
},
|
||||
schema: [{
|
||||
type: 'object',
|
||||
properties: {
|
||||
exclude: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
additionalProperties: false,
|
||||
}],
|
||||
};
|
||||
|
||||
create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener {
|
||||
const config = context.options[0] as { exclude?: string[] } | undefined;
|
||||
const filename = context.filename.replace(/\\/g, '/');
|
||||
if (config?.exclude?.some(e => filename.endsWith(e))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const check = (loop: LoopNode) => {
|
||||
const sleep = pollingSleep(loop);
|
||||
if (sleep && !isInFakedTimers(loop)) {
|
||||
context.report({ node: sleep as ESTree.Node, messageId: 'polling' });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ForStatement: (node: ESTree.Node) => check(node as TSESTree.ForStatement),
|
||||
ForOfStatement: (node: ESTree.Node) => check(node as TSESTree.ForOfStatement),
|
||||
ForInStatement: (node: ESTree.Node) => check(node as TSESTree.ForInStatement),
|
||||
WhileStatement: (node: ESTree.Node) => check(node as TSESTree.WhileStatement),
|
||||
DoWhileStatement: (node: ESTree.Node) => check(node as TSESTree.DoWhileStatement),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { RuleTester } from 'eslint';
|
||||
import { suite, test } from 'node:test';
|
||||
import { resolve } from 'path';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import rule from '../code-no-timeout-polling-loop.ts';
|
||||
|
||||
RuleTester.describe = suite;
|
||||
RuleTester.it = test;
|
||||
|
||||
const filename = resolve(import.meta.dirname, '../../src/vs/workbench/test/browser/example.test.ts');
|
||||
const errors = [{ messageId: 'polling' }];
|
||||
|
||||
new RuleTester({ languageOptions: { parser: tseslint.parser } }).run('code-no-timeout-polling-loop', rule, {
|
||||
valid: [
|
||||
// the loop does work besides waiting
|
||||
{ filename, code: `async function f() { for (const chunk of chunks) { emitter.fire(chunk); await timeout(10); } }` },
|
||||
{ filename, code: `async function f() { for (const kind of ['a', 'b']) { service.dispatch(kind); await timeout(0); assert.ok(service.done); } }` },
|
||||
{ filename, code: `async function f() { while (!done) { clock.tick(1); await timeout(0); } }` },
|
||||
{ filename, code: `async function f() { for (let i = 0; i < 10 && !done; i++) { await timeout(1); release(); } }` },
|
||||
{ filename, code: `async function f() { for (let i = 0; i < 3; i++) { try { return await op(); } catch { await timeout(100); } } }` },
|
||||
{ filename, code: `async function f() { while (!done) { if (ready) { start(); } await timeout(1); } }` },
|
||||
{ filename, code: `async function f() { while (!done) { this.count = await read(); await timeout(1); } }` },
|
||||
// work in a branch that continues the loop
|
||||
{ filename, code: `async function f() { while (!done) { if (ready) { service.dispatch(); continue; } await timeout(1); } }` },
|
||||
// declarations and assignments that do work
|
||||
{ filename, code: `async function f() { for (const chunk of chunks) { const sent = socket.write(chunk); await timeout(10); } }` },
|
||||
{ filename, code: `async function f() { let sent; for (const chunk of chunks) { sent = socket.write(chunk); await timeout(10); } }` },
|
||||
{ filename, code: `async function f() { while (!done) { const editor = new Editor(); await timeout(10); } }` },
|
||||
// executors that wait on a signal or do work
|
||||
{ filename, code: `async function f() { while (!done) { await new Promise((resolve, reject) => { onDidChange(resolve); setTimeout(reject, 1000); }); } }` },
|
||||
{ filename, code: `async function f() { while (!done) { await new Promise(resolve => { emitter.fire(data); setTimeout(resolve, 10); }); } }` },
|
||||
{ filename, code: `async function f() { while (!done) { await new Promise((_, reject) => setTimeout(reject, 10)); } }` },
|
||||
// waiting on a signal
|
||||
{ filename, code: `async function f() { while (!done) { await Event.toPromise(onDidChange); } }` },
|
||||
{ filename, code: `async function f() { for (let i = 0; i < 10; i++) { await promises[i]; } }` },
|
||||
// not awaited, or not a timer
|
||||
{ filename, code: `async function f() { for (let i = 0; i < 10; i++) { timeout(1); } }` },
|
||||
{ filename, code: `async function f() { for (let i = 0; i < 10; i++) { await barrier.wait(); } }` },
|
||||
{ filename, code: `async function f() { for (let i = 0; i < 10; i++) { await new Promise(resolve => queueMicrotask(resolve)); } }` },
|
||||
// a timer within a nested function
|
||||
{ filename, code: `for (const name of names) { test(name, async () => { await timeout(1); }); }` },
|
||||
// virtual time
|
||||
{ filename, code: `test('x', () => runWithFakedTimers({}, async () => { for (let i = 0; i < 100 && !done; i++) { await timeout(1); } }));` },
|
||||
{ filename, code: `test('x', () => runWithFakedTimers({}, async () => { const waitFor = async () => { while (!done) { await timeout(1); } }; await waitFor(); }));` },
|
||||
{ filename, code: `test('x', () => runWithFakedTimers({ useFakeTimers: true }, async () => { while (!done) { await timeout(1); } }));` },
|
||||
{ filename, code: `test('x', () => runWithFakedTimers({}, async () => { await runWithFakedTimers({ useFakeTimers: false }, async () => { while (!done) { await timeout(1); } }); }));` },
|
||||
// excluded file
|
||||
{ filename, code: `async function f() { while (!done) { await timeout(1); } }`, options: [{ exclude: ['/example.test.ts'] }] },
|
||||
],
|
||||
invalid: [
|
||||
// bounded poll
|
||||
{ filename, code: `async function f() { for (let i = 0; i < 100 && agent.historyHits === 0; i++) { await timeout(1); } }`, errors },
|
||||
{ filename, code: `async function f() { for (let i = 0; i < 100 && !done; i++) await timeout(1); }`, errors },
|
||||
// unbounded poll
|
||||
{ filename, code: `async function f() { while (!done) { await timeout(0); } }`, errors },
|
||||
{ filename, code: `async function f() { do { await timeout(5); } while (!done); }`, errors },
|
||||
// fixed number of ticks
|
||||
{ filename, code: `async function f() { for (let i = 0; i < 10; i++) { await timeout(0); } }`, errors },
|
||||
// polls with guards and reads
|
||||
{ filename, code: `async function f() { while (Date.now() < deadline) { if (done) { return; } await timeout(10); } }`, errors },
|
||||
{ filename, code: `async function f() { const deadline = Date.now() + 1000; while (true) { const request = find(); if (request) { return request; } if (Date.now() > deadline) { const sent = list(); throw new Error(sent); } await timeout(5); } }`, errors },
|
||||
{ filename, code: `async function f() { let state; for (let i = 0; i < 50 && !state; i++) { await timeout(2); state = await db.read(); } }`, errors },
|
||||
{ filename, code: `async function f() { while (true) { const requests = transport.sentMessages.filter(m => m.method === method); if (requests[index]) { return requests[index]; } await timeout(5); } }`, errors },
|
||||
{ filename, code: `async function f() { while (!done) { if (Date.now() > deadline) { log.error(state); throw new Error('timeout'); } await timeout(5); } }`, errors },
|
||||
{ filename, code: `async function f() { while (!done) { if (ready) { service.dispatch(); break; } await timeout(1); } }`, errors },
|
||||
{ filename, code: `async function f() { while (!done) { if (skip) { const now = Date.now(); continue; } await timeout(1); } }`, errors },
|
||||
// other timer sleeps
|
||||
{ filename, code: `async function f() { while (!done) { await new Promise(resolve => setTimeout(resolve, 5)); } }`, errors },
|
||||
{ filename, code: `async function f() { while (!done) { await new Promise<void>(function (r) { setTimeout(() => r(), 5); }); } }`, errors },
|
||||
{ filename, code: `async function f() { while (!done) { await new Promise<void>(resolve => { setTimeout(() => { resolve(); }, 5); }); } }`, errors },
|
||||
{ filename, code: `async function f() { while (!done) { await setTimeout(5); } }`, errors },
|
||||
{ filename, code: `async function f() { while (!done) { await sleep(5); } }`, errors },
|
||||
{ filename, code: `async function f() { while (!done) { await (timeout(5) as Promise<void>); } }`, errors },
|
||||
// a helper defined outside of runWithFakedTimers runs on real time
|
||||
{ filename, code: `async function waitFor(predicate) { for (let i = 0; i < 100 && !predicate(); i++) { await timeout(0); } }\ntest('x', () => runWithFakedTimers({}, async () => { await waitFor(() => done); }));`, errors },
|
||||
// fake timers disabled
|
||||
{ filename, code: `test('x', () => runWithFakedTimers({ useFakeTimers: false }, async () => { while (!done) { await timeout(1); } }));`, errors },
|
||||
],
|
||||
});
|
||||
@@ -86,6 +86,7 @@ const rules: readonly IChangeRule[] = [
|
||||
'.github/actions/restore-node-modules/',
|
||||
'.github/workflows/node_modules_cache/',
|
||||
'build/azure-pipelines/common/computeNodeModulesCacheKey.ts',
|
||||
'build/azure-pipelines/common/cacheKeys.ts',
|
||||
'build/npm/dirs.ts',
|
||||
'build/azure-pipelines/win32/exec.ps1',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Detect missing caches
|
||||
description: Determines which jobs of pr-node-modules.yml need to run because a cache they keep warm is missing.
|
||||
|
||||
inputs:
|
||||
token:
|
||||
description: "GitHub token with the `actions: read` permission, used to list the caches."
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
compile:
|
||||
description: "'true' when the Compile job needs to run."
|
||||
value: ${{ steps.detect.outputs.compile }}
|
||||
linux:
|
||||
description: "'true' when the Linux job needs to run."
|
||||
value: ${{ steps.detect.outputs.linux }}
|
||||
macOS:
|
||||
description: "'true' when the macOS job needs to run."
|
||||
value: ${{ steps.detect.outputs.macOS }}
|
||||
windows:
|
||||
description: "'true' when the Windows job needs to run."
|
||||
value: ${{ steps.detect.outputs.windows }}
|
||||
copilot-linux:
|
||||
description: "'true' when the Copilot (Linux) job needs to run."
|
||||
value: ${{ steps.detect.outputs.copilot-linux }}
|
||||
copilot-windows:
|
||||
description: "'true' when the Copilot (Windows) job needs to run."
|
||||
value: ${{ steps.detect.outputs.copilot-windows }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect missing caches
|
||||
id: detect
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ inputs.token }}
|
||||
script: |
|
||||
const { pathToFileURL } = require('url');
|
||||
const scriptUrl = pathToFileURL(process.env.GITHUB_WORKSPACE + '/.github/actions/detect-missing-caches/detect.ts');
|
||||
const { detectMissingCaches } = await import(scriptUrl.href);
|
||||
await detectMissingCaches(github, context, core);
|
||||
@@ -0,0 +1,220 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { computeBuiltInDepsCacheKey, computeNodeModulesCacheKey, type ReadRepositoryFile } from '../../../build/azure-pipelines/common/cacheKeys.ts';
|
||||
|
||||
interface ICacheListParameters {
|
||||
readonly owner: string;
|
||||
readonly repo: string;
|
||||
readonly ref: string;
|
||||
readonly key: string;
|
||||
readonly per_page: number;
|
||||
}
|
||||
|
||||
interface IGitHub {
|
||||
readonly rest: {
|
||||
readonly actions: {
|
||||
readonly getActionsCacheList: (parameters: ICacheListParameters) => Promise<{ readonly data: { readonly actions_caches?: readonly { readonly key?: string; readonly version?: string }[] } }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface IContext {
|
||||
readonly repo: { readonly owner: string; readonly repo: string };
|
||||
readonly ref: string;
|
||||
}
|
||||
|
||||
interface ICore {
|
||||
info(message: string): void;
|
||||
warning(message: string): void;
|
||||
setOutput(name: string, value: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `runner.os` of a job. The Windows runners check out text files with CRLF line endings, which
|
||||
* changes the cache keys that hash them.
|
||||
*/
|
||||
type RunnerOs = 'Linux' | 'macOS' | 'Windows';
|
||||
|
||||
/**
|
||||
* Line endings of a runner's checkout.
|
||||
*/
|
||||
type LineEndings = 'lf' | 'crlf';
|
||||
|
||||
/**
|
||||
* A job of `.github/workflows/pr-node-modules.yml` and the caches it keeps warm on `main`.
|
||||
*/
|
||||
export interface ICacheWarmingJob {
|
||||
readonly os: RunnerOs;
|
||||
/** The `key-prefix` and the evaluated `key-args` of `.github/actions/restore-node-modules`. */
|
||||
readonly nodeModules: { readonly prefix: string; readonly args: readonly string[] };
|
||||
/** The `arch` of `.github/actions/setup-electron`. */
|
||||
readonly electron?: { readonly arch: string };
|
||||
/** The marketplace target platform of the built-in extensions cache. */
|
||||
readonly builtInExtensions?: { readonly target: string };
|
||||
/** Whether the job caches the WSL installer with `.github/actions/restore-wsl-installer`. */
|
||||
readonly wslInstaller?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cache that a job saves. `actions/cache` only restores a cache whose key and version both match.
|
||||
*/
|
||||
export interface ICache {
|
||||
readonly key: string;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cache warming jobs by job id, which is also the name of the output that tells whether the job needs to run.
|
||||
*/
|
||||
export const cacheWarmingJobs: Readonly<Record<string, ICacheWarmingJob>> = {
|
||||
'compile': {
|
||||
os: 'Linux',
|
||||
nodeModules: { prefix: 'node_modules-compile', args: ['compile', 'x64'] },
|
||||
builtInExtensions: { target: 'linux-x64' },
|
||||
},
|
||||
'linux': {
|
||||
os: 'Linux',
|
||||
nodeModules: { prefix: 'node_modules-linux', args: ['linux', 'x64', 'x64'] },
|
||||
electron: { arch: 'x64' },
|
||||
},
|
||||
'macOS': {
|
||||
os: 'macOS',
|
||||
nodeModules: { prefix: 'node_modules-macos', args: ['darwin', 'arm64', 'arm64'] },
|
||||
electron: { arch: 'arm64' },
|
||||
},
|
||||
'windows': {
|
||||
os: 'Windows',
|
||||
nodeModules: { prefix: 'node_modules-windows', args: ['win32', 'x64', 'x64'] },
|
||||
electron: { arch: 'x64' },
|
||||
wslInstaller: true,
|
||||
},
|
||||
'copilot-linux': {
|
||||
os: 'Linux',
|
||||
nodeModules: { prefix: 'copilot-node_modules-linux', args: ['linux', 'x64'] },
|
||||
},
|
||||
'copilot-windows': {
|
||||
os: 'Windows',
|
||||
nodeModules: { prefix: 'copilot-node_modules-windows', args: ['win32', 'x64'] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The `ELECTRON_CACHE_PATH` that `.github/actions/setup-electron` sets on the runners of the jobs. It
|
||||
* depends on the home directory of the runners and is part of the cache version.
|
||||
*/
|
||||
const electronCachePaths: Readonly<Record<RunnerOs, string>> = {
|
||||
Linux: '/home/cloudtest/.cache/electron',
|
||||
macOS: '/Users/runner/Library/Caches/electron',
|
||||
Windows: 'C:\\Users\\cloudtest\\AppData\\Local\\electron\\Cache',
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors the `hashFiles()` workflow expression function for the given file contents.
|
||||
*/
|
||||
function hashFiles(...contents: Buffer[]): string {
|
||||
const result = createHash('sha256');
|
||||
for (const content of contents) {
|
||||
result.update(createHash('sha256').update(content).digest());
|
||||
}
|
||||
return result.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the cache version of `actions/cache` (`getCacheVersion` in `@actions/cache`), which
|
||||
* depends on the cache path, the compression method (zstd on all runners) and the OS.
|
||||
*/
|
||||
function getCacheVersion(os: RunnerOs, cachePath: string, enableCrossOsArchive = false): string {
|
||||
const components = [cachePath, 'zstd-without-long'];
|
||||
if (os === 'Windows' && !enableCrossOsArchive) {
|
||||
components.push('windows-only');
|
||||
}
|
||||
components.push('1.0');
|
||||
return createHash('sha256').update(components.join('|')).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the caches a job saves, as the job computes their keys and versions on its runner.
|
||||
*/
|
||||
export function getCaches(job: ICacheWarmingJob, readFile: ReadRepositoryFile): ICache[] {
|
||||
const cache = (key: string, cachePath: string, enableCrossOsArchive?: boolean): ICache => ({ key, version: getCacheVersion(job.os, cachePath, enableCrossOsArchive) });
|
||||
const caches = [cache(`${job.nodeModules.prefix}-${hashFiles(Buffer.from(computeNodeModulesCacheKey(job.nodeModules.args, readFile)))}`, '.build/node_modules_cache')];
|
||||
if (job.electron) {
|
||||
caches.push(cache(`electron-download-v1-${job.os}-${job.electron.arch}-${hashFiles(readFile('.npmrc'), readFile('build/checksums/electron.txt'))}`, electronCachePaths[job.os]));
|
||||
}
|
||||
if (job.builtInExtensions) {
|
||||
const target = job.builtInExtensions.target;
|
||||
caches.push(cache(`builtin-extensions-${hashFiles(Buffer.from(computeBuiltInDepsCacheKey(() => target, readFile)))}`, '.build/builtInExtensions', true));
|
||||
}
|
||||
if (job.wslInstaller) {
|
||||
caches.push(cache(`wsl-installer-v1-${job.os}-${hashFiles(readFile('build/checksums/wsl.txt'))}`, '.build/wsl-installer'));
|
||||
}
|
||||
return caches;
|
||||
}
|
||||
|
||||
function createCheckoutReaders(): Readonly<Record<LineEndings, ReadRepositoryFile>> {
|
||||
const root = path.join(import.meta.dirname, '../../..');
|
||||
const crlfFiles = new Map<string, Buffer>();
|
||||
return {
|
||||
lf: relativePath => readFileSync(path.join(root, relativePath)),
|
||||
// Let Git convert the line endings so that `.gitattributes` applies as on the Windows runners.
|
||||
crlf: relativePath => {
|
||||
let content = crlfFiles.get(relativePath);
|
||||
if (!content) {
|
||||
content = execFileSync('git', ['-c', 'core.autocrlf=true', 'cat-file', '--filters', `HEAD:${relativePath}`], { cwd: root, maxBuffer: 256 * 1024 * 1024 });
|
||||
crlfFiles.set(relativePath, content);
|
||||
}
|
||||
return content;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the versions of the caches on the current branch with the given key.
|
||||
*/
|
||||
async function listCacheVersions(github: IGitHub, context: IContext, key: string): Promise<(string | undefined)[]> {
|
||||
// The key parameter matches key prefixes.
|
||||
const { data } = await github.rest.actions.getActionsCacheList({ ...context.repo, ref: context.ref, key, per_page: 100 });
|
||||
return (data.actions_caches ?? []).filter(cache => cache.key === key).map(cache => cache.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an output for each cache warming job: 'true' when one of its caches is missing on the
|
||||
* current branch, otherwise 'false'. A cache with the right key but another version is missing,
|
||||
* because the job could not restore it. Jobs whose caches cannot be checked run.
|
||||
*/
|
||||
export async function detectMissingCaches(github: IGitHub, context: IContext, core: ICore, readers: Readonly<Record<LineEndings, ReadRepositoryFile>> = createCheckoutReaders()): Promise<void> {
|
||||
for (const [name, job] of Object.entries(cacheWarmingJobs)) {
|
||||
let missing: string[] | undefined;
|
||||
try {
|
||||
const caches = getCaches(job, readers[job.os === 'Windows' ? 'crlf' : 'lf']);
|
||||
const versions = await Promise.all(caches.map(cache => listCacheVersions(github, context, cache.key)));
|
||||
missing = [];
|
||||
for (const [index, cache] of caches.entries()) {
|
||||
if (versions[index].includes(cache.version)) {
|
||||
continue;
|
||||
}
|
||||
missing.push(cache.key);
|
||||
if (versions[index].length > 0) {
|
||||
core.warning(`${cache.key} exists with version ${versions[index].join(', ')} instead of ${cache.version}. If ${name} keeps running, update the cache versions in .github/actions/detect-missing-caches/detect.ts.`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
core.warning(`Unable to check the caches of ${name}; running it. ${message}`);
|
||||
}
|
||||
|
||||
const run = missing === undefined || missing.length > 0;
|
||||
const reason = missing === undefined ? 'caches unavailable'
|
||||
: missing.length > 0 ? `missing ${missing.join(', ')}`
|
||||
: 'all caches exist';
|
||||
core.info(`${name}: ${run ? 'run' : 'skip'} (${reason})`);
|
||||
core.setOutput(name, run ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
@@ -150,14 +150,14 @@ managedSettings: { [COPILOT_ENABLED_PLUGINS_KEY]: { type: 'string' } },
|
||||
|
||||
### Governance presence disables the third-party harnesses
|
||||
|
||||
`IPolicyData.managedSettingsActive` is `true` when a channel contributes managed-settings governance. `AccountPolicyService.getPolicyData` derives it from effective managed settings and raw settings that normalization cannot represent, independently of which keys VS Code declares. Presence governs for existing settings. The Local-only `vscode.temporaryAllowLocalHarness` is excluded at every value: it controls Local, not other providers or presence-gated features. Channel resolution retains its value for precedence and diagnostics, but neither its normalized value nor its raw container contributes governance. Empty or Local-only server responses explicitly clear a previously cached raw-governance flag.
|
||||
`IPolicyData.managedSettingsActive` is `true` when a channel contributes managed-settings governance. `AccountPolicyService.getPolicyData` derives it from effective managed settings and raw settings that normalization cannot represent, independently of which keys VS Code declares. Presence governs for existing settings. The `vscode.temporaryAllowLocalHarness` control is excluded from this flag at every value; its explicit false value separately disables Local, Claude, and Codex without affecting unrelated presence-gated features. Channel resolution retains its value for precedence and diagnostics, but neither its normalized value nor its raw container contributes general governance. Empty server responses or responses containing only this control explicitly clear a previously cached raw-governance flag.
|
||||
|
||||
The `Claude3PIntegration` and `Codex3PIntegration` policies both use `thirdPartyAgentEnabledValue`, which forces its setting to `false` when the account disables chat preview features **or** when `managedSettingsActive` is `true`. Rationale: managed settings are composed and enforced by the Copilot runtime and never reach the Claude or Codex harnesses, so leaving those harnesses available would hand a governed user an ungoverned path around every control the enterprise set. This mirrors the runtime-owned `sandbox.enabled` floor retiring the local harness (`IAgentHostEnablementService.managedSandboxEnforced`).
|
||||
The `Claude3PIntegration` and `Codex3PIntegration` policies both use `thirdPartyAgentEnabledValue`, which forces its setting to `false` when the account disables chat preview features, when `managedSettingsActive` is `true`, or when effective `vscode.temporaryAllowLocalHarness` is boolean `false`. Rationale: managed settings are composed and enforced by the Copilot runtime and never reach the Claude or Codex harnesses, so leaving those harnesses available would hand a governed user an ungoverned path around every control the enterprise set. This mirrors the runtime-owned `sandbox.enabled` floor retiring the local harness (`IAgentHostEnablementService.managedSandboxEnforced`).
|
||||
|
||||
Invariants:
|
||||
|
||||
- The policies consume the shared governance decision rather than inspecting values themselves, and deliberately declare **no** `managedSettings` keys. The Local-only exception is resolved centrally, not duplicated in each provider.
|
||||
- `AccountPolicyService.resolvePolicyValue` probes for this presence dependence (re-evaluating the callback with `managedSettingsActive: false`) so **Developer: Policy Diagnostics** attributes the value to the governing channel rather than to the account.
|
||||
- Both policies declare `vscode.temporaryAllowLocalHarness` as a boolean managed key and use the shared callback. True, absence, or malformed values do not independently disable either provider; other managed settings and preview-feature restrictions still apply.
|
||||
- `AccountPolicyService.resolvePolicyValue` probes declared keys and presence dependence so **Developer: Policy Diagnostics** attributes the value to the governing channel rather than to the account. Presence attribution also clears declared values so an independently sufficient harness denial does not mask general governance.
|
||||
- The `value` callback stays pure and deterministic — attribution evaluates it more than once.
|
||||
|
||||
### Normalization: the structured-key descriptor table
|
||||
|
||||
@@ -34,21 +34,23 @@ Do not add a VS Code `policy:` merely to mirror runtime policy. Do not add a Git
|
||||
|
||||
### Harness eligibility for managed policy
|
||||
|
||||
A nonempty effective managed `permissions` object, or managed `sandbox.enabled: true`, requires Copilot Agent Host. Permission-key presence is sufficient, including `defaultMode`, explicit `false` / `"enable"`, and empty rule lists. VS Code preserves permission-key presence across server and file delivery and watches the runtime's declared permission keys in native MDM; it does not parse or enforce rule grammar. Permissions do not turn sandboxing on.
|
||||
A non-empty effective managed `permissions.deny` list, or managed `sandbox.enabled: true`, requires Copilot Agent Host, subject to the existing temporary Local escape hatch. A valid empty deny array, other `permissions.*` settings, and other sandbox settings do not independently require Agent Host. Malformed deny values remain fail-closed for runtime validation. VS Code only checks whether the deny list is empty; it does not interpret or enforce rule grammar. Native MDM continues watching the runtime's declared permission keys. Permissions do not turn sandboxing on. Explicit `vscode.temporaryAllowLocalHarness: false` still overrides the escape hatch. Claude/Codex retain their separate presence-based managed-settings governance.
|
||||
|
||||
When effective enterprise policy prohibits Local, the harness picker keeps its row visible but disabled, with a policy explanation and a link to learn why Local is disabled. This presentation does not make Local selectable, usable as a fallback, or exempt from request blocking and migration. Personal visibility settings still apply when no enterprise restriction is active.
|
||||
|
||||
The legacy network boundary bridge targets runtime-native Domain operations only. It does not change tool selection, the experimental terminal override, or VS Code client fetch. Native `limitTo` enforcement in the integrated browser is deferred; existing browser controls remain unchanged. Browser/client tools, experimental terminal overrides, arbitrary extension/MCP networking, and client-run code must not be assumed to inherit the runtime's Domain enforcement.
|
||||
|
||||
Resolve initial account policy before choosing or activating Local. Governed new chats select Copilot Agent Host, including explicit or remembered Local choices. An unavailable host must produce an actionable error, never a Local fallback. Displaying an existing non-archived Local chat automatically moves its transcript and unsent draft/attachments to Copilot, without sending or copying model and permission settings. Background history loads do not migrate chats. The original is archived only after the destination opens, and archived originals remain readable without migrating again. On failure, the original remains read-only with a Move to Copilot retry action; sending, retrying requests, and queueing stay blocked. The destination uses its normal policy-constrained defaults. Removing the requirement restores normal Local eligibility without rewriting preferences. Absent permissions or `permissions: {}` do not impose this requirement. Malformed permission values still require runtime validation rather than a Local fallback. Legacy `ChatToolsAutoApprove` alone and personal sandbox preferences do not require Agent Host. Removing an explicit permission key, rather than setting it to a nonrestrictive value, removes its harness requirement. The same request gate applies to old Local chats under a managed sandbox floor; virtual workspaces and unavailable hosts cannot silently run Local instead. Editor inline chat's limited read/edit flow remains available as an explicit exception; this gate does not enforce runtime permission rules on those edits.
|
||||
Resolve initial account policy before choosing or activating Local. When effective policy requires Agent Host, new chats select Copilot, including explicit or remembered Local choices. An unavailable host must produce an actionable error, never a Local fallback. Displaying an existing non-archived Local chat automatically moves its transcript and unsent draft/attachments to Copilot, without sending or copying model and permission settings. Background history loads do not migrate chats. The original is archived only after the destination opens, and archived originals remain readable without migrating again. On failure, the original remains read-only with a Move to Copilot retry action; sending, retrying requests, and queueing stay blocked. The destination uses its normal policy-constrained defaults. Removing the requirement restores normal Local eligibility without rewriting preferences. Removing `permissions.deny` or replacing it with a valid empty array removes its harness requirement. Legacy `ChatToolsAutoApprove` alone and personal sandbox preferences do not require Agent Host. The same request gate applies to old Local chats under a managed sandbox floor; virtual workspaces and unavailable hosts cannot silently run Local instead. Editor inline chat's limited read/edit flow remains available as an explicit exception; this gate does not enforce runtime permission rules on those edits.
|
||||
|
||||
## Host-Injection Lifecycle
|
||||
|
||||
### Approval modes
|
||||
|
||||
Copilot Agent Host resolves native mode restrictions before applying the legacy global approval fallback. An explicitly configured new mode restriction replaces the legacy blanket restriction; `defaultMode` alone does not. Other bridged restrictions, including the per-tool bypass lock and terminal asks, remain in force. The host publishes selectable modes and the starting selection in session config, validates client writes, and reconciles runtime refusals to the actual applied mode. Desktop and remote Copilot Agent Hosts use the same policy path. Native settings resolve where the host runs; this does not replicate desktop device policies to remote hosts. The UI must not reapply the legacy clamp to host-reported choices. Local offers Default, Allow All, and Autopilot, not Assisted. Managed permissions make full Local chat unavailable instead of implementing runtime modes in Local; legacy policy alone retains the existing Local controls. Clients retain the legacy guard until a host advertises its approval-policy report. Both standard `approvalMode` and VS Code `autoApprove` reports follow this contract. The report requires a valid approval binding. Under the legacy restriction, discover that binding before creation even without an explicit preference, and seed Manual when the host does not publish its own policy report. Host-owned approval reports are never forwarded as client configuration inputs. Explicit and remembered approval preferences are normalized after schema discovery, before prewarming a backend; they must not be irreversibly clamped before discovery. Defaults are only startup preferences; no additional durable pre-override restoration state is maintained. Restart to apply changed managed settings. A client restart refreshes selectable modes for already-loaded chats even while their SDK sessions remain disconnected, without resuming them or sending a prompt. The last runtime-reported applied mode is not overwritten by this preview.
|
||||
Copilot Agent Host resolves native mode restrictions before applying the legacy global approval fallback. An explicitly configured new mode restriction replaces the legacy blanket restriction; `defaultMode` alone does not. Other bridged restrictions, including the per-tool bypass lock and terminal asks, remain in force. The host publishes selectable modes and the starting selection in session config, validates client writes, and reconciles runtime refusals to the actual applied mode. Desktop and remote Copilot Agent Hosts use the same policy path. Native settings resolve where the host runs; this does not replicate desktop device policies to remote hosts. The UI must not reapply the legacy clamp to host-reported choices. Local offers Default, Allow All, and Autopilot, not Assisted. Mode restrictions alone do not disable the Local harness or implement runtime modes in Local; its existing controls remain in effect. Clients retain the legacy guard until a host advertises its approval-policy report. Both standard `approvalMode` and VS Code `autoApprove` reports follow this contract. The report requires a valid approval binding. Under the legacy restriction, discover that binding before creation even without an explicit preference, and seed Manual when the host does not publish its own policy report. Host-owned approval reports are never forwarded as client configuration inputs. Explicit and remembered approval preferences are normalized after schema discovery, before prewarming a backend; they must not be irreversibly clamped before discovery. Defaults are only startup preferences; no additional durable pre-override restoration state is maintained. Restart to apply changed managed settings. A client restart refreshes selectable modes for already-loaded chats even while their SDK sessions remain disconnected, without resuming them or sending a prompt. The last runtime-reported applied mode is not overwritten by this preview.
|
||||
|
||||
**Simple rule:** an explicitly configured valid new mode restriction replaces the legacy blanket mode policy on the supported Copilot Agent Host; otherwise the old policy is the fallback. `defaultMode` alone never removes restrictions.
|
||||
|
||||
**Potential surprise:** scenarios 6 and 8 below allow Allow All despite the old key being false. Setting a new mode restriction opts into the new group, so an unspecified mode is not implicitly restricted by the legacy key. Local is unavailable under managed permissions. Other managed/per-tool restrictions still apply. These rows assume all modes are supported and show selectable modes, not defaults. The legacy JSON is logical policy notation, not an OS-specific deployment format.
|
||||
**Potential surprise:** scenarios 6 and 8 below allow Allow All despite the old key being false. Setting a new mode restriction opts into the new group, so an unspecified mode is not implicitly restricted by the legacy key. These mode settings alone do not disable Local; its eligibility follows the harness rules above. Other managed/per-tool restrictions still apply. These rows assume all modes are supported and show selectable modes, not defaults. The legacy JSON is logical policy notation, not an OS-specific deployment format.
|
||||
|
||||
✅ Allowed; ❌ Blocked.
|
||||
|
||||
@@ -56,12 +58,12 @@ Copilot Agent Host resolves native mode restrictions before applying the legacy
|
||||
|---|---|---|---|:---:|:---:|:---:|
|
||||
| 1 | `{}` | `{}` | Available | ✅ | ✅ | ✅ |
|
||||
| 2 | `{"ChatToolsAutoApprove":false}` | `{}` | Available, legacy restrictions | ❌ | ❌ | ✅ |
|
||||
| 3 | `{}` | `{"permissions":{"disableBypassPermissionsMode":"disable"}}` | Unavailable; migrate to Copilot | ❌ | ✅ | ✅ |
|
||||
| 4 | `{"ChatToolsAutoApprove":false}` | `{"permissions":{"disableBypassPermissionsMode":"disable"}}` | Unavailable; migrate to Copilot | ❌ | ✅ | ✅ |
|
||||
| 5 | `{}` | `{"permissions":{"disableAssistedPermissionsMode":true}}` | Unavailable; migrate to Copilot | ✅ | ❌ | ✅ |
|
||||
| 6 ⚠️ | `{"ChatToolsAutoApprove":false}` | `{"permissions":{"disableAssistedPermissionsMode":true}}` | Unavailable; migrate to Copilot | ✅ | ❌ | ✅ |
|
||||
| 7 | `{"ChatToolsAutoApprove":false}` | `{"permissions":{"disableBypassPermissionsMode":"disable","disableAssistedPermissionsMode":true}}` | Unavailable; migrate to Copilot | ❌ | ❌ | ✅ |
|
||||
| 8 ⚠️ | `{"ChatToolsAutoApprove":false}` | `{"permissions":{"disableBypassPermissionsMode":"enable"}}` | Unavailable; migrate to Copilot | ✅ | ✅ | ✅ |
|
||||
| 3 | `{}` | `{"permissions":{"disableBypassPermissionsMode":"disable"}}` | Normal harness eligibility | ❌ | ✅ | ✅ |
|
||||
| 4 | `{"ChatToolsAutoApprove":false}` | `{"permissions":{"disableBypassPermissionsMode":"disable"}}` | Normal harness eligibility | ❌ | ✅ | ✅ |
|
||||
| 5 | `{}` | `{"permissions":{"disableAssistedPermissionsMode":true}}` | Normal harness eligibility | ✅ | ❌ | ✅ |
|
||||
| 6 ⚠️ | `{"ChatToolsAutoApprove":false}` | `{"permissions":{"disableAssistedPermissionsMode":true}}` | Normal harness eligibility | ✅ | ❌ | ✅ |
|
||||
| 7 | `{"ChatToolsAutoApprove":false}` | `{"permissions":{"disableBypassPermissionsMode":"disable","disableAssistedPermissionsMode":true}}` | Normal harness eligibility | ❌ | ❌ | ✅ |
|
||||
| 8 ⚠️ | `{"ChatToolsAutoApprove":false}` | `{"permissions":{"disableBypassPermissionsMode":"enable"}}` | Normal harness eligibility | ✅ | ✅ | ✅ |
|
||||
|
||||
Host-injected managed settings are startup configuration:
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ All findings are **report-only**. They do not change experiment enrollment, auto
|
||||
|
||||
Harness selection belongs to its existing controls. `ChatEditorPreferCopilotHarness` manages `chat.editor.preferCopilotHarness`, which promotes otherwise-Local new chats to Copilot. It is not a hard lock: false does not require Local or prohibit explicit/remembered Copilot choices. A hard harness mandate would require a separate enforcement contract, not a new reaction to diagnostic uncertainty. Remote hosts, Claude, and Codex are outside this inventory's enforcement scope.
|
||||
|
||||
The temporary managed setting `vscode.temporaryAllowLocalHarness: false` prohibits the Local chat harness without requiring any `permissions.*` or sandbox settings. It exists only while Local remains supported and uses the existing managed-policy routing, Local request blocking, and automatic conversation migration to Copilot. Local is hidden and cannot be selected explicitly or used as a fallback when Copilot is unavailable, including in virtual workspaces. Existing transcripts are preserved by the migration flow; editor inline chat retains its existing exemption. Only effective boolean `false` adds this restriction; true, absence, and malformed values leave existing policy and rollout behavior unchanged. Neither value contributes managed-settings governance for Claude/Codex or other presence-gated features; other managed settings keep their existing effects. This is a managed-settings-only control, watched by native MDM and consumed through `IManagedSettingsService`, not a user/workspace configuration setting. It retains per-key native MDM > server > file precedence.
|
||||
The temporary managed setting `vscode.temporaryAllowLocalHarness: false` prohibits the Local chat harness without requiring any `permissions.*` or sandbox settings. It also disables Claude and Codex through their existing integration policies. It exists only while Local remains supported and uses the existing managed-policy routing, Local request blocking, and automatic conversation migration to Copilot. Local remains visible as a disabled picker option with a policy explanation and learn-more link, and cannot be selected explicitly or used as a fallback when Copilot is unavailable, including in virtual workspaces. Existing transcripts are preserved by the migration flow; editor inline chat retains its existing exemption. Only effective boolean `false` adds these restrictions; true, absence, and malformed values leave existing policy and rollout behavior unchanged. Neither value contributes general managed-settings governance for unrelated presence-gated features; other managed settings keep their existing effects. This is a managed-settings-only control, watched by native MDM and consumed through `IManagedSettingsService` and the Claude/Codex policy projections, not a user/workspace configuration setting. It retains per-key native MDM > server > file precedence.
|
||||
|
||||
The temporary enterprise escape hatch `vscode.temporaryDefaultToLocalHarness: true` projects to the hidden, policy-only `chat.temporaryDefaultToLocalHarness` setting. Unless `vscode.temporaryAllowLocalHarness` is false, it keeps Local visible and usable and suppresses both Copilot-default and Copilot-preference rollout settings, including automatic routing and Local request blocking caused by managed sandbox or permission settings. Explicit, remembered, and current non-Local choices remain unchanged, as do existing conversations. Local cannot enforce the runtime-only sandbox or permission rules; those managed values still apply to Copilot runtime sessions. Only an effective managed/policy `true` enables the escape hatch; ordinary user/workspace settings cannot enable it. False or removal restores normal routing. Managed delivery retains per-key native MDM > server > file precedence. Callers must treat `requiresCopilotAgentHost` as the effective routing decision rather than independently reapplying the escape hatch.
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
run: node build/lib/preLaunch.ts
|
||||
|
||||
- name: Cache Electron
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/electron
|
||||
key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }}
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Cache Playwright
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }}
|
||||
|
||||
@@ -34,6 +34,14 @@ If you change the archive format or flags, update both the `archive` and
|
||||
`extract` branch of the script, and bump `build/.cachesalt` to invalidate
|
||||
existing caches.
|
||||
|
||||
## Warming the caches on `main`
|
||||
|
||||
Pull requests can only restore caches saved on their base branch, so [pr-node-modules.yml](../pr-node-modules.yml) saves the node_modules, Electron, built-in extensions and WSL installer caches on every push to `main`. Most pushes don't change any of them, so the `Detect Missing Caches` job first computes each platform job's cache keys on a GitHub-hosted runner and lists the caches on `main` (see [detect-missing-caches](../../actions/detect-missing-caches/detect.ts)). A platform job only starts its runner when one of its caches is missing, for example after a dependency change or a cache eviction. All platform jobs run when the detection fails.
|
||||
|
||||
The detection mirrors the cache keys of the platform jobs, including the CRLF line endings of the Windows checkouts, and the cache versions that `actions/cache` matches in addition to the key: they depend on the cache paths, including the home directory of the runners for Electron. A cache with another version counts as missing, so the job runs and saves a compatible one; if the detection then keeps warning about a version, update its cache paths. When you change a cache key, path, key prefix, or the jobs of the workflow, update `cacheWarmingJobs` and `getCaches` in `detect.ts`; `build/lib/test/detectMissingCaches.test.ts` fails until they match.
|
||||
|
||||
At most one run of the workflow is in progress, and newer pushes replace its pending run, so concurrent pushes don't install the same missing dependencies several times.
|
||||
|
||||
## Electron download cache
|
||||
|
||||
The platform jobs in [pr-node-modules.yml](../pr-node-modules.yml) also warm a
|
||||
|
||||
@@ -7,9 +7,47 @@ on:
|
||||
|
||||
permissions: {}
|
||||
|
||||
# Warming a missing cache takes up to 15 minutes, and the pushes in the meantime need the same
|
||||
# caches. Allow one running and one pending run: GitHub replaces the pending run with the newest
|
||||
# push, which then skips the caches the running one saved. Don't cancel in-progress runs, or
|
||||
# frequent pushes may keep a slow cache from ever being saved.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Most pushes don't change the dependencies. Each job below only runs when one of the caches
|
||||
# it keeps warm is missing (see .github/actions/detect-missing-caches/detect.ts), for example
|
||||
# after a dependency change or a cache eviction. Jobs also run when the detection fails.
|
||||
caches:
|
||||
name: Detect Missing Caches
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
outputs:
|
||||
compile: ${{ steps.detect.outputs.compile }}
|
||||
linux: ${{ steps.detect.outputs.linux }}
|
||||
macOS: ${{ steps.detect.outputs.macOS }}
|
||||
windows: ${{ steps.detect.outputs.windows }}
|
||||
copilot-linux: ${{ steps.detect.outputs.copilot-linux }}
|
||||
copilot-windows: ${{ steps.detect.outputs.copilot-windows }}
|
||||
steps:
|
||||
- name: Checkout microsoft/vscode
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect missing caches
|
||||
id: detect
|
||||
uses: ./.github/actions/detect-missing-caches
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
compile:
|
||||
name: Compile
|
||||
needs: caches
|
||||
if: ${{ !cancelled() && (needs.caches.result != 'success' || needs.caches.outputs.compile == 'true') }}
|
||||
runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=compile-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ]
|
||||
steps:
|
||||
- name: Checkout microsoft/vscode
|
||||
@@ -79,6 +117,8 @@ jobs:
|
||||
|
||||
linux:
|
||||
name: Linux
|
||||
needs: caches
|
||||
if: ${{ !cancelled() && (needs.caches.result != 'success' || needs.caches.outputs.linux == 'true') }}
|
||||
runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=linux-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ]
|
||||
env:
|
||||
NPM_ARCH: x64
|
||||
@@ -165,6 +205,8 @@ jobs:
|
||||
|
||||
macOS:
|
||||
name: macOS
|
||||
needs: caches
|
||||
if: ${{ !cancelled() && (needs.caches.result != 'success' || needs.caches.outputs.macOS == 'true') }}
|
||||
runs-on: macos-26-xlarge
|
||||
env:
|
||||
NPM_ARCH: arm64
|
||||
@@ -240,6 +282,8 @@ jobs:
|
||||
|
||||
windows:
|
||||
name: Windows
|
||||
needs: caches
|
||||
if: ${{ !cancelled() && (needs.caches.result != 'success' || needs.caches.outputs.windows == 'true') }}
|
||||
runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=windows-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ]
|
||||
env:
|
||||
NPM_ARCH: x64
|
||||
@@ -322,6 +366,8 @@ jobs:
|
||||
|
||||
copilot-linux:
|
||||
name: Copilot (Linux)
|
||||
needs: caches
|
||||
if: ${{ !cancelled() && (needs.caches.result != 'success' || needs.caches.outputs.copilot-linux == 'true') }}
|
||||
runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-linux-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ]
|
||||
steps:
|
||||
- name: Checkout microsoft/vscode
|
||||
@@ -355,6 +401,8 @@ jobs:
|
||||
|
||||
copilot-windows:
|
||||
name: Copilot (Windows)
|
||||
needs: caches
|
||||
if: ${{ !cancelled() && (needs.caches.result != 'success' || needs.caches.outputs.copilot-windows == 'true') }}
|
||||
runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=copilot-windows-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ]
|
||||
steps:
|
||||
- name: Checkout microsoft/vscode
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { dirs } from '../../npm/dirs.ts';
|
||||
import { getCurrentExtensionTarget } from '../../lib/extensionTarget.ts';
|
||||
|
||||
const ROOT = path.join(import.meta.dirname, '../../../');
|
||||
|
||||
/**
|
||||
* Reads a file given its `/`-separated path relative to the repository root.
|
||||
*/
|
||||
export type ReadRepositoryFile = (relativePath: string) => Buffer;
|
||||
|
||||
function readRepositoryFile(relativePath: string): Buffer {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the node_modules cache key from the npm configuration, the dependency sections of
|
||||
* every `package.json`, every `package-lock.json` and the given arguments.
|
||||
*/
|
||||
export function computeNodeModulesCacheKey(args: readonly string[], readFile: ReadRepositoryFile = readRepositoryFile): string {
|
||||
const shasum = crypto.createHash('sha256');
|
||||
|
||||
shasum.update(readFile('build/.cachesalt'));
|
||||
shasum.update(readFile('.npmrc'));
|
||||
shasum.update(readFile('build/.npmrc'));
|
||||
shasum.update(readFile('remote/.npmrc'));
|
||||
|
||||
// Add `package.json` and `package-lock.json` files
|
||||
for (const dir of dirs) {
|
||||
const packageJson = JSON.parse(readFile(path.posix.join(dir, 'package.json')).toString());
|
||||
const relevantPackageJsonSections = {
|
||||
dependencies: packageJson.dependencies,
|
||||
devDependencies: packageJson.devDependencies,
|
||||
optionalDependencies: packageJson.optionalDependencies,
|
||||
resolutions: packageJson.resolutions,
|
||||
distro: packageJson.distro
|
||||
};
|
||||
shasum.update(JSON.stringify(relevantPackageJsonSections));
|
||||
|
||||
shasum.update(readFile(path.posix.join(dir, 'package-lock.json')));
|
||||
}
|
||||
|
||||
for (const arg of args) {
|
||||
shasum.update(arg);
|
||||
}
|
||||
|
||||
return shasum.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the built-in extensions cache key from the built-in extensions in `product.json`.
|
||||
*
|
||||
* @param getTarget Returns the marketplace target platform of the build.
|
||||
*/
|
||||
export function computeBuiltInDepsCacheKey(getTarget: () => string | undefined = getCurrentExtensionTarget, readFile: ReadRepositoryFile = readRepositoryFile): string {
|
||||
const productjson = JSON.parse(readFile('product.json').toString());
|
||||
const shasum = crypto.createHash('sha256');
|
||||
|
||||
// Only fold the build target into the key when at least one built-in extension is platform-specific.
|
||||
// Otherwise the cache stays shared across platforms (as before), so a single platform populates it
|
||||
// and the rest reuse it instead of each re-downloading.
|
||||
const hasPlatformSpecific = (productjson.builtInExtensions as { platformSpecific?: unknown }[]).some(ext => ext.platformSpecific);
|
||||
const target = hasPlatformSpecific ? getTarget() : undefined;
|
||||
if (target) {
|
||||
shasum.update(`target:${target}`);
|
||||
}
|
||||
|
||||
for (const ext of productjson.builtInExtensions) {
|
||||
shasum.update(`${ext.name}@${ext.version}`);
|
||||
if (ext.platformSpecific && target && ext.platformSpecific[target]) {
|
||||
shasum.update(`:${ext.platformSpecific[target]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return shasum.digest('hex');
|
||||
}
|
||||
@@ -3,28 +3,6 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { getCurrentExtensionTarget } from '../../lib/extensionTarget.ts';
|
||||
import { computeBuiltInDepsCacheKey } from './cacheKeys.ts';
|
||||
|
||||
const productjson = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '../../../product.json'), 'utf8'));
|
||||
const shasum = crypto.createHash('sha256');
|
||||
|
||||
// Only fold the build target into the key when at least one built-in extension is platform-specific.
|
||||
// Otherwise the cache stays shared across platforms (as before), so a single platform populates it
|
||||
// and the rest reuse it instead of each re-downloading.
|
||||
const hasPlatformSpecific = (productjson.builtInExtensions as { platformSpecific?: unknown }[]).some(ext => ext.platformSpecific);
|
||||
const target = hasPlatformSpecific ? getCurrentExtensionTarget() : undefined;
|
||||
if (target) {
|
||||
shasum.update(`target:${target}`);
|
||||
}
|
||||
|
||||
for (const ext of productjson.builtInExtensions) {
|
||||
shasum.update(`${ext.name}@${ext.version}`);
|
||||
if (ext.platformSpecific && target && ext.platformSpecific[target]) {
|
||||
shasum.update(`:${ext.platformSpecific[target]}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(shasum.digest('hex'));
|
||||
process.stdout.write(computeBuiltInDepsCacheKey());
|
||||
@@ -2,40 +2,7 @@
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { dirs } from '../../npm/dirs.ts';
|
||||
|
||||
const ROOT = path.join(import.meta.dirname, '../../../');
|
||||
import { computeNodeModulesCacheKey } from './cacheKeys.ts';
|
||||
|
||||
const shasum = crypto.createHash('sha256');
|
||||
|
||||
shasum.update(fs.readFileSync(path.join(ROOT, 'build/.cachesalt')));
|
||||
shasum.update(fs.readFileSync(path.join(ROOT, '.npmrc')));
|
||||
shasum.update(fs.readFileSync(path.join(ROOT, 'build', '.npmrc')));
|
||||
shasum.update(fs.readFileSync(path.join(ROOT, 'remote', '.npmrc')));
|
||||
|
||||
// Add `package.json` and `package-lock.json` files
|
||||
for (const dir of dirs) {
|
||||
const packageJsonPath = path.join(ROOT, dir, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString());
|
||||
const relevantPackageJsonSections = {
|
||||
dependencies: packageJson.dependencies,
|
||||
devDependencies: packageJson.devDependencies,
|
||||
optionalDependencies: packageJson.optionalDependencies,
|
||||
resolutions: packageJson.resolutions,
|
||||
distro: packageJson.distro
|
||||
};
|
||||
shasum.update(JSON.stringify(relevantPackageJsonSections));
|
||||
|
||||
const packageLockPath = path.join(ROOT, dir, 'package-lock.json');
|
||||
shasum.update(fs.readFileSync(packageLockPath));
|
||||
}
|
||||
|
||||
// Add any other command line arguments
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
shasum.update(process.argv[i]);
|
||||
}
|
||||
|
||||
process.stdout.write(shasum.digest('hex'));
|
||||
process.stdout.write(computeNodeModulesCacheKey(process.argv.slice(2)));
|
||||
@@ -352,16 +352,6 @@ extends:
|
||||
VSCODE_CHECK_ONLY: true
|
||||
VSCODE_POOL_NAME: 1es-windows-2022-x64-wus3
|
||||
|
||||
- template: build/azure-pipelines/win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
@@ -370,6 +360,16 @@ extends:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: build/azure-pipelines/win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
@@ -421,16 +421,6 @@ extends:
|
||||
VSCODE_CHECK_ONLY: true
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
|
||||
- template: build/azure-pipelines/linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
@@ -439,6 +429,16 @@ extends:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: build/azure-pipelines/linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
@@ -494,15 +494,6 @@ extends:
|
||||
VSCODE_CHECK_ONLY: true
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
@@ -510,6 +501,15 @@ extends:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: true
|
||||
|
||||
@@ -127,16 +127,6 @@ stages:
|
||||
VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }}
|
||||
|
||||
- ${{ if eq(variables['VSCODE_CIBUILD'], true) }}:
|
||||
- template: win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -145,6 +135,16 @@ stages:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -190,16 +190,6 @@ stages:
|
||||
VSCODE_RUN_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }}
|
||||
# Same test jobs as the Windows CI jobs above
|
||||
- ${{ if eq(parameters.VSCODE_STEP_ON_IT, false) }}:
|
||||
- template: win32/product-build-win32-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: x64
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: win32/product-build-win32-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: x64
|
||||
@@ -208,6 +198,16 @@ stages:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: win32/product-build-win32-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: x64
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: win32/product-build-win32-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: x64
|
||||
@@ -285,16 +285,6 @@ stages:
|
||||
|
||||
# Same test jobs as the Linux CI jobs
|
||||
- ${{ if eq(parameters.VSCODE_STEP_ON_IT, false) }}:
|
||||
- template: linux/product-build-linux-test.yml@self
|
||||
parameters:
|
||||
NPM_ARCH: x64
|
||||
VSCODE_ARCH: x64
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: linux/product-build-linux-test.yml@self
|
||||
parameters:
|
||||
NPM_ARCH: x64
|
||||
@@ -303,6 +293,16 @@ stages:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: linux/product-build-linux-test.yml@self
|
||||
parameters:
|
||||
NPM_ARCH: x64
|
||||
VSCODE_ARCH: x64
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: linux/product-build-linux-test.yml@self
|
||||
parameters:
|
||||
NPM_ARCH: x64
|
||||
@@ -393,16 +393,6 @@ stages:
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
|
||||
- ${{ if eq(variables['VSCODE_CIBUILD'], true) }}:
|
||||
- template: linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -411,6 +401,16 @@ stages:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -487,15 +487,6 @@ stages:
|
||||
VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
|
||||
- template: darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -503,6 +494,15 @@ stages:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -584,16 +584,6 @@ stages:
|
||||
|
||||
# Same test jobs as the macOS CI jobs
|
||||
- ${{ if eq(parameters.VSCODE_STEP_ON_IT, false) }}:
|
||||
- template: darwin/product-build-darwin-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: arm64
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: darwin/product-build-darwin-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: arm64
|
||||
@@ -602,6 +592,16 @@ stages:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: darwin/product-build-darwin-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: arm64
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: darwin/product-build-darwin-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: arm64
|
||||
@@ -756,32 +756,32 @@ stages:
|
||||
container: centos
|
||||
arch: arm64
|
||||
|
||||
# Debian 11
|
||||
# Debian 13
|
||||
- ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}:
|
||||
- template: common/sanity-tests.yml@self
|
||||
parameters:
|
||||
name: debian_11_amd64
|
||||
displayName: Debian 11 amd64
|
||||
name: debian_13_amd64
|
||||
displayName: Debian 13 amd64
|
||||
poolName: 1es-ubuntu-22.04-x64
|
||||
container: debian-11
|
||||
container: debian-13
|
||||
arch: amd64
|
||||
|
||||
- ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}:
|
||||
- template: common/sanity-tests.yml@self
|
||||
parameters:
|
||||
name: debian_11_arm32
|
||||
displayName: Debian 11 arm32
|
||||
name: debian_13_arm32
|
||||
displayName: Debian 13 arm32
|
||||
poolName: 1es-azure-linux-3-arm64
|
||||
container: debian-11
|
||||
container: debian-13
|
||||
arch: arm
|
||||
|
||||
- ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}:
|
||||
- template: common/sanity-tests.yml@self
|
||||
parameters:
|
||||
name: debian_11_arm64
|
||||
displayName: Debian 11 arm64
|
||||
name: debian_13_arm64
|
||||
displayName: Debian 13 arm64
|
||||
poolName: 1es-azure-linux-3-arm64
|
||||
container: debian-11
|
||||
container: debian-13
|
||||
arch: arm64
|
||||
|
||||
# Debian 12
|
||||
|
||||
@@ -369,16 +369,6 @@ extends:
|
||||
VSCODE_POOL_NAME: ${{ variables.VSCODE_WINDOWS_POOL }}
|
||||
|
||||
- ${{ if eq(variables['VSCODE_CIBUILD'], true) }}:
|
||||
- template: build/azure-pipelines/win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -387,6 +377,16 @@ extends:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: build/azure-pipelines/win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/win32/product-build-win32-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -432,16 +432,6 @@ extends:
|
||||
VSCODE_RUN_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }}
|
||||
# Same test jobs as the Windows CI jobs above
|
||||
- ${{ if eq(parameters.VSCODE_STEP_ON_IT, false) }}:
|
||||
- template: build/azure-pipelines/win32/product-build-win32-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: x64
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/win32/product-build-win32-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: x64
|
||||
@@ -450,6 +440,16 @@ extends:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: build/azure-pipelines/win32/product-build-win32-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: x64
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/win32/product-build-win32-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: x64
|
||||
@@ -529,16 +529,6 @@ extends:
|
||||
|
||||
# Same test jobs as the Linux CI jobs
|
||||
- ${{ if eq(parameters.VSCODE_STEP_ON_IT, false) }}:
|
||||
- template: build/azure-pipelines/linux/product-build-linux-test.yml@self
|
||||
parameters:
|
||||
NPM_ARCH: x64
|
||||
VSCODE_ARCH: x64
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/linux/product-build-linux-test.yml@self
|
||||
parameters:
|
||||
NPM_ARCH: x64
|
||||
@@ -547,6 +537,16 @@ extends:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: build/azure-pipelines/linux/product-build-linux-test.yml@self
|
||||
parameters:
|
||||
NPM_ARCH: x64
|
||||
VSCODE_ARCH: x64
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/linux/product-build-linux-test.yml@self
|
||||
parameters:
|
||||
NPM_ARCH: x64
|
||||
@@ -640,16 +640,6 @@ extends:
|
||||
VSCODE_POOL_NAME: ${{ variables.VSCODE_UBUNTU_POOL }}
|
||||
|
||||
- ${{ if eq(variables['VSCODE_CIBUILD'], true) }}:
|
||||
- template: build/azure-pipelines/linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -658,6 +648,16 @@ extends:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: build/azure-pipelines/linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-chromium
|
||||
- copilot
|
||||
- template: build/azure-pipelines/linux/product-build-linux-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -738,15 +738,6 @@ extends:
|
||||
VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }}
|
||||
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -754,6 +745,15 @@ extends:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self
|
||||
parameters:
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
@@ -835,16 +835,6 @@ extends:
|
||||
|
||||
# Same test jobs as the macOS CI jobs
|
||||
- ${{ if eq(parameters.VSCODE_STEP_ON_IT, false) }}:
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: arm64
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: arm64
|
||||
@@ -853,6 +843,16 @@ extends:
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Electron)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-electron
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: arm64
|
||||
VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }}
|
||||
VSCODE_JOB_NAME: Unit
|
||||
VSCODE_JOB_DISPLAY_NAME: Unit Tests (Node.js, Browser & Copilot)
|
||||
VSCODE_TEST_IDS:
|
||||
- unit-node
|
||||
- unit-browser-webkit
|
||||
- copilot
|
||||
- template: build/azure-pipelines/darwin/product-build-darwin-test.yml@self
|
||||
parameters:
|
||||
VSCODE_ARCH: arm64
|
||||
@@ -1039,32 +1039,32 @@ extends:
|
||||
container: centos
|
||||
arch: arm64
|
||||
|
||||
# Debian 11
|
||||
# Debian 13
|
||||
- ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}:
|
||||
- template: build/azure-pipelines/common/sanity-tests.yml@self
|
||||
parameters:
|
||||
name: debian_11_amd64
|
||||
displayName: Debian 11 amd64
|
||||
name: debian_13_amd64
|
||||
displayName: Debian 13 amd64
|
||||
poolName: 1es-ubuntu-22.04-x64
|
||||
container: debian-11
|
||||
container: debian-13
|
||||
arch: amd64
|
||||
|
||||
- ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}:
|
||||
- template: build/azure-pipelines/common/sanity-tests.yml@self
|
||||
parameters:
|
||||
name: debian_11_arm32
|
||||
displayName: Debian 11 arm32
|
||||
name: debian_13_arm32
|
||||
displayName: Debian 13 arm32
|
||||
poolName: 1es-azure-linux-3-arm64
|
||||
container: debian-11
|
||||
container: debian-13
|
||||
arch: arm
|
||||
|
||||
- ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}:
|
||||
- template: build/azure-pipelines/common/sanity-tests.yml@self
|
||||
parameters:
|
||||
name: debian_11_arm64
|
||||
displayName: Debian 11 arm64
|
||||
name: debian_13_arm64
|
||||
displayName: Debian 13 arm64
|
||||
poolName: 1es-azure-linux-3-arm64
|
||||
container: debian-11
|
||||
container: debian-13
|
||||
arch: arm64
|
||||
|
||||
# Debian 12
|
||||
|
||||
@@ -128,29 +128,29 @@ extends:
|
||||
container: centos
|
||||
arch: arm64
|
||||
|
||||
# Debian 11
|
||||
# Debian 13
|
||||
- template: build/azure-pipelines/common/sanity-tests.yml@self
|
||||
parameters:
|
||||
name: debian_11_amd64
|
||||
displayName: Debian 11 amd64
|
||||
name: debian_13_amd64
|
||||
displayName: Debian 13 amd64
|
||||
poolName: 1es-ubuntu-22.04-x64
|
||||
container: debian-11
|
||||
container: debian-13
|
||||
arch: amd64
|
||||
|
||||
- template: build/azure-pipelines/common/sanity-tests.yml@self
|
||||
parameters:
|
||||
name: debian_11_arm32
|
||||
displayName: Debian 11 arm32
|
||||
name: debian_13_arm32
|
||||
displayName: Debian 13 arm32
|
||||
poolName: 1es-azure-linux-3-arm64
|
||||
container: debian-11
|
||||
container: debian-13
|
||||
arch: arm
|
||||
|
||||
- template: build/azure-pipelines/common/sanity-tests.yml@self
|
||||
parameters:
|
||||
name: debian_11_arm64
|
||||
displayName: Debian 11 arm64
|
||||
name: debian_13_arm64
|
||||
displayName: Debian 13 arm64
|
||||
poolName: 1es-azure-linux-3-arm64
|
||||
container: debian-11
|
||||
container: debian-13
|
||||
arch: arm64
|
||||
|
||||
# Debian 12
|
||||
|
||||
@@ -14,6 +14,7 @@ import product from '../product.json' with { type: 'json' };
|
||||
import { getDependencies } from './linux/dependencies-generator.ts';
|
||||
import { recommendedDeps as debianRecommendedDependencies } from './linux/debian/dep-lists.ts';
|
||||
import { recommendedDeps as rpmRecommendedDependencies } from './linux/rpm/dep-lists.ts';
|
||||
import { readFileSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cp from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
@@ -22,6 +23,8 @@ const exec = promisify(cp.exec);
|
||||
const root = path.dirname(import.meta.dirname);
|
||||
const commit = getVersion(root);
|
||||
const linuxDesktopName = product.linuxDesktopName;
|
||||
const copilotLinkHelper = readFileSync(path.join(root, 'resources/linux/copilot-link.sh'), 'utf8');
|
||||
const rpmCopilotLinkHelper = copilotLinkHelper.replace(/%/g, '%%');
|
||||
|
||||
const linuxPackageRevision = Math.floor(new Date().getTime() / 1000);
|
||||
|
||||
@@ -103,10 +106,12 @@ function prepareDebPackage(arch: string) {
|
||||
.pipe(rename('DEBIAN/prerm'));
|
||||
|
||||
const postrm = gulp.src('resources/linux/debian/postrm.template', { base: '.' })
|
||||
.pipe(replace('@@COPILOT_LINK_HELPER@@', () => copilotLinkHelper))
|
||||
.pipe(replace('@@NAME@@', product.applicationName))
|
||||
.pipe(rename('DEBIAN/postrm'));
|
||||
|
||||
const postinst = gulp.src('resources/linux/debian/postinst.template', { base: '.' })
|
||||
.pipe(replace('@@COPILOT_LINK_HELPER@@', () => copilotLinkHelper))
|
||||
.pipe(replace('@@NAME@@', product.applicationName))
|
||||
.pipe(replace('@@ARCHITECTURE@@', debArch))
|
||||
.pipe(rename('DEBIAN/postinst'));
|
||||
@@ -194,6 +199,7 @@ function prepareRpmPackage(arch: string) {
|
||||
.pipe(rename(function (p) { p.dirname = 'BUILD/usr/share/' + product.applicationName + '/' + p.dirname; }));
|
||||
|
||||
const spec = gulp.src('resources/linux/rpm/code.spec.template', { base: '.' })
|
||||
.pipe(replace('@@COPILOT_LINK_HELPER@@', () => rpmCopilotLinkHelper))
|
||||
.pipe(replace('@@NAME@@', product.applicationName))
|
||||
.pipe(replace('@@NAME_LONG@@', product.nameLong))
|
||||
.pipe(replace('@@DESKTOP_NAME@@', linuxDesktopName))
|
||||
|
||||
@@ -513,7 +513,7 @@
|
||||
"localization": {
|
||||
"description": {
|
||||
"key": "chat.copilotCliCommand.enabled.policy",
|
||||
"value": "Controls whether VS Code provides the copilot command for GitHub Copilot CLI. When disabled, VS Code setup on Windows doesn't add the command to PATH and removes the entry it added, VS Code doesn't install GitHub Copilot CLI, and integrated terminals don't include the command. A GitHub Copilot CLI that was installed separately isn't affected."
|
||||
"value": "Controls whether VS Code provides the copilot command for GitHub Copilot CLI. When disabled, VS Code setup on Windows and the Linux DEB and RPM packages don't add the command to PATH and remove the entry they added, VS Code doesn't install GitHub Copilot CLI, and integrated terminals don't include the command. A GitHub Copilot CLI that was installed separately isn't affected."
|
||||
}
|
||||
},
|
||||
"type": "boolean",
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { suite, test } from 'node:test';
|
||||
import { FAILSAFE_SCHEMA, load } from 'js-yaml';
|
||||
import { cacheWarmingJobs, detectMissingCaches, getCaches, type ICacheWarmingJob } from '../../../.github/actions/detect-missing-caches/detect.ts';
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, '../../..');
|
||||
|
||||
interface IStep {
|
||||
readonly uses?: string;
|
||||
readonly run?: string;
|
||||
readonly with?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface IJob {
|
||||
readonly 'runs-on': string | readonly string[];
|
||||
readonly needs?: string;
|
||||
readonly if?: string;
|
||||
readonly env?: Record<string, string>;
|
||||
readonly steps: readonly IStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a workflow or action with all values as strings.
|
||||
*/
|
||||
function loadYaml<T>(relativePath: string): T {
|
||||
return load(readFileSync(path.join(repositoryRoot, relativePath), 'utf8'), { schema: FAILSAFE_SCHEMA }) as T;
|
||||
}
|
||||
|
||||
function loadActionSteps(name: string): readonly IStep[] {
|
||||
return loadYaml<{ runs: { steps: IStep[] } }>(`.github/actions/${name}/action.yml`).runs.steps;
|
||||
}
|
||||
|
||||
function readRepositoryFile(relativePath: string): Buffer {
|
||||
return readFileSync(path.join(repositoryRoot, relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* The runners of the cache warming jobs, as the cache keys see them.
|
||||
*/
|
||||
const runners: Record<string, { platform: string; arch: string; os: ICacheWarmingJob['os'] }> = {
|
||||
'1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64': { platform: 'linux', arch: 'x64', os: 'Linux' },
|
||||
'1ES.Pool=1es-vscode-oss-windows-2022-x64': { platform: 'win32', arch: 'x64', os: 'Windows' },
|
||||
'macos-26-xlarge': { platform: 'darwin', arch: 'arm64', os: 'macOS' },
|
||||
};
|
||||
|
||||
suite('Detect missing caches', () => {
|
||||
test('the jobs match pr-node-modules.yml', () => {
|
||||
const workflow = loadYaml<{ jobs: Record<string, IJob> }>('.github/workflows/pr-node-modules.yml');
|
||||
const jobs: Record<string, ICacheWarmingJob> = {};
|
||||
const gates: Record<string, string | undefined> = {};
|
||||
for (const [name, job] of Object.entries(workflow.jobs)) {
|
||||
if (name === 'caches') {
|
||||
continue;
|
||||
}
|
||||
const runsOn = typeof job['runs-on'] === 'string' ? [job['runs-on']] : job['runs-on'];
|
||||
const runner = runners[runsOn.find(label => runners[label]) ?? ''];
|
||||
assert.ok(runner, `Unknown runner of ${name}: ${runsOn.join(', ')}`);
|
||||
const evaluate = (value: string) => value
|
||||
.replaceAll('${{ env.VSCODE_ARCH }}', job.env?.VSCODE_ARCH ?? '')
|
||||
.replaceAll('$(node -p process.platform)', runner.platform)
|
||||
.replaceAll('$(node -p process.arch)', runner.arch);
|
||||
const step = (uses: string) => job.steps.find(step => step.uses === uses);
|
||||
|
||||
const nodeModules = step('./.github/actions/restore-node-modules')?.with;
|
||||
const electron = step('./.github/actions/setup-electron')?.with;
|
||||
jobs[name] = {
|
||||
os: runner.os,
|
||||
nodeModules: { prefix: nodeModules?.['key-prefix'] ?? '', args: evaluate(nodeModules?.['key-args'] ?? '').split(/\s+/) },
|
||||
...electron ? { electron: { arch: evaluate(electron.arch) } } : {},
|
||||
...job.steps.some(step => step.with?.key?.startsWith('builtin-extensions-')) ? { builtInExtensions: { target: `${runner.platform}-${runner.arch}` } } : {},
|
||||
...step('./.github/actions/restore-wsl-installer') ? { wslInstaller: true } : {},
|
||||
};
|
||||
gates[name] = `${job.needs} ${job.if}`;
|
||||
}
|
||||
|
||||
assert.deepStrictEqual({ jobs, gates }, {
|
||||
jobs: cacheWarmingJobs,
|
||||
gates: Object.fromEntries(Object.keys(cacheWarmingJobs).map(name =>
|
||||
[name, `caches \${{ !cancelled() && (needs.caches.result != 'success' || needs.caches.outputs.${name} == 'true') }}`])),
|
||||
});
|
||||
});
|
||||
|
||||
test('the action and the caches job forward an output for each job', () => {
|
||||
// The runner only loads the action when the workflow runs on main, so parse it here.
|
||||
const action = loadYaml<{ outputs: Record<string, { value: string }> }>('.github/actions/detect-missing-caches/action.yml');
|
||||
const workflow = loadYaml<{ jobs: { caches: { outputs: Record<string, string> } } }>('.github/workflows/pr-node-modules.yml');
|
||||
const expected = Object.fromEntries(Object.keys(cacheWarmingJobs).map(name => [name, `\${{ steps.detect.outputs.${name} }}`]));
|
||||
assert.deepStrictEqual({
|
||||
action: Object.fromEntries(Object.entries(action.outputs).map(([name, output]) => [name, output.value])),
|
||||
workflow: workflow.jobs.caches.outputs,
|
||||
}, { action: expected, workflow: expected });
|
||||
});
|
||||
|
||||
test('the cache keys and versions match the actions that save them', () => {
|
||||
// The cache keys and versions depend on these keys, paths, commands and cross-OS settings.
|
||||
// When they change, update getCaches in .github/actions/detect-missing-caches/detect.ts to match.
|
||||
const caches = (steps: readonly IStep[]) => steps
|
||||
.filter(step => step.uses?.startsWith('actions/cache'))
|
||||
.map(step => ({ key: step.with?.key, path: step.with?.path, enableCrossOsArchive: step.with?.enableCrossOsArchive }));
|
||||
const commands = (steps: readonly IStep[], pattern: RegExp) => steps.flatMap(step => step.run?.split('\n').filter(line => pattern.test(line)).map(line => line.trim()) ?? []);
|
||||
const compile = loadYaml<{ jobs: Record<string, IJob> }>('.github/workflows/pr-node-modules.yml').jobs.compile.steps;
|
||||
assert.deepStrictEqual({
|
||||
nodeModules: [...caches(loadActionSteps('restore-node-modules')), ...caches(loadActionSteps('save-node-modules')), ...commands(loadActionSteps('restore-node-modules'), /computeNodeModulesCacheKey/)],
|
||||
electron: [...caches(loadActionSteps('setup-electron')), ...commands(loadActionSteps('setup-electron'), /ELECTRON_CACHE_PATH=|\$cacheRoot =/)],
|
||||
builtInExtensions: [...caches(compile), ...commands(compile, /computeBuiltInDepsCacheKey/)],
|
||||
wslInstaller: caches(loadActionSteps('restore-wsl-installer')),
|
||||
}, {
|
||||
nodeModules: [
|
||||
{ key: '${{ inputs.key-prefix }}-${{ hashFiles(\'.build/packagelockhash\') }}', path: '.build/node_modules_cache', enableCrossOsArchive: undefined },
|
||||
{ key: '${{ env.NODE_MODULES_CACHE_KEY }}', path: '.build/node_modules_cache', enableCrossOsArchive: undefined },
|
||||
'mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts ${{ inputs.key-args }} > .build/packagelockhash',
|
||||
'node build/azure-pipelines/common/computeNodeModulesCacheKey.ts ${{ inputs.key-args }} > .build/packagelockhash',
|
||||
],
|
||||
electron: [
|
||||
{ key: 'electron-download-v1-${{ runner.os }}-${{ inputs.arch }}-${{ hashFiles(\'.npmrc\', \'build/checksums/electron.txt\') }}', path: '${{ env.ELECTRON_CACHE_PATH }}', enableCrossOsArchive: undefined },
|
||||
{ key: '${{ steps.restore.outputs.cache-primary-key }}', path: '${{ env.ELECTRON_CACHE_PATH }}', enableCrossOsArchive: undefined },
|
||||
'echo "ELECTRON_CACHE_PATH=$HOME/Library/Caches/electron" >> "$GITHUB_ENV"',
|
||||
'echo "ELECTRON_CACHE_PATH=${XDG_CACHE_HOME:-$HOME/.cache}/electron" >> "$GITHUB_ENV"',
|
||||
'$cacheRoot = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { Join-Path $HOME \'AppData/Local\' }',
|
||||
'Add-Content -Path $env:GITHUB_ENV -Value "ELECTRON_CACHE_PATH=$(Join-Path $cacheRoot \'electron/Cache\')"',
|
||||
],
|
||||
builtInExtensions: [
|
||||
{ key: 'builtin-extensions-${{ hashFiles(\'.build/builtindepshash\') }}', path: '.build/builtInExtensions', enableCrossOsArchive: 'true' },
|
||||
'node build/azure-pipelines/common/computeBuiltInDepsCacheKey.ts > .build/builtindepshash',
|
||||
],
|
||||
wslInstaller: [
|
||||
{ key: 'wsl-installer-v1-${{ runner.os }}-${{ hashFiles(\'build/checksums/wsl.txt\') }}', path: '.build/wsl-installer', enableCrossOsArchive: undefined },
|
||||
{ key: '${{ steps.restore.outputs.cache-primary-key }}', path: '.build/wsl-installer', enableCrossOsArchive: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('jobs run when one of their caches is missing, has another version or cannot be listed', async () => {
|
||||
const [missing, incompatible] = [getCaches(cacheWarmingJobs.windows, readRepositoryFile)[1], getCaches(cacheWarmingJobs.macOS, readRepositoryFile)[1]];
|
||||
const existing = Object.values(cacheWarmingJobs)
|
||||
.flatMap(job => getCaches(job, readRepositoryFile))
|
||||
.filter(cache => cache.key !== missing.key)
|
||||
.map(cache => cache.key === incompatible.key ? { ...cache, version: 'other' } : cache);
|
||||
|
||||
const detect = async (listCaches: (key: string) => { key: string; version: string }[]) => {
|
||||
const outputs: Record<string, string> = {};
|
||||
await detectMissingCaches({
|
||||
rest: {
|
||||
actions: {
|
||||
getActionsCacheList: async parameters => {
|
||||
assert.deepStrictEqual([parameters.ref, parameters.per_page], ['refs/heads/main', 100]);
|
||||
return { data: { actions_caches: listCaches(parameters.key) } };
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
repo: { owner: 'microsoft', repo: 'vscode' },
|
||||
ref: 'refs/heads/main',
|
||||
}, {
|
||||
info: () => { },
|
||||
warning: () => { },
|
||||
setOutput: (name, value) => outputs[name] = value,
|
||||
}, { lf: readRepositoryFile, crlf: readRepositoryFile });
|
||||
return outputs;
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(await Promise.all([
|
||||
// Listing by key prefix also returns longer keys.
|
||||
detect(key => existing.filter(cache => cache.key.startsWith(key)).concat({ key: `${key}-other`, version: 'other' })),
|
||||
detect(() => { throw new Error('Not available'); }),
|
||||
]), [
|
||||
{ 'compile': 'false', 'linux': 'false', 'macOS': 'true', 'windows': 'true', 'copilot-linux': 'false', 'copilot-windows': 'false' },
|
||||
{ 'compile': 'true', 'linux': 'true', 'macOS': 'true', 'windows': 'true', 'copilot-linux': 'true', 'copilot-windows': 'true' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -17,15 +17,15 @@ import {
|
||||
} from '../../dictation-runtime/nuget.ts';
|
||||
|
||||
const dependencies: IFoundryDependencyVersions = {
|
||||
onnxruntime: { version: '1.30.0' },
|
||||
'onnxruntime-genai': { version: '0.17.1' },
|
||||
onnxruntime: { version: '1.28.0' },
|
||||
'onnxruntime-genai': { version: '0.15.2' },
|
||||
};
|
||||
|
||||
suite('dictation runtime', () => {
|
||||
test('uses the Foundry Local 2.x dependency artifact set', () => {
|
||||
assert.deepStrictEqual(getStandardArtifacts(dependencies), [
|
||||
{ name: 'Microsoft.ML.OnnxRuntime', version: '1.30.0' },
|
||||
{ name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: '0.17.1' },
|
||||
{ name: 'Microsoft.ML.OnnxRuntime', version: '1.28.0' },
|
||||
{ name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: '0.15.2' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ suite('dictation runtime', () => {
|
||||
}
|
||||
await assert.rejects(
|
||||
fetchDependencyLibraries(target, getStandardArtifacts(dependencies), targetDirectory, { feeds: [] }),
|
||||
/Failed to download Microsoft\.ML\.OnnxRuntime 1\.30\.0 from any feed/,
|
||||
/Failed to download Microsoft\.ML\.OnnxRuntime 1\.28\.0 from any feed/,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -349,8 +349,8 @@ suite('Product test checkpoint templates', () => {
|
||||
testIdsType: 'object',
|
||||
jobDisplayName: '${{ parameters.VSCODE_JOB_DISPLAY_NAME }}',
|
||||
displayNames: [
|
||||
{ name: 'Unit', displayName: 'Unit Tests' },
|
||||
{ name: 'UnitElectron', displayName: 'Unit Tests (Electron)' },
|
||||
{ name: 'Unit', displayName: 'Unit Tests (Node.js, Browser & Copilot)' },
|
||||
{ name: 'BrowserRemote', displayName: 'Browser & Remote Tests' },
|
||||
{ name: 'Integration', displayName: 'Integration Tests (Electron)' },
|
||||
{ name: 'AgentHostE2E', displayName: 'Agent Host E2E Tests (Electron)' },
|
||||
@@ -466,7 +466,7 @@ suite('Product test checkpoint templates', () => {
|
||||
.map(parameter => parameter.name).filter(name => /^VSCODE_RUN_\w+_TESTS$/.test(name)),
|
||||
};
|
||||
});
|
||||
const jobDisplayNames = ['Unit Tests', 'Unit Tests (Electron)', 'Browser & Remote Tests', 'Integration Tests (Electron)', 'Agent Host E2E Tests (Electron)', 'Smoke Tests (Electron)'];
|
||||
const jobDisplayNames = ['Unit Tests (Electron)', 'Unit Tests (Node.js, Browser & Copilot)', 'Browser & Remote Tests', 'Integration Tests (Electron)', 'Agent Host E2E Tests (Electron)', 'Smoke Tests (Electron)'];
|
||||
const copilotCondition = '${{ if containsValue(parameters.VSCODE_TEST_IDS, \'copilot\') }}';
|
||||
const remoteNodeCondition = '${{ if or(containsValue(parameters.VSCODE_TEST_IDS, \'integration-remote\'), containsValue(parameters.VSCODE_TEST_IDS, \'smoke-remote\')) }}';
|
||||
const runTests = '${{ eq(parameters.VSCODE_STEP_ON_IT, false) }}';
|
||||
|
||||
Generated
+104
-1
@@ -16,13 +16,21 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "copilot_shim"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -50,6 +58,12 @@ dependencies = [
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -62,12 +76,36 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
@@ -87,6 +125,59 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -100,6 +191,12 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
@@ -114,3 +211,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "copilot_shim"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
autobins = false
|
||||
|
||||
@@ -24,3 +24,12 @@ libc = "0.2.144"
|
||||
|
||||
[target."cfg(windows)".dependencies]
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Networking_WinHttp", "Win32_Security", "Win32_Security_Cryptography", "Win32_Security_Cryptography_Catalog", "Win32_Security_Cryptography_Sip", "Win32_Security_WinTrust", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_Environment", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
serde_json = "1.0.149"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation-sys = "0.8.7"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1.0.149"
|
||||
+101
-35
@@ -112,6 +112,25 @@ usable after a failed refresh, but the extension does not advertise a folder
|
||||
with surviving legacy wrappers that could shadow the native executable.
|
||||
The terminal link provider and Copilot session data are unchanged.
|
||||
|
||||
### Terminal PATH locality and recovery
|
||||
|
||||
Authority matching follows terminal backend routing and is case-insensitive. Variable names follow the terminal's OS: Windows treats all PATH spellings alike; on macOS and Linux only `PATH` is subject to locality filtering, while `Path` and `path` remain distinct ordinary variables.
|
||||
|
||||
The extension's terminal PATH contribution is applied only to terminals on the
|
||||
same computer as the producing extension host. A local contribution applies to
|
||||
local terminals, including local terminals in remote windows; a remote
|
||||
contribution applies only to terminals on that same remote authority. Other
|
||||
environment variables retain their existing cross-host sharing behavior, and
|
||||
the terminal's base PATH and independently installed commands are not changed.
|
||||
|
||||
Saved PATH contributions with an unknown origin, or an origin on another
|
||||
computer, are absent from the extension's initial cached collection until the
|
||||
extension refreshes them. Correctly tagged same-computer saved contributions
|
||||
remain usable before activation. This cache migration is distinct from remote
|
||||
process recovery: a recovered pre-fix remote process retains its complete
|
||||
historical environment until normal relaunch, when the current filtered
|
||||
contribution is applied.
|
||||
|
||||
### Ownership
|
||||
|
||||
VS Code owns only the shim, the launcher that gets Copilot CLI onto the device.
|
||||
@@ -120,6 +139,30 @@ CLI's files, its `PATH` entry, its updates, its uninstallation, and its data.
|
||||
The shim installs Copilot CLI only when there is none, never updates it, and
|
||||
uninstalling VS Code never removes it or its data (`~/.copilot`).
|
||||
|
||||
### `copilot` on PATH in Linux DEB/RPM packages
|
||||
|
||||
Stable and Insiders packages expose their existing `bin/copilot-shim/copilot` through a lifecycle-managed `/usr/bin/copilot` symlink. Package installation does not download or install the real Copilot CLI, modify shell profiles, or change the existing `code` command. The system command directory must already be on the user's `PATH`.
|
||||
|
||||
The shared link is not an ordinary package payload file or an RPM `%ghost` entry. Each edition owns its nested shim, while the [shared lifecycle helper](../resources/linux/copilot-link.sh) guards changes to the shared command:
|
||||
|
||||
- With no command entry, installation creates a link to its own usable shim.
|
||||
- Only links whose literal targets are `/usr/share/code/bin/copilot-shim/copilot` or `/usr/share/code-insiders/bin/copilot-shim/copilot` are recognized as VS Code-owned. An installation or upgrade takes over either recognized link, including a dangling one. The last processed installation/upgrade wins, not necessarily the newest shim version.
|
||||
- Foreign executables, directories, unknown links, and relative or indirect links remain unchanged, even when a relative or indirect link ultimately resolves to a VS Code shim.
|
||||
- Removing the owning edition repoints to the other edition's regular executable shim when available; otherwise it removes only its own link. Removing a non-owner leaves the entry alone. Old-version removal during an upgrade must not undo the new selection.
|
||||
- Inspection or mutation failures produce a warning without failing the editor package operation. A failed final removal may leave a broken recognized link; a later installation can repair it. This is not a successful-cleanup result.
|
||||
|
||||
Every installation and upgrade, and every final removal, applies the [`CopilotCliCommand` policy](#enterprise-policy) and the edition's choice, in this order:
|
||||
|
||||
1. The scripts run [`copilot --vscode-shim policy`](#policy) with the edition's own shim; removal uses the other edition's shim because its own is already gone. When it reports the policy disabled, they remove a link to either edition's shim, so no `copilot` command remains. On Linux both editions read the same `/etc/vscode/policy.json`.
|
||||
2. A DEB edition whose `<package>/add-copilot-command` debconf answer is `false` doesn't create or take over the link and gives up a link to its own shim. The question defaults to `true` and is asked at medium priority, so the default applies unless an administrator preseeds the answer, for example with `echo "code code/add-copilot-command boolean false" | sudo debconf-set-selections`, or `dpkg-reconfigure` asks it. RPM has no equivalent choice.
|
||||
3. Otherwise installation creates or takes over the link as above. When the policy can't be read, for example because the shim can't run, installation warns and leaves the entry unchanged rather than create a link.
|
||||
|
||||
Giving up or removing a link to the current shim hands it to the other edition only when that edition's shim is usable, its debconf answer isn't `false`, and its shim reports that the policy allows the command; otherwise the link is removed.
|
||||
|
||||
The scripts apply a changed policy or answer at the next upgrade. To apply it sooner, run `sudo dpkg-reconfigure code` (or `code-insiders`), which also asks the question again, or reinstall the RPM.
|
||||
|
||||
This command ownership applies only to `code` and `code-insiders`, not OSS or Exploration. Snap aliases, archive exposure/layout, third-party recipes, and special handling for image-based Fedora systems remain outside this work. Shared Linux runtime and policy changes still apply to binaries included in other existing payloads.
|
||||
|
||||
### macOS Shell Command installation
|
||||
|
||||
On macOS, the Shell Command action in the Command Palette offers a localized Yes/No choice to also install GitHub Copilot CLI, with Yes initially active:
|
||||
@@ -233,8 +276,8 @@ The implementation does not:
|
||||
- register a VS Code terminal profile.
|
||||
- replace `CopilotCLITerminalLinkProvider`.
|
||||
- modify the existing `cli/` Cargo package.
|
||||
- add the shim to `PATH` outside integrated terminals on macOS and Linux, apart
|
||||
from WSL shells, which use the Windows `PATH`.
|
||||
- add the shim to `PATH` outside integrated terminals on macOS.
|
||||
- add Snap command aliases, change archive command exposure/layout, or manage third-party and image-based Linux packaging.
|
||||
- ship the shim in the web server builds (`vscode-server-*-web`).
|
||||
|
||||
## Normative language
|
||||
@@ -279,7 +322,7 @@ consumes an argument meant for the Copilot CLI:
|
||||
|
||||
```text
|
||||
copilot [--vscode-shim <modifier>]... [--] [copilot arguments...]
|
||||
copilot [--vscode-shim verbose]... --vscode-shim <probe|install> [command options...]
|
||||
copilot [--vscode-shim verbose]... --vscode-shim <probe|install|policy> [command options...]
|
||||
```
|
||||
|
||||
- Only leading `--vscode-shim <name>` pairs are options. Parsing stops at the
|
||||
@@ -806,21 +849,15 @@ The official script tests `command -v copilot` after installation. A shim
|
||||
visible to that command would suppress the missing-PATH notice and profile
|
||||
prompt even when the newly installed CLI is not on PATH.
|
||||
|
||||
Only the Bash command executing that script MUST receive a PATH with every
|
||||
positively identified current, copied, aliased, and legacy shim directory
|
||||
excluded. Preserve unrelated entries, order, and native strings; handle
|
||||
relative/empty entries and aliases without substring matching. Reject an
|
||||
entirely excluded PATH rather than accidentally searching the current
|
||||
directory. Leave the parent environment, downloader commands, Homebrew,
|
||||
Windows MSI, host probes, and final CLI environment unchanged.
|
||||
On Linux the Bash command executing the script MUST inherit the original PATH without an override, preserving native bytes, order, and duplicates. Filtering a system-directory shim would also remove utilities from `/usr/bin` and, on merged-`/usr` systems, `/bin`. Real-CLI discovery still rejects the running shim, other Rust shim copies, and recognized legacy shims.
|
||||
|
||||
Do not add the predicted installation directory to this child PATH. The
|
||||
official script must decide whether the real CLI is accessible and, when
|
||||
needed, offer its own profile update. It selects the profile from SHELL and
|
||||
existing configuration (for example `.profile`, `.bash_profile`, `.zprofile`,
|
||||
or fish configuration); neither Rust nor VS Code duplicates this prompt or
|
||||
edits/sources profiles itself. The directory persisted on consent is the real
|
||||
CLI's installation directory, not extension global storage.
|
||||
On macOS only the Bash command executing the script receives a PATH with every positively identified current, copied, aliased, and legacy shim directory excluded. Preserve unrelated entries, order, and native strings; handle relative/empty entries and aliases without substring matching. Reject an entirely excluded PATH rather than accidentally searching the current directory.
|
||||
|
||||
Leave the parent environment, downloader commands, Homebrew, Windows MSI, host probes, and final CLI environment unchanged on both platforms. Do not add the predicted installation directory to the script's child PATH.
|
||||
|
||||
On Linux a visible shim may cause the script to skip its profile prompt. The shim must still find a successfully installed real CLI through the official-install-directory fallback, immediately and on subsequent invocations. Uninstalling VS Code can remove this route while leaving the real CLI installed; that CLI may then need its own PATH entry. A custom prefix must remain discoverable through PATH or the applicable fallback environment on later invocations.
|
||||
|
||||
When the script does offer a profile update, it selects the profile from SHELL and existing configuration (for example `.profile`, `.bash_profile`, `.zprofile`, or fish configuration). Neither Rust nor VS Code duplicates this prompt or edits/sources profiles itself. The directory persisted on consent is the real CLI's installation directory, not extension global storage.
|
||||
|
||||
Profile updates affect future shells that read that profile. They do not
|
||||
change the running terminal's or shim's PATH. Declining the profile edit does
|
||||
@@ -889,7 +926,7 @@ directly; a tool that ends `copilot` by its process ID ends the CLI.
|
||||
| Installer tools are missing | Print which tools to install and the documentation URL; exit `1`. |
|
||||
| All installation attempts fail | Print that the install failed, the documentation URL, and how to turn on verbose mode; exit `1`. The Windows MSI install first prints its reason. Each attempt's result is a diagnostic. |
|
||||
| Installer returns zero but re-discovery fails | Print actionable PATH/installation guidance; exit `1`. |
|
||||
| The Unix installation location or installer-only PATH cannot be prepared | Print an actionable explanation; exit `1`. |
|
||||
| The Unix installation location or macOS installer-only PATH cannot be prepared | Print an actionable explanation; exit `1`. |
|
||||
| Automatic installation is unsupported for the target | Print manual-install or upstream-support guidance; exit `1` without an installer attempt. |
|
||||
| Bootstrap or installer operation is canceled | Wait for the installer to stop, without fallback, and clean up owned temporary files; exit `130`. |
|
||||
| User declines installation | Exit `0` without launching. |
|
||||
@@ -916,15 +953,15 @@ own output are neither, and are always visible.
|
||||
|
||||
## Enterprise policy
|
||||
|
||||
The VS Code policy `CopilotCliCommand` controls the core setting
|
||||
`chat.copilotCliCommand.enabled`. When it is disabled:
|
||||
The VS Code policy `CopilotCliCommand` controls the core setting `chat.copilotCliCommand.enabled`. The native shim discovers an eligible installed CLI, including its supported installation-directory fallback, before evaluating installation policy. When the policy is disabled:
|
||||
|
||||
- the shim still launches a Copilot CLI that is already installed, but never
|
||||
installs one. It prints a message that names the policy and exits with `10`;
|
||||
- an already-installed eligible CLI still launches; when no CLI is available, the shim prints a message naming the policy and exits with `10` without offering installation;
|
||||
- `probe` reports `policy=disabled` and skips its network check, and `install`
|
||||
reports the `policy` status;
|
||||
- VS Code setup on Windows doesn't show its Copilot CLI page, doesn't publish
|
||||
the shim, and removes the `PATH` entry it added; and
|
||||
the shim, and removes the `PATH` entry it added;
|
||||
- the Linux DEB/RPM scripts don't create `/usr/bin/copilot` and remove a link
|
||||
to either edition's shim (see [`copilot` on PATH in Linux DEB/RPM packages](#copilot-on-path-in-linux-debrpm-packages)); and
|
||||
- the Copilot extension doesn't add the shim to integrated terminals, which run
|
||||
`copilot` from `PATH` instead.
|
||||
|
||||
@@ -936,7 +973,11 @@ quality, the `HKLM` value takes precedence over the `HKCU` value, as in VS Code.
|
||||
A disabled policy in any quality wins, because the shim of any installed quality
|
||||
can be the one on `PATH`; VS Code setup applies the same rule.
|
||||
|
||||
On macOS and Linux the shim doesn't read the policy. It is reachable from integrated terminals there, where the Copilot extension applies the policy through the setting; the macOS workbench Shell Command action also applies that effective setting before advertising or invoking its install-only shim route.
|
||||
On Linux the native shim reads `/etc/vscode/policy.json`. Only an object member named `CopilotCliCommand` with the JSON boolean value `false` disables installation. Missing or unreadable files, invalid JSON, non-object roots, missing members, `true`, and other value types are non-disabling. The reader is used on each fresh invocation that needs an installation decision, with no cache or production policy-path override. The setup `probe`, `install`, and `policy` commands consume the same result.
|
||||
|
||||
The Linux shim doesn't read an ordinary per-user VS Code setting. The DEB/RPM scripts use its `policy` command to manage the system link; a link that predates the policy remains until the next upgrade or reconfiguration, and the shim then still refuses installation. The Copilot extension still controls integrated-terminal exposure through the setting.
|
||||
|
||||
On macOS the native shim reads the effective `CopilotCliCommand` preference with `CFPreferencesCopyAppValue` in the `com.microsoft.VSCode`, `com.microsoft.VSCodeInsiders`, `com.microsoft.VSCodeExploration`, and `com.visualstudio.code.oss` domains, which configuration profiles manage. Only a `CFBoolean` `false` disables installation; missing values and other types are non-disabling. As on Windows, a disabled policy in any quality wins. The reader matches VS Code's scalar policy semantics and doesn't require `CFPreferencesAppValueIsForced`. The Copilot extension also applies the policy through the effective setting for integrated terminals, and the macOS workbench Shell Command action applies that setting before advertising or invoking its install-only shim route; the shim enforces the policy again when that route runs.
|
||||
|
||||
`CopilotCliCommand` is the only local control over the shim and installation.
|
||||
It doesn't turn Copilot CLI itself on or off: that's a GitHub organization or
|
||||
@@ -944,7 +985,8 @@ enterprise policy, which the CLI applies after sign-in.
|
||||
|
||||
## Setup commands
|
||||
|
||||
The Windows installer runs these commands; they never launch the Copilot CLI.
|
||||
The Windows installer runs `probe` and `install`, and the Linux DEB/RPM scripts
|
||||
run `policy`; they never launch the Copilot CLI.
|
||||
Result files are INI files encoded as UTF-16LE with a byte order mark, so
|
||||
`GetPrivateProfileString` reads them on every Windows version. Values are
|
||||
single-line, and each file is replaced atomically.
|
||||
@@ -984,7 +1026,7 @@ copilot --vscode-shim install --non-interactive --consent=installer
|
||||
`install` runs the Windows installation described under
|
||||
[Installation commands](#installation-commands), including its result-file status contract. On macOS x64/arm64 only, `copilot --vscode-shim install --interactive` is an install-only route for the workbench action after it has collected consent: it discovers an existing CLI first without executing, probing, updating, or reinstalling it; otherwise it uses the normal Homebrew-then-temporary-file official-script routes, rediscovery, and installer-owned prompts. It never launches the discovered CLI. Linux and macOS noninteractive setup semantics are unchanged, including their existing-CLI reporting; only macOS interactive mode gains this route.
|
||||
|
||||
The macOS interactive install-only route exits `0` after an initial discovery finds an existing usable CLI or post-install rediscovery finds a newly installed one. The existing discovery checks require an eligible executable file and exclude the running, copied, aliased, and legacy shims; they do not execute, version-probe, or canonicalize the discovered `candidate.path`. On stderr, initial discovery prints `GitHub Copilot CLI is already installed.` and post-install rediscovery prints `GitHub Copilot CLI installed successfully.`, each followed by `Location: <discovered absolute candidate.path>`. It exits immediately without launching the CLI. It exits `1` for discovery or installer failures, `127` when a missing CLI cannot be installed without an interactive terminal, and `130` for handled cancellation. These are not Windows setup result-file statuses. On macOS, the [workbench action](#macos-shell-command-installation) checks the effective Copilot CLI setting/policy before starting the shim; the native macOS shim does not read that policy or return a policy-denial exit code.
|
||||
The macOS interactive install-only route exits `0` after an initial discovery finds an existing usable CLI or post-install rediscovery finds a newly installed one. The existing discovery checks require an eligible executable file and exclude the running, copied, aliased, and legacy shims; they do not execute, version-probe, or canonicalize the discovered `candidate.path`. On stderr, initial discovery prints `GitHub Copilot CLI is already installed.` and post-install rediscovery prints `GitHub Copilot CLI installed successfully.`, each followed by `Location: <discovered absolute candidate.path>`. It exits immediately without launching the CLI. It exits `1` for discovery or installer failures, `10` when a missing CLI can't be installed because the `CopilotCliCommand` policy is disabled, `127` when a missing CLI cannot be installed without an interactive terminal, and `130` for handled cancellation. These are not Windows setup result-file statuses. On macOS, the [workbench action](#macos-shell-command-installation) checks the effective Copilot CLI setting/policy before starting the shim, and the shim reads the policy again before installing.
|
||||
|
||||
`--non-interactive` requires `--consent=installer`, the consent that setup
|
||||
collected on its page or command line. In that mode the command:
|
||||
@@ -1008,6 +1050,20 @@ collected on its page or command line. In that mode the command:
|
||||
| `cancelled` | `50` |
|
||||
| `unsupported`, `error` | `1` |
|
||||
|
||||
### `policy`
|
||||
|
||||
```text
|
||||
copilot --vscode-shim policy
|
||||
```
|
||||
|
||||
`policy` reads the [`CopilotCliCommand` policy](#enterprise-policy) with the
|
||||
same platform reader as the launch workflow and reports it only through its
|
||||
exit code: `0` when the policy allows the command and `10` (the `policy`
|
||||
status) when it turns the command off. It takes no options, writes nothing
|
||||
except a verbose-mode diagnostic, never searches for or launches a Copilot CLI,
|
||||
and doesn't read standard input. Callers treat any other exit code, including
|
||||
`2` from an older shim without this command, as an unknown policy.
|
||||
|
||||
## Cargo package requirements
|
||||
|
||||
`copilot_shim/` is an independent Cargo package with:
|
||||
@@ -1192,12 +1248,9 @@ fixtures.
|
||||
- Successful install not visible in current PATH.
|
||||
- Official-script default/root/custom/relative PREFIX resolution, invalid
|
||||
fallback files, and original-PATH precedence.
|
||||
- Installer-only exclusion of all shim entries without affecting downloader,
|
||||
Homebrew, MSI, ordinary discovery, or final CLI environments.
|
||||
- Isolated Unix PTY installation with both profile-consent answers, actual
|
||||
`command -v` checking, immediate verified launch, and no reinstall on a
|
||||
second invocation with unchanged PATH. Use temporary HOME/PREFIX and fake
|
||||
installers; never modify the developer's real profile.
|
||||
- Production Linux installer dispatch inherits PATH unchanged for curl and wget, including directories containing both shims and tools, duplicate entries, and merged-directory aliases.
|
||||
- macOS installer-only exclusion of all shim entries without affecting downloader, Homebrew, MSI, ordinary discovery, or final CLI environments.
|
||||
- Isolated macOS PTY installation with both profile-consent answers, and Linux PTY installation where visible shims suppress profile consent. Both verify immediate launch and no reinstall on a second invocation with unchanged PATH. Use temporary HOME/PREFIX and fake installers; never modify the developer's real profile.
|
||||
- No repeated install prompt in one invocation.
|
||||
- Ctrl+C/handled termination during installation cancels without fallback,
|
||||
another prompt, or final CLI launch; the shim waits for the installer to stop.
|
||||
@@ -1229,6 +1282,19 @@ fixtures.
|
||||
policy message and exits with code `10` without a prompt; an installed CLI
|
||||
still launches.
|
||||
- Policy registry values are read only from `REG_DWORD` values.
|
||||
- Linux parser/reader tests cover strict-false, true, missing, unreadable, malformed, non-object, and wrong-type data through temporary paths. Installed CLI discovery must precede the launch workflow's policy read.
|
||||
- macOS reader tests cover `false`, `true`, missing, numeric, and string values through an injected copier, release every copied value, stop at the first disabling domain, and read a never-written domain without writing product-domain preferences.
|
||||
- The `policy` command exits `0` or `10` from the runtime's policy without prompting, searching, or launching, and rejects options.
|
||||
- A separately selected native Linux policy-dispatch test runs only in an explicitly provisioned disposable environment. It refuses existing policy files, verifies the real runtime's policy message/exit and installed-CLI launch, and cleans only its own fixture. Ordinary tests never modify the host policy file.
|
||||
|
||||
### Linux package lifecycle
|
||||
|
||||
- Real DEB/RPM transactions cover both edition roles, fresh installation, coexistence, upgrades in both orders, non-owner removal, usable fallback, last-owner removal, and recognized dangling-link repair.
|
||||
- Foreign regular files, valid/broken symlinks, and relative/indirect links survive installation, upgrades, and removal. The shared path is absent from package file ownership; the nested executable remains package-owned.
|
||||
- Batched transactions use observed installation-hook order, not command-line argument order, to verify the final selected edition.
|
||||
- Reconfiguration, old-version cleanup, OSS no-op behaviour, and warning-only mutation failures are covered without weakening existing fresh-install safety checks.
|
||||
- Helper tests run the shipped lifecycle helper with `sh` and `bash` against stub shims and a temporary link path. They cover allowed, disabled, and unknown policy; foreign entries under a disabled policy; the DEB `add-copilot-command` answer for each edition; and hand-over on removal. They also verify that the shim never receives the maintainer script's standard input, which debconf uses.
|
||||
- Source-built test packages and official product artifacts are distinct validation inputs. Minimal lifecycle fixtures do not establish complete-package launcher behaviour. See the [focused sanity suite](../test/sanity/README.md#focused-linux-copilot-shim-package-lifecycle-suite) for prerequisites and execution.
|
||||
|
||||
### Launch behavior
|
||||
|
||||
@@ -1306,9 +1372,9 @@ The work is complete only when:
|
||||
global-storage native shim without deleting that directory or unrelated
|
||||
content. Unconfigured paths and unavailable platforms are not reported
|
||||
as validated live deployment.
|
||||
17. Shim entries do not suppress the official installer's profile prompt, and
|
||||
a verified official-script installation launches immediately and on later
|
||||
stale-PATH invocations without another installation.
|
||||
17. Linux inherits the installer PATH without depending on a profile prompt, while macOS retains shim-directory filtering and profile-consent coverage. A verified official-script installation launches immediately and on later stale-PATH invocations without another installation.
|
||||
18. Native Linux policy dispatch enforces the strict-false contract for installation without blocking an eligible installed CLI or changing Windows/macOS behaviour.
|
||||
19. DEB/RPM lifecycle handling satisfies the shared-command ownership contract for both editions, including package-manager file ownership and cleanup semantics, without installing the real CLI or modifying its files/data.
|
||||
|
||||
## Source references
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
## Scope
|
||||
|
||||
This document explains how GitHub's official install script decides whether to
|
||||
offer a `PATH` update, why the legacy extension shim and an earlier Rust shim
|
||||
got different results from it, and what the Rust shim does now, during and
|
||||
after an install through that script.
|
||||
This document explains how GitHub's official install script decides whether to offer a `PATH` update, why the legacy extension shim and an earlier Rust shim got different results from it, and the current platform-specific Rust behaviour. Linux preserves the installer's original `PATH`; macOS retains shim-directory filtering. Both use verified discovery after installation.
|
||||
|
||||
It is based on:
|
||||
|
||||
@@ -115,8 +112,15 @@ the Rust shim downloads the official script to a temporary file with curl or
|
||||
wget and runs `bash <file>` attached to the terminal, so the script's prompts
|
||||
work. It never pipes the script into `bash`.
|
||||
|
||||
The `bash` command, and only that command, gets a filtered `PATH`
|
||||
([`installer_path`](./src/candidate.rs)):
|
||||
#### Linux: inherited PATH
|
||||
|
||||
The Linux `bash` process inherits the original `PATH`, including its order, duplicates, and shim-containing directories. This matters when `/usr/bin/copilot` points to a VS Code shim: removing `/usr/bin`, or its merged-`/usr` alias `/bin`, would also hide utilities that the installer needs.
|
||||
|
||||
The upstream script installs the CLI before checking `command -v copilot`. A visible shim can satisfy that check and suppress the profile prompt, but the shim independently finds the real CLI through its installation-directory fallback. This preserves access to installer utilities without returning to the historical repeat-install behaviour.
|
||||
|
||||
#### macOS: filtered PATH
|
||||
|
||||
On macOS, the `bash` command, and only that command, gets a filtered `PATH` ([`installer_path`](./src/candidate.rs)):
|
||||
|
||||
1. The shim runs discovery and collects the directory of every candidate it
|
||||
rejected as the running shim (including links to it), another copy of the
|
||||
@@ -132,15 +136,11 @@ The `bash` command, and only that command, gets a filtered `PATH`
|
||||
the Windows MSI install, PowerShell probes, and the final CLI keep the
|
||||
unchanged environment, and the shim's own environment isn't modified.
|
||||
|
||||
The script's `command -v copilot` now fails unless a real Copilot CLI is on
|
||||
`PATH`, so it shows the missing-`PATH` notice and offers to update the profile.
|
||||
Declining the profile change doesn't cancel the install.
|
||||
With macOS filtering, the script's `command -v copilot` fails unless a real Copilot CLI is on `PATH`, so it shows the missing-`PATH` notice and offers to update the profile. Declining the profile change doesn't cancel the install. Linux does not rely on this prompt being shown or accepted.
|
||||
|
||||
### Finding the CLI after the install
|
||||
|
||||
The shim doesn't add the install directory to that `PATH`; the script decides
|
||||
whether the CLI is reachable. Instead, after a successful install through the
|
||||
script, the shim runs discovery again:
|
||||
Neither platform adds the predicted install directory to the script's `PATH`. After a successful script installation, the shim independently runs discovery again:
|
||||
|
||||
1. `PATH`, as before; an eligible CLI there always wins.
|
||||
2. Then the script's install directory
|
||||
@@ -152,6 +152,8 @@ script, the shim runs discovery again:
|
||||
|
||||
For ordinary shim invocation, the CLI launches right away with the original arguments, although the terminal's `PATH` hasn't changed. The macOS workbench install-only route instead reports the verified availability path and exits without launching the CLI. Later invocations use the same fallback before offering to install, so a terminal with a stale `PATH` doesn't install again.
|
||||
|
||||
On Linux, removing VS Code can remove the system-shim route without removing the real CLI. If the installer skipped its profile update, the real CLI may then need its own `PATH` entry. A custom `PREFIX` must remain available through the appropriate environment or the CLI's own `PATH` entry on later invocations.
|
||||
|
||||
If neither finds the CLI, the shim prints, even without verbose mode:
|
||||
|
||||
```text
|
||||
@@ -178,9 +180,9 @@ always searches `%LOCALAPPDATA%\GitHubCopilotCLI` after `PATH`; see
|
||||
| Linux install order | npm, Homebrew if present, curl, wget | curl, then wget |
|
||||
| Running the official script | `curl ... \| bash` or `wget ... \| bash` | Download to a temporary file, then `bash <file>` |
|
||||
| Script's stdin | The pipe; prompts read `/dev/tty` | The terminal |
|
||||
| Script's `PATH` | Its own directory removed | Every rejected shim's directory removed |
|
||||
| Script's `command -v copilot` | Fails unless a real CLI is on `PATH` | Fails unless a real CLI is on `PATH` |
|
||||
| Missing-`PATH` notice and profile prompt | Shown | Shown |
|
||||
| Script's `PATH` | Its own directory removed | Linux: inherited unchanged; macOS: every rejected shim's directory removed |
|
||||
| Script's `command -v copilot` | Fails unless a real CLI is on `PATH` | Linux: a shim can satisfy the check; macOS: fails unless a real CLI is on `PATH` |
|
||||
| Missing-`PATH` notice and profile prompt | Shown | Linux: may be skipped; macOS: shown when no real CLI is on `PATH` |
|
||||
| After the install | Checks `copilot --version` again with the same `PATH` | Searches `PATH`, then the script's install directory, and launches the CLI |
|
||||
|
||||
## Legacy shims in existing terminals
|
||||
@@ -189,14 +191,11 @@ The legacy extension put its `copilotCli` directory at the front of the
|
||||
integrated terminal `PATH`. The current extension replaces that contribution
|
||||
(see [`REQUIREMENTS.md`](./REQUIREMENTS.md)), but a terminal restored from
|
||||
before the update keeps its old `PATH` until it's restarted, which is how the
|
||||
observed terminal still ran the legacy shim. The Rust shim rejects legacy shims
|
||||
both as candidates and in the script's `PATH`, so they don't affect it.
|
||||
observed terminal still ran the legacy shim. The Rust shim rejects legacy shims as CLI candidates on both platforms. macOS also excludes their directories from the installer's `PATH`; Linux keeps the original environment and does not rely on the installer's profile prompt.
|
||||
|
||||
## Upstream
|
||||
|
||||
The filtered `PATH` works around the script accepting any `copilot`. If the
|
||||
script checked that `command -v copilot` resolves to the CLI it just installed,
|
||||
the filtering would no longer be needed.
|
||||
macOS filtering works around the script accepting any `copilot`. Linux instead preserves access to system utilities and relies on the shim's real-CLI discovery/fallback. Neither platform patches the upstream script. If its accessibility check changes, re-evaluate the platform-specific assumptions against the new script rather than assuming this historical behaviour remains fixed.
|
||||
|
||||
## Not established by the transcript
|
||||
|
||||
|
||||
+189
-2
@@ -357,6 +357,7 @@ pub(crate) fn run<R: Runtime>(
|
||||
Ok(Invocation::Launch { clear, arguments }) => (clear, arguments),
|
||||
// Commands for VS Code report and exit; they never launch the Copilot CLI.
|
||||
Ok(Invocation::Probe(options)) => return setup::probe(runtime, target, &options),
|
||||
Ok(Invocation::Policy) => return report_policy(runtime),
|
||||
Ok(Invocation::Install(options))
|
||||
if matches!(options.mode, InstallMode::Interactive)
|
||||
&& matches!(target, Some(HostTarget::MacosX64 | HostTarget::MacosArm64)) =>
|
||||
@@ -409,6 +410,22 @@ pub(crate) fn run<R: Runtime>(
|
||||
result.map_or_else(ApplicationExit::code, ApplicationExit::code)
|
||||
}
|
||||
|
||||
/// Exits with `0` when the `CopilotCliCommand` policy allows the command and with the policy install status when it
|
||||
/// turns it off, so package install scripts can query the policy without parsing it themselves.
|
||||
fn report_policy<R: Runtime>(runtime: &R) -> i32 {
|
||||
let disabled = runtime.copilot_cli_command_disabled();
|
||||
runtime.write_diagnostic(&format!(
|
||||
"the {} policy is {}",
|
||||
setup::COPILOT_CLI_COMMAND_POLICY,
|
||||
if disabled { "disabled" } else { "allowed" }
|
||||
));
|
||||
if disabled {
|
||||
setup::InstallStatus::Policy.exit_code()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_candidate<R: Runtime>(
|
||||
runtime: &R,
|
||||
candidate: Candidate,
|
||||
@@ -670,6 +687,7 @@ mod tests {
|
||||
messages: RefCell<Vec<String>>,
|
||||
diagnostics_enabled: Cell<bool>,
|
||||
policy_disabled: Cell<bool>,
|
||||
policy_reads: Cell<usize>,
|
||||
no_terminal: Cell<bool>,
|
||||
}
|
||||
|
||||
@@ -704,6 +722,7 @@ mod tests {
|
||||
messages: RefCell::new(Vec::new()),
|
||||
diagnostics_enabled: Cell::new(true),
|
||||
policy_disabled: Cell::new(false),
|
||||
policy_reads: Cell::new(0),
|
||||
no_terminal: Cell::new(false),
|
||||
}
|
||||
}
|
||||
@@ -858,6 +877,7 @@ mod tests {
|
||||
|
||||
impl PolicyEffects for FakeRuntime {
|
||||
fn copilot_cli_command_disabled(&self) -> bool {
|
||||
self.policy_reads.set(self.policy_reads.get() + 1);
|
||||
self.policy_disabled.get()
|
||||
}
|
||||
}
|
||||
@@ -1278,7 +1298,15 @@ mod tests {
|
||||
PathBuf::from("/home/test/.local/bin/copilot"),
|
||||
PathBuf::from("/home/test/.local/bin/copilot")
|
||||
],
|
||||
vec![None, Some(OsString::from("/tools")), None, None],
|
||||
vec![
|
||||
None,
|
||||
#[cfg(target_os = "linux")]
|
||||
None,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
Some(OsString::from("/tools")),
|
||||
None,
|
||||
None,
|
||||
],
|
||||
Some(CommandArguments::Native(arguments)),
|
||||
Some(OsString::from("/tools")),
|
||||
)
|
||||
@@ -1765,7 +1793,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_blocks_install_but_launches_an_installed_cli() {
|
||||
fn policy_blocks_install_after_cli_discovery() {
|
||||
let policy_runtime = |directory: &str| {
|
||||
let runtime = FakeRuntime::default();
|
||||
runtime.policy_disabled.set(true);
|
||||
@@ -1790,8 +1818,10 @@ mod tests {
|
||||
missing_exit,
|
||||
missing.prompts.borrow().len(),
|
||||
missing.messages.borrow().clone(),
|
||||
missing.policy_reads.get(),
|
||||
installed_exit,
|
||||
programs(&installed),
|
||||
installed.policy_reads.get(),
|
||||
),
|
||||
(
|
||||
10,
|
||||
@@ -1799,8 +1829,165 @@ mod tests {
|
||||
vec![String::from(
|
||||
"GitHub Copilot CLI was not found. Installing it from VS Code is turned off by the CopilotCliCommand policy; contact your administrator."
|
||||
)],
|
||||
1,
|
||||
7,
|
||||
vec![cli_path("/cli")],
|
||||
0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn policy_is_not_read_before_fallback_cli_discovery() {
|
||||
let runtime = FakeRuntime::default();
|
||||
runtime.policy_disabled.set(true);
|
||||
runtime.set_path(&[Path::new("/empty")]);
|
||||
let fallback = PathBuf::from("/home/test/.local/bin/copilot");
|
||||
runtime.add_program(&fallback);
|
||||
runtime.push_process_result(Ok(interactive_exit(8)));
|
||||
|
||||
let exit = run(
|
||||
&runtime,
|
||||
vec![OsString::from("--resume")],
|
||||
Some(HostTarget::LinuxGnuX64),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
exit,
|
||||
runtime.policy_reads.get(),
|
||||
runtime.prompts.borrow().clone(),
|
||||
programs(&runtime),
|
||||
),
|
||||
(8, 0, Vec::<String>::new(), vec![fallback])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_policy_suppresses_probe_network_check() {
|
||||
let runtime = FakeRuntime::default();
|
||||
runtime.policy_disabled.set(true);
|
||||
runtime.set_path(&[Path::new("/empty")]);
|
||||
let directory = tempfile::tempdir().expect("create probe result directory");
|
||||
let result_file = directory.path().join("probe.ini");
|
||||
|
||||
let exit = run(
|
||||
&runtime,
|
||||
vec![
|
||||
OsString::from("--vscode-shim"),
|
||||
OsString::from("probe"),
|
||||
OsString::from("--result-file"),
|
||||
result_file.as_os_str().to_os_string(),
|
||||
],
|
||||
Some(HostTarget::LinuxGnuX64),
|
||||
);
|
||||
|
||||
let bytes = std::fs::read(result_file).expect("read probe result");
|
||||
let result = String::from_utf16(
|
||||
&bytes
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|bytes| u16::from_le_bytes(*bytes))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.expect("decode UTF-16 probe result");
|
||||
assert_eq!(
|
||||
(
|
||||
exit,
|
||||
runtime.policy_reads.get(),
|
||||
runtime.commands.borrow().len()
|
||||
),
|
||||
(0, 1, 0)
|
||||
);
|
||||
assert!(result.contains("policy=disabled\r\n"), "{result}");
|
||||
assert!(
|
||||
result.contains("reason=the CopilotCliCommand policy is disabled\r\n"),
|
||||
"{result}"
|
||||
);
|
||||
assert!(!result.contains("download:"), "{result}");
|
||||
assert!(!result.contains("unsupported"), "{result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_command_reports_the_policy_through_the_exit_code() {
|
||||
let report = |disabled: bool| {
|
||||
let runtime = FakeRuntime::default();
|
||||
runtime.policy_disabled.set(disabled);
|
||||
let exit = run(
|
||||
&runtime,
|
||||
vec![
|
||||
OsString::from("--vscode-shim"),
|
||||
OsString::from("verbose"),
|
||||
OsString::from("--vscode-shim"),
|
||||
OsString::from("policy"),
|
||||
],
|
||||
Some(HostTarget::LinuxGnuX64),
|
||||
);
|
||||
let report = (
|
||||
exit,
|
||||
runtime.policy_reads.get(),
|
||||
runtime.commands.borrow().len(),
|
||||
runtime.prompts.borrow().len(),
|
||||
runtime.messages.borrow().len(),
|
||||
runtime.diagnostics.borrow().clone(),
|
||||
);
|
||||
report
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
[report(false), report(true)],
|
||||
[
|
||||
(
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
vec![String::from("the CopilotCliCommand policy is allowed")]
|
||||
),
|
||||
(
|
||||
10,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
vec![String::from("the CopilotCliCommand policy is disabled")]
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_policy_blocks_interactive_setup_install() {
|
||||
let runtime = FakeRuntime::default();
|
||||
runtime.policy_disabled.set(true);
|
||||
|
||||
let exit = run(
|
||||
&runtime,
|
||||
vec![
|
||||
OsString::from("--vscode-shim"),
|
||||
OsString::from("install"),
|
||||
OsString::from("--interactive"),
|
||||
],
|
||||
Some(HostTarget::LinuxGnuX64),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
exit,
|
||||
runtime.policy_reads.get(),
|
||||
runtime.commands.borrow().len(),
|
||||
runtime.messages.borrow().clone(),
|
||||
),
|
||||
(
|
||||
10,
|
||||
1,
|
||||
0,
|
||||
vec![String::from(
|
||||
"GitHub Copilot CLI couldn't be installed: installing GitHub Copilot CLI from VS Code is turned off by the CopilotCliCommand policy"
|
||||
)],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, any(not(target_os = "linux"), test)))]
|
||||
use std::ffi::OsString;
|
||||
use std::io::{self, Cursor, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -150,7 +150,7 @@ where
|
||||
}
|
||||
|
||||
/// The official installer must not mistake a shim for the CLI it just installed. Only its child PATH is filtered.
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, any(not(target_os = "linux"), test)))]
|
||||
pub(crate) fn installer_path<R>(runtime: &R) -> io::Result<OsString>
|
||||
where
|
||||
R: EnvironmentEffects + FileSystemEffects,
|
||||
|
||||
+114
-2
@@ -351,11 +351,11 @@ pub(crate) fn run_installer<R: Runtime>(
|
||||
tools,
|
||||
verbose,
|
||||
|| {
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
{
|
||||
crate::candidate::installer_path(runtime).map(Some)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(any(not(unix), target_os = "linux"))]
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
@@ -599,6 +599,8 @@ mod tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use install::run_installer;
|
||||
use install::{
|
||||
discover_tools, installer_plan, run_installer_with, HostTarget, InstallerPlanError,
|
||||
InstallerResult, InstallerRoute, InstallerStage, TemporaryScript, TemporaryScriptFactory,
|
||||
@@ -613,6 +615,8 @@ mod tests {
|
||||
prompt, EnvironmentEffects, FileSystemEffects, InspectedFileType, PathInspection,
|
||||
ProcessEffects,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
use runtime::{PolicyEffects, UserInteractionEffects};
|
||||
|
||||
fn path(name: &str) -> Option<PathBuf> {
|
||||
Some(PathBuf::from(name))
|
||||
@@ -1021,6 +1025,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct LinuxInstallerRuntime {
|
||||
processes: FakeProcesses,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl LinuxInstallerRuntime {
|
||||
fn new(results: impl IntoIterator<Item = FakeProcessResult>) -> Self {
|
||||
Self {
|
||||
processes: FakeProcesses::new(results),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl EnvironmentEffects for LinuxInstallerRuntime {
|
||||
fn path(&self) -> Option<OsString> {
|
||||
Some(OsString::from("/unchanged/path"))
|
||||
}
|
||||
|
||||
fn current_executable(&self) -> io::Result<PathBuf> {
|
||||
panic!("Linux installer dispatch must not inspect PATH candidates")
|
||||
}
|
||||
|
||||
fn current_directory(&self) -> io::Result<PathBuf> {
|
||||
panic!("Linux installer dispatch must not inspect PATH candidates")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl FileSystemEffects for LinuxInstallerRuntime {
|
||||
fn inspect_path(&self, _path: &Path) -> io::Result<Option<PathInspection>> {
|
||||
panic!("Linux installer dispatch must not inspect PATH candidates")
|
||||
}
|
||||
|
||||
fn open_file(&self, _path: &Path) -> io::Result<Box<dyn Read>> {
|
||||
panic!("Linux installer dispatch must not inspect PATH candidates")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl UserInteractionEffects for LinuxInstallerRuntime {
|
||||
fn clear_terminal(&self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn can_prompt(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn prompt(&self) -> io::Result<PromptResponse> {
|
||||
panic!("Linux installer dispatch does not prompt")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl PolicyEffects for LinuxInstallerRuntime {
|
||||
fn copilot_cli_command_disabled(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl ProcessEffects for LinuxInstallerRuntime {
|
||||
fn supervise(
|
||||
&self,
|
||||
command: &CommandSpec,
|
||||
mode: SupervisionMode,
|
||||
) -> Result<ProcessOutcome, ProcessError> {
|
||||
self.processes.supervise(command, mode)
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackedPath {
|
||||
path: PathBuf,
|
||||
active: bool,
|
||||
@@ -1141,6 +1218,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_production_installer_routes_inherit_path() {
|
||||
for (route, downloader) in [
|
||||
(InstallerRoute::Curl, "curl"),
|
||||
(InstallerRoute::Wget, "wget"),
|
||||
] {
|
||||
let runtime = LinuxInstallerRuntime::new([
|
||||
FakeProcessResult::Exit(0),
|
||||
FakeProcessResult::Exit(0),
|
||||
]);
|
||||
|
||||
let result = run_installer(&runtime, &[route], &all_tools(), false);
|
||||
let commands = runtime.processes.commands.borrow();
|
||||
|
||||
assert!(matches!(result, InstallerResult::Succeeded { .. }));
|
||||
assert_eq!(
|
||||
(
|
||||
commands
|
||||
.iter()
|
||||
.map(|(command, _)| command.program().to_os_string())
|
||||
.collect::<Vec<_>>(),
|
||||
commands
|
||||
.iter()
|
||||
.map(|(command, _)| command.path().map(OsString::from))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
(
|
||||
vec![OsString::from(downloader), OsString::from("bash")],
|
||||
vec![None, None],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_fallback_order_and_cancellation_are_terminal() {
|
||||
let tools = all_tools();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! copilot [--vscode-shim <clear|verbose>]... [--] [copilot arguments...]
|
||||
//! copilot [--vscode-shim verbose]... --vscode-shim <probe|install> [command options...]
|
||||
//! copilot [--vscode-shim verbose]... --vscode-shim <probe|install|policy> [command options...]
|
||||
//! ```
|
||||
//!
|
||||
//! Modifiers are removed and the remaining arguments are forwarded to the Copilot CLI. Commands run and exit without
|
||||
@@ -36,6 +36,8 @@ pub(crate) enum Invocation {
|
||||
},
|
||||
Probe(ProbeOptions),
|
||||
Install(InstallOptions),
|
||||
/// Reports the `CopilotCliCommand` policy through the exit code, for package install scripts.
|
||||
Policy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -164,11 +166,12 @@ fn parse_invocation(
|
||||
*verbose = true;
|
||||
index += 2;
|
||||
}
|
||||
Some("probe" | "install") if clear => {
|
||||
Some("probe" | "install" | "policy") if clear => {
|
||||
return Err(InvocationError::CommandAfterClear(name.clone()));
|
||||
}
|
||||
Some("probe") => return parse_probe(&arguments[index + 2..]),
|
||||
Some("install") => return parse_install(&arguments[index + 2..]),
|
||||
Some("policy") => return parse_policy(&arguments[index + 2..]),
|
||||
_ => return Err(InvocationError::UnknownOption(name.clone())),
|
||||
}
|
||||
}
|
||||
@@ -253,6 +256,16 @@ fn parse_probe(options: &[OsString]) -> Result<Invocation, InvocationError> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_policy(options: &[OsString]) -> Result<Invocation, InvocationError> {
|
||||
match options.first() {
|
||||
Some(option) => Err(InvocationError::UnknownCommandOption {
|
||||
command: "policy",
|
||||
option: option.clone(),
|
||||
}),
|
||||
None => Ok(Invocation::Policy),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_install(options: &[OsString]) -> Result<Invocation, InvocationError> {
|
||||
const COMMAND: &str = "install";
|
||||
let mut interactive = false;
|
||||
@@ -443,6 +456,12 @@ mod tests {
|
||||
"install",
|
||||
"--interactive"
|
||||
])),
|
||||
parse(arguments(&[
|
||||
"--vscode-shim",
|
||||
"verbose",
|
||||
"--vscode-shim",
|
||||
"policy"
|
||||
])),
|
||||
],
|
||||
[
|
||||
parsed(
|
||||
@@ -471,6 +490,7 @@ mod tests {
|
||||
mode: InstallMode::Interactive,
|
||||
}))
|
||||
),
|
||||
parsed(true, Ok(Invocation::Policy)),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -521,6 +541,18 @@ mod tests {
|
||||
"x"
|
||||
])),
|
||||
parse(arguments(&["--vscode-shim", "install"])),
|
||||
parse(arguments(&[
|
||||
"--vscode-shim",
|
||||
"policy",
|
||||
"--result-file",
|
||||
"x"
|
||||
])),
|
||||
parse(arguments(&[
|
||||
"--vscode-shim",
|
||||
"clear",
|
||||
"--vscode-shim",
|
||||
"policy"
|
||||
])),
|
||||
],
|
||||
[
|
||||
parsed(false, Err(InvocationError::MissingOptionName)),
|
||||
@@ -585,6 +617,17 @@ mod tests {
|
||||
option: "exactly one of --interactive or --non-interactive",
|
||||
})
|
||||
),
|
||||
parsed(
|
||||
false,
|
||||
Err(InvocationError::UnknownCommandOption {
|
||||
command: "policy",
|
||||
option: OsString::from("--result-file"),
|
||||
})
|
||||
),
|
||||
parsed(
|
||||
false,
|
||||
Err(InvocationError::CommandAfterClear(OsString::from("policy")))
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod install;
|
||||
mod invocation;
|
||||
mod legacy;
|
||||
mod model;
|
||||
mod policy;
|
||||
mod runtime;
|
||||
mod setup;
|
||||
#[cfg(any(windows, test))]
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const LINUX_POLICY_PATH: &str = "/etc/vscode/policy.json";
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn linux_policy_disabled(contents: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(contents)
|
||||
.ok()
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.and_then(|object| {
|
||||
object
|
||||
.get("CopilotCliCommand")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
}) == Some(false)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn linux_copilot_cli_command_disabled_at(path: &std::path::Path) -> bool {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.is_some_and(|contents| linux_policy_disabled(&contents))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_copilot_cli_command_disabled() -> bool {
|
||||
linux_copilot_cli_command_disabled_at(std::path::Path::new(LINUX_POLICY_PATH))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const MACOS_POLICY_DOMAINS: &[&str] = &[
|
||||
"com.microsoft.VSCode",
|
||||
"com.microsoft.VSCodeInsiders",
|
||||
"com.microsoft.VSCodeExploration",
|
||||
"com.visualstudio.code.oss",
|
||||
];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_copilot_cli_command_disabled() -> bool {
|
||||
unsafe {
|
||||
macos_copilot_cli_command_disabled_with(MACOS_POLICY_DOMAINS, |key, domain| {
|
||||
core_foundation_sys::preferences::CFPreferencesCopyAppValue(key, domain)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
/// # Safety
|
||||
///
|
||||
/// Each callback result must be null or a valid Core Foundation object with a +1 ownership
|
||||
/// reference transferred to this helper for exactly one release. Callback key and domain arguments
|
||||
/// are borrowed valid strings for the callback invocation; they must not be released or retained
|
||||
/// for later use without an explicit retain.
|
||||
unsafe fn macos_copilot_cli_command_disabled_with<F>(domains: &[&str], mut copy_value: F) -> bool
|
||||
where
|
||||
F: FnMut(
|
||||
core_foundation_sys::string::CFStringRef,
|
||||
core_foundation_sys::string::CFStringRef,
|
||||
) -> core_foundation_sys::propertylist::CFPropertyListRef,
|
||||
{
|
||||
domains.iter().any(|domain| unsafe {
|
||||
// Scalar policies use the effective app value, not CFPreferencesAppValueIsForced. The copied value is owned.
|
||||
let Some(key) = cf_string("CopilotCliCommand") else {
|
||||
return false;
|
||||
};
|
||||
let Some(domain) = cf_string(domain) else {
|
||||
core_foundation_sys::base::CFRelease(key.cast());
|
||||
return false;
|
||||
};
|
||||
let value = copy_value(key, domain);
|
||||
core_foundation_sys::base::CFRelease(key.cast());
|
||||
core_foundation_sys::base::CFRelease(domain.cast());
|
||||
macos_owned_value_disables_command(value)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe fn macos_owned_value_disables_command(
|
||||
value: core_foundation_sys::propertylist::CFPropertyListRef,
|
||||
) -> bool {
|
||||
if value.is_null() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The reader transfers ownership of every non-null value to this reducer.
|
||||
let disabled = core_foundation_sys::base::CFGetTypeID(value)
|
||||
== core_foundation_sys::number::CFBooleanGetTypeID()
|
||||
&& !core_foundation_sys::number::CFBooleanGetValue(value.cast());
|
||||
core_foundation_sys::base::CFRelease(value);
|
||||
disabled
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe fn cf_string(value: &str) -> Option<core_foundation_sys::string::CFStringRef> {
|
||||
let value = core_foundation_sys::string::CFStringCreateWithBytes(
|
||||
std::ptr::null(),
|
||||
value.as_ptr(),
|
||||
value.len() as core_foundation_sys::base::CFIndex,
|
||||
core_foundation_sys::string::kCFStringEncodingUTF8,
|
||||
0,
|
||||
);
|
||||
(!value.is_null()).then_some(value)
|
||||
}
|
||||
|
||||
pub(crate) fn copilot_cli_command_disabled() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return crate::setup::windows::copilot_cli_command_disabled();
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return linux_copilot_cli_command_disabled();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return macos_copilot_cli_command_disabled();
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{linux_copilot_cli_command_disabled_at, linux_policy_disabled, LINUX_POLICY_PATH};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn linux_parser_accepts_only_a_false_boolean_member() {
|
||||
for (contents, expected) in [
|
||||
(r#"{"CopilotCliCommand":false}"#, true),
|
||||
(r#"{"CopilotCliCommand":true}"#, false),
|
||||
(r#"{}"#, false),
|
||||
(r#"{"CopilotCliCommand":false"#, false),
|
||||
(r#"false"#, false),
|
||||
(r#"{"CopilotCliCommand":0}"#, false),
|
||||
(r#"{"CopilotCliCommand":"false"}"#, false),
|
||||
(r#"{"CopilotCliCommand":null}"#, false),
|
||||
(r#"{"CopilotCliCommand":[]}"#, false),
|
||||
(r#"{"CopilotCliCommand":{}}"#, false),
|
||||
] {
|
||||
assert_eq!(linux_policy_disabled(contents), expected, "{contents}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_reader_observes_changed_file_contents_and_fails_open() {
|
||||
assert_eq!(LINUX_POLICY_PATH, "/etc/vscode/policy.json");
|
||||
|
||||
let file = tempfile::NamedTempFile::new().expect("create temporary policy");
|
||||
std::fs::write(file.path(), r#"{"CopilotCliCommand":false}"#)
|
||||
.expect("write disabled policy");
|
||||
assert!(linux_copilot_cli_command_disabled_at(file.path()));
|
||||
|
||||
std::fs::write(file.path(), r#"{"CopilotCliCommand":true}"#).expect("write enabled policy");
|
||||
assert!(!linux_copilot_cli_command_disabled_at(file.path()));
|
||||
|
||||
assert!(!linux_copilot_cli_command_disabled_at(
|
||||
&file.path().with_file_name("missing-policy.json")
|
||||
));
|
||||
|
||||
let directory = tempfile::tempdir().expect("create temporary directory");
|
||||
assert!(!linux_copilot_cli_command_disabled_at(Path::new(
|
||||
directory.path()
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use core_foundation_sys::base::{CFRelease, CFRetain, CFTypeRef};
|
||||
use core_foundation_sys::number::{
|
||||
kCFBooleanFalse, kCFBooleanTrue, kCFNumberIntType, CFNumberCreate,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
cf_string, macos_copilot_cli_command_disabled_with, MACOS_POLICY_DOMAINS,
|
||||
};
|
||||
|
||||
static NEXT_DOMAIN: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
unsafe fn retained(
|
||||
value: CFTypeRef,
|
||||
) -> core_foundation_sys::propertylist::CFPropertyListRef {
|
||||
CFRetain(value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_domains_are_fixed() {
|
||||
assert_eq!(
|
||||
MACOS_POLICY_DOMAINS,
|
||||
[
|
||||
"com.microsoft.VSCode",
|
||||
"com.microsoft.VSCodeInsiders",
|
||||
"com.microsoft.VSCodeExploration",
|
||||
"com.visualstudio.code.oss",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reader_accepts_only_false_booleans_and_releases_owned_values() {
|
||||
unsafe {
|
||||
assert!(macos_copilot_cli_command_disabled_with(
|
||||
&["test"],
|
||||
|_, _| retained(kCFBooleanFalse.cast())
|
||||
));
|
||||
assert!(!macos_copilot_cli_command_disabled_with(
|
||||
&["test"],
|
||||
|_, _| retained(kCFBooleanTrue.cast())
|
||||
));
|
||||
}
|
||||
unsafe {
|
||||
assert!(!macos_copilot_cli_command_disabled_with(
|
||||
&["test"],
|
||||
|_, _| { std::ptr::null() }
|
||||
));
|
||||
}
|
||||
|
||||
let zero = 0_i32;
|
||||
let zero = unsafe {
|
||||
CFNumberCreate(
|
||||
std::ptr::null(),
|
||||
kCFNumberIntType,
|
||||
(&zero as *const i32).cast(),
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
assert!(!macos_copilot_cli_command_disabled_with(
|
||||
&["test"],
|
||||
|_, _| { retained(zero.cast()) }
|
||||
));
|
||||
}
|
||||
|
||||
let false_string = unsafe { cf_string("false").expect("create false string") };
|
||||
unsafe {
|
||||
assert!(!macos_copilot_cli_command_disabled_with(
|
||||
&["test"],
|
||||
|_, _| { retained(false_string.cast()) }
|
||||
));
|
||||
CFRelease(zero.cast());
|
||||
CFRelease(false_string.cast());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn false_stops_later_domain_reads_and_reader_is_not_cached() {
|
||||
let reads = Cell::new(0);
|
||||
unsafe {
|
||||
assert!(macos_copilot_cli_command_disabled_with(
|
||||
&["first", "later"],
|
||||
|_, _| {
|
||||
reads.set(reads.get() + 1);
|
||||
retained(if reads.get() == 1 {
|
||||
kCFBooleanTrue.cast()
|
||||
} else {
|
||||
kCFBooleanFalse.cast()
|
||||
})
|
||||
}
|
||||
));
|
||||
}
|
||||
assert_eq!(reads.get(), 2);
|
||||
|
||||
reads.set(0);
|
||||
unsafe {
|
||||
assert!(macos_copilot_cli_command_disabled_with(
|
||||
&["first", "later"],
|
||||
|_, _| {
|
||||
reads.set(reads.get() + 1);
|
||||
retained(kCFBooleanFalse.cast())
|
||||
}
|
||||
));
|
||||
}
|
||||
assert_eq!(reads.get(), 1);
|
||||
|
||||
let false_value = unsafe { retained(kCFBooleanFalse.cast()) };
|
||||
let true_value = unsafe { retained(kCFBooleanTrue.cast()) };
|
||||
let backing = Cell::new(false_value);
|
||||
unsafe {
|
||||
assert!(macos_copilot_cli_command_disabled_with(
|
||||
&["test"],
|
||||
|_, _| retained(backing.get())
|
||||
));
|
||||
}
|
||||
backing.set(true_value);
|
||||
unsafe {
|
||||
assert!(!macos_copilot_cli_command_disabled_with(
|
||||
&["test"],
|
||||
|_, _| retained(backing.get())
|
||||
));
|
||||
}
|
||||
unsafe {
|
||||
CFRelease(false_value);
|
||||
CFRelease(true_value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_copier_fails_open_for_a_unique_never_written_domain() {
|
||||
let domain = format!(
|
||||
"com.microsoft.vscode.copilot-shim-test-never-written-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_DOMAIN.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
unsafe {
|
||||
assert!(!macos_copilot_cli_command_disabled_with(
|
||||
&[&domain],
|
||||
|key, domain| {
|
||||
core_foundation_sys::preferences::CFPreferencesCopyAppValue(key, domain)
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,16 +114,7 @@ pub(crate) struct NativeRuntime {
|
||||
|
||||
impl PolicyEffects for NativeRuntime {
|
||||
fn copilot_cli_command_disabled(&self) -> bool {
|
||||
// Only Windows setup puts the shim on the system PATH. Elsewhere it is reachable from integrated terminals; the
|
||||
// Copilot extension and macOS workbench installation action apply the effective setting before invoking it.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::setup::windows::copilot_cli_command_disabled()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
false
|
||||
}
|
||||
crate::policy::copilot_cli_command_disabled()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::env;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const ENABLE_VARIABLE: &str = "VSCODE_COPILOT_SHIM_NATIVE_POLICY_TEST";
|
||||
const RUNNER_DIRECTORY_VARIABLE: &str = "VSCODE_COPILOT_SHIM_NATIVE_POLICY_TEST_ROOT";
|
||||
const POLICY_DIRECTORY: &str = "/etc/vscode";
|
||||
const POLICY_PATH: &str = "/etc/vscode/policy.json";
|
||||
const ROOT_FALLBACK_CLI_PATH: &str = "/usr/local/bin/copilot";
|
||||
const RUNNER_MARKER: &str = ".vscode-copilot-shim-native-policy-isolated-runner";
|
||||
const RUNNER_MARKER_CONTENT: &[u8] = b"vscode-copilot-shim-native-policy-isolated-runner\n";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct EntryIdentity {
|
||||
device: u64,
|
||||
inode: u64,
|
||||
}
|
||||
|
||||
impl EntryIdentity {
|
||||
fn read(path: &Path) -> io::Result<Self> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
Ok(Self {
|
||||
device: metadata.dev(),
|
||||
inode: metadata.ino(),
|
||||
})
|
||||
}
|
||||
|
||||
fn matches(self, metadata: &fs::Metadata) -> bool {
|
||||
self.device == metadata.dev() && self.inode == metadata.ino()
|
||||
}
|
||||
}
|
||||
|
||||
struct NativePolicyFixture {
|
||||
policy_path: PathBuf,
|
||||
policy_identity: Option<EntryIdentity>,
|
||||
fixture_directory: PathBuf,
|
||||
fixture_directory_identity: EntryIdentity,
|
||||
cli_path: PathBuf,
|
||||
cli_identity: Option<EntryIdentity>,
|
||||
}
|
||||
|
||||
impl NativePolicyFixture {
|
||||
fn new(runner_directory: &Path) -> Self {
|
||||
let (fixture_directory, fixture_directory_identity) =
|
||||
create_fixture_directory(runner_directory);
|
||||
let cli_path = fixture_directory.join("copilot");
|
||||
let cli_identity = create_fake_cli(&cli_path);
|
||||
Self {
|
||||
policy_path: PathBuf::from(POLICY_PATH),
|
||||
policy_identity: None,
|
||||
fixture_directory,
|
||||
fixture_directory_identity,
|
||||
cli_path,
|
||||
cli_identity: Some(cli_identity),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&mut self) {
|
||||
assert!(
|
||||
self.policy_identity.is_none(),
|
||||
"native policy fixture is already set"
|
||||
);
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&self.policy_path)
|
||||
.expect("create native policy fixture");
|
||||
self.policy_identity =
|
||||
Some(EntryIdentity::read(&self.policy_path).expect("identify native policy fixture"));
|
||||
file.write_all(br#"{"CopilotCliCommand":false}"#)
|
||||
.expect("write native policy fixture");
|
||||
file.flush().expect("flush native policy fixture");
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
let Some(identity) = self.policy_identity else {
|
||||
return Ok(());
|
||||
};
|
||||
remove_owned_file(&self.policy_path, identity)?;
|
||||
self.policy_identity = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup(&mut self) -> Result<(), String> {
|
||||
let mut failures = Vec::new();
|
||||
if let Err(error) = self.clear() {
|
||||
failures.push(format!("policy fixture: {error}"));
|
||||
}
|
||||
if let Some(identity) = self.cli_identity {
|
||||
match remove_owned_file(&self.cli_path, identity) {
|
||||
Ok(()) => self.cli_identity = None,
|
||||
Err(error) => failures.push(format!("fake CLI: {error}")),
|
||||
}
|
||||
}
|
||||
if self.cli_identity.is_none() {
|
||||
if let Err(error) =
|
||||
remove_owned_directory(&self.fixture_directory, self.fixture_directory_identity)
|
||||
{
|
||||
failures.push(format!("fixture directory: {error}"));
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(failures.join("; "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativePolicyFixture {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = self.cleanup() {
|
||||
eprintln!("failed to clean native policy fixture: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_fixture_directory(runner_directory: &Path) -> (PathBuf, EntryIdentity) {
|
||||
for attempt in 0..100 {
|
||||
let directory = runner_directory.join(format!(
|
||||
".vscode-copilot-shim-native-policy-fixture-{}-{attempt}",
|
||||
std::process::id()
|
||||
));
|
||||
match fs::create_dir(&directory) {
|
||||
Ok(()) => {
|
||||
let identity = EntryIdentity::read(&directory)
|
||||
.expect("identify native policy fixture directory");
|
||||
return (directory, identity);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => panic!("create native policy fixture directory {directory:?}: {error}"),
|
||||
}
|
||||
}
|
||||
panic!("could not allocate a native policy fixture directory");
|
||||
}
|
||||
|
||||
fn create_fake_cli(path: &Path) -> EntryIdentity {
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.expect("create fake CLI");
|
||||
let identity = EntryIdentity::read(path).expect("identify fake CLI");
|
||||
file.write_all(b"#!/bin/sh\nprintf 'native-policy-fake-cli:%s\\n' \"$1\"\nexit 73\n")
|
||||
.expect("write fake CLI");
|
||||
file.flush().expect("flush fake CLI");
|
||||
let mut permissions = file
|
||||
.metadata()
|
||||
.expect("read fake CLI metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o700);
|
||||
fs::set_permissions(path, permissions).expect("make fake CLI executable");
|
||||
identity
|
||||
}
|
||||
|
||||
fn remove_owned_file(path: &Path, identity: EntryIdentity) -> io::Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata)
|
||||
if metadata.file_type().is_file()
|
||||
&& !metadata.file_type().is_symlink()
|
||||
&& identity.matches(&metadata) =>
|
||||
{
|
||||
fs::remove_file(path)
|
||||
}
|
||||
Ok(_) => Err(io::Error::other(format!(
|
||||
"refusing to remove entry not created by this test: {path:?}"
|
||||
))),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_owned_directory(path: &Path, identity: EntryIdentity) -> io::Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata)
|
||||
if metadata.file_type().is_dir()
|
||||
&& !metadata.file_type().is_symlink()
|
||||
&& identity.matches(&metadata) =>
|
||||
{
|
||||
fs::remove_dir(path)
|
||||
}
|
||||
Ok(_) => Err(io::Error::other(format!(
|
||||
"refusing to remove directory not created by this test: {path:?}"
|
||||
))),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn provisioned_runner_directory() -> PathBuf {
|
||||
assert_eq!(
|
||||
env::var(ENABLE_VARIABLE).as_deref(),
|
||||
Ok("1"),
|
||||
"set {ENABLE_VARIABLE}=1 only in a provisioned isolated Linux runner"
|
||||
);
|
||||
let directory = PathBuf::from(env::var_os(RUNNER_DIRECTORY_VARIABLE).unwrap_or_else(|| {
|
||||
panic!("set {RUNNER_DIRECTORY_VARIABLE} to {POLICY_DIRECTORY} in the isolated runner")
|
||||
}));
|
||||
assert_eq!(
|
||||
directory.as_path(),
|
||||
Path::new(POLICY_DIRECTORY),
|
||||
"{RUNNER_DIRECTORY_VARIABLE} must be {POLICY_DIRECTORY}"
|
||||
);
|
||||
let metadata = fs::symlink_metadata(&directory)
|
||||
.unwrap_or_else(|error| panic!("inspect isolated runner directory {directory:?}: {error}"));
|
||||
assert!(
|
||||
metadata.file_type().is_dir() && !metadata.file_type().is_symlink(),
|
||||
"isolated runner directory must be a real directory: {directory:?}"
|
||||
);
|
||||
let marker = directory.join(RUNNER_MARKER);
|
||||
let marker_metadata = fs::symlink_metadata(&marker)
|
||||
.unwrap_or_else(|error| panic!("inspect isolated runner marker {marker:?}: {error}"));
|
||||
assert!(
|
||||
marker_metadata.file_type().is_file() && !marker_metadata.file_type().is_symlink(),
|
||||
"isolated runner marker must be a regular file: {marker:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&marker).expect("read isolated runner marker"),
|
||||
RUNNER_MARKER_CONTENT,
|
||||
"isolated runner marker contents"
|
||||
);
|
||||
directory
|
||||
}
|
||||
|
||||
fn refuse_existing_policy_path() {
|
||||
match fs::symlink_metadata(POLICY_PATH) {
|
||||
Ok(_) => panic!("refusing to overwrite existing policy entry: {POLICY_PATH}"),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => panic!("inspect policy entry {POLICY_PATH}: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse_uncontrolled_root_fallback() {
|
||||
match fs::symlink_metadata(ROOT_FALLBACK_CLI_PATH) {
|
||||
Ok(_) => {
|
||||
panic!("refusing to inspect or run existing fallback CLI: {ROOT_FALLBACK_CLI_PATH}")
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => panic!("inspect fallback CLI {ROOT_FALLBACK_CLI_PATH}: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_shim(path: &Path, arguments: &[&str], home: &Path) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_copilot"))
|
||||
.args(arguments)
|
||||
.env("PATH", path)
|
||||
.env("HOME", home)
|
||||
.env_remove("PREFIX")
|
||||
.output()
|
||||
.expect("run native shim")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires VSCODE_COPILOT_SHIM_NATIVE_POLICY_TEST=1 in an isolated Linux runner"]
|
||||
fn native_binary_dispatches_provisioned_policy() {
|
||||
let runner_directory = provisioned_runner_directory();
|
||||
refuse_existing_policy_path();
|
||||
refuse_uncontrolled_root_fallback();
|
||||
|
||||
let mut fixture = NativePolicyFixture::new(&runner_directory);
|
||||
fixture.set();
|
||||
|
||||
let blocked = run_shim(Path::new(""), &[], &fixture.fixture_directory);
|
||||
assert_eq!(
|
||||
(
|
||||
blocked.status.code(),
|
||||
blocked.stdout,
|
||||
String::from_utf8(blocked.stderr).expect("UTF-8 policy diagnostic"),
|
||||
),
|
||||
(
|
||||
Some(10),
|
||||
Vec::<u8>::new(),
|
||||
String::from(
|
||||
"GitHub Copilot CLI was not found. Installing it from VS Code is turned off by the CopilotCliCommand policy; contact your administrator.\n"
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
let launched = run_shim(
|
||||
&fixture.fixture_directory,
|
||||
&["--native-policy-dispatch"],
|
||||
&fixture.fixture_directory,
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
launched.status.code(),
|
||||
String::from_utf8(launched.stdout).expect("UTF-8 fake CLI output"),
|
||||
launched.stderr,
|
||||
),
|
||||
(
|
||||
Some(73),
|
||||
String::from("native-policy-fake-cli:--native-policy-dispatch\n"),
|
||||
Vec::<u8>::new(),
|
||||
)
|
||||
);
|
||||
|
||||
let entries = fs::read_dir(&fixture.fixture_directory)
|
||||
.expect("read native policy fixture directory")
|
||||
.map(|entry| entry.expect("read native policy fixture entry").file_name())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(entries, vec![std::ffi::OsString::from("copilot")]);
|
||||
|
||||
fixture.cleanup().expect("clean native policy fixture");
|
||||
}
|
||||
@@ -307,6 +307,7 @@ fn unix_pty_preserves_prompt_and_cli_interaction() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn unix_pty_install_offers_profile_update_and_launches_without_refreshing_path() {
|
||||
for accept_profile in [false, true] {
|
||||
@@ -452,6 +453,243 @@ printf '\nInstallation complete\n'
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_pty_installer_inherits_path_and_launches_from_fallback_directory() {
|
||||
let root = tempfile::tempdir().expect("Linux installer test directory");
|
||||
let home = root.path().join("home");
|
||||
let working = root.path().join("working");
|
||||
let state = root.path().join("state");
|
||||
let usr_bin = root.path().join("usr").join("bin");
|
||||
let merged_bin = root.path().join("bin");
|
||||
let self_alias = root.path().join("self-alias");
|
||||
let legacy = root.path().join("legacy");
|
||||
let prefix = root.path().join("install prefix");
|
||||
for directory in [
|
||||
&home,
|
||||
&working,
|
||||
&state,
|
||||
&usr_bin,
|
||||
&self_alias,
|
||||
&legacy,
|
||||
&prefix.join("bin"),
|
||||
] {
|
||||
fs::create_dir_all(directory).expect("fixture directory");
|
||||
}
|
||||
std::os::unix::fs::symlink(&usr_bin, &merged_bin).expect("merged /usr directory alias");
|
||||
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_copilot"), self_alias.join("copilot"))
|
||||
.expect("running shim alias");
|
||||
fs::copy(env!("CARGO_BIN_EXE_copilot"), usr_bin.join("copilot")).expect("copied Rust shim");
|
||||
write_executable(
|
||||
&legacy.join("copilot"),
|
||||
include_bytes!("fixtures/legacy/posix-launcher.sh"),
|
||||
);
|
||||
std::os::unix::fs::symlink("/bin/bash", usr_bin.join("bash")).expect("Bash");
|
||||
write_executable(
|
||||
&usr_bin.join("cp"),
|
||||
br#"#!/bin/sh
|
||||
printf 'cp\n' >> "$COPILOT_SHIM_TEST_STATE/utilities"
|
||||
exec /bin/cp "$@"
|
||||
"#,
|
||||
);
|
||||
write_executable(
|
||||
&usr_bin.join("curl"),
|
||||
br#"#!/bin/sh
|
||||
printf 'download\n' >> "$COPILOT_SHIM_TEST_STATE/downloads"
|
||||
printf '%s\0' "$PATH" >> "$COPILOT_SHIM_TEST_STATE/downloader-paths"
|
||||
cp "$COPILOT_SHIM_TEST_INSTALLER" "$4"
|
||||
"#,
|
||||
);
|
||||
let cli = root.path().join("cli");
|
||||
write_executable(
|
||||
&cli,
|
||||
br#"#!/bin/sh
|
||||
printf 'launch\n' >> "$COPILOT_SHIM_TEST_STATE/cli-launches"
|
||||
printf '%s\0' "$PATH" >> "$COPILOT_SHIM_TEST_STATE/cli-paths"
|
||||
printf '%s\0' "$(pwd -P)" >> "$COPILOT_SHIM_TEST_STATE/cwds"
|
||||
printf '%s\0' "$COPILOT_SHIM_TEST_ENV" >> "$COPILOT_SHIM_TEST_STATE/environments"
|
||||
for argument do
|
||||
printf '%s\0' "$argument" >> "$COPILOT_SHIM_TEST_STATE/arguments"
|
||||
done
|
||||
printf '\n' >> "$COPILOT_SHIM_TEST_STATE/arguments"
|
||||
printf 'CLI_READY\n'
|
||||
IFS= read -r input || exit 91
|
||||
printf '%s\0' "$input" >> "$COPILOT_SHIM_TEST_STATE/stdin"
|
||||
printf 'CLI_STDOUT_EXACT\n'
|
||||
printf 'CLI_STDERR_EXACT\n' >&2
|
||||
exit 47
|
||||
"#,
|
||||
);
|
||||
let installer = root.path().join("installer.sh");
|
||||
fs::write(
|
||||
&installer,
|
||||
r#"#!/bin/bash
|
||||
set -e
|
||||
printf '%s\0' "$PATH" > "$COPILOT_SHIM_TEST_STATE/installer-path"
|
||||
for utility in copilot curl bash cp; do
|
||||
command -v "$utility" > "$COPILOT_SHIM_TEST_STATE/installer-$utility"
|
||||
done
|
||||
cp "$COPILOT_SHIM_TEST_CLI" "$PREFIX/bin/copilot"
|
||||
if command -v copilot >/dev/null 2>&1; then
|
||||
printf 'Installer sees shims and skips profile update\n'
|
||||
else
|
||||
printf 'Expected a shim on PATH\n' >&2
|
||||
exit 91
|
||||
fi
|
||||
printf 'Installation complete\n'
|
||||
"#,
|
||||
)
|
||||
.expect("fake installer");
|
||||
let path = std::env::join_paths([&self_alias, &merged_bin, &usr_bin, &merged_bin, &legacy])
|
||||
.expect("PATH");
|
||||
assert!(
|
||||
!std::env::split_paths(&path).any(|entry| entry == prefix.join("bin")),
|
||||
"the fake CLI must be outside PATH"
|
||||
);
|
||||
let environment = [
|
||||
("HOME", home.as_os_str()),
|
||||
("PREFIX", prefix.as_os_str()),
|
||||
("SHELL", OsStr::new("/bin/bash")),
|
||||
("COPILOT_SHIM_TEST_STATE", state.as_os_str()),
|
||||
("COPILOT_SHIM_TEST_ENV", OsStr::new("inherited")),
|
||||
("COPILOT_SHIM_TEST_INSTALLER", installer.as_os_str()),
|
||||
("COPILOT_SHIM_TEST_CLI", cli.as_os_str()),
|
||||
];
|
||||
let arguments: Vec<OsString> = ["--resume", "session with spaces", "", "quote'\""]
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
let mut first = PtyProcess::spawn(&arguments, &path, &working, &environment);
|
||||
first.wait_for(INSTALL_PROMPT);
|
||||
first.write(b"y\n");
|
||||
first.wait_for(b"CLI_READY\n");
|
||||
first.write(b"first-input\n");
|
||||
first.wait_for(b"CLI_STDERR_EXACT\n");
|
||||
let first_status = first.wait_for_exit();
|
||||
|
||||
let mut second = PtyProcess::spawn(&arguments, &path, &working, &environment);
|
||||
second.wait_for(b"CLI_READY\n");
|
||||
second.write(b"second-input\n");
|
||||
second.wait_for(b"CLI_STDERR_EXACT\n");
|
||||
let second_status = second.wait_for_exit();
|
||||
|
||||
let mut expected_arguments = Vec::new();
|
||||
for argument in &arguments {
|
||||
expected_arguments.extend_from_slice(argument.as_bytes());
|
||||
expected_arguments.push(0);
|
||||
}
|
||||
expected_arguments.push(b'\n');
|
||||
let mut expected_arguments_for_both = expected_arguments.clone();
|
||||
expected_arguments_for_both.extend_from_slice(&expected_arguments);
|
||||
let mut expected_path = path.as_bytes().to_vec();
|
||||
expected_path.push(0);
|
||||
let mut expected_paths = expected_path.clone();
|
||||
expected_paths.extend_from_slice(&expected_path);
|
||||
let mut expected_cwd = fs::canonicalize(&working)
|
||||
.expect("cwd")
|
||||
.as_os_str()
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
expected_cwd.push(0);
|
||||
let mut expected_cwds = expected_cwd.clone();
|
||||
expected_cwds.extend_from_slice(&expected_cwd);
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct InstallerSnapshot {
|
||||
first_status: Option<i32>,
|
||||
second_status: Option<i32>,
|
||||
cli_installed: bool,
|
||||
profile_created: bool,
|
||||
downloads: Vec<u8>,
|
||||
utility_invocations: Vec<u8>,
|
||||
installer_path: Vec<u8>,
|
||||
downloader_paths: Vec<u8>,
|
||||
installer_copilot: Vec<u8>,
|
||||
installer_curl: Vec<u8>,
|
||||
installer_bash: Vec<u8>,
|
||||
installer_cp: Vec<u8>,
|
||||
cli_launches: Vec<u8>,
|
||||
cli_paths: Vec<u8>,
|
||||
arguments: Vec<u8>,
|
||||
environments: Vec<u8>,
|
||||
stdin: Vec<u8>,
|
||||
cwds: Vec<u8>,
|
||||
first_install_prompts: usize,
|
||||
second_install_prompts: usize,
|
||||
installer_saw_shims: bool,
|
||||
installer_profile_prompt: bool,
|
||||
first_stdout: bool,
|
||||
first_stderr: bool,
|
||||
second_stdout: bool,
|
||||
second_stderr: bool,
|
||||
}
|
||||
let actual = InstallerSnapshot {
|
||||
first_status: first_status.code(),
|
||||
second_status: second_status.code(),
|
||||
cli_installed: prefix.join("bin").join("copilot").is_file(),
|
||||
profile_created: home.join(".profile").exists(),
|
||||
downloads: read(&state.join("downloads")),
|
||||
utility_invocations: read(&state.join("utilities")),
|
||||
installer_path: read(&state.join("installer-path")),
|
||||
downloader_paths: read(&state.join("downloader-paths")),
|
||||
installer_copilot: trim_newline(read(&state.join("installer-copilot"))),
|
||||
installer_curl: trim_newline(read(&state.join("installer-curl"))),
|
||||
installer_bash: trim_newline(read(&state.join("installer-bash"))),
|
||||
installer_cp: trim_newline(read(&state.join("installer-cp"))),
|
||||
cli_launches: read(&state.join("cli-launches")),
|
||||
cli_paths: read(&state.join("cli-paths")),
|
||||
arguments: read(&state.join("arguments")),
|
||||
environments: read(&state.join("environments")),
|
||||
stdin: read(&state.join("stdin")),
|
||||
cwds: read(&state.join("cwds")),
|
||||
first_install_prompts: count_bytes(first.transcript(), INSTALL_PROMPT),
|
||||
second_install_prompts: count_bytes(second.transcript(), INSTALL_PROMPT),
|
||||
installer_saw_shims: contains_bytes(
|
||||
first.transcript(),
|
||||
b"Installer sees shims and skips profile update\n",
|
||||
),
|
||||
installer_profile_prompt: contains_bytes(first.transcript(), b".profile? [y/N] "),
|
||||
first_stdout: contains_bytes(first.transcript(), b"CLI_STDOUT_EXACT\n"),
|
||||
first_stderr: contains_bytes(first.transcript(), b"CLI_STDERR_EXACT\n"),
|
||||
second_stdout: contains_bytes(second.transcript(), b"CLI_STDOUT_EXACT\n"),
|
||||
second_stderr: contains_bytes(second.transcript(), b"CLI_STDERR_EXACT\n"),
|
||||
};
|
||||
let expected = InstallerSnapshot {
|
||||
first_status: Some(47),
|
||||
second_status: Some(47),
|
||||
cli_installed: true,
|
||||
profile_created: false,
|
||||
downloads: b"download\n".to_vec(),
|
||||
utility_invocations: b"cp\ncp\n".to_vec(),
|
||||
installer_path: expected_path.clone(),
|
||||
downloader_paths: expected_path,
|
||||
installer_copilot: self_alias.join("copilot").as_os_str().as_bytes().to_vec(),
|
||||
installer_curl: merged_bin.join("curl").as_os_str().as_bytes().to_vec(),
|
||||
installer_bash: merged_bin.join("bash").as_os_str().as_bytes().to_vec(),
|
||||
installer_cp: merged_bin.join("cp").as_os_str().as_bytes().to_vec(),
|
||||
cli_launches: b"launch\nlaunch\n".to_vec(),
|
||||
cli_paths: expected_paths,
|
||||
arguments: expected_arguments_for_both,
|
||||
environments: b"inherited\0inherited\0".to_vec(),
|
||||
stdin: b"first-input\0second-input\0".to_vec(),
|
||||
cwds: expected_cwds,
|
||||
first_install_prompts: 1,
|
||||
second_install_prompts: 0,
|
||||
installer_saw_shims: true,
|
||||
installer_profile_prompt: false,
|
||||
first_stdout: true,
|
||||
first_stderr: true,
|
||||
second_stdout: true,
|
||||
second_stderr: true,
|
||||
};
|
||||
assert_eq!(
|
||||
actual,
|
||||
expected,
|
||||
"first PTY transcript:\n{}\nsecond PTY transcript:\n{}",
|
||||
String::from_utf8_lossy(first.transcript()),
|
||||
String::from_utf8_lossy(second.transcript())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_pty_ctrl_c_cancels_without_fallback() {
|
||||
let root = tempfile::tempdir().expect("create cancellation test directory");
|
||||
|
||||
+42
-1
@@ -975,7 +975,48 @@ export default defineConfig(
|
||||
]
|
||||
}
|
||||
],
|
||||
'local/code-run-with-faked-timers-whole-test': 'warn'
|
||||
'local/code-run-with-faked-timers-whole-test': 'warn',
|
||||
'local/code-no-timeout-polling-loop': [
|
||||
'warn',
|
||||
{
|
||||
// Files should (only) be removed from the list once they no longer poll
|
||||
'exclude': [
|
||||
'src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts',
|
||||
'src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/agentHostSessionStateStore.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/agentHostWorktreeCleanupService.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/agentService.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/agentSessionResidency.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/agentSideEffects.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/copilotAgent.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/localModelBridge/copilotRequestHandler.test.ts',
|
||||
'src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts',
|
||||
'src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts',
|
||||
'src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts',
|
||||
'src/vs/sessions/contrib/layout/test/browser/chatLayoutOwnership.test.ts',
|
||||
'src/vs/sessions/contrib/layout/test/browser/desktopLayoutController.test.ts',
|
||||
'src/vs/workbench/api/test/browser/mainThreadCustomEditors.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/browser/agentHostCustomizationSidecar.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/browser/agentHostUsageSidecar.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/browser/widget/chatWorkingLogoAtlas.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/common/plugins/fileBackedInstalledPluginsStore.test.ts',
|
||||
'src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts',
|
||||
'src/vs/workbench/services/configuration/test/browser/configurationService.test.ts',
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// git extension - ban non-type imports from git.d.ts (use git.constants for runtime values)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: migrate-customizations
|
||||
description: Guide a safe, reversible migration of VS Code local agent customizations when launched from the Agent Customizations Migrations page with VS Code-generated migration context.
|
||||
description: Guide a safe, reversible migration of VS Code agent customizations when launched from the Agent Customizations Migrations page with VS Code-generated migration context.
|
||||
argument-hint: Launch from Agent Customizations > Migrations > Migrate with Agent
|
||||
user-invocable: true
|
||||
disable-model-invocation: true
|
||||
@@ -16,7 +16,6 @@ Before doing anything else, verify that the invoking prompt includes all of the
|
||||
|
||||
- Selected harness
|
||||
- Migration telemetry flow
|
||||
- Recovery bundle folder and filesystem path
|
||||
- Customizations that need migration
|
||||
- Valid target folders reported by the selected harness
|
||||
|
||||
@@ -26,18 +25,14 @@ Read [migration techniques](./references/migration-techniques.md) before proposi
|
||||
|
||||
## Safety Contract
|
||||
|
||||
1. Never modify a customization before creating a recovery bundle.
|
||||
2. Use the recovery bundle folder supplied in the invoking prompt. VS Code created and is watching this folder; do not move it or substitute another location.
|
||||
3. In the recovery bundle, create:
|
||||
- `migration-log.md`, containing the selected harness, scope, timestamps, source and destination paths, commands or tools used, validation results, and every user decision.
|
||||
- `backups/`, containing a byte-for-byte copy of every file before its first modification or deletion.
|
||||
- `restore.md`, containing exact steps for restoring the backups and reverting newly created files.
|
||||
- `migration-results.json`, containing only the privacy-safe aggregate schema described in Finish. VS Code watches this file for the final migration outcome.
|
||||
4. Append to the log after every attempted operation, including failures and rollbacks.
|
||||
5. Approval to migrate a group authorizes destination writes only; it does not authorize deleting source files, removing source MCP entries, or clearing location settings. Preserve sources by default. After the destination is validated, ask separately for explicit approval for each kind of cleanup. If runtime validation is not possible, explain what was and was not verified, keep the source, and ask whether to defer cleanup.
|
||||
6. Stop on an unexpected source change, destination conflict, invalid configuration, failed backup, or failed validation. Explain the problem instead of guessing.
|
||||
7. Never include customization names, paths, contents, MCP configuration, or other user data in telemetry.
|
||||
8. Classify each inventory item exactly once using its supplied category and scope. Do not offer the same item again in another group; if groups overlap, explain the overlap and use the item's inventory category to decide where it is handled.
|
||||
1. VS Code created and verified a private recovery bundle before starting this workflow. Do not search for, inspect, modify, move, or replace its manifest or backups.
|
||||
2. The recovery bundle restores original sources only. It does not remove or revert destination files created or changed during migration.
|
||||
3. Call `report_customization_migration` exactly once using the privacy-safe aggregate schema described in Finish. This internal tool is available only in the migration chat and does not require filesystem access.
|
||||
4. Approval to migrate a group authorizes destination writes only; it does not authorize deleting source files, removing source MCP entries, or clearing location settings. Preserve sources by default. After the destination is validated, ask separately for explicit approval for each kind of cleanup. If runtime validation is not possible, explain what was and was not verified, keep the source, and ask whether to defer cleanup.
|
||||
5. Stop on an unexpected source change, destination conflict, invalid configuration, or failed validation. Explain the problem instead of guessing.
|
||||
6. Never include customization names, paths, contents, MCP configuration, or other user data in telemetry.
|
||||
7. Classify each inventory item exactly once using its supplied category and scope. Do not offer the same item again in another group; if groups overlap, explain the overlap and use the item's inventory category to decide where it is handled.
|
||||
8. When using `ask_user`, keep the question short and write it as a single sentence so it remains readable in the chat viewer.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -76,7 +71,7 @@ Work one group at a time. Do not request approval for all writes at once.
|
||||
|
||||
### 3. Migrate Prompt Files to Skills
|
||||
|
||||
Explain the compatibility reason for converting each prompt to a skill. Convert each selected prompt into a skill directory with a `SKILL.md`, preserving supported name, description, argument guidance, invocation semantics, and body content. Add `disable-model-invocation: true` when needed to preserve an explicit-invocation-only prompt. Record unsupported frontmatter in the log for review; do not silently drop behavior.
|
||||
Explain the compatibility reason for converting each prompt to a skill. Convert each selected prompt into a skill directory with a `SKILL.md`, preserving supported name, description, argument guidance, invocation semantics, and body content. Add `disable-model-invocation: true` when needed to preserve an explicit-invocation-only prompt. Report unsupported frontmatter for review; do not silently drop behavior.
|
||||
|
||||
Validate that each skill has valid frontmatter, a meaningful description, and a folder name that matches the skill name. Distinguish structural validation from runtime validation: test discovery and representative invocation in the selected destination harness when possible. A file inspection or a test against a different harness is not proof that the destination harness loads the skill. If runtime validation requires a new session or is otherwise unavailable, say so, leave the source in place, and defer cleanup unless the user explicitly chooses otherwise after hearing the limitation.
|
||||
|
||||
@@ -101,7 +96,7 @@ Handle only items reported as `configuredLocations` and not already handled in a
|
||||
|
||||
### 7. Finish
|
||||
|
||||
Before the final summary, write `migration-results.json` in the recovery bundle:
|
||||
Before the final summary, call `report_customization_migration` with:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -127,11 +122,11 @@ Aggregate results by:
|
||||
- customization type: `agent`, `instructions`, `skill`, or `mcpServer`;
|
||||
- outcome: `migrated`, `skipped`, or `failed`.
|
||||
|
||||
The total reported for each category, scope, and customization type must not exceed the inventory supplied by VS Code. The result file must never include names, paths, contents, configuration values, error messages, or other user data.
|
||||
The total reported for each category, scope, and customization type must not exceed the inventory supplied by VS Code. The tool input must never include names, paths, contents, configuration values, error messages, or other user data.
|
||||
|
||||
Write the validated JSON to the exact recovery bundle supplied by VS Code. Do not write a result file when no migration telemetry flow was supplied.
|
||||
Do not print this payload in the chat response and do not write it to a file. If the tool call fails, explain the failure and retry once with a corrected payload. Do not claim that VS Code recorded the outcome unless the tool succeeds. Do not call the tool when no migration telemetry flow was supplied.
|
||||
|
||||
If the user stops the workflow, set `cancelled` to `true`, record completed work with its actual outcome, mark the remaining inventory as `skipped`, and still validate and write the result file before the final cancellation summary.
|
||||
If the user stops the workflow, set `cancelled` to `true`, record completed work with its actual outcome, mark the remaining inventory as `skipped`, and still call the tool before the final cancellation summary.
|
||||
|
||||
Summarize:
|
||||
|
||||
@@ -139,7 +134,7 @@ Summarize:
|
||||
- created, modified, deleted, and retained files;
|
||||
- validation performed;
|
||||
- cleanup still pending;
|
||||
- recovery bundle and restoration instructions;
|
||||
- recovery bundle, noting that VS Code can restore original sources but does not revert destination files;
|
||||
- pull request URL when one was requested.
|
||||
|
||||
Keep the recovery bundle until the user confirms the migrated customizations work.
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "copilot-chat",
|
||||
"version": "0.71.0",
|
||||
"version": "0.72.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "copilot-chat",
|
||||
"version": "0.71.0",
|
||||
"version": "0.72.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"dependencies": {
|
||||
@@ -143,7 +143,7 @@
|
||||
"engines": {
|
||||
"node": ">=22.14.0",
|
||||
"npm": ">=9.0.0",
|
||||
"vscode": "^1.143.0"
|
||||
"vscode": "^1.144.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "copilot-chat",
|
||||
"displayName": "GitHub Copilot",
|
||||
"description": "AI chat features powered by Copilot",
|
||||
"version": "0.71.0",
|
||||
"version": "0.72.0",
|
||||
"build": "1",
|
||||
"completionsCoreVersion": "1.378.1799",
|
||||
"internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917",
|
||||
@@ -22,7 +22,7 @@
|
||||
"icon": "assets/copilot.png",
|
||||
"pricing": "Trial",
|
||||
"engines": {
|
||||
"vscode": "^1.143.0",
|
||||
"vscode": "^1.144.0",
|
||||
"npm": ">=9.0.0",
|
||||
"node": ">=22.14.0"
|
||||
},
|
||||
@@ -82,6 +82,7 @@
|
||||
"onLanguageModelChat:copilot",
|
||||
"onUri",
|
||||
"onCommand:_github.copilot.chat.reportModelFeedbackSurvey",
|
||||
"onCommand:github.copilot.survey.signalUsage",
|
||||
"onSpeech",
|
||||
"onFileSystem:ccreq",
|
||||
"onFileSystem:ccsettings"
|
||||
|
||||
+380
-6
@@ -3,27 +3,50 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import * as nodePath from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { Uri } from 'vscode';
|
||||
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
|
||||
import { createDirectoryIfNotExists, IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
|
||||
import { FileType } from '../../../../platform/filesystem/common/fileTypes';
|
||||
import { ILogService } from '../../../../platform/log/common/logService';
|
||||
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
|
||||
import { findLast } from '../../../../util/vs/base/common/arraysFind';
|
||||
import { disposableTimeout, SequencerByKey, ThrottledDelayer } from '../../../../util/vs/base/common/async';
|
||||
import { Disposable, DisposableMap } from '../../../../util/vs/base/common/lifecycle';
|
||||
import { disposableTimeout, SequencerByKey, ThrottledDelayer, timeout } from '../../../../util/vs/base/common/async';
|
||||
import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';
|
||||
import { Disposable, DisposableMap, toDisposable } from '../../../../util/vs/base/common/lifecycle';
|
||||
import { generateUuid } from '../../../../util/vs/base/common/uuid';
|
||||
import { dirname } from '../../../../util/vs/base/common/resources';
|
||||
import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry } from '../../common/chatSessionMetadataStore';
|
||||
import { ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';
|
||||
import { ChatSessionWorktreeData, ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';
|
||||
import { isUntitledSessionId } from '../../common/utils';
|
||||
import { IWorkspaceInfo } from '../../common/workspaceInfo';
|
||||
import { getCopilotBulkMetadataFile, getCopilotCLISessionDir } from '../../copilotcli/node/cliHelpers';
|
||||
import { ICopilotCLIAgents } from '../../copilotcli/node/copilotCli';
|
||||
|
||||
// const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders';
|
||||
// const WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
|
||||
/** Original `globalState` stores, read only by the one-time per-session backfill. */
|
||||
const LEGACY_WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders';
|
||||
const LEGACY_WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
|
||||
const LEGACY_BULK_METADATA_FILENAME = 'copilotcli.session.metadata.json';
|
||||
const LEGACY_BULK_MIGRATED_KEY = 'github.copilot.cli.legacyBulkMigrated';
|
||||
const SESSION_METADATA_BACKFILLED_KEY = 'github.copilot.cli.sessionMetadataBackfilled.v1';
|
||||
const SESSION_METADATA_BACKFILL_ATTEMPTS_KEY = 'github.copilot.cli.sessionMetadataBackfillAttempts.v1';
|
||||
/** Delay before the one-time backfill starts, so it does not compete with startup session listing and restore. */
|
||||
const SESSION_METADATA_BACKFILL_DELAY_MS = 60_000;
|
||||
/** The backfill pauses after every batch so catalogs with thousands of sessions are processed at a bounded rate. */
|
||||
const SESSION_METADATA_BACKFILL_BATCH_SIZE = 100;
|
||||
const SESSION_METADATA_BACKFILL_BATCH_PAUSE_MS = 50;
|
||||
/** A run stops after this many consecutive write failures (e.g. a read-only file system) and retries on a later start. */
|
||||
const SESSION_METADATA_BACKFILL_MAX_CONSECUTIVE_FAILURES = 20;
|
||||
/** Incomplete runs are retried on later starts up to this many times. */
|
||||
const SESSION_METADATA_BACKFILL_MAX_ATTEMPTS = 3;
|
||||
const SESSION_METADATA_BACKFILL_MAX_LOGGED_FAILURES = 10;
|
||||
const REQUEST_MAPPING_FILENAME = 'vscode.requests.metadata.json';
|
||||
const SAFE_SESSION_ID = /^[\w.-]+$/;
|
||||
|
||||
type BulkMetadataFileStatus = 'present' | 'missing' | 'invalid';
|
||||
type SessionMetadataBackfillResult = 'exists' | 'missingSessionDirectory' | 'written' | 'failed';
|
||||
|
||||
/**
|
||||
* Maximum number of sessions kept in the shared bulk metadata cache file
|
||||
@@ -74,18 +97,39 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
|
||||
/** Serializes bulk-file flush against {@link reloadBulkFromDisk}. */
|
||||
private readonly _bulkSequencer = new SequencerByKey<string>();
|
||||
|
||||
/** Background one-time backfill of per-session metadata files; see {@link backfillSessionMetadataFiles}. */
|
||||
private readonly _sessionMetadataBackfill: Promise<void>;
|
||||
/** Cancelled on dispose so the backfill's delays and loop stop promptly. */
|
||||
private readonly _sessionMetadataBackfillCancellation = new CancellationTokenSource();
|
||||
/** Whether the per-install bulk file had been merged on an earlier start, captured before {@link initializeStorage} merges it. */
|
||||
private readonly _legacyBulkMigratedBeforeStartup: boolean;
|
||||
|
||||
constructor(
|
||||
@IFileSystemService private readonly fileSystemService: IFileSystemService,
|
||||
@ILogService private readonly logService: ILogService,
|
||||
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
|
||||
@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,
|
||||
@ITelemetryService private readonly telemetryService: ITelemetryService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._legacyBulkMigratedBeforeStartup = this.extensionContext.globalState.get<boolean>(LEGACY_BULK_MIGRATED_KEY) === true;
|
||||
this._ready = this.initializeStorage();
|
||||
this._ready.catch(error => {
|
||||
this.logService.error('[ChatSessionMetadataStore] Initialization failed: ', error);
|
||||
});
|
||||
// Deferred past startup; reads and writes never wait for it.
|
||||
this._register(toDisposable(() => this._sessionMetadataBackfillCancellation.dispose(true)));
|
||||
this._sessionMetadataBackfill = this._ready.catch(() => undefined)
|
||||
.then(() => this.backfillSessionMetadataFilesAfterStartup())
|
||||
.catch(error => {
|
||||
this.logService.error('[ChatSessionMetadataStore] Session metadata backfill failed: ', error);
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves once the one-time per-session metadata backfill has finished. */
|
||||
public whenSessionMetadataBackfilled(): Promise<void> {
|
||||
return this._sessionMetadataBackfill;
|
||||
}
|
||||
|
||||
public refresh(): Promise<void> {
|
||||
@@ -671,7 +715,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyCacheFile = Uri.joinPath(this.extensionContext.globalStorageUri, 'copilotcli', LEGACY_BULK_METADATA_FILENAME);
|
||||
const legacyCacheFile = this.getLegacyBulkFileUri();
|
||||
let legacyData: Record<string, ChatSessionMetadataFile>;
|
||||
try {
|
||||
const raw = await this.fileSystemService.readFile(legacyCacheFile);
|
||||
@@ -723,4 +767,334 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
|
||||
this.logService.error('[ChatSessionMetadataStore] Failed to migrate legacy bulk file: ', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the original pre-file metadata store: two extension `globalState`
|
||||
* keys that were never cleared. Users who skipped every build that copied them
|
||||
* into the per-install file (2026-02 to 2026-04) have their oldest sessions'
|
||||
* metadata only here. Parsed the way that copy did; worktree data wins.
|
||||
*/
|
||||
private readGlobalStateMetadata(): Record<string, ChatSessionMetadataFile> {
|
||||
const result: Record<string, ChatSessionMetadataFile> = {};
|
||||
const folders = this.extensionContext.globalState.get<Record<string, string | Partial<WorkspaceFolderEntry> | undefined>>(LEGACY_WORKSPACE_FOLDER_MEMENTO_KEY, {});
|
||||
for (const [sessionId, entry] of Object.entries(folders ?? {})) {
|
||||
if (entry && typeof entry === 'object' && typeof entry.folderPath === 'string' && entry.folderPath && typeof entry.timestamp === 'number' && entry.timestamp) {
|
||||
result[sessionId] = { workspaceFolder: { folderPath: entry.folderPath, timestamp: entry.timestamp } };
|
||||
}
|
||||
}
|
||||
const worktrees = this.extensionContext.globalState.get<Record<string, string | Partial<ChatSessionWorktreeData> | undefined>>(LEGACY_WORKTREE_MEMENTO_KEY, {});
|
||||
for (const [sessionId, value] of Object.entries(worktrees ?? {})) {
|
||||
if (!value || typeof value !== 'object' || typeof value.data !== 'string') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value.data);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
result[sessionId] = { worktreeProperties: (value.version === 1 ? { ...parsed, version: 1 } : parsed) as ChatSessionWorktreeProperties };
|
||||
}
|
||||
} catch {
|
||||
// Unreadable entry; skip it.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private getLegacyBulkFileUri(): vscode.Uri {
|
||||
return Uri.joinPath(this.extensionContext.globalStorageUri, 'copilotcli', LEGACY_BULK_METADATA_FILENAME);
|
||||
}
|
||||
|
||||
private isSessionMetadataBackfilled(): boolean {
|
||||
return this.extensionContext.globalState.get<boolean>(SESSION_METADATA_BACKFILLED_KEY) === true;
|
||||
}
|
||||
|
||||
private async backfillSessionMetadataFilesAfterStartup(): Promise<void> {
|
||||
if (this.isSessionMetadataBackfilled()) {
|
||||
return;
|
||||
}
|
||||
if (await this.waitForSessionMetadataBackfill(SESSION_METADATA_BACKFILL_DELAY_MS)) {
|
||||
await this.backfillSessionMetadataFiles();
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits unless the store is disposed first; returns whether the backfill may continue. */
|
||||
private async waitForSessionMetadataBackfill(millis: number): Promise<boolean> {
|
||||
const token = this._sessionMetadataBackfillCancellation.token;
|
||||
try {
|
||||
await timeout(millis, token);
|
||||
} catch {
|
||||
// Cancelled on dispose.
|
||||
}
|
||||
return !token.isCancellationRequested;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time backfill of per-session `vscode.metadata.json` files from both bulk
|
||||
* stores (the shared `~/.copilot/` cache and the older per-install globalStorage
|
||||
* file).
|
||||
*
|
||||
* The agent host identifies legacy VS Code Copilot CLI sessions solely by the
|
||||
* per-session file, so a session whose metadata only exists in a bulk file
|
||||
* (e.g. an older session whose per-session write never landed) would not be
|
||||
* migrated once the extension-host provider is retired. Existing per-session
|
||||
* files are never overwritten, non-VS Code entries are skipped, and the entry
|
||||
* is written as stored so `created`/`modified` keep their original values.
|
||||
*
|
||||
* It only adds missing per-session files: the in-memory cache, the bulk file
|
||||
* and the session list are left untouched (the SDK derives a session's modified
|
||||
* time from its event log, so adding a file does not reorder sessions). Files
|
||||
* are processed one at a time with a short pause after every batch, so large
|
||||
* catalogs are handled at a bounded rate. Every window runs it until one
|
||||
* completes, so it stops early once another window has recorded completion.
|
||||
*/
|
||||
private async backfillSessionMetadataFiles(): Promise<void> {
|
||||
if (this.isSessionMetadataBackfilled()) {
|
||||
return;
|
||||
}
|
||||
const startTime = Date.now();
|
||||
const [shared, perInstall] = await Promise.all([
|
||||
this.readBulkMetadataFile(this._cacheFile),
|
||||
this.readBulkMetadataFile(this.getLegacyBulkFileUri()),
|
||||
]);
|
||||
const globalStateData = this.readGlobalStateMetadata();
|
||||
|
||||
// Newest `modified` wins when a session is in both files.
|
||||
const candidates = new Map<string, ChatSessionMetadataFile>();
|
||||
for (const data of [shared.data, perInstall.data]) {
|
||||
for (const [sessionId, entry] of Object.entries(data)) {
|
||||
const existing = candidates.get(sessionId);
|
||||
if (!existing || (entry?.modified ?? 0) > (existing.modified ?? 0)) {
|
||||
candidates.set(sessionId, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The oldest store only fills gaps: the bulk files hold newer copies of the same sessions.
|
||||
let globalStateOnly = 0;
|
||||
for (const [sessionId, entry] of Object.entries(globalStateData)) {
|
||||
if (!candidates.has(sessionId)) {
|
||||
candidates.set(sessionId, entry);
|
||||
globalStateOnly++;
|
||||
}
|
||||
}
|
||||
|
||||
const counts: Record<SessionMetadataBackfillResult | 'notVSCode', number> = { notVSCode: 0, exists: 0, missingSessionDirectory: 0, written: 0, failed: 0 };
|
||||
let checked = 0;
|
||||
let consecutiveFailures = 0;
|
||||
let aborted = false;
|
||||
for (const [sessionId, entry] of candidates) {
|
||||
if (this._sessionMetadataBackfillCancellation.token.isCancellationRequested || this.isSessionMetadataBackfilled()) {
|
||||
return;
|
||||
}
|
||||
if (!isBackfillableSessionMetadata(sessionId, entry)) {
|
||||
counts.notVSCode++;
|
||||
continue;
|
||||
}
|
||||
if (checked > 0 && checked % SESSION_METADATA_BACKFILL_BATCH_SIZE === 0 && !await this.waitForSessionMetadataBackfill(SESSION_METADATA_BACKFILL_BATCH_PAUSE_MS)) {
|
||||
return;
|
||||
}
|
||||
checked++;
|
||||
const result = await this._metadataWriteSequencer.queue(sessionId, () => this.backfillSessionMetadataFile(sessionId, entry, counts.failed < SESSION_METADATA_BACKFILL_MAX_LOGGED_FAILURES));
|
||||
counts[result]++;
|
||||
consecutiveFailures = result === 'failed' ? consecutiveFailures + 1 : 0;
|
||||
if (consecutiveFailures >= SESSION_METADATA_BACKFILL_MAX_CONSECUTIVE_FAILURES) {
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Incomplete runs retry on later starts, but only a bounded number of times.
|
||||
const attempt = (this.extensionContext.globalState.get<number>(SESSION_METADATA_BACKFILL_ATTEMPTS_KEY) ?? 0) + 1;
|
||||
const completed = !aborted && counts.failed === 0 && shared.status !== 'invalid' && perInstall.status !== 'invalid';
|
||||
if (completed || attempt >= SESSION_METADATA_BACKFILL_MAX_ATTEMPTS) {
|
||||
await this.extensionContext.globalState.update(SESSION_METADATA_BACKFILLED_KEY, true);
|
||||
} else {
|
||||
await this.extensionContext.globalState.update(SESSION_METADATA_BACKFILL_ATTEMPTS_KEY, attempt);
|
||||
}
|
||||
this.logService.info(`[ChatSessionMetadataStore] Session metadata backfill: attempt=${attempt}, completed=${completed}, aborted=${aborted}, shared=${shared.status}(${Object.keys(shared.data).length}), perInstall=${perInstall.status}(${Object.keys(perInstall.data).length}), globalState=${Object.keys(globalStateData).length}, candidates=${candidates.size}, notVSCode=${counts.notVSCode}, exists=${counts.exists}, missingSessionDirectory=${counts.missingSessionDirectory}, written=${counts.written}, failed=${counts.failed}`);
|
||||
|
||||
/* __GDPR__
|
||||
"copilotcli.sessionMetadataBackfill" : {
|
||||
"owner": "vijayupadya",
|
||||
"comment": "One-time backfill of per-session Copilot CLI metadata files from the bulk metadata stores, so legacy sessions stay discoverable by the agent host after the extension-host provider is retired. Contains counts only.",
|
||||
"sharedBulkFile" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the shared ~/.copilot bulk metadata file was present, missing or unreadable." },
|
||||
"perInstallBulkFile" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the older per-install globalStorage bulk metadata file was present, missing or unreadable." },
|
||||
"legacyBulkMigrated" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the per-install bulk file had already been merged into the shared file on an earlier start (captured before this start's merge)." },
|
||||
"sharedAtCap" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the shared bulk metadata file holds at least its 1000-entry cap, so older entries may have been evicted from it." },
|
||||
"completed" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether this run finished without failures." },
|
||||
"aborted" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether this run stopped early after repeated consecutive write failures." },
|
||||
"attempt" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Which run this is; incomplete runs are retried on later starts up to a fixed limit." },
|
||||
"sharedEntryCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of entries in the shared bulk metadata file." },
|
||||
"perInstallEntryCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of entries in the per-install bulk metadata file." },
|
||||
"globalStateEntryCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of sessions in the original extension globalState metadata store." },
|
||||
"globalStateOnlyCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Sessions found only in the original globalState store and in neither bulk file." },
|
||||
"candidateCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of distinct sessions across both bulk files." },
|
||||
"notVSCodeCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Entries skipped because they are not VS Code-created sessions or have an invalid session id." },
|
||||
"existingFileCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "VS Code sessions that already had a per-session metadata file." },
|
||||
"missingSessionDirectoryCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "VS Code sessions whose session-state directory no longer exists." },
|
||||
"writtenCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "VS Code sessions whose missing per-session metadata file was written by the backfill." },
|
||||
"failedCount" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Per-session metadata files that could not be written." },
|
||||
"durationMs" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time taken by the backfill in milliseconds." }
|
||||
}
|
||||
*/
|
||||
this.telemetryService.sendMSFTTelemetryEvent('copilotcli.sessionMetadataBackfill', {
|
||||
sharedBulkFile: shared.status,
|
||||
perInstallBulkFile: perInstall.status,
|
||||
legacyBulkMigrated: String(this._legacyBulkMigratedBeforeStartup),
|
||||
sharedAtCap: String(Object.keys(shared.data).length >= MAX_BULK_STORAGE_ENTRIES),
|
||||
completed: String(completed),
|
||||
aborted: String(aborted),
|
||||
}, {
|
||||
attempt,
|
||||
sharedEntryCount: Object.keys(shared.data).length,
|
||||
perInstallEntryCount: Object.keys(perInstall.data).length,
|
||||
globalStateEntryCount: Object.keys(globalStateData).length,
|
||||
globalStateOnlyCount: globalStateOnly,
|
||||
candidateCount: candidates.size,
|
||||
notVSCodeCount: counts.notVSCode,
|
||||
existingFileCount: counts.exists,
|
||||
missingSessionDirectoryCount: counts.missingSessionDirectory,
|
||||
writtenCount: counts.written,
|
||||
failedCount: counts.failed,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
private async backfillSessionMetadataFile(sessionId: string, entry: ChatSessionMetadataFile, logFailure: boolean): Promise<SessionMetadataBackfillResult> {
|
||||
const fileUri = this.getMetadataFileUri(sessionId);
|
||||
const fail = (error: unknown): SessionMetadataBackfillResult => {
|
||||
if (logFailure) {
|
||||
this.logService.error(error instanceof Error ? error : String(error), `[ChatSessionMetadataStore] Failed to backfill metadata for session ${sessionId}`);
|
||||
}
|
||||
return 'failed';
|
||||
};
|
||||
try {
|
||||
const sessionDirectory = await this.fileSystemService.stat(dirname(fileUri));
|
||||
if ((sessionDirectory.type & FileType.Directory) === 0) {
|
||||
return 'missingSessionDirectory';
|
||||
}
|
||||
} catch (error) {
|
||||
return isFileNotFoundError(error) ? 'missingSessionDirectory' : fail(error);
|
||||
}
|
||||
const { writtenToDisc: _writtenToDisc, ...metadata } = entry;
|
||||
try {
|
||||
// Create-only: another window or app may write this file concurrently, and its data must win.
|
||||
const created = await this.createSessionMetadataFileIfAbsent(fileUri, new TextEncoder().encode(JSON.stringify(metadata, null, 2)));
|
||||
return created ? 'written' : 'exists';
|
||||
} catch (error) {
|
||||
return isFileNotFoundError(error) ? 'missingSessionDirectory' : fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically creates the file unless it already exists; returns whether it was created. */
|
||||
protected createSessionMetadataFileIfAbsent(uri: vscode.Uri, content: Uint8Array): Promise<boolean> {
|
||||
return writeFileIfAbsent(uri.fsPath, content);
|
||||
}
|
||||
|
||||
private async readBulkMetadataFile(uri: vscode.Uri): Promise<{ status: BulkMetadataFileStatus; data: Record<string, ChatSessionMetadataFile> }> {
|
||||
let raw: Uint8Array;
|
||||
try {
|
||||
// Bulk files can exceed the default read limit; they are read once, in the background.
|
||||
raw = await this.fileSystemService.readFile(uri, true);
|
||||
} catch (error) {
|
||||
return { status: isFileNotFoundError(error) ? 'missing' : 'invalid', data: {} };
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(new TextDecoder().decode(raw));
|
||||
return data && typeof data === 'object' && !Array.isArray(data)
|
||||
? { status: 'present', data: data as Record<string, ChatSessionMetadataFile> }
|
||||
: { status: 'invalid', data: {} };
|
||||
} catch {
|
||||
return { status: 'invalid', data: {} };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** File operations used by {@link writeFileIfAbsent}; injectable for fault-injection tests. */
|
||||
export type WriteFileIfAbsentFileSystem = Pick<typeof fs, 'writeFile' | 'link' | 'unlink' | 'open'>;
|
||||
|
||||
/**
|
||||
* Creates a file with its complete contents only if it does not exist yet, so a
|
||||
* concurrent writer is never overwritten and an interrupted write never leaves
|
||||
* a partial file behind. Returns `false` if it already exists.
|
||||
*
|
||||
* The contents are written to a temporary file that is then hard-linked into
|
||||
* place; `link` fails with `EEXIST` instead of replacing an existing file. File
|
||||
* systems without hard links fall back to an exclusive create that removes its
|
||||
* own partial file if the write fails.
|
||||
*/
|
||||
export async function writeFileIfAbsent(path: string, content: Uint8Array, fileSystem: WriteFileIfAbsentFileSystem = fs): Promise<boolean> {
|
||||
const temp = nodePath.join(nodePath.dirname(path), `.${nodePath.basename(path)}.${generateUuid()}.tmp`);
|
||||
try {
|
||||
await fileSystem.writeFile(temp, content, { flag: 'wx' });
|
||||
try {
|
||||
await fileSystem.link(temp, path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = getErrorCode(error);
|
||||
if (code === 'EEXIST') {
|
||||
return false;
|
||||
}
|
||||
if (code !== 'EPERM' && code !== 'ENOTSUP' && code !== 'ENOSYS' && code !== 'EXDEV') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await fileSystem.unlink(temp).catch(() => undefined);
|
||||
}
|
||||
return writeFileExclusively(path, content, fileSystem);
|
||||
}
|
||||
|
||||
async function writeFileExclusively(path: string, content: Uint8Array, fileSystem: WriteFileIfAbsentFileSystem): Promise<boolean> {
|
||||
let handle: fs.FileHandle;
|
||||
try {
|
||||
handle = await fileSystem.open(path, 'wx');
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) === 'EEXIST') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let written = false;
|
||||
try {
|
||||
await handle.writeFile(content);
|
||||
written = true;
|
||||
} finally {
|
||||
await handle.close();
|
||||
if (!written) {
|
||||
// The file was created by this call, so removing it cannot discard another writer's data.
|
||||
await fileSystem.unlink(path).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): unknown {
|
||||
return error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
||||
}
|
||||
|
||||
/** Whether an error from the file system service or Node means the path does not exist. */
|
||||
function isFileNotFoundError(error: unknown): boolean {
|
||||
const code = getErrorCode(error);
|
||||
return code === 'FileNotFound' || code === 'ENOENT' || (error instanceof Error && error.message.startsWith('ENOENT'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a bulk metadata entry describes a session created by VS Code and is
|
||||
* safe to backfill. Mirrors `getSessionOrigin` (and the agent host's legacy
|
||||
* marker check): an explicit `origin` wins, and origin-less entries (written
|
||||
* before `origin` existed) count only when they carry VS Code-specific
|
||||
* workspace properties.
|
||||
*/
|
||||
function isBackfillableSessionMetadata(sessionId: string, entry: ChatSessionMetadataFile | undefined): entry is ChatSessionMetadataFile {
|
||||
if (!SAFE_SESSION_ID.test(sessionId) || sessionId === '.' || sessionId === '..' || isUntitledSessionId(sessionId)) {
|
||||
return false;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return false;
|
||||
}
|
||||
if (entry.origin !== undefined) {
|
||||
return entry.origin === 'vscode';
|
||||
}
|
||||
return entry.repositoryProperties !== undefined
|
||||
|| entry.worktreeProperties !== undefined
|
||||
|| entry.workspaceFolder !== undefined;
|
||||
}
|
||||
+489
-2
@@ -3,12 +3,18 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { mkdtemp, readdir, readFile, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as vscode from 'vscode';
|
||||
import { Uri } from 'vscode';
|
||||
import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';
|
||||
import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';
|
||||
import { ILogService } from '../../../../../platform/log/common/logService';
|
||||
import { NullTelemetryService } from '../../../../../platform/telemetry/common/nullTelemetryService';
|
||||
import { TelemetryEventMeasurements, TelemetryEventProperties } from '../../../../../platform/telemetry/common/telemetry';
|
||||
import { mock } from '../../../../../util/common/test/simpleMock';
|
||||
import { Emitter } from '../../../../../util/vs/base/common/event';
|
||||
import { URI } from '../../../../../util/vs/base/common/uri';
|
||||
@@ -16,7 +22,7 @@ import { ChatSessionWorktreeProperties } from '../../../common/chatSessionWorktr
|
||||
import { IWorkspaceInfo } from '../../../common/workspaceInfo';
|
||||
import { getCopilotCLISessionDir } from '../../../copilotcli/node/cliHelpers';
|
||||
import { NullCopilotCLIAgents } from '../../../copilotcli/node/test/testHelpers';
|
||||
import { ChatSessionMetadataStore } from '../chatSessionMetadataStoreImpl';
|
||||
import { ChatSessionMetadataStore, writeFileIfAbsent, WriteFileIfAbsentFileSystem } from '../chatSessionMetadataStoreImpl';
|
||||
|
||||
// Hoisted holder lets each test point the JSONL helper at its own mock path.
|
||||
const jsonlPathHolder = vi.hoisted(() => {
|
||||
@@ -88,6 +94,36 @@ class MockLogService extends mock<ILogService>() {
|
||||
override debug = vi.fn();
|
||||
}
|
||||
|
||||
class TestTelemetryService extends NullTelemetryService {
|
||||
public readonly events: Array<{ name: string; properties?: TelemetryEventProperties; measurements?: TelemetryEventMeasurements }> = [];
|
||||
override sendMSFTTelemetryEvent(name: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void {
|
||||
this.events.push({ name, properties, measurements });
|
||||
}
|
||||
}
|
||||
|
||||
/** Emulates the real exclusive create against the in-memory file system. */
|
||||
class TestChatSessionMetadataStore extends ChatSessionMetadataStore {
|
||||
constructor(
|
||||
private readonly testFileSystem: MockFileSystemService,
|
||||
logService: ILogService,
|
||||
extensionContext: IVSCodeExtensionContext,
|
||||
copilotCLIAgents: NullCopilotCLIAgents,
|
||||
telemetryService: NullTelemetryService,
|
||||
) {
|
||||
super(testFileSystem, logService, extensionContext, copilotCLIAgents, telemetryService);
|
||||
}
|
||||
|
||||
protected override async createSessionMetadataFileIfAbsent(uri: vscode.Uri, content: Uint8Array): Promise<boolean> {
|
||||
try {
|
||||
await this.testFileSystem.stat(uri);
|
||||
return false;
|
||||
} catch {
|
||||
await this.testFileSystem.writeFile(uri, content);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paths used by the store
|
||||
// New shared bulk file (top-N cache). Lives at `~/.copilot/...` in production but is
|
||||
// mocked above so the IFileSystemService mock can intercept reads/writes.
|
||||
@@ -152,12 +188,14 @@ describe('ChatSessionMetadataStore', () => {
|
||||
let mockFs: MockFileSystemServiceWithMotification;
|
||||
let logService: MockLogService;
|
||||
let extensionContext: MockExtensionContext;
|
||||
let telemetryService: TestTelemetryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFs = new MockFileSystemServiceWithMotification();
|
||||
logService = new MockLogService();
|
||||
extensionContext = new MockExtensionContext();
|
||||
telemetryService = new TestTelemetryService();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -171,11 +209,12 @@ describe('ChatSessionMetadataStore', () => {
|
||||
* Constructor eagerly triggers init; we flush microtasks so it settles.
|
||||
*/
|
||||
async function createStore(): Promise<ChatSessionMetadataStore> {
|
||||
const store = new ChatSessionMetadataStore(
|
||||
const store = new TestChatSessionMetadataStore(
|
||||
mockFs,
|
||||
logService,
|
||||
extensionContext,
|
||||
new NullCopilotCLIAgents(),
|
||||
telemetryService,
|
||||
);
|
||||
// Flush enough microtask rounds so that initializeStorage() —
|
||||
// which chains several async I/O steps — fully settles.
|
||||
@@ -1446,6 +1485,8 @@ describe('ChatSessionMetadataStore', () => {
|
||||
|
||||
it('skips migration when memento flag is already set', async () => {
|
||||
extensionContext.globalState.seed('github.copilot.cli.legacyBulkMigrated', true);
|
||||
// The one-time per-session backfill also reads the legacy file; isolate the migration path.
|
||||
extensionContext.globalState.seed('github.copilot.cli.sessionMetadataBackfilled.v1', true);
|
||||
mockFs.mockFile(LEGACY_BULK_METADATA_FILE, JSON.stringify({
|
||||
'legacy-session': { workspaceFolder: { folderPath: Uri.file('/legacy').fsPath, timestamp: 1 } },
|
||||
}));
|
||||
@@ -1562,4 +1603,450 @@ describe('ChatSessionMetadataStore', () => {
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-session metadata backfill', () => {
|
||||
const BACKFILLED_KEY = 'github.copilot.cli.sessionMetadataBackfilled.v1';
|
||||
|
||||
async function readSessionFile(sessionId: string): Promise<unknown> {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(await mockFs.readFile(sessionMetadataFileUri(sessionId))));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mockSessionDirectories(...sessionIds: string[]): void {
|
||||
for (const sessionId of sessionIds) {
|
||||
mockFs.mockDirectory(sessionDirectoryUri(sessionId), []);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackfilledStore(): Promise<ChatSessionMetadataStore> {
|
||||
const store = await createStore();
|
||||
let done = false;
|
||||
void store.whenSessionMetadataBackfilled().then(() => done = true);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
// Batches pause between each other; keep time moving until the run settles.
|
||||
for (let i = 0; !done && i < 1_000; i++) {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
function backfillEvents() {
|
||||
return telemetryService.events.filter(e => e.name === 'copilotcli.sessionMetadataBackfill');
|
||||
}
|
||||
|
||||
it('handles thousands of sessions in paced batches without touching existing files', async () => {
|
||||
const bulk: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 2_500; i++) {
|
||||
bulk[`s-${i}`] = { origin: 'vscode', workspaceFolder: { folderPath: `/w/${i}`, timestamp: i }, modified: i };
|
||||
mockSessionDirectories(`s-${i}`);
|
||||
if (i % 2 === 0) {
|
||||
mockFs.mockFile(sessionMetadataFileUri(`s-${i}`), JSON.stringify({ origin: 'vscode', customTitle: `existing ${i}` }));
|
||||
}
|
||||
}
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify(bulk));
|
||||
|
||||
const store = await createBackfilledStore();
|
||||
|
||||
expect({
|
||||
existingKept: await readSessionFile('s-0'),
|
||||
written: await readSessionFile('s-2499'),
|
||||
sharedAtCap: backfillEvents()[0]?.properties?.sharedAtCap,
|
||||
measurements: { ...backfillEvents()[0]?.measurements, durationMs: 0 },
|
||||
backfilled: extensionContext.globalState.get(BACKFILLED_KEY),
|
||||
}).toEqual({
|
||||
existingKept: { origin: 'vscode', customTitle: 'existing 0' },
|
||||
written: { origin: 'vscode', workspaceFolder: { folderPath: '/w/2499', timestamp: 2499 }, modified: 2499 },
|
||||
sharedAtCap: 'true',
|
||||
measurements: { attempt: 1, sharedEntryCount: 2_500, perInstallEntryCount: 0, globalStateEntryCount: 0, globalStateOnlyCount: 0, candidateCount: 2_500, notVSCodeCount: 0, existingFileCount: 1_250, missingSessionDirectoryCount: 0, writtenCount: 1_250, failedCount: 0, durationMs: 0 },
|
||||
backfilled: true,
|
||||
});
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
it('reports whether the per-install bulk file was merged on an earlier start, not by this start', async () => {
|
||||
const reported = [];
|
||||
for (const mergedEarlier of [false, true]) {
|
||||
extensionContext = new MockExtensionContext();
|
||||
if (mergedEarlier) {
|
||||
extensionContext.globalState.seed('github.copilot.cli.legacyBulkMigrated', true);
|
||||
}
|
||||
mockFs.mockFile(LEGACY_BULK_METADATA_FILE, JSON.stringify({
|
||||
'legacy': { workspaceFolder: { folderPath: '/legacy', timestamp: 1 } },
|
||||
}));
|
||||
const store = await createBackfilledStore();
|
||||
reported.push({
|
||||
mergedEarlier,
|
||||
legacyBulkMigrated: backfillEvents().at(-1)?.properties?.legacyBulkMigrated,
|
||||
mergedNow: extensionContext.globalState.get('github.copilot.cli.legacyBulkMigrated'),
|
||||
});
|
||||
store.dispose();
|
||||
}
|
||||
|
||||
expect(reported).toEqual([
|
||||
{ mergedEarlier: false, legacyBulkMigrated: 'false', mergedNow: true },
|
||||
{ mergedEarlier: true, legacyBulkMigrated: 'true', mergedNow: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recovers sessions whose metadata only exists in the original globalState store, without overriding newer bulk data', async () => {
|
||||
const worktree = makeWorktreeV1Props();
|
||||
extensionContext.globalState.seed('github.copilot.cli.sessionWorkspaceFolders', {
|
||||
'folder-only': { folderPath: '/old/folder', timestamp: 5 },
|
||||
'in-bulk': { folderPath: '/stale', timestamp: 1 },
|
||||
'untitled-1': { folderPath: '/untitled', timestamp: 1 },
|
||||
'legacy-string': '/plain/string/entry',
|
||||
'both': { folderPath: '/replaced-by-worktree', timestamp: 1 },
|
||||
});
|
||||
extensionContext.globalState.seed('github.copilot.cli.sessionWorktrees', {
|
||||
'worktree-only': { version: 1, data: JSON.stringify({ ...worktree, version: undefined }) },
|
||||
'both': { version: 2, data: JSON.stringify(makeWorktreeV2Props()) },
|
||||
'bad-json': { version: 1, data: '{not json' },
|
||||
'legacy-string': 'plain',
|
||||
});
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
|
||||
'in-bulk': { origin: 'vscode', workspaceFolder: { folderPath: '/newer', timestamp: 9 }, modified: 9 },
|
||||
}));
|
||||
mockSessionDirectories('folder-only', 'worktree-only', 'both', 'in-bulk', 'untitled-1', 'bad-json', 'legacy-string');
|
||||
|
||||
const store = await createBackfilledStore();
|
||||
|
||||
expect({
|
||||
files: {
|
||||
'folder-only': await readSessionFile('folder-only'),
|
||||
'worktree-only': await readSessionFile('worktree-only'),
|
||||
'both': await readSessionFile('both'),
|
||||
'in-bulk': await readSessionFile('in-bulk'),
|
||||
'untitled-1': await readSessionFile('untitled-1'),
|
||||
'bad-json': await readSessionFile('bad-json'),
|
||||
'legacy-string': await readSessionFile('legacy-string'),
|
||||
},
|
||||
globalStateEntryCount: backfillEvents()[0]?.measurements?.globalStateEntryCount,
|
||||
globalStateOnlyCount: backfillEvents()[0]?.measurements?.globalStateOnlyCount,
|
||||
}).toEqual({
|
||||
files: {
|
||||
'folder-only': { workspaceFolder: { folderPath: '/old/folder', timestamp: 5 } },
|
||||
'worktree-only': { worktreeProperties: worktree },
|
||||
'both': { worktreeProperties: makeWorktreeV2Props() },
|
||||
'in-bulk': { origin: 'vscode', workspaceFolder: { folderPath: '/newer', timestamp: 9 }, modified: 9 },
|
||||
'untitled-1': undefined,
|
||||
'bad-json': undefined,
|
||||
'legacy-string': undefined,
|
||||
},
|
||||
globalStateEntryCount: 5,
|
||||
globalStateOnlyCount: 4,
|
||||
});
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
it('keeps a marker that another window creates while the backfill is running', async () => {
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
|
||||
'session': { origin: 'vscode', workspaceFolder: { folderPath: '/stale', timestamp: 1 }, modified: 1 },
|
||||
}));
|
||||
mockSessionDirectories('session');
|
||||
const otherWindow = JSON.stringify({ origin: 'vscode', customTitle: 'Renamed in another window', modified: 99 });
|
||||
const stat = mockFs.stat.bind(mockFs);
|
||||
vi.spyOn(mockFs, 'stat').mockImplementation(async uri => {
|
||||
const result = await stat(uri);
|
||||
// Another window writes the marker right after the backfill checked the session folder.
|
||||
if (uri.toString() === sessionDirectoryUri('session').toString()) {
|
||||
mockFs.mockFile(sessionMetadataFileUri('session'), otherWindow);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const store = await createBackfilledStore();
|
||||
|
||||
expect({
|
||||
file: await readSessionFile('session'),
|
||||
existingFileCount: backfillEvents()[0]?.measurements?.existingFileCount,
|
||||
writtenCount: backfillEvents()[0]?.measurements?.writtenCount,
|
||||
}).toEqual({
|
||||
file: JSON.parse(otherWindow),
|
||||
existingFileCount: 1,
|
||||
writtenCount: 0,
|
||||
});
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
it('creates files exclusively on disk and never overwrites an existing one', async () => {
|
||||
vi.useRealTimers();
|
||||
const directory = await mkdtemp(join(tmpdir(), 'copilot-backfill-'));
|
||||
try {
|
||||
const path = join(directory, 'vscode.metadata.json');
|
||||
const results = [
|
||||
await writeFileIfAbsent(path, new TextEncoder().encode('first')),
|
||||
await writeFileIfAbsent(path, new TextEncoder().encode('second')),
|
||||
];
|
||||
expect({ results, content: await readFile(path, 'utf8'), files: await readdir(directory) }).toEqual({ results: [true, false], content: 'first', files: ['vscode.metadata.json'] });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never leaves a partial marker behind when a write is interrupted, so the next run can still create it', async () => {
|
||||
vi.useRealTimers();
|
||||
const directory = await mkdtemp(join(tmpdir(), 'copilot-backfill-'));
|
||||
const diskFull = () => Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' });
|
||||
// Simulates disk exhaustion after the file is created: an empty file is left and the write throws.
|
||||
const interruptedWriteFile = (async (file: Parameters<typeof fsPromises.writeFile>[0], _data: unknown, options: Parameters<typeof fsPromises.writeFile>[2]) => {
|
||||
await fsPromises.writeFile(file, '', options);
|
||||
throw diskFull();
|
||||
}) as typeof fsPromises.writeFile;
|
||||
const interruptedOpen = (async (file: Parameters<typeof fsPromises.open>[0], flags: Parameters<typeof fsPromises.open>[1]) => {
|
||||
const handle = await fsPromises.open(file, flags);
|
||||
return { writeFile: async () => { throw diskFull(); }, close: () => handle.close() };
|
||||
}) as unknown as typeof fsPromises.open;
|
||||
const cases: Record<string, WriteFileIfAbsentFileSystem> = {
|
||||
hardLink: { ...fsPromises, writeFile: interruptedWriteFile },
|
||||
noHardLinkFallback: { ...fsPromises, link: async () => { throw Object.assign(new Error('EPERM'), { code: 'EPERM' }); }, open: interruptedOpen },
|
||||
};
|
||||
try {
|
||||
const outcomes: Record<string, unknown> = {};
|
||||
for (const [name, failingFileSystem] of Object.entries(cases)) {
|
||||
const folder = join(directory, name);
|
||||
await fsPromises.mkdir(folder);
|
||||
const path = join(folder, 'vscode.metadata.json');
|
||||
const firstRun = await writeFileIfAbsent(path, new TextEncoder().encode('{"origin":"vscode"}'), failingFileSystem).then(() => 'created', (error: NodeJS.ErrnoException) => error.code);
|
||||
const filesAfterFailure = await readdir(folder);
|
||||
const retry = await writeFileIfAbsent(path, new TextEncoder().encode('{"origin":"vscode"}'));
|
||||
outcomes[name] = { firstRun, filesAfterFailure, retry, content: await readFile(path, 'utf8') };
|
||||
}
|
||||
const expected = { firstRun: 'ENOSPC', filesAfterFailure: [], retry: true, content: '{"origin":"vscode"}' };
|
||||
expect(outcomes).toEqual({ hardLink: expected, noHardLinkFallback: expected });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat unreadable files or folders as missing', async () => {
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
|
||||
'session': { origin: 'vscode', workspaceFolder: { folderPath: '/w', timestamp: 1 } },
|
||||
}));
|
||||
mockSessionDirectories('session');
|
||||
mockFs.mockError(sessionDirectoryUri('session'), new Error('EACCES: permission denied'));
|
||||
mockFs.mockError(LEGACY_BULK_METADATA_FILE, new Error('EIO: i/o error'));
|
||||
|
||||
const store = await createBackfilledStore();
|
||||
|
||||
const event = backfillEvents()[0];
|
||||
expect({
|
||||
perInstallBulkFile: event?.properties?.perInstallBulkFile,
|
||||
completed: event?.properties?.completed,
|
||||
failedCount: event?.measurements?.failedCount,
|
||||
missingSessionDirectoryCount: event?.measurements?.missingSessionDirectoryCount,
|
||||
backfilled: extensionContext.globalState.get(BACKFILLED_KEY),
|
||||
}).toEqual({
|
||||
perInstallBulkFile: 'invalid',
|
||||
completed: 'false',
|
||||
failedCount: 1,
|
||||
missingSessionDirectoryCount: 0,
|
||||
backfilled: undefined,
|
||||
});
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
it('stops a run after repeated consecutive write failures and gives up after a bounded number of runs', async () => {
|
||||
const bulk: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 30; i++) {
|
||||
bulk[`s-${i}`] = { origin: 'vscode', workspaceFolder: { folderPath: `/w/${i}`, timestamp: i } };
|
||||
mockSessionDirectories(`s-${i}`);
|
||||
}
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify(bulk));
|
||||
const writeFile = mockFs.writeFile.bind(mockFs);
|
||||
vi.spyOn(mockFs, 'writeFile').mockImplementation(async (uri, content) => {
|
||||
if (uri.toString().endsWith('/vscode.metadata.json')) {
|
||||
throw new Error('EROFS');
|
||||
}
|
||||
return writeFile(uri, content);
|
||||
});
|
||||
|
||||
const runs = [];
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const store = await createBackfilledStore();
|
||||
const event = backfillEvents()[run];
|
||||
runs.push({ aborted: event?.properties?.aborted, attempt: event?.measurements?.attempt, failedCount: event?.measurements?.failedCount, backfilled: extensionContext.globalState.get(BACKFILLED_KEY) });
|
||||
store.dispose();
|
||||
}
|
||||
|
||||
expect({ runs, loggedFailures: logService.error.mock.calls.filter(c => String(c[1]).includes('Failed to backfill metadata')).length }).toEqual({
|
||||
runs: [
|
||||
{ aborted: 'true', attempt: 1, failedCount: 20, backfilled: undefined },
|
||||
{ aborted: 'true', attempt: 2, failedCount: 20, backfilled: undefined },
|
||||
{ aborted: 'true', attempt: 3, failedCount: 20, backfilled: true },
|
||||
],
|
||||
loggedFailures: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('waits until after startup and leaves the bulk file and cached metadata untouched', async () => {
|
||||
const bulk = JSON.stringify({
|
||||
'session': { origin: 'vscode', workspaceFolder: { folderPath: '/w', timestamp: 1 }, modified: 7 },
|
||||
});
|
||||
mockFs.mockFile(BULK_METADATA_FILE, bulk);
|
||||
mockSessionDirectories('session');
|
||||
|
||||
const store = await createStore();
|
||||
await vi.advanceTimersByTimeAsync(59_000);
|
||||
const beforeDelay = await readSessionFile('session');
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await store.whenSessionMetadataBackfilled();
|
||||
|
||||
expect({
|
||||
beforeDelay,
|
||||
afterDelay: await readSessionFile('session'),
|
||||
bulkFile: new TextDecoder().decode(await mockFs.readFile(BULK_METADATA_FILE)),
|
||||
workspaceFolder: (await store.getSessionWorkspaceFolder('session'))?.fsPath,
|
||||
}).toEqual({
|
||||
beforeDelay: undefined,
|
||||
afterDelay: { origin: 'vscode', workspaceFolder: { folderPath: '/w', timestamp: 1 }, modified: 7 },
|
||||
bulkFile: bulk,
|
||||
workspaceFolder: Uri.file('/w').fsPath,
|
||||
});
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
it('stops when another window completes the backfill first', async () => {
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
|
||||
'session': { origin: 'vscode', workspaceFolder: { folderPath: '/w', timestamp: 1 } },
|
||||
}));
|
||||
mockSessionDirectories('session');
|
||||
|
||||
const store = await createStore();
|
||||
extensionContext.globalState.seed(BACKFILLED_KEY, true);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await store.whenSessionMetadataBackfilled();
|
||||
|
||||
expect({
|
||||
file: await readSessionFile('session'),
|
||||
events: telemetryService.events.filter(e => e.name === 'copilotcli.sessionMetadataBackfill').length,
|
||||
}).toEqual({ file: undefined, events: 0 });
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
it('does not run when the store is disposed before startup settles', async () => {
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
|
||||
'session': { origin: 'vscode', workspaceFolder: { folderPath: '/w', timestamp: 1 } },
|
||||
}));
|
||||
mockSessionDirectories('session');
|
||||
|
||||
const store = await createStore();
|
||||
store.dispose();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await store.whenSessionMetadataBackfilled();
|
||||
|
||||
expect(await readSessionFile('session')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes missing per-session files for VS Code sessions from both bulk files, as stored', async () => {
|
||||
const worktree = makeWorktreeV2Props();
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
|
||||
'shared-only': { workspaceFolder: { folderPath: '/a', timestamp: 1 }, origin: 'vscode', customTitle: 'Renamed', archived: true, created: 10, modified: 20, writtenToDisc: true },
|
||||
'origin-less': { worktreeProperties: worktree, modified: 30 },
|
||||
'in-both': { workspaceFolder: { folderPath: '/old', timestamp: 1 }, modified: 50 },
|
||||
}));
|
||||
mockFs.mockFile(LEGACY_BULK_METADATA_FILE, JSON.stringify({
|
||||
'per-install-only': { repositoryProperties: { repositoryPath: '/repo' }, modified: 5 },
|
||||
'in-both': { workspaceFolder: { folderPath: '/newer', timestamp: 2 }, modified: 200 },
|
||||
}));
|
||||
mockSessionDirectories('shared-only', 'origin-less', 'in-both', 'per-install-only');
|
||||
|
||||
const store = await createBackfilledStore();
|
||||
|
||||
expect({
|
||||
'shared-only': await readSessionFile('shared-only'),
|
||||
'origin-less': await readSessionFile('origin-less'),
|
||||
'in-both': await readSessionFile('in-both'),
|
||||
'per-install-only': await readSessionFile('per-install-only'),
|
||||
backfilled: extensionContext.globalState.get(BACKFILLED_KEY),
|
||||
}).toEqual({
|
||||
'shared-only': { workspaceFolder: { folderPath: '/a', timestamp: 1 }, origin: 'vscode', customTitle: 'Renamed', archived: true, created: 10, modified: 20 },
|
||||
'origin-less': { worktreeProperties: worktree, modified: 30 },
|
||||
'in-both': { workspaceFolder: { folderPath: '/newer', timestamp: 2 }, modified: 200 },
|
||||
'per-install-only': { repositoryProperties: { repositoryPath: '/repo' }, modified: 5 },
|
||||
backfilled: true,
|
||||
});
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
it('skips non-VS Code, untitled, unsafe and missing-directory sessions and never overwrites an existing file', async () => {
|
||||
const folder = { folderPath: '/w', timestamp: 1 };
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
|
||||
'external': { origin: 'other', workspaceFolder: folder },
|
||||
'no-workspace': { customTitle: 'title only' },
|
||||
'untitled-1': { origin: 'vscode', workspaceFolder: folder },
|
||||
'..': { origin: 'vscode', workspaceFolder: folder },
|
||||
'no-directory': { origin: 'vscode', workspaceFolder: folder },
|
||||
'has-file': { origin: 'vscode', workspaceFolder: folder },
|
||||
}));
|
||||
mockSessionDirectories('external', 'no-workspace', 'untitled-1', 'has-file');
|
||||
mockFs.mockFile(sessionMetadataFileUri('has-file'), JSON.stringify({ origin: 'other', customTitle: 'from another app' }));
|
||||
|
||||
const store = await createBackfilledStore();
|
||||
|
||||
expect({
|
||||
files: {
|
||||
'external': await readSessionFile('external'),
|
||||
'no-workspace': await readSessionFile('no-workspace'),
|
||||
'untitled-1': await readSessionFile('untitled-1'),
|
||||
'no-directory': await readSessionFile('no-directory'),
|
||||
'has-file': await readSessionFile('has-file'),
|
||||
},
|
||||
telemetry: telemetryService.events.filter(e => e.name === 'copilotcli.sessionMetadataBackfill').map(e => ({ properties: e.properties, measurements: { ...e.measurements, durationMs: 0 } })),
|
||||
}).toEqual({
|
||||
files: {
|
||||
'external': undefined,
|
||||
'no-workspace': undefined,
|
||||
'untitled-1': undefined,
|
||||
'no-directory': undefined,
|
||||
'has-file': { origin: 'other', customTitle: 'from another app' },
|
||||
},
|
||||
telemetry: [{
|
||||
properties: { sharedBulkFile: 'present', perInstallBulkFile: 'missing', legacyBulkMigrated: 'false', sharedAtCap: 'false', completed: 'true', aborted: 'false' },
|
||||
measurements: { attempt: 1, sharedEntryCount: 6, perInstallEntryCount: 0, globalStateEntryCount: 0, globalStateOnlyCount: 0, candidateCount: 6, notVSCodeCount: 4, existingFileCount: 1, missingSessionDirectoryCount: 1, writtenCount: 0, failedCount: 0, durationMs: 0 },
|
||||
}],
|
||||
});
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
it('does not mark the backfill complete when a write fails', async () => {
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
|
||||
'fails': { origin: 'vscode', workspaceFolder: { folderPath: '/w', timestamp: 1 } },
|
||||
}));
|
||||
mockSessionDirectories('fails');
|
||||
const writeFile = mockFs.writeFile.bind(mockFs);
|
||||
vi.spyOn(mockFs, 'writeFile').mockImplementation(async (uri, content) => {
|
||||
if (uri.toString() === sessionMetadataFileUri('fails').toString()) {
|
||||
throw new Error('EACCES');
|
||||
}
|
||||
return writeFile(uri, content);
|
||||
});
|
||||
|
||||
const store = await createBackfilledStore();
|
||||
|
||||
expect({
|
||||
backfilled: extensionContext.globalState.get(BACKFILLED_KEY),
|
||||
failedCount: telemetryService.events.find(e => e.name === 'copilotcli.sessionMetadataBackfill')?.measurements?.failedCount,
|
||||
}).toEqual({ backfilled: undefined, failedCount: 1 });
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
it('does nothing once the backfill has completed', async () => {
|
||||
extensionContext.globalState.seed(BACKFILLED_KEY, true);
|
||||
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
|
||||
'session': { origin: 'vscode', workspaceFolder: { folderPath: '/w', timestamp: 1 } },
|
||||
}));
|
||||
mockSessionDirectories('session');
|
||||
|
||||
const store = await createBackfilledStore();
|
||||
|
||||
expect({
|
||||
file: await readSessionFile('session'),
|
||||
events: telemetryService.events.filter(e => e.name === 'copilotcli.sessionMetadataBackfill').length,
|
||||
}).toEqual({ file: undefined, events: 0 });
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ export class SurveyCommandContribution extends Disposable {
|
||||
constructor(@ISurveyService private readonly _surveyService: ISurveyService) {
|
||||
super();
|
||||
this._register(vscode.commands.registerCommand(SURVEY_SIGNAL_USAGE_ID, (source: string, languageId?: string) => {
|
||||
this._surveyService.signalUsage(source, languageId);
|
||||
return this._surveyService.signalUsage(source, languageId);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
|
||||
import { SurveyService } from '../../../../platform/survey/vscode/surveyServiceImpl';
|
||||
import { NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';
|
||||
import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';
|
||||
import { MockExtensionContext } from '../../../../platform/test/node/extensionContext';
|
||||
import { mock } from '../../../../util/common/test/simpleMock';
|
||||
import { SURVEY_SIGNAL_USAGE_ID, SurveyCommandContribution } from '../surveyCommands';
|
||||
|
||||
const vscodeMock = vi.hoisted(() => {
|
||||
const commands = new Map<string, (source: string, languageId?: string) => Promise<void>>();
|
||||
return {
|
||||
commands,
|
||||
registerCommand: (id: string, handler: (source: string, languageId?: string) => Promise<void>) => {
|
||||
commands.set(id, handler);
|
||||
return { dispose: () => commands.delete(id) };
|
||||
},
|
||||
showInformationMessage: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('vscode', async importOriginal => ({
|
||||
...await importOriginal<typeof import('vscode')>(),
|
||||
commands: { registerCommand: vscodeMock.registerCommand },
|
||||
window: { showInformationMessage: vscodeMock.showInformationMessage },
|
||||
}));
|
||||
|
||||
class TestExtensionContext extends mock<IVSCodeExtensionContext>() {
|
||||
override readonly globalState = Object.assign(new MockExtensionContext().globalState, { setKeysForSync() { } });
|
||||
}
|
||||
|
||||
describe('Copilot survey harness usage bridge', () => {
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const now = new Date(2026, 9, 19, 12).getTime();
|
||||
const today = new Date(now).setHours(0, 0, 0, 0);
|
||||
let context: TestExtensionContext;
|
||||
let telemetry: NullTelemetryService;
|
||||
let service: SurveyService;
|
||||
let contribution: SurveyCommandContribution;
|
||||
|
||||
async function signalHarnessUsage() {
|
||||
await vscodeMock.commands.get(SURVEY_SIGNAL_USAGE_ID)!('agent.copilot');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
vscodeMock.showInformationMessage.mockClear();
|
||||
context = new TestExtensionContext();
|
||||
telemetry = new NullTelemetryService();
|
||||
const experimentation = new NullExperimentationService();
|
||||
vi.spyOn(experimentation, 'getTreatmentVariable').mockReturnValue(100);
|
||||
service = new SurveyService(telemetry, context, experimentation);
|
||||
contribution = new SurveyCommandContribution(service);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
contribution.dispose();
|
||||
service.dispose();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('refreshes activity before the startup inactivity check and does not prompt a one-day Copilot harness user', async () => {
|
||||
await context.globalState.update('survey.usage', { firstActive: now - 30 * day, activeDays: [today - 20 * day] });
|
||||
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
|
||||
await signalHarnessUsage();
|
||||
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
|
||||
expect({
|
||||
usage: context.globalState.get('survey.usage'),
|
||||
inactive: await service.checkInactiveUserHeuristic(),
|
||||
prompts: vscodeMock.showInformationMessage.mock.calls,
|
||||
}).toEqual({
|
||||
usage: { firstActive: now - 30 * day, activeDays: [today] },
|
||||
inactive: false,
|
||||
prompts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('schedules the normal debounced usage check with Copilot harness attribution and the existing cooldown', async () => {
|
||||
const shown = vi.spyOn(telemetry, 'sendMSFTTelemetryEvent');
|
||||
await context.globalState.update('survey.usage', { firstActive: now - 30 * day, activeDays: [today - day] });
|
||||
await signalHarnessUsage();
|
||||
await signalHarnessUsage();
|
||||
await vi.advanceTimersByTimeAsync(3 * 60 * 1000 - 1);
|
||||
expect(vscodeMock.showInformationMessage).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.advanceTimersByTimeAsync(3 * 60 * 1000);
|
||||
expect({
|
||||
shown: shown.mock.calls.filter(([event]) => event === 'survey.show'),
|
||||
promptCount: vscodeMock.showInformationMessage.mock.calls.length,
|
||||
nextSurveyDate: context.globalState.get('survey.nextSurveyDate'),
|
||||
}).toEqual({
|
||||
shown: [['survey.show', { source: 'agent.copilot', language: '', surveyType: 'usage' }, { activeDays: 2, firstActive: 30 }]],
|
||||
promptCount: 1,
|
||||
nextSurveyDate: now + 3 * 60 * 1000 + 90 * day,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns signal completion and propagates persistence failures to the workbench caller', async () => {
|
||||
const error = new Error('Survey state could not be saved');
|
||||
vi.spyOn(context.globalState, 'update').mockRejectedValue(error);
|
||||
await expect(signalHarnessUsage()).rejects.toBe(error);
|
||||
expect(vscodeMock.showInformationMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,6 @@ suite('built-in skills', () => {
|
||||
path.join(copilotRoot, 'assets', 'prompts', 'skills', 'migrate-customizations', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(skill).not.toContain('report_customization_migration');
|
||||
|
||||
expect({
|
||||
contributions,
|
||||
requiredContent: [
|
||||
@@ -82,10 +80,10 @@ suite('built-in skills', () => {
|
||||
'Agent Customizations > Migrations',
|
||||
'Do not ask the user to manually reconstruct or paste the missing context',
|
||||
'./references/migration-techniques.md',
|
||||
'migration-log.md',
|
||||
'restore.md',
|
||||
'VS Code created and verified a private recovery bundle',
|
||||
'When using `ask_user`, keep the question short and write it as a single sentence',
|
||||
'pull request',
|
||||
'migration-results.json',
|
||||
'report_customization_migration',
|
||||
].filter(anchor => skill.includes(anchor)),
|
||||
}).toEqual({
|
||||
contributions: [{
|
||||
@@ -99,10 +97,10 @@ suite('built-in skills', () => {
|
||||
'Agent Customizations > Migrations',
|
||||
'Do not ask the user to manually reconstruct or paste the missing context',
|
||||
'./references/migration-techniques.md',
|
||||
'migration-log.md',
|
||||
'restore.md',
|
||||
'VS Code created and verified a private recovery bundle',
|
||||
'When using `ask_user`, keep the question short and write it as a single sentence',
|
||||
'pull request',
|
||||
'migration-results.json',
|
||||
'report_customization_migration',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,10 @@ background and glow, and defaults to enabled. Resource-specific changes are sent
|
||||
to the renderer without reloading the document or changing source-marker visibility.
|
||||
The webview reserves symmetric scrollbar gutters so the document remains centered.
|
||||
|
||||
### Rich editor diagnostics
|
||||
|
||||
Built-in Markdown pull diagnostics use the language client's custom-editor tab tracking (provided by `vscode-languageclient` 9.0.1). A Markdown document opened only in the rich editor must receive diagnostics without a companion text-editor tab. Keep `diagnosticPullOptions.onTabs` and `onChange` enabled. When validating client upgrades, enable `markdown.validate.enabled`, open a broken link only in the rich editor, and verify that fixing it clears the diagnostic. The renderer continues to consume the existing VS Code diagnostic collections; no separate diagnostic provider is needed.
|
||||
|
||||
### Outline and breadcrumbs
|
||||
|
||||
The rich editor opts into `customTextEditorNavigation`. The workbench reuses the document's symbol providers for Outline and breadcrumbs; the extension reports each panel's source selection and routes reveal and view-state requests through the Markdown editor RPC bridge. Offset mapping uses the renderer's synchronized text, including when its line endings differ from the document.
|
||||
|
||||
+31
-62
@@ -21,7 +21,7 @@
|
||||
"morphdom": "^2.7.7",
|
||||
"picomatch": "^2.3.2",
|
||||
"punycode": "^2.3.1",
|
||||
"vscode-languageclient": "^8.0.2",
|
||||
"vscode-languageclient": "^9.0.1",
|
||||
"vscode-languageserver-textdocument": "^1.0.11",
|
||||
"vscode-markdown-languageserver": "0.5.0",
|
||||
"vscode-uri": "^3.0.3",
|
||||
@@ -736,7 +736,8 @@
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
@@ -745,13 +746,12 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities": {
|
||||
@@ -773,11 +773,6 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
||||
},
|
||||
"node_modules/cose-base": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
|
||||
@@ -2303,15 +2298,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/morphdom": {
|
||||
@@ -2506,24 +2501,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.0.2.tgz",
|
||||
"integrity": "sha512-RY7HwI/ydoC1Wwg4gJ3y6LpU9FJRZAUnTYMXthqhFXXu77ErDd/xkREpGuk4MyYkk4a+XDWAMqe0S3KkelYQEQ==",
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageclient": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-8.0.2.tgz",
|
||||
"integrity": "sha512-lHlthJtphG9gibGb/y72CKqQUxwPsMXijJVpHEC2bvbFqxmkj9LwQ3aGU9dwjBLqsX1S4KjShYppLvg1UJDF/Q==",
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz",
|
||||
"integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimatch": "^3.0.4",
|
||||
"semver": "^7.3.5",
|
||||
"vscode-languageserver-protocol": "3.17.2"
|
||||
"minimatch": "^5.1.0",
|
||||
"semver": "^7.3.7",
|
||||
"vscode-languageserver-protocol": "3.17.5"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.67.0"
|
||||
"vscode": "^1.82.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver": {
|
||||
@@ -2539,35 +2536,6 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.2",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.2.tgz",
|
||||
"integrity": "sha512-8kYisQ3z/SQ2kyjlNeQxbkkTNmVFoQCqkmGrzLH6A9ecPlgTbp3wDTnUNqaUxYr4vlAcloxx8zwy7G5WdguYNg==",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.0.2",
|
||||
"vscode-languageserver-types": "3.17.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.2",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.2.tgz",
|
||||
"integrity": "sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA=="
|
||||
},
|
||||
"node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
@@ -2577,7 +2545,13 @@
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver/node_modules/vscode-languageserver-types": {
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||
@@ -2600,11 +2574,6 @@
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-markdown-languageserver/node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="
|
||||
},
|
||||
"node_modules/vscode-markdown-languageservice": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.5.0.tgz",
|
||||
|
||||
@@ -2004,7 +2004,7 @@
|
||||
"morphdom": "^2.7.7",
|
||||
"picomatch": "^2.3.2",
|
||||
"punycode": "^2.3.1",
|
||||
"vscode-languageclient": "^8.0.2",
|
||||
"vscode-languageclient": "^9.0.1",
|
||||
"vscode-languageserver-textdocument": "^1.0.11",
|
||||
"vscode-markdown-languageserver": "0.5.0",
|
||||
"vscode-uri": "^3.0.3",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import 'mocha';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
suite('Markdown editor pull diagnostics', () => {
|
||||
test('validates and clears diagnostics with only a custom-editor tab open', async function () {
|
||||
this.timeout(30000);
|
||||
const folder = vscode.workspace.workspaceFolders?.[0];
|
||||
assert.ok(folder, 'The integration test requires a workspace folder');
|
||||
const uri = vscode.Uri.joinPath(folder.uri, `markdown-rich-diagnostics-${Date.now()}.md`);
|
||||
const config = vscode.workspace.getConfiguration('markdown', uri);
|
||||
const previousEnabled = config.inspect<boolean>('validate.enabled')?.workspaceValue;
|
||||
const previousFragments = config.inspect<string>('validate.fragmentLinks.enabled')?.workspaceValue;
|
||||
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode('# Heading\n\n[broken](#missing)\n'));
|
||||
try {
|
||||
await config.update('validate.enabled', true, vscode.ConfigurationTarget.Workspace);
|
||||
await config.update('validate.fragmentLinks.enabled', 'warning', vscode.ConfigurationTarget.Workspace);
|
||||
await vscode.commands.executeCommand('vscode.openWith', uri, 'vscode.markdown.editor');
|
||||
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
const assertCustomOnly = () => {
|
||||
const tabs = vscode.window.tabGroups.all.flatMap(group => group.tabs);
|
||||
assert.ok(tabs.some(tab => tab.input instanceof vscode.TabInputCustom && tab.input.uri.toString() === uri.toString()));
|
||||
assert.ok(!tabs.some(tab => tab.input instanceof vscode.TabInputText && tab.input.uri.toString() === uri.toString()));
|
||||
assert.ok(!vscode.window.visibleTextEditors.some(editor => editor.document.uri.toString() === uri.toString()));
|
||||
};
|
||||
assertCustomOnly();
|
||||
await waitForDiagnostics(uri, items => items.some(item => item.range.start.line === 2 && item.severity === vscode.DiagnosticSeverity.Warning));
|
||||
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.replace(uri, new vscode.Range(2, 0, 2, document.lineAt(2).text.length), '[fixed](#heading)');
|
||||
assert.ok(await vscode.workspace.applyEdit(edit));
|
||||
await waitForDiagnostics(uri, items => items.length === 0);
|
||||
assertCustomOnly();
|
||||
} finally {
|
||||
const document = vscode.workspace.textDocuments.find(document => document.uri.toString() === uri.toString());
|
||||
if (document?.isDirty) {
|
||||
await document.save();
|
||||
}
|
||||
const tabs = vscode.window.tabGroups.all.flatMap(group => group.tabs)
|
||||
.filter(tab => tab.input instanceof vscode.TabInputCustom && tab.input.uri.toString() === uri.toString());
|
||||
await vscode.window.tabGroups.close(tabs);
|
||||
await vscode.workspace.fs.delete(uri);
|
||||
await config.update('validate.enabled', previousEnabled, vscode.ConfigurationTarget.Workspace);
|
||||
await config.update('validate.fragmentLinks.enabled', previousFragments, vscode.ConfigurationTarget.Workspace);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForDiagnostics(uri: vscode.Uri, matches: (items: readonly vscode.Diagnostic[]) => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (Date.now() < deadline) {
|
||||
if (matches(vscode.languages.getDiagnostics(uri))) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.fail(`Timed out waiting for diagnostics: ${JSON.stringify(vscode.languages.getDiagnostics(uri))}`);
|
||||
}
|
||||
Generated
+10
-10
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "code-oss-dev",
|
||||
"version": "1.143.0",
|
||||
"version": "1.144.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "code-oss-dev",
|
||||
"version": "1.143.0",
|
||||
"version": "1.144.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -23,7 +23,7 @@
|
||||
"@microsoft/dev-tunnels-ssh-tcp": "^3.12.22",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@vscode/codicons": "^0.0.46-44",
|
||||
"@vscode/codicons": "^0.0.46-46",
|
||||
"@vscode/copilot-api": "^0.5.2",
|
||||
"@vscode/deviceid": "^0.1.1",
|
||||
"@vscode/diff": "0.0.2-7",
|
||||
@@ -55,7 +55,7 @@
|
||||
"@xterm/xterm": "^6.1.0-beta.304",
|
||||
"chrome-remote-interface": "^0.33.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"foundry-local-sdk": "2.1.0",
|
||||
"foundry-local-sdk": "2.0.1",
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"jschardet": "3.1.4",
|
||||
@@ -4293,9 +4293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/codicons": {
|
||||
"version": "0.0.46-44",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-44.tgz",
|
||||
"integrity": "sha512-Oy9BsJ0Yr4cQc8vTgqY4F8vXsByIwOfXpGxnWKIwaJ6V9twR35C96uIIHPxNDW64oxQpx8oRR+9L7+Y7oPp+8w==",
|
||||
"version": "0.0.46-46",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-46.tgz",
|
||||
"integrity": "sha512-5P6z/falKoU3upoWeCcgjMV7HYJ1dQE0F6cXViLKIRe+l4JvVjQXmL0LetwUjVRV0eh7yXfpgIoDrdY2VadNoQ==",
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/@vscode/component-explorer": {
|
||||
@@ -10168,9 +10168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/foundry-local-sdk": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/foundry-local-sdk/-/foundry-local-sdk-2.1.0.tgz",
|
||||
"integrity": "sha512-sUgF+O7uHGVj3bInufe9nmj2SDry06dKGfVLpg9Y1TPPk/hxZEDAk2QPtU13eGZ0k7SIP3OOZFTkmvbtT+TPrw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/foundry-local-sdk/-/foundry-local-sdk-2.0.1.tgz",
|
||||
"integrity": "sha512-5TwBhKypAGEeezfvluiNlWPVIBX82MW9riqerlROKEzODRYxmzo13fKQ2qEDkVBbioV2PWP4LFFzUqk/NrKjcw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "code-oss-dev",
|
||||
"version": "1.143.0",
|
||||
"version": "1.144.0",
|
||||
"distro": "f208aaeb1f93d9d6b44034abbb2a911f3b9ef471",
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
@@ -113,7 +113,7 @@
|
||||
"@microsoft/dev-tunnels-ssh-tcp": "^3.12.22",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@vscode/codicons": "^0.0.46-44",
|
||||
"@vscode/codicons": "^0.0.46-46",
|
||||
"@vscode/copilot-api": "^0.5.2",
|
||||
"@vscode/deviceid": "^0.1.1",
|
||||
"@vscode/diff": "0.0.2-7",
|
||||
@@ -145,7 +145,7 @@
|
||||
"@xterm/xterm": "^6.1.0-beta.304",
|
||||
"chrome-remote-interface": "^0.33.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"foundry-local-sdk": "2.1.0",
|
||||
"foundry-local-sdk": "2.0.1",
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"jschardet": "3.1.4",
|
||||
@@ -298,7 +298,7 @@
|
||||
"allowScripts": {
|
||||
"bufferutil@4.1.0": true,
|
||||
"cpu-features": false,
|
||||
"foundry-local-sdk@2.1.0": true,
|
||||
"foundry-local-sdk@2.0.1": true,
|
||||
"kerberos@2.1.1": true,
|
||||
"koffi@3.2.1": false,
|
||||
"native-keymap@3.3.9": true,
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@vscode/codicons": "^0.0.46-44",
|
||||
"@vscode/codicons": "^0.0.46-46",
|
||||
"@vscode/iconv-lite-umd": "0.7.1",
|
||||
"@vscode/tree-sitter-wasm": "^0.3.1",
|
||||
"@vscode/vscode-languagedetection": "1.0.23",
|
||||
@@ -73,9 +73,9 @@
|
||||
"integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ=="
|
||||
},
|
||||
"node_modules/@vscode/codicons": {
|
||||
"version": "0.0.46-44",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-44.tgz",
|
||||
"integrity": "sha512-Oy9BsJ0Yr4cQc8vTgqY4F8vXsByIwOfXpGxnWKIwaJ6V9twR35C96uIIHPxNDW64oxQpx8oRR+9L7+Y7oPp+8w==",
|
||||
"version": "0.0.46-46",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-46.tgz",
|
||||
"integrity": "sha512-5P6z/falKoU3upoWeCcgjMV7HYJ1dQE0F6cXViLKIRe+l4JvVjQXmL0LetwUjVRV0eh7yXfpgIoDrdY2VadNoQ==",
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/@vscode/iconv-lite-umd": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"dependencies": {
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@vscode/codicons": "^0.0.46-44",
|
||||
"@vscode/codicons": "^0.0.46-46",
|
||||
"@vscode/iconv-lite-umd": "0.7.1",
|
||||
"@vscode/tree-sitter-wasm": "^0.3.1",
|
||||
"@vscode/vscode-languagedetection": "1.0.23",
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
|
||||
vscode_copilot_link_path=/usr/bin/copilot
|
||||
vscode_copilot_link_code_target=/usr/share/code/bin/copilot-shim/copilot
|
||||
vscode_copilot_link_code_insiders_target=/usr/share/code-insiders/bin/copilot-shim/copilot
|
||||
|
||||
vscode_copilot_link_warn() {
|
||||
printf '%s\n' "warning: vscode_copilot_link: $1" >&2
|
||||
}
|
||||
|
||||
vscode_copilot_link_read_target() {
|
||||
vscode_copilot_link_read_path=$1
|
||||
vscode_copilot_link_read_target=
|
||||
|
||||
if [ ! -L "$vscode_copilot_link_read_path" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! vscode_copilot_link_read_target=$(readlink -n -- "$vscode_copilot_link_read_path" && printf x); then
|
||||
vscode_copilot_link_warn "could not inspect symbolic link $vscode_copilot_link_read_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
vscode_copilot_link_read_target=${vscode_copilot_link_read_target%x}
|
||||
return 0
|
||||
}
|
||||
|
||||
vscode_copilot_link_is_usable() {
|
||||
[ -f "$1" ] && [ -x "$1" ]
|
||||
}
|
||||
|
||||
# Whether an edition should provide the command. A DEB records the choice in debconf; RPM has no equivalent, so it
|
||||
# always does unless the CopilotCliCommand policy turns the command off.
|
||||
vscode_copilot_link_wanted() {
|
||||
if command -v db_get >/dev/null 2>&1 && db_get "$1/add-copilot-command" && [ "$RET" = false ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Sets vscode_copilot_link_policy to allowed, disabled, or unknown using the shim's own policy reader, which exits with
|
||||
# 0 or 10. Unknown means the shim is missing, can't run, or predates the policy command.
|
||||
vscode_copilot_link_query_policy() {
|
||||
vscode_copilot_link_policy=unknown
|
||||
|
||||
if ! vscode_copilot_link_is_usable "$1"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Keep the shim off standard input, which debconf uses for its protocol.
|
||||
if "$1" --vscode-shim policy </dev/null >/dev/null 2>&1; then
|
||||
vscode_copilot_link_policy=allowed
|
||||
else
|
||||
vscode_copilot_link_policy_status=$?
|
||||
if [ "$vscode_copilot_link_policy_status" -eq 10 ]; then
|
||||
vscode_copilot_link_policy=disabled
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
vscode_copilot_link_cleanup_temporary() {
|
||||
vscode_copilot_link_cleanup_status=0
|
||||
|
||||
if [ "$vscode_copilot_link_temporary_link_created" = true ] && [ -L "$vscode_copilot_link_temporary_link" ]; then
|
||||
if vscode_copilot_link_read_target "$vscode_copilot_link_temporary_link" && [ "$vscode_copilot_link_read_target" = "$vscode_copilot_link_new_target" ]; then
|
||||
if ! rm -f -- "$vscode_copilot_link_temporary_link"; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: failed to remove temporary link $vscode_copilot_link_temporary_link"
|
||||
vscode_copilot_link_cleanup_status=1
|
||||
fi
|
||||
else
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: temporary link $vscode_copilot_link_temporary_link changed before cleanup; preserving it"
|
||||
vscode_copilot_link_cleanup_status=1
|
||||
fi
|
||||
elif [ -e "$vscode_copilot_link_temporary_link" ]; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: temporary path $vscode_copilot_link_temporary_link is no longer an owned symbolic link; preserving it"
|
||||
vscode_copilot_link_cleanup_status=1
|
||||
fi
|
||||
|
||||
if [ -d "$vscode_copilot_link_temporary_directory" ]; then
|
||||
if ! rmdir -- "$vscode_copilot_link_temporary_directory"; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: failed to remove temporary directory $vscode_copilot_link_temporary_directory"
|
||||
vscode_copilot_link_cleanup_status=1
|
||||
fi
|
||||
elif [ -e "$vscode_copilot_link_temporary_directory" ] || [ -L "$vscode_copilot_link_temporary_directory" ]; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: temporary path $vscode_copilot_link_temporary_directory is no longer an owned directory; preserving it"
|
||||
vscode_copilot_link_cleanup_status=1
|
||||
fi
|
||||
|
||||
return "$vscode_copilot_link_cleanup_status"
|
||||
}
|
||||
|
||||
vscode_copilot_link_replace_recognized() {
|
||||
vscode_copilot_link_new_target=$1
|
||||
vscode_copilot_link_expected_target=$2
|
||||
vscode_copilot_link_operation=$3
|
||||
vscode_copilot_link_temporary_directory=
|
||||
vscode_copilot_link_temporary_link=
|
||||
vscode_copilot_link_temporary_link_created=false
|
||||
|
||||
if ! vscode_copilot_link_temporary_directory=$(mktemp -d "${vscode_copilot_link_path}.vscode-copilot.XXXXXX"); then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: failed to create a temporary directory beside $vscode_copilot_link_path while selecting $vscode_copilot_link_new_target"
|
||||
return 1
|
||||
fi
|
||||
|
||||
vscode_copilot_link_temporary_link=$vscode_copilot_link_temporary_directory/copilot
|
||||
if ! ln -s -T -- "$vscode_copilot_link_new_target" "$vscode_copilot_link_temporary_link"; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: failed to create temporary link $vscode_copilot_link_temporary_link"
|
||||
vscode_copilot_link_cleanup_temporary || :
|
||||
return 1
|
||||
fi
|
||||
vscode_copilot_link_temporary_link_created=true
|
||||
|
||||
if ! vscode_copilot_link_read_target "$vscode_copilot_link_path"; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: could not recheck $vscode_copilot_link_path before replacement; preserving it"
|
||||
vscode_copilot_link_cleanup_temporary || :
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$vscode_copilot_link_read_target" != "$vscode_copilot_link_expected_target" ]; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: $vscode_copilot_link_path changed before replacement; preserving it"
|
||||
vscode_copilot_link_cleanup_temporary || :
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! mv -Tf -- "$vscode_copilot_link_temporary_link" "$vscode_copilot_link_path"; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: failed to replace $vscode_copilot_link_path with $vscode_copilot_link_new_target"
|
||||
vscode_copilot_link_cleanup_temporary || :
|
||||
return 1
|
||||
fi
|
||||
vscode_copilot_link_temporary_link_created=false
|
||||
|
||||
if ! vscode_copilot_link_cleanup_temporary; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
vscode_copilot_link_install() {
|
||||
if ! vscode_copilot_link_is_usable "$vscode_copilot_link_current_target"; then
|
||||
vscode_copilot_link_warn "install: current Copilot shim is not a regular executable file at $vscode_copilot_link_current_target; preserving $vscode_copilot_link_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
vscode_copilot_link_query_policy "$vscode_copilot_link_current_target"
|
||||
if [ "$vscode_copilot_link_policy" = disabled ]; then
|
||||
vscode_copilot_link_withdraw install
|
||||
return $?
|
||||
fi
|
||||
|
||||
if ! vscode_copilot_link_wanted "$vscode_copilot_link_application_name"; then
|
||||
vscode_copilot_link_release install
|
||||
return $?
|
||||
fi
|
||||
|
||||
if [ "$vscode_copilot_link_policy" != allowed ]; then
|
||||
vscode_copilot_link_warn "install: could not read the CopilotCliCommand policy with $vscode_copilot_link_current_target; preserving $vscode_copilot_link_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -L "$vscode_copilot_link_path" ]; then
|
||||
if ! vscode_copilot_link_read_target "$vscode_copilot_link_path"; then
|
||||
vscode_copilot_link_warn "install: could not inspect $vscode_copilot_link_path; preserving it"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$vscode_copilot_link_read_target" = "$vscode_copilot_link_code_target" ] || [ "$vscode_copilot_link_read_target" = "$vscode_copilot_link_code_insiders_target" ]; then
|
||||
vscode_copilot_link_replace_recognized "$vscode_copilot_link_current_target" "$vscode_copilot_link_read_target" install
|
||||
return $?
|
||||
fi
|
||||
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -e "$vscode_copilot_link_path" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! ln -s -T -- "$vscode_copilot_link_current_target" "$vscode_copilot_link_path"; then
|
||||
vscode_copilot_link_warn "install: failed to create $vscode_copilot_link_path pointing to $vscode_copilot_link_current_target; preserving the current entry"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
vscode_copilot_link_unlink() {
|
||||
vscode_copilot_link_unlink_expected_target=$1
|
||||
vscode_copilot_link_operation=$2
|
||||
|
||||
if ! vscode_copilot_link_read_target "$vscode_copilot_link_path"; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: could not recheck $vscode_copilot_link_path before removal; preserving it"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$vscode_copilot_link_read_target" != "$vscode_copilot_link_unlink_expected_target" ]; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: $vscode_copilot_link_path changed before removal; preserving it"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! rm -f -- "$vscode_copilot_link_path"; then
|
||||
vscode_copilot_link_warn "$vscode_copilot_link_operation: failed to remove owned link $vscode_copilot_link_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Removes a link that selects either edition's shim, because the CopilotCliCommand policy turns the command off.
|
||||
vscode_copilot_link_withdraw() {
|
||||
if [ ! -L "$vscode_copilot_link_path" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! vscode_copilot_link_read_target "$vscode_copilot_link_path"; then
|
||||
vscode_copilot_link_warn "$1: could not inspect $vscode_copilot_link_path; preserving it"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$vscode_copilot_link_read_target" = "$vscode_copilot_link_code_target" ] || [ "$vscode_copilot_link_read_target" = "$vscode_copilot_link_code_insiders_target" ]; then
|
||||
vscode_copilot_link_unlink "$vscode_copilot_link_read_target" "$1"
|
||||
return $?
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Gives up a link that selects the current shim: the other edition takes it over when it wants the command and its
|
||||
# shim reports that the policy allows it; otherwise the link is removed.
|
||||
vscode_copilot_link_release() {
|
||||
if [ ! -L "$vscode_copilot_link_path" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! vscode_copilot_link_read_target "$vscode_copilot_link_path"; then
|
||||
vscode_copilot_link_warn "$1: could not inspect $vscode_copilot_link_path; preserving it"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$vscode_copilot_link_read_target" != "$vscode_copilot_link_current_target" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if vscode_copilot_link_is_usable "$vscode_copilot_link_other_target" && vscode_copilot_link_wanted "$vscode_copilot_link_other_application_name"; then
|
||||
vscode_copilot_link_query_policy "$vscode_copilot_link_other_target"
|
||||
if [ "$vscode_copilot_link_policy" = allowed ]; then
|
||||
vscode_copilot_link_replace_recognized "$vscode_copilot_link_other_target" "$vscode_copilot_link_current_target" "$1"
|
||||
return $?
|
||||
fi
|
||||
fi
|
||||
|
||||
vscode_copilot_link_unlink "$vscode_copilot_link_current_target" "$1"
|
||||
}
|
||||
|
||||
vscode_copilot_link_remove() {
|
||||
# The current shim is already gone, so only the other edition's shim can report a policy that turns the command off.
|
||||
vscode_copilot_link_query_policy "$vscode_copilot_link_other_target"
|
||||
if [ "$vscode_copilot_link_policy" = disabled ]; then
|
||||
vscode_copilot_link_withdraw remove
|
||||
return $?
|
||||
fi
|
||||
|
||||
vscode_copilot_link_release remove
|
||||
}
|
||||
|
||||
vscode_copilot_link() {
|
||||
vscode_copilot_link_action=${1-}
|
||||
vscode_copilot_link_application_name=${2-}
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
vscode_copilot_link_warn "expected an action and application name"
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$vscode_copilot_link_application_name" in
|
||||
code)
|
||||
vscode_copilot_link_current_target=$vscode_copilot_link_code_target
|
||||
vscode_copilot_link_other_target=$vscode_copilot_link_code_insiders_target
|
||||
vscode_copilot_link_other_application_name=code-insiders
|
||||
;;
|
||||
code-insiders)
|
||||
vscode_copilot_link_current_target=$vscode_copilot_link_code_insiders_target
|
||||
vscode_copilot_link_other_target=$vscode_copilot_link_code_target
|
||||
vscode_copilot_link_other_application_name=code
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$vscode_copilot_link_action" in
|
||||
install)
|
||||
vscode_copilot_link_install
|
||||
;;
|
||||
remove)
|
||||
vscode_copilot_link_remove
|
||||
;;
|
||||
*)
|
||||
vscode_copilot_link_warn "unknown action $vscode_copilot_link_action"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -3,10 +3,27 @@
|
||||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
|
||||
@@COPILOT_LINK_HELPER@@
|
||||
|
||||
# Symlink bin command to /usr/bin
|
||||
rm -f /usr/bin/@@NAME@@
|
||||
ln -s /usr/share/@@NAME@@/bin/@@NAME@@ /usr/bin/@@NAME@@
|
||||
|
||||
if [ "$1" = "configure" ]; then
|
||||
# Ask at medium priority so the default applies unless the question is preseeded or asked by dpkg-reconfigure.
|
||||
# Only the editions that manage /usr/bin/copilot ask it.
|
||||
case "@@NAME@@" in
|
||||
code|code-insiders)
|
||||
if [ -e '/usr/share/debconf/confmodule' ]; then
|
||||
. /usr/share/debconf/confmodule
|
||||
db_input medium @@NAME@@/add-copilot-command || true
|
||||
db_go || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
vscode_copilot_link install "@@NAME@@" || :
|
||||
fi
|
||||
|
||||
# Register code in the alternatives system
|
||||
# Priority of 0 should never make code the default editor in auto mode as most
|
||||
# developers would prefer a terminal editor as the default.
|
||||
|
||||
@@ -3,8 +3,20 @@
|
||||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
|
||||
@@COPILOT_LINK_HELPER@@
|
||||
|
||||
rm -f /usr/bin/@@NAME@@
|
||||
|
||||
case "$1" in
|
||||
remove|purge)
|
||||
# Load debconf so the other edition's add-copilot-command answer is respected before handing the link over.
|
||||
if [ -e '/usr/share/debconf/confmodule' ]; then
|
||||
. /usr/share/debconf/confmodule
|
||||
fi
|
||||
vscode_copilot_link remove "@@NAME@@" || :
|
||||
;;
|
||||
esac
|
||||
|
||||
# Uninstall the desktop entry
|
||||
if hash update-desktop-database 2>/dev/null; then
|
||||
update-desktop-database
|
||||
|
||||
@@ -4,3 +4,11 @@ Default: true
|
||||
Description: Add Microsoft apt repository for Visual Studio Code?
|
||||
The installer would like to add the Microsoft repository and signing
|
||||
key to update VS Code through apt.
|
||||
|
||||
Template: @@NAME@@/add-copilot-command
|
||||
Type: boolean
|
||||
Default: true
|
||||
Description: Add the copilot command for GitHub Copilot CLI to PATH?
|
||||
The installer would like to add /usr/bin/copilot, which runs GitHub
|
||||
Copilot CLI and offers to install it when it's missing. The
|
||||
CopilotCliCommand policy in /etc/vscode/policy.json takes precedence.
|
||||
@@ -49,11 +49,15 @@ cp usr/share/bash-completion/completions/%{name} %{buildroot}%{_datadir}/bash-co
|
||||
cp usr/share/zsh/site-functions/_%{name} %{buildroot}%{_datadir}/zsh/site-functions/_%{name}
|
||||
|
||||
%post
|
||||
@@COPILOT_LINK_HELPER@@
|
||||
|
||||
# Remove the legacy bin command if this is the stable build
|
||||
if [ "%{name}" = "code" ]; then
|
||||
rm -f /usr/local/bin/code
|
||||
fi
|
||||
|
||||
vscode_copilot_link install "%{name}" || :
|
||||
|
||||
# Register yum repository
|
||||
# TODO: #229: Enable once the yum repository is signed
|
||||
#if [ "@@NAME@@" != "code-oss" ]; then
|
||||
@@ -71,6 +75,12 @@ update-desktop-database &> /dev/null || :
|
||||
update-mime-database %{_datadir}/mime &> /dev/null || :
|
||||
|
||||
%postun
|
||||
@@COPILOT_LINK_HELPER@@
|
||||
|
||||
if [ "$1" = 0 ]; then
|
||||
vscode_copilot_link remove "%{name}" || :
|
||||
fi
|
||||
|
||||
# Uninstall the desktop entry
|
||||
update-desktop-database &> /dev/null || :
|
||||
|
||||
|
||||
@@ -56,14 +56,23 @@ function _indentVerbose(text: string): string {
|
||||
interface StreamChunk {
|
||||
content: string;
|
||||
delayMs: number;
|
||||
/** Optional test-controlled barrier before this chunk is sent. */
|
||||
waitFor?: Promise<void>;
|
||||
}
|
||||
|
||||
type ScenarioToolCallArguments = Record<string, any> | ((request: readonly any[]) => Record<string, any>);
|
||||
|
||||
interface ScenarioToolCall {
|
||||
type ScenarioToolCall = {
|
||||
toolNamePattern: RegExp;
|
||||
namespace?: string;
|
||||
kind?: 'function';
|
||||
arguments: ScenarioToolCallArguments;
|
||||
}
|
||||
} | {
|
||||
toolNamePattern: RegExp;
|
||||
namespace?: string;
|
||||
kind: 'custom';
|
||||
input: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single turn in a multi-turn scenario.
|
||||
@@ -916,7 +925,7 @@ function resolveCurrentTurn(turns: ScenarioTurn[], messages: any[]): { turn: Mod
|
||||
function findLastToolResult(items: any[]): any {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const item = items[i];
|
||||
if (item?.role === 'tool' || item?.type === 'function_call_output') {
|
||||
if (item?.role === 'tool' || item?.type === 'function_call_output' || item?.type === 'custom_tool_call_output') {
|
||||
return item;
|
||||
}
|
||||
if (Array.isArray(item?.content)) {
|
||||
@@ -1067,6 +1076,7 @@ async function streamContent(res: import('http').ServerResponse, chunks: StreamC
|
||||
res.write(`data: ${JSON.stringify(makeInitialChunk())}\n\n`);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await chunk.waitFor;
|
||||
if (chunk.delayMs > 0) { await sleep(chunk.delayMs); }
|
||||
res.write(`data: ${JSON.stringify(makeChunk(chunk.content, 0, false))}\n\n`);
|
||||
}
|
||||
@@ -1215,13 +1225,17 @@ async function sendResponsesNonStreaming(
|
||||
toolName = call.toolNamePattern.source.replace(/[\\.|?*+^${}()\[\]]/g, '');
|
||||
}
|
||||
const callId = `call_${scenarioId}_${i}_${Date.now()}`;
|
||||
const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call.arguments, input));
|
||||
if (call.kind === 'custom') {
|
||||
return { id: `ctc_${callId}`, type: 'custom_tool_call', status: 'completed', call_id: callId, name: toolName, namespace: call.namespace, input: call.input };
|
||||
}
|
||||
const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call, input));
|
||||
return {
|
||||
id: `fc_${callId}`,
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: callId,
|
||||
name: toolName,
|
||||
namespace: call.namespace,
|
||||
arguments: argsJson,
|
||||
};
|
||||
});
|
||||
@@ -1288,8 +1302,8 @@ async function sendResponsesNonStreaming(
|
||||
/**
|
||||
* Count completed assistant turns in a Responses API `input` array, after the
|
||||
* last user message carrying a `[scenario:X]` tag. Consecutive assistant
|
||||
* output items (`role === 'assistant'` messages or `type === 'function_call'`
|
||||
* items) are grouped into a single turn so multi-tool-call turns count once.
|
||||
* output items (assistant messages, function calls, or custom tool calls)
|
||||
* are grouped into a single turn so multi-tool-call turns count once.
|
||||
*/
|
||||
function countCompletedResponsesApiModelTurns(input: any[]): number {
|
||||
let scenarioIdx = -1;
|
||||
@@ -1312,7 +1326,7 @@ function countCompletedResponsesApiModelTurns(input: any[]): number {
|
||||
const startIdx = scenarioIdx >= 0 ? scenarioIdx + 1 : 0;
|
||||
for (let i = startIdx; i < input.length; i++) {
|
||||
const item = input[i];
|
||||
const isAssistantOutput = item.role === 'assistant' || item.type === 'function_call';
|
||||
const isAssistantOutput = item.role === 'assistant' || item.type === 'function_call' || item.type === 'custom_tool_call';
|
||||
if (isAssistantOutput) {
|
||||
if (!inAssistantBlock) {
|
||||
turns++;
|
||||
@@ -1337,7 +1351,7 @@ function resolveCurrentResponsesApiTurn(turns: ScenarioTurn[], input: any[]): {
|
||||
|
||||
/**
|
||||
* Stream tool calls as Responses API SSE events. Emits one
|
||||
* `function_call` output item per requested tool call.
|
||||
* function or custom-tool output item per requested tool call.
|
||||
*/
|
||||
async function streamResponsesApiToolCalls(
|
||||
res: import('http').ServerResponse,
|
||||
@@ -1386,15 +1400,19 @@ async function streamResponsesApiToolCalls(
|
||||
|
||||
const callId = `call_${scenarioId}_${i}_${Date.now()}`;
|
||||
const itemId = `fc_${callId}`;
|
||||
const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call.arguments, request));
|
||||
const custom = call.kind === 'custom';
|
||||
const argumentField = custom ? 'input' : 'arguments';
|
||||
const eventName = custom ? 'custom_tool_call_input' : 'function_call_arguments';
|
||||
const argsJson = custom ? call.input : JSON.stringify(resolveScenarioToolCallArguments(call, request));
|
||||
|
||||
const item = {
|
||||
id: itemId,
|
||||
type: 'function_call',
|
||||
type: custom ? 'custom_tool_call' : 'function_call',
|
||||
status: 'in_progress',
|
||||
call_id: callId,
|
||||
name: toolName,
|
||||
arguments: '',
|
||||
namespace: call.namespace,
|
||||
[argumentField]: '',
|
||||
};
|
||||
|
||||
res.write(`event: response.output_item.added\ndata: ${JSON.stringify({
|
||||
@@ -1404,23 +1422,23 @@ async function streamResponsesApiToolCalls(
|
||||
item,
|
||||
})}\n\n`);
|
||||
|
||||
res.write(`event: response.function_call_arguments.delta\ndata: ${JSON.stringify({
|
||||
type: 'response.function_call_arguments.delta',
|
||||
res.write(`event: response.${eventName}.delta\ndata: ${JSON.stringify({
|
||||
type: `response.${eventName}.delta`,
|
||||
sequence_number: nextSeq(),
|
||||
item_id: itemId,
|
||||
output_index: i,
|
||||
delta: argsJson,
|
||||
})}\n\n`);
|
||||
|
||||
res.write(`event: response.function_call_arguments.done\ndata: ${JSON.stringify({
|
||||
type: 'response.function_call_arguments.done',
|
||||
res.write(`event: response.${eventName}.done\ndata: ${JSON.stringify({
|
||||
type: `response.${eventName}.done`,
|
||||
sequence_number: nextSeq(),
|
||||
item_id: itemId,
|
||||
output_index: i,
|
||||
arguments: argsJson,
|
||||
[argumentField]: argsJson,
|
||||
})}\n\n`);
|
||||
|
||||
const doneItem = { ...item, status: 'completed', arguments: argsJson };
|
||||
const doneItem = { ...item, status: 'completed', [argumentField]: argsJson };
|
||||
finalOutput.push(doneItem);
|
||||
|
||||
res.write(`event: response.output_item.done\ndata: ${JSON.stringify({
|
||||
@@ -1504,6 +1522,7 @@ async function streamResponsesContent(res: import('http').ServerResponse, chunks
|
||||
// 4. Stream text deltas
|
||||
let fullText = '';
|
||||
for (const chunk of chunks) {
|
||||
await chunk.waitFor;
|
||||
if (chunk.delayMs > 0) { await sleep(chunk.delayMs); }
|
||||
fullText += chunk.content;
|
||||
res.write(`data: ${JSON.stringify({
|
||||
@@ -1631,6 +1650,7 @@ async function streamAnthropicContent(res: import('http').ServerResponse, chunks
|
||||
|
||||
let totalOutputTokens = 0;
|
||||
for (const chunk of chunks) {
|
||||
await chunk.waitFor;
|
||||
if (chunk.delayMs > 0) { await sleep(chunk.delayMs); }
|
||||
writeAnthropicEvent(res, 'content_block_delta', {
|
||||
index: 0,
|
||||
@@ -1822,7 +1842,7 @@ async function streamAnthropicToolCalls(
|
||||
content_block: { type: 'tool_use', id: callId, name: toolName, input: {} },
|
||||
});
|
||||
|
||||
const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call.arguments, request));
|
||||
const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call, request));
|
||||
const fragmentSize = Math.max(20, Math.ceil(argsJson.length / 4));
|
||||
for (let pos = 0; pos < argsJson.length; pos += fragmentSize) {
|
||||
const fragment = argsJson.slice(pos, pos + fragmentSize);
|
||||
@@ -1863,6 +1883,7 @@ async function streamThinkingThenContent(
|
||||
|
||||
// Stream thinking text
|
||||
for (const chunk of thinkingChunks) {
|
||||
await chunk.waitFor;
|
||||
if (chunk.delayMs > 0) { await sleep(chunk.delayMs); }
|
||||
res.write(`data: ${JSON.stringify(makeThinkingChunk(chunk.content))}\n\n`);
|
||||
}
|
||||
@@ -1874,6 +1895,7 @@ async function streamThinkingThenContent(
|
||||
|
||||
// Stream content
|
||||
for (const chunk of contentChunks) {
|
||||
await chunk.waitFor;
|
||||
if (chunk.delayMs > 0) { await sleep(chunk.delayMs); }
|
||||
res.write(`data: ${JSON.stringify(makeChunk(chunk.content, 0, false))}\n\n`);
|
||||
}
|
||||
@@ -1914,7 +1936,7 @@ async function streamToolCalls(
|
||||
res.write(`data: ${JSON.stringify(makeToolCallStartChunk(i, callId, toolName))}\n\n`);
|
||||
await sleep(10);
|
||||
|
||||
const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call.arguments, request));
|
||||
const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call, request));
|
||||
const fragmentSize = Math.max(20, Math.ceil(argsJson.length / 4));
|
||||
for (let pos = 0; pos < argsJson.length; pos += fragmentSize) {
|
||||
const fragment = argsJson.slice(pos, pos + fragmentSize);
|
||||
@@ -1928,8 +1950,11 @@ async function streamToolCalls(
|
||||
res.end();
|
||||
}
|
||||
|
||||
function resolveScenarioToolCallArguments(argumentsOrResolver: ScenarioToolCallArguments, request: readonly any[]): Record<string, any> {
|
||||
return typeof argumentsOrResolver === 'function' ? argumentsOrResolver(request) : argumentsOrResolver;
|
||||
function resolveScenarioToolCallArguments(call: ScenarioToolCall, request: readonly any[]): Record<string, any> {
|
||||
if (call.kind === 'custom') {
|
||||
throw new Error('Custom tool calls require the Responses API');
|
||||
}
|
||||
return typeof call.arguments === 'function' ? call.arguments(request) : call.arguments;
|
||||
}
|
||||
|
||||
interface MockLlmServerHandle {
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface MarkdownSanitizerConfig {
|
||||
readonly allowedLinkSchemes?: {
|
||||
readonly augment: readonly string[];
|
||||
};
|
||||
readonly allowedMediaSchemes?: {
|
||||
readonly augment: readonly string[];
|
||||
};
|
||||
readonly remoteImageIsAllowed?: (uri: URI) => boolean;
|
||||
}
|
||||
|
||||
@@ -252,7 +255,7 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende
|
||||
domSanitize.safeSetInnerHtml(renderedContent, renderedMarkdown, sanitizerConfig);
|
||||
|
||||
// Rewrite links and images before potentially inserting them into the real dom
|
||||
rewriteRenderedLinks(markdown, options, renderedContent);
|
||||
rewriteRenderedLinks(markdown, options, renderedContent, disposables);
|
||||
|
||||
let outElement: HTMLElement;
|
||||
if (target) {
|
||||
@@ -439,7 +442,7 @@ function replaceChildrenPreservingCodeBlocks(target: HTMLElement, source: HTMLEl
|
||||
updateChildren(target, source);
|
||||
}
|
||||
|
||||
function rewriteRenderedLinks(markdown: IMarkdownString, options: MarkdownRenderOptions, root: HTMLElement) {
|
||||
function rewriteRenderedLinks(markdown: IMarkdownString, options: MarkdownRenderOptions, root: HTMLElement, disposables: DisposableStore) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const el of root.querySelectorAll('img, audio, video, source')) {
|
||||
const src = el.getAttribute('src'); // Get the raw 'src' attribute value as text, not the resolved 'src'
|
||||
@@ -451,7 +454,26 @@ function rewriteRenderedLinks(markdown: IMarkdownString, options: MarkdownRender
|
||||
}
|
||||
} catch (err) { }
|
||||
|
||||
el.setAttribute('src', massageHref(markdown, href, true));
|
||||
const uri = URI.revive(markdown.uris?.[href]) ?? URI.parse(href);
|
||||
const resolved = FileAccess.resolveBrowserUri(uri);
|
||||
if (resolved) {
|
||||
el.removeAttribute('src');
|
||||
void resolved.then(ref => {
|
||||
if (disposables.isDisposed) {
|
||||
ref.dispose();
|
||||
return;
|
||||
}
|
||||
disposables.add(ref);
|
||||
el.setAttribute('src', ref.object.toString(true));
|
||||
}, error => {
|
||||
if (!disposables.isDisposed) {
|
||||
el.dispatchEvent(new ErrorEvent('error', { error }));
|
||||
options.asyncRenderCallback?.();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
el.setAttribute('src', massageHref(markdown, href, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,13 +668,14 @@ function resolveWithBaseUri(baseUri: URI, href: string): string {
|
||||
type MdStrConfig = {
|
||||
readonly isTrusted?: boolean | MarkdownStringTrustedOptions;
|
||||
readonly baseUri?: UriComponents;
|
||||
readonly uris?: IMarkdownString['uris'];
|
||||
};
|
||||
|
||||
function isLocalFileUri(uri: URI): boolean {
|
||||
return uri.scheme === Schemas.file && !uri.authority && !isUNC(uri.fsPath);
|
||||
}
|
||||
|
||||
function isMediaSourceAllowed(mdStrConfig: MdStrConfig, source: string, remoteImageIsAllowed: (uri: URI) => boolean): boolean {
|
||||
function isMediaSourceAllowed(mdStrConfig: MdStrConfig, source: string, remoteImageIsAllowed: (uri: URI) => boolean, customSchemes: readonly string[]): boolean {
|
||||
let uri: URI;
|
||||
try {
|
||||
const baseUri = mdStrConfig.baseUri ? URI.from(mdStrConfig.baseUri) : undefined;
|
||||
@@ -662,6 +685,9 @@ function isMediaSourceAllowed(mdStrConfig: MdStrConfig, source: string, remoteIm
|
||||
}
|
||||
const href = baseUri ? resolveWithBaseUri(baseUri, source) : source;
|
||||
uri = URI.parse(href);
|
||||
if (customSchemes.includes(uri.scheme)) {
|
||||
return remoteImageIsAllowed(URI.revive(mdStrConfig.uris?.[href]) ?? uri);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -789,10 +815,11 @@ function getDomSanitizerConfig(mdStrConfig: MdStrConfig, options: MarkdownSaniti
|
||||
Schemas.vscodeFileResource,
|
||||
Schemas.vscodeRemote,
|
||||
Schemas.vscodeRemoteResource,
|
||||
...(options.allowedMediaSchemes?.augment ?? []),
|
||||
]
|
||||
},
|
||||
allowRelativeMediaPaths: !!mdStrConfig.baseUri,
|
||||
mediaSourceIsAllowed: remoteImageIsAllowed ? source => isMediaSourceAllowed(mdStrConfig, source, remoteImageIsAllowed) : undefined,
|
||||
mediaSourceIsAllowed: remoteImageIsAllowed ? source => isMediaSourceAllowed(mdStrConfig, source, remoteImageIsAllowed, options.allowedMediaSchemes?.augment ?? []) : undefined,
|
||||
replaceWithPlaintext: options.replaceWithPlaintext,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -772,4 +772,5 @@ export const codiconsLibrary = {
|
||||
gripperCorner: register('gripper-corner', 0xecf6),
|
||||
meteredConnection: register('metered-connection', 0xecf7),
|
||||
reportQuestion: register('report-question', 0xecf8),
|
||||
trophy: register('trophy', 0xecf9),
|
||||
} as const;
|
||||
@@ -8,6 +8,7 @@ import * as platform from './platform.js';
|
||||
import { equalsIgnoreCase, startsWithIgnoreCase } from './strings.js';
|
||||
import { URI } from './uri.js';
|
||||
import * as paths from './path.js';
|
||||
import { IDisposable, IReference, toDisposable } from './lifecycle.js';
|
||||
|
||||
export namespace Schemas {
|
||||
|
||||
@@ -287,9 +288,29 @@ export const nodeModulesAsarUnpackedPath: AppResourcePath = 'vs/../../node_modul
|
||||
export const AGENTS_AUTHORITY = 'agents';
|
||||
export const VSCODE_AUTHORITY = 'vscode-app';
|
||||
|
||||
interface BrowserUriProvider {
|
||||
uriToBrowserUri?: (uri: URI) => URI;
|
||||
resolve: (uri: URI) => Promise<IReference<URI>>;
|
||||
}
|
||||
|
||||
class FileAccessImpl {
|
||||
|
||||
private static readonly FALLBACK_AUTHORITY = VSCODE_AUTHORITY;
|
||||
private readonly browserUriProviders = new Map<string, BrowserUriProvider>();
|
||||
|
||||
/** Registers a browser-resource transport for a filesystem scheme. */
|
||||
registerBrowserUriProvider(scheme: string, provider: BrowserUriProvider): IDisposable {
|
||||
if (this.browserUriProviders.has(scheme)) {
|
||||
throw new Error(`Browser URI provider already registered for ${scheme}`);
|
||||
}
|
||||
this.browserUriProviders.set(scheme, provider);
|
||||
return toDisposable(() => this.browserUriProviders.delete(scheme));
|
||||
}
|
||||
|
||||
/** Resolves filesystem resources, including scoped object URLs on web hosts without a resource endpoint. */
|
||||
resolveBrowserUri(uri: URI): Promise<IReference<URI>> | undefined {
|
||||
return this.browserUriProviders.get(uri.scheme)?.resolve(uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a URI to use in contexts where the browser is responsible
|
||||
@@ -309,6 +330,10 @@ class FileAccessImpl {
|
||||
* **Note:** use `dom.ts#asCSSUrl` whenever the URL is to be used in CSS context.
|
||||
*/
|
||||
uriToBrowserUri(uri: URI): URI {
|
||||
const provider = this.browserUriProviders.get(uri.scheme);
|
||||
if (provider?.uriToBrowserUri) {
|
||||
return provider.uriToBrowserUri(uri);
|
||||
}
|
||||
// Handle remote URIs via `RemoteAuthorities`
|
||||
if (uri.scheme === Schemas.vscodeRemote) {
|
||||
return RemoteAuthorities.rewrite(uri);
|
||||
|
||||
@@ -253,6 +253,7 @@ export async function validateZip(zipPath: string, options: IZipValidationOption
|
||||
export interface IZipOptions {
|
||||
readonly token?: CancellationToken;
|
||||
readonly onProgress?: (bytes: number, path: string) => void;
|
||||
readonly onSourceError?: (path: string, error: Error) => void;
|
||||
}
|
||||
|
||||
export async function zip(zipPath: string, files: IFile[], options?: IZipOptions): Promise<string> {
|
||||
@@ -314,6 +315,7 @@ export async function zip(zipPath: string, files: IFile[], options?: IZipOptions
|
||||
handle = await promises.open(f.localPath, 'r');
|
||||
} catch (error) {
|
||||
if (f.skipSourceErrors) {
|
||||
options?.onSourceError?.(f.path, error instanceof Error ? error : new Error(String(error)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
@@ -326,6 +328,7 @@ export async function zip(zipPath: string, files: IFile[], options?: IZipOptions
|
||||
size = f.localPathSize === undefined ? currentSize : Math.min(f.localPathSize, currentSize);
|
||||
} catch (error) {
|
||||
if (f.skipSourceErrors) {
|
||||
options?.onSourceError?.(f.path, error instanceof Error ? error : new Error(String(error)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -12,6 +12,10 @@ import * as marked from '../../common/marked/marked.js';
|
||||
import { parse } from '../../common/marshalling.js';
|
||||
import { isWeb } from '../../common/platform.js';
|
||||
import { URI } from '../../common/uri.js';
|
||||
import { DeferredPromise } from '../../common/async.js';
|
||||
import { FileAccess } from '../../common/network.js';
|
||||
import { addDisposableListener } from '../../browser/dom.js';
|
||||
import { errorHandler } from '../../common/errors.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js';
|
||||
|
||||
function strToNode(str: string): HTMLElement {
|
||||
@@ -95,6 +99,91 @@ suite('MarkdownRenderer', () => {
|
||||
});
|
||||
|
||||
suite('Images', () => {
|
||||
test('resolves only sanitized media through the registered browser transport', async () => {
|
||||
const read: string[] = [];
|
||||
const resource = URI.parse('test-filesystem://connection/image.png?metadata=original');
|
||||
const resolved = new DeferredPromise<{ object: URI; dispose(): void }>();
|
||||
let disposed = false;
|
||||
store.add(FileAccess.registerBrowserUriProvider('test-filesystem', {
|
||||
resolve: uri => {
|
||||
read.push(uri.toString());
|
||||
return resolved.p;
|
||||
},
|
||||
}));
|
||||
const result = store.add(renderMarkdown({ value: `}) ` }, {
|
||||
sanitizerConfig: {
|
||||
allowedMediaSchemes: { augment: ['test-filesystem'] },
|
||||
remoteImageIsAllowed: uri => uri.scheme === 'test-filesystem',
|
||||
replaceWithPlaintext: true,
|
||||
},
|
||||
}));
|
||||
const image = result.element.querySelector('img')!;
|
||||
const before = image.getAttribute('src');
|
||||
await resolved.complete({ object: URI.parse('data:image/png;base64,AA=='), dispose: () => { disposed = true; } });
|
||||
await Promise.resolve();
|
||||
const source = image.getAttribute('src');
|
||||
result.dispose();
|
||||
assert.deepStrictEqual({ before, source, read, disposed }, {
|
||||
before: null, source: 'data:image/png;base64,AA==', read: [resource.toString()], disposed: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('a disposed render never activates a pending media source', async () => {
|
||||
const resolved = new DeferredPromise<{ object: URI; dispose(): void }>();
|
||||
let disposed = false;
|
||||
store.add(FileAccess.registerBrowserUriProvider('test-filesystem', { resolve: () => resolved.p }));
|
||||
const result = renderMarkdown({ value: '' }, {
|
||||
sanitizerConfig: { allowedMediaSchemes: { augment: ['test-filesystem'] } },
|
||||
});
|
||||
const image = result.element.querySelector('img')!;
|
||||
result.dispose();
|
||||
await resolved.complete({ object: URI.parse('data:image/png;base64,AA=='), dispose: () => { disposed = true; } });
|
||||
await Promise.resolve();
|
||||
assert.deepStrictEqual({ source: image.getAttribute('src'), disposed }, { source: null, disposed: true });
|
||||
});
|
||||
|
||||
test('custom media policy checks the URI metadata before activating a source', () => {
|
||||
const href = 'test-filesystem://connection/image.png';
|
||||
let resolutions = 0;
|
||||
store.add(FileAccess.registerBrowserUriProvider('test-filesystem', {
|
||||
resolve: async () => { resolutions++; return { object: URI.parse('data:image/png;base64,AA=='), dispose: () => { } }; },
|
||||
}));
|
||||
const result = store.add(renderMarkdown({
|
||||
value: ``,
|
||||
uris: { [href]: URI.parse('https://attacker.test/image.png') },
|
||||
}, {
|
||||
sanitizerConfig: {
|
||||
allowedMediaSchemes: { augment: ['test-filesystem'] },
|
||||
remoteImageIsAllowed: uri => uri.scheme === 'test-filesystem',
|
||||
replaceWithPlaintext: true,
|
||||
},
|
||||
}));
|
||||
assert.deepStrictEqual({ images: result.element.querySelectorAll('img').length, resolutions }, { images: 0, resolutions: 0 });
|
||||
});
|
||||
|
||||
test('failed resource reads report an image error without reporting a product error', async () => {
|
||||
let unexpectedErrors = 0;
|
||||
let heightChanges = 0;
|
||||
store.add(toDisposable(errorHandler.addListener(() => unexpectedErrors++)));
|
||||
const error = new Error('Image file is unavailable');
|
||||
store.add(FileAccess.registerBrowserUriProvider('test-filesystem', { resolve: async () => { throw error; } }));
|
||||
const result = store.add(renderMarkdown({ value: '' }, {
|
||||
sanitizerConfig: { allowedMediaSchemes: { augment: ['test-filesystem'] } },
|
||||
asyncRenderCallback: () => heightChanges++,
|
||||
}));
|
||||
const image = result.element.querySelector('img')!;
|
||||
const failed = new DeferredPromise<Event>();
|
||||
store.add(addDisposableListener(image, 'error', event => failed.complete(event)));
|
||||
const event = await failed.p;
|
||||
assert.deepStrictEqual({
|
||||
error: event instanceof ErrorEvent ? event.error : undefined,
|
||||
source: image.getAttribute('src'),
|
||||
alt: image.alt,
|
||||
unexpectedErrors,
|
||||
heightChanges,
|
||||
}, { error, source: null, alt: 'Image', unexpectedErrors: 0, heightChanges: 1 });
|
||||
});
|
||||
|
||||
test('image rendering conforms to default', () => {
|
||||
const markdown = { value: `` };
|
||||
const result: HTMLElement = store.add(renderMarkdown(markdown)).element;
|
||||
|
||||
@@ -151,12 +151,19 @@ suite('Zip', () => {
|
||||
const zipPath = path.join(testDir, 'logs.zip');
|
||||
await fs.promises.mkdir(testDir, { recursive: true });
|
||||
await fs.promises.writeFile(presentPath, 'present-contents');
|
||||
const sourceErrors: { path: string; missing: boolean }[] = [];
|
||||
await zip(zipPath, [
|
||||
{ path: 'missing.txt', localPath: missingPath, localPathSize: 8, skipSourceErrors: true },
|
||||
{ path: 'present.txt', localPath: presentPath, localPathSize: 7 },
|
||||
]);
|
||||
], { onSourceError: (path, error) => sourceErrors.push({ path, missing: error.message.includes('ENOENT') }) });
|
||||
|
||||
assert.strictEqual((await buffer(zipPath, 'present.txt')).toString(), 'present');
|
||||
assert.deepStrictEqual({
|
||||
contents: (await buffer(zipPath, 'present.txt')).toString(),
|
||||
sourceErrors,
|
||||
}, {
|
||||
contents: 'present',
|
||||
sourceErrors: [{ path: 'missing.txt', missing: true }],
|
||||
});
|
||||
await assert.rejects(buffer(zipPath, 'missing.txt'));
|
||||
|
||||
await Promises.rm(testDir);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ILogService } from '../../../../platform/log/common/log.js';
|
||||
import { EditorOption } from '../../../common/config/editorOptions.js';
|
||||
import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { VSDataTransfer } from '../../../../base/common/dataTransfer.js';
|
||||
import { getPathForFile } from '../../../../platform/dnd/browser/dnd.js';
|
||||
import { toExternalVSDataTransfer } from '../../dataTransfer.js';
|
||||
|
||||
export function generateDataToCopyAndStoreInMemory(viewModel: IViewModel, id: string | undefined, isFirefox: boolean): { dataToCopy: ClipboardDataToCopy; metadata: ClipboardStoredMetadata } {
|
||||
@@ -133,7 +134,7 @@ interface InMemoryClipboardMetadata {
|
||||
|
||||
const ClipboardEventUtils = {
|
||||
|
||||
getTextData(clipboardData: IReadableClipboardData | DataTransfer): [string, ClipboardStoredMetadata | null] {
|
||||
getTextData(clipboardData: IReadableClipboardData | DataTransfer, getFilePath: (file: File) => string | undefined): [string, ClipboardStoredMetadata | null] {
|
||||
const text = clipboardData.getData(Mimes.text);
|
||||
let metadata: ClipboardStoredMetadata | null = null;
|
||||
const rawmetadata = clipboardData.getData('vscode-editor-data');
|
||||
@@ -148,9 +149,11 @@ const ClipboardEventUtils = {
|
||||
}
|
||||
}
|
||||
if (text.length === 0 && metadata === null && clipboardData.files.length > 0) {
|
||||
// no textual data pasted, generate text from file names
|
||||
// No textual data pasted. This is what Chromium does when files copied in a file manager are pasted:
|
||||
// it only exposes the files and withholds the text (which on Linux contains their full paths).
|
||||
// Generate text from the file paths, or from the file names when the paths are not available (e.g. web).
|
||||
const files: File[] = Array.prototype.slice.call(clipboardData.files, 0);
|
||||
return [files.map(file => file.name).join('\n'), null];
|
||||
return [files.map(file => getFilePath(file) || file.name).join('\n'), null];
|
||||
}
|
||||
return [text, metadata];
|
||||
},
|
||||
@@ -301,9 +304,9 @@ export function createClipboardCopyEvent(e: ClipboardEvent, isCut: boolean, cont
|
||||
/**
|
||||
* Creates an IClipboardPasteEvent from a DOM ClipboardEvent.
|
||||
*/
|
||||
export function createClipboardPasteEvent(e: ClipboardEvent): IClipboardPasteEvent {
|
||||
export function createClipboardPasteEvent(e: ClipboardEvent, getFilePath: (file: File) => string | undefined = getPathForFile): IClipboardPasteEvent {
|
||||
let handled = false;
|
||||
let [text, metadata] = e.clipboardData ? ClipboardEventUtils.getTextData(e.clipboardData) : ['', null];
|
||||
let [text, metadata] = e.clipboardData ? ClipboardEventUtils.getTextData(e.clipboardData, getFilePath) : ['', null];
|
||||
metadata = metadata || InMemoryClipboardMetadataManager.INSTANCE.get(text);
|
||||
return {
|
||||
clipboardData: createReadableClipboardData(e.clipboardData),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { createClipboardPasteEvent } from '../../../browser/controller/editContext/clipboardUtils.js';
|
||||
|
||||
suite('ClipboardUtils', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('pasting files copied in a file manager generates text from their paths, falling back to their names', () => {
|
||||
// Chromium only exposes the files in this case and withholds the clipboard text
|
||||
const files = [
|
||||
new File(['hello'], 'example.txt', { type: 'text/plain' }),
|
||||
new File(['# hi'], 'notes #1.md', { type: 'text/markdown' }),
|
||||
];
|
||||
const paths = new Map([
|
||||
['example.txt', '/home/john/Downloads/example.txt'],
|
||||
['notes #1.md', '/home/john/Downloads/my folder/notes #1.md'],
|
||||
]);
|
||||
|
||||
function paste(getFilePath?: (file: File) => string | undefined) {
|
||||
const clipboardData = new DataTransfer();
|
||||
for (const file of files) {
|
||||
clipboardData.items.add(file);
|
||||
}
|
||||
const event = createClipboardPasteEvent(new ClipboardEvent('paste', { clipboardData }), getFilePath);
|
||||
return { text: event.text, metadata: event.metadata };
|
||||
}
|
||||
|
||||
assert.deepStrictEqual({
|
||||
filesWithPaths: paste(file => paths.get(file.name)),
|
||||
filesWithoutPaths: paste(() => undefined),
|
||||
inMemoryFiles: paste(),
|
||||
}, {
|
||||
filesWithPaths: { text: '/home/john/Downloads/example.txt\n/home/john/Downloads/my folder/notes #1.md', metadata: null },
|
||||
filesWithoutPaths: { text: 'example.txt\nnotes #1.md', metadata: null },
|
||||
inMemoryFiles: { text: 'example.txt\nnotes #1.md', metadata: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2696,6 +2696,7 @@ export class ActionListWidget<T> extends Disposable {
|
||||
|
||||
private _showHoverForElement(element: IActionListItem<T>, index: number): void {
|
||||
if (this._currentSubmenuElement === element) {
|
||||
this._cancelSubmenuHide();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2726,7 +2727,11 @@ export class ActionListWidget<T> extends Disposable {
|
||||
}
|
||||
|
||||
private _showSubmenuForElement(element: IActionListItem<T>, anchor: HTMLElement): void {
|
||||
if (!this._hoverEnabled || this._currentSubmenuElement === element) {
|
||||
if (!this._hoverEnabled) {
|
||||
return;
|
||||
}
|
||||
this._cancelSubmenuHide();
|
||||
if (this._currentSubmenuElement === element) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -158,8 +158,12 @@ export interface ITabbedActionListShowOptions<T> {
|
||||
* while the popup stays open is not replayed stale on a tab switch.
|
||||
*/
|
||||
readonly widgetClassNames?: (activeTab: string) => readonly string[];
|
||||
/** Attaches behavior scoped to the popup's `.action-widget` element for its lifetime. */
|
||||
renderWidget?(container: HTMLElement): IDisposable;
|
||||
/** Tab whose contents fix the popup's height, so switching tabs scrolls instead of resizing. */
|
||||
readonly sizingTab?: string;
|
||||
/** Remeasures the popup when its content mode changes, without dismissing it or changing its anchor side. */
|
||||
readonly sizingKey?: string;
|
||||
/** Show the checked item's hover after the popup has been positioned. */
|
||||
readonly showCheckedItemHover?: boolean;
|
||||
/** Optional icon buttons rendered after the tabs. */
|
||||
@@ -235,6 +239,7 @@ export class TabbedActionListWidget extends Disposable {
|
||||
private _fixedListHeight: number | undefined;
|
||||
private _fixedPopupHeight: number | undefined;
|
||||
private _hasMeasuredSizingTab = false;
|
||||
private _sizingKey: string | undefined;
|
||||
/** Side of the anchor the popup opened on, kept across tab swaps so it cannot jump sides. */
|
||||
private _anchorPosition: AnchorPosition | undefined;
|
||||
|
||||
@@ -274,6 +279,12 @@ export class TabbedActionListWidget extends Disposable {
|
||||
this._hasMeasuredSizingTab = false;
|
||||
this._anchorPosition = undefined;
|
||||
}
|
||||
if (this._sizingKey !== options.sizingKey) {
|
||||
this._fixedListHeight = undefined;
|
||||
this._fixedPopupHeight = undefined;
|
||||
this._hasMeasuredSizingTab = false;
|
||||
}
|
||||
this._sizingKey = options.sizingKey;
|
||||
|
||||
let activeTab = options.initialTab;
|
||||
const popupDisposables = new DisposableStore();
|
||||
@@ -303,6 +314,9 @@ export class TabbedActionListWidget extends Disposable {
|
||||
let pendingRefresh: ITabbedActionListRefreshOptions | undefined;
|
||||
|
||||
const widget = dom.append(container, dom.$('.action-widget'));
|
||||
if (options.renderWidget) {
|
||||
renderDisposables.add(options.renderWidget(widget));
|
||||
}
|
||||
// Programmatic page focus can match :focus-visible even after a pointer click.
|
||||
renderDisposables.add(dom.addDisposableListener(widget, dom.EventType.KEY_DOWN, () => widget.classList.add('keyboard-navigation'), true));
|
||||
renderDisposables.add(dom.addDisposableListener(widget, dom.EventType.POINTER_DOWN, () => widget.classList.remove('keyboard-navigation'), true));
|
||||
|
||||
@@ -3152,6 +3152,38 @@ suite('ActionListWidget', () => {
|
||||
});
|
||||
}));
|
||||
|
||||
for (const side of ['left', 'right'] as const) {
|
||||
test(`Chat submenu tolerates diagonal pointer travel into the ${side} flyout`, () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { widget, panel, hover } = createPersistentPreview(side, true);
|
||||
widget.updateItems([{
|
||||
...action('Chat'),
|
||||
hover: { preserveVerticalPosition: true, alignToAnchorTop: true },
|
||||
submenuActions: ['This Computer', 'ah1', 'ah2', 'ah3', 'ah4', 'sh1', 'sh2', 'sh3', 'sh4'].map(label =>
|
||||
toAction({ id: label, label, run: () => { } })),
|
||||
openSubmenuOnClick: true,
|
||||
}, action('workspace'), action('another workspace'), action('Remote')]);
|
||||
|
||||
const origin = hover(0);
|
||||
const initialContents = panel.textContent;
|
||||
hover(2, origin.x + (side === 'right' ? 60 : -60));
|
||||
const duringTravel = panel.textContent;
|
||||
panel.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
await timeout(350);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
opened: initialContents?.includes('sh4'),
|
||||
preservedDuringTravel: duringTravel === initialContents,
|
||||
preservedAfterEntering: panel.textContent === initialContents,
|
||||
visible: panel.style.display !== 'none',
|
||||
}, {
|
||||
opened: true,
|
||||
preservedDuringTravel: true,
|
||||
preservedAfterEntering: true,
|
||||
visible: true,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
test('pointer travel grace is bounded across multiple crossed rows', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { panel, hover } = createPersistentPreview();
|
||||
const origin = hover(0);
|
||||
@@ -3182,6 +3214,53 @@ suite('ActionListWidget', () => {
|
||||
});
|
||||
}));
|
||||
|
||||
for (const { name, crossWorkspace, enterSubmenu } of [
|
||||
{ name: 'returning to its trigger', crossWorkspace: true, enterSubmenu: false },
|
||||
{ name: 'returning to its trigger and entering it', crossWorkspace: true, enterSubmenu: true },
|
||||
{ name: 'entering it after the pointer leaves', crossWorkspace: false, enterSubmenu: true },
|
||||
]) {
|
||||
test(`keyboard navigation cancels pending submenu dismissal when ${name}`, () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { widget, panel } = createPersistentPreview('right', true);
|
||||
widget.updateItems([{
|
||||
...action('Chat'),
|
||||
submenuActions: [toAction({ id: 'local', label: 'This Computer', run: () => { } })],
|
||||
}, action('workspace')]);
|
||||
const parentList = widget.domNode.querySelector<HTMLElement>('.monaco-list')!;
|
||||
const hoverRow = (index: number) => {
|
||||
const row = parentList.querySelectorAll<HTMLElement>('.monaco-list-row')[index];
|
||||
const bounds = row.getBoundingClientRect();
|
||||
const point = { clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2 };
|
||||
row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, ...point }));
|
||||
row.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, movementX: 1, ...point }));
|
||||
};
|
||||
parentList.focus();
|
||||
hoverRow(0);
|
||||
if (crossWorkspace) {
|
||||
hoverRow(1);
|
||||
widget.focusPrevious();
|
||||
} else {
|
||||
panel.dispatchEvent(new MouseEvent('mouseleave'));
|
||||
}
|
||||
if (enterSubmenu) {
|
||||
dispatchKeyDown(parentList, { key: 'ArrowRight', keyCode: 39 });
|
||||
}
|
||||
const immediateFocus = enterSubmenu ? panel.contains(document.activeElement) : document.activeElement === parentList;
|
||||
await timeout(350);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
immediateFocus,
|
||||
retainedFocus: enterSubmenu ? panel.contains(document.activeElement) : document.activeElement === parentList,
|
||||
visible: panel.style.display !== 'none',
|
||||
content: panel.querySelector('.title')?.textContent,
|
||||
}, {
|
||||
immediateFocus: true,
|
||||
retainedFocus: true,
|
||||
visible: true,
|
||||
content: 'This Computer',
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
test('disposing a persistent preview cancels pending pointer travel', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { widget, panel, hover } = createPersistentPreview();
|
||||
const origin = hover(0);
|
||||
|
||||
@@ -328,11 +328,12 @@ Behind the root setting `chatMoves.enabled` (default off). AHP `moveChat` moves
|
||||
- **What moves.** A chat and its host-managed descendants (subagents, by recorded parent chain) move together; side chats and forks don't follow their source. Chat URIs, `ChatOrigin`, state, storage and checkpoint rows never change: ownership does (`chats_v2.owner_session_uri`, `origin_session_uri` recorded at the first move and never overwritten), with both catalogs, the moved chats' central rows and the per-folder rows they use committed in one agent-host.db transaction before anything is published. A default chat is never movable (`ChatSummary.movable`); a promoted chat becomes the new session's default and keeps its own database; a moved session's chat becomes a peer storing in that session's database, and the session's central state is tombstoned in the same transaction, never its data.
|
||||
- **Order.** Placement is recorded in `chats_v2.chat_order` for every visible chat, the default chat included, and restore follows it: a chat moved or reordered before the default chat stays there after a restart.
|
||||
- **Movable.** `ChatSummary.movable` is strict (`AgentHostChatMovability`, `isChatMovable`): besides being structurally eligible (setting on, not a default, tool or hidden chat), a chat is movable only while nothing about it or its own session would refuse its move: archived session, workspace-less session or chat without a folder, a workspace change of the chat or a quarantined session, its own additional worktree that a chat staying in its session also uses (D2; every other worktree is shared by the move, see Worktree clean-up), Agent Merge it runs for a session folder, or a legacy catalog the reconciliation parked. It is recomputed from in-memory state on the events of those inputs (session actions, `onDidChangePending`, `onDidChangeLoadedState`, `onDidChangeParkedSessions`). Destination-dependent refusals and `NotStarted` (promotion only) stay request-time; a request checks only structural eligibility up front, so its refusal keeps the specific reason.
|
||||
- **Refusals change nothing.** Requests are validated, re-validated after every await and refused with a typed `AgentChatMoveRefusal` (`reason` in the protocol error). Before validating, a move, promotion or reorder prepares every session it re-projects as Block 4a's two-session contract asks (agentHostChatCatalogV2.md): it waits for their peer-store writes (`whenSessionsIdle`), converts a legacy catalog to normalized authority, and imports what older builds changed (`reconcileOlderBuildCopy`). A session that is legacy only because it is new (its aggregate awaits a re-projection the background reconciliation hasn't run, also before the first listing) is converted on demand: its aggregate is staged from its live state, with each peer's persisted origin, and converted at once (`_normalizeCatalogForMove`); a normalized catalog costs one snapshot read. `legacyCatalog` is reported only for a session whose reconciliation is parked (its provider can't vouch for its chats); one that still can't be converted is `sessionNotReady`. A chat may move out of (or be promoted from) a new session whose default chat hasn't run (recorded provisional); a session that moves, or one a chat moves into, must be durable. In its transaction the database refuses a catalog that isn't normalized (`sessionNotReady`) and guards each session it re-projects with `guardOlderBuildEdits`; an `olderBuildEdits` refusal is imported and the write tried once more. Each write records the projection it wrote (`projected_revision`) and, once committed, schedules the older-build copy of the sessions it changed (`scheduleOlderBuildCopyPublish`), so older builds see a moved chat where it is now. Addresses are never consulted. A chat is promoted only once its first turn started (`notStarted` otherwise): no agent persists a conversation before that, and a session whose default chat has none can't be restored, just as a brand-new session is durable only after its first send. A `sessionProvisional:` marker ("no conversation at the session's own storage") is ignored for a session whose v2 catalog records its default chat stored elsewhere, as a promoted session's is: older builds and the catalog reconciliation would otherwise hide it for good. A normalized draft, whose default chat stores at the session, stays hidden.
|
||||
- **Refusals change nothing.** Requests are validated, re-validated after every await and refused with a typed `AgentChatMoveRefusal` (`reason` in the protocol error). Before validating, a move, promotion or reorder prepares every session it re-projects as Block 4a's two-session contract asks (agentHostChatCatalogV2.md): it waits for their peer-store writes (`whenSessionsIdle`), converts a legacy catalog to normalized authority, and imports what older builds changed (`reconcileOlderBuildCopy`). A session that is legacy only because it is new (its aggregate awaits a re-projection the background reconciliation hasn't run, also before the first listing) is converted on demand: its aggregate is staged from its live state, with each peer's persisted origin, and converted at once (`_normalizeCatalogForMove`); a normalized catalog costs one snapshot read. `legacyCatalog` is reported only for a session whose reconciliation is parked (its provider can't vouch for its chats); one that still can't be converted is `sessionNotReady`. A chat may move out of (or be promoted from) a new session whose default chat hasn't run (recorded provisional); a session that moves, or one a chat moves into, must be durable. In its transaction the database refuses a catalog that isn't normalized (`sessionNotReady`) and guards each session it re-projects with `guardOlderBuildEdits`; an `olderBuildEdits` refusal is imported and the write tried once more. Each write records the projection it wrote (`projected_revision`) and, once committed, schedules the older-build copy of the sessions it changed (`scheduleOlderBuildCopyPublish`), so older builds see a moved chat where it is now. Addresses are never consulted. A chat is promoted only once its first turn started (`notStarted` otherwise): no agent persists a conversation before that, and a session whose default chat has none can't be restored, just as a brand-new session is durable only after its first send. A `sessionProvisional:` marker ("no conversation at the session's own storage") is ignored for a session whose v2 catalog records its default chat stored elsewhere, as a promoted session's is: older builds and the catalog reconciliation would otherwise hide it for good. A normalized draft, whose default chat stores at the session, stays hidden. A move into another session and a moving session are refused (`differentCredentialOwner`) when the chat's and the destination's credential principals differ, sponsored or not, in both directions; a promotion is not, since the new session inherits the chat's principal. A moved chat's credential binding is re-keyed to its new owner (`moveChatCredentials`) on the recorded owner change, so it survives the source's removal. A session with only an agent-merge binding counts as sponsored (conservative). While a move or promotion is in flight its sessions are held (`holdSessionCredentialOwners`): an unsponsored one can't be bound a new principal until it ends (restoring an already persisted principal isn't blocked; the move re-checks the owners before transferring), so the validated owners stay valid through the commit. A promoted session's principal is written to the new session's own `session.db` (the only store of a principal, which older builds read too) before the promote commit, while the sessions are held; a failed write refuses the promotion (`conflict`) with nothing changed, and a failed commit leaves only an orphan `session.db` no session refers to (nothing cleans it up; it is small and no session lists it). The principal is read from the source session, not the chat, so a restored session whose chat has no context yet is still covered, and a chat without a recorded credential owner follows its previous owner when it moves. So a committed promotion always has its principal on disk and a restart restores it instead of falling back to the ambient token, even if the in-memory step fails after the commit; the target is held during the promotion too. Should a chat change owner across different credential owners anyway (unreachable), its sponsored bindings are removed and it resolves under its new owner or `AuthRequired`, never the ambient token.
|
||||
- **Live moves.** A move never cancels or waits for a turn: the owner is late-bound (3c seams), the in-memory transfer is one synchronous step (`transferChats`, `promoteChats`, `transferSession`), queued turns of moved chats are held until the move commits, and provider runtimes keep their launch owner until they relaunch. Deferred host work (notices, histories, watches) reads the chat's owner when delivered, never when queued.
|
||||
- **Config.** A moved chat takes its source's effective `chatMutable` values as its own, written to its database before the commit; a promoted session starts from the source's config with the chat's own values on top. Neither path goes through D1 seeding. A session that moves under another session ends: its chat becomes an ordinary peer there and, like every peer, follows the destination's session-level state (customization enablement, non-`chatMutable` config); only its `chatMutable` values carry. Promotion differs because its destination is a new session, which starts from the source's.
|
||||
- **Data.** A chat's attachments are snapshotted under its recorded storage. A deleted session keeps `attachments/` while a live chat that moved out of it exists, including when that directory is a moved chat's storage; the chat's deletion releases it. After a move, a background job (`AgentHostAttachmentMigration`) copies the files the moved chat's messages attached in its origin (their recorded snapshot URIs under the origin's `attachments/`, never the whole folder) into its own storage under the same relative paths, a few files per step through a temporary file, recording its progress in `agent-host.db` (`metadata` `chatAttachmentCopy:<chat>`), so a restart resumes it once the chat's session is restored. A host read (`resourceRead`) of a copied file the origin no longer has, also because an older build deleted it, reads the copy. The origin keeps its attachments while any moved chat lives, also once the copy is complete, because provider-native tools still read the original path; releasing on completion is switched off (`RELEASE_COPIED_ATTACHMENT_ORIGINS`). The copy protects the files from deletions by older builds. A chat moved with its session (its storage is in the origin) copies nothing.
|
||||
- **Worktrees.** A chat keeps the folders it works in, worktrees included (Block 5): the worktree becomes shared by the source and the target (co-owner rows written in the move's transaction), and the source withholds its branch from older builds' copy while it is shared; it is removed only once no live co-owner uses it, and a deletion then commits it. A chat's own additional worktree that no staying chat uses transfers with it; one a staying chat also uses refuses the move (`worktreeFolder`, D2). A session that moves under another one hands its own worktree and additional worktrees to the target (shares them, into a target older builds read no session values of). See 3b, Worktree clean-up. Folder reservations, the git and GitHub rows (`keepInSource`) and Agent Merge's refusal are unchanged.
|
||||
- **Agent tool.** The `move_session` server tool (`node/shared/sessionMoveTools.ts`, Block 6) lets an agent reorder a nested session, move it into another session, make it its own session, or move a single-chat session under another, through the same `moveChat` / `moveSession` operations and refusals. It is a group of its own, never materialized: offered only while `chatMoves.enabled` is on (read per call; sessions are advertised again when it changes) to an agent with `multipleChats`, so tool lists are unchanged while it is off: `list_sessions` and `get_current_session` mention it in their descriptions only while it is offered (`getDefinitionForSession`). Their results always list a session's main session and nested sessions (`chats`: title, `?chat=` link, `main`, status; list data only), a general improvement whose links also work with `send_message`, `get_session_context` and `rename_chat`. Every call needs confirmation (`sessionConfirmationToolNames`), unless the user turned approvals off (Allow All, Claude `bypassPermissions`, Codex full access), as for `create_session`. Providers that fix their tool list at launch (Claude's allow-list) see the tool after their next launch once the setting turns on. Its check and its move run in the move sequencer like AHP moves, so the sessions they restore never overlap another move. Its confirmation, built by the host (`IAgentServerToolHost.getConfirmationDisplay`), links only sessions and chats the host knows. It resolves sessions only from links and recorded chats, never from a chat URI, and places a chat at the end of its destination by resolving its anchor in the accessor. A move that includes the calling chat is checked when requested and applied when that turn succeeds (`IAgentHostScheduledChatMoves`, contribution order 160); the chat's queued turns are held from that turn end until the move is done, so queue drain (200) starts the next one in the session the chat moved to; a refusal then is only logged. Process-wide backstop: 50 moves.
|
||||
- **Clients.** Clients without `vscode.ahpChatUris` see a chat only in the session it was created in, and nothing that may have moved while the moved-chat origins can't be read. The origins load in the background from startup: such a client's session listing waits for them, any other client's doesn't, and until a chat first moved (a `metadata` marker the move writes) loading them reads that one row instead of scanning `chats_v2`. Clients pair a chat leaving one catalog with the same backend chat appearing in another (`AgentHostChatMoveTracker`) and keep showing it. Mission Control replicates only a session's default chat: promotion starts the new session's SDK stream, and a default chat that moves under another session stops its own.
|
||||
|
||||
## 4. Capabilities Gating
|
||||
@@ -525,6 +526,8 @@ Agent Merge is session-resident but folder-scoped. Client-owned enablement lives
|
||||
|
||||
The `chat` value records the chat that enabled Agent Merge for that folder. Repair turns and transcript notices target that chat while it still works in the folder; otherwise the host picks the default chat first, then the first peer chat whose first effective working directory is that folder. Repair concurrency is per chat: a folder waits only for its owning chat to be idle, while session-wide elevation is applied when any folder is enabled and restored only after the last folder stops.
|
||||
|
||||
Repair pacing: a turn whose only action is fixing CI waits while checks (required or optional, or check suites that have not reported runs yet) are pending and still changing, starting once they have been unchanged for 10 minutes or 60 minutes after it was first held; each claimed repair starts a new hold window. Never waiting for every check to finish matters, because some checks only complete after the others pass (for example an approval given once CI is green); other repairs are never held, since they push and restart CI anyway. The lifecycle state records the head and blockers (`check:<name>`, `thread:<id>`, `feedback`, `conflicting`, `behind`) of the last repair turn. A new head or a resolved blocker counts as progress and resets the repeated (3) and consecutive (6) repair limits; `lifetimePromptCount` never resets, and caps an enablement at 20 repair turns.
|
||||
|
||||
Initial tool enablement validates the invoking folder's current Git branch before persisting and returning the target. Configuration-only updates preserve the folder's target and owning chat; asynchronous evaluations revalidate that folder's live configuration and chat ownership before acting.
|
||||
|
||||
---
|
||||
@@ -559,7 +562,7 @@ Customization discovery (agents, skills, instructions and workspace MCP configur
|
||||
|
||||
Steering received during turn preparation or configuration refresh is buffered for the addressed chat. A dispatched turn confirmed running by root SDK events accepts and flushes steering immediately, even if its admission RPC has not returned; successful admission also flushes it. Preparation or send failure retains buffered steering for a successful retry, rather than dispatching against an unready runtime. Pending updates replace or remove buffered steering; abort clears it and emits `steering_consumed` to clear the pending UI. The live SDK-session wrapper takes ownership synchronously before MCP reconciliation, so abort and disposal can clear steering throughout delivery. Chat disposal and host shutdown discard buffered delivery, and pending-message updates are ignored once host shutdown starts.
|
||||
|
||||
AHP stores steering and queued messages together in `queuedMessages`, distinguished by `PendingMessage.kind`. Steering entries use `IAgent.sendSteeringMessage` individually, bypassing Copilot's replaceable legacy-slot buffer. The host retries deferred handoff on provider progress and correlates consumption by message ID. Cancellation clears every pending steering entry, including prompts handed off but not yet consumed by the SDK, without draining queued follow-ups. A client may remove handed-off steering during a turn only when the provider implements `IAgent.withdrawSteeringMessage`; Copilot then withdraws it from the SDK with `rpc.queue.withdrawMessage`, or keeps it as its own turn if the model already received it.
|
||||
AHP stores steering and queued messages together in `queuedMessages`, distinguished by `PendingMessage.kind`. Steering entries use `IAgent.sendSteeringMessage` individually, bypassing Copilot's replaceable legacy-slot buffer. The host retries deferred handoff on provider progress and correlates consumption by message ID. A handoff promise that resolves false returns the message unsent because its turn ended first; the host then steers the active turn with it or, once the chat is idle, runs it as the next turn ahead of queued messages. Steering the provider still holds keeps queued work waiting; when a held handoff resolves true while the chat is idle and steering is still pending, for example because sending it failed, the host drains again. Cancellation clears every pending steering entry, including prompts handed off but not yet consumed by the SDK, without draining queued follow-ups. A client may remove handed-off steering during a turn only when the provider implements `IAgent.withdrawSteeringMessage`; Copilot then withdraws it from the SDK with `rpc.queue.withdrawMessage`, or keeps it as its own turn if the model already received it.
|
||||
|
||||
`CopilotSessionLauncher` sets `mcpOAuthTokenStorage: 'in-memory'` for created, resumed, and ephemeral SDK sessions. VS Code owns durable MCP credentials through `onMcpAuthRequest`; the runtime must not consult its persistent MCP OAuth keychain store.
|
||||
|
||||
@@ -579,7 +582,11 @@ Before a message is admitted, a Copilot session can prepare plugins required by
|
||||
|
||||
Behind the `chat.agentHost.experimental.prewarmSessions` setting, an idle provisional draft can launch its SDK session and start its MCP servers before the first send (`IAgentChats.warmChat`). `AgentHostSessionPrewarmer` (`node/agentHostSessionPrewarmer.ts`) decides when: the provider must support warming, the draft must have an active client and resolved customization enablement, and every launch folder, including additional multi-root folders, must be trusted. A missing trust configuration counts as untrusted; workspace-less drafts skip the trust check because their scratch folder has no workspace content. Ephemeral and imported drafts never warm. The experiment trigger is logged only after these gates, in both arms.
|
||||
|
||||
A warm launch is the launch the first send would run, started early and kept on the draft's reservation (`IProvisionalSession.earlyLaunch` in `CopilotAgent`). Its session is registered live, so MCP status and sign-in requests reach it, but it is never persisted, listed or announced until the first send completes it exactly as a cold materialization would. Nothing is applied to it live: when anything fixed at launch has changed by the first send, that send discards it and launches cold. That covers the working directory (always the case for a worktree send), the model or custom agent selection, client tools, plugin or MCP definitions, and the chat's owner. Turn telemetry reports whether the send used it (`usedPrewarmedSession`) or why it discarded it (`prewarmedSessionDiscardReason`). It is also discarded when the chat is disposed, or after 5 unsent minutes. A runtime torn down underneath it (a client restart, closed-connection recovery or idle release) is forgotten while the reservation stays, and its leftover SDK state is deleted before the next launch. An unsent warm launch is never resumed, because the SDK has saved nothing to resume. A worktree draft never warms: its customization enablement stays pending until the worktree is created on the first send.
|
||||
A dispose of a session waits for a `createSession` of the same session that was requested before it (`AgentService.disposeSession` and `disposeSessionIf`), so it runs after the provider has registered the draft's provisional chat and disposes it. Creation does not offer such a draft to the prewarmer; otherwise its launch would only be discarded by the expiry below. Creation must never dispose its own session through these entry points (it rolls back the provider chat directly), or it would wait on itself.
|
||||
|
||||
A warm launch is the launch the first send would run, started early and kept on the draft's reservation (`IProvisionalSession.earlyLaunch` in `CopilotAgent`). Its session is registered live, so MCP status and sign-in requests reach it, but it is never persisted, listed or announced until the first send completes it exactly as a cold materialization would. Nothing is applied to it live: when anything fixed at launch has changed by the first send, that send discards it and launches cold. That covers the working directory (always the case for a worktree send), the model or custom agent selection, client tools, plugin or MCP definitions, and the chat's owner. Turn telemetry reports whether the send used it (`usedPrewarmedSession`) or why it discarded it (`prewarmedSessionDiscardReason`). It is also discarded when the chat is disposed, or after 5 unsent minutes. A runtime torn down underneath it (a client restart, closed-connection recovery or idle release) is forgotten while the reservation stays, and its leftover SDK state is deleted before the next launch. An unsent warm launch is never resumed, because the SDK has saved nothing to resume. A worktree draft never warms: its customization enablement stays pending until the worktree is created on the first send. While the setting is on, the Agents window therefore keeps the isolation choice local until Send: it creates every draft in the picked folder, even when the user picked or inherited a worktree, and its first request sends the picked isolation (and base branch) with the rest of the draft's config as a `SessionConfigChanged`, which marks a worktree pending while the session is still creating. That folder draft warms; a worktree send discards the warm launch and starts cold in the new worktree, and a folder send claims it.
|
||||
|
||||
Both windows create their draft with the model selected in the chat input (`AgentHostUntitledProvisionalSessionService` in the editor window), because the first send selects its model before it runs and discards a launch made with another one (`modelChanged`). In the editor window, a draft whose chat model was disposed is retired, so chat input chips that still show it while the widget switches to the next chat cannot recreate a backend for it.
|
||||
|
||||
### Codex (`node/codex/codexAgent.ts`)
|
||||
|
||||
@@ -766,6 +773,8 @@ Copilot reserves the names of host tools registered for the current launch befor
|
||||
|
||||
### 8f. Session config (already centralized)
|
||||
|
||||
Codex applies Default Permissions ↔ Auto-Review changes to subsequent steps of the active turn through `turn/settings/update`. Updates are serialized per turn and retain the native turn identity captured before queuing; completion or replacement never redirects them to another turn. Pending approvals keep their original reviewer. Approval-policy and sandbox changes, including entering or leaving Full Access, remain saved for the next turn, and the picker explains this while Codex is working or awaiting input. Host-owned tools use the running turn's applied permissions rather than a deferred selection.
|
||||
|
||||
Copilot model `configSchema` advertises both `thinkingLevel` / `reasoningEffort` and, for Auto, `tier` / `autoTier`. The legacy names retain their defaults and take precedence when both spellings are supplied. The Copilot-host aliases carry the same choices without defaults. The protocol boundary projects one spelling (`thinkingLevel` / `tier`) to positively identified VS Code clients in model schemas, chat messages, and automation templates, including updates and reconnect replay, without changing authoritative state. This keeps alias handling out of shared workbench configuration and prevents schema defaults from shadowing selections authored by other clients. Mobile and unmarked peers retain the full advertised schema and original selections; no additional capability is required. Tool-spawned worker seed messages use `MessageKind.Tool` in live turns and reconstructed Copilot history; explicit follow-up messages retain their supplied origin.
|
||||
|
||||
Live provider runtimes that react to session config subscribe to `IAgentConfigurationService.onDidSessionConfigChange` with their explicit config resource. `AgentSideEffects` does not enumerate chats or fan config values through provider hooks.
|
||||
|
||||
@@ -10,7 +10,11 @@ The local workbench uses a separate MessagePort transport. This document describ
|
||||
|
||||
Desktop and remote-server starters forward the setting through `VSCODE_AGENT_HOST_AHP_JSONL_LOGGING` at launch so host capture can include the first handshake. The owning local window also mirrors the setting into the read-only `ahpJsonlLoggingEnabled` AHP root-config property; updates affect existing Mission Control lanes without restarting or replenishing the byte budget. Remote Mission Control clients do not mirror their window's setting over the host owner's choice. Hosts without an explicit startup value use the Insiders default.
|
||||
|
||||
Mission Control hosts advertise their log directory in the optional `vscode.ahpLogDirectory` initialization metadata. The wire-log viewer and remote export read those host-owned files through standard AHP resource operations, subject to the existing authentication and filesystem grants. Reads of the capture directory, their responses, and log-only file-watch changes are excluded from capture so inspection cannot recursively record log contents or feed back into file watching. This does not enable host-management methods, advertise OTLP diagnostic logs, or expand filesystem access. Hosts without this metadata retain historical window-local logs as a fallback.
|
||||
Mission Control hosts advertise their log directory in the optional `vscode.ahpLogDirectory` initialization metadata. The wire-log viewer reads those host-owned files through standard AHP resource operations, subject to the existing authentication and filesystem grants. Debug-log exports include Mission Control transport logs collected by the local Agent Host, but do not fetch logs from remote Mission Control hosts. Reads of the capture directory, their responses, and log-only file-watch changes are excluded from capture so inspection cannot recursively record log contents or feed back into file watching. This does not enable host-management methods, advertise OTLP diagnostic logs, or expand filesystem access. Hosts without this metadata retain historical window-local logs as a fallback.
|
||||
|
||||
Debug-log exports log and skip unsupported or unavailable individual log sources. Up to 1,000 client-file candidates are retained independently of the host archive; the combined ZIP entry budget and Mission Control deduplication are applied only after the host archive is available. A failed host-archive download does not prevent exporting client-owned logs. Cancellation and destination folder, write, or installation failures stop the operation and preserve the previous export.
|
||||
|
||||
Native ZIP creation reports skipped sources back to the exporter. If the host archive disappears during native inspection or opening, the exporter rebuilds the ZIP from the retained client candidates and reports the partial export.
|
||||
|
||||
All AHP JSONL writers in one process that use the same log directory share a cumulative 500 MiB budget, including concurrent connections, reconnects, and Mission Control lanes. Each window and host process accounts independently. Existing per-connection rotation (five files of up to 75 MiB each) still applies, but deleting or rotating a file does not replenish the shared budget. Pending entries and dropped-entry markers reserve their UTF-8 byte count before being queued, so queued writes cannot overshoot the budget.
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ Records are emitted at terminal observation, including errors/cancellation. Pend
|
||||
|
||||
### Existing product telemetry and logs
|
||||
|
||||
`agentHost.copilot.runtimeRestart` counts configuration-driven SDK session relaunches and MCP servers returning to `pending` after stopping, with a bounded cause, whether the chat has a completed turn, and the turn-preparation/in-turn/idle phase; MCP attribution uses host-mutation ordering rather than time windows and never includes server names.
|
||||
|
||||
The renderer reports `agentHost.firstResponse` and writes a content-free `[AgentHostFirstResponse]` JSON record to its existing log. Schema version 1 contains `requestId`, `provider`, `connectionKind`, optional backend `agentSessionId` / `chatId`, `sessionTurnKind` (`first`, `later`, or `unknown`), `outcome` (`success`, `cancelled`, `error`, or `notDispatched`), `hasResponseText`, optional `firstResponseTextMs`, and `totalElapsedMs`. The log additionally retains `sessionId`, identical to `agentSessionId`, for compatibility, and includes `turnId`, identical to `requestId`, for AHP joins. The telemetry property uses `agentSessionId` to avoid colliding with the common VS Code `sessionID` property. Durations use a local monotonic clock starting at agent invocation, before trust/authentication/session preparation. `invocationKind` is `newTurn`, `existingTurn`, `subagent`, or `unknown` before hydration. Exclude `existingTurn` and `subagent` records from first-response comparisons rather than treating them as successful turns without an answer. The endpoint is the first nonwhitespace live root markdown emission, including final-only responses, not physical submit, paint, or a semantic guarantee of an answer. Reasoning, tool output, restored history and server-initiated observation do not start this metric. Existing first-progress measurements are unchanged.
|
||||
|
||||
`connectionKind` reuses `AgentHostClientConnectionKind`, assigned by the owning client transport/integration and exposed through `IAgentConnection`. Values are `local`, `direct_websocket`, `dev_tunnel`, `dev_container`, `ssh`, `wsl`, `remote_extension_host`, `web_pub_sub`, `mission_control`, and `unknown`. The invocation captures this classification once, independently of provider names, host identities, or the configured-host registry, so it survives disconnection. Missing or unrecognized classifications become `unknown`; older diagnostic payloads can omit the field. The log and OTel diagnostic carry the same value, with no environment IDs, addresses, or credentials.
|
||||
|
||||
@@ -284,6 +284,21 @@ Outcomes are `success`, `error`, `unavailable`, `deferred`, `partial`, and `canc
|
||||
|
||||
The implementation is in [`node/agentHostStartupPerformance.ts`](node/agentHostStartupPerformance.ts). Bootstrap records named milestones and publishes them only after composition has applied the host telemetry configuration. Providers report at their existing enumeration and processed-result boundaries; the orchestrator reports at its existing listing, migration and discovery registration boundaries. Startup telemetry does not use chat lifecycle contributions, since no turn or hydration hook owns these operations.
|
||||
|
||||
## Copilot account discovery
|
||||
|
||||
`agentHost.copilotAccountDiscovery` reports one completed `/copilot_internal/user` HTTP lookup from `CopilotApiService`, not cache reads or individual model calls. It uses the existing usage-telemetry transport and consent controls. Local endpoint overrides that skip discovery do not emit it.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `trigger` | `initial`, `cacheRefresh`, or `entitlementChange`. |
|
||||
| `outcome` | `published` when the result updated the discovery cache; `superseded` when a newer revalidation or invalidation prevented publication; `failed` for transport, HTTP, or response-processing failure. |
|
||||
| `copilotSku`, `copilotTrackingId` | SKU and analytics ID returned by this lookup, when available. These are not copied from the client's entitlement hint or a process-wide common property. |
|
||||
| `previousSku`, `cacheAgeMs` | Prior cached SKU and its age when lookup was requested. Age is derived from the cache entry's creation/expiry time, not a model request's entitlement age. Both can be absent on initial discovery. |
|
||||
| `httpStatus`, `gitHubRequestId` | HTTP status and validated `x-github-request-id` header, when a response supplied them. The request ID belongs to discovery, not model inference. |
|
||||
| `durationMs` | Time from starting HTTP discovery through completion/failure, including response-body consumption. |
|
||||
|
||||
Superseded successful responses retain their returned metadata in this event even though they did not update the cache. Coalesced callers share a single lookup/event; failed attempts do not include response bodies, error messages, credentials, login names, or endpoint URLs. Events are not emitted after service disposal, and transport/consent gaps can leave missing observations. No discovery snapshot ID or additional fields on turn/tool/SDK response events are introduced. Account/time comparisons can reveal inconsistent responses but cannot prove which discovery snapshot or backend authorization decision served an individual model request.
|
||||
|
||||
## Canvas rollout and load telemetry
|
||||
|
||||
Canvas product telemetry uses existing usage consent, independently of OTel.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { supportsSandboxCertificateInstallation, supportsSandboxCertificateRemoval, supportsSandboxCertificateRotation } from '../common/meta/agentSandboxCertificate.js';
|
||||
import { InstallSandboxCertificateExtensionMethod, RemoveSandboxCertificateExtensionMethod, RotateSandboxCertificateExtensionMethod } from '../common/sandboxCertificateProtocol.js';
|
||||
import { DeferredPromise, disposableTimeout, IntervalTimer, TimeoutTimer } from '../../../base/common/async.js';
|
||||
import { DeferredPromise, disposableTimeout, IntervalTimer, raceCancellationError, TimeoutTimer } from '../../../base/common/async.js';
|
||||
import { CancellationToken } from '../../../base/common/cancellation.js';
|
||||
import { CancellationError } from '../../../base/common/errors.js';
|
||||
import { Emitter, Event } from '../../../base/common/event.js';
|
||||
@@ -19,6 +19,7 @@ import { generateUuid } from '../../../base/common/uuid.js';
|
||||
import { newAgentHostSessionUri } from '../common/agentHostSessionIdentity.js';
|
||||
import { AgentHostChatUrisCapabilityMetaKey, AgentHostSessionUrisCapabilityMetaKey } from '../common/meta/agentHostSessionUrisMeta.js';
|
||||
import { readRelayKeepAliveTimeout } from '../common/meta/relayConnectionMeta.js';
|
||||
import { supportsAgentHostWorktreeDiskUsage } from '../common/meta/agentHostWorktreeDiskUsageMeta.js';
|
||||
import { vArray, vEnum, vObj, vOptionalProp, vString } from '../../../base/common/validation.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
@@ -29,7 +30,7 @@ import { AgentPolicyError, IAgentCreateChatRequestOptions, IAgentCreateSessionCo
|
||||
import { isPolicyResolutionErrorData } from '../common/meta/errorMeta.js';
|
||||
import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js';
|
||||
import { supportsAgentHostDebugLogsCancellation } from '../common/meta/agentHostDebugLogsMeta.js';
|
||||
import { AgentHostSessionMoveUnsupportedError, MoveAgentHostSessionExtensionMethod, supportsAgentHostSessionMove, CompleteSavedChatContinuationExtensionMethod, CancelAgentHostDebugLogsExtensionMethod, ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ImportSessionExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, ReportAgentHostFirstResponseExtensionMethod, ReportChatUserInteractionExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, StopBackgroundWorkExtensionMethod, supportsAgentHostChatStateFile, supportsAgentHostDevContainers, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostMcpAuthenticationRequest, ReadSavedChatExtensionMethod } from '../common/agentHostExtensionProtocol.js';
|
||||
import { CancelWorktreeDiskUsageExtensionMethod, MeasureWorktreeDiskUsageExtensionMethod, AgentHostSessionMoveUnsupportedError, MoveAgentHostSessionExtensionMethod, supportsAgentHostSessionMove, CompleteSavedChatContinuationExtensionMethod, CancelAgentHostDebugLogsExtensionMethod, ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ImportSessionExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, ReportAgentHostFirstResponseExtensionMethod, ReportChatUserInteractionExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, StopBackgroundWorkExtensionMethod, supportsAgentHostChatStateFile, supportsAgentHostDevContainers, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostMcpAuthenticationRequest, ReadSavedChatExtensionMethod } from '../common/agentHostExtensionProtocol.js';
|
||||
import { supportsAgentHostSavedChat, supportsAgentHostSavedChatContinuation } from '../common/meta/agentHostSavedChatMeta.js';
|
||||
import { reviveSavedChat, type IAgentHostSavedChat, type IAgentHostSavedChatContinuation, type IAgentHostSavedChatContinuationResult } from '../common/agentHostSavedChat.js';
|
||||
import { McpAuthRequiredReason } from '../common/state/protocol/channels-session/state.js';
|
||||
@@ -46,6 +47,8 @@ import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRe
|
||||
import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js';
|
||||
import { getOlderHostDefaultChat } from './agentHostOlderHostCompatibility.js';
|
||||
import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isAhpChatChannel, isSessionChatArchived, isSessionStatusRead, type ClientPluginCustomization, type SessionState, type Message, type RootState } from '../common/state/sessionState.js';
|
||||
import type { AgentChatMoveDestination } from '../common/agentHostChatMove.js';
|
||||
import { ChatMoveDestinationKind } from '../common/state/protocol/channels-chat/commands.js';
|
||||
import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js';
|
||||
import { getAgentHostSupportedProtocolVersions } from '../common/agentHostProtocolCompatibility.js';
|
||||
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, JSON_RPC_INTERNAL_ERROR, type ProtocolMessage, type IStateSnapshot, type SubscribeResult } from '../common/state/sessionProtocol.js';
|
||||
@@ -1418,6 +1421,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
|
||||
private _updateWorkspaceTrust(): void {
|
||||
const remoteAuthority = typeof this._resourceIdentity === 'string' && this._resourceIdentity.startsWith(`${Schemas.vscodeRemote}://`)
|
||||
? URI.parse(this._resourceIdentity).authority : undefined;
|
||||
const connectionAuthorityTrusted = this._resourceIdentity !== LOCAL_AGENT_HOST_RESOURCE_IDENTITY
|
||||
&& ((remoteAuthority !== undefined && this._workspaceTrustManagementService.isAuthorityTrusted?.(Schemas.vscodeRemote, remoteAuthority) === true)
|
||||
|| this._workspaceTrustManagementService.isAuthorityTrusted?.(AGENT_HOST_SCHEME, this._connectionAuthority) === true);
|
||||
const trustedUris = this._workspaceTrustManagementService.getTrustedUris()
|
||||
.filter(uri => this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY
|
||||
? uri.scheme === Schemas.file
|
||||
@@ -1430,6 +1436,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
|
||||
[AgentHostWorkspaceTrustConfigKey]: {
|
||||
enabled: this._workspaceTrustEnablementService.isWorkspaceTrustEnabled(),
|
||||
trustedUris,
|
||||
...(connectionAuthorityTrusted ? { connectionAuthorityTrusted: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1723,6 +1730,31 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
|
||||
return result.stopped;
|
||||
}
|
||||
|
||||
async measureWorktreeDiskUsage(root: URI, token: CancellationToken): Promise<number | undefined> {
|
||||
if (token.isCancellationRequested) {
|
||||
throw new CancellationError();
|
||||
}
|
||||
if (!supportsAgentHostWorktreeDiskUsage(this.initializeResult.get())) {
|
||||
this._logService.debug('[WorktreeDiskUsage] Host does not support aggregate measurement');
|
||||
return undefined;
|
||||
}
|
||||
const requestId = generateUuid();
|
||||
const measurement = this._sendExtensionRequest(MeasureWorktreeDiskUsageExtensionMethod, { uri: fromAgentHostUri(root).toString(), requestId });
|
||||
const cancellation = token.onCancellationRequested(() => {
|
||||
void this._sendExtensionRequest(CancelWorktreeDiskUsageExtensionMethod, { requestId })
|
||||
.catch(error => this._logService.warn('[WorktreeDiskUsage] Failed to cancel measurement', error));
|
||||
});
|
||||
try {
|
||||
const result = await raceCancellationError(measurement, token);
|
||||
if (result.sizeBytes !== undefined && (!Number.isSafeInteger(result.sizeBytes) || result.sizeBytes < 0)) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned an invalid worktree size');
|
||||
}
|
||||
return result.sizeBytes;
|
||||
} finally {
|
||||
cancellation.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async installSandboxCertificate(chat: string, scopeId: string): Promise<void> {
|
||||
if (!supportsSandboxCertificateInstallation(this.initializeResult.get())) {
|
||||
throw new Error('This agent host does not support sandbox certificate installation.');
|
||||
@@ -1780,6 +1812,16 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
|
||||
return URI.parse(result.session);
|
||||
}
|
||||
|
||||
async moveChat(chat: URI, destination: AgentChatMoveDestination): Promise<URI> {
|
||||
const result = await this._sendRequest('moveChat', {
|
||||
channel: chat.toString(),
|
||||
destination: destination.kind === 'session'
|
||||
? { kind: ChatMoveDestinationKind.Session, session: destination.session.toString(), ...(destination.after ? { after: destination.after.toString() } : {}) }
|
||||
: { kind: ChatMoveDestinationKind.NewSession },
|
||||
});
|
||||
return URI.parse(result.session);
|
||||
}
|
||||
|
||||
async createDetachedWorktree(session: URI, prompt: string): Promise<{ handle: string; worktree: URI }> {
|
||||
const result = await this._sendExtensionRequest(CreateAgentHostDetachedWorktreeExtensionMethod, {
|
||||
session: session.toString(),
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './media/codexApprovalsPicker.css';
|
||||
import { CODEX_DEFAULT_PERMISSIONS_PRESET, CODEX_PERMISSIONS_PRESET_LABELS, narrowCodexPermissionsPreset } from '../common/codexSessionConfigKeys.js';
|
||||
import { IActionListOptions } from '../../actionWidget/browser/actionList.js';
|
||||
import { IDialogService } from '../../dialogs/common/dialogs.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
|
||||
const CODEX_APPROVALS_PICKER_CLASS = 'codex-approvals-picker';
|
||||
const CODEX_APPROVALS_PICKER_WIDTH = 340;
|
||||
@@ -19,3 +22,28 @@ export function getCodexApprovalsPickerListOptions(): IActionListOptions {
|
||||
detailItemHeight: CODEX_APPROVALS_PICKER_DETAIL_ITEM_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
/** Offers to restart a turn with new permissions, returning the continuation message only on Apply Now. */
|
||||
export async function showCodexPermissionsChangeNotice(dialogService: IDialogService, previousValue: unknown, selectedValue: string): Promise<string | undefined> {
|
||||
const previousPreset = narrowCodexPermissionsPreset(previousValue) ?? CODEX_DEFAULT_PERMISSIONS_PRESET;
|
||||
const selectedPreset = narrowCodexPermissionsPreset(selectedValue);
|
||||
if (!selectedPreset || previousPreset === selectedPreset || (previousPreset !== 'full-access' && selectedPreset !== 'full-access')) {
|
||||
return;
|
||||
}
|
||||
const selectedLabel = CODEX_PERMISSIONS_PRESET_LABELS[selectedPreset];
|
||||
const { result } = await dialogService.prompt({
|
||||
type: 'question',
|
||||
message: selectedPreset === 'full-access'
|
||||
? localize('codexApprovals.enableFullAccess.title', "Enable Full Access Now?")
|
||||
: localize('codexApprovals.disableFullAccess.title', "Disable Full Access Now?"),
|
||||
detail: selectedPreset === 'full-access'
|
||||
? localize('codexApprovals.enableFullAccess.detail', "Applying now will stop the current turn and send a new message to continue with Full Access.\n\nClose this dialog to apply the change with your next message after the current turn finishes.")
|
||||
: localize('codexApprovals.disableFullAccess.detail', "Applying now will stop the current turn and send a new message to disable Full Access and continue with {0}.\n\nClose this dialog to apply the change with your next message after the current turn finishes.", selectedLabel),
|
||||
buttons: [{
|
||||
label: localize('codexApprovals.applyNow', "Apply Now"),
|
||||
run: () => localize('codexApprovals.continue', "Continue (I changed permissions from {0} to {1})", CODEX_PERMISSIONS_PRESET_LABELS[previousPreset], selectedLabel),
|
||||
}],
|
||||
custom: true,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { constObservable, IObservable } from '../../../base/common/observable.js
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import type { IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agent.js';
|
||||
import type { AgentHostDebugLogsArtifactKind, IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk, IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostService, IAgentHostSocketInfo } from '../common/agentService.js';
|
||||
import type { AgentChatMoveDestination } from '../common/agentHostChatMove.js';
|
||||
import type { IActiveSubscriptionInfo, IAgentSubscription } from '../common/state/agentSubscription.js';
|
||||
import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
|
||||
import type { InitializeResult } from '../common/state/protocol/common/commands.js';
|
||||
@@ -68,6 +69,7 @@ export class NullAgentHostService implements IAgentHostService {
|
||||
async stopBackgroundWork(_chat: URI, _id: string): Promise<boolean> { return notSupported(); }
|
||||
async importSession(_session: URI): Promise<void> { return notSupported(); }
|
||||
async moveSession(_session: URI, _destination: URI, _after: URI | undefined): Promise<URI> { throw new AgentHostSessionMoveUnsupportedError(); }
|
||||
async moveChat(_chat: URI, _destination: AgentChatMoveDestination): Promise<URI> { return notSupported(); }
|
||||
async createDetachedWorktree(_session: URI, _prompt: string): Promise<{ handle: string; worktree: URI }> { return notSupported(); }
|
||||
async claimDetachedWorktree(_handle: string): Promise<void> { return notSupported(); }
|
||||
async setDetachedWorktreeArchived(_handle: string, _archived: boolean): Promise<void> { return notSupported(); }
|
||||
|
||||
@@ -377,6 +377,7 @@ export interface IAgentCustomizationMarketplaceSearchItem {
|
||||
readonly version?: string;
|
||||
readonly repository?: string;
|
||||
readonly path?: string;
|
||||
readonly connectorIds?: readonly string[];
|
||||
readonly installable: boolean;
|
||||
readonly unavailableMessage?: string;
|
||||
}
|
||||
@@ -1694,6 +1695,9 @@ export interface IAgent {
|
||||
/** Streamed progress for an exact chat. */
|
||||
readonly onDidChatProgress: Event<AgentSignal>;
|
||||
|
||||
/** Native snapshots can arrive after host cancellation or steering has already ended a turn. */
|
||||
readonly onDidStoreTurnEdits?: Event<{ readonly chat: URI; readonly turnIds: readonly string[] }>;
|
||||
|
||||
/** Host-wide progress for provider setup, including work without a chat yet. */
|
||||
readonly onDidReportProgress?: Event<Omit<ProgressParams, 'channel'>>;
|
||||
|
||||
@@ -1724,8 +1728,12 @@ export interface IAgent {
|
||||
/** Optional steering hook. Return false to defer handoff until the provider's turn is ready. */
|
||||
setPendingMessages?(chat: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[], steeringSender?: IAgentPendingMessageSender): boolean | void;
|
||||
|
||||
/** Hands off one independent submission without replacing the legacy slot; false defers until ready. */
|
||||
sendSteeringMessage?(chat: URI, message: PendingMessage, sender?: IAgentPendingMessageSender): boolean | void;
|
||||
/**
|
||||
* Hands off one independent submission without replacing the legacy slot; false defers until ready.
|
||||
* A promise that resolves false hands the message back unsent, for example because the turn it was
|
||||
* meant to steer ended first. The host then keeps it pending and runs it as the next turn.
|
||||
*/
|
||||
sendSteeringMessage?(chat: URI, message: PendingMessage, sender?: IAgentPendingMessageSender): boolean | void | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Withdraws a steering message, previously handed off through {@link sendSteeringMessage}, that a
|
||||
@@ -1737,6 +1745,9 @@ export interface IAgent {
|
||||
/** Optional history mutation for providers with a native truncation operation. */
|
||||
truncateChat?(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise<void>;
|
||||
|
||||
/** Waits for native file snapshots to be persisted, including after a host turn is cancelled or steered. */
|
||||
waitForTurnEdits?(chat: URI, turnId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stops an entry of the chat's background work that this provider marked
|
||||
* stoppable. Resolves false when the entry is unknown or had already finished.
|
||||
@@ -1877,8 +1888,8 @@ export interface IAgent {
|
||||
|
||||
getProtectedResources(): ProtectedResourceMetadata[];
|
||||
|
||||
/** An empty token revokes the credential previously forwarded for this resource. */
|
||||
authenticate(resource: string, token: string, expiresIn?: number): Promise<boolean>;
|
||||
/** An empty token revokes the credential previously forwarded for this resource. Optional metadata carries client hints, not authorization. */
|
||||
authenticate(resource: string, token: string, expiresIn?: number, _meta?: Record<string, unknown>): Promise<boolean>;
|
||||
|
||||
/** Optional token consumer for provider-owned resources such as MCP servers. */
|
||||
handleAuthenticationToken?(params: AuthenticateParams): Promise<boolean>;
|
||||
|
||||
@@ -34,6 +34,22 @@ export const AgentHostPullRequestOperationId = {
|
||||
|
||||
export const AGENT_HOST_PULL_REQUEST_OPERATION_IDS: ReadonlySet<string> = new Set(Object.values(AgentHostPullRequestOperationId));
|
||||
|
||||
/**
|
||||
* Changeset operations that create a pull request for the session's branch.
|
||||
* Declared here for the same reason as {@link AgentHostPullRequestOperationId}.
|
||||
* Current hosts advertise only `Create` (configured through request metadata);
|
||||
* the other variants are still handled and may be advertised by older hosts.
|
||||
*/
|
||||
export const AgentHostCreatePullRequestOperationId = {
|
||||
Create: 'create-pr',
|
||||
CreateDraft: 'create-draft-pr',
|
||||
CreateAutoMerge: 'create-pr-auto-merge',
|
||||
CreateAutoSquash: 'create-pr-auto-squash',
|
||||
CreateAutoRebase: 'create-pr-auto-rebase',
|
||||
CreateAgentMerge: 'create-pr-agent-merge',
|
||||
CreateDraftAgentMerge: 'create-draft-pr-agent-merge',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The subset that hands the merge off to GitHub's own auto-merge. Agent Merge
|
||||
* covers the same intent and replaces them on the changes button bar, which
|
||||
|
||||
@@ -13,9 +13,11 @@ export const ChatMoveConfigKey = {
|
||||
Enabled: 'chatMoves.enabled',
|
||||
} as const;
|
||||
|
||||
/** Workbench settings mirrored into {@link ChatMoveConfigKey}. */
|
||||
/** Workbench settings for moving chats. `Enabled` is mirrored into {@link ChatMoveConfigKey}. */
|
||||
export const ChatMoveSettingId = {
|
||||
Enabled: 'chat.agentHost.moveChats.enabled',
|
||||
/** Whether moving a chat or session by drag and drop asks for confirmation; a workbench preference only. */
|
||||
ConfirmDragAndDrop: 'chat.agentHost.moveChats.confirmDragAndDrop',
|
||||
} as const;
|
||||
|
||||
export const chatMoveRootConfigSchema = createSchema({
|
||||
@@ -109,6 +111,12 @@ export const enum AgentChatMoveRefusal {
|
||||
NotSingleChat = 'notSingleChat',
|
||||
/** A chat becomes its own session only once its first turn started, which is when every agent persists its conversation. */
|
||||
NotStarted = 'notStarted',
|
||||
/**
|
||||
* The chat's session and the destination session are sponsored by different credential owners, or only one of them is.
|
||||
* Credentials never cross owners, so such sessions can't be combined. Client text: "This chat uses a different account's
|
||||
* credentials than the destination session."
|
||||
*/
|
||||
DifferentCredentialOwner = 'differentCredentialOwner',
|
||||
}
|
||||
|
||||
/** A chat move the host refused before changing anything. */
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface IAgentHostEnablementService {
|
||||
/**
|
||||
* Whether an enterprise has mandated the Copilot SDK sandbox floor through managed settings
|
||||
* (`sandbox.enabled`). The runtime owns composing and enforcing that floor; VS Code reads it
|
||||
* to display sandbox state. Together with any managed permission setting, this floor requires
|
||||
* to display sandbox state. Like non-empty managed permission deny rules, this floor requires
|
||||
* Copilot Agent Host for new requests; existing Local history remains readable.
|
||||
* A user- or workspace-level sandbox opt-in does not set this.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { IAgentHostSavedChatDto, IAgentHostSavedChatContinuationResult } fr
|
||||
import { AgentHostAutonomousAutomationsCapabilityMetaKey } from './meta/agentHostAutomationsMeta.js';
|
||||
import { AgentHostChatUrisCapabilityMetaKey, AgentHostNativeImplementationMetaKey, AgentHostSessionUrisCapabilityMetaKey } from './meta/agentHostSessionUrisMeta.js';
|
||||
import { AgentHostDebugLogsCancellationCapabilityMetaKey } from './meta/agentHostDebugLogsMeta.js';
|
||||
import { AgentHostWorktreeDiskUsageCapabilityMetaKey } from './meta/agentHostWorktreeDiskUsageMeta.js';
|
||||
import { InstallSandboxCertificateExtensionMethod, RemoveSandboxCertificateExtensionMethod, RotateSandboxCertificateExtensionMethod, sandboxCertificateParamsValidator } from './sandboxCertificateProtocol.js';
|
||||
|
||||
export { supportsAgentHostArtifactRemoval } from './meta/agentHostArtifactRemovalMeta.js';
|
||||
@@ -66,6 +67,10 @@ export const RequestAgentHostWorkspaceTrustExtensionMethod = 'vscode/requestWork
|
||||
export const RequestAgentHostMcpAuthenticationExtensionMethod = 'vscode/requestMcpAuthentication';
|
||||
export const RemoveSessionArtifactExtensionMethod = 'vscode/removeSessionArtifact';
|
||||
export const StopBackgroundWorkExtensionMethod = 'vscode/stopBackgroundWork';
|
||||
export const MeasureWorktreeDiskUsageExtensionMethod = 'vscode/measureWorktreeDiskUsage';
|
||||
export const CancelWorktreeDiskUsageExtensionMethod = 'vscode/cancelWorktreeDiskUsage';
|
||||
export const measureWorktreeDiskUsageParamsValidator = vObj({ uri: vString(), requestId: vString() });
|
||||
export const cancelWorktreeDiskUsageParamsValidator = vObj({ requestId: vString() });
|
||||
export const ImportSessionExtensionMethod = 'vscode/importSession';
|
||||
/**
|
||||
* Moves a single-chat session under another session (Block 4); advertised by `vscode.sessionMove` whether or not moving
|
||||
@@ -81,6 +86,7 @@ const AgentHostDetachedWorktreeCapabilityMetaKey = 'vscode.detachedWorktrees';
|
||||
|
||||
/** Namespaced VS Code implementation capabilities carried alongside standardized AHP initialize capabilities. */
|
||||
export interface IAgentHostExtensionInitializeResultMeta extends Record<string, unknown> {
|
||||
readonly [AgentHostWorktreeDiskUsageCapabilityMetaKey]?: true;
|
||||
readonly [AgentHostDebugLogsCancellationCapabilityMetaKey]?: true;
|
||||
readonly [AgentHostSessionUrisCapabilityMetaKey]?: true;
|
||||
readonly [AgentHostChatUrisCapabilityMetaKey]?: true;
|
||||
@@ -182,6 +188,15 @@ export const readSavedChatParamsValidator = vObj({ session: vString(), chat: vOp
|
||||
export const completeSavedChatContinuationParamsValidator = vObj({ session: vString(), chat: vString(), identity: vString(), destination: vString() });
|
||||
|
||||
export interface IAgentHostExtensionCommandMap {
|
||||
[MeasureWorktreeDiskUsageExtensionMethod]: {
|
||||
params: ValidatorType<typeof measureWorktreeDiskUsageParamsValidator>;
|
||||
/** Allocated regular-file bytes on the root volume, excluding files with links outside the measured worktree. */
|
||||
result: { sizeBytes?: number };
|
||||
};
|
||||
[CancelWorktreeDiskUsageExtensionMethod]: {
|
||||
params: ValidatorType<typeof cancelWorktreeDiskUsageParamsValidator>;
|
||||
result: void;
|
||||
};
|
||||
[InstallSandboxCertificateExtensionMethod]: { params: ValidatorType<typeof sandboxCertificateParamsValidator>; result: void };
|
||||
[RotateSandboxCertificateExtensionMethod]: { params: ValidatorType<typeof sandboxCertificateParamsValidator>; result: void };
|
||||
[RemoveSandboxCertificateExtensionMethod]: { params: ValidatorType<typeof sandboxCertificateParamsValidator>; result: void };
|
||||
|
||||
@@ -177,8 +177,8 @@ export const agentHostPolicySupport: Readonly<Record<string, IAgentHostPolicySup
|
||||
ExtensionGalleryServiceUrl: { status: 'enforced' },
|
||||
EnableFeedback: { status: 'enforced' },
|
||||
UpdateMode: { status: 'enforced' },
|
||||
// Windows setup and the `copilot` command read the policy themselves, so it applies to shells of
|
||||
// every session type.
|
||||
// Windows setup, the Linux DEB/RPM scripts, and the `copilot` command on every platform read the
|
||||
// policy themselves, so it applies to shells of every session type.
|
||||
CopilotCliCommand: { status: 'enforced' },
|
||||
|
||||
// #endregion
|
||||
|
||||
@@ -553,9 +553,15 @@ export const AgentHostAutoReplyAnswer = 'The user is not available to answer you
|
||||
|
||||
export const AgentHostWorkspaceTrustConfigKey = 'workspaceTrust';
|
||||
|
||||
interface IAgentHostWorkspaceTrust {
|
||||
export interface IAgentHostWorkspaceTrust {
|
||||
readonly enabled: boolean;
|
||||
readonly trustedUris: readonly string[];
|
||||
/** Whether the current Agent Host connection authority is explicitly trusted. */
|
||||
readonly connectionAuthorityTrusted?: boolean;
|
||||
}
|
||||
|
||||
export function areAllAgentHostWorkspaceResourcesTrusted(trust: IAgentHostWorkspaceTrust): boolean {
|
||||
return !trust.enabled || trust.connectionAuthorityTrusted === true;
|
||||
}
|
||||
|
||||
/** Root config key forwarded from the renderer for automatic OS system proxy discovery. */
|
||||
@@ -950,6 +956,7 @@ export const platformRootSchema = createSchema({
|
||||
title: localize('agentHost.config.workspaceTrust.trustedUris', "Trusted Folders"),
|
||||
items: { type: 'string', title: localize('agentHost.config.workspaceTrust.uri', "Folder URI") },
|
||||
},
|
||||
connectionAuthorityTrusted: { type: 'boolean', title: localize('agentHost.config.workspaceTrust.connectionAuthorityTrusted', "Connection Authority Trusted") },
|
||||
},
|
||||
required: ['enabled', 'trustedUris'],
|
||||
readOnly: true,
|
||||
|
||||
@@ -345,8 +345,8 @@ export interface ISessionStateKnownCatalog {
|
||||
export interface IAgentHostChatRowStore {
|
||||
getDraft(session: URI, chat: URI): Promise<Message | undefined>;
|
||||
setDraft(session: URI, chat: URI, draft: Message | undefined): Promise<void>;
|
||||
/** The persisted turns of `chat`, ordered by `seq`. */
|
||||
getPersistedTurns(session: URI, chat: URI): Promise<Array<IPersistedTurnRecord & { seq: number }>>;
|
||||
/** The persisted turns of `chat`, ordered by `seq`, resolving local anchors to IDs in the restored history when supplied. */
|
||||
getPersistedTurns(session: URI, chat: URI, visibleTurnIds?: ReadonlySet<string>): Promise<Array<IPersistedTurnRecord & { seq: number }>>;
|
||||
/** Persists `record` for `record.chatUri`. */
|
||||
insertPersistedTurn(session: URI, record: IPersistedTurnRecord): Promise<void>;
|
||||
deletePersistedTurns(session: URI, chat: URI, turnIds: readonly string[]): Promise<void>;
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as nls from '../../../nls.js';
|
||||
import { IPolicyData } from '../../../base/common/defaultAccount.js';
|
||||
import { PolicyCategory } from '../../../base/common/policy.js';
|
||||
import { AgentHostConfigurationSyncScope, ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationPropertySchema, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js';
|
||||
import { COPILOT_OTEL_CAPTURE_CONTENT_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_OTEL_ENABLED_KEY, COPILOT_OTEL_ENDPOINT_KEY, COPILOT_OTEL_FILE_PATH_KEY, COPILOT_OTEL_HEADERS_KEY, COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY, COPILOT_OTEL_PROTOCOL_KEY, COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, COPILOT_OTEL_SERVICE_NAME_KEY, managedSettingValue, thirdPartyAgentEnabledValue } from '../../policy/common/copilotManagedSettings.js';
|
||||
import { COPILOT_OTEL_CAPTURE_CONTENT_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_OTEL_ENABLED_KEY, COPILOT_OTEL_ENDPOINT_KEY, COPILOT_OTEL_FILE_PATH_KEY, COPILOT_OTEL_HEADERS_KEY, COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY, COPILOT_OTEL_PROTOCOL_KEY, COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, COPILOT_OTEL_SERVICE_NAME_KEY, COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY, managedSettingValue, thirdPartyAgentEnabledValue } from '../../policy/common/copilotManagedSettings.js';
|
||||
import product from '../../product/common/product.js';
|
||||
import { Registry } from '../../registry/common/platform.js';
|
||||
import {
|
||||
@@ -223,6 +223,13 @@ configurationRegistry.registerConfiguration({
|
||||
tags: ['experimental'],
|
||||
agentHost: { key: ChatMoveConfigKey.Enabled },
|
||||
},
|
||||
[ChatMoveSettingId.ConfirmDragAndDrop]: {
|
||||
type: 'boolean',
|
||||
description: nls.localize('chat.agentHost.moveChats.confirmDragAndDrop', "Controls whether the Agents window asks for confirmation before moving a chat or session to another session or into a new session via drag and drop. Reordering chats within a session is never confirmed."),
|
||||
default: true,
|
||||
scope: ConfigurationScope.APPLICATION,
|
||||
tags: ['experimental'],
|
||||
},
|
||||
...titleGenerationConfigurationProperties,
|
||||
...artifactToolsConfigurationProperties,
|
||||
[CanvasesEnabledSettingId]: {
|
||||
@@ -327,6 +334,9 @@ configurationRegistry.registerConfiguration({
|
||||
category: PolicyCategory.InteractiveSession,
|
||||
minimumVersion: '1.113',
|
||||
value: thirdPartyAgentEnabledValue,
|
||||
managedSettings: {
|
||||
[COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY]: { type: 'boolean' },
|
||||
},
|
||||
localization: {
|
||||
description: {
|
||||
key: 'chat.agentHost.claudeAgent.enabled.policy',
|
||||
@@ -361,6 +371,9 @@ configurationRegistry.registerConfiguration({
|
||||
category: PolicyCategory.InteractiveSession,
|
||||
minimumVersion: '1.126',
|
||||
value: thirdPartyAgentEnabledValue,
|
||||
managedSettings: {
|
||||
[COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY]: { type: 'boolean' },
|
||||
},
|
||||
localization: {
|
||||
description: {
|
||||
key: 'chat.agentHost.codexAgent.enabled.policy',
|
||||
|
||||
@@ -114,6 +114,12 @@ export interface AgentMergeSessionState {
|
||||
readonly lastPromptAt?: string;
|
||||
readonly repeatedPromptCount?: number;
|
||||
readonly totalPromptCount?: number;
|
||||
/** Pull request head when the most recent repair turn started; a different head afterwards counts as progress. */
|
||||
readonly lastPromptHeadSha?: string;
|
||||
/** Blockers the most recent repair turn was asked to fix; resolving any of them counts as progress. */
|
||||
readonly lastPromptBlockers?: readonly string[];
|
||||
/** Repair turns since Agent Merge was enabled. Unlike the other counters, progress never resets it. */
|
||||
readonly lifetimePromptCount?: number;
|
||||
/**
|
||||
* Local worktree commit observed when the most recent repair turn started.
|
||||
* A different local commit afterwards means that turn produced work, which
|
||||
@@ -134,6 +140,9 @@ export interface AgentMergeControllerState {
|
||||
readonly lastPromptAt?: string;
|
||||
readonly repeatedPromptCount?: number;
|
||||
readonly totalPromptCount?: number;
|
||||
readonly lastPromptHeadSha?: string;
|
||||
readonly lastPromptBlockers?: readonly string[];
|
||||
readonly lifetimePromptCount?: number;
|
||||
readonly repairBaseCommit?: string;
|
||||
}
|
||||
|
||||
@@ -325,6 +334,14 @@ export const agentMergeDisableReasons = {
|
||||
log: 'the same pull request blockers remained after repeated repair attempts',
|
||||
notice: localize('agentMerge.disabled.repairBudgetExhausted', "Agent Merge was disabled because the same pull request blockers remained after repeated repair attempts."),
|
||||
}),
|
||||
repairWithoutProgress: (attempts: number): AgentMergeDisableReason => ({
|
||||
log: `${attempts} repair attempts in a row made no progress`,
|
||||
notice: localize('agentMerge.disabled.repairWithoutProgress', "Agent Merge was disabled because {0} repair attempts in a row made no progress on the pull request.", attempts),
|
||||
}),
|
||||
repairLimitReached: (attempts: number): AgentMergeDisableReason => ({
|
||||
log: `the limit of ${attempts} repair attempts was reached`,
|
||||
notice: localize('agentMerge.disabled.repairLimitReached', "Agent Merge was disabled after {0} repair attempts on this pull request. Enable it again to continue.", attempts),
|
||||
}),
|
||||
pendingReviewReply: (): AgentMergeDisableReason => ({
|
||||
log: 'a review reply is unpublished in the user\'s pending review',
|
||||
notice: localize('agentMerge.disabled.pendingReviewReply', "Agent Merge was disabled for this folder because a review reply is unpublished in your pending GitHub review. Handle the pending review on GitHub, then enable Agent Merge again to resume."),
|
||||
@@ -854,6 +871,9 @@ function readFolderControllerState(value: unknown): AgentMergeFolderControllerSt
|
||||
...(typeof value.lastPromptAt === 'string' ? { lastPromptAt: value.lastPromptAt } : {}),
|
||||
...(typeof value.repeatedPromptCount === 'number' && Number.isInteger(value.repeatedPromptCount) && value.repeatedPromptCount >= 0 ? { repeatedPromptCount: value.repeatedPromptCount } : {}),
|
||||
...(typeof value.totalPromptCount === 'number' && Number.isInteger(value.totalPromptCount) && value.totalPromptCount >= 0 ? { totalPromptCount: value.totalPromptCount } : {}),
|
||||
...(typeof value.lastPromptHeadSha === 'string' ? { lastPromptHeadSha: value.lastPromptHeadSha } : {}),
|
||||
...(Array.isArray(value.lastPromptBlockers) && value.lastPromptBlockers.every(blocker => typeof blocker === 'string') ? { lastPromptBlockers: value.lastPromptBlockers } : {}),
|
||||
...(typeof value.lifetimePromptCount === 'number' && Number.isInteger(value.lifetimePromptCount) && value.lifetimePromptCount >= 0 ? { lifetimePromptCount: value.lifetimePromptCount } : {}),
|
||||
...(typeof value.repairBaseCommit === 'string' ? { repairBaseCommit: value.repairBaseCommit } : {}),
|
||||
};
|
||||
}
|
||||
@@ -865,6 +885,9 @@ function toAgentMergeFolderControllerState(state: AgentMergeFolderControllerStat
|
||||
...(state.lastPromptAt ? { lastPromptAt: state.lastPromptAt } : {}),
|
||||
...(state.repeatedPromptCount !== undefined ? { repeatedPromptCount: state.repeatedPromptCount } : {}),
|
||||
...(state.totalPromptCount !== undefined ? { totalPromptCount: state.totalPromptCount } : {}),
|
||||
...(state.lastPromptHeadSha ? { lastPromptHeadSha: state.lastPromptHeadSha } : {}),
|
||||
...(state.lastPromptBlockers ? { lastPromptBlockers: state.lastPromptBlockers } : {}),
|
||||
...(state.lifetimePromptCount !== undefined ? { lifetimePromptCount: state.lifetimePromptCount } : {}),
|
||||
...(state.repairBaseCommit ? { repairBaseCommit: state.repairBaseCommit } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1207,3 +1230,110 @@ export function classifyAgentMergeRequiredChecks(checks: PullRequestChecks): Age
|
||||
}
|
||||
return { kind: 'ready', failed, pending };
|
||||
}
|
||||
|
||||
const checkBlockerPrefix = 'check:';
|
||||
const threadBlockerPrefix = 'thread:';
|
||||
const feedbackBlocker = 'feedback';
|
||||
const conflictingBlocker = 'conflicting';
|
||||
const behindBlocker = 'behind';
|
||||
|
||||
/**
|
||||
* The blockers a repair turn is asked to fix, as stable keys that survive a
|
||||
* new head or a workflow rerun, so a later evaluation can tell whether the
|
||||
* turn made progress; see {@link hasAgentMergeRepairProgress}.
|
||||
*/
|
||||
export function getAgentMergeRepairBlockers(actions: readonly AgentMergeRepairAction[], context: AgentMergePromptContext): string[] {
|
||||
const blockers: string[] = [];
|
||||
if (actions.includes('addressReviews')) {
|
||||
blockers.push(...context.reviewThreads.map(thread => `${threadBlockerPrefix}${thread.id}`));
|
||||
if (context.reviewSummaries.length > 0 || context.newComments.length > 0) {
|
||||
blockers.push(feedbackBlocker);
|
||||
}
|
||||
}
|
||||
if (actions.includes('fixCI')) {
|
||||
blockers.push(...context.failedChecks.map(name => `${checkBlockerPrefix}${name}`));
|
||||
}
|
||||
if (actions.includes('resolveConflicts')) {
|
||||
if (context.conflicting) {
|
||||
blockers.push(conflictingBlocker);
|
||||
}
|
||||
if (context.behind) {
|
||||
blockers.push(behindBlocker);
|
||||
}
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pull request moved forward since the most recent repair turn
|
||||
* started: its head changed, or one of the blockers that turn was asked to
|
||||
* fix is now resolved. A failed check only counts as resolved once a check of
|
||||
* the same name passes, not merely because a new head has not reported it yet.
|
||||
*
|
||||
* Expects a snapshot that {@link evaluateAgentMerge} could evaluate.
|
||||
*/
|
||||
export function hasAgentMergeRepairProgress(snapshot: PullRequestSnapshot, commentWatermark: string, previous: { readonly headSha?: string; readonly blockers?: readonly string[] }): boolean {
|
||||
const headSha = snapshot.core.value?.headSha;
|
||||
if (!headSha) {
|
||||
return false;
|
||||
}
|
||||
if (previous.headSha !== undefined && previous.headSha !== headSha) {
|
||||
return true;
|
||||
}
|
||||
if (!previous.blockers?.length) {
|
||||
return false;
|
||||
}
|
||||
const { reviewThreads, changesRequested, newComments } = getAgentMergeFeedback(snapshot, commentWatermark);
|
||||
const unresolvedThreads = new Set(reviewThreads.map(thread => `${threadBlockerPrefix}${thread.id}`));
|
||||
const checks = snapshot.checks.value?.checks.filter(check => check.required === true) ?? [];
|
||||
const mergeability = snapshot.mergeability.value;
|
||||
return previous.blockers.some(blocker => {
|
||||
if (blocker.startsWith(checkBlockerPrefix)) {
|
||||
const name = blocker.slice(checkBlockerPrefix.length);
|
||||
const named = checks.filter(check => check.name === name);
|
||||
return named.length > 0 && named.every(isSuccessfulCheck);
|
||||
}
|
||||
if (blocker.startsWith(threadBlockerPrefix)) {
|
||||
return !unresolvedThreads.has(blocker);
|
||||
}
|
||||
switch (blocker) {
|
||||
case feedbackBlocker:
|
||||
return changesRequested.length === 0 && newComments.length === 0;
|
||||
case conflictingBlocker:
|
||||
return mergeability?.mergeable === 'MERGEABLE';
|
||||
case behindBlocker: {
|
||||
const status = mergeability?.mergeStateStatus?.toUpperCase();
|
||||
return status !== undefined && status !== 'BEHIND' && status !== 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function isSuccessfulCheck(check: PullRequestCheck): boolean {
|
||||
const status = check.status?.toUpperCase();
|
||||
return check.type === 'statusContext'
|
||||
? status === 'SUCCESS'
|
||||
: status === 'COMPLETED' && successfulCheckConclusions.has(check.conclusion?.toUpperCase() ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes the checks of a head so a caller can tell when they last changed:
|
||||
* `digest` changes whenever any check or check suite changes state, and
|
||||
* `pending` is whether any of them has not finished. Covers all checks,
|
||||
* required or not, and suites that have not reported check runs yet, since any
|
||||
* of them moving means CI is still running.
|
||||
*/
|
||||
export function getAgentMergeCheckActivity(checks: PullRequestChecks): { readonly digest: string; readonly pending: boolean } {
|
||||
const states = [
|
||||
...checks.checks.map(check => `check:${check.id}:${check.status?.toUpperCase() ?? ''}:${check.conclusion?.toUpperCase() ?? ''}`),
|
||||
...checks.expectedSuites.map(suite => `suite:${suite.id}:${suite.status?.toUpperCase() ?? ''}:${suite.conclusion?.toUpperCase() ?? ''}:${suite.checkRunsReported}`),
|
||||
].sort();
|
||||
const pending = checks.checks.some(check => {
|
||||
const status = check.status?.toUpperCase();
|
||||
return check.type === 'statusContext'
|
||||
? !status || status === 'PENDING' || status === 'EXPECTED'
|
||||
: status !== 'COMPLETED';
|
||||
}) || checks.expectedSuites.some(suite => !suite.checkRunsReported && suite.status?.toUpperCase() !== 'COMPLETED');
|
||||
return { digest: states.join('|'), pending };
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { ToolDefinition, URI } from './state/sessionState.js';
|
||||
import type { StringOrMarkdown, ToolDefinition, URI } from './state/sessionState.js';
|
||||
|
||||
/**
|
||||
* A server tool definition plus agent-host-local metadata that is not part of
|
||||
@@ -40,6 +40,13 @@ export interface IAgentServerToolDefinition extends ToolDefinition {
|
||||
* Tool invocation methods take the exact Agent Host chat channel URI. The host
|
||||
* resolves its owning session for session-scoped state and tools.
|
||||
*/
|
||||
/** The confirmation a server tool's call shows, built with the host's state. */
|
||||
export interface IServerToolConfirmationDisplay {
|
||||
readonly confirmationTitle?: string;
|
||||
readonly confirmationMessage?: StringOrMarkdown;
|
||||
readonly hideConfirmationInput?: boolean;
|
||||
}
|
||||
|
||||
export interface IAgentServerToolHost {
|
||||
/** Every server tool definition across the contributed groups. */
|
||||
readonly definitions: readonly IAgentServerToolDefinition[];
|
||||
@@ -70,6 +77,12 @@ export interface IAgentServerToolHost {
|
||||
* Providers must consult this before prompting or executing the tool.
|
||||
*/
|
||||
requiresConfirmation(chatUri: URI, toolName: string): boolean;
|
||||
/**
|
||||
* The confirmation a call of {@link toolName} with {@link args} shows, built by its group with the host's state
|
||||
* (for example, linking only sessions the host knows). `undefined` for a tool with no confirmation display, where
|
||||
* providers fall back to their generic one.
|
||||
*/
|
||||
getConfirmationDisplay(toolName: string, args: unknown): IServerToolConfirmationDisplay | undefined;
|
||||
/**
|
||||
* Executes a server tool for the exact chat that invoked it, dispatching any
|
||||
* resulting actions, and returns the textual tool result for the agent.
|
||||
|
||||
@@ -856,6 +856,8 @@ export interface IMissionControlOptions {
|
||||
readonly defaultDirectory?: string;
|
||||
readonly live?: boolean;
|
||||
readonly requireConnectionBinding?: boolean;
|
||||
/** Explicit host-owner opt-in to use local Copilot credentials for same-owner relay clients. */
|
||||
readonly useLocalCredentials?: boolean;
|
||||
/** Explicit host-owner testing override; omit device remote-control policy from Mission Control registration. */
|
||||
readonly ignoreRemoteControlPolicy?: boolean;
|
||||
}
|
||||
@@ -1202,6 +1204,9 @@ export interface IAgentService {
|
||||
*/
|
||||
resourceList(uri: URI): Promise<ResourceListResult>;
|
||||
|
||||
/** Estimates reclaimable regular-file allocation using the host-wide scan budget across all protocol endpoints. */
|
||||
measureWorktreeDiskUsage(root: URI, token: CancellationToken): Promise<number | undefined>;
|
||||
|
||||
/**
|
||||
* Read stored content by URI from the agent host (e.g. file edit snapshots,
|
||||
* or reading files from the remote filesystem).
|
||||
@@ -1356,6 +1361,12 @@ export interface IAgentConnection {
|
||||
* when the host doesn't advertise `vscode.sessionMove`.
|
||||
*/
|
||||
moveSession(session: URI, destination: URI, after: URI | undefined): Promise<URI>;
|
||||
/**
|
||||
* Moves `chat` (AHP `moveChat`) within its session, into another session, or into a new session, and resolves to
|
||||
* its owning session after the move. A host refusal rejects with a `ProtocolError` whose `data.reason` is an
|
||||
* `AgentChatMoveRefusal`.
|
||||
*/
|
||||
moveChat(chat: URI, destination: AgentChatMoveDestination): Promise<URI>;
|
||||
/** Requires the VS Code artifact removal capability advertised by initialize. */
|
||||
removeSessionArtifact?(session: URI, artifactId: string): Promise<void>;
|
||||
/** Only for background work whose `_meta` the host marked stoppable. Resolves false when it had already finished. */
|
||||
@@ -1450,6 +1461,8 @@ export interface IAgentConnection {
|
||||
invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult>;
|
||||
|
||||
// ---- Filesystem operations ----------------------------------------------
|
||||
/** Estimates reclaimable regular-file allocation on local or remote hosts. Unsupported hosts must not fall back to per-file RPCs. */
|
||||
measureWorktreeDiskUsage?(root: URI, token: CancellationToken): Promise<number | undefined>;
|
||||
resourceList(uri: URI): Promise<ResourceListResult>;
|
||||
resourceRead(uri: URI, encoding?: ContentEncoding): Promise<ResourceReadResult>;
|
||||
resourceWrite(params: ResourceWriteParams): Promise<ResourceWriteResult>;
|
||||
|
||||
@@ -3,17 +3,14 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { localize } from '../../../nls.js';
|
||||
|
||||
/**
|
||||
* Well-known session-config keys advertised by the agent-host Codex provider
|
||||
* in its `resolveSessionConfig` schema.
|
||||
*
|
||||
* This file is intentionally protocol-free (no imports from the generated
|
||||
* `node/protocol` types) so it can be shared with the browser pickers, which
|
||||
* cannot import from the `node` layer. The string-literal unions below are
|
||||
* declared to match — and are structurally assignable to — the corresponding
|
||||
* generated Codex app-server types (`AskForApproval`, `SandboxMode`,
|
||||
* `ApprovalsReviewer`). Protocol-typed narrowing helpers live alongside the
|
||||
* node agent in `node/codex/codexSessionConfigKeys.ts`.
|
||||
* Shared with browser pickers. Native permission resolution and protocol-typed
|
||||
* narrowing helpers live in `node/codex/codexSessionConfigKeys.ts`.
|
||||
*/
|
||||
export const enum CodexSessionConfigKey {
|
||||
PermissionsPreset = 'codex.permissionsPreset',
|
||||
@@ -27,15 +24,6 @@ export const enum CodexSessionConfigKey {
|
||||
ReasoningSummary = 'codex.reasoningSummary',
|
||||
}
|
||||
|
||||
/** Subset of the generated `AskForApproval` union that VS Code exposes. */
|
||||
export type CodexApprovalPolicy = 'never' | 'on-request' | 'untrusted';
|
||||
|
||||
/** Mirrors the generated `SandboxMode` union. */
|
||||
export type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||
|
||||
/** Mirrors the generated `ApprovalsReviewer` union. */
|
||||
export type CodexApprovalsReviewer = 'user' | 'auto_review' | 'guardian_subagent';
|
||||
|
||||
/**
|
||||
* Codex collapses its three security axes (sandbox × approval policy ×
|
||||
* approvals reviewer) into a single user-facing "permissions" preset, matching
|
||||
@@ -48,6 +36,12 @@ export type CodexPermissionsPreset = 'default' | 'auto-review' | 'full-access';
|
||||
/** Ordered preset list advertised in the Codex session-config schema. */
|
||||
export const CODEX_PERMISSIONS_PRESETS: readonly CodexPermissionsPreset[] = ['default', 'auto-review', 'full-access'];
|
||||
|
||||
export const CODEX_PERMISSIONS_PRESET_LABELS: Readonly<Record<CodexPermissionsPreset, string>> = {
|
||||
default: localize('codex.sessionConfig.permissionsPreset.default', "Default Permissions"),
|
||||
'auto-review': localize('codex.sessionConfig.permissionsPreset.autoReview', "Auto-Review"),
|
||||
'full-access': localize('codex.sessionConfig.permissionsPreset.fullAccess', "Full Access"),
|
||||
};
|
||||
|
||||
/** Default preset applied to new Codex sessions. */
|
||||
export const CODEX_DEFAULT_PERMISSIONS_PRESET: CodexPermissionsPreset = 'default';
|
||||
|
||||
@@ -71,49 +65,3 @@ export function narrowCodexPermissionsPreset(raw: unknown): CodexPermissionsPres
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ICodexResolvedPermissions {
|
||||
readonly approvalPolicy: CodexApprovalPolicy;
|
||||
readonly sandboxMode: CodexSandboxMode;
|
||||
readonly approvalsReviewer: CodexApprovalsReviewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a {@link CodexPermissionsPreset} into the three underlying Codex
|
||||
* security axes sent to the app-server (`approvalPolicy`, `sandbox`,
|
||||
* `approvalsReviewer`).
|
||||
*/
|
||||
export function resolveCodexPermissionsPreset(preset: CodexPermissionsPreset): ICodexResolvedPermissions {
|
||||
switch (preset) {
|
||||
case 'auto-review':
|
||||
// Same workspace-write sandbox as `default`, but on-request approvals
|
||||
// are routed through the auto-reviewer instead of a UI prompt.
|
||||
return { approvalPolicy: 'on-request', sandboxMode: 'workspace-write', approvalsReviewer: 'auto_review' };
|
||||
case 'full-access':
|
||||
return { approvalPolicy: 'never', sandboxMode: 'danger-full-access', approvalsReviewer: 'user' };
|
||||
case 'default':
|
||||
default:
|
||||
return { approvalPolicy: 'on-request', sandboxMode: 'workspace-write', approvalsReviewer: 'user' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link resolveCodexPermissionsPreset}: find the preset whose
|
||||
* expanded axes exactly match the given resolved permissions, or `undefined`
|
||||
* when no preset can represent them (e.g. a `read-only` sandbox, which no
|
||||
* preset expands to).
|
||||
*
|
||||
* Used when restoring a legacy session that persisted the individual security
|
||||
* axes but no preset: if the axes map cleanly onto a preset we can migrate them
|
||||
* to the modern single-preset representation; otherwise the raw axes must be
|
||||
* preserved so they are not silently escalated.
|
||||
*/
|
||||
export function presetForResolvedPermissions(resolved: ICodexResolvedPermissions): CodexPermissionsPreset | undefined {
|
||||
for (const preset of CODEX_PERMISSIONS_PRESETS) {
|
||||
const axes = resolveCodexPermissionsPreset(preset);
|
||||
if (axes.approvalPolicy === resolved.approvalPolicy && axes.sandboxMode === resolved.sandboxMode && axes.approvalsReviewer === resolved.approvalsReviewer) {
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -48,6 +48,10 @@ export const enum CopilotCliConfigKey {
|
||||
Tgrep = 'tgrep',
|
||||
/** Offer the runtime's `search_code_subagent` tool to Copilot SDK sessions. Off by default. */
|
||||
SearchSubagent = 'searchSubagent',
|
||||
/** CAPI model override for the runtime's search subagent. Empty uses the runtime default. */
|
||||
SearchSubagentModel = 'searchSubagentModel',
|
||||
/** Offer the runtime's built-in Explore agent. On by default. */
|
||||
ExploreAgent = 'exploreAgent',
|
||||
/** Use the runtime's execution subagent instead of the extension-native client tool. */
|
||||
ExecutionSubagent = 'executionSubagent',
|
||||
/** Runtime model ID for the execution subagent; empty inherits the session model. */
|
||||
@@ -122,6 +126,10 @@ export function isImageGenerationEnabledByDefault(quality: string | undefined):
|
||||
return quality === 'insider';
|
||||
}
|
||||
|
||||
export const CopilotSearchSubagentModelSettingId = 'chat.copilot.searchSubagent.model';
|
||||
|
||||
export const CopilotExploreAgentEnabledSettingId = 'chat.copilot.exploreAgent.enabled';
|
||||
|
||||
export const AgentHostCopilotExecutionSubagentEnabledSettingId = 'chat.agentHost.copilot.executionSubagent.enabled';
|
||||
|
||||
export const AgentHostCopilotExecutionSubagentModelSettingId = 'chat.agentHost.copilot.executionSubagent.model';
|
||||
@@ -286,6 +294,18 @@ export const copilotCliConfigSchema = createSchema({
|
||||
description: localize('agentHost.config.searchSubagent.description', "When enabled, Copilot SDK sessions are offered the runtime's search subagent tool for codebase search. Applied when a session launches or resumes."),
|
||||
default: false,
|
||||
}),
|
||||
[CopilotCliConfigKey.SearchSubagentModel]: schemaProperty<string>({
|
||||
type: 'string',
|
||||
title: localize('agentHost.config.searchSubagentModel.title', "Search Subagent Model"),
|
||||
description: localize('agentHost.config.searchSubagentModel.description', "CAPI model ID to use for the search subagent. Empty uses the runtime default. An unavailable model makes the search tool unavailable. Changing this value restarts the Copilot SDK client after active turns finish."),
|
||||
default: '',
|
||||
}),
|
||||
[CopilotCliConfigKey.ExploreAgent]: schemaProperty<boolean>({
|
||||
type: 'boolean',
|
||||
title: localize('agentHost.config.exploreAgent.title', "Explore Agent"),
|
||||
description: localize('agentHost.config.exploreAgent.description', "When enabled, Copilot SDK sessions can use the runtime's built-in Explore agent, independently of the search subagent tool. Applied when a session launches or resumes. Does not disable custom agents named Explore."),
|
||||
default: true,
|
||||
}),
|
||||
[CopilotCliConfigKey.ExecutionSubagent]: schemaProperty<boolean>({
|
||||
type: 'boolean',
|
||||
title: localize('agentHost.config.executionSubagent.title', "Runtime Execution Subagent"),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export { agentEntitlementHintMeta, readAgentEntitlementHint } from './vscode/agentEntitlementHintMeta.js';
|
||||
export type { IAgentEntitlementHint } from './vscode/agentEntitlementHintMeta.js';
|
||||
@@ -0,0 +1,6 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export { AgentHostWorktreeDiskUsageCapabilityMetaKey, supportsAgentHostWorktreeDiskUsage } from './vscode/agentHostWorktreeDiskUsageMeta.js';
|
||||
@@ -4,4 +4,4 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export type { McpServerSource } from './vscode/mcpCustomizationMeta.js';
|
||||
export { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from './vscode/mcpCustomizationMeta.js';
|
||||
export { readMcpServerConnectorName, readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerConnectorNameMeta, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from './vscode/mcpCustomizationMeta.js';
|
||||
@@ -0,0 +1,31 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { isObject } from '../../../../../base/common/types.js';
|
||||
|
||||
const entitlementKey = 'vscode.authentication.entitlement';
|
||||
|
||||
/** Client-observed account metadata used only to request discovery revalidation. */
|
||||
export interface IAgentEntitlementHint {
|
||||
readonly trackingId: string;
|
||||
readonly sku: string;
|
||||
}
|
||||
|
||||
export function readAgentEntitlementHint(source: { readonly _meta?: Record<string, unknown> }): IAgentEntitlementHint | undefined {
|
||||
const value = source._meta?.[entitlementKey];
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = value as Record<string, unknown>;
|
||||
if (typeof raw.trackingId !== 'string' || !raw.trackingId.trim()
|
||||
|| typeof raw.sku !== 'string' || !raw.sku.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
return { trackingId: raw.trackingId, sku: raw.sku };
|
||||
}
|
||||
|
||||
export function agentEntitlementHintMeta(hint: IAgentEntitlementHint): Record<string, unknown> {
|
||||
return { [entitlementKey]: { ...hint } };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { InitializeResult } from '../../state/protocol/common/commands.js';
|
||||
|
||||
export const AgentHostWorktreeDiskUsageCapabilityMetaKey = 'vscode.worktreeDiskUsage';
|
||||
|
||||
export function supportsAgentHostWorktreeDiskUsage(result: InitializeResult | undefined): boolean {
|
||||
return result?._meta?.[AgentHostWorktreeDiskUsageCapabilityMetaKey] === true;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const displayNameKey = 'vscode.mcpServerDisplayName';
|
||||
const maxDisplayNameLength = 512;
|
||||
const sourcePluginKey = 'agentHost.mcpServerSourcePlugin';
|
||||
const controllingSettingKey = 'vscode.mcpServerControllingSetting';
|
||||
const connectorNameKey = 'vscode.mcpServerConnectorName';
|
||||
|
||||
export type McpServerSource =
|
||||
| 'user' // Defined in user-level configuration.
|
||||
@@ -95,6 +96,23 @@ export function withMcpServerControllingSettingMeta(meta: Record<string, unknown
|
||||
return withMetaEntry(meta, controllingSettingKey, settingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the canonical Copilot Connector name a host declares as owning a server. Connector-owned servers
|
||||
* authenticate with the session's GitHub credential, so clients must authorize the account rather than
|
||||
* supply a per-server MCP token.
|
||||
*/
|
||||
export function readMcpServerConnectorName(customization: McpServerCustomization | undefined): string | undefined {
|
||||
return readNonEmptyString(customization?._meta?.[connectorNameKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the canonical Copilot Connector name that owns a server. An absent name removes a previously
|
||||
* recorded one; every other entry is preserved.
|
||||
*/
|
||||
export function withMcpServerConnectorNameMeta(meta: Record<string, unknown> | undefined, connectorName: string | undefined): Record<string, unknown> | undefined {
|
||||
return withMetaEntry(meta, connectorNameKey, connectorName);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js';
|
||||
import { IMissionControlEnvironment } from './cloudSandboxAgentHost.js';
|
||||
|
||||
export const AgentHostRemoteConnectionsSettingId = 'chat.agentHost.remoteConnections';
|
||||
export const MissionControlUseLocalCredentialsSettingId = 'chat.agentHost.experimentalMissionControl.useLocalCredentials';
|
||||
/** Logical identity for host-side Mission Control wire logs, including failed handshakes. */
|
||||
export const MISSION_CONTROL_AHP_LOG_ID = 'mission-control';
|
||||
export type AgentHostRemoteConnectionsBackend = 'devTunnel' | 'githubEnvironment' | 'missionControl';
|
||||
|
||||
@@ -27,6 +27,7 @@ export const enum SessionServerToolName {
|
||||
SendMessage = 'send_message',
|
||||
GetSessionContext = 'get_session_context',
|
||||
DeleteSession = 'delete_session',
|
||||
MoveSession = 'move_session',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loaded 100 of 560 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user