Skip to content

Commit 25aa536

Browse files
feat(vscode): install backend from pinned release assets (#734)
* feat(vscode): install backend from pinned release assets Co-authored-by: openhands <openhands@all-hands.dev> * feat(vscode): improve backend setup guidance Co-authored-by: openhands <openhands@all-hands.dev> * feat(apps): document and validate remote artifacts Teach the Canvas App contract and validator about mutually exclusive checksum-pinned HTTPS artifacts and their runtime prerequisite. Co-authored-by: openhands <openhands@all-hands.dev> --------- Co-authored-by: openhands <openhands@all-hands.dev>
1 parent f95fd9a commit 25aa536

11 files changed

Lines changed: 193 additions & 109 deletions

File tree

‎.github/workflows/build-vscode-app.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ jobs:
2121
run: |
2222
test -f dist/apps/vscode/canvas-extension.json
2323
test -f dist/apps/vscode/extension.js
24-
test -f dist/apps/vscode/backend/openvscode-linux-amd64.tar.gz
25-
test -f dist/apps/vscode/backend/openvscode-linux-arm64.tar.gz
24+
test -f dist/apps/vscode/README.md
25+
test -z "$(find dist/apps/vscode -type f -name '*.tar.gz' -print -quit)"
2626
- uses: actions/upload-artifact@v7
2727
with:
2828
name: vscode-app-${{ github.sha }}

‎apps/vscode/README.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
# VS Code App
22

3-
This App provides a full-height OpenVSCode editor. Before the backend is ready, the App shows backend status, an explicit workspace-folder input, and revision-bound prepare/start/stop/refresh actions. Once ready, the page contains only the editor iframe. The folder is persisted in browser storage and sent as an optional `query.folder` mount parameter; it is a user convenience, not OS sandboxing.
3+
Install this App in Agent Canvas by opening **Apps → Add App** and entering:
44

5-
## Artifact delivery
5+
```text
6+
https://github.com/OpenHands/extensions/tree/main/apps/vscode
7+
```
68

7-
The repository intentionally contains no OpenVSCode binaries. `scripts/package.mjs` downloads the pinned upstream OpenVSCode Server `v1.109.5` Linux amd64 and arm64 archives, verifies their upstream checksums, normalizes the archive layout and metadata, and writes the package-contained archives required by `canvas-extension.json`. The GitHub Actions workflow publishes the resulting `vscode-app-<commit>` artifact; that archive is the installable coordinate for this revision.
9+
The App provides a full-height OpenVSCode editor. Before the backend is ready, it shows backend status, an optional workspace-folder input, and revision-bound prepare/start/stop/refresh actions. Once ready, the page contains only the editor iframe.
810

9-
The package is not a release until reviewed and merged. The workflow artifact is a development delivery coordinate, not a published release or a version pin for Agent Server. This App depends on the Canvas consumer change that adds optional `mount({ container, query: { folder } })` support; until that consumer PR lands, setup works but the selected folder is not forwarded to OpenVSCode.
11+
## Artifact delivery
1012

11-
The backend uses app-owned data directories for user data and extensions and receives only the manifest allowlisted environment. It does not receive Agent Server credentials. OpenVSCode starts at the existing app-owned `{data_dir}` by default; the optional folder query changes the editor folder only when the Canvas consumer supports it. Folder selection is not OS sandboxing.
13+
The repository contains no OpenVSCode binaries. During the explicit **Prepare and start** step, Agent Server downloads only the current platform's pinned OpenVSCode Server `v1.109.5` archive from its public GitHub Release, verifies the manifest-pinned SHA-256 digest, safely extracts it into an App-owned content-addressed cache, and starts it with App-owned home, user-data, and extensions directories. It does not send Agent Server or repository credentials to the artifact host.
1214

13-
The real ngrok editor/save/terminal click-through GIF is maintained in the Agent Canvas implementation PR as development-stack evidence. It is not bundled here and is not a release or package test.
15+
The optional folder is persisted in browser storage and forwarded as the editor folder. It is a user convenience, not OS sandboxing.

‎apps/vscode/canvas-extension.json‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,18 @@
99
"backend": {
1010
"schema_version": 1,
1111
"artifacts": {
12-
"linux-amd64": {"path": "backend/openvscode-linux-amd64.tar.gz", "sha256": "5ad14274cc5e5ed938ffecf1dd53c0e58272828d8b50cb5da93b6d9e5d2cd9d5"},
13-
"linux-arm64": {"path": "backend/openvscode-linux-arm64.tar.gz", "sha256": "91876160f1b549020204e3909ad42a80d22ef0ebf66a62306cdf60ca5d44524b"}
12+
"linux-amd64": {
13+
"url": "https://github.com/gitpod-io/openvscode-server/releases/download/openvscode-server-v1.109.5/openvscode-server-v1.109.5-linux-x64.tar.gz",
14+
"sha256": "b433bf4f0227321a7014d8460d10a8f958adc0f45aa79bd889e84e65e8f88363",
15+
"strip_components": 1
16+
},
17+
"linux-arm64": {
18+
"url": "https://github.com/gitpod-io/openvscode-server/releases/download/openvscode-server-v1.109.5/openvscode-server-v1.109.5-linux-arm64.tar.gz",
19+
"sha256": "36d9c14036489b63de84ebace837fcacf7e60e669a0dc715802c5443684ea4dc",
20+
"strip_components": 1
21+
}
1422
},
15-
"argv": ["{artifact_dir}/bin/openvscode-bootstrap", "--host", "127.0.0.1", "--port", "{port}", "--without-connection-token", "--accept-server-license-terms", "--telemetry-level", "off", "--user-data-dir", "{data_dir}/user", "--extensions-dir", "{data_dir}/extensions", "--server-base-path", "/app-backends/vscode", "{data_dir}"],
23+
"argv": ["{artifact_dir}/bin/openvscode-server", "--host", "127.0.0.1", "--port", "{port}", "--without-connection-token", "--accept-server-license-terms", "--telemetry-level", "off", "--user-data-dir", "{data_dir}/user", "--extensions-dir", "{data_dir}/extensions", "--server-base-path", "/app-backends/vscode", "{data_dir}"],
1624
"health": {"path": "/", "timeout_seconds": 90, "interval_seconds": 0.5},
1725
"inherit_environment": ["PATH", "LANG", "LC_ALL", "TMPDIR"]
1826
}

‎apps/vscode/extension.js‎

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,32 @@ function folderKey(host) {
55
return `${FOLDER_KEY_PREFIX}:${host.backend.id}:${host.backend.orgId || "local"}:${host.extension.name}`;
66
}
77

8-
function button(label, onClick) {
8+
function button(label, onClick, variant = "secondary") {
99
const element = document.createElement("button");
1010
element.type = "button";
1111
element.textContent = label;
12+
element.style.cssText = [
13+
"min-height:40px",
14+
"padding:0 18px",
15+
"border-radius:8px",
16+
"border:1px solid transparent",
17+
"font:inherit",
18+
"font-size:14px",
19+
"font-weight:600",
20+
"cursor:pointer",
21+
"transition:opacity 120ms ease,background-color 120ms ease",
22+
variant === "primary"
23+
? "background:#f3ce49;color:#171717"
24+
: "background:#262626;color:#fafafa;border-color:#525252",
25+
].join(";");
1226
element.addEventListener("click", onClick);
1327
return element;
1428
}
1529

30+
function statusLabel(state) {
31+
return state.charAt(0).toUpperCase() + state.slice(1);
32+
}
33+
1634
export function activate(host) {
1735
return host.registerPage("editor", async ({ container }) => {
1836
container.style.minHeight = "100vh";
@@ -54,26 +72,55 @@ export function activate(host) {
5472
return;
5573
}
5674

75+
shell.style.justifyContent = "center";
76+
shell.style.alignItems = "center";
77+
shell.style.padding = "32px";
78+
shell.style.boxSizing = "border-box";
79+
5780
const panel = document.createElement("section");
5881
panel.setAttribute("aria-live", "polite");
82+
panel.style.cssText = "width:min(100%,520px);padding:32px;border:1px solid #262626;border-radius:12px;background:#0a0a0a;color:#fafafa;box-sizing:border-box;text-align:center";
5983
const heading = document.createElement("h2");
60-
heading.textContent = "VS Code setup";
61-
const detail = document.createElement("p");
62-
detail.textContent = status.detail || `Backend status: ${status.state}.`;
63-
panel.append(heading, detail);
84+
heading.textContent = "Set up VS Code";
85+
heading.style.cssText = "margin:0;font-size:24px;line-height:32px;font-weight:600";
86+
const description = document.createElement("p");
87+
description.textContent = "Start the secure VS Code backend for this Agent Canvas session. The first launch downloads and verifies the pinned editor package.";
88+
description.style.cssText = "margin:12px auto 0;max-width:440px;color:#a3a3a3;font-size:14px;line-height:21px";
89+
const statusRow = document.createElement("div");
90+
statusRow.style.cssText = "display:flex;align-items:center;justify-content:center;gap:8px;margin:20px 0 24px;font-size:13px;color:#a3a3a3";
91+
const statusDot = document.createElement("span");
92+
statusDot.setAttribute("aria-hidden", "true");
93+
statusDot.style.cssText = `width:8px;height:8px;border-radius:999px;background:${status.state === "unhealthy" ? "#fda4af" : "#737373"}`;
94+
const detail = document.createElement("span");
95+
detail.textContent = status.detail || `Backend status: ${statusLabel(status.state)}`;
96+
statusRow.append(statusDot, detail);
97+
panel.append(heading, description, statusRow);
98+
99+
const field = document.createElement("label");
100+
field.style.cssText = "display:flex;flex-direction:column;gap:8px;text-align:left";
101+
const fieldLabel = document.createElement("span");
102+
fieldLabel.textContent = "Workspace folder";
103+
fieldLabel.style.cssText = "font-size:13px;font-weight:500;color:#fafafa";
64104
const folder = document.createElement("input");
65105
folder.type = "text";
66-
folder.placeholder = "Workspace folder path (optional)";
106+
folder.placeholder = "Optional path to open in VS Code";
67107
folder.value = localStorage.getItem(storageKey) || "";
68108
folder.setAttribute("aria-label", "Workspace folder path");
69-
panel.append(folder);
70-
const actions = document.createElement("p");
109+
folder.style.cssText = "width:100%;height:42px;padding:0 12px;border:1px solid #525252;border-radius:8px;background:#1a1a1a;color:#fafafa;font:inherit;font-size:14px;box-sizing:border-box;outline:none";
110+
const hint = document.createElement("span");
111+
hint.textContent = "Leave blank to open the default App workspace.";
112+
hint.style.cssText = "font-size:12px;color:#8c8c8c";
113+
field.append(fieldLabel, folder, hint);
114+
panel.append(field);
115+
116+
const actions = document.createElement("div");
117+
actions.style.cssText = "display:flex;justify-content:center;gap:10px;margin-top:24px;flex-wrap:wrap";
71118
if (status.state === "missing" || status.state === "stopped" || status.state === "unhealthy") {
72119
actions.append(button("Prepare and start", async () => {
73120
const selectedFolder = folder.value.trim();
74121
if (selectedFolder) localStorage.setItem(storageKey, selectedFolder);
75122
else localStorage.removeItem(storageKey);
76-
actions.replaceChildren(document.createTextNode("Preparing..."));
123+
actions.replaceChildren(document.createTextNode("Preparing and verifying VS Code..."));
77124
try {
78125
let approval = status;
79126
if (!approval.revision) approval = await request("GET");
@@ -90,14 +137,15 @@ export function activate(host) {
90137
render(await request("GET"));
91138
} catch (error) {
92139
detail.textContent = `Setup failed: ${error.message}`;
93-
actions.replaceChildren(button("Prepare and start", () => render(status)));
140+
statusDot.style.background = "#fda4af";
141+
actions.replaceChildren(button("Try again", () => render(status), "primary"));
94142
}
95-
}));
143+
}, "primary"));
96144
}
97145
if (status.state === "ready" || status.state === "starting") {
98146
actions.append(button("Stop", async () => render(await request("POST", `${BACKEND}/stop`))));
99147
}
100-
actions.append(button("Refresh", async () => render(await request("GET"))));
148+
actions.append(button("Refresh status", async () => render(await request("GET"))));
101149
panel.append(actions);
102150
shell.append(panel);
103151
};

‎apps/vscode/scripts/package.mjs‎

Lines changed: 20 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,30 @@
1-
import { createHash } from "node:crypto";
2-
import { readdir, readFile, mkdir, rm, writeFile, chmod, stat } from "node:fs/promises";
3-
import { execFile } from "node:child_process";
4-
import { promisify } from "node:util";
1+
import { cp, mkdir, readFile, rm } from "node:fs/promises";
52
import path from "node:path";
63

7-
const exec = promisify(execFile);
84
const appRoot = path.resolve(new URL("..", import.meta.url).pathname);
95
const root = path.resolve(appRoot, "../..");
10-
if (process.platform !== "linux") throw new Error("VS Code App packaging currently supports Linux build runners only");
11-
12-
/**
13-
* Force a umask-independent mode set on the staged tree.
14-
*
15-
* `cp -a` preserves whatever modes tar materialized, and the build host's umask
16-
* narrows them (0664/0775 under umask 0002, 0644/0755 under umask 022). Those
17-
* bits end up in the final archive's tar headers, so the digest — and with it
18-
* the `sha256` declared in canvas-extension.json — used to vary per build host.
19-
*/
20-
async function normalizeModes(dir) {
21-
for (const entry of await readdir(dir, { withFileTypes: true })) {
22-
const target = path.join(dir, entry.name);
23-
if (entry.isSymbolicLink()) continue;
24-
if (entry.isDirectory()) {
25-
await chmod(target, 0o755);
26-
await normalizeModes(target);
27-
} else if (entry.isFile()) {
28-
const { mode } = await stat(target);
29-
await chmod(target, mode & 0o111 ? 0o755 : 0o644);
30-
}
31-
}
32-
}
336
const output = path.resolve(process.env.OUTPUT_DIR ?? path.join(root, "dist"));
34-
const version = "1.109.5";
35-
const releases = {
36-
"linux-amd64": ["x64", "b433bf4f0227321a7014d8460d10a8f958adc0f45aa79bd889e84e65e8f88363"],
37-
"linux-arm64": ["arm64", "36d9c14036489b63de84ebace837fcacf7e60e669a0dc715802c5443684ea4dc"],
38-
};
39-
const work = path.join(output, ".download");
407
const packageRoot = path.join(output, "apps", "vscode");
41-
const manifest = JSON.parse(await readFile(path.join(appRoot, "canvas-extension.json"), "utf8"));
42-
await rm(work, { recursive: true, force: true });
43-
await rm(packageRoot, { recursive: true, force: true });
44-
await mkdir(work, { recursive: true });
45-
await mkdir(path.join(packageRoot, "backend"), { recursive: true });
46-
for (const [platform, [upstreamArch, expected]] of Object.entries(releases)) {
47-
const source = path.join(work, `${platform}.tar.gz`);
48-
const archive = `openvscode-server-v${version}-linux-${upstreamArch}.tar.gz`;
49-
const url = `https://github.com/gitpod-io/openvscode-server/releases/download/openvscode-server-v${version}/${archive}`;
50-
await exec("curl", ["--fail", "--location", "--retry", "3", "--output", source, url]);
51-
const actual = createHash("sha256").update(await readFile(source)).digest("hex");
52-
if (actual !== expected) throw new Error(`${platform}: upstream checksum mismatch (${actual})`);
53-
const unpacked = path.join(work, `${platform}-unpacked`);
54-
const normalized = path.join(work, `${platform}-normalized`);
55-
await mkdir(unpacked, { recursive: true });
56-
await exec("tar", ["-xzf", source, "-C", unpacked]);
57-
await mkdir(normalized, { recursive: true });
58-
const [top] = await readdir(unpacked);
59-
await exec("cp", ["-a", `${path.join(unpacked, top)}/.`, normalized]);
60-
await writeFile(path.join(normalized, "bin", "openvscode-bootstrap"), "#!/bin/sh\nset -eu\ndata_dir=\"\"\nfor candidate do data_dir=\"$candidate\"; done\nmkdir -p \"$data_dir\" \"$data_dir/home\" \"$data_dir/workspace\"\nexport HOME=\"$data_dir/home\"\nexec \"$(dirname \"$0\")/openvscode-server\" \"$@\"\n");
61-
await exec("chmod", ["0755", path.join(normalized, "bin", "openvscode-bootstrap")]);
62-
await normalizeModes(normalized);
63-
const destination = path.join(packageRoot, "backend", `openvscode-${platform}.tar.gz`);
64-
await exec("tar", ["--sort=name", "--mtime=UTC 1970-01-01", "--owner=0", "--group=0", "--numeric-owner", "-czf", destination, "-C", normalized, "."]);
65-
const produced = createHash("sha256").update(await readFile(destination)).digest("hex");
66-
const artifact = manifest?.backend?.artifacts?.[platform];
67-
if (!artifact?.sha256) {
68-
throw new Error(`canvas-extension.json declares no backend.artifacts.${platform}.sha256`);
8+
const manifest = JSON.parse(
9+
await readFile(path.join(appRoot, "canvas-extension.json"), "utf8"),
10+
);
11+
12+
for (const [platform, artifact] of Object.entries(manifest.backend.artifacts)) {
13+
const url = new URL(artifact.url);
14+
if (url.protocol !== "https:" || !url.pathname.endsWith(".tar.gz")) {
15+
throw new Error(`${platform}: invalid HTTPS artifact URL`);
6916
}
70-
if (path.basename(destination) !== path.basename(artifact.path)) {
71-
throw new Error(`${platform}: built ${path.basename(destination)} but canvas-extension.json declares ${artifact.path}`);
17+
if (!/^[0-9a-f]{64}$/.test(artifact.sha256)) {
18+
throw new Error(`${platform}: invalid SHA-256`);
7219
}
73-
if (produced !== artifact.sha256) {
74-
throw new Error(
75-
`${platform}: built archive digest ${produced} does not match the sha256 declared for backend.artifacts.${platform} in canvas-extension.json (${artifact.sha256}); update the manifest with the reproducible digest`
76-
);
20+
if (!Number.isInteger(artifact.strip_components) || artifact.strip_components < 0) {
21+
throw new Error(`${platform}: invalid strip_components`);
7722
}
78-
console.log(`${platform}: ${produced}`);
7923
}
80-
await exec("cp", [path.join(appRoot, "canvas-extension.json"), path.join(packageRoot, "canvas-extension.json")]);
81-
await exec("cp", [path.join(appRoot, "extension.js"), path.join(packageRoot, "extension.js")]);
82-
await exec("cp", [path.join(appRoot, "README.md"), path.join(packageRoot, "README.md")]);
83-
await rm(work, { recursive: true, force: true });
84-
console.log(`Wrote ${packageRoot}`);
24+
25+
await rm(packageRoot, { recursive: true, force: true });
26+
await mkdir(packageRoot, { recursive: true });
27+
for (const filename of ["canvas-extension.json", "extension.js", "README.md"]) {
28+
await cp(path.join(appRoot, filename), path.join(packageRoot, filename));
29+
}
30+
console.log(`Wrote source-installable App package to ${packageRoot}`);

0 commit comments

Comments
 (0)