From 963715231002a75303e2fa94e66197ef6ce84a6d Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Fri, 6 Nov 2026 12:46:17 -0500 Subject: [PATCH] =?UTF-8?q?feat(extension):=20EULA=20gate=20=E2=80=94=20mo?= =?UTF-8?q?dal=20acceptance=20on=20first=20activation=20+=20EULA.txt=20pac?= =?UTF-8?q?kaged=20as=20license=20asset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code has no built-in extension EULA infrastructure (microsoft/vscode#173887, under discussion). Two-layer approach: - Packaged license: 'license': 'SEE LICENSE IN EULA.txt' — vsce ships the placeholder EULA.txt as the Microsoft.VisualStudio.Services.Content.License gallery asset. - Runtime gate: modal (Accept / Read EULA / Decline) on first activation, persisted per machine in globalState, keyed on EULA_VERSION. Declining leaves the extension fully inert (status bar item + sidebar notice + amicode.reviewEula command) until accepted. Sidebar view stays registered as a notice stub. EULA.txt is an explicit placeholder pending legal text; bump EULA_VERSION in src/eula.ts when final wording lands to re-prompt all users. --- packages/extension/EULA.txt | 75 ++++++ packages/extension/package.json | 7 +- packages/extension/src/eula.ts | 235 ++++++++++++++++++ packages/extension/src/extension.ts | 14 ++ packages/extension/test/eula.test.ts | 67 +++++ .../panel_registration_invariants.test.ts | 2 + 6 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 packages/extension/EULA.txt create mode 100644 packages/extension/src/eula.ts create mode 100644 packages/extension/test/eula.test.ts diff --git a/packages/extension/EULA.txt b/packages/extension/EULA.txt new file mode 100644 index 000000000..ebf2f3d0b --- /dev/null +++ b/packages/extension/EULA.txt @@ -0,0 +1,75 @@ +AMICODE — END USER LICENSE AGREEMENT +Version 0.1.0-placeholder (effective 2026-11-05) + +*** PLACEHOLDER — NOT LEGAL TEXT *** +This file is a stand-in. Harmoniqs has not supplied the real End User License +Agreement yet. Everything below is placeholder material so the install-time +gate, the packaged license asset, and the review flow can be wired up and +tested. Replace this file with counsel-approved wording before any external +release. Do NOT ship this text as-is. +*** END PLACEHOLDER NOTICE *** + +1. Parties + This End User License Agreement ("Agreement") is a legal agreement between + you ("Licensee") and Harmoniqs, Inc. ("Harmoniqs", "we", "us") governing + your use of the Amicode extension for Visual Studio Code (the "Software"). + +2. Grant of License + Subject to your acceptance of this Agreement, Harmoniqs grants you a + non-exclusive, non-transferable, revocable license to install and use the + Software for its intended purpose, in accordance with this Agreement. + +3. Restrictions (placeholder) + You may not: (a) reverse engineer the Software except as permitted by law; + (b) redistribute the Software except as permitted by its license notices; + (c) use the Software in violation of applicable law; (d) remove or alter + any proprietary notices in the Software. + +4. Beta / Preview Notice + The Software is currently a preview ("preview": true). It may contain + errors, change materially, or be withdrawn at any time. No SLA is offered. + +5. Data & Telemetry + The Software may include opt-in telemetry (see the amicode.telemetry.* + settings). Telemetry is OFF by default and nothing is transmitted until you + explicitly consent in the product. See the README for details. + +6. No Warranty (placeholder) + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + +7. Limitation of Liability (placeholder) + IN NO EVENT SHALL HARMONIQS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, + SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES ARISING OUT OF OR IN CONNECTION + WITH THIS AGREEMENT OR THE USE OF THE SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGES. + +8. Termination + This Agreement terminates automatically upon breach of its terms. Upon + termination you must cease all use of the Software and uninstall it. + +9. Governing Law + This Agreement is governed by the laws of [PLACEHOLDER — jurisdiction to be + supplied with the final text], without regard to conflict-of-laws rules. + +10. Source Code License + The Amicode source code in this repository is licensed under the Apache + License, Version 2.0 (see the LICENSE file at the repository root). This + Agreement governs the packaged extension and its distribution to end + users; where the two conflict, this Agreement controls for the packaged + distribution. Third-party components retain their own licenses (see + THIRD_PARTY_LICENSES.md). + +11. Entire Agreement; Changes + This Agreement is the placeholder entire agreement between you and + Harmoniqs regarding the Software. Harmoniqs may update this Agreement; on + a material change the Software will require you to review and accept the + new version before it runs again. The version line at the top tracks + acceptance — bump it when the final text is in place. + +12. Contact + Questions about this Agreement: [PLACEHOLDER — legal contact to be + supplied with the final text]. + +Copyright (c) 2026 Harmoniqs, Inc. All rights reserved. diff --git a/packages/extension/package.json b/packages/extension/package.json index b03e9dc06..71767376a 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -4,7 +4,7 @@ "description": "Open autonomous research in VS Code \u2014 vaults, fleet, and live solves for quantum control and physical intelligence.", "version": "0.4.0", "publisher": "harmoniqs", - "license": "Apache-2.0", + "license": "SEE LICENSE IN EULA.txt", "icon": "media/icon.png", "private": false, "preview": true, @@ -39,6 +39,7 @@ "onCommand:amicode.newChat", "onCommand:amicode.newProject", "onCommand:amicode.chatDeck", + "onCommand:amicode.reviewEula", "onView:amicode.workspace", "onStartupFinished" ], @@ -66,6 +67,10 @@ ] }, "commands": [ + { + "command": "amicode.reviewEula", + "title": "Amicode: Review EULA" + }, { "command": "amicode.onboarding.open", "title": "Amicode: Open Onboarding" diff --git a/packages/extension/src/eula.ts b/packages/extension/src/eula.ts new file mode 100644 index 000000000..4ed9a6901 --- /dev/null +++ b/packages/extension/src/eula.ts @@ -0,0 +1,235 @@ +// eula.ts — the install-time End User License Agreement gate. +// +// VS Code has no declarative, manifest-level "must accept the EULA to install" +// hook for extensions (verified against microsoft/vscode — the only shipping +// mechanism is the `license` manifest field, packaged by vsce as the +// `Microsoft.VisualStudio.Services.Content.License` gallery asset, shown under +// Resources on the Marketplace/Open VSX listing). The SUPPORTED way to make an +// end user actually agree is the pattern Microsoft uses for C/C++ (cpptools) and +// the IntelliJ extensions: a modal Accept/Decline prompt on first activation, +// persisted per machine, with the extension inert until acceptance. +// +// This module owns: +// - EULA_VERSION: bump it whenever the EULA text changes materially — recorded +// acceptance is keyed on it, so everyone is re-prompted on the new text. +// - isEulaAccepted(memento): the pure gate predicate (unit-tested). +// - eulaText(): the text shown when the user asks to read (EULA.txt from the +// extension root — the same file vsce packages as the License asset). +// - ensureEulaAccepted(): the activation-time gate. Runs the modal loop. +// - registerEulaCommand(): the `amicode.reviewEula` palette command, available +// in EVERY mode (full and gate) so a declining user can always come back. +// - registerEulaGateSurface(): the inert-mode UX — status bar item + a stub +// sidebar view (the contributed `amicode.workspace` view would otherwise +// render VS Code's generic "no provider registered" error pane). +// +// Acceptance lives in globalState (per machine), like the telemetry consent and +// the What's-New last-seen version. An install that accepted v is NOT +// accepted for a bumped EULA_VERSION. +import * as vscode from "vscode"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export const EULA_ACCEPTED_KEY = "amicode.eula.acceptedVersion"; +/** The EULA text version acceptance is keyed on. Bump on any material change + * to EULA.txt (the final legal text lands as a bump, re-prompting all users). */ +export const EULA_VERSION = "0.1.0-placeholder"; +export const EULA_FILE = "EULA.txt"; + +/** User-facing EULA version for UI chrome (sidebar footer, modal). The + * acceptance key stays EULA_VERSION ("0.1.0-placeholder") until the final + * legal text lands; the UI shows the clean version. */ +export const EULA_DISPLAY_VERSION = "0.1.0"; + +const ACCEPT = "Accept EULA"; +const READ = "Read EULA"; +const DECLINE = "Decline"; + +/** Embedded fallback so the read-path never dead-ends if EULA.txt is missing + * from the install (packaging regression). Deliberately short; the real text + * lives in EULA.txt. */ +export const PLACEHOLDER_EULA = `AMICODE — END USER LICENSE AGREEMENT +Version ${EULA_VERSION} (PLACEHOLDER) + +The bundled EULA.txt could not be read from this installation. This is a +placeholder, not legal text — Harmoniqs has not supplied the final agreement. +By accepting you agree to be bound by the EULA shipped in this extension +(EULA.txt). Replace this file with counsel-approved wording before external +release. Copyright (c) 2026 Harmoniqs, Inc.`; + +/** Minimal memento shape (matches the WhatsNewMemento convention). */ +export interface EulaMemento { + get(key: string): T | undefined; + update(key: string, value: unknown): Thenable | Promise; +} + +/** The gate predicate: accepted IFF the recorded version matches the current + * EULA_VERSION. Unset, an older version, or junk → not accepted. */ +export function isEulaAccepted(memento: EulaMemento | undefined): boolean { + if (!memento) return false; + return memento.get(EULA_ACCEPTED_KEY) === EULA_VERSION; +} + +/** The EULA text for the read-path: EULA.txt from the extension root, falling + * back to the embedded placeholder. Never throws. */ +export function eulaText(extensionPath: string, readFile: (p: string) => string = (p) => fs.readFileSync(p, "utf8")): string { + try { + const text = readFile(path.join(extensionPath, EULA_FILE)); + return text.trim() !== "" ? text : PLACEHOLDER_EULA; + } catch { + return PLACEHOLDER_EULA; + } +} + +/** Open the EULA for reading: the real EULA.txt when present, else an untitled + * document carrying the embedded placeholder. */ +async function openEulaDocument(ctx: vscode.ExtensionContext): Promise { + try { + const uri = vscode.Uri.file(path.join(ctx.extensionPath, EULA_FILE)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc, { preview: false }); + return; + } catch { + // fall through to the embedded placeholder + } + try { + const doc = await vscode.workspace.openTextDocument({ content: PLACEHOLDER_EULA, language: "plaintext" }); + await vscode.window.showTextDocument(doc, { preview: false }); + } catch { + /* no editor available — the modal keeps looping; the user can still accept or decline */ + } +} + +const MODAL_DETAIL = + `Amicode is provided by Harmoniqs, Inc. under the End User License Agreement bundled with this extension ` + + `(EULA.txt, version ${EULA_DISPLAY_VERSION}).\n\n` + + `Accept to activate Amicode. Decline and Amicode stays inert — no server, no views, no commands — until you ` + + `accept (Command Palette → "Amicode: Review EULA").`; + +/** The modal accept/read/decline loop. Resolves true iff the user accepted + * (and acceptance was recorded). Declining or dismissing the modal → false. */ +export async function promptForEula(ctx: vscode.ExtensionContext, log?: (line: string) => void): Promise { + for (;;) { + const choice = await vscode.window.showInformationMessage( + "Amicode End User License Agreement", + { modal: true, detail: MODAL_DETAIL }, + ACCEPT, + READ, + DECLINE, + ); + if (choice === ACCEPT) { + await ctx.globalState.update(EULA_ACCEPTED_KEY, EULA_VERSION); + log?.(`[eula] EULA ${EULA_VERSION} accepted`); + return true; + } + if (choice === READ) { + await openEulaDocument(ctx); + continue; + } + log?.(`[eula] EULA ${EULA_VERSION} not accepted (choice: ${choice ?? "dismissed"})`); + return false; + } +} + +/** Activation-time gate. true = accepted (now or previously) → proceed with a + * full activation. false = declined/dismissed → caller registers the inert + * gate surface and returns. */ +export async function ensureEulaAccepted( + ctx: vscode.ExtensionContext, + log?: (line: string) => void, +): Promise { + if (isEulaAccepted(ctx.globalState)) return true; + return promptForEula(ctx, log); +} + +async function reviewEula(ctx: vscode.ExtensionContext, log?: (line: string) => void): Promise { + if (isEulaAccepted(ctx.globalState)) { + await openEulaDocument(ctx); + return; + } + const accepted = await promptForEula(ctx, log); + if (!accepted) return; + const reload = await vscode.window.showInformationMessage( + "Thank you — the Amicode EULA is accepted. Reload the window to finish activating Amicode?", + "Reload Window", + "Later", + ); + if (reload === "Reload Window") { + await vscode.commands.executeCommand("workbench.action.reloadWindow"); + } +} + +/** Register `amicode.reviewEula`. Called in BOTH modes — in the full mode it + * re-opens the EULA for review; in the gate mode it is the only way back in. */ +export function registerEulaCommand(ctx: vscode.ExtensionContext, log?: (line: string) => void): void { + ctx.subscriptions.push(vscode.commands.registerCommand("amicode.reviewEula", () => void reviewEula(ctx, log))); +} + +/** The inert surface for a declined/unaccepted install: the review command, a + * persistent status bar item, and a stub for the contributed `amicode.workspace` + * webview view so it renders a notice instead of VS Code's generic + * "no provider registered" error. */ +export function registerEulaGateSurface(ctx: vscode.ExtensionContext, log?: (line: string) => void): void { + registerEulaCommand(ctx, log); + + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + status.text = "$(law) Amicode EULA"; + status.tooltip = "Amicode is disabled until you accept the End User License Agreement. Click to review."; + status.command = "amicode.reviewEula"; + status.show(); + ctx.subscriptions.push(status); + + ctx.subscriptions.push( + vscode.window.registerWebviewViewProvider( + "amicode.workspace", + { + resolveWebviewView(webviewView) { + webviewView.webview.options = { + enableCommandUris: true, + // retain-exempt: static EULA notice — no SSE stream or session state to suspend when hidden + }; + webviewView.webview.html = eulaGateHtml(); + }, + }, + { webviewOptions: { retainContextWhenHidden: true } }, + ), + ); +} + +/** The inert sidebar body. Command URI → amicode.reviewEula (requires the + * webview's enableCommandUris, set above). */ +export function eulaGateHtml(): string { + return ` + + + + +Amicode — EULA required + + + +

Accept the EULA to activate Amicode

+

Amicode is inert until you read and accept the End User License Agreement bundled with the extension + (EULA.txt, version ${EULA_DISPLAY_VERSION}).

+
    +
  • No server is spawned
  • +
  • No chat, views, or commands are active
  • +
+

Accepting takes one click and is remembered on this machine.

+ Review & Accept EULA… +
Amicode 0.4.0 · EULA ${EULA_DISPLAY_VERSION}
+ +`; +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 8a34d6740..ab6ee80df 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -138,6 +138,7 @@ import { type HubClientSession, } from "./hub_client"; import { stopServer } from "./stop_server"; +import { ensureEulaAccepted, registerEulaCommand, registerEulaGateSurface } from "./eula"; import { quitAmicode } from "./quit_command"; import { pushEngineState, pushFleetRole } from "./engine_state_push"; import type { QueueView } from "./qick_job_server"; @@ -145,6 +146,7 @@ import { postDeviceStatus, postDeviceActions, postDeviceActivate } from "./inspe // ============================================================================ // Extension entry point. Boot order on activate: +// 0. EULA gate — decline ⇒ inert (see eula.ts); accept ⇒ continue // 1. Register UI surfaces (trees, inspector, status bar, commands) // 2. Start watching /tmp/amicode-runs/latest/ for new runs // 3. Write per-session opencode project dir with AGENTS.md @@ -430,6 +432,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const devicesChannel = vscode.window.createOutputChannel("Amicode — devices"); ctx.subscriptions.push(opencodeChannel, runsChannel, devicesChannel); + // EULA gate — before ANYTHING else activates (no staging, no server spawn, + // no views, no commands). Declining registers only the inert surface (a + // status bar item, a notice in the sidebar view, and `amicode.reviewEula`); + // Amicode stays off until the user reads and accepts the EULA. Acceptance is + // per machine and keyed on the EULA version — a material EULA change + // re-prompts everyone. + if (!(await ensureEulaAccepted(ctx, (l) => opencodeChannel.appendLine(l)))) { + registerEulaGateSurface(ctx, (l) => opencodeChannel.appendLine(l)); + return; + } + registerEulaCommand(ctx, (l) => opencodeChannel.appendLine(l)); + // Runs root (resolved early — the inspector needs it for its CSP resource roots). const runsRoot = resolveRunsRoot(vscode.workspace.getConfiguration("amicode").get("runsRoot", "")); diff --git a/packages/extension/test/eula.test.ts b/packages/extension/test/eula.test.ts new file mode 100644 index 000000000..e46c27cf0 --- /dev/null +++ b/packages/extension/test/eula.test.ts @@ -0,0 +1,67 @@ +// eula.ts — the install-time EULA gate: the pure predicate, the text loader's +// fallback, and the inert-gate HTML (command URI → amicode.reviewEula). +import { describe, expect, it } from "vitest"; +import { + EULA_ACCEPTED_KEY, + EULA_VERSION, + PLACEHOLDER_EULA, + eulaGateHtml, + eulaText, + isEulaAccepted, +} from "../src/eula"; + +function memento(init: Record = {}) { + const data = new Map(Object.entries(init)); + return { + data, + get: (k: string) => data.get(k) as T | undefined, + update: async (k: string, v: unknown) => { + data.set(k, v); + }, + }; +} + +describe("isEulaAccepted", () => { + it("a fresh install (unset) is not accepted", () => { + expect(isEulaAccepted(memento())).toBe(false); + }); + it("acceptance of the CURRENT version is accepted", () => { + expect(isEulaAccepted(memento({ [EULA_ACCEPTED_KEY]: EULA_VERSION }))).toBe(true); + }); + it("acceptance of an OLD version re-prompts (not accepted)", () => { + expect(isEulaAccepted(memento({ [EULA_ACCEPTED_KEY]: "0.0.1" }))).toBe(false); + }); + it("junk in the key is not accepted", () => { + expect(isEulaAccepted(memento({ [EULA_ACCEPTED_KEY]: true }))).toBe(false); + expect(isEulaAccepted(memento({ [EULA_ACCEPTED_KEY]: 1 }))).toBe(false); + }); + it("no memento → not accepted", () => { + expect(isEulaAccepted(undefined)).toBe(false); + }); +}); + +describe("eulaText", () => { + it("reads EULA.txt from the extension root when present", () => { + const text = eulaText("/fake/ext", () => "REAL EULA TEXT"); + expect(text).toBe("REAL EULA TEXT"); + }); + it("falls back to the embedded placeholder when the file is missing", () => { + const text = eulaText("/fake/ext", () => { + throw new Error("ENOENT"); + }); + expect(text).toBe(PLACEHOLDER_EULA); + }); + it("falls back to the placeholder on an empty file", () => { + expect(eulaText("/fake/ext", () => " ")).toBe(PLACEHOLDER_EULA); + }); +}); + +describe("eulaGateHtml", () => { + it("links to the review command and shows the clean display version", () => { + const html = eulaGateHtml(); + expect(html).toContain("command:amicode.reviewEula"); + expect(html).toContain("0.1.0"); + expect(html).toContain("Review & Accept EULA…"); + expect(html).not.toContain("0.1.0-placeholder"); + }); +}); diff --git a/packages/extension/test/panel_registration_invariants.test.ts b/packages/extension/test/panel_registration_invariants.test.ts index 9cb1ed1e6..d1ae696a2 100644 --- a/packages/extension/test/panel_registration_invariants.test.ts +++ b/packages/extension/test/panel_registration_invariants.test.ts @@ -183,6 +183,8 @@ function invariantViolations(regs: PanelRegistration[]): Array<{ reg: PanelRegis // that the enumeration is alive and complete. const GOLDEN_REGISTRATIONS = [ + { kind: "registerWebviewViewProvider", viewType: "amicode.workspace", file: "eula.ts", status: "retained" }, + { kind: "webview.options", viewType: "", file: "eula.ts", status: "exempt" }, { kind: "registerWebviewViewProvider", viewType: "amicode.workspace", file: "extension.ts", status: "retained" }, { kind: "createWebviewPanel", viewType: "amicode.chat", file: "chat_panel.ts", status: "retained" }, { kind: "createWebviewPanel", viewType: "amicode.deck", file: "deck_panel.ts", status: "retained" },