Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
101 changes: 94 additions & 7 deletions packages/extension/scripts/pasqal-connector/pasqal_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": <str>, "expires_at": <iso8601|null>}

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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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:
Expand Down
23 changes: 22 additions & 1 deletion packages/extension/src/server_auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -62,9 +78,14 @@ export function buildServerSpawnEnv(opts: {
/** The per-boot password from mintServerPassword(). */
serverPassword: string;
}): Record<string, string> {
return {
const env: Record<string, string> = {
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;
}
Loading