mirror of
https://github.com/microsoft/vscode.git
synced 2026-10-28 13:26:52 +08:00
copilot: apply the CopilotCliCommand policy to Linux packages and the macOS shim (#342101)
* linux: expose the Copilot shim in DEB/RPM packages Honor Linux installation policy and preserve the official installer PATH. Manage Stable and Insiders command ownership through guarded package lifecycle hooks. Add native runtime regressions and a focused package lifecycle harness while leaving deferred packaging formats and non-Linux behavior unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: fix Copilot package launcher and rendering checks Use a scenario-local profile for root launcher validation and ignore disabled shell comments when checking unresolved package placeholders. Preserve strict helper validation and add focused regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * copilot: apply the CopilotCliCommand policy to Linux packages and the macOS shim The Linux DEB/RPM scripts now ask the shim for the CopilotCliCommand policy before managing /usr/bin/copilot, matching Windows setup: a disabled policy removes a link to either edition's shim instead of creating one, and an unreadable policy leaves the entry unchanged. DEB packages add a preseedable <package>/add-copilot-command debconf question (medium priority, default true) that lets an edition opt out; hand-over on removal respects the other edition's answer and the policy. The shim gains `--vscode-shim policy`, which exits 0 when the policy allows the command and 10 when it turns it off, and reads the policy on macOS from the configuration-profile domains of every quality (ported from #341916). Co-authored-by: Don Jayamanne <don.jayamanne@outlook.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: drop rendering tests that only exercised the harness validator Keep the checks of the real link helper source and the DEB/RPM templates; the removed cases tested the package-lifecycle harness's own validator rather than product code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * copilot: bump the shim to 0.1.2 main's #342224 and this branch both changed the shim and bumped it from 0.1.0 to 0.1.1. Windows setup keeps a published shim whose file version matches, so the merged shim needs its own version to replace main's 0.1.1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Don Jayamanne <don.jayamanne@outlook.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
39 files changed
+5468
-80
No files matched your search
@@ -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",
|
||||
|
||||
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"
|
||||
@@ -120,6 +120,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 +257,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 +303,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 +830,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 +907,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 +934,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 +954,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 +966,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 +1007,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 +1031,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 +1229,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 +1263,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 +1353,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");
|
||||
|
||||
@@ -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 || :
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2431,7 +2431,7 @@ configurationRegistry.registerConfiguration({
|
||||
localization: {
|
||||
description: {
|
||||
key: 'chat.copilotCliCommand.enabled.policy',
|
||||
value: nls.localize('chat.copilotCliCommand.enabled.policy', "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: nls.localize('chat.copilotCliCommand.enabled.policy', "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."),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -91,6 +91,105 @@ The `run-docker.sh` script accepts the following options:
|
||||
|
||||
All other arguments are passed through to the sanity test runner.
|
||||
|
||||
## Focused Linux Copilot Shim Package Lifecycle Suite
|
||||
|
||||
`test-copilot-shim` is intentionally separate from the normal sanity runner. It does not use the single-quality `TestContext`, capability-based skipping, fresh-install helpers, or Mocha retry behavior from `src/index.ts`. It requires an explicit fixture manifest, one explicit package format, and an explicit Mocha selector; it always runs with `retries: 0` and bails after a stateful failure.
|
||||
|
||||
The suite must run as root **only inside a provisioned disposable Linux container or VM**. Root and an environment flag are not sufficient evidence of isolation. The runner refuses non-Linux hosts, unavailable `dpkg`/`rpm`, a missing marker, a marker from another mount namespace, bind/subdirectory mounts for `/usr` or `/etc`, non-provisioned marker directories, existing `code`/`code-insiders`/`code-oss` packages, `/etc/vscode/policy.json`, or existing `/usr/bin/copilot`, `/usr/local/bin/copilot`, VS Code payload directories, and launcher entries. It never falls back to a skipped scenario.
|
||||
|
||||
The marker is created only by `scripts/provision-copilot-shim-runner.sh`. It requires a unique `VSCODE_COPILOT_SHIM_RUNNER_ID`, creates root-owned probes under `/usr/share/vscode-copilot-shim-package-lifecycle/<id>` and `/etc/vscode-copilot-shim-package-lifecycle/<id>`, and writes a mount-namespace-bound marker at `/run/vscode-copilot-shim-package-lifecycle/<id>/marker.json`. The harness accepts only that marker layout and uses the marker's test-owned work/evidence roots. Successful scenarios remove only fixture packages and paths introduced by the scenario. Failed scenarios retain package state and write their command transcript to the marker's evidence root.
|
||||
|
||||
For a prepared workspace, the safe source-only helper check is:
|
||||
|
||||
```bash
|
||||
npm run test-copilot-shim-rendering
|
||||
```
|
||||
|
||||
It verifies the checked-in `resources/linux/copilot-link.sh` source has no unresolved rendering markers or RPM macros, has literal Stable/Insiders targets, and defines `vscode_copilot_link`. It does not invoke a package manager or mutate package state.
|
||||
|
||||
On Linux, this check runs the same helper with `sh` and `bash` against stub shims and a temporary link path. It covers the `CopilotCliCommand` policy that each shim reports, the DEB `add-copilot-command` debconf answer, and hand-over on removal. It doesn't need root and doesn't touch `/usr/bin/copilot`:
|
||||
|
||||
```bash
|
||||
npm run test-copilot-shim-helper
|
||||
```
|
||||
|
||||
### Fixture Manifest and Generation
|
||||
|
||||
The generated manifest is self-contained: package paths are relative to the manifest, and it records DEB/RPM format, package architecture, identity, role, actual package name/version, a monotonically declared revision ordinal, payload kind, launcher/shim paths, source revision, generation command, production templates/specs used, deliberate fixture adjustments, and the SHA-256 of the exact helper source. Its JSON schema is [`fixtures/copilot-shim-fixtures.schema.json`](fixtures/copilot-shim-fixtures.schema.json). The generator rejects missing fixture roles, equal initial/upgrade revisions, a relabelled `code-oss` source, unresolved paths, and incomplete current Stable/Insiders payloads.
|
||||
|
||||
Use [`fixtures/copilot-shim-fixture-plan.example.json`](fixtures/copilot-shim-fixture-plan.example.json) as a plan template. A complete plan supplies, for **both DEB and RPM**, branded `code` and `code-insiders` initial, strictly newer upgrade, and older no-shim inputs; `code-oss` initial/upgrade negative inputs; and no manually assembled VS Code package. The generator creates its own tiny foreign-command package only for the test case that proves package-manager ownership preservation. It does not duplicate the VS Code link helper or relabel an OSS package.
|
||||
|
||||
Each source root in the plan must be a disposable, already branded source/build copy rather than this checkout. Put the prepared payload that production Gulp expects beside it as `../VSCode-linux-<arch>`, then create this marker in the disposable source root before running the generator:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"purpose": "vscode-copilot-shim-fixture-source",
|
||||
"disposable": true,
|
||||
"sourceRevision": "<exact-source-commit>"
|
||||
}
|
||||
```
|
||||
|
||||
Keep every entry in its own parent directory so its prepared payload cannot be confused with another revision or identity. For example, the `code-initial` plan entry uses `/fixtures/code-initial/source` and its prepared payload is `/fixtures/code-initial/VSCode-linux-x64`; `code-upgrade` uses a different `/fixtures/code-upgrade/` parent. The source root's real `product.json` must already name `code`, `code-insiders`, or `code-oss`; the tool only reads that branding and fails on a mismatch. It does not modify the live checkout's branding. `sourceKind: "prepared-payload"` means the prepared payload was supplied before package construction; `sourceKind: "source-build"` records that the disposable copy built it. In both cases the generator invokes the actual production `vscode-linux-<arch>-prepare-deb/build-deb` and `prepare-rpm/build-rpm` Gulp tasks, so it consumes the real templates, package file lists, `resources/linux/copilot-link.sh`, and build rendering callbacks. It copies generated DEB controls and RPM specs into `provenance/` beside the manifest for review.
|
||||
|
||||
From `test/sanity`, after compiling this package and preparing the disposable inputs:
|
||||
|
||||
```bash
|
||||
npm run compile
|
||||
npm run generate-copilot-shim-fixtures -- \
|
||||
--plan ./fixtures/copilot-shim-fixture-plan.json \
|
||||
--output ./artifacts/copilot-shim-fixtures
|
||||
```
|
||||
|
||||
This creates `./artifacts/copilot-shim-fixtures/copilot-shim-fixtures.json`. A `minimal-lifecycle` entry is generated only after the actual production prepare task; it trims the staged app payload while retaining the generated edition launcher, nested shim, and `chrome-sandbox` required by the generated RPM spec. It may accelerate lifecycle coverage, but it does **not** establish full product launcher behavior. Complete source-built initial and upgrade fixtures remain mandatory for current `code` and `code-insiders`: K1 explicitly installs both and invokes each nested edition launcher with `--version`, `--no-sandbox`, and a scenario-local `--user-data-dir` because the isolated package runner runs as root. The remaining lifecycle matrix prefers a matching minimal fixture when available and otherwise uses the complete fixture. Every available initial/upgrade payload-kind pair is checked for distinct, strictly increasing metadata.
|
||||
|
||||
### Running in an Isolated Package Runner
|
||||
|
||||
Use one disposable Debian-family runner and one disposable RPM-family runner. The exact focused commands are:
|
||||
|
||||
```bash
|
||||
npm run test-copilot-shim -- \
|
||||
--fixtures ./artifacts/copilot-shim-fixtures/copilot-shim-fixtures.json \
|
||||
--format deb \
|
||||
--grep 'DEB (K[1-9]|OSS|warns)'
|
||||
|
||||
npm run test-copilot-shim -- \
|
||||
--fixtures ./artifacts/copilot-shim-fixtures/copilot-shim-fixtures.json \
|
||||
--format rpm \
|
||||
--grep 'RPM (K[1-9]|OSS|warns)'
|
||||
```
|
||||
|
||||
The preferred container wrapper compiles no code and does not mount host `/usr` or `/etc`; it mounts only the read-only sanity workspace, read-only fixture bundle, and a retained evidence directory:
|
||||
|
||||
```bash
|
||||
bash scripts/run-copilot-shim-container.sh \
|
||||
--container ubuntu \
|
||||
--format deb \
|
||||
--fixtures ./artifacts/copilot-shim-fixtures/copilot-shim-fixtures.json \
|
||||
--grep 'DEB (K[1-9]|OSS|warns)'
|
||||
|
||||
bash scripts/run-copilot-shim-container.sh \
|
||||
--container fedora \
|
||||
--format rpm \
|
||||
--fixtures ./artifacts/copilot-shim-fixtures/copilot-shim-fixtures.json \
|
||||
--grep 'RPM (K[1-9]|OSS|warns)'
|
||||
```
|
||||
|
||||
The wrapper invokes `scripts/provision-copilot-shim-runner.sh` before the package suite and retains diagnostics under `artifacts/copilot-shim-evidence/<runner-id>/` if a test fails. For a VM or another runner launcher, set the marker variables described above, run the provisioning script inside that disposable root filesystem, and then run the corresponding package-local command. Never run the suite directly against a developer or CI host image that contains user packages, policy, `/usr`, or `/etc` state.
|
||||
|
||||
The suite inspects actual DEB control scripts and actual RPM `rpm -qp --scripts` output after macro expansion, compares the embedded helper byte-for-byte with the provenance source, checks unresolved markers, literal targets, and lifecycle call sites, and verifies `/usr/bin/copilot` is absent from package payload/file lists. During transactions it checks nested payload ownership with `dpkg-query`/`rpm -qf`, verifies the shared path has no package owner except in the deliberate foreign-owner fixture, records real helper mutation order through narrowly scoped wrappers for `/usr/bin/copilot` and `copilot.vscode-copilot.*` staging paths, and blocks `curl`/`wget` to prove package scripts did not install or download the real Copilot CLI. It also seeds an independent CLI and user-data sentinel in the test-owned home and verifies both remain unchanged.
|
||||
|
||||
| Acceptance coverage | Focused assertions |
|
||||
| --- | --- |
|
||||
| K1–K2 | Fresh absence, exact symlink creation, cross-edition takeover, payload/database ownership, launcher behavior |
|
||||
| K3 | Separate and batched upgrades in both input orders; final owner equals the last observed successful helper hook, never merely either edition |
|
||||
| K4–K6 | Non-owner removal, usable/missing/non-executable/older-no-shim fallback, and both owner-first removal orders |
|
||||
| K7–K8 | Exact recognized dangling repair; foreign regular/valid/dangling links; relative and indirect healthy links; foreign package ownership; administrator replacement |
|
||||
| K9 | Reconfigure/reinstall, remove/purge or repeated RPM erase handling, old-version cleanup, OSS and unrecognized-helper no-op behavior, launcher and independent data preservation |
|
||||
| Warning recovery | Injected helper creation, replacement, and final-removal failures must warn while the package transaction/configured state succeeds; a later transaction repairs the command |
|
||||
|
||||
The focused suite is an implementation gate for source-built fixtures, not an official-artifact release gate. It does not validate Snap/archive exposure, image-based Fedora variants, or a publisher-signed artifact. Run ordinary artifact-backed desktop sanity separately once changed official DEB/RPM artifacts are available.
|
||||
|
||||
## Containers
|
||||
|
||||
Docker container definitions are provided in the `containers/` directory for testing on various Linux distributions:
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"fixtureSetId": "copilot-shim-source-built-x64",
|
||||
"architecture": "x64",
|
||||
"fixtures": [
|
||||
{
|
||||
"id": "code-initial",
|
||||
"sourceRoot": "/fixtures/code-initial/source",
|
||||
"sourceRevision": "SOURCE_COMMIT_FOR_CODE_INITIAL",
|
||||
"sourceKind": "prepared-payload",
|
||||
"identity": "code",
|
||||
"role": "initial",
|
||||
"revisionOrdinal": 1,
|
||||
"payloadKind": "complete",
|
||||
"formats": ["deb", "rpm"],
|
||||
"fixtureAdjustments": []
|
||||
},
|
||||
{
|
||||
"id": "code-upgrade",
|
||||
"sourceRoot": "/fixtures/code-upgrade/source",
|
||||
"sourceRevision": "SOURCE_COMMIT_FOR_CODE_UPGRADE",
|
||||
"sourceKind": "prepared-payload",
|
||||
"identity": "code",
|
||||
"role": "upgrade",
|
||||
"revisionOrdinal": 2,
|
||||
"payloadKind": "complete",
|
||||
"formats": ["deb", "rpm"],
|
||||
"fixtureAdjustments": ["Package version/revision is deliberately newer than code-initial."]
|
||||
},
|
||||
{
|
||||
"id": "code-legacy",
|
||||
"sourceRoot": "/fixtures/code-legacy/source",
|
||||
"sourceRevision": "SOURCE_COMMIT_BEFORE_COPILOT_SHIM",
|
||||
"sourceKind": "prepared-payload",
|
||||
"identity": "code",
|
||||
"role": "legacy-no-shim",
|
||||
"revisionOrdinal": 1,
|
||||
"payloadKind": "complete",
|
||||
"formats": ["deb", "rpm"],
|
||||
"fixtureAdjustments": ["Deliberately uses an older payload with no bin/copilot-shim/copilot."]
|
||||
},
|
||||
{
|
||||
"id": "code-insiders-initial",
|
||||
"sourceRoot": "/fixtures/code-insiders-initial/source",
|
||||
"sourceRevision": "SOURCE_COMMIT_FOR_CODE_INSIDERS_INITIAL",
|
||||
"sourceKind": "prepared-payload",
|
||||
"identity": "code-insiders",
|
||||
"role": "initial",
|
||||
"revisionOrdinal": 1,
|
||||
"payloadKind": "complete",
|
||||
"formats": ["deb", "rpm"],
|
||||
"fixtureAdjustments": []
|
||||
},
|
||||
{
|
||||
"id": "code-insiders-upgrade",
|
||||
"sourceRoot": "/fixtures/code-insiders-upgrade/source",
|
||||
"sourceRevision": "SOURCE_COMMIT_FOR_CODE_INSIDERS_UPGRADE",
|
||||
"sourceKind": "prepared-payload",
|
||||
"identity": "code-insiders",
|
||||
"role": "upgrade",
|
||||
"revisionOrdinal": 2,
|
||||
"payloadKind": "complete",
|
||||
"formats": ["deb", "rpm"],
|
||||
"fixtureAdjustments": ["Package version/revision is deliberately newer than code-insiders-initial."]
|
||||
},
|
||||
{
|
||||
"id": "code-insiders-legacy",
|
||||
"sourceRoot": "/fixtures/code-insiders-legacy/source",
|
||||
"sourceRevision": "SOURCE_COMMIT_BEFORE_COPILOT_SHIM",
|
||||
"sourceKind": "prepared-payload",
|
||||
"identity": "code-insiders",
|
||||
"role": "legacy-no-shim",
|
||||
"revisionOrdinal": 1,
|
||||
"payloadKind": "complete",
|
||||
"formats": ["deb", "rpm"],
|
||||
"fixtureAdjustments": ["Deliberately uses an older payload with no bin/copilot-shim/copilot."]
|
||||
},
|
||||
{
|
||||
"id": "code-oss-initial",
|
||||
"sourceRoot": "/fixtures/code-oss-initial/source",
|
||||
"sourceRevision": "SOURCE_COMMIT_FOR_CODE_OSS_INITIAL",
|
||||
"sourceKind": "prepared-payload",
|
||||
"identity": "code-oss",
|
||||
"role": "oss-initial",
|
||||
"revisionOrdinal": 1,
|
||||
"payloadKind": "complete",
|
||||
"formats": ["deb", "rpm"],
|
||||
"fixtureAdjustments": ["Negative fixture: its generated lifecycle calls must be a no-op for code-oss."]
|
||||
},
|
||||
{
|
||||
"id": "code-oss-upgrade",
|
||||
"sourceRoot": "/fixtures/code-oss-upgrade/source",
|
||||
"sourceRevision": "SOURCE_COMMIT_FOR_CODE_OSS_UPGRADE",
|
||||
"sourceKind": "prepared-payload",
|
||||
"identity": "code-oss",
|
||||
"role": "oss-upgrade",
|
||||
"revisionOrdinal": 2,
|
||||
"payloadKind": "complete",
|
||||
"formats": ["deb", "rpm"],
|
||||
"fixtureAdjustments": ["Package version/revision is deliberately newer than code-oss-initial."]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "VS Code Linux Copilot shim package lifecycle fixtures",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "fixtureSetId", "generatedAt", "helper", "fixtures"],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": 1 },
|
||||
"fixtureSetId": { "type": "string", "minLength": 1 },
|
||||
"generatedAt": { "type": "string", "format": "date-time" },
|
||||
"helper": {
|
||||
"type": "object",
|
||||
"required": ["sourcePath", "sha256", "functionName", "templateMarker"],
|
||||
"properties": {
|
||||
"sourcePath": { "type": "string", "minLength": 1 },
|
||||
"sha256": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" },
|
||||
"functionName": { "const": "vscode_copilot_link" },
|
||||
"templateMarker": { "const": "@@COPILOT_LINK_HELPER@@" }
|
||||
}
|
||||
},
|
||||
"fixtures": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "format", "architecture", "identity", "role", "packageName", "packageVersion", "revisionOrdinal", "packagePath", "payload", "provenance"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"format": { "enum": ["deb", "rpm"] },
|
||||
"architecture": { "type": "string", "minLength": 1 },
|
||||
"identity": { "enum": ["code", "code-insiders", "code-oss", "foreign-copilot-owner"] },
|
||||
"role": { "enum": ["initial", "upgrade", "legacy-no-shim", "oss-initial", "oss-upgrade", "foreign-owner"] },
|
||||
"packageName": { "type": "string", "minLength": 1 },
|
||||
"packageVersion": { "type": "string", "minLength": 1 },
|
||||
"revisionOrdinal": { "type": "integer", "minimum": 1 },
|
||||
"packagePath": { "type": "string", "minLength": 1 },
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"required": ["kind", "hasCopilotShim"],
|
||||
"properties": {
|
||||
"kind": { "enum": ["complete", "minimal-lifecycle"] },
|
||||
"hasCopilotShim": { "type": "boolean" },
|
||||
"launcherPath": { "type": "string" },
|
||||
"shimPath": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"type": "object",
|
||||
"required": ["kind", "sourceRevision", "generationCommand", "templatePaths", "fixtureAdjustments"],
|
||||
"properties": {
|
||||
"kind": { "enum": ["source-build", "prepared-payload", "test-owned-foreign-package"] },
|
||||
"sourceRevision": { "type": "string", "minLength": 1 },
|
||||
"generationCommand": { "type": "string", "minLength": 1 },
|
||||
"templatePaths": { "type": "array", "items": { "type": "string" } },
|
||||
"fixtureAdjustments": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@
|
||||
"main": "./out/index.js",
|
||||
"scripts": {
|
||||
"compile": "tsc",
|
||||
"start": "node ./out/index.js"
|
||||
"start": "node ./out/index.js",
|
||||
"test-copilot-shim": "node ./out/copilotShimIndex.js",
|
||||
"test-copilot-shim-rendering": "node ./out/copilotShimRenderingIndex.js",
|
||||
"test-copilot-shim-helper": "node ./out/copilotShimHelperIndex.js",
|
||||
"generate-copilot-shim-fixtures": "node ./out/generateCopilotShimFixtures.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
|
||||
set -eu
|
||||
|
||||
purpose='vscode-copilot-shim-package-lifecycle'
|
||||
marker_root="/run/$purpose"
|
||||
state_root="/var/lib/$purpose"
|
||||
usr_probe_root="/usr/share/$purpose"
|
||||
etc_probe_root="/etc/$purpose"
|
||||
|
||||
fail() {
|
||||
echo "Error: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ "$(uname -s)" = Linux ] || fail 'Copilot shim package lifecycle provisioning requires Linux.'
|
||||
[ "$(id -u)" -eq 0 ] || fail 'Copilot shim package lifecycle provisioning requires root inside a disposable runner.'
|
||||
[ "${VSCODE_COPILOT_SHIM_DISPOSABLE_RUNNER:-}" = 1 ] || fail 'Set VSCODE_COPILOT_SHIM_DISPOSABLE_RUNNER=1 only in a disposable container or VM.'
|
||||
|
||||
runner_id=${VSCODE_COPILOT_SHIM_RUNNER_ID:-}
|
||||
case "$runner_id" in
|
||||
''|*[!A-Za-z0-9_-]*)
|
||||
fail 'VSCODE_COPILOT_SHIM_RUNNER_ID must contain only letters, digits, underscores, and dashes.'
|
||||
;;
|
||||
esac
|
||||
|
||||
assert_not_bind_mounted() {
|
||||
mount_path=$1
|
||||
if ! awk -v mount_path="$mount_path" '$5 == mount_path && $4 != "/" { bad = 1 } END { exit bad }' /proc/self/mountinfo; then
|
||||
fail "$mount_path is a bind or subdirectory mount. Do not mount host /usr or /etc into this runner."
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_bind_mounted /usr
|
||||
assert_not_bind_mounted /etc
|
||||
|
||||
marker_directory="$marker_root/$runner_id"
|
||||
marker_path="$marker_directory/marker.json"
|
||||
work_root="$state_root/$runner_id/work"
|
||||
usr_probe="$usr_probe_root/$runner_id"
|
||||
etc_probe="$etc_probe_root/$runner_id"
|
||||
evidence_root=${VSCODE_COPILOT_SHIM_EVIDENCE_DIR:-"$work_root/evidence"}
|
||||
|
||||
[ ! -e "$marker_path" ] || fail "Runner marker already exists: $marker_path. Provision a new disposable runner instead of reusing state."
|
||||
case "${VSCODE_COPILOT_SHIM_RUNNER_MARKER:-$marker_path}" in
|
||||
"$marker_path") ;;
|
||||
*) fail "VSCODE_COPILOT_SHIM_RUNNER_MARKER must be $marker_path." ;;
|
||||
esac
|
||||
|
||||
umask 077
|
||||
mkdir -p "$marker_directory" "$work_root" "$usr_probe" "$etc_probe" "$evidence_root"
|
||||
mount_namespace=$(readlink /proc/self/ns/mnt)
|
||||
|
||||
printf '{\n\t"schemaVersion": 1,\n\t"purpose": "%s",\n\t"runnerId": "%s",\n\t"mountNamespace": "%s",\n\t"workRoot": "%s",\n\t"evidenceRoot": "%s",\n\t"usrProbe": "%s",\n\t"etcProbe": "%s"\n}\n' \
|
||||
"$purpose" "$runner_id" "$mount_namespace" "$work_root" "$evidence_root" "$usr_probe" "$etc_probe" > "$marker_path"
|
||||
|
||||
echo "Provisioned disposable Copilot shim package-lifecycle runner marker: $marker_path"
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
|
||||
set -eu
|
||||
|
||||
container=
|
||||
format=
|
||||
fixtures=
|
||||
grep=
|
||||
arch=amd64
|
||||
timeout=
|
||||
registry='vscodehub.azurecr.io/vscode-linux-build-agent/sanity-tests'
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: run-copilot-shim-container.sh --container <ubuntu|fedora|...> --format <deb|rpm> --fixtures <manifest> --grep <selector> [--arch <amd64|arm64>] [--timeout <seconds>]
|
||||
|
||||
Runs the focused Copilot shim package-lifecycle suite in a new container. It mounts only the
|
||||
sanity workspace, fixture bundle, and a retained evidence directory; it never mounts host /usr or /etc.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--container) container=$2; shift 2 ;;
|
||||
--format) format=$2; shift 2 ;;
|
||||
--fixtures) fixtures=$2; shift 2 ;;
|
||||
--grep) grep=$2; shift 2 ;;
|
||||
--arch) arch=$2; shift 2 ;;
|
||||
--timeout) timeout=$2; shift 2 ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) echo "Error: unknown argument $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$container" ] || { echo 'Error: --container is required.' >&2; exit 1; }
|
||||
[ "$format" = deb ] || [ "$format" = rpm ] || { echo 'Error: --format must be deb or rpm.' >&2; exit 1; }
|
||||
[ -n "$fixtures" ] || { echo 'Error: --fixtures is required.' >&2; exit 1; }
|
||||
[ -n "$grep" ] || { echo 'Error: --grep is required.' >&2; exit 1; }
|
||||
[ -f "$fixtures" ] || { echo "Error: fixture manifest does not exist: $fixtures" >&2; exit 1; }
|
||||
|
||||
script_directory=$(cd "$(dirname "$0")" && pwd)
|
||||
sanity_root=$(cd "$script_directory/.." && pwd)
|
||||
fixture_manifest=$(cd "$(dirname "$fixtures")" && pwd)/$(basename "$fixtures")
|
||||
fixture_root=$(dirname "$fixture_manifest")
|
||||
runner_id="copilot-shim-$(date +%s)-$$"
|
||||
marker="/run/vscode-copilot-shim-package-lifecycle/$runner_id/marker.json"
|
||||
evidence_directory="$sanity_root/artifacts/copilot-shim-evidence/$runner_id"
|
||||
image="$registry:$container-$arch"
|
||||
|
||||
mkdir -p "$evidence_directory"
|
||||
|
||||
docker pull --platform "linux/$arch" "$image"
|
||||
docker_arguments=(
|
||||
--rm
|
||||
--platform "linux/$arch"
|
||||
--mount "type=bind,source=$sanity_root,target=/workspace,readonly"
|
||||
--mount "type=bind,source=$fixture_root,target=/fixtures,readonly"
|
||||
--mount "type=bind,source=$evidence_directory,target=/evidence"
|
||||
--env VSCODE_COPILOT_SHIM_DISPOSABLE_RUNNER=1
|
||||
--env VSCODE_COPILOT_SHIM_RUNNER_ID="$runner_id"
|
||||
--env VSCODE_COPILOT_SHIM_RUNNER_MARKER="$marker"
|
||||
--env VSCODE_COPILOT_SHIM_EVIDENCE_DIR=/evidence
|
||||
--env "VSCODE_COPILOT_SHIM_FIXTURES=/fixtures/$(basename "$fixture_manifest")"
|
||||
--env "VSCODE_COPILOT_SHIM_FORMAT=$format"
|
||||
--env "VSCODE_COPILOT_SHIM_GREP=$grep"
|
||||
)
|
||||
if [ -n "$timeout" ]; then
|
||||
docker_arguments+=(--env "VSCODE_COPILOT_SHIM_TIMEOUT=$timeout")
|
||||
fi
|
||||
|
||||
docker run "${docker_arguments[@]}" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" \
|
||||
-c 'set -eu
|
||||
bash /workspace/scripts/provision-copilot-shim-runner.sh
|
||||
if [ -n "${VSCODE_COPILOT_SHIM_TIMEOUT:-}" ]; then
|
||||
exec node /workspace/out/copilotShimIndex.js \
|
||||
--fixtures "$VSCODE_COPILOT_SHIM_FIXTURES" \
|
||||
--format "$VSCODE_COPILOT_SHIM_FORMAT" \
|
||||
--grep "$VSCODE_COPILOT_SHIM_GREP" \
|
||||
--timeout "$VSCODE_COPILOT_SHIM_TIMEOUT"
|
||||
fi
|
||||
exec node /workspace/out/copilotShimIndex.js \
|
||||
--fixtures "$VSCODE_COPILOT_SHIM_FIXTURES" \
|
||||
--format "$VSCODE_COPILOT_SHIM_FORMAT" \
|
||||
--grep "$VSCODE_COPILOT_SHIM_GREP"'
|
||||
@@ -0,0 +1,419 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { after, afterEach, before, beforeEach, describe, test } from 'mocha';
|
||||
import {
|
||||
CopilotShimFixtureRole,
|
||||
CopilotShimIdentity,
|
||||
CopilotShimPayloadKind,
|
||||
getCopilotShimFixture,
|
||||
getPreferredCopilotShimLifecycleFixture,
|
||||
ICopilotShimFixture,
|
||||
} from './copilotShimFixtures.js';
|
||||
import { CopilotShimPackageHarness } from './copilotShimHarness.js';
|
||||
import { getCopilotShimTestRuntime } from './copilotShimRuntime.js';
|
||||
|
||||
const runtime = getCopilotShimTestRuntime();
|
||||
const formatLabel = runtime.format.toUpperCase();
|
||||
const brandedIdentities = ['code', 'code-insiders'] as const;
|
||||
|
||||
describe(`Copilot shim ${formatLabel} package lifecycle`, () => {
|
||||
let harness: CopilotShimPackageHarness;
|
||||
let harnessInitialized = false;
|
||||
let scenarioStarted = false;
|
||||
|
||||
before(() => {
|
||||
harness = CopilotShimPackageHarness.create(runtime.fixtures, runtime.format);
|
||||
harnessInitialized = true;
|
||||
harness.inspectFixtureArtifacts();
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
harness.startScenario(this.currentTest?.title ?? 'unnamed-scenario');
|
||||
scenarioStarted = true;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (!scenarioStarted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
harness.finishScenario(this.currentTest?.state === 'passed');
|
||||
} finally {
|
||||
scenarioStarted = false;
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (harnessInitialized) {
|
||||
harness.cleanupArtifactInspection();
|
||||
}
|
||||
});
|
||||
|
||||
for (const identity of brandedIdentities) {
|
||||
test(`${formatLabel} K1 creates an unowned shared link for ${identity}`, () => {
|
||||
const initial = fixture(identity, 'initial', 'complete');
|
||||
install(initial);
|
||||
harness.assertSharedLink(identity);
|
||||
harness.assertInstalledFixtureOwnership(initial);
|
||||
harness.assertLauncherBehavior(initial);
|
||||
|
||||
const upgradeFixture = fixture(identity, 'upgrade', 'complete');
|
||||
upgrade(upgradeFixture);
|
||||
harness.assertSharedLink(identity);
|
||||
harness.assertInstalledFixtureOwnership(upgradeFixture);
|
||||
harness.assertLauncherBehavior(upgradeFixture);
|
||||
});
|
||||
}
|
||||
|
||||
for (const first of brandedIdentities) {
|
||||
test(`${formatLabel} K2 lets ${otherEdition(first)} take over ${first}`, () => {
|
||||
const second = otherEdition(first);
|
||||
install(fixture(first, 'initial'));
|
||||
install(fixture(second, 'initial'));
|
||||
harness.assertSharedLink(second);
|
||||
});
|
||||
}
|
||||
|
||||
for (const upgraded of brandedIdentities) {
|
||||
test(`${formatLabel} K3 lets upgraded ${upgraded} take over from ${otherEdition(upgraded)}`, () => {
|
||||
const other = otherEdition(upgraded);
|
||||
install(fixture(upgraded, 'initial'));
|
||||
install(fixture(other, 'initial'));
|
||||
harness.assertSharedLink(other);
|
||||
upgrade(fixture(upgraded, 'upgrade'));
|
||||
harness.assertSharedLink(upgraded);
|
||||
harness.assertInstalledPackageVersion(fixture(upgraded, 'upgrade'));
|
||||
});
|
||||
}
|
||||
|
||||
for (const upgradeOrder of [
|
||||
['code', 'code-insiders'],
|
||||
['code-insiders', 'code'],
|
||||
] as const) {
|
||||
test(`${formatLabel} K3 sequential upgrades take over in ${upgradeOrder.join(' then ')} order`, () => {
|
||||
const [first, second] = upgradeOrder;
|
||||
install(fixture(first, 'initial'));
|
||||
install(fixture(second, 'initial'));
|
||||
harness.assertSharedLink(second);
|
||||
upgrade(fixture(first, 'upgrade'));
|
||||
harness.assertSharedLink(first);
|
||||
upgrade(fixture(second, 'upgrade'));
|
||||
harness.assertSharedLink(second);
|
||||
});
|
||||
}
|
||||
|
||||
for (const inputOrder of [
|
||||
['code', 'code-insiders'],
|
||||
['code-insiders', 'code'],
|
||||
] as const) {
|
||||
test(`${formatLabel} K3 batch install records actual hook order for ${inputOrder.join(' then ')}`, () => {
|
||||
const result = harness.packageBatch(inputOrder.map(identity => fixture(identity, 'initial')), 'install');
|
||||
for (const identity of inputOrder) {
|
||||
harness.assertPackageOperation(result, fixture(identity, 'initial'), true);
|
||||
}
|
||||
harness.assertFinalLinkMatchesLastSuccessfulHook();
|
||||
});
|
||||
|
||||
test(`${formatLabel} K3 batch upgrade records actual hook order for ${inputOrder.join(' then ')}`, () => {
|
||||
for (const identity of brandedIdentities) {
|
||||
install(fixture(identity, 'initial'));
|
||||
}
|
||||
harness.clearHookObservation();
|
||||
const result = harness.packageBatch(inputOrder.map(identity => fixture(identity, 'upgrade')), 'upgrade');
|
||||
for (const identity of inputOrder) {
|
||||
harness.assertPackageOperation(result, fixture(identity, 'upgrade'), true);
|
||||
harness.assertInstalledPackageVersion(fixture(identity, 'upgrade'));
|
||||
}
|
||||
harness.assertFinalLinkMatchesLastSuccessfulHook();
|
||||
});
|
||||
}
|
||||
|
||||
for (const owner of brandedIdentities) {
|
||||
test(`${formatLabel} K4 preserves ${owner} when ${otherEdition(owner)} is removed`, () => {
|
||||
const nonOwner = otherEdition(owner);
|
||||
install(fixture(nonOwner, 'initial'));
|
||||
install(fixture(owner, 'initial'));
|
||||
remove(fixture(nonOwner, 'initial'));
|
||||
harness.assertSharedLink(owner);
|
||||
});
|
||||
}
|
||||
|
||||
for (const owner of brandedIdentities) {
|
||||
test(`${formatLabel} K5 falls back from ${owner} to a usable ${otherEdition(owner)} shim`, () => {
|
||||
const fallback = otherEdition(owner);
|
||||
install(fixture(fallback, 'initial'));
|
||||
install(fixture(owner, 'initial'));
|
||||
remove(fixture(owner, 'initial'));
|
||||
harness.assertSharedLink(fallback);
|
||||
});
|
||||
}
|
||||
|
||||
for (const unavailableKind of ['missing', 'non-executable'] as const) {
|
||||
for (const owner of brandedIdentities) {
|
||||
test(`${formatLabel} K5 removes ${owner} when ${otherEdition(owner)} is ${unavailableKind}`, () => {
|
||||
const fallback = otherEdition(owner);
|
||||
install(fixture(fallback, 'initial'));
|
||||
install(fixture(owner, 'initial'));
|
||||
harness.makeShimUnavailable(fallback, unavailableKind);
|
||||
remove(fixture(owner, 'initial'));
|
||||
harness.assertNoSharedCommand();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const owner of brandedIdentities) {
|
||||
test(`${formatLabel} K5 rejects an older ${otherEdition(owner)} package with no shim as fallback`, () => {
|
||||
const oldEdition = otherEdition(owner);
|
||||
install(fixture(owner, 'initial'));
|
||||
install(fixture(oldEdition, 'legacy-no-shim'));
|
||||
remove(fixture(owner, 'initial'));
|
||||
harness.assertNoSharedCommand();
|
||||
});
|
||||
}
|
||||
|
||||
for (const installationOrder of [
|
||||
['code', 'code-insiders'],
|
||||
['code-insiders', 'code'],
|
||||
] as const) {
|
||||
test(`${formatLabel} K6 removes both editions cleanly after owner-first removal (${installationOrder.join(' then ')})`, () => {
|
||||
const [fallback, owner] = installationOrder;
|
||||
install(fixture(fallback, 'initial'));
|
||||
install(fixture(owner, 'initial'));
|
||||
remove(fixture(owner, 'initial'));
|
||||
harness.assertSharedLink(fallback);
|
||||
remove(fixture(fallback, 'initial'));
|
||||
harness.assertNoSharedCommand();
|
||||
});
|
||||
}
|
||||
|
||||
for (const owner of brandedIdentities) {
|
||||
test(`${formatLabel} K6 removes both editions cleanly after non-owner-first removal (${otherEdition(owner)} then ${owner})`, () => {
|
||||
const nonOwner = otherEdition(owner);
|
||||
install(fixture(nonOwner, 'initial'));
|
||||
install(fixture(owner, 'initial'));
|
||||
remove(fixture(nonOwner, 'initial'));
|
||||
harness.assertSharedLink(owner);
|
||||
remove(fixture(owner, 'initial'));
|
||||
harness.assertNoSharedCommand();
|
||||
});
|
||||
}
|
||||
|
||||
for (const targetOwner of brandedIdentities) {
|
||||
test(`${formatLabel} K7 repairs a dangling ${targetOwner} target with ${otherEdition(targetOwner)}`, () => {
|
||||
const installingEdition = otherEdition(targetOwner);
|
||||
harness.seedRecognizedDanglingLink(targetOwner);
|
||||
install(fixture(installingEdition, 'initial'));
|
||||
harness.assertSharedLink(installingEdition);
|
||||
});
|
||||
}
|
||||
|
||||
for (const foreignEntry of [
|
||||
{ name: 'regular executable', seed: () => harness.seedForeignRegularCommand() },
|
||||
{ name: 'valid absolute link', seed: () => harness.seedForeignAbsoluteLink(false) },
|
||||
{ name: 'dangling absolute link', seed: () => harness.seedForeignAbsoluteLink(true) },
|
||||
]) {
|
||||
test(`${formatLabel} K8 preserves a foreign ${foreignEntry.name}`, () => {
|
||||
const snapshot = foreignEntry.seed();
|
||||
install(fixture('code', 'initial'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
install(fixture('code-insiders', 'initial'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
upgrade(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
upgrade(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
remove(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
remove(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
test(`${formatLabel} K8 preserves relative and indirect healthy links to a recognized shim`, () => {
|
||||
install(fixture('code', 'initial'));
|
||||
const relative = harness.seedRelativeRecognizedLink('code');
|
||||
install(fixture('code-insiders', 'initial'));
|
||||
harness.assertEntryUnchanged(relative);
|
||||
upgrade(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(relative);
|
||||
upgrade(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertEntryUnchanged(relative);
|
||||
remove(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertEntryUnchanged(relative);
|
||||
remove(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(relative);
|
||||
|
||||
harness.clearTestOwnedSharedCommand();
|
||||
install(fixture('code', 'initial'));
|
||||
const indirect = harness.seedIndirectRecognizedLink('code');
|
||||
install(fixture('code-insiders', 'initial'));
|
||||
harness.assertEntryUnchanged(indirect);
|
||||
upgrade(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(indirect);
|
||||
upgrade(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertEntryUnchanged(indirect);
|
||||
remove(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(indirect);
|
||||
remove(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertEntryUnchanged(indirect);
|
||||
});
|
||||
|
||||
test(`${formatLabel} K8 preserves a foreign command owned by another package`, () => {
|
||||
const foreign = fixture('foreign-copilot-owner', 'foreign-owner');
|
||||
install(foreign);
|
||||
const snapshot = harness.captureSharedCommandEntry();
|
||||
harness.assertFileOwnedByPackage('/usr/bin/copilot', foreign.packageName);
|
||||
install(fixture('code', 'initial'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
harness.assertFileOwnedByPackage('/usr/bin/copilot', foreign.packageName);
|
||||
install(fixture('code-insiders', 'initial'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
upgrade(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
harness.assertFileOwnedByPackage('/usr/bin/copilot', foreign.packageName);
|
||||
upgrade(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
harness.assertFileOwnedByPackage('/usr/bin/copilot', foreign.packageName);
|
||||
remove(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
remove(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
});
|
||||
|
||||
test(`${formatLabel} K8 preserves an administrator replacement after installation`, () => {
|
||||
install(fixture('code', 'initial'));
|
||||
const snapshot = harness.seedForeignRegularCommand();
|
||||
upgrade(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
remove(fixture('code', 'upgrade'));
|
||||
harness.assertEntryUnchanged(snapshot);
|
||||
});
|
||||
|
||||
test(`${formatLabel} K9 keeps repeated lifecycle calls safe and old-version cleanup from changing the selection`, () => {
|
||||
install(fixture('code-insiders', 'initial'));
|
||||
install(fixture('code', 'initial'));
|
||||
upgrade(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertSharedLink('code-insiders');
|
||||
|
||||
const reconfiguration = harness.reconfigure(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertPackageOperation(reconfiguration, fixture('code-insiders', 'upgrade'), true);
|
||||
harness.assertSharedLink('code-insiders');
|
||||
|
||||
remove(fixture('code', 'initial'));
|
||||
harness.assertSharedLink('code-insiders');
|
||||
remove(fixture('code-insiders', 'upgrade'));
|
||||
harness.assertNoSharedCommand();
|
||||
|
||||
if (runtime.format === 'deb') {
|
||||
const purge = harness.remove(fixture('code-insiders', 'upgrade'), true);
|
||||
harness.assertPackageOperation(purge, fixture('code-insiders', 'upgrade'), false);
|
||||
const repeatPurge = harness.remove(fixture('code-insiders', 'upgrade'), true);
|
||||
harness.assertPackageOperation(repeatPurge, fixture('code-insiders', 'upgrade'), false);
|
||||
} else {
|
||||
const repeatErase = harness.remove(fixture('code-insiders', 'upgrade'));
|
||||
assert.notStrictEqual(repeatErase.status, 0, 'RPM must report the already-erased package rather than rerunning a lifecycle script.');
|
||||
harness.assertNoSharedCommand();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${formatLabel} OSS K9 never creates or changes the shared command`, () => {
|
||||
const foreign = harness.seedForeignRegularCommand();
|
||||
install(fixture('code-oss', 'oss-initial'));
|
||||
harness.assertEntryUnchanged(foreign);
|
||||
upgrade(fixture('code-oss', 'oss-upgrade'));
|
||||
harness.assertEntryUnchanged(foreign);
|
||||
remove(fixture('code-oss', 'oss-upgrade'));
|
||||
harness.assertEntryUnchanged(foreign);
|
||||
});
|
||||
|
||||
test(`${formatLabel} OSS K9 leaves an absent shared command absent across install, upgrade, and removal`, () => {
|
||||
install(fixture('code-oss', 'oss-initial'));
|
||||
harness.assertNoSharedCommand();
|
||||
upgrade(fixture('code-oss', 'oss-upgrade'));
|
||||
harness.assertNoSharedCommand();
|
||||
remove(fixture('code-oss', 'oss-upgrade'));
|
||||
harness.assertNoSharedCommand();
|
||||
});
|
||||
|
||||
test(`${formatLabel} OSS K9 keeps the shared helper a no-op for an unrecognized application`, () => {
|
||||
harness.assertUnrecognizedApplicationNoOp('code-exploration');
|
||||
});
|
||||
|
||||
test(`${formatLabel} warns but configures when shared-link creation fails, then repairs on reinstall`, () => {
|
||||
const initial = fixture('code', 'initial');
|
||||
harness.injectMutationFailure('ln');
|
||||
const failedCreation = harness.package(initial);
|
||||
harness.assertPackageOperation(failedCreation, initial, true);
|
||||
harness.assertWarning(failedCreation, 'shared-link creation failure');
|
||||
harness.assertNoSharedCommand();
|
||||
|
||||
harness.clearMutationFailure();
|
||||
const repair = harness.package(initial, 'reinstall');
|
||||
harness.assertPackageOperation(repair, initial, true);
|
||||
harness.assertSharedLink('code');
|
||||
});
|
||||
|
||||
test(`${formatLabel} warns but configures when shared-link replacement fails, then repairs on reinstall`, () => {
|
||||
const stable = fixture('code', 'initial');
|
||||
const insiders = fixture('code-insiders', 'initial');
|
||||
install(stable);
|
||||
|
||||
harness.injectMutationFailure('mv');
|
||||
const failedReplacement = harness.package(insiders);
|
||||
harness.assertPackageOperation(failedReplacement, insiders, true);
|
||||
harness.assertWarning(failedReplacement, 'shared-link replacement failure');
|
||||
harness.assertSharedLink('code');
|
||||
|
||||
harness.clearMutationFailure();
|
||||
const repair = harness.package(insiders, 'reinstall');
|
||||
harness.assertPackageOperation(repair, insiders, true);
|
||||
harness.assertSharedLink('code-insiders');
|
||||
});
|
||||
|
||||
test(`${formatLabel} warns but removes the package when final shared-link removal fails, then repairs later`, () => {
|
||||
const stable = fixture('code', 'initial');
|
||||
install(stable);
|
||||
|
||||
harness.injectMutationFailure('rm');
|
||||
const failedRemoval = harness.remove(stable);
|
||||
harness.assertPackageOperation(failedRemoval, stable, false);
|
||||
harness.assertWarning(failedRemoval, 'shared-link removal failure');
|
||||
harness.assertSharedLinkText('code');
|
||||
|
||||
harness.clearMutationFailure();
|
||||
install(fixture('code-insiders', 'initial'));
|
||||
harness.assertSharedLink('code-insiders');
|
||||
});
|
||||
|
||||
function fixture(identity: CopilotShimIdentity, role: CopilotShimFixtureRole, payloadKind?: CopilotShimPayloadKind): ICopilotShimFixture {
|
||||
if (payloadKind) {
|
||||
return getCopilotShimFixture(runtime.fixtures, runtime.format, identity, role, payloadKind);
|
||||
}
|
||||
return getPreferredCopilotShimLifecycleFixture(runtime.fixtures, runtime.format, identity, role);
|
||||
}
|
||||
|
||||
function install(packageFixture: ICopilotShimFixture): void {
|
||||
const result = harness.package(packageFixture);
|
||||
harness.assertPackageOperation(result, packageFixture, true);
|
||||
harness.assertInstalledPackageVersion(packageFixture);
|
||||
}
|
||||
|
||||
function upgrade(packageFixture: ICopilotShimFixture): void {
|
||||
const result = harness.package(packageFixture, 'upgrade');
|
||||
harness.assertPackageOperation(result, packageFixture, true);
|
||||
harness.assertInstalledPackageVersion(packageFixture);
|
||||
}
|
||||
|
||||
function remove(packageFixture: ICopilotShimFixture): void {
|
||||
const result = harness.remove(packageFixture);
|
||||
harness.assertPackageOperation(result, packageFixture, false);
|
||||
}
|
||||
});
|
||||
|
||||
function otherEdition(identity: 'code' | 'code-insiders'): 'code' | 'code-insiders' {
|
||||
return identity === 'code' ? 'code-insiders' : 'code';
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const copilotShimTargets = {
|
||||
code: '/usr/share/code/bin/copilot-shim/copilot',
|
||||
'code-insiders': '/usr/share/code-insiders/bin/copilot-shim/copilot',
|
||||
} as const;
|
||||
|
||||
export const debPackageMetadataFormat = '${Package}\\n${Version}\\n${Architecture}\\n';
|
||||
export const copilotShimInstallHookLogEnvironmentVariable = 'VSCODE_COPILOT_SHIM_INSTALL_HOOK_LOG';
|
||||
export const copilotShimInstallHookInstrumentation = 'test-only-install-hook-order';
|
||||
|
||||
export type CopilotShimPackageFormat = 'deb' | 'rpm';
|
||||
export type CopilotShimIdentity = 'code' | 'code-insiders' | 'code-oss' | 'foreign-copilot-owner';
|
||||
export type CopilotShimFixtureRole = 'initial' | 'upgrade' | 'legacy-no-shim' | 'oss-initial' | 'oss-upgrade' | 'foreign-owner';
|
||||
export type CopilotShimPayloadKind = 'complete' | 'minimal-lifecycle';
|
||||
|
||||
export interface ICopilotShimFixturePayload {
|
||||
kind: CopilotShimPayloadKind;
|
||||
hasCopilotShim: boolean;
|
||||
launcherPath?: string;
|
||||
shimPath?: string;
|
||||
}
|
||||
|
||||
export interface ICopilotShimFixtureProvenance {
|
||||
kind: 'source-build' | 'prepared-payload' | 'test-owned-foreign-package';
|
||||
sourceRevision: string;
|
||||
generationCommand: string;
|
||||
templatePaths: readonly string[];
|
||||
fixtureAdjustments: readonly string[];
|
||||
}
|
||||
|
||||
export interface ICopilotShimFixture {
|
||||
id: string;
|
||||
format: CopilotShimPackageFormat;
|
||||
architecture: string;
|
||||
identity: CopilotShimIdentity;
|
||||
role: CopilotShimFixtureRole;
|
||||
packageName: string;
|
||||
packageVersion: string;
|
||||
revisionOrdinal: number;
|
||||
packagePath: string;
|
||||
payload: ICopilotShimFixturePayload;
|
||||
provenance: ICopilotShimFixtureProvenance;
|
||||
}
|
||||
|
||||
export interface ICopilotShimHelperProvenance {
|
||||
sourcePath: string;
|
||||
sha256: string;
|
||||
functionName: 'vscode_copilot_link';
|
||||
templateMarker: '@@COPILOT_LINK_HELPER@@';
|
||||
}
|
||||
|
||||
export interface ICopilotShimFixtureManifest {
|
||||
schemaVersion: 1;
|
||||
fixtureSetId: string;
|
||||
generatedAt: string;
|
||||
helper: ICopilotShimHelperProvenance;
|
||||
fixtures: readonly ICopilotShimFixture[];
|
||||
}
|
||||
|
||||
const currentIdentities: readonly CopilotShimIdentity[] = ['code', 'code-insiders'];
|
||||
const requiredProductionTemplatePaths = [
|
||||
'build/gulpfile.vscode.linux.ts',
|
||||
'resources/linux/copilot-link.sh',
|
||||
'resources/linux/debian/postinst.template',
|
||||
'resources/linux/debian/postrm.template',
|
||||
'resources/linux/rpm/code.spec.template',
|
||||
];
|
||||
|
||||
/**
|
||||
* Loads and validates a fixture manifest without invoking a package manager.
|
||||
*/
|
||||
export function loadCopilotShimFixtureManifest(manifestPath: string): ICopilotShimFixtureManifest {
|
||||
const absoluteManifestPath = path.resolve(manifestPath);
|
||||
const manifestDirectory = path.dirname(absoluteManifestPath);
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(absoluteManifestPath, 'utf8')) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to read Copilot shim fixture manifest ${absoluteManifestPath}: ${errorMessage(error)}`);
|
||||
}
|
||||
|
||||
const manifest = parseManifest(parsed, manifestDirectory);
|
||||
validateFixtureManifest(manifest, absoluteManifestPath);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the fixture selected for a package format, identity, and lifecycle role.
|
||||
*/
|
||||
export function getCopilotShimFixture(
|
||||
manifest: ICopilotShimFixtureManifest,
|
||||
format: CopilotShimPackageFormat,
|
||||
identity: CopilotShimIdentity,
|
||||
role: CopilotShimFixtureRole,
|
||||
preferredPayloadKind?: CopilotShimPayloadKind,
|
||||
): ICopilotShimFixture {
|
||||
const candidates = manifest.fixtures.filter(fixture =>
|
||||
fixture.format === format
|
||||
&& fixture.identity === identity
|
||||
&& fixture.role === role
|
||||
);
|
||||
|
||||
const fixture = preferredPayloadKind
|
||||
? candidates.find(candidate => candidate.payload.kind === preferredPayloadKind)
|
||||
: candidates.find(candidate => candidate.payload.kind === 'complete') ?? candidates[0];
|
||||
|
||||
if (!fixture) {
|
||||
throw new Error(`Fixture manifest is missing ${format}/${identity}/${role}${preferredPayloadKind ? `/${preferredPayloadKind}` : ''}.`);
|
||||
}
|
||||
|
||||
return fixture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the smallest available fixture for lifecycle coverage, preferring minimal payloads.
|
||||
*/
|
||||
export function getPreferredCopilotShimLifecycleFixture(
|
||||
manifest: ICopilotShimFixtureManifest,
|
||||
format: CopilotShimPackageFormat,
|
||||
identity: CopilotShimIdentity,
|
||||
role: CopilotShimFixtureRole,
|
||||
): ICopilotShimFixture {
|
||||
const candidates = manifest.fixtures.filter(fixture =>
|
||||
fixture.format === format
|
||||
&& fixture.identity === identity
|
||||
&& fixture.role === role
|
||||
);
|
||||
const fixture = candidates.find(candidate => candidate.payload.kind === 'minimal-lifecycle')
|
||||
?? candidates.find(candidate => candidate.payload.kind === 'complete')
|
||||
?? candidates[0];
|
||||
if (!fixture) {
|
||||
throw new Error(`Fixture manifest is missing ${format}/${identity}/${role}.`);
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all fixtures for one package format.
|
||||
*/
|
||||
export function getCopilotShimFixturesForFormat(
|
||||
manifest: ICopilotShimFixtureManifest,
|
||||
format: CopilotShimPackageFormat,
|
||||
): readonly ICopilotShimFixture[] {
|
||||
return manifest.fixtures.filter(fixture => fixture.format === format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies a current Stable or Insiders fixture that must embed lifecycle handling.
|
||||
*/
|
||||
export function isCurrentCopilotShimFixture(fixture: ICopilotShimFixture): boolean {
|
||||
return (fixture.identity === 'code' || fixture.identity === 'code-insiders')
|
||||
&& (fixture.role === 'initial' || fixture.role === 'upgrade');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the expected nested shim path for a supported Microsoft edition.
|
||||
*/
|
||||
export function getCopilotShimTarget(identity: 'code' | 'code-insiders'): string {
|
||||
return copilotShimTargets[identity];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SHA-256 digest of a fixture provenance file.
|
||||
*/
|
||||
export function sha256File(filePath: string): string {
|
||||
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||
}
|
||||
|
||||
function parseManifest(value: unknown, manifestDirectory: string): ICopilotShimFixtureManifest {
|
||||
const record = asRecord(value, 'fixture manifest');
|
||||
const schemaVersion = asNumber(record.schemaVersion, 'schemaVersion');
|
||||
if (schemaVersion !== 1) {
|
||||
throw new Error(`Unsupported Copilot shim fixture manifest schema version: ${schemaVersion}.`);
|
||||
}
|
||||
|
||||
const helperRecord = asRecord(record.helper, 'helper');
|
||||
const helper = {
|
||||
sourcePath: resolveFixturePath(asString(helperRecord.sourcePath, 'helper.sourcePath'), manifestDirectory, 'helper.sourcePath'),
|
||||
sha256: asString(helperRecord.sha256, 'helper.sha256'),
|
||||
functionName: asString(helperRecord.functionName, 'helper.functionName') as ICopilotShimHelperProvenance['functionName'],
|
||||
templateMarker: asString(helperRecord.templateMarker, 'helper.templateMarker') as ICopilotShimHelperProvenance['templateMarker'],
|
||||
};
|
||||
|
||||
const fixtureValues = asArray(record.fixtures, 'fixtures');
|
||||
const fixtures = fixtureValues.map((fixtureValue, index) => parseFixture(fixtureValue, manifestDirectory, `fixtures[${index}]`));
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
fixtureSetId: asString(record.fixtureSetId, 'fixtureSetId'),
|
||||
generatedAt: asString(record.generatedAt, 'generatedAt'),
|
||||
helper,
|
||||
fixtures,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFixture(value: unknown, manifestDirectory: string, propertyPath: string): ICopilotShimFixture {
|
||||
const record = asRecord(value, propertyPath);
|
||||
const payloadRecord = asRecord(record.payload, `${propertyPath}.payload`);
|
||||
const provenanceRecord = asRecord(record.provenance, `${propertyPath}.provenance`);
|
||||
|
||||
const payload: ICopilotShimFixturePayload = {
|
||||
kind: asPayloadKind(asString(payloadRecord.kind, `${propertyPath}.payload.kind`)),
|
||||
hasCopilotShim: asBoolean(payloadRecord.hasCopilotShim, `${propertyPath}.payload.hasCopilotShim`),
|
||||
launcherPath: asOptionalString(payloadRecord.launcherPath, `${propertyPath}.payload.launcherPath`),
|
||||
shimPath: asOptionalString(payloadRecord.shimPath, `${propertyPath}.payload.shimPath`),
|
||||
};
|
||||
|
||||
const provenance: ICopilotShimFixtureProvenance = {
|
||||
kind: asProvenanceKind(asString(provenanceRecord.kind, `${propertyPath}.provenance.kind`)),
|
||||
sourceRevision: asString(provenanceRecord.sourceRevision, `${propertyPath}.provenance.sourceRevision`),
|
||||
generationCommand: asString(provenanceRecord.generationCommand, `${propertyPath}.provenance.generationCommand`),
|
||||
templatePaths: asStringArray(provenanceRecord.templatePaths, `${propertyPath}.provenance.templatePaths`),
|
||||
fixtureAdjustments: asStringArray(provenanceRecord.fixtureAdjustments, `${propertyPath}.provenance.fixtureAdjustments`),
|
||||
};
|
||||
|
||||
return {
|
||||
id: asString(record.id, `${propertyPath}.id`),
|
||||
format: asPackageFormat(asString(record.format, `${propertyPath}.format`)),
|
||||
architecture: asString(record.architecture, `${propertyPath}.architecture`),
|
||||
identity: asIdentity(asString(record.identity, `${propertyPath}.identity`)),
|
||||
role: asFixtureRole(asString(record.role, `${propertyPath}.role`)),
|
||||
packageName: asString(record.packageName, `${propertyPath}.packageName`),
|
||||
packageVersion: asString(record.packageVersion, `${propertyPath}.packageVersion`),
|
||||
revisionOrdinal: asPositiveInteger(record.revisionOrdinal, `${propertyPath}.revisionOrdinal`),
|
||||
packagePath: resolveFixturePath(asString(record.packagePath, `${propertyPath}.packagePath`), manifestDirectory, `${propertyPath}.packagePath`),
|
||||
payload,
|
||||
provenance,
|
||||
};
|
||||
}
|
||||
|
||||
function validateFixtureManifest(manifest: ICopilotShimFixtureManifest, manifestPath: string): void {
|
||||
if (!manifest.fixtureSetId || manifest.fixtureSetId.includes('@@')) {
|
||||
throw new Error('fixtureSetId must be a non-placeholder value.');
|
||||
}
|
||||
if (Number.isNaN(Date.parse(manifest.generatedAt))) {
|
||||
throw new Error(`Fixture manifest generatedAt is not an ISO date: ${manifest.generatedAt}.`);
|
||||
}
|
||||
if (manifest.helper.functionName !== 'vscode_copilot_link') {
|
||||
throw new Error(`Fixture helper function must be vscode_copilot_link, got ${manifest.helper.functionName}.`);
|
||||
}
|
||||
if (manifest.helper.templateMarker !== '@@COPILOT_LINK_HELPER@@') {
|
||||
throw new Error(`Fixture helper marker must be @@COPILOT_LINK_HELPER@@, got ${manifest.helper.templateMarker}.`);
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/i.test(manifest.helper.sha256)) {
|
||||
throw new Error('Fixture helper SHA-256 must be a 64-character hexadecimal digest.');
|
||||
}
|
||||
if (!fs.statSync(manifest.helper.sourcePath).isFile()) {
|
||||
throw new Error(`Fixture helper source is not a file: ${manifest.helper.sourcePath}.`);
|
||||
}
|
||||
if (sha256File(manifest.helper.sourcePath) !== manifest.helper.sha256) {
|
||||
throw new Error(`Fixture helper digest does not match manifest provenance: ${manifest.helper.sourcePath}.`);
|
||||
}
|
||||
if (manifest.fixtures.length === 0) {
|
||||
throw new Error('Fixture manifest contains no fixtures.');
|
||||
}
|
||||
|
||||
const fixtureIds = new Set<string>();
|
||||
const packagePaths = new Set<string>();
|
||||
for (const fixture of manifest.fixtures) {
|
||||
if (!fixture.id || fixture.id.includes('@@') || fixtureIds.has(fixture.id)) {
|
||||
throw new Error(`Fixture id must be unique and resolved: ${fixture.id || '(empty)'}.`);
|
||||
}
|
||||
fixtureIds.add(fixture.id);
|
||||
|
||||
if (fixture.packagePath.includes('@@') || packagePaths.has(fixture.packagePath)) {
|
||||
throw new Error(`Fixture package path must be unique and resolved: ${fixture.packagePath}.`);
|
||||
}
|
||||
packagePaths.add(fixture.packagePath);
|
||||
if (!fs.statSync(fixture.packagePath).isFile()) {
|
||||
throw new Error(`Fixture package does not exist or is not a file: ${fixture.packagePath}.`);
|
||||
}
|
||||
|
||||
if (fixture.identity === 'code' || fixture.identity === 'code-insiders' || fixture.identity === 'code-oss') {
|
||||
if (fixture.packageName !== fixture.identity) {
|
||||
throw new Error(`Fixture ${fixture.id} packageName must match its branded identity ${fixture.identity}, got ${fixture.packageName}.`);
|
||||
}
|
||||
}
|
||||
if (!fixture.packageVersion || fixture.packageVersion.includes('@@')) {
|
||||
throw new Error(`Fixture ${fixture.id} has an unresolved packageVersion.`);
|
||||
}
|
||||
if (!fixture.provenance.sourceRevision || fixture.provenance.sourceRevision.includes('@@')) {
|
||||
throw new Error(`Fixture ${fixture.id} has unresolved source provenance.`);
|
||||
}
|
||||
if ((fixture.provenance.kind === 'source-build' || fixture.provenance.kind === 'prepared-payload') && fixture.role !== 'legacy-no-shim') {
|
||||
for (const templatePath of requiredProductionTemplatePaths) {
|
||||
if (!fixture.provenance.templatePaths.includes(templatePath)) {
|
||||
throw new Error(`Fixture ${fixture.id} did not record the production template ${templatePath}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
validateFixturePayload(fixture);
|
||||
}
|
||||
|
||||
for (const format of ['deb', 'rpm'] as const) {
|
||||
validateFormatMatrix(manifest, format);
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(manifestPath)) {
|
||||
throw new Error(`Fixture manifest path must be absolute after loading: ${manifestPath}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFixturePayload(fixture: ICopilotShimFixture): void {
|
||||
if (isCurrentCopilotShimFixture(fixture)) {
|
||||
if (!fixture.payload.hasCopilotShim || !fixture.payload.shimPath || !fixture.payload.launcherPath) {
|
||||
throw new Error(`Current fixture ${fixture.id} must declare an executable shim and edition launcher.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fixture.role === 'legacy-no-shim' && fixture.payload.hasCopilotShim) {
|
||||
throw new Error(`Legacy fixture ${fixture.id} must not claim to include a Copilot shim.`);
|
||||
}
|
||||
if ((fixture.role === 'oss-initial' || fixture.role === 'oss-upgrade') && fixture.identity !== 'code-oss') {
|
||||
throw new Error(`OSS fixture ${fixture.id} must use the code-oss identity.`);
|
||||
}
|
||||
if (fixture.role === 'foreign-owner' && fixture.identity !== 'foreign-copilot-owner') {
|
||||
throw new Error(`Foreign-owner fixture ${fixture.id} must use the foreign-copilot-owner identity.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormatMatrix(manifest: ICopilotShimFixtureManifest, format: CopilotShimPackageFormat): void {
|
||||
const fixtures = manifest.fixtures.filter(fixture => fixture.format === format);
|
||||
if (fixtures.length === 0) {
|
||||
throw new Error(`Fixture manifest ${manifest.fixtureSetId} is missing the required ${format.toUpperCase()} fixture set.`);
|
||||
}
|
||||
|
||||
for (const identity of currentIdentities) {
|
||||
getPreferredCopilotShimLifecycleFixture(manifest, format, identity, 'legacy-no-shim');
|
||||
validateUpgradePair(manifest, format, identity, 'initial', 'upgrade', 'complete', true);
|
||||
validateUpgradePair(manifest, format, identity, 'initial', 'upgrade', 'minimal-lifecycle', false);
|
||||
}
|
||||
const hasOssFixturePair = (['complete', 'minimal-lifecycle'] as const)
|
||||
.map(payloadKind => validateUpgradePair(manifest, format, 'code-oss', 'oss-initial', 'oss-upgrade', payloadKind, false))
|
||||
.some(Boolean);
|
||||
if (!hasOssFixturePair) {
|
||||
throw new Error(`Fixture manifest ${manifest.fixtureSetId} is missing an ${format}/code-oss initial and upgrade fixture pair.`);
|
||||
}
|
||||
getCopilotShimFixture(manifest, format, 'foreign-copilot-owner', 'foreign-owner');
|
||||
}
|
||||
|
||||
function validateUpgradePair(
|
||||
manifest: ICopilotShimFixtureManifest,
|
||||
format: CopilotShimPackageFormat,
|
||||
identity: CopilotShimIdentity,
|
||||
initialRole: CopilotShimFixtureRole,
|
||||
upgradeRole: CopilotShimFixtureRole,
|
||||
payloadKind: CopilotShimPayloadKind,
|
||||
required: boolean,
|
||||
): boolean {
|
||||
const initial = manifest.fixtures.find(fixture => fixture.format === format && fixture.identity === identity && fixture.role === initialRole && fixture.payload.kind === payloadKind);
|
||||
const upgrade = manifest.fixtures.find(fixture => fixture.format === format && fixture.identity === identity && fixture.role === upgradeRole && fixture.payload.kind === payloadKind);
|
||||
if (!initial && !upgrade) {
|
||||
if (required) {
|
||||
throw new Error(`Fixture manifest ${manifest.fixtureSetId} is missing the required ${format}/${identity}/${initialRole}/${payloadKind} and ${upgradeRole}/${payloadKind} pair.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!initial || !upgrade) {
|
||||
throw new Error(`Fixture manifest ${manifest.fixtureSetId} must provide both ${format}/${identity}/${initialRole}/${payloadKind} and ${upgradeRole}/${payloadKind}.`);
|
||||
}
|
||||
if (upgrade.revisionOrdinal <= initial.revisionOrdinal || upgrade.packageVersion === initial.packageVersion) {
|
||||
throw new Error(`Fixture ${format}/${identity}/${payloadKind} upgrade must have a strictly higher declared revision than its initial fixture.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function asPackageFormat(value: string): CopilotShimPackageFormat {
|
||||
if (value === 'deb' || value === 'rpm') {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported package format: ${value}.`);
|
||||
}
|
||||
|
||||
function asIdentity(value: string): CopilotShimIdentity {
|
||||
if (value === 'code' || value === 'code-insiders' || value === 'code-oss' || value === 'foreign-copilot-owner') {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported fixture identity: ${value}.`);
|
||||
}
|
||||
|
||||
function asFixtureRole(value: string): CopilotShimFixtureRole {
|
||||
if (value === 'initial' || value === 'upgrade' || value === 'legacy-no-shim' || value === 'oss-initial' || value === 'oss-upgrade' || value === 'foreign-owner') {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported fixture role: ${value}.`);
|
||||
}
|
||||
|
||||
function asPayloadKind(value: string): CopilotShimPayloadKind {
|
||||
if (value === 'complete' || value === 'minimal-lifecycle') {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported fixture payload kind: ${value}.`);
|
||||
}
|
||||
|
||||
function asProvenanceKind(value: string): ICopilotShimFixtureProvenance['kind'] {
|
||||
if (value === 'source-build' || value === 'prepared-payload' || value === 'test-owned-foreign-package') {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported fixture provenance kind: ${value}.`);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, propertyPath: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${propertyPath} must be an object.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asArray(value: unknown, propertyPath: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`${propertyPath} must be an array.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asString(value: unknown, propertyPath: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${propertyPath} must be a non-empty string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown, propertyPath: string): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return asString(value, propertyPath);
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown, propertyPath: string): readonly string[] {
|
||||
return asArray(value, propertyPath).map((item, index) => asString(item, `${propertyPath}[${index}]`));
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, propertyPath: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error(`${propertyPath} must be a finite number.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asPositiveInteger(value: unknown, propertyPath: string): number {
|
||||
const number = asNumber(value, propertyPath);
|
||||
if (!Number.isInteger(number) || number < 1) {
|
||||
throw new Error(`${propertyPath} must be a positive integer.`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown, propertyPath: string): boolean {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`${propertyPath} must be a boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveFixturePath(value: string, manifestDirectory: string, propertyPath: string): string {
|
||||
const resolved = path.resolve(manifestDirectory, value);
|
||||
const relativePath = path.relative(manifestDirectory, resolved);
|
||||
if (relativePath === '' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
||||
throw new Error(`${propertyPath} must point to a file below the fixture manifest directory.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,186 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import { before, describe, test } from 'mocha';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const helperPath = path.join(repositoryRoot, 'resources/linux/copilot-link.sh');
|
||||
|
||||
type Edition = 'code' | 'code-insiders';
|
||||
|
||||
/**
|
||||
* What an edition's shim reports for `--vscode-shim policy`. `unknown` is an older shim without the command.
|
||||
*/
|
||||
type ShimPolicy = 'allowed' | 'disabled' | 'unknown';
|
||||
|
||||
type LinkState = Edition | 'foreign-file' | 'foreign-link' | 'none';
|
||||
|
||||
interface IScenario {
|
||||
readonly action: 'install' | 'remove';
|
||||
readonly edition: Edition;
|
||||
/** Installed shims and the policy each reports; an absent edition has no shim. */
|
||||
readonly shims: Partial<Record<Edition, ShimPolicy>>;
|
||||
readonly link?: LinkState;
|
||||
/** Simulates debconf: editions listed here answered false to add-copilot-command. Omit for RPM, which has none. */
|
||||
readonly declined?: readonly Edition[];
|
||||
}
|
||||
|
||||
interface IOutcome {
|
||||
readonly link: LinkState;
|
||||
readonly warned: boolean;
|
||||
}
|
||||
|
||||
const shimPolicyExitCodes: Record<ShimPolicy, number> = { allowed: 0, disabled: 10, unknown: 2 };
|
||||
|
||||
/**
|
||||
* Runs the shipped helper against stub shims and a temporary link path, then reports the resulting link.
|
||||
*/
|
||||
function runScenario(shell: string, scenario: IScenario): IOutcome {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-link-'));
|
||||
try {
|
||||
const targets: Record<Edition, string> = {
|
||||
'code': path.join(root, 'code', 'copilot'),
|
||||
'code-insiders': path.join(root, 'code-insiders', 'copilot'),
|
||||
};
|
||||
const linkPath = path.join(root, 'bin', 'copilot');
|
||||
fs.mkdirSync(path.dirname(linkPath));
|
||||
for (const edition of ['code', 'code-insiders'] as const) {
|
||||
const policy = scenario.shims[edition];
|
||||
if (policy) {
|
||||
fs.mkdirSync(path.dirname(targets[edition]));
|
||||
// The stub records any standard input it receives, which must never be the package manager's.
|
||||
fs.writeFileSync(targets[edition], `#!/bin/sh\n[ "$1 $2" = "--vscode-shim policy" ] || exit 99\ncat >> "${root}/stdin"\nexit ${shimPolicyExitCodes[policy]}\n`, { mode: 0o755 });
|
||||
}
|
||||
}
|
||||
|
||||
switch (scenario.link ?? 'none') {
|
||||
case 'code':
|
||||
case 'code-insiders':
|
||||
fs.symlinkSync(targets[scenario.link as Edition], linkPath);
|
||||
break;
|
||||
case 'foreign-file':
|
||||
fs.writeFileSync(linkPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
break;
|
||||
case 'foreign-link':
|
||||
fs.symlinkSync('/opt/other/copilot', linkPath);
|
||||
break;
|
||||
}
|
||||
|
||||
const debconf = scenario.declined
|
||||
? `db_get() { case " ${scenario.declined.join(' ')} " in *" \${1%%/*} "*) RET=false ;; *) RET=true ;; esac; }\n`
|
||||
: '';
|
||||
const script = `. "${helperPath}"
|
||||
vscode_copilot_link_path="${linkPath}"
|
||||
vscode_copilot_link_code_target="${targets.code}"
|
||||
vscode_copilot_link_code_insiders_target="${targets['code-insiders']}"
|
||||
${debconf}vscode_copilot_link ${scenario.action} ${scenario.edition}
|
||||
`;
|
||||
const result = spawnSync(shell, ['-c', script], { input: 'debconf-protocol-reply\n', encoding: 'utf8' });
|
||||
assert.strictEqual(result.error, undefined, `${shell} failed to start: ${result.error}`);
|
||||
assert.strictEqual(fs.existsSync(path.join(root, 'stdin')) ? fs.readFileSync(path.join(root, 'stdin'), 'utf8') : '', '', 'The shim must not read the maintainer script\'s standard input.');
|
||||
|
||||
let link: LinkState = 'none';
|
||||
const stat = fs.lstatSync(linkPath, { throwIfNoEntry: false });
|
||||
if (stat?.isSymbolicLink()) {
|
||||
const target = fs.readlinkSync(linkPath);
|
||||
link = target === targets.code ? 'code' : target === targets['code-insiders'] ? 'code-insiders' : 'foreign-link';
|
||||
} else if (stat) {
|
||||
link = 'foreign-file';
|
||||
}
|
||||
return { link, warned: result.stderr.includes('warning: vscode_copilot_link:') };
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runScenarios(shell: string, scenarios: Record<string, IScenario>): Record<string, IOutcome> {
|
||||
return Object.fromEntries(Object.entries(scenarios).map(([name, scenario]) => [name, runScenario(shell, scenario)]));
|
||||
}
|
||||
|
||||
describe('Copilot link helper policy and opt-out', function () {
|
||||
const shells = ['sh', 'bash'];
|
||||
|
||||
before(function () {
|
||||
if (process.platform !== 'linux') {
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
for (const shell of shells) {
|
||||
test(`${shell}: install follows the CopilotCliCommand policy`, () => {
|
||||
const both = (policy: ShimPolicy) => ({ 'code': policy, 'code-insiders': policy });
|
||||
assert.deepStrictEqual(runScenarios(shell, {
|
||||
allowedCreates: { action: 'install', edition: 'code', shims: both('allowed') },
|
||||
allowedTakesOver: { action: 'install', edition: 'code', shims: both('allowed'), link: 'code-insiders' },
|
||||
disabledCreatesNothing: { action: 'install', edition: 'code', shims: both('disabled') },
|
||||
disabledWithdrawsOwnLink: { action: 'install', edition: 'code', shims: both('disabled'), link: 'code' },
|
||||
disabledWithdrawsOtherEditionLink: { action: 'install', edition: 'code', shims: both('disabled'), link: 'code-insiders' },
|
||||
disabledPreservesForeignFile: { action: 'install', edition: 'code', shims: both('disabled'), link: 'foreign-file' },
|
||||
disabledPreservesForeignLink: { action: 'install', edition: 'code', shims: both('disabled'), link: 'foreign-link' },
|
||||
unknownCreatesNothing: { action: 'install', edition: 'code', shims: both('unknown') },
|
||||
unknownPreservesExistingLink: { action: 'install', edition: 'code', shims: both('unknown'), link: 'code-insiders' },
|
||||
}), {
|
||||
allowedCreates: { link: 'code', warned: false },
|
||||
allowedTakesOver: { link: 'code', warned: false },
|
||||
disabledCreatesNothing: { link: 'none', warned: false },
|
||||
disabledWithdrawsOwnLink: { link: 'none', warned: false },
|
||||
disabledWithdrawsOtherEditionLink: { link: 'none', warned: false },
|
||||
disabledPreservesForeignFile: { link: 'foreign-file', warned: false },
|
||||
disabledPreservesForeignLink: { link: 'foreign-link', warned: false },
|
||||
unknownCreatesNothing: { link: 'none', warned: true },
|
||||
unknownPreservesExistingLink: { link: 'code-insiders', warned: true },
|
||||
});
|
||||
});
|
||||
|
||||
test(`${shell}: install respects the debconf add-copilot-command answer`, () => {
|
||||
const allowed = { 'code': 'allowed', 'code-insiders': 'allowed' } as const;
|
||||
assert.deepStrictEqual(runScenarios(shell, {
|
||||
acceptedCreates: { action: 'install', edition: 'code', shims: allowed, declined: [] },
|
||||
declinedCreatesNothing: { action: 'install', edition: 'code', shims: allowed, declined: ['code'] },
|
||||
declinedHandsOwnLinkToOtherEdition: { action: 'install', edition: 'code', shims: allowed, link: 'code', declined: ['code'] },
|
||||
declinedRemovesOwnLinkWhenOtherDeclined: { action: 'install', edition: 'code', shims: allowed, link: 'code', declined: ['code', 'code-insiders'] },
|
||||
declinedKeepsOtherEditionLink: { action: 'install', edition: 'code', shims: allowed, link: 'code-insiders', declined: ['code'] },
|
||||
declinedRemovesOwnLinkWhenPolicyUnknown: { action: 'install', edition: 'code', shims: { 'code': 'unknown', 'code-insiders': 'unknown' }, link: 'code', declined: ['code'] },
|
||||
disabledPolicyWinsOverAcceptedAnswer: { action: 'install', edition: 'code', shims: { 'code': 'disabled', 'code-insiders': 'disabled' }, link: 'code-insiders', declined: [] },
|
||||
}), {
|
||||
acceptedCreates: { link: 'code', warned: false },
|
||||
declinedCreatesNothing: { link: 'none', warned: false },
|
||||
declinedHandsOwnLinkToOtherEdition: { link: 'code-insiders', warned: false },
|
||||
declinedRemovesOwnLinkWhenOtherDeclined: { link: 'none', warned: false },
|
||||
declinedKeepsOtherEditionLink: { link: 'code-insiders', warned: false },
|
||||
declinedRemovesOwnLinkWhenPolicyUnknown: { link: 'none', warned: false },
|
||||
disabledPolicyWinsOverAcceptedAnswer: { link: 'none', warned: false },
|
||||
});
|
||||
});
|
||||
|
||||
test(`${shell}: remove hands the link over only when the remaining edition may provide it`, () => {
|
||||
assert.deepStrictEqual(runScenarios(shell, {
|
||||
handsOverWhenAllowed: { action: 'remove', edition: 'code', shims: { 'code-insiders': 'allowed' }, link: 'code' },
|
||||
removesWhenNoOtherEdition: { action: 'remove', edition: 'code', shims: {}, link: 'code' },
|
||||
removesWhenPolicyDisabled: { action: 'remove', edition: 'code', shims: { 'code-insiders': 'disabled' }, link: 'code' },
|
||||
removesWhenPolicyUnknown: { action: 'remove', edition: 'code', shims: { 'code-insiders': 'unknown' }, link: 'code' },
|
||||
removesWhenOtherDeclined: { action: 'remove', edition: 'code', shims: { 'code-insiders': 'allowed' }, link: 'code', declined: ['code-insiders'] },
|
||||
keepsOtherEditionLinkWhenAllowed: { action: 'remove', edition: 'code', shims: { 'code-insiders': 'allowed' }, link: 'code-insiders' },
|
||||
withdrawsOtherEditionLinkWhenDisabled: { action: 'remove', edition: 'code', shims: { 'code-insiders': 'disabled' }, link: 'code-insiders' },
|
||||
preservesForeignLinkWhenDisabled: { action: 'remove', edition: 'code', shims: { 'code-insiders': 'disabled' }, link: 'foreign-link' },
|
||||
}), {
|
||||
handsOverWhenAllowed: { link: 'code-insiders', warned: false },
|
||||
removesWhenNoOtherEdition: { link: 'none', warned: false },
|
||||
removesWhenPolicyDisabled: { link: 'none', warned: false },
|
||||
removesWhenPolicyUnknown: { link: 'none', warned: false },
|
||||
removesWhenOtherDeclined: { link: 'none', warned: false },
|
||||
keepsOtherEditionLinkWhenAllowed: { link: 'code-insiders', warned: false },
|
||||
withdrawsOtherEditionLinkWhenDisabled: { link: 'none', warned: false },
|
||||
preservesForeignLinkWhenDisabled: { link: 'foreign-link', warned: false },
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import Mocha from 'mocha';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const mocha = new Mocha({ color: true, retries: 0 });
|
||||
mocha.addFile(fileURLToPath(new URL('./copilotShimHelper.test.js', import.meta.url)));
|
||||
await mocha.loadFilesAsync();
|
||||
mocha.run(failures => {
|
||||
process.exitCode = failures > 0 ? 1 : 0;
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import minimist from 'minimist';
|
||||
import Mocha, { MochaOptions } from 'mocha';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { CopilotShimPackageFormat, loadCopilotShimFixtureManifest } from './copilotShimFixtures.js';
|
||||
import { setCopilotShimTestRuntime } from './copilotShimRuntime.js';
|
||||
|
||||
const options = minimist(process.argv.slice(2), {
|
||||
string: ['fixtures', 'format', 'grep', 'timeout'],
|
||||
boolean: ['help', 'verbose'],
|
||||
alias: { fixtures: 'f', grep: 'g', help: 'h' },
|
||||
});
|
||||
|
||||
if (options.help) {
|
||||
console.info(`Usage: node ${path.basename(process.argv[1])} --fixtures <manifest> --format <deb|rpm> --grep <selector> [options]`);
|
||||
console.info('Options:');
|
||||
console.info(' --fixtures, -f <manifest> Fixture manifest generated from disposable source/build copies');
|
||||
console.info(' --format <deb|rpm> Package format for this isolated runner');
|
||||
console.info(' --grep, -g <selector> Required Mocha selector, for example "DEB (K[1-9]|OSS|warns)"');
|
||||
console.info(' --timeout <seconds> Per-test timeout in seconds (default: 600)');
|
||||
console.info(' --verbose Print test lifecycle progress');
|
||||
console.info(' --help, -h Show this help');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (typeof options.fixtures !== 'string' || options.fixtures.length === 0) {
|
||||
throw new Error('--fixtures <manifest> is required. This runner never downloads or discovers package fixtures.');
|
||||
}
|
||||
if (options.format !== 'deb' && options.format !== 'rpm') {
|
||||
throw new Error('--format must be exactly "deb" or "rpm". Run each package family in its own disposable runner.');
|
||||
}
|
||||
if (typeof options.grep !== 'string' || options.grep.length === 0) {
|
||||
throw new Error('--grep <selector> is required so a stateful lifecycle run has an explicit scope.');
|
||||
}
|
||||
|
||||
const timeout = parseTimeout(options.timeout);
|
||||
const format = options.format as CopilotShimPackageFormat;
|
||||
const fixtures = loadCopilotShimFixtureManifest(options.fixtures);
|
||||
setCopilotShimTestRuntime({ fixtures, format, grep: options.grep, timeout });
|
||||
|
||||
const mochaOptions: MochaOptions = {
|
||||
color: true,
|
||||
timeout,
|
||||
grep: options.grep,
|
||||
retries: 0,
|
||||
bail: true,
|
||||
};
|
||||
|
||||
const mocha = new Mocha(mochaOptions);
|
||||
mocha.addFile(fileURLToPath(new URL('./copilotShim.test.js', import.meta.url)));
|
||||
await mocha.loadFilesAsync();
|
||||
|
||||
let executedTests = 0;
|
||||
const runner = mocha.run(failures => {
|
||||
if (executedTests === 0) {
|
||||
console.error(`No Copilot shim package lifecycle tests matched --grep ${JSON.stringify(options.grep)}.`);
|
||||
}
|
||||
process.exitCode = failures > 0 || executedTests === 0 ? 1 : 0;
|
||||
});
|
||||
runner.on('test', () => executedTests++);
|
||||
|
||||
if (options.verbose) {
|
||||
runner.on('test', test => console.log(`Starting: ${test.fullTitle()}`));
|
||||
runner.on('pass', test => console.log(`Passed: ${test.fullTitle()}`));
|
||||
runner.on('fail', (test, error) => console.log(`Failed: ${test.fullTitle()} - ${error.message}`));
|
||||
}
|
||||
|
||||
function parseTimeout(value: unknown): number {
|
||||
if (value === undefined) {
|
||||
return 10 * 60 * 1000;
|
||||
}
|
||||
if (typeof value !== 'string' || !/^\d+(?:\.\d+)?$/u.test(value)) {
|
||||
throw new Error('--timeout must be a positive number of seconds.');
|
||||
}
|
||||
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
throw new Error('--timeout must be a positive number of seconds.');
|
||||
}
|
||||
return seconds * 1000;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 fs from 'fs';
|
||||
import { test } from 'mocha';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { assertCopilotLinkHelperSource } from './copilotShimRendering.js';
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const helperPath = path.join(repositoryRoot, 'resources/linux/copilot-link.sh');
|
||||
const helperSource = fs.readFileSync(helperPath, 'utf8');
|
||||
|
||||
test('Copilot link helper has literal targets and no rendering placeholders', () => {
|
||||
assertCopilotLinkHelperSource(helperSource, helperPath);
|
||||
});
|
||||
|
||||
test('Copilot link templates use callback rendering and preserve RPM percent escaping', () => {
|
||||
const postinst = fs.readFileSync(path.join(repositoryRoot, 'resources/linux/debian/postinst.template'), 'utf8');
|
||||
const postrm = fs.readFileSync(path.join(repositoryRoot, 'resources/linux/debian/postrm.template'), 'utf8');
|
||||
const rpmSpec = fs.readFileSync(path.join(repositoryRoot, 'resources/linux/rpm/code.spec.template'), 'utf8');
|
||||
const gulpfile = fs.readFileSync(path.join(repositoryRoot, 'build/gulpfile.vscode.linux.ts'), 'utf8');
|
||||
|
||||
for (const [name, template, expectedMarkers] of [
|
||||
['DEB postinst', postinst, 1],
|
||||
['DEB postrm', postrm, 1],
|
||||
['RPM spec', rpmSpec, 2],
|
||||
] as const) {
|
||||
assert.strictEqual((template.match(/@@COPILOT_LINK_HELPER@@/gu) ?? []).length, expectedMarkers, `${name} must embed the helper once per lifecycle script.`);
|
||||
}
|
||||
|
||||
assert.match(gulpfile, /const rpmCopilotLinkHelper = copilotLinkHelper\.replace\(\/%\/g, '%%'\);/u);
|
||||
assert.strictEqual((gulpfile.match(/replace\('@@COPILOT_LINK_HELPER@@', \(\) => copilotLinkHelper\)/gu) ?? []).length, 2, 'DEB templates must use callback replacement for the helper.');
|
||||
assert.strictEqual((gulpfile.match(/replace\('@@COPILOT_LINK_HELPER@@', \(\) => rpmCopilotLinkHelper\)/gu) ?? []).length, 1, 'RPM template must use the percent-escaped helper.');
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { copilotShimTargets } from './copilotShimFixtures.js';
|
||||
|
||||
/**
|
||||
* Normalizes shell source for comparisons against package-manager-expanded scriptlets.
|
||||
*/
|
||||
export function normalizeShellSource(source: string): string {
|
||||
return source.replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the production helper's placeholder-free, literal-target contract.
|
||||
*/
|
||||
export function assertCopilotLinkHelperSource(source: string, sourceName: string): void {
|
||||
const normalized = normalizeShellSource(source);
|
||||
assert.ok(normalized.length > 0, `${sourceName} is empty.`);
|
||||
assert.ok(!normalized.includes('@@'), `${sourceName} contains an unresolved template marker.`);
|
||||
assert.ok(!normalized.includes('%{'), `${sourceName} contains an RPM macro.`);
|
||||
assert.match(normalized, /\bvscode_copilot_link\s*\(\)\s*\{/u, `${sourceName} must define vscode_copilot_link.`);
|
||||
assert.match(normalized, /\binstall\b/u, `${sourceName} must support the install action.`);
|
||||
assert.match(normalized, /\bremove\b/u, `${sourceName} must support the remove action.`);
|
||||
|
||||
for (const target of Object.values(copilotShimTargets)) {
|
||||
assert.ok(normalized.includes(target), `${sourceName} must contain the literal target ${target}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a generated maintainer script embeds the exact, macro-expanded helper source.
|
||||
*/
|
||||
export function assertEmbeddedCopilotLinkHelper(script: string, helperSource: string, scriptName: string): void {
|
||||
const normalizedScript = normalizeShellSource(script);
|
||||
const normalizedHelper = normalizeShellSource(helperSource);
|
||||
assert.ok(!normalizedScript.includes('@@COPILOT_LINK_HELPER@@'), `${scriptName} retains @@COPILOT_LINK_HELPER@@.`);
|
||||
const executableLines = normalizedScript.split('\n').filter(line => !line.trimStart().startsWith('#'));
|
||||
assert.ok(!executableLines.some(line => line.includes('@@')), `${scriptName} contains an unresolved template marker.`);
|
||||
assert.ok(normalizedScript.includes(normalizedHelper), `${scriptName} does not embed the exact Copilot link helper source.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a generated lifecycle call for a specific package identity.
|
||||
*/
|
||||
export function assertCopilotLinkLifecycleCall(script: string, action: 'install' | 'remove', applicationName: string, scriptName: string): void {
|
||||
const expression = new RegExp(`\\bvscode_copilot_link\\s+${action}\\s+["']?${escapeRegExp(applicationName)}["']?(?:\\s|$)`, 'm');
|
||||
assert.match(script, expression, `${scriptName} does not call vscode_copilot_link ${action} ${applicationName}.`);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import Mocha from 'mocha';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const mocha = new Mocha({ color: true, retries: 0 });
|
||||
mocha.addFile(fileURLToPath(new URL('./copilotShimRendering.test.js', import.meta.url)));
|
||||
await mocha.loadFilesAsync();
|
||||
mocha.run(failures => {
|
||||
process.exitCode = failures > 0 ? 1 : 0;
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { CopilotShimPackageFormat, ICopilotShimFixtureManifest } from './copilotShimFixtures.js';
|
||||
|
||||
export interface ICopilotShimTestRuntime {
|
||||
fixtures: ICopilotShimFixtureManifest;
|
||||
format: CopilotShimPackageFormat;
|
||||
grep: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
let runtime: ICopilotShimTestRuntime | undefined;
|
||||
|
||||
/**
|
||||
* Sets the dedicated package-lifecycle runner configuration before Mocha loads the suite.
|
||||
*/
|
||||
export function setCopilotShimTestRuntime(value: ICopilotShimTestRuntime): void {
|
||||
if (runtime) {
|
||||
throw new Error('The Copilot shim package-lifecycle runtime was configured more than once.');
|
||||
}
|
||||
runtime = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dedicated package-lifecycle runner configuration.
|
||||
*/
|
||||
export function getCopilotShimTestRuntime(): ICopilotShimTestRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error('Copilot shim package-lifecycle tests must be launched through copilotShimIndex.ts.');
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import minimist from 'minimist';
|
||||
import path from 'path';
|
||||
import {
|
||||
CopilotShimFixtureRole,
|
||||
CopilotShimIdentity,
|
||||
CopilotShimPackageFormat,
|
||||
CopilotShimPayloadKind,
|
||||
copilotShimInstallHookInstrumentation,
|
||||
copilotShimInstallHookLogEnvironmentVariable,
|
||||
debPackageMetadataFormat,
|
||||
getCopilotShimTarget,
|
||||
ICopilotShimFixture,
|
||||
ICopilotShimFixtureManifest,
|
||||
sha256File,
|
||||
} from './copilotShimFixtures.js';
|
||||
|
||||
const fixtureSourceMarker = '.vscode-copilot-shim-fixture-source.json';
|
||||
const fixtureSourcePurpose = 'vscode-copilot-shim-fixture-source';
|
||||
const productionTemplatePaths = [
|
||||
'build/gulpfile.vscode.linux.ts',
|
||||
'resources/linux/copilot-link.sh',
|
||||
'resources/linux/debian/postinst.template',
|
||||
'resources/linux/debian/postrm.template',
|
||||
'resources/linux/rpm/code.spec.template',
|
||||
];
|
||||
|
||||
interface IFixtureBuildPlan {
|
||||
schemaVersion: 1;
|
||||
fixtureSetId: string;
|
||||
architecture: 'x64' | 'arm64' | 'armhf';
|
||||
fixtures: readonly IFixtureBuildPlanEntry[];
|
||||
}
|
||||
|
||||
interface IFixtureBuildPlanEntry {
|
||||
id: string;
|
||||
sourceRoot: string;
|
||||
sourceRevision: string;
|
||||
sourceKind: 'source-build' | 'prepared-payload';
|
||||
identity: Exclude<CopilotShimIdentity, 'foreign-copilot-owner'>;
|
||||
role: Exclude<CopilotShimFixtureRole, 'foreign-owner'>;
|
||||
revisionOrdinal: number;
|
||||
payloadKind: CopilotShimPayloadKind;
|
||||
formats: readonly CopilotShimPackageFormat[];
|
||||
fixtureAdjustments: readonly string[];
|
||||
}
|
||||
|
||||
const options = minimist(process.argv.slice(2), {
|
||||
string: ['plan', 'output'],
|
||||
boolean: ['help'],
|
||||
alias: { plan: 'p', output: 'o', help: 'h' },
|
||||
});
|
||||
|
||||
if (options.help) {
|
||||
console.info(`Usage: node ${path.basename(process.argv[1])} --plan <fixture-plan.json> --output <fixture-directory>`);
|
||||
console.info('The plan must point only to marked disposable source/build copies with prepared payloads.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (typeof options.plan !== 'string' || options.plan.length === 0 || typeof options.output !== 'string' || options.output.length === 0) {
|
||||
throw new Error('--plan <fixture-plan.json> and --output <fixture-directory> are required.');
|
||||
}
|
||||
|
||||
generateFixtureSet(path.resolve(options.plan), path.resolve(options.output));
|
||||
|
||||
/**
|
||||
* Builds source-branded DEB/RPM fixture packages using the production Gulp tasks and emits one
|
||||
* self-contained manifest. It never changes product branding in the checkout running this tool.
|
||||
*/
|
||||
export function generateFixtureSet(planPath: string, outputDirectory: string): void {
|
||||
const plan = loadPlan(planPath);
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, '../../..');
|
||||
if (samePath(outputDirectory, repositoryRoot)) {
|
||||
throw new Error(`Fixture output must not replace the live checkout: ${outputDirectory}.`);
|
||||
}
|
||||
if (fs.existsSync(outputDirectory) && fs.readdirSync(outputDirectory).length > 0) {
|
||||
throw new Error(`Fixture output directory must be empty: ${outputDirectory}.`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDirectory, { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(path.join(outputDirectory, 'packages'), { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(path.join(outputDirectory, 'provenance'), { recursive: true, mode: 0o700 });
|
||||
|
||||
const fixtures: ICopilotShimFixture[] = [];
|
||||
let helperSource: string | undefined;
|
||||
for (const entry of plan.fixtures) {
|
||||
validateDisposableSource(entry, repositoryRoot);
|
||||
const product = readProduct(entry.sourceRoot);
|
||||
if (product.applicationName !== entry.identity) {
|
||||
throw new Error(`Fixture source ${entry.sourceRoot} is branded ${product.applicationName}, not the requested ${entry.identity}. Do not relabel an OSS package.`);
|
||||
}
|
||||
if (entry.identity === 'code-oss' && (entry.role !== 'oss-initial' && entry.role !== 'oss-upgrade')) {
|
||||
throw new Error(`The code-oss source can only generate OSS negative fixtures, not ${entry.role}.`);
|
||||
}
|
||||
if (entry.identity !== 'code-oss' && (entry.role === 'oss-initial' || entry.role === 'oss-upgrade')) {
|
||||
throw new Error(`Fixture ${entry.id} has an OSS role but a non-OSS identity.`);
|
||||
}
|
||||
|
||||
const preparedPayload = path.resolve(entry.sourceRoot, '..', `VSCode-linux-${plan.architecture}`);
|
||||
validatePreparedPayload(entry, preparedPayload);
|
||||
const sourceHelperPath = path.join(entry.sourceRoot, 'resources', 'linux', 'copilot-link.sh');
|
||||
if (entry.role !== 'legacy-no-shim') {
|
||||
const sourceHelper = fs.readFileSync(sourceHelperPath, 'utf8');
|
||||
if (helperSource !== undefined && helperSource !== sourceHelper) {
|
||||
throw new Error(`Fixture ${entry.id} uses a different Copilot link helper from the rest of this fixture set.`);
|
||||
}
|
||||
helperSource = sourceHelper;
|
||||
}
|
||||
|
||||
for (const format of entry.formats) {
|
||||
runProductionPackageTask(entry.sourceRoot, plan.architecture, format, 'prepare');
|
||||
if (entry.payloadKind === 'minimal-lifecycle') {
|
||||
minimizePreparedPackage(entry, plan.architecture, format);
|
||||
}
|
||||
if (entry.role !== 'legacy-no-shim') {
|
||||
instrumentGeneratedInstallHook(entry, plan.architecture, format);
|
||||
}
|
||||
runProductionPackageTask(entry.sourceRoot, plan.architecture, format, 'build');
|
||||
const packagePath = findBuiltPackage(entry.sourceRoot, plan.architecture, format);
|
||||
const outputPackageName = `${entry.id}.${format}.${path.basename(packagePath)}`;
|
||||
const outputPackagePath = path.join(outputDirectory, 'packages', outputPackageName);
|
||||
fs.copyFileSync(packagePath, outputPackagePath);
|
||||
|
||||
const metadata = queryPackageMetadata(format, outputPackagePath);
|
||||
const fixture = createFixture(entry, format, plan.architecture, preparedPayload, metadata, outputPackageName);
|
||||
fixtures.push(fixture);
|
||||
copyRenderingProvenance(entry, format, plan.architecture, outputDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
if (!helperSource) {
|
||||
throw new Error('Fixture plan did not include a current Copilot shim package with the production helper.');
|
||||
}
|
||||
const helperOutputPath = path.join(outputDirectory, 'provenance', 'copilot-link.sh');
|
||||
fs.writeFileSync(helperOutputPath, helperSource, { mode: 0o644 });
|
||||
|
||||
for (const format of ['deb', 'rpm'] as const) {
|
||||
fixtures.push(createForeignOwnerFixture(format, plan.architecture, plan.fixtureSetId, outputDirectory));
|
||||
}
|
||||
|
||||
const manifest: ICopilotShimFixtureManifest = {
|
||||
schemaVersion: 1,
|
||||
fixtureSetId: plan.fixtureSetId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
helper: {
|
||||
sourcePath: path.join('provenance', 'copilot-link.sh'),
|
||||
sha256: sha256File(helperOutputPath),
|
||||
functionName: 'vscode_copilot_link',
|
||||
templateMarker: '@@COPILOT_LINK_HELPER@@',
|
||||
},
|
||||
fixtures,
|
||||
};
|
||||
fs.writeFileSync(path.join(outputDirectory, 'copilot-shim-fixtures.json'), `${JSON.stringify(manifest, undefined, '\t')}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function loadPlan(planPath: string): IFixtureBuildPlan {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(fs.readFileSync(planPath, 'utf8')) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to read fixture build plan ${planPath}: ${errorMessage(error)}`);
|
||||
}
|
||||
|
||||
const record = asRecord(value, 'fixture build plan');
|
||||
if (record.schemaVersion !== 1) {
|
||||
throw new Error('Fixture build plan schemaVersion must be 1.');
|
||||
}
|
||||
const architecture = asString(record.architecture, 'architecture');
|
||||
if (architecture !== 'x64' && architecture !== 'arm64' && architecture !== 'armhf') {
|
||||
throw new Error(`Unsupported fixture build architecture: ${architecture}.`);
|
||||
}
|
||||
const entries = asArray(record.fixtures, 'fixtures').map((entry, index) => parsePlanEntry(entry, `fixtures[${index}]`));
|
||||
const plan: IFixtureBuildPlan = {
|
||||
schemaVersion: 1,
|
||||
fixtureSetId: asString(record.fixtureSetId, 'fixtureSetId'),
|
||||
architecture,
|
||||
fixtures: entries,
|
||||
};
|
||||
validatePlanMatrix(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
function parsePlanEntry(value: unknown, propertyPath: string): IFixtureBuildPlanEntry {
|
||||
const record = asRecord(value, propertyPath);
|
||||
const identity = asString(record.identity, `${propertyPath}.identity`);
|
||||
if (identity !== 'code' && identity !== 'code-insiders' && identity !== 'code-oss') {
|
||||
throw new Error(`${propertyPath}.identity must be code, code-insiders, or code-oss.`);
|
||||
}
|
||||
const role = asString(record.role, `${propertyPath}.role`);
|
||||
if (role !== 'initial' && role !== 'upgrade' && role !== 'legacy-no-shim' && role !== 'oss-initial' && role !== 'oss-upgrade') {
|
||||
throw new Error(`${propertyPath}.role is not a supported source-built fixture role.`);
|
||||
}
|
||||
const sourceKind = asString(record.sourceKind, `${propertyPath}.sourceKind`);
|
||||
if (sourceKind !== 'source-build' && sourceKind !== 'prepared-payload') {
|
||||
throw new Error(`${propertyPath}.sourceKind must be source-build or prepared-payload.`);
|
||||
}
|
||||
const payloadKind = asString(record.payloadKind, `${propertyPath}.payloadKind`);
|
||||
if (payloadKind !== 'complete' && payloadKind !== 'minimal-lifecycle') {
|
||||
throw new Error(`${propertyPath}.payloadKind must be complete or minimal-lifecycle.`);
|
||||
}
|
||||
const formats = asArray(record.formats, `${propertyPath}.formats`).map((format, index) => {
|
||||
const value = asString(format, `${propertyPath}.formats[${index}]`);
|
||||
if (value !== 'deb' && value !== 'rpm') {
|
||||
throw new Error(`${propertyPath}.formats[${index}] must be deb or rpm.`);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (formats.length === 0) {
|
||||
throw new Error(`${propertyPath}.formats must not be empty.`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: asString(record.id, `${propertyPath}.id`),
|
||||
sourceRoot: path.resolve(asString(record.sourceRoot, `${propertyPath}.sourceRoot`)),
|
||||
sourceRevision: asString(record.sourceRevision, `${propertyPath}.sourceRevision`),
|
||||
sourceKind,
|
||||
identity,
|
||||
role,
|
||||
revisionOrdinal: asPositiveInteger(record.revisionOrdinal, `${propertyPath}.revisionOrdinal`),
|
||||
payloadKind,
|
||||
formats,
|
||||
fixtureAdjustments: asStringArray(record.fixtureAdjustments, `${propertyPath}.fixtureAdjustments`),
|
||||
};
|
||||
}
|
||||
|
||||
function validatePlanMatrix(plan: IFixtureBuildPlan): void {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of plan.fixtures) {
|
||||
if (!entry.id || ids.has(entry.id)) {
|
||||
throw new Error(`Fixture build plan id must be unique: ${entry.id || '(empty)'}.`);
|
||||
}
|
||||
ids.add(entry.id);
|
||||
}
|
||||
|
||||
for (const format of ['deb', 'rpm'] as const) {
|
||||
for (const identity of ['code', 'code-insiders'] as const) {
|
||||
const initial = findPlanEntry(plan, identity, 'initial', format, 'complete');
|
||||
const upgrade = findPlanEntry(plan, identity, 'upgrade', format, 'complete');
|
||||
findPlanEntry(plan, identity, 'legacy-no-shim', format);
|
||||
if (upgrade.revisionOrdinal <= initial.revisionOrdinal) {
|
||||
throw new Error(`${format}/${identity} upgrade revisionOrdinal must be greater than the initial revisionOrdinal.`);
|
||||
}
|
||||
}
|
||||
const ossInitial = findPlanEntry(plan, 'code-oss', 'oss-initial', format);
|
||||
const ossUpgrade = findPlanEntry(plan, 'code-oss', 'oss-upgrade', format);
|
||||
if (ossUpgrade.revisionOrdinal <= ossInitial.revisionOrdinal) {
|
||||
throw new Error(`${format}/code-oss upgrade revisionOrdinal must be greater than the initial revisionOrdinal.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findPlanEntry(
|
||||
plan: IFixtureBuildPlan,
|
||||
identity: IFixtureBuildPlanEntry['identity'],
|
||||
role: IFixtureBuildPlanEntry['role'],
|
||||
format: CopilotShimPackageFormat,
|
||||
payloadKind?: CopilotShimPayloadKind,
|
||||
): IFixtureBuildPlanEntry {
|
||||
const entry = plan.fixtures.find(candidate => candidate.identity === identity && candidate.role === role && candidate.formats.includes(format) && (payloadKind === undefined || candidate.payloadKind === payloadKind));
|
||||
if (!entry) {
|
||||
throw new Error(`Fixture build plan is missing ${format}/${identity}/${role}.`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function validateDisposableSource(entry: IFixtureBuildPlanEntry, repositoryRoot: string): void {
|
||||
if (samePath(entry.sourceRoot, repositoryRoot)) {
|
||||
throw new Error(`Fixture ${entry.id} points at the live checkout. Use a marked disposable source/build copy instead.`);
|
||||
}
|
||||
const markerPath = path.join(entry.sourceRoot, fixtureSourceMarker);
|
||||
const marker = asRecord(JSON.parse(fs.readFileSync(markerPath, 'utf8')) as unknown, `${entry.id} source marker`);
|
||||
if (marker.purpose !== fixtureSourcePurpose || marker.disposable !== true || marker.sourceRevision !== entry.sourceRevision) {
|
||||
throw new Error(`Fixture ${entry.id} source marker must confirm a disposable source copy and the declared sourceRevision.`);
|
||||
}
|
||||
if (!fs.statSync(path.join(entry.sourceRoot, 'package.json')).isFile() || !fs.statSync(path.join(entry.sourceRoot, 'product.json')).isFile()) {
|
||||
throw new Error(`Fixture ${entry.id} sourceRoot is not a VS Code source checkout: ${entry.sourceRoot}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function readProduct(sourceRoot: string): { applicationName: string } {
|
||||
const value = asRecord(JSON.parse(fs.readFileSync(path.join(sourceRoot, 'product.json'), 'utf8')) as unknown, 'product.json');
|
||||
return { applicationName: asString(value.applicationName, 'product.json applicationName') };
|
||||
}
|
||||
|
||||
function validatePreparedPayload(entry: IFixtureBuildPlanEntry, payloadDirectory: string): void {
|
||||
if (!fs.statSync(payloadDirectory).isDirectory()) {
|
||||
throw new Error(`Fixture ${entry.id} lacks the prepared payload expected by production Gulp tasks: ${payloadDirectory}.`);
|
||||
}
|
||||
const shimPath = path.join(payloadDirectory, 'bin', 'copilot-shim', 'copilot');
|
||||
if (entry.role === 'legacy-no-shim') {
|
||||
if (fs.existsSync(shimPath)) {
|
||||
throw new Error(`Legacy fixture ${entry.id} unexpectedly contains ${shimPath}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (entry.identity === 'code-oss') {
|
||||
return;
|
||||
}
|
||||
const shim = fs.statSync(shimPath);
|
||||
if (!shim.isFile() || (shim.mode & 0o111) === 0) {
|
||||
throw new Error(`Fixture ${entry.id} must contain an executable source-built Copilot shim: ${shimPath}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function runProductionPackageTask(
|
||||
sourceRoot: string,
|
||||
architecture: IFixtureBuildPlan['architecture'],
|
||||
format: CopilotShimPackageFormat,
|
||||
phase: 'prepare' | 'build',
|
||||
): void {
|
||||
const suffix = format === 'deb' ? 'deb' : 'rpm';
|
||||
runRequiredCommand('npm', ['run', 'gulp', `vscode-linux-${architecture}-${phase}-${suffix}`], sourceRoot);
|
||||
}
|
||||
|
||||
function minimizePreparedPackage(entry: IFixtureBuildPlanEntry, architecture: IFixtureBuildPlan['architecture'], format: CopilotShimPackageFormat): void {
|
||||
const applicationRoot = format === 'deb'
|
||||
? path.join(entry.sourceRoot, '.build', 'linux', 'deb', debArchitecture(architecture), `${entry.identity}-${debArchitecture(architecture)}`, 'usr', 'share', entry.identity)
|
||||
: path.join(entry.sourceRoot, '.build', 'linux', 'rpm', rpmArchitecture(architecture), 'rpmbuild', 'BUILD', 'usr', 'share', entry.identity);
|
||||
const launcherPath = path.join(applicationRoot, 'bin', entry.identity);
|
||||
const shimPath = path.join(applicationRoot, 'bin', 'copilot-shim', 'copilot');
|
||||
const sandboxPath = path.join(applicationRoot, 'chrome-sandbox');
|
||||
const requiredPaths = entry.role === 'legacy-no-shim'
|
||||
? [launcherPath, sandboxPath]
|
||||
: [launcherPath, shimPath, sandboxPath];
|
||||
for (const requiredPath of requiredPaths) {
|
||||
if (!fs.statSync(requiredPath).isFile()) {
|
||||
throw new Error(`Cannot create minimal fixture ${entry.id}: expected production payload file ${requiredPath} is missing.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of fs.readdirSync(applicationRoot)) {
|
||||
if (entryName !== 'bin' && entryName !== 'chrome-sandbox') {
|
||||
fs.rmSync(path.join(applicationRoot, entryName), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
for (const entryName of fs.readdirSync(path.join(applicationRoot, 'bin'))) {
|
||||
if (entryName !== entry.identity && (entry.role === 'legacy-no-shim' || entryName !== 'copilot-shim')) {
|
||||
fs.rmSync(path.join(applicationRoot, 'bin', entryName), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
if (entry.role !== 'legacy-no-shim') {
|
||||
for (const entryName of fs.readdirSync(path.join(applicationRoot, 'bin', 'copilot-shim'))) {
|
||||
if (entryName !== 'copilot') {
|
||||
fs.rmSync(path.join(applicationRoot, 'bin', 'copilot-shim', entryName), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function instrumentGeneratedInstallHook(entry: IFixtureBuildPlanEntry, architecture: IFixtureBuildPlan['architecture'], format: CopilotShimPackageFormat): void {
|
||||
const phase = format === 'deb' ? 'deb-postinst-configure' : 'rpm-post';
|
||||
const environmentWithDefault = '${' + copilotShimInstallHookLogEnvironmentVariable + ':-}';
|
||||
const environmentPath = '$' + copilotShimInstallHookLogEnvironmentVariable;
|
||||
const recorder = [
|
||||
`if [ -n "${environmentWithDefault}" ]; then`,
|
||||
`\tprintf '${format === 'deb' ? '%s\\t%s\\n' : '%%s\\t%%s\\n'}' '${phase}' '${entry.identity}' >> "${environmentPath}"`,
|
||||
'fi',
|
||||
].join('\n');
|
||||
const scriptPath = format === 'deb'
|
||||
? path.join(entry.sourceRoot, '.build', 'linux', 'deb', debArchitecture(architecture), `${entry.identity}-${debArchitecture(architecture)}`, 'DEBIAN', 'postinst')
|
||||
: path.join(entry.sourceRoot, '.build', 'linux', 'rpm', rpmArchitecture(architecture), 'rpmbuild', 'SPECS', `${entry.identity}.spec`);
|
||||
const installInvocation = format === 'deb'
|
||||
? `vscode_copilot_link install "${entry.identity}" || :`
|
||||
: 'vscode_copilot_link install "%{name}" || :';
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
const firstIndex = script.indexOf(installInvocation);
|
||||
if (firstIndex < 0 || firstIndex !== script.lastIndexOf(installInvocation)) {
|
||||
throw new Error(`Fixture ${entry.id} must contain exactly one generated ${format.toUpperCase()} install hook invocation before instrumentation.`);
|
||||
}
|
||||
fs.writeFileSync(scriptPath, script.replace(installInvocation, `${recorder}\n${installInvocation}`), { mode: 0o644 });
|
||||
}
|
||||
|
||||
function findBuiltPackage(sourceRoot: string, architecture: IFixtureBuildPlan['architecture'], format: CopilotShimPackageFormat): string {
|
||||
const directory = format === 'deb'
|
||||
? path.join(sourceRoot, '.build', 'linux', 'deb', debArchitecture(architecture), 'deb')
|
||||
: path.join(sourceRoot, '.build', 'linux', 'rpm', rpmArchitecture(architecture));
|
||||
const extension = format === 'deb' ? '.deb' : '.rpm';
|
||||
const packages = fs.readdirSync(directory, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile() && entry.name.endsWith(extension))
|
||||
.map(entry => path.join(directory, entry.name));
|
||||
if (packages.length !== 1) {
|
||||
throw new Error(`Expected exactly one ${format.toUpperCase()} package in ${directory}, found ${packages.length}: ${packages.join(', ')}.`);
|
||||
}
|
||||
return packages[0];
|
||||
}
|
||||
|
||||
function createFixture(
|
||||
entry: IFixtureBuildPlanEntry,
|
||||
format: CopilotShimPackageFormat,
|
||||
architecture: IFixtureBuildPlan['architecture'],
|
||||
preparedPayload: string,
|
||||
metadata: { packageName: string; packageVersion: string; architecture: string },
|
||||
outputPackageName: string,
|
||||
): ICopilotShimFixture {
|
||||
const sourceShimPath = path.join(preparedPayload, 'bin', 'copilot-shim', 'copilot');
|
||||
const hasCopilotShim = entry.role !== 'legacy-no-shim' && fs.existsSync(sourceShimPath);
|
||||
const launcherPath = `/usr/share/${entry.identity}/bin/${entry.identity}`;
|
||||
const shimPath = hasCopilotShim
|
||||
? entry.identity === 'code' || entry.identity === 'code-insiders'
|
||||
? getCopilotShimTarget(entry.identity as 'code' | 'code-insiders')
|
||||
: `/usr/share/${entry.identity}/bin/copilot-shim/copilot`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: `${entry.id}-${format}`,
|
||||
format,
|
||||
architecture: metadata.architecture,
|
||||
identity: entry.identity,
|
||||
role: entry.role,
|
||||
packageName: metadata.packageName,
|
||||
packageVersion: metadata.packageVersion,
|
||||
revisionOrdinal: entry.revisionOrdinal,
|
||||
packagePath: path.join('packages', outputPackageName),
|
||||
payload: {
|
||||
kind: entry.payloadKind,
|
||||
hasCopilotShim,
|
||||
launcherPath,
|
||||
shimPath,
|
||||
},
|
||||
provenance: {
|
||||
kind: entry.sourceKind,
|
||||
sourceRevision: entry.sourceRevision,
|
||||
generationCommand: `npm run gulp vscode-linux-${architecture}-prepare-${format} && npm run gulp vscode-linux-${architecture}-build-${format}`,
|
||||
templatePaths: entry.role === 'legacy-no-shim' ? [] : productionTemplatePaths,
|
||||
fixtureAdjustments: [
|
||||
...entry.fixtureAdjustments,
|
||||
...(entry.role === 'legacy-no-shim' ? [] : [copilotShimInstallHookInstrumentation]),
|
||||
...(entry.payloadKind === 'minimal-lifecycle' ? ['Trimmed only after the production prepare task, retaining the edition launcher, nested Copilot shim, and chrome-sandbox required by the generated RPM spec. This fixture does not prove full product launcher behavior.'] : []),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function copyRenderingProvenance(entry: IFixtureBuildPlanEntry, format: CopilotShimPackageFormat, architecture: IFixtureBuildPlan['architecture'], outputDirectory: string): void {
|
||||
const destination = path.join(outputDirectory, 'provenance', entry.id, format);
|
||||
fs.mkdirSync(destination, { recursive: true, mode: 0o700 });
|
||||
if (format === 'deb') {
|
||||
const source = path.join(entry.sourceRoot, '.build', 'linux', 'deb', debArchitecture(architecture), `${entry.identity}-${debArchitecture(architecture)}`, 'DEBIAN');
|
||||
for (const script of ['postinst', 'postrm']) {
|
||||
const scriptPath = path.join(source, script);
|
||||
if (fs.existsSync(scriptPath)) {
|
||||
fs.copyFileSync(scriptPath, path.join(destination, script));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const specPath = path.join(entry.sourceRoot, '.build', 'linux', 'rpm', rpmArchitecture(architecture), 'rpmbuild', 'SPECS', `${entry.identity}.spec`);
|
||||
if (fs.existsSync(specPath)) {
|
||||
fs.copyFileSync(specPath, path.join(destination, `${entry.identity}.spec`));
|
||||
}
|
||||
}
|
||||
|
||||
function queryPackageMetadata(format: CopilotShimPackageFormat, packagePath: string): { packageName: string; packageVersion: string; architecture: string } {
|
||||
if (format === 'deb') {
|
||||
const result = runRequiredCommand('dpkg-deb', ['--show', `--showformat=${debPackageMetadataFormat}`, packagePath]);
|
||||
const [packageName, packageVersion, architecture] = result.stdout.trim().split(/\r?\n/u);
|
||||
if (!packageName || !packageVersion || !architecture) {
|
||||
throw new Error(`Unable to parse DEB metadata for ${packagePath}.`);
|
||||
}
|
||||
return { packageName, packageVersion, architecture };
|
||||
}
|
||||
|
||||
const result = runRequiredCommand('rpm', ['-qp', '--nosignature', '--qf', '%{NAME}\n%{EPOCHNUM}:%{VERSION}-%{RELEASE}\n%{ARCH}\n', packagePath]);
|
||||
const [packageName, packageVersion, architecture] = result.stdout.trim().split(/\r?\n/u);
|
||||
if (!packageName || !packageVersion || !architecture) {
|
||||
throw new Error(`Unable to parse RPM metadata for ${packagePath}.`);
|
||||
}
|
||||
return { packageName, packageVersion, architecture };
|
||||
}
|
||||
|
||||
function createForeignOwnerFixture(
|
||||
format: CopilotShimPackageFormat,
|
||||
architecture: IFixtureBuildPlan['architecture'],
|
||||
fixtureSetId: string,
|
||||
outputDirectory: string,
|
||||
): ICopilotShimFixture {
|
||||
const outputPackageName = `foreign-copilot-owner.${format}.${format === 'deb' ? 'deb' : 'rpm'}`;
|
||||
const outputPackagePath = path.join(outputDirectory, 'packages', outputPackageName);
|
||||
const stagingRoot = path.join(outputDirectory, 'staging', `foreign-${format}`);
|
||||
fs.rmSync(stagingRoot, { recursive: true, force: true });
|
||||
fs.mkdirSync(stagingRoot, { recursive: true, mode: 0o700 });
|
||||
|
||||
if (format === 'deb') {
|
||||
const packageRoot = path.join(stagingRoot, 'package');
|
||||
fs.mkdirSync(path.join(packageRoot, 'DEBIAN'), { recursive: true, mode: 0o755 });
|
||||
fs.mkdirSync(path.join(packageRoot, 'usr', 'bin'), { recursive: true, mode: 0o755 });
|
||||
fs.writeFileSync(path.join(packageRoot, 'DEBIAN', 'control'), `Package: vscode-copilot-shim-foreign-command\nVersion: 1.0.0-1\nArchitecture: ${debArchitecture(architecture)}\nMaintainer: VS Code Fixture Harness <vscode@example.invalid>\nDescription: Test-owned foreign Copilot command\n`, { mode: 0o644 });
|
||||
fs.writeFileSync(path.join(packageRoot, 'usr', 'bin', 'copilot'), '#!/bin/sh\nprintf "%s\\n" foreign-package-copilot\n', { mode: 0o755 });
|
||||
runRequiredCommand('dpkg-deb', ['--build', packageRoot, outputPackagePath]);
|
||||
} else {
|
||||
const topDirectory = path.join(stagingRoot, 'rpmbuild');
|
||||
for (const directory of ['BUILD', 'BUILDROOT', 'RPMS', 'SOURCES', 'SPECS', 'SRPMS']) {
|
||||
fs.mkdirSync(path.join(topDirectory, directory), { recursive: true, mode: 0o755 });
|
||||
}
|
||||
const specPath = path.join(topDirectory, 'SPECS', 'foreign-copilot-owner.spec');
|
||||
fs.writeFileSync(specPath, `Name: vscode-copilot-shim-foreign-command
|
||||
Version: 1.0.0
|
||||
Release: 1
|
||||
Summary: Test-owned foreign Copilot command
|
||||
License: MIT
|
||||
BuildArch: ${rpmArchitecture(architecture)}
|
||||
|
||||
%description
|
||||
Test-owned foreign Copilot command for package ownership preservation tests.
|
||||
|
||||
%install
|
||||
mkdir -p %{buildroot}/usr/bin
|
||||
printf '%s\\n' '#!/bin/sh' 'printf "%s\\\\n" foreign-package-copilot' > %{buildroot}/usr/bin/copilot
|
||||
chmod 755 %{buildroot}/usr/bin/copilot
|
||||
|
||||
%files
|
||||
/usr/bin/copilot
|
||||
`, { mode: 0o644 });
|
||||
runRequiredCommand('rpmbuild', ['-bb', '--define', `_topdir ${topDirectory}`, '--target', rpmArchitecture(architecture), specPath]);
|
||||
const builtPath = path.join(topDirectory, 'RPMS', rpmArchitecture(architecture), 'vscode-copilot-shim-foreign-command-1.0.0-1.' + rpmArchitecture(architecture) + '.rpm');
|
||||
fs.copyFileSync(builtPath, outputPackagePath);
|
||||
}
|
||||
|
||||
const metadata = queryPackageMetadata(format, outputPackagePath);
|
||||
fs.rmSync(stagingRoot, { recursive: true, force: true });
|
||||
return {
|
||||
id: `foreign-copilot-owner-${format}`,
|
||||
format,
|
||||
architecture: metadata.architecture,
|
||||
identity: 'foreign-copilot-owner',
|
||||
role: 'foreign-owner',
|
||||
packageName: metadata.packageName,
|
||||
packageVersion: metadata.packageVersion,
|
||||
revisionOrdinal: 1,
|
||||
packagePath: path.join('packages', outputPackageName),
|
||||
payload: {
|
||||
kind: 'minimal-lifecycle',
|
||||
hasCopilotShim: false,
|
||||
},
|
||||
provenance: {
|
||||
kind: 'test-owned-foreign-package',
|
||||
sourceRevision: fixtureSetId,
|
||||
generationCommand: 'test/sanity/src/generateCopilotShimFixtures.ts',
|
||||
templatePaths: [],
|
||||
fixtureAdjustments: ['Creates only a foreign /usr/bin/copilot ownership fixture; it does not reproduce VS Code package lifecycle logic.'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runRequiredCommand(command: string, args: readonly string[], cwd?: string): { stdout: string } {
|
||||
const result = spawnSync(command, args, { cwd, encoding: 'utf8', timeout: 10 * 60 * 1000 });
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed with status ${result.status ?? 'null'}: ${result.error?.message ?? commandOutput(result.stderr)}`);
|
||||
}
|
||||
return { stdout: commandOutput(result.stdout) };
|
||||
}
|
||||
|
||||
function debArchitecture(architecture: IFixtureBuildPlan['architecture']): string {
|
||||
switch (architecture) {
|
||||
case 'x64': return 'amd64';
|
||||
case 'arm64': return 'arm64';
|
||||
case 'armhf': return 'armhf';
|
||||
}
|
||||
}
|
||||
|
||||
function rpmArchitecture(architecture: IFixtureBuildPlan['architecture']): string {
|
||||
switch (architecture) {
|
||||
case 'x64': return 'x86_64';
|
||||
case 'arm64': return 'aarch64';
|
||||
case 'armhf': return 'armv7hl';
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, description: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${description} must be an object.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asArray(value: unknown, description: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`${description} must be an array.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asString(value: unknown, description: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${description} must be a non-empty string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown, description: string): readonly string[] {
|
||||
return asArray(value, description).map((item, index) => asString(item, `${description}[${index}]`));
|
||||
}
|
||||
|
||||
function asPositiveInteger(value: unknown, description: string): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${description} must be a positive integer.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
return canonicalPath(left) === canonicalPath(right);
|
||||
}
|
||||
|
||||
function canonicalPath(filePath: string): string {
|
||||
try {
|
||||
return fs.realpathSync.native(filePath);
|
||||
} catch {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function commandOutput(output: string | Buffer | undefined): string {
|
||||
return typeof output === 'string' ? output : output?.toString() ?? '';
|
||||
}
|
||||
Reference in new issue
Block a user