Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a2762da
fix(auth): bind GitHub linking to the initiating browser
kerdofficial Oct 9, 2026
9e5b02e
fix(server): keep service state and credentials private
kerdofficial Oct 9, 2026
8f4a676
fix(site): verify font downloads and isolate deployment
kerdofficial Oct 9, 2026
70bf908
fix(github): enforce approval for private fork workflows
kerdofficial Oct 9, 2026
e803fdf
fix(runner): isolate guest networks and SSH identities
kerdofficial Oct 9, 2026
5aaa095
fix(ci): keep PR metadata jobs off Vectis runners
kerdofficial Oct 9, 2026
98b7e2b
fix(ci): use an available Xcode runner for native checks
kerdofficial Oct 9, 2026
440c9a3
fix(windows): install MinGit outside the standard Git path
kerdofficial Oct 10, 2026
13b8120
fix(runner): preserve exit evidence when VM cleanup fails
kerdofficial Oct 11, 2026
4e384d9
fix(windows): preserve bootstrap failure diagnostics
kerdofficial Oct 11, 2026
0ac3d4c
fix(runner): reconcile requests without cloud leases
kerdofficial Oct 11, 2026
5af0c49
chore(deps): bump @modelcontextprotocol/sdk from 1.30.0 to 1.32.1
dependabot[bot] Oct 14, 2026
c91d8fc
feat(runner): release idle runners whose job no longer needs capacity
kerdofficial Oct 14, 2026
77010ac
docs(runner): explain idle runner drain
kerdofficial Oct 14, 2026
cebfb8b
fix(runner): decode runner busy state only for status reads
kerdofficial Oct 14, 2026
35ff99e
fix(cloud): expire runner requests that machines never claimed
kerdofficial Oct 14, 2026
4549860
docs(runner): explain unclaimed request expiry
kerdofficial Oct 14, 2026
331545c
fix(security): redact credentials from logs and operation details
kerdofficial Oct 14, 2026
491a3fc
perf(server): redact operations once when they are written
kerdofficial Oct 14, 2026
da7f537
fix(design): pin extracted fonts instead of the repackaged archive
kerdofficial Oct 14, 2026
17ada9b
feat(repositories): enforce binding access policies
kerdofficial Oct 14, 2026
6d0792d
feat(runner): verify assignments with a job-started hook
kerdofficial Oct 14, 2026
95a401f
fix(runner): stop the runner when a job assignment is rejected
kerdofficial Oct 14, 2026
17ef64c
feat(desktop): choose who can start runners and approve jobs
kerdofficial Oct 14, 2026
c459bd6
fix(desktop): approve jobs on the environment they asked for
kerdofficial Oct 14, 2026
bfa0c31
fix(runner): keep the assignment hook within its own runner process tree
kerdofficial Oct 14, 2026
66210f8
feat(runner): add opt-in Rosetta for Linux guests
kerdofficial Oct 14, 2026
5f09fa4
test(runner): allow real process trees time on loaded runners
kerdofficial Oct 14, 2026
b6853e5
docs(linux): explain optional x86_64 tools
kerdofficial Oct 14, 2026
9a7a60c
Merge remote-tracking branch 'origin/dependabot/npm_and_yarn/modelcon…
kerdofficial Oct 14, 2026
761120d
Merge remote-tracking branch 'origin/fix/overnight-windows-setup-2026…
kerdofficial Oct 14, 2026
8ad4808
Merge remote-tracking branch 'origin/feat/shared-redaction' into HEAD
kerdofficial Oct 14, 2026
f170e48
Merge commit '8ad4808ee8754ef9bce46e9990764ea1fbdf9283' into HEAD
kerdofficial Oct 14, 2026
5e5bdca
Merge commit 'f170e485ed1f85b740c23f5f82158c795350d629' into HEAD
kerdofficial Oct 14, 2026
289a54e
Merge commit '5e5bdcac906e8e467865b8ff7028f4f4e33325b3' into HEAD
kerdofficial Oct 14, 2026
b3eb20b
Merge commit '289a54e30d9f30be1ec2d9e8e688bf345ce3dcd4' into HEAD
kerdofficial Oct 14, 2026
0cbb179
Merge integration into feat/repository-access-policy
kerdofficial Oct 14, 2026
1474878
Merge integration into feat/rosetta-x64
kerdofficial Oct 14, 2026
a0d82c0
Merge main into feat/rosetta-x64
kerdofficial Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Vectis runs repository-scoped GitHub Actions runners in disposable local virtual

### 1. The host stays in charge

Vectis runs untrusted CI code on someone's own machine. Guests never receive host directories, host Docker sockets, or broad host credentials by default. The GitHub App private key stays in the cloud. Repository IDs and installation access establish authority; labels, names, and display strings never do. Forks never inherit upstream runner capacity, and approving external pull requests stays a GitHub maintainer decision, never an automatic Vectis action.
Vectis runs untrusted CI code on someone's own machine. Guests never receive host directories, host Docker sockets, or broad host credentials by default. The sole host-directory exception is Apple's read-only Rosetta runtime, shared only when the user enables it for a Linux environment. The GitHub App private key stays in the cloud. Repository IDs and installation access establish authority; labels, names, and display strings never do. Forks never inherit upstream runner capacity, and approving external pull requests stays a GitHub maintainer decision, never an automatic Vectis action.

### 2. Honest state

Expand Down
45 changes: 45 additions & 0 deletions apps/cli/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,48 @@ test.each([
}
},
);

test.each(["rosetta", "off"])(
"environment configure parses --x64 %s through the shared command",
async (x64) => {
const home = await mkdtemp(join(tmpdir(), "vectis-cli-x64-"));
const server = await startService({ home });
const submit = vi.spyOn(server.service, "submit");
const argv = process.argv;
const exitCode = process.exitCode;
const output = vi.spyOn(process.stdout, "write").mockReturnValue(true);
try {
vi.resetModules();
process.argv = [
process.execPath,
"vectis",
"--home",
home,
"environment",
"configure",
"linux",
"--x64",
x64,
"--key",
"x64",
"--json",
];
await import("./main.js");
await vi.waitFor(() =>
expect(submit).toHaveBeenCalledWith("x64", {
type: "environment.configure",
id: "linux",
x64,
}),
);
await vi.waitFor(() => expect(output).toHaveBeenCalled());
} finally {
process.argv = argv;
process.exitCode = exitCode;
output.mockRestore();
submit.mockRestore();
await server.close();
await rm(home, { recursive: true, force: true });
}
},
);
4 changes: 3 additions & 1 deletion apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ async function main() {
"non-interactive": { type: "boolean" },
timeout: { type: "string" },
cpu: { type: "string" },
x64: { type: "string" },
"max-runners": { type: "string" },
"job-id": { type: "string" },
actors: { type: "string" },
Expand Down Expand Up @@ -169,7 +170,7 @@ Environments
environment install-windows <id> Experimental: install Windows 11 from your ISO, drivers and firmware
environment resume-windows <setup-id> Continue an interrupted Windows installation
environment register --file <path> Register an existing prepared environment JSON file
environment configure <id> Set default --cpu, --memory-mib or --storage-path for future VMs
environment configure <id> Set --cpu, --memory-mib, --storage-path or --x64 rosetta|off for future VMs
environment start <id> Start a clean VM, optionally overriding --cpu, --memory-mib, --storage-path
environment remove <id> Remove an idle definition, preserving its disk
instance stop <id> Stop a VM owned by this service
Expand Down Expand Up @@ -661,6 +662,7 @@ Documentation: https://vectis.kerd.dev/docs
request = decodeCommand({
type: "environment.configure",
id,
...(values.x64 !== undefined ? { x64: values.x64 } : {}),
...(values.cpu !== undefined ? { cpu: Number(values.cpu) } : {}),
...(values["memory-mib"] !== undefined ? { memoryMiB: Number(values["memory-mib"]) } : {}),
...(values["storage-path"] !== undefined ? { storagePath: values["storage-path"] } : {}),
Expand Down
22 changes: 22 additions & 0 deletions apps/docs/docs/guides/linux-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ So a prepared guest has Git, Docker, OpenSSH and the official runner's dependenc

Deliberately absent: language runtimes and their version managers, databases, browsers and drivers, cloud CLIs, Android and Java SDKs. A job that needs one installs it in a step, or runs in a container with `container:` in the workflow, which works because the guest runs Docker. Nothing a job installs survives it, since every VM starts from the same base image, and the base image itself cannot be customized yet.

## Run x86_64 Linux tools

Enable Rosetta per Linux environment to run x86_64 Linux binaries and Docker images requested with `docker run --platform linux/amd64`. The Mac must have Rosetta installed, and the Linux image must have been prepared with Rosetta guest support. Older images need re-preparation; enabling the option alone does not update an image. Prepare a new Linux environment and reconnect its repositories if the existing image lacks support.

```sh
# Run on the Mac if Rosetta is not installed:
softwareupdate --install-rosetta
vectis environment configure ubuntu-24-arm64 --x64 rosetta --json
# Run inside a job:
docker run --rm --platform linux/amd64 ubuntu:24.04 uname -m
# Disable translation for future instances:
vectis environment configure ubuntu-24-arm64 --x64 off --json
```

Vectis shares only [Apple's read-only Rosetta runtime](https://developer.apple.com/documentation/virtualization/running-intel-binaries-in-linux-vms) with an opted-in Linux guest. A conditional boot unit mounts it read-only at `/run/rosetta` and registers an x86_64 ELF handler with `binfmt_misc`. It registers after `systemd-binfmt` has finished, so the normal boot registration cannot clear the handler. The `CF` flags preserve binary credentials and keep the interpreter available in container mount namespaces. The mount attempt is the unit condition: a missing tag skips registration without failing the unit, including on Ubuntu kernels that do not expose a sysfs tag file.

Instance startup verifies the guest handler before reporting success. Missing host support reports `rosetta_not_installed` or `rosetta_unsupported`; a missing or disabled guest handler reports `rosetta_guest_unprepared` with the next step "Prepare the Linux environment again". Translation does not provide x86_64 libraries for standalone dynamically linked binaries; the binary needs compatible libraries, for example inside an amd64 container.

The runner and guest kernel stay ARM64. Jobs requesting `X64` labels still do not match, and `setup-*` actions still install ARM64 tools by default. Rosetta does not change label matching or workflow migration.

The setting applies to future instances. Like other environment configuration changes, enabling or disabling it changes `environmentRevision`, invalidates previous environment evidence and prevents preparing runner leases from completing against the old revision. Those attempts need cleanup and a fresh lease.

## Cancel and resume

```sh
Expand Down
31 changes: 30 additions & 1 deletion apps/mcp/src/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { expect, test } from "vitest";
Expand Down Expand Up @@ -40,6 +40,35 @@ test("MCP discovers and executes the same authenticated commands as the CLI clie
expect(configured.isError).not.toBe(true);
await service.service.drain();
expect((await api.status()).machine.maxRunners).toBe(3);
const commandTool = discovered.find((tool) => tool.name === "vectis_command");
expect(JSON.stringify(commandTool?.inputSchema)).toContain('"x64"');
expect(JSON.stringify(commandTool?.inputSchema)).toContain('"rosetta"');
expect(JSON.stringify(commandTool?.inputSchema)).toContain('"off"');
const basePath = join(home, "base.img");
await writeFile(basePath, "synthetic Linux image");
service.store.put("environment", "linux", {
id: "linux",
name: "Linux",
os: "linux",
state: "ready",
basePath,
cpu: 1,
memoryMiB: 512,
});
for (const x64 of ["rosetta", "off"]) {
const configured = await client.callTool({
name: "vectis_command",
arguments: {
key: `mcp-x64-${x64}`,
command: { type: "environment.configure", id: "linux", x64 },
},
});
expect(configured.isError).not.toBe(true);
await service.service.drain();
expect((await api.status()).environments[0]?.x64).toBe(
x64 === "rosetta" ? "rosetta" : undefined,
);
}
expect(await client.callTool({ name: "vectis_doctor", arguments: {} })).toMatchObject({
structuredContent: { result: { source: "service" } },
});
Expand Down
118 changes: 118 additions & 0 deletions apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,121 @@ test("request errors redact credentials even when no operation was accepted", as
submit.mockRestore();
}
});

test("Linux Rosetta configuration persists, survives resource updates and off removes it", async () => {
const { home, server, client } = await fixture();
const basePath = join(home, "base.img");
await writeFile(basePath, "synthetic Linux image");
await client.submit(
{
type: "environment.register",
environment: {
id: "linux",
name: "Linux",
os: "linux",
basePath,
cpu: 1,
memoryMiB: 512,
state: "ready",
},
},
"register",
);
await server.service.drain();
for (const [key, configuration] of [
["enable", { x64: "rosetta" }],
["resources", { memoryMiB: 1024 }],
["disable", { x64: "off" }],
] as const) {
const operation = await client.submit(
{ type: "environment.configure", id: "linux", ...configuration },
key,
);
await server.service.drain();
expect((await client.wait(operation.id)).status).toBe("succeeded");
const environment = (await client.status()).environments[0];
if (key === "disable") expect(environment).not.toHaveProperty("x64");
else expect(environment?.x64).toBe("rosetta");
}
});

test.each(["macos", "windows"] as const)("%s rejects Rosetta with a coded error", async (os) => {
const { home, server, client } = await fixture();
const basePath = join(home, "base");
if (os === "macos") await mkdir(basePath);
else await writeFile(basePath, "synthetic Windows image");
server.store.put("environment", os, {
id: os,
name: os,
os,
basePath,
cpu: 1,
memoryMiB: 512,
state: "ready",
});
const operation = await client.submit(
{ type: "environment.configure", id: os, x64: "rosetta" },
"enable",
);
await server.service.drain();
expect(await client.wait(operation.id)).toMatchObject({
status: "failed",
result: { code: "unsupported" },
message: expect.stringContaining("only for Linux"),
});
expect((await client.status()).environments[0]).not.toHaveProperty("x64");
const off = await client.submit({ type: "environment.configure", id: os, x64: "off" }, "off");
await server.service.drain();
expect((await client.wait(off.id)).status).toBe("succeeded");
});

test("Rosetta host and guest startup failures remain action required on the service", async () => {
const { home, server, client } = await fixture();
const { VectisError } = await import("../../../packages/protocol/src/index.js");
const basePath = join(home, "base.img");
await writeFile(basePath, "synthetic Linux image");
server.store.put("environment", "linux", {
id: "linux",
name: "Linux",
os: "linux",
basePath,
cpu: 1,
memoryMiB: 512,
state: "ready",
x64: "rosetta",
});
const start = vi.spyOn(server.service.runtime, "start");
try {
for (const code of [
"rosetta_not_installed",
"rosetta_unsupported",
"rosetta_guest_unprepared",
]) {
start.mockRejectedValueOnce(
new VectisError(code, "Rosetta needs attention.", "Resolve Rosetta setup."),
);
const operation = await client.submit({ type: "environment.start", id: "linux" }, code);
await server.service.drain();
expect(await client.wait(operation.id)).toMatchObject({
status: "action_required",
result: { code, nextStep: "Resolve Rosetta setup." },
});
}
} finally {
start.mockRestore();
}
});

test("invalid x64 values are rejected at the protocol boundary before mutation", async () => {
const { server } = await fixture();
const response = await fetch(`${server.connection.url}/v1/commands`, {
method: "POST",
headers: { Authorization: `Bearer ${server.connection.token}` },
body: JSON.stringify({
key: "invalid-x64",
command: { type: "environment.configure", id: "linux", x64: "qemu" },
}),
});
expect(response.status).toBe(400);
expect(server.service.snapshot().operations).toEqual([]);
});
24 changes: 24 additions & 0 deletions apps/server/src/runner-task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,3 +649,27 @@ test("a witnessed policy rejection stays failed if cancellation arrives during c
const result = await runRunnerTask("binding", "key", environment, controls, abort.signal);
expect(result).toMatchObject({ status: "failed", code: "runner_assignment_rejected" });
});

test.each(["rosetta_not_installed", "rosetta_unsupported", "rosetta_guest_unprepared"])(
"runner startup preserves actionable %s without allocating credentials",
async (code) => {
const { controls, broker } = fixture();
controls.start.mockRejectedValue(
new VectisError(code, "Rosetta needs setup.", "Fix Rosetta setup."),
);
const result = await runRunnerTask(
"binding",
"key",
{ ...environment, x64: "rosetta" },
controls,
AbortSignal.timeout(1000),
);
expect(result).toMatchObject({
status: "action_required",
code,
nextStep: "Fix Rosetta setup.",
});
expect(broker.prepareRunner).not.toHaveBeenCalled();
expect(controls.stop).toHaveBeenCalledOnce();
},
);
13 changes: 13 additions & 0 deletions apps/server/src/runner-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,19 @@ export async function runRunnerTask(
!(failure instanceof VectisError && failure.code === "runner_assignment_rejected")
)
return { status: "cancelled" as const, progress, message: "Runner cancelled and cleaned up." };
if (
failure instanceof VectisError &&
["rosetta_not_installed", "rosetta_unsupported", "rosetta_guest_unprepared"].includes(
failure.code,
)
)
return {
status: "action_required" as const,
progress,
message: failure.message,
code: failure.code,
nextStep: failure.nextStep,
};
if (failure)
return {
status: "failed" as const,
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/service-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,62 @@ test.skipIf(process.platform !== "darwin" || process.arch !== "arm64").each([
}
},
);

test.each(["rosetta_not_installed", "rosetta_unsupported", "rosetta_guest_unprepared"])(
"parent runner operation preserves %s from its instance startup",
async (code) => {
const { VectisError } = await import("../../../packages/protocol/src/index.js");
const home = await mkdtemp(join(tmpdir(), "vectis-rosetta-runner-"));
const store = new Store(":memory:");
const runtime = new VmRuntime({ home });
const unused = async () => {
throw new Error("Unexpected broker call");
};
const broker = {
reconcileRunner: vi.fn(unused),
prepareRunner: vi.fn(unused),
releaseRunner: vi.fn(async () => {}),
runnerStatus: vi.fn(unused),
repositories: async () => [
{ id: "binding", repositoryId: 1, repositoryName: "test/repo", environmentId: "linux" },
],
analyzeMigration: unused,
publishMigration: unused,
connectRepository: unused,
disconnectRepository: unused,
setAutomatic: unused,
setAccess: unused,
scanJobs: unused,
refreshJob: unused,
};
const service = new Service(store, runtime, () => broker);
vi.spyOn(runtime, "start").mockRejectedValue(
new VectisError(code, "Rosetta needs setup.", "Fix Rosetta setup."),
);
store.put("environment", "linux", {
id: "linux",
name: "Linux",
os: "linux",
state: "ready",
cpu: 1,
memoryMiB: 512,
basePath: join(home, "synthetic.img"),
x64: "rosetta",
});
try {
const operation = service.submit("runner", { type: "runner.run", bindingId: "binding" });
await service.drain();
await vi.waitFor(() =>
expect(store.snapshot().operations.find((item) => item.id === operation.id)).toMatchObject({
status: "action_required",
result: { code, nextStep: "Fix Rosetta setup." },
}),
);
expect(broker.prepareRunner).not.toHaveBeenCalled();
} finally {
await service.close();
store.close();
await rm(home, { recursive: true, force: true });
}
},
);
Loading
Loading