From 82a04eae4438dfb20a44f20a437e311b9b33b050 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Tue, 21 Jul 2026 21:31:35 -0400 Subject: [PATCH 1/2] feat(pasqal): validator list-projects mode for the two-step picker (#194) When PASQAL_PROJECT_ID is absent, authenticate on username+password alone and return the caller's projects ({ok, mode:'list', projects:[{id,name}], token, expires_at}) so the panel can offer a picker. When present, the original connect-and-validate behavior is unchanged. Fixed error messages only; no secret ever printed. Co-Authored-By: Claude Opus 4.8 --- .../pasqal-connector/pasqal_validate.py | 101 ++++++++++++++++-- 1 file changed, 94 insertions(+), 7 deletions(-) diff --git a/packages/extension/scripts/pasqal-connector/pasqal_validate.py b/packages/extension/scripts/pasqal-connector/pasqal_validate.py index ef46086e..b28ce38f 100644 --- a/packages/extension/scripts/pasqal-connector/pasqal_validate.py +++ b/packages/extension/scripts/pasqal-connector/pasqal_validate.py @@ -13,9 +13,17 @@ script never persists anything. Credentials are read from environment variables ONLY (never from argv, never -written to disk): PASQAL_USERNAME, PASQAL_PASSWORD, PASQAL_PROJECT_ID. The -caller (the connections panel's validator route) is expected to set these for -this invocation alone. +written to disk): PASQAL_USERNAME, PASQAL_PASSWORD, and OPTIONALLY +PASQAL_PROJECT_ID. When PASQAL_PROJECT_ID is absent, the script runs in +LIST-PROJECTS mode: it authenticates and prints the caller's projects so the +panel can offer a picker — + + {"ok": true, "mode": "list", "projects": [{"id":..., "name":...}], + "token": , "expires_at": } + +When PASQAL_PROJECT_ID is present it runs the connect-and-validate mode above. +The caller (the connections panel's validator route) sets these for this +invocation alone. Exit codes: 0 success; 1 missing environment variable; 2 authentication failure; 3 network failure / service unreachable; 4 project-authorization @@ -139,10 +147,54 @@ def _extract_token(connection): return token, _token_expiry(provider, token) +def _account_api_url() -> str: + # Prefer the SDK's own endpoint constant (region-correct); fall back to the + # documented prod account API base. + try: + from pasqal_cloud.endpoints import ACCOUNT_API_URL + + if isinstance(ACCOUNT_API_URL, str) and ACCOUNT_API_URL: + return ACCOUNT_API_URL + except Exception: # noqa: BLE001 - fall back to the documented default + pass + return "https://apis.pasqal.cloud/account" + + +def _list_projects(token: str) -> list: + """GET the caller's projects with the bearer token (account API). Returns a + list of {"id","name"}; the token's `sub` scopes it to this user, so no + account id is needed in the path.""" + import requests + + url = _account_api_url().rstrip("/") + "/api/v1/projects" + resp = requests.get( + url, headers={"Authorization": f"Bearer {token}"}, timeout=30 + ) + resp.raise_for_status() + body = resp.json() + items = body.get("data") if isinstance(body, dict) else body + if not isinstance(items, list): + items = [] + projects = [] + for item in items: + if not isinstance(item, dict): + continue + pid = item.get("id") or item.get("project_id") + if not pid: + continue + name = item.get("name") or str(pid) + projects.append({"id": str(pid), "name": str(name)}) + return projects + + def main() -> None: username = _require_env("PASQAL_USERNAME") password = _require_env("PASQAL_PASSWORD") - project_id = _require_env("PASQAL_PROJECT_ID") + # PROJECT_ID is now OPTIONAL: absent → LIST-PROJECTS mode (authenticate, + # return the caller's projects so the panel can offer a picker); present → + # the original connect-and-validate mode (mint a token for that project). + project_id = os.environ.get("PASQAL_PROJECT_ID", "") + list_mode = project_id == "" # Imported lazily so the env guard above fails cleanly even where # pasqal-cloud is not installed, and so tests can pre-inject a stub. @@ -158,10 +210,16 @@ def main() -> None: sys.exit(EXIT_MISSING_ENV) # Constructing the connection performs the auth handshake. Fixed messages - # only: exception text may echo credentials and must never be printed. + # only: exception text may echo credentials and must never be printed. In + # list mode we omit project_id (account-level auth); in connect mode we + # bind the chosen project. try: - connection = PasqalCloudConnection( - username=username, password=password, project_id=project_id + connection = ( + PasqalCloudConnection(username=username, password=password) + if list_mode + else PasqalCloudConnection( + username=username, password=password, project_id=project_id + ) ) except TokenProviderError: print(MSG_AUTH_FAILURE, file=sys.stderr) @@ -173,6 +231,35 @@ def main() -> None: print(MSG_AUTH_FAILURE, file=sys.stderr) sys.exit(EXIT_AUTH_FAILURE) + if list_mode: + # Listing projects needs the bearer token. A null token means the SDK + # exposed no way to obtain it — we cannot list, so classify as a + # service/config failure rather than pretend an empty project set. + token, expires_at = _extract_token(connection) + if not token: + print(MSG_NETWORK_FAILURE, file=sys.stderr) + sys.exit(EXIT_NETWORK_FAILURE) + try: + projects = _list_projects(token) + except Exception as exc: # noqa: BLE001 - classified, never echoed + if _is_network_error(exc): + print(MSG_NETWORK_FAILURE, file=sys.stderr) + sys.exit(EXIT_NETWORK_FAILURE) + print(MSG_AUTH_FAILURE, file=sys.stderr) + sys.exit(EXIT_AUTH_FAILURE) + print( + json.dumps( + { + "ok": True, + "mode": "list", + "projects": projects, + "token": token, + "expires_at": expires_at, + } + ) + ) + return + # Auth succeeded; the device listing is the first project-scoped API call, # so a non-network failure here means the project refused us. try: From c760e5f13950856deb596ae0c9313eb97bc307ba Mon Sep 17 00:00:00 2001 From: kate bonner Date: Tue, 21 Jul 2026 21:42:56 -0400 Subject: [PATCH 2/2] feat(extension): pass isolated AMICO_* state vars through to the opencode server (#194) Adds a fixed allowlist of non-secret amico state locations (pasqal.json / cloud.json / connections.json / ops dir / keychain service / validator / python) that pass through buildServerSpawnEnv when set. Keeps the minimal-env discipline (never a process.env spread; unset vars never appear) while letting an isolated sandbox launch its own ~/.amico + keychain, separate from the real install. Enables the VS Code extension sandbox for #194 testing. Co-Authored-By: Claude Fable 5 --- packages/extension/src/server_auth.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/server_auth.ts b/packages/extension/src/server_auth.ts index 61a98e26..c6b7de2d 100644 --- a/packages/extension/src/server_auth.ts +++ b/packages/extension/src/server_auth.ts @@ -54,6 +54,22 @@ export function serverAuthHeader(password: string): string { * OPENCODE_CONFIG_CONTENT — the amico instructions/permission merge * OPENCODE_SERVER_PASSWORD — arms the fork's route auth (this module) * One builder for all spawn sites so no respawn path can drop the password. */ +/** Non-secret path/config overrides passed THROUGH to the server when set in + * the extension host's env. The minimal-env discipline stands — this is a + * fixed allowlist of amico state locations (never a process.env spread), so a + * sandbox launch (`AMICO_PASQAL_FILE=… code …`) can isolate its ~/.amico + + * keychain from the real install. None of these are secrets; unset vars never + * appear. */ +const SANDBOX_ENV_PASSTHROUGH = [ + "AMICO_CLOUD_FILE", + "AMICO_PASQAL_FILE", + "AMICODE_CONNECTIONS_FILE", + "AMICODE_OPS_DIR", + "AMICO_PASQAL_KEYCHAIN_SERVICE", + "AMICO_PASQAL_VALIDATOR", + "AMICO_PYTHON", +] as const + export function buildServerSpawnEnv(opts: { /** amico-run launcher bin dir; undefined = launcher missing (boot warns). */ amicoRunBinDir: string | undefined; @@ -62,9 +78,14 @@ export function buildServerSpawnEnv(opts: { /** The per-boot password from mintServerPassword(). */ serverPassword: string; }): Record { - return { + const env: Record = { PATH: `${opts.amicoRunBinDir ? opts.amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, OPENCODE_CONFIG_CONTENT: opts.configContent, OPENCODE_SERVER_PASSWORD: opts.serverPassword, }; + for (const key of SANDBOX_ENV_PASSTHROUGH) { + const value = process.env[key]; + if (value !== undefined && value !== "") env[key] = value; + } + return env; }