From 8b7a98954e6f8393cec1246bc704809c44a80a26 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:12:59 -0400 Subject: [PATCH 01/15] pasqal_validate: auth-only validation with token mint + device enumeration (AC1) New standalone validator (sibling of the connectivity spike's connector): env-only credentials, authenticates via the pasqal_cloud SDK, extracts the bearer token best-effort through the SDK's token-provider chain (null token = session-only fallback signal), enumerates devices, and emits exactly one JSON line: ok / project_id / devices / token (nullable) / expires_at. Tests stub the SDK fully via sys.modules injection; no network, no pasqal-cloud install required. Part of #164 (parent #159). Co-Authored-By: Claude Fable 5 --- .../pasqal_validate.cpython-311.pyc | Bin 0 -> 9752 bytes .../pasqal-connector/pasqal_validate.py | 186 ++++++++++++++ .../test_pasqal_validate.cpython-311.pyc | Bin 0 -> 17485 bytes .../tests/test_pasqal_validate.py | 228 ++++++++++++++++++ 4 files changed, 414 insertions(+) create mode 100644 packages/extension/scripts/pasqal-connector/__pycache__/pasqal_validate.cpython-311.pyc create mode 100644 packages/extension/scripts/pasqal-connector/pasqal_validate.py create mode 100644 packages/extension/scripts/pasqal-connector/tests/__pycache__/test_pasqal_validate.cpython-311.pyc create mode 100644 packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py diff --git a/packages/extension/scripts/pasqal-connector/__pycache__/pasqal_validate.cpython-311.pyc b/packages/extension/scripts/pasqal-connector/__pycache__/pasqal_validate.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ca5732ac16af0435b4ba6b9ce15ed5ad36f7a50 GIT binary patch literal 9752 zcmdT}TWlLwdY<77$>Bw!BucX6TgI|1n~rs{q}YisavV#(D7NgFiW4g~Sc)@}Xi=nc zW+Yh()iBr;%WMr)TO^2pHmDxfuCuXGzZ5mlmu~B#dDsGEFhPg`1f(qjY#s_Q$Sz<6 zedu=%uS2=*76A%$IQ(bkT>tZ*|9<|muFlQj`uFLZw@&QfxPPM`rAsWneE(~_e9S4_ z1gG$dEyhpq>}i{@v8OO0u%|d7;wi-JbM^`QoMXblbBZ|ORO}Oy;+SwL&Iz|7O?VX7 zgjaD-)F~ddUh%5+Q=(G$zBu7i>eU8(H{#oe?{zQ`(ePe6_3Fm2I;&iZ$`tIJNyA-aezB>;g8nAo~Z}7vfml=>>hJI}wj9 z%U8nM+u@jeCYD%G#F8jq-Q zg1EJX$+@Vmvx@k#d@C{8BVUfo+Cl^~Y4SV)8k#)v=IJxz7q82g$42DQ;N>yk6IX$o ztmz5{KjwDJxZdeV%yt};b+Q2bJLXB^mKxDRQ3Yi^Jv}HYE&$(eq2T5r)=TbSYeeA* zO()6tLM%3fw(9NqC{eXgSf@2nE%D-!zQcvuRkwS5MwKVkFi}Z?LUL4-)!RA=M|4#| zjiz_2Q&S0|%lZsSEKJYfsmj6OSNCg$4&6X+F{-G+!S^BUsE5lAtH&mwlJp>OsGYtTQKQN?*jW2KoP*6bB=^lA}2198P5}nuOxVnfH&Z|Vj{$qpV%lZs$-Ket8Vrcc3sHYZfNci-!smxR(f6fG#~D zD^YDWa7?~(I{4b@i=o$pBUi^xUm7`7DnNPgjmuYuD+O0CkB*!f4_z3hn5*sxB^!|s zP;!F=ac}_E2u2FW)fi||WcUP;u`Rk9C~Vw3nt|AiD0ItIO_i~GZmd?5qw&QAWCFv; z;aDQBV)Y}pqdJCHRPC7DS7O%V@?l5~XbL8O77>oi{navt@!V74Xl#L~$K?S~LSIUd zS!@w2l6$c~gvy(|5C?I=BcS7Pc@QGO__~{lYl1{q7-prBdgQak{&U#f@H8k7CY7h6 zx3N61H*nJ!i>S9F>O5PM4!)&SV0+^RGbU8!kgO$80MVt}AJ#OWrceefvODBH9G7Ei zm@yJwLCbjvIc8A9b3N{FsS+9s*h~q^Mb)Eoswq*p0`h&!qwZU(xxN)Bpqh8(luAf~ zVBf2iQ|g8TW}zmnvnq?4|byg0(-->8?^8=696d_4?8q$K3-~pOrD= zxg;Ot0^*p-t#lU&R^oF4RSq?&Jd@$&PkB?E!lnfz)0WUoXHhClVOrHqJJao^pe<{r z19}`Io1zNAG8;zTyf7ZRbRihLFm^69GIlLsBNS)Tj*VYXHA)Lv{^2irF~TDFt5=_! z?~TO53yRwPLf_%u?$;qYTK6ofCaDS_y?v8YgOg#kzk4v!*VjF$^dIdGD+2@FlYA^f!M*2^jMRZ{sf*uZSk4q6*cRra z8_byRmw5Rp5B4i>EXO4|z06n~r&C5PUV51^IBu$jg$P`spoNW+H7rwMzn}4XU1>~? zOY>83s@|6rVVJWzELN6d>^5#$2VKJe{@=?0AMvkpJVYtU-IS<6d|+GTu5lmpJa@;X zaOlmCeaZy{)7H~xim+HZ;~$NVAbcUi@sHhx5jKh0Q#GvzI z+E7oq)l2B4?FV@J8}2vQz)bxO+#6zAU(?gc!dEpU+tD^4n(i}YGh{l)p#-5TP3Kra z6`7vbE80EaG~H2{57>TqN2agRafE6|(_8Fa_;Owj7G6Ysc^>*T6ru_!6fy;vX-Et_ zT&}QeCND3uRhx}lmP)$Oa1ejSm`_vOH_pbjy`T6V?)>Ye%(hcm=gWrkCd4q;k2+x#$h zvwfAfa%PfOxO>>K&*-kPYt@#ty(cDZ5gU&^zOy|^ciJ|FeHKHSGHd!M3{|W?bc-?@ z;7upgO<30n8oH}$MOtRMV96Bf1FJ>$kPC$Jg%;w_NwH;^Ogb((0vt}@ubl!&ak+;6 zJSW(9F_0EFn|G{_-WTsH+2#SGc_8h|d73jGIp^P*@fPpAz|VAZCVZ324Oss-F?A&9TqLL_b0v8{p+ zVa8bhlQ8QZpi;bQRmG}c*>*t@wyagOtW~t)L$+08i*<|Ebz55pxoOHYuw~n(R4DsU zcoAL&b*)0h;K0JD)S@z_a}_UTmD5qfs@NS1hBdokW#Xcm+Dlbv(XPQ!BK?>E{0b(u zk>}{Uqlizz*fE|Ydr+|=hiEWXLiZVpY80(BD$qqe)e_9K1RpPcG`&8xG4(L=sO!_& z2eaAMV@B&SRMgyzGtCxAOmT*g2Ip7{Dba`?a1_v#sFO`;M!G1bBCUf7N3^3VOiTiH zLaAoj8I4U>naJcYD`rD#lpSSy6$Gx%vMzE4wZr&pe+59zrGITSBfs=`FzbEU@V=ZH z*>w8W?C-t%{=0cjw2$+-=JvIbpP&2iT-vqiX<3WjZO(eS3{Mv{muugqzkNNB^>-Tn z&WyKn)7Mg1_2=z)W?O;=pf70nf*C2e>26;?n04BvD^R}_k<#`C}P>3s%RuZdRWvQDh&`ggyhH+brBa;q*&B= zG2{;;g%U4~)D1sGB{UKzDmB&UGEKsWA!*z>hRCQKyIUf28nd&I9g#g7iaH21+D=T# zVFW>8ZKlU`7O@;N9R+k@>*_2RoAp+dVJ)o%P$JyueE0NPVb^M#B>(+a>944oyYlO6`=F(2S~9?LPog-0$3+XM2_O z)itjL*8>~l*}8*9-N8)VYY(R%MIOKScjMWfQKM%R1)oP!XLAtlfem-2>G?e8vF~9Z z?Z|l=(?dCLbFO(b&q*#61EhyH+n(ESFgx8gV6+XSFXVh}8K0bMXxnVrxqdx!;Kj$C z*_KmA%c=C)9Q1^5&)u1aOOF;Gzw`NUw)Zuo_qA--Rio=FDstO)t<{x&o4#$II5%AP zk3SZ(Z6}SklUd)8;TwYXsY63K(_p#t0*doKidGZnZAcFm)UKToK2qh9a}E%ODGnK> zMOW!9*m9_m(v^e--eNNaix>*ku6>QQA4m!tRSKsxD#C$h#*;??w}tE65+9Ig&c$6xU78N|zWf8#;uF^1Z9-gsH zUkNl6B)kj7gz|yu4r`@c4G~%61wCTAkOD~Oc!Em+!SwViMVNxCBh2A z_{p%Q9vL(n7f4JCPpP5FBZG=c^JHcnP5RMc5!}qp!!tk+-E@tV(ol$fq>?rzV-y#r zROptOp84=Hoq!H!IuM^Ls$x2egtL6Z}Q}_5;*qhhzf4Z*SPP zE^Tz)7qboBMniYnPMrt!lQ%2Q!%)uO_LYC{7yi9>&;R0L)_>UWAO6aJ{0sl_tpB9p zKbi3sZwR$(DDQ9}P-@-r@$8?@ZtTsrb{VZ*8*dw}fwU*rxN}{*tKC1I-G9{Be>Br| z^zoi-*YWIgC$fzv(vHoh)(;PUa?RNF!lS9|u9L>Dli8*rqiHDZgl;i>`|hgu&pjG> zForHh*YSUpo_I69OIhC~!*}T`-y2`}-pKl{8@}rq>H5=6PcsYHDu9J? zs!;*?mHf)?GpD$JIOQI$6P|c^K#TKB8HTBz!jXtoJ9hsY#^S-OXOH25T}0vi7WQE6 zkWBihdqcXvFWb~-H1%aXeHpP&qx$lr=7ZcX-7gA%!@np*@Lj&Bie;SQ#(rA8Tcj_+ z4HebCYRNy
EVr3f{BZHlPaWBiiK|e9E)Id807HoVhH?ciRapaj z{Hwh%8sM)JNPbpvhh8=UFb-Ae&s$|admvf42mO8JRdct>iB!G0QfkGSs_6K~dTXT~ zc>FQUO2uNpfLQyGmn>M)JW}!cky!35jk7hyYnk!?h%O4Z>i9!PTq-=5_>$DSIGu*T z#jDmpuR7!F&>a82C@Pm$^P@SfqcW0}YYNW5vni_YtMsa+=MCt`RcX~_;Q*TFEMB+D zysfwpQ96@S(xtfn{J(XZAe}VL;u$l2H79xHg3?Kz*|_B_g#aU@aLty&X%IW9uH-^d z(KJp^rLPpDHOM;q^`E~*X&aVkLKXhE82vxZB-bD`6(kDm{nJ$>Qg>sLnz zHMNA&khIVSjqGs4(t??cZd&Q5y~jqzam4v*X#lgC^{6Rj`H@2V(jioF!eaZHQf5`i zTXYh;vM@KV1>7V-k(#G~l7lU?uo7miSpHa}70ex@+3OmJ*o3GUyZvmyANj@`;Er_)M#Gf>;oCGzxvLJLmBIxm%R4Q zT+6{s%fVdBuKUvCiM(KIb$`bJ{FZ_=eoylb&byl)pmbIMj#9p?-+edui;0ZXmzDYq zsShv3Bb1J;wAYaKW=c16ob0rF32b_Hu9q#P&Gr1IxADuSeGkt(7|nQ(7Lr1E+4TDE zod2>Vko6ukyazMhga4~p%hSU)Kh1kEHGMWY?fW;Ip4)I|o1QnCo=-c|&Vp&T+xJua z3gE}!x96eoz@3o>veJMd4dA6nm3>*M%aFPVZ1`)<7CJ&wBiJO}WdAi1z#0N)92yW_VUK;8y ztv~JNe&rq--YxvS+eM-O6@Iu~c+zgi$CD1kh8N_LdMzx-A=I7_>UNQ33f`e$k%HS4(72K? zhmP89v+flHI_22K6ekmj7&}K{DHBtq8CoWx1;>y&fdv7iVnUv$fQE7`>St2)gLGmp zp)ADIA@UPcPzPEI0m5bD`MlW5i+PTMK8~+X)zM#$^Q71>$GKAMm*dU_SAhmQ zf0JK7NlR|>_s0v*XF#-TrNu?i;Nj~x5TD>t3wHW>oPU, "expires_at": } + +The bearer token is extracted best-effort through the SDK's token-provider +mechanism; when the installed SDK exposes no way to obtain it, "token" is +null — the consumer's signal to fall back to session-only handling. This +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. + +Exit codes: 0 success; 1 missing environment variable; 2 authentication +failure; 3 network failure / service unreachable; 4 project-authorization +failure. Failure messages are fixed strings — exception text is never +echoed, so neither passwords nor tokens can leak to an output stream. +""" + +import base64 +import json +import os +import sys +from datetime import datetime, timezone + +EXIT_MISSING_ENV = 1 +EXIT_AUTH_FAILURE = 2 +EXIT_NETWORK_FAILURE = 3 +EXIT_PROJECT_FAILURE = 4 + +MSG_AUTH_FAILURE = "error: Pasqal Cloud authentication failed (invalid credentials)" +MSG_NETWORK_FAILURE = "error: Pasqal Cloud is unreachable (network failure)" +MSG_PROJECT_FAILURE = ( + "error: Pasqal Cloud project authorization failed (check PASQAL_PROJECT_ID)" +) + + +def _require_env(name: str) -> str: + value = os.environ.get(name, "") + if not value: + print(f"error: missing required environment variable {name}", file=sys.stderr) + sys.exit(EXIT_MISSING_ENV) + return value + + +def _is_network_error(exc: BaseException) -> bool: + # requests' ConnectionError/Timeout subclass OSError; cover renames by name. + if isinstance(exc, (ConnectionError, TimeoutError, OSError)): + return True + name = type(exc).__name__ + return any(hint in name for hint in ("Connection", "Timeout", "Network", "Unreachable")) + + +def _underlying_sdk(connection): + for attr in ("_sdk", "_sdk_connection", "sdk"): + candidate = getattr(connection, attr, None) + if candidate is not None: + return candidate + return connection + + +def _list_devices(connection) -> list: + fetch = getattr(connection, "fetch_available_devices", None) + if callable(fetch): + available = fetch() + if isinstance(available, dict): + return sorted(available) + return sorted(getattr(device, "name", str(device)) for device in available) + sdk = _underlying_sdk(connection) + specs = getattr(sdk, "get_device_specs_dict", None) + if callable(specs): + return sorted(specs()) + return [] + + +def _find_token_provider(connection): + """Walk to the SDK's token provider (pasqal-cloud 0.23: + SDK._client.authenticator.token_provider), defensively enough that an + SDK-internal rename degrades to None — the session-only fallback — + instead of a crash.""" + sdk = _underlying_sdk(connection) + client = getattr(sdk, "_client", None) or getattr(sdk, "client", None) or sdk + for holder in (client, sdk, connection): + if holder is None: + continue + for attr in ("authenticator", "token_provider", "_token_provider"): + candidate = getattr(holder, attr, None) + if candidate is None: + continue + nested = getattr(candidate, "token_provider", None) + if nested is not None and callable(getattr(nested, "get_token", None)): + return nested + if callable(getattr(candidate, "get_token", None)): + return candidate + return None + + +def _token_expiry(provider, token: str): + for attr in ("expires_at", "expiry"): + value = getattr(provider, attr, None) + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat() + # Auth0 access tokens are JWTs; the exp claim is the expiry. + try: + payload_b64 = token.split(".")[1] + padded = payload_b64 + "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded)) + return datetime.fromtimestamp(float(payload["exp"]), tz=timezone.utc).isoformat() + except Exception: # noqa: BLE001 - expiry is best-effort metadata + return None + + +def _extract_token(connection): + """Best-effort (token, expires_at) via the SDK's token-provider mechanism.""" + provider = _find_token_provider(connection) + if provider is None: + return None, None + try: + token = provider.get_token() + except Exception: # noqa: BLE001 - token minting is best-effort by design + return None, None + if not isinstance(token, str) or not token: + return None, None + return token, _token_expiry(provider, token) + + +def main() -> None: + username = _require_env("PASQAL_USERNAME") + password = _require_env("PASQAL_PASSWORD") + project_id = _require_env("PASQAL_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. + from pasqal_cloud import PasqalCloudConnection + from pasqal_cloud.authentication import TokenProviderError + + # Constructing the connection performs the auth handshake. Fixed messages + # only: exception text may echo credentials and must never be printed. + try: + connection = PasqalCloudConnection( + username=username, password=password, project_id=project_id + ) + except TokenProviderError: + print(MSG_AUTH_FAILURE, file=sys.stderr) + sys.exit(EXIT_AUTH_FAILURE) + 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) + + # Auth succeeded; the device listing is the first project-scoped API call, + # so a non-network failure here means the project refused us. + try: + devices = _list_devices(connection) + 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_PROJECT_FAILURE, file=sys.stderr) + sys.exit(EXIT_PROJECT_FAILURE) + + token, expires_at = _extract_token(connection) + + print( + json.dumps( + { + "ok": True, + "project_id": project_id, + "devices": devices, + "token": token, + "expires_at": expires_at, + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/packages/extension/scripts/pasqal-connector/tests/__pycache__/test_pasqal_validate.cpython-311.pyc b/packages/extension/scripts/pasqal-connector/tests/__pycache__/test_pasqal_validate.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3099c17afb093bdd788faad807564a247b31e47c GIT binary patch literal 17485 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 G$NvG$BXCmy literal 0 HcmV?d00001 diff --git a/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py b/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py new file mode 100644 index 000000000..c0a678a0a --- /dev/null +++ b/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py @@ -0,0 +1,228 @@ +"""Contract tests for pasqal_validate.py: exit codes, single-JSON-line stdout, +secret hygiene. + +The pasqal_cloud SDK is fully stubbed via sys.modules injection — these tests +never touch the network and do not require pasqal-cloud to be installed. The +stub records every SDK call so tests can assert the no-job-submission +invariant, and it embeds a poison password in every exception message so +tests can prove exception text never reaches an output stream. +""" + +import base64 +import io +import json +import os +import subprocess +import sys +import types +import unittest +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime, timezone +from pathlib import Path + +CONNECTOR_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(CONNECTOR_DIR)) + +POISON_PASSWORD = "hunter2-P0ison-pa55word" +USERNAME = "kate@example.com" +PROJECT_ID = "proj-0000-aaaa-bbbb" +CRED_ENV = { + "PASQAL_USERNAME": USERNAME, + "PASQAL_PASSWORD": POISON_PASSWORD, + "PASQAL_PROJECT_ID": PROJECT_ID, +} +DEVICES = ("FRESNEL", "EMU_FREE") + + +class Recorder: + """Records the name of every SDK call the script makes (never values).""" + + def __init__(self): + self.calls = [] + + +def build_stub( + recorder, + scenario="ok", + token="tok-opaque-bearer", + provider="full", + provider_expires_at=None, + devices=DEVICES, +): + """Build stub pasqal_cloud modules mirroring pasqal-cloud 0.23's shape. + + scenario: "ok" | "auth" | "network-init" | "network-devices" | "project" + provider: "full" (token provider reachable) | "absent" (SDK exposes no + way to obtain the token — the session-only fallback signal) + + Every raised exception embeds POISON_PASSWORD in its message, so any + test that finds the poison in an output stream has caught a leak. + """ + root = types.ModuleType("pasqal_cloud") + auth = types.ModuleType("pasqal_cloud.authentication") + + class TokenProviderError(Exception): + pass + + auth.TokenProviderError = TokenProviderError + root.authentication = auth + + class _TokenProvider: + expires_at = provider_expires_at + + def get_token(self): + recorder.calls.append("get_token") + return token + + class _Authenticator: # mirrors HTTPBearerAuthenticator + def __init__(self): + if provider == "full": + self.token_provider = _TokenProvider() + + class _Client: + def __init__(self): + self.authenticator = _Authenticator() + + class _SDK: + def __init__(self): + self._client = _Client() + + class PasqalCloudConnection: + def __init__(self, **kwargs): + # Record argument NAMES only — values include the password. + recorder.calls.append("PasqalCloudConnection(%s)" % ",".join(sorted(kwargs))) + if scenario == "auth": + raise TokenProviderError( + "login denied for password=%s" % kwargs.get("password") + ) + if scenario == "network-init": + raise ConnectionError( + "cannot reach auth endpoint; password=%s" % kwargs.get("password") + ) + self._sdk = _SDK() + + def fetch_available_devices(self): + recorder.calls.append("fetch_available_devices") + if scenario == "network-devices": + raise ConnectionError("connection dropped; secret=" + POISON_PASSWORD) + if scenario == "project": + raise RuntimeError( + "403: project not authorized; secret=" + POISON_PASSWORD + ) + return {name: object() for name in devices} + + def submit(self, *args, **kwargs): + recorder.calls.append("submit") + + class RemoteEmuFreeBackend: + def __init__(self, *args, **kwargs): + recorder.calls.append("RemoteEmuFreeBackend") + + def run(self, *args, **kwargs): + recorder.calls.append("backend.run") + + root.PasqalCloudConnection = PasqalCloudConnection + root.RemoteEmuFreeBackend = RemoteEmuFreeBackend + root.SDK = _SDK + return {"pasqal_cloud": root, "pasqal_cloud.authentication": auth} + + +def _purge_modules(): + for name in [ + m + for m in list(sys.modules) + if m == "pasqal_cloud" or m.startswith("pasqal_cloud.") or m == "pasqal_validate" + ]: + del sys.modules[name] + + +def run_validator(env, stub=None): + """Run pasqal_validate.main() in-process with a stubbed SDK. + + Returns (exit_code, stdout, stderr). + """ + _purge_modules() + if stub is not None: + sys.modules.update(stub) + saved = {k: os.environ.pop(k) for k in list(os.environ) if k.startswith("PASQAL_")} + os.environ.update(env) + stdout, stderr = io.StringIO(), io.StringIO() + code = 0 + try: + with redirect_stdout(stdout), redirect_stderr(stderr): + import pasqal_validate + + try: + pasqal_validate.main() + except SystemExit as exc: + code = int(exc.code or 0) + finally: + for key in env: + os.environ.pop(key, None) + os.environ.update(saved) + _purge_modules() + return code, stdout.getvalue(), stderr.getvalue() + + +def make_jwt(exp_epoch): + def b64(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=") + + return b".".join([b64({"alg": "none"}), b64({"exp": exp_epoch}), b"sig"]).decode() + + +class TestValidCredentials(unittest.TestCase): + """AC1: valid credentials → exit 0 + one JSON line (project, devices, token).""" + + def test_exit_zero_and_single_json_line(self): + recorder = Recorder() + code, stdout, stderr = run_validator(CRED_ENV, build_stub(recorder)) + self.assertEqual(code, 0, stderr) + self.assertEqual(stderr, "") + lines = stdout.splitlines() + self.assertEqual(len(lines), 1, "stdout must be exactly one JSON line") + payload = json.loads(lines[0]) + self.assertIs(payload["ok"], True) + self.assertEqual(payload["project_id"], PROJECT_ID) + self.assertEqual(payload["devices"], sorted(DEVICES)) + self.assertEqual(payload["token"], "tok-opaque-bearer") + self.assertIn("expires_at", payload) + self.assertIsNone(payload["expires_at"]) # opaque token: no expiry known + + def test_jwt_token_reports_expiry(self): + exp = 1900000000 + token = make_jwt(exp) + recorder = Recorder() + code, stdout, _ = run_validator(CRED_ENV, build_stub(recorder, token=token)) + self.assertEqual(code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["token"], token) + expected = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat() + self.assertEqual(payload["expires_at"], expected) + + def test_provider_exposed_expiry_wins_over_opaque_token(self): + expiry = datetime(2027, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + recorder = Recorder() + code, stdout, _ = run_validator( + CRED_ENV, build_stub(recorder, provider_expires_at=expiry) + ) + self.assertEqual(code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["expires_at"], expiry.isoformat()) + + def test_null_token_when_sdk_cannot_yield_one(self): + # The session-only fallback signal: still exit 0, token: null. + recorder = Recorder() + code, stdout, stderr = run_validator( + CRED_ENV, build_stub(recorder, provider="absent") + ) + self.assertEqual(code, 0, stderr) + payload = json.loads(stdout) + self.assertIs(payload["ok"], True) + self.assertIsNone(payload["token"]) + self.assertIsNone(payload["expires_at"]) + self.assertEqual(payload["devices"], sorted(DEVICES)) + + +if __name__ == "__main__": + unittest.main() From b54e78ae5f5ca3158de45204952906903038d105 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:14:15 -0400 Subject: [PATCH 02/15] pasqal_validate tests: exit-code classification contract (AC2) Auth failure -> 2, network failure (connect or device fetch) -> 3, project-authorization failure -> 4; stderr carries a fixed, distinct, token- and password-free message per class and stdout stays empty. Stub exceptions embed a poison password so any echoed exception text fails the hygiene assertions (verified by mutation: swapping an exit code and echoing exception text each break the suite). Part of #164. Co-Authored-By: Claude Fable 5 --- .../test_pasqal_validate.cpython-311.pyc | Bin 17485 -> 20761 bytes .../tests/test_pasqal_validate.py | 34 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/extension/scripts/pasqal-connector/tests/__pycache__/test_pasqal_validate.cpython-311.pyc b/packages/extension/scripts/pasqal-connector/tests/__pycache__/test_pasqal_validate.cpython-311.pyc index 3099c17afb093bdd788faad807564a247b31e47c..4b66ac5b27b0013f244845e16b589914055ae498 100644 GIT binary patch delta 5197 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 diff --git a/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py b/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py index c0a678a0a..000422d5f 100644 --- a/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py +++ b/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py @@ -224,5 +224,39 @@ def test_null_token_when_sdk_cannot_yield_one(self): self.assertEqual(payload["devices"], sorted(DEVICES)) +class TestFailureClassification(unittest.TestCase): + """AC2: exit 2 = auth, 3 = network, 4 = project — token- and + password-free stderr, nothing on stdout.""" + + def _assert_failure(self, scenario, expected_code): + recorder = Recorder() + code, stdout, stderr = run_validator(CRED_ENV, build_stub(recorder, scenario=scenario)) + self.assertEqual(code, expected_code) + self.assertEqual(stdout, "", "failures must print nothing to stdout") + self.assertTrue(stderr.strip(), "failures must explain themselves on stderr") + self.assertNotIn(POISON_PASSWORD, stderr) + self.assertNotIn("tok-opaque-bearer", stderr) + return stderr + + def test_auth_failure_exits_2(self): + self._assert_failure("auth", 2) + + def test_network_failure_at_connect_exits_3(self): + self._assert_failure("network-init", 3) + + def test_network_failure_at_device_fetch_exits_3(self): + self._assert_failure("network-devices", 3) + + def test_project_authorization_failure_exits_4(self): + self._assert_failure("project", 4) + + def test_distinct_messages_per_failure_class(self): + messages = { + scenario: self._assert_failure(scenario, code) + for scenario, code in (("auth", 2), ("network-init", 3), ("project", 4)) + } + self.assertEqual(len(set(messages.values())), 3) + + if __name__ == "__main__": unittest.main() From bd06ff65af350734afd53e9fedaecdbdec8c31f8 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:21:36 -0400 Subject: [PATCH 03/15] pasqal_validate: no-submission invariant, secret hygiene, real 0.23 SDK shape (AC3, AC4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stub now mirrors pasqal-cloud 0.23.0's real object graph: PasqalCloudConnection.cloud_client._client.authenticator.token_provider; token extraction walks that chain (verified offline against the real SDK) and reads the exact expiry from ExpiringTokenProvider's token cache, with JWT exp decode as fallback. - Devices come from cloud_client.get_device_specs_dict() (pure SDK) — never fetch_available_devices, which deserializes specs via pulser. - AC3: recorder-backed stub proves no run/submit/backend/job call path. - AC4: poison password appears in no argv and no output stream, across every scenario; spawn tests are hermetic via a PYTHONPATH shadow stub (never the live service, even where pasqal-cloud is installed). - Misconfigured python (SDK not importable) renders a fixed exit-1 message, distinct from unreachable-service, never a traceback. Part of #164. Co-Authored-By: Claude Fable 5 --- .../pasqal_validate.cpython-311.pyc | Bin 9752 -> 10279 bytes .../pasqal-connector/pasqal_validate.py | 39 +++- .../test_pasqal_validate.cpython-311.pyc | Bin 20761 -> 32108 bytes .../tests/test_pasqal_validate.py | 199 ++++++++++++++++-- 4 files changed, 207 insertions(+), 31 deletions(-) diff --git a/packages/extension/scripts/pasqal-connector/__pycache__/pasqal_validate.cpython-311.pyc b/packages/extension/scripts/pasqal-connector/__pycache__/pasqal_validate.cpython-311.pyc index 8ca5732ac16af0435b4ba6b9ce15ed5ad36f7a50..ddac8a815c74824aefd904b4e12a2723aa54b077 100644 GIT binary patch delta 2449 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 diff --git a/packages/extension/scripts/pasqal-connector/pasqal_validate.py b/packages/extension/scripts/pasqal-connector/pasqal_validate.py index 02bb21c47..ef46086ef 100644 --- a/packages/extension/scripts/pasqal-connector/pasqal_validate.py +++ b/packages/extension/scripts/pasqal-connector/pasqal_validate.py @@ -58,7 +58,9 @@ def _is_network_error(exc: BaseException) -> bool: def _underlying_sdk(connection): - for attr in ("_sdk", "_sdk_connection", "sdk"): + # pasqal-cloud 0.23: PasqalCloudConnection.cloud_client is the + # PasqalCloudClient (nee SDK); older spellings kept defensively. + for attr in ("cloud_client", "_sdk", "_sdk_connection", "sdk"): candidate = getattr(connection, attr, None) if candidate is not None: return candidate @@ -66,16 +68,18 @@ def _underlying_sdk(connection): def _list_devices(connection) -> list: + # Prefer the plain specs dict (device name -> serialized specs): it is + # pure SDK, whereas fetch_available_devices deserializes via pulser. + sdk = _underlying_sdk(connection) + specs = getattr(sdk, "get_device_specs_dict", None) + if callable(specs): + return sorted(specs()) fetch = getattr(connection, "fetch_available_devices", None) if callable(fetch): available = fetch() if isinstance(available, dict): return sorted(available) return sorted(getattr(device, "name", str(device)) for device in available) - sdk = _underlying_sdk(connection) - specs = getattr(sdk, "get_device_specs_dict", None) - if callable(specs): - return sorted(specs()) return [] @@ -102,10 +106,15 @@ def _find_token_provider(connection): def _token_expiry(provider, token: str): - for attr in ("expires_at", "expiry"): - value = getattr(provider, attr, None) - if isinstance(value, datetime): - return value.astimezone(timezone.utc).isoformat() + # pasqal-cloud 0.23: ExpiringTokenProvider caches (expiry, token); the + # cached expiry is exact even when the token is not a decodable JWT. + cache = getattr(provider, "_ExpiringTokenProvider__token_cache", None) + if ( + isinstance(cache, tuple) + and len(cache) == 2 + and isinstance(cache[0], datetime) + ): + return cache[0].astimezone(timezone.utc).isoformat() # Auth0 access tokens are JWTs; the exp claim is the expiry. try: payload_b64 = token.split(".")[1] @@ -137,8 +146,16 @@ def main() -> None: # Imported lazily so the env guard above fails cleanly even where # pasqal-cloud is not installed, and so tests can pre-inject a stub. - from pasqal_cloud import PasqalCloudConnection - from pasqal_cloud.authentication import TokenProviderError + # Misconfigured python renders distinctly from unreachable-service. + try: + from pasqal_cloud import PasqalCloudConnection + from pasqal_cloud.authentication import TokenProviderError + except ImportError: + print( + f"error: the pasqal-cloud SDK is not installed for {sys.executable}", + file=sys.stderr, + ) + 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. diff --git a/packages/extension/scripts/pasqal-connector/tests/__pycache__/test_pasqal_validate.cpython-311.pyc b/packages/extension/scripts/pasqal-connector/tests/__pycache__/test_pasqal_validate.cpython-311.pyc index 4b66ac5b27b0013f244845e16b589914055ae498..3245eaea2954f2e690248345f4a968d8c1bb9a42 100644 GIT binary patch delta 12665 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 P>^^w@3}_;AeYXDxT-w%- delta 3517 zcma)84NP0t6@K^m2V)y!{tN~Jn3x1N2?UZ9Qec5#NL+$LAfXve^h)q!ILT~!pGg)% z0&TP5WQmmYEE?(9nr2DUpKL4hv`tf1b(5-TnxaBPyXsbDjZ{ zzpl^r=X1|F_uO;tJLlZ{;XJ!^h1ssBq?iSKoPGNbezNj{Ek}HxO_Y9~F(*9B_NL43 zK+YkN6nL5_$fkoO;Hq)Jh-hKCv9lsGz=w||+b%Of7!^j@s5q({)x(vfYe9Wfh>E?s zfZ>!WV0;-ZyslXxOJ{7NuuX{CqPku&kc4v6J#vdCxAd|=GD^+&NUfUGic$+ot?&nv zbJ&2Zv0g(U1v$36^-q{IeF61TQEIIYQPSdQ$T*Y~ zNE;VB1W)?Fn;7!ef#BglkrEkzzT`^NYNbf&>+Y9hC(V^2tA`0|RcR5JH|I+0+hJ{(O2CblL5O3Y4@$*#4w3 z7paR3&ZlRZ7NW_cXp*GH!$CO|fr?ZI{4>20diuoT5-V6pipq{Nq^&gKnBO zyfCdyl7gXNM3Op4*NK3U+;Np(E|_vvOuH(g-f3&Wr1s?wl=}H5L=WSdZG1OE+_bvr z2dCX=$DdM$uxwjqM3!7RBP>9Ef?P&vOYj=pH0J~2%^}D2Qir!?A=tgl6^{j3OMyH= zYg4ooNM7++)uqkjo1@;dJEHixJBAukT^568o7W+?lOzuapo9Xr7t#{UcmVYGQ^LUK?tqDmDV7c2T9FN7nFpa|RWfwtZ($zTlI4as@;mGLX+9J1S&Lm21_^9xgY(M<0$j6R=#dQhkYc4N427gABp}g2(?m=gGH^KnyEZ)O< z;99YFm`lVZk*dR-_oQlDp#`LMCccs6l((EBQuRC>j_@WLFU^c^36(FW{EsJ5IZ{e0 z!c*E)gik#^TH8>~2dDu{N>OCs06&acS{HmDE9c|HGa~$>7+zQ@)fx=W8R4_REa}Z9 zlx?uz?Sin|04LoU!`K)^!^2^gq;m-slW4%o{4}5$B{X9}&*U_$oe>@eo`*?hNidg? zJ_=8jG#XPgM!cuC$F7uAuykzlW+l?iBg)ZWq|YPrJ@Cg>TiJ6^x;o4{VQh7w;RtTy zv$312j~QxFJE^UqV^_lt$)o2~e5x2$)Fe4{B*?=dRo5TxCmSSShvE&rDBjSYfT!23 zVgO_7{KaIEM+m6ARO3K@pgSUSg*bx*$H7#V&%#hr=3+yzt*p{`ir8ToE?aLnhvZjd zKPbDYXRpB18+!7#BSSR?h&grmtjLpV5Y_JIqanOLHxLU3HmoWW5=5%F&QrwezNs# z_+WdFoswtyzC)+ay?!q;JxTEk4(X~XxQ%#T$^nT|^}Yb$HD4S1Q|w3Ew8$jEZGvfn_XvKcy^&uKN$>&q+rQF4lxmWs zFLnn*lEm#e^sM5!q;!E`m_REc>J-?#1`c2<_Q;A7?Xx~XlnO2v^Mb@WLK+I%CUHAYE`Q>EA9PKVXLOy zSQw4nXh%yW&HHSc@eDy8fkS|S!F2OqkY@N(vSJqow=j23{vB(^OiBl1+c4)I^mkZ< hx#OGGRWeM$`(dnlI&bYP3FZ>ggU~rt4vi-~{{vP0f*Ak+ diff --git a/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py b/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py index 000422d5f..0db9ecf10 100644 --- a/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py +++ b/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py @@ -46,10 +46,13 @@ def build_stub( scenario="ok", token="tok-opaque-bearer", provider="full", - provider_expires_at=None, + token_cache_expiry=None, devices=DEVICES, ): - """Build stub pasqal_cloud modules mirroring pasqal-cloud 0.23's shape. + """Build stub pasqal_cloud modules mirroring pasqal-cloud 0.23.0's real + shape: PasqalCloudConnection.cloud_client (PasqalCloudClient) ._client + (HTTPClient) .authenticator (HTTPBearerAuthenticator) .token_provider + (ExpiringTokenProvider, caching (expiry, token) in __token_cache). scenario: "ok" | "auth" | "network-init" | "network-devices" | "project" provider: "full" (token provider reachable) | "absent" (SDK exposes no @@ -67,25 +70,40 @@ class TokenProviderError(Exception): auth.TokenProviderError = TokenProviderError root.authentication = auth - class _TokenProvider: - expires_at = provider_expires_at - + class _TokenProvider: # mirrors ExpiringTokenProvider def get_token(self): recorder.calls.append("get_token") return token + if token_cache_expiry is not None: + setattr( + _TokenProvider, + "_ExpiringTokenProvider__token_cache", + (token_cache_expiry, token), + ) + class _Authenticator: # mirrors HTTPBearerAuthenticator def __init__(self): if provider == "full": self.token_provider = _TokenProvider() - class _Client: + class _HTTPClient: def __init__(self): - self.authenticator = _Authenticator() + self.authenticator = _Authenticator() if provider == "full" else None - class _SDK: + class _PasqalCloudClient: def __init__(self): - self._client = _Client() + self._client = _HTTPClient() + + def get_device_specs_dict(self): + recorder.calls.append("get_device_specs_dict") + if scenario == "network-devices": + raise ConnectionError("connection dropped; secret=" + POISON_PASSWORD) + if scenario == "project": + raise RuntimeError( + "403: project not authorized; secret=" + POISON_PASSWORD + ) + return {name: "" for name in devices} class PasqalCloudConnection: def __init__(self, **kwargs): @@ -99,17 +117,15 @@ def __init__(self, **kwargs): raise ConnectionError( "cannot reach auth endpoint; password=%s" % kwargs.get("password") ) - self._sdk = _SDK() + self.cloud_client = _PasqalCloudClient() def fetch_available_devices(self): + # The real method deserializes device specs via pulser; the + # validator should stay on the plain specs-dict path instead. recorder.calls.append("fetch_available_devices") - if scenario == "network-devices": - raise ConnectionError("connection dropped; secret=" + POISON_PASSWORD) - if scenario == "project": - raise RuntimeError( - "403: project not authorized; secret=" + POISON_PASSWORD - ) - return {name: object() for name in devices} + return { + name: object() for name in self.cloud_client.get_device_specs_dict() + } def submit(self, *args, **kwargs): recorder.calls.append("submit") @@ -123,7 +139,6 @@ def run(self, *args, **kwargs): root.PasqalCloudConnection = PasqalCloudConnection root.RemoteEmuFreeBackend = RemoteEmuFreeBackend - root.SDK = _SDK return {"pasqal_cloud": root, "pasqal_cloud.authentication": auth} @@ -200,11 +215,13 @@ def test_jwt_token_reports_expiry(self): expected = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat() self.assertEqual(payload["expires_at"], expected) - def test_provider_exposed_expiry_wins_over_opaque_token(self): + def test_provider_token_cache_expiry_wins_over_opaque_token(self): + # pasqal-cloud's ExpiringTokenProvider caches (expiry, token); the + # cached expiry is exact even when the token is not a decodable JWT. expiry = datetime(2027, 1, 2, 3, 4, 5, tzinfo=timezone.utc) recorder = Recorder() code, stdout, _ = run_validator( - CRED_ENV, build_stub(recorder, provider_expires_at=expiry) + CRED_ENV, build_stub(recorder, token_cache_expiry=expiry) ) self.assertEqual(code, 0) payload = json.loads(stdout) @@ -258,5 +275,147 @@ def test_distinct_messages_per_failure_class(self): self.assertEqual(len(set(messages.values())), 3) +class TestNoSubmissionPath(unittest.TestCase): + """AC3: auth only — the stub records every call; no run/submit/job path.""" + + FORBIDDEN = ("run", "submit", "batch", "backend", "job", "sequence") + + def test_no_run_or_submit_invoked(self): + recorder = Recorder() + code, _, _ = run_validator(CRED_ENV, build_stub(recorder)) + self.assertEqual(code, 0) + # Positive control: the calls we DO expect were recorded. + self.assertIn("PasqalCloudConnection(password,project_id,username)", recorder.calls) + self.assertIn("get_device_specs_dict", recorder.calls) + self.assertIn("get_token", recorder.calls) + # Device names must come from the plain specs dict, not from + # fetch_available_devices (which deserializes specs via pulser). + self.assertNotIn("fetch_available_devices", recorder.calls) + for call in recorder.calls: + for forbidden in self.FORBIDDEN: + self.assertNotIn( + forbidden, call.lower(), + f"job-submission call path invoked: {call}", + ) + + def test_no_submission_attempted_even_on_project_failure(self): + recorder = Recorder() + run_validator(CRED_ENV, build_stub(recorder, scenario="project")) + joined = " ".join(recorder.calls).lower() + for forbidden in self.FORBIDDEN: + self.assertNotIn(forbidden, joined) + + +class TestSecretHygiene(unittest.TestCase): + """AC4: the password appears in no argv and no output stream.""" + + SCENARIOS = ("ok", "auth", "network-init", "network-devices", "project") + + def test_password_never_in_output_streams(self): + for scenario in self.SCENARIOS: + with self.subTest(scenario=scenario): + recorder = Recorder() + _, stdout, stderr = run_validator( + CRED_ENV, build_stub(recorder, scenario=scenario) + ) + self.assertNotIn(POISON_PASSWORD, stdout) + self.assertNotIn(POISON_PASSWORD, stderr) + + def test_password_never_in_argv(self): + # The script takes credentials from env ONLY: its invocation argv is + # just [interpreter, script]. Prove the in-process run never saw the + # password in argv, and that a real spawn (against an on-disk poison + # stub — never the live service) needs no secret arguments and leaks + # nothing on either stream. + recorder = Recorder() + run_validator(CRED_ENV, build_stub(recorder)) + self.assertNotIn(POISON_PASSWORD, " ".join(sys.argv)) + argv = [sys.executable, str(CONNECTOR_DIR / "pasqal_validate.py")] + self.assertNotIn(POISON_PASSWORD, " ".join(argv)) + with _spawn_stub(SPAWN_STUB_AUTH_FAIL) as stub_path: + result = _spawn_validator(argv, env_extra=CRED_ENV, pythonpath=stub_path) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertNotIn(POISON_PASSWORD, result.stdout + result.stderr) + + def test_missing_env_fails_cleanly_before_sdk_import(self): + # Spawned with no PASQAL_* vars: the env guard must fire (exit 1, + # names the variable) before any SDK import is attempted — the stub + # here would explode the run if it were imported. + argv = [sys.executable, str(CONNECTOR_DIR / "pasqal_validate.py")] + with _spawn_stub(SPAWN_STUB_IMPORT_BOMB) as stub_path: + result = _spawn_validator(argv, env_extra={}, pythonpath=stub_path) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("PASQAL_USERNAME", result.stderr) + self.assertEqual(result.stdout, "") + + def test_missing_sdk_renders_distinctly_not_as_traceback(self): + # Misconfigured python (no pasqal-cloud) must render as its own fixed + # error — never an uncaught traceback, never a network/auth code. + argv = [sys.executable, str(CONNECTOR_DIR / "pasqal_validate.py")] + with _spawn_stub(SPAWN_STUB_IMPORT_BOMB) as stub_path: + result = _spawn_validator(argv, env_extra=CRED_ENV, pythonpath=stub_path) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("pasqal-cloud", result.stderr) + self.assertNotIn("Traceback", result.stderr) + self.assertNotIn(POISON_PASSWORD, result.stderr) + self.assertEqual(result.stdout, "") + + def test_missing_password_named_specifically(self): + argv = [sys.executable, str(CONNECTOR_DIR / "pasqal_validate.py")] + with _spawn_stub(SPAWN_STUB_IMPORT_BOMB) as stub_path: + result = _spawn_validator( + argv, + env_extra={"PASQAL_USERNAME": USERNAME, "PASQAL_PROJECT_ID": PROJECT_ID}, + pythonpath=stub_path, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("PASQAL_PASSWORD", result.stderr) + + +# On-disk stubs for subprocess runs: a pasqal_cloud package on PYTHONPATH +# shadows any locally installed SDK, so spawn tests stay hermetic (no +# network) even on machines where pasqal-cloud is really installed. +SPAWN_STUB_AUTH_FAIL = { + "__init__.py": ( + "from pasqal_cloud.authentication import TokenProviderError\n" + "\n" + "class PasqalCloudConnection:\n" + " def __init__(self, **kwargs):\n" + " raise TokenProviderError(\n" + " 'denied; password=%s' % kwargs.get('password'))\n" + ), + "authentication.py": "class TokenProviderError(Exception):\n pass\n", +} +SPAWN_STUB_IMPORT_BOMB = { + "__init__.py": "raise ImportError('pasqal-cloud not installed (simulated)')\n", +} + + +class _spawn_stub: + def __init__(self, files): + self.files = files + + def __enter__(self): + import tempfile + + self.tmpdir = tempfile.TemporaryDirectory() + pkg = Path(self.tmpdir.name) / "pasqal_cloud" + pkg.mkdir() + for name, body in self.files.items(): + (pkg / name).write_text(body) + return self.tmpdir.name + + def __exit__(self, *exc_info): + self.tmpdir.cleanup() + return False + + +def _spawn_validator(argv, env_extra, pythonpath): + env = {k: v for k, v in os.environ.items() if not k.startswith("PASQAL_")} + env["PYTHONPATH"] = pythonpath + env.update(env_extra) + return subprocess.run(argv, capture_output=True, text=True, env=env, timeout=60) + + if __name__ == "__main__": unittest.main() From 0a9ef2a2e470664b91b12f1c04fca044815316be Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:22:14 -0400 Subject: [PATCH 04/15] pasqal-connector: pin pasqal-cloud + optional live smoke behind opt-in requirements.txt pins pasqal-cloud==0.23.0 (the spike's pin; the token extraction chain in pasqal_validate.py is verified against this version's object graph). tests/slow_live.py is the optional live smoke: excluded from default discovery by filename, gated on PASQAL_LIVE_SMOKE=1, and asserts token shape without echoing it. Closes out #164's testing decisions. Co-Authored-By: Claude Fable 5 --- .../scripts/pasqal-connector/requirements.txt | 1 + .../__pycache__/slow_live.cpython-311.pyc | Bin 0 -> 3068 bytes .../pasqal-connector/tests/slow_live.py | 43 ++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 packages/extension/scripts/pasqal-connector/requirements.txt create mode 100644 packages/extension/scripts/pasqal-connector/tests/__pycache__/slow_live.cpython-311.pyc create mode 100644 packages/extension/scripts/pasqal-connector/tests/slow_live.py diff --git a/packages/extension/scripts/pasqal-connector/requirements.txt b/packages/extension/scripts/pasqal-connector/requirements.txt new file mode 100644 index 000000000..985275e42 --- /dev/null +++ b/packages/extension/scripts/pasqal-connector/requirements.txt @@ -0,0 +1 @@ +pasqal-cloud==0.23.0 diff --git a/packages/extension/scripts/pasqal-connector/tests/__pycache__/slow_live.cpython-311.pyc b/packages/extension/scripts/pasqal-connector/tests/__pycache__/slow_live.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6fb1b7f5c906eb7bd68b8fe29b8d18f18699178 GIT binary patch literal 3068 zcma)8-EZ606~FwDM9EexJDFprg~OpiY-^Te$6hyd^8mwhvwT^uWw(Qhji7n2W6Bh% zl2?vxIDl3dm{$~79|FV$QcN%NGNccEX#Wq%AP|8-fC78Un}QS=(5IeDh5D$|VVBhX zI3GWrbMHCl>R<7A3_-hG_*3D(A%y0Hd&WB7hA1TCLa0 ze1Z((SPRV_^z9mYy(EV)pYwl{eQFN2B3q)mWvYg3=sQGqiqT2wBoRNIm%aj2440AHO-O0X1X(v7su~7- zL^bgldw?wybLX8{Ooq0ykb7_8wsQN%`%6mhPWIN){7gVw&n>MkFWgz0Pp8uX4a9RF zWLJOh)4KFmRV;!-vKA>7}h<--@(*Q>^a-|YMapN9Ta=^#F%(_{U$SqaFE9Q8?hS*kSad+!x^6uPqt} z0*-XFFxOiuz{{W7CjmTrH{(0;l*qMj+&Zu`gEI5|wg$zr@ zjwiKXZaCnc<3*r6>Nap=E%KUOCT$UOQuIWJ7@J-gjE3S37(QyGr0)4>EH_zc{Qe)A zlCAGRACvU?d$oz>x|DUL zY)#5O8ybF)JYKKPJo;mO=&Cz(^(X=we~*6>|LpEDeo(Bg)up5>C2LZ$jdXmm`ldUY z{)YcLR{PDeJDsgdE3ULslUA7Oz$XL8k!sW(N!F%r)}>pnbgL%a`nC;vuP%+d(s)f8 z4?bj6uh*q1SDLB??@QPoxb8$xQWSQ@D~cCWl%j>p2AlUQ3Jc>F$DRV&eZcg09kjnz3`CR1=-;TKY_teYB|RrlLTWMHB^Fnxfb;aBPNUGgNyRI!_9& zY!5=3ZW;Q9%`Phj!2Xw&GST}^Y{z@Nv8Yo)v zp9UJN1#bhzD*n?z0~P;ippjbd`)xFSbm3_0Z)2a1J&j&Ijb5%tf9|q(!i{EX*WRs1 z->rn3VZ{HeGU9R<8e;hH=3kdTUap Date: Sun, 19 Jul 2026 20:22:27 -0400 Subject: [PATCH 05/15] pasqal-connector: ignore python bytecode caches Co-Authored-By: Claude Fable 5 --- .../scripts/pasqal-connector/.gitignore | 2 ++ .../__pycache__/pasqal_validate.cpython-311.pyc | Bin 10279 -> 0 bytes .../tests/__pycache__/slow_live.cpython-311.pyc | Bin 3068 -> 0 bytes .../test_pasqal_validate.cpython-311.pyc | Bin 32108 -> 0 bytes 4 files changed, 2 insertions(+) create mode 100644 packages/extension/scripts/pasqal-connector/.gitignore delete mode 100644 packages/extension/scripts/pasqal-connector/__pycache__/pasqal_validate.cpython-311.pyc delete mode 100644 packages/extension/scripts/pasqal-connector/tests/__pycache__/slow_live.cpython-311.pyc delete mode 100644 packages/extension/scripts/pasqal-connector/tests/__pycache__/test_pasqal_validate.cpython-311.pyc diff --git a/packages/extension/scripts/pasqal-connector/.gitignore b/packages/extension/scripts/pasqal-connector/.gitignore new file mode 100644 index 000000000..7a60b85e1 --- /dev/null +++ b/packages/extension/scripts/pasqal-connector/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/packages/extension/scripts/pasqal-connector/__pycache__/pasqal_validate.cpython-311.pyc b/packages/extension/scripts/pasqal-connector/__pycache__/pasqal_validate.cpython-311.pyc deleted file mode 100644 index ddac8a815c74824aefd904b4e12a2723aa54b077..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10279 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 At^fc4 diff --git a/packages/extension/scripts/pasqal-connector/tests/__pycache__/slow_live.cpython-311.pyc b/packages/extension/scripts/pasqal-connector/tests/__pycache__/slow_live.cpython-311.pyc deleted file mode 100644 index e6fb1b7f5c906eb7bd68b8fe29b8d18f18699178..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3068 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 W@{OY}ADw0&m@?xmIadQC2>&1as0qmc From f9ba2afff08ec70641f3e5da19ccf863aea885a0 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:23:12 -0400 Subject: [PATCH 06/15] 163/AC1+AC4: per-boot server password armed at every opencode spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork's route auth (ServerAuth @ v1.17.3-amicode.5) is a verified no-op without OPENCODE_SERVER_PASSWORD in the server env. New server_auth module mints a cryptographically random per-boot password (32B base64url, in-memory only) and builds the spawn env the extension ADDS — PATH + config content + password — used by all three ServerManager sites (boot, solver switch, vault respawn), so no respawn path can drop it. Tests pin exactly the ADDED env keys (never full-env equality — the server inherits the host env by design), the fork's Basic base64(opencode:pw) credential shape, and that two mints differ. Co-Authored-By: Claude Fable 5 --- packages/extension/src/extension.ts | 41 +++++++----- packages/extension/src/server_auth.ts | 66 ++++++++++++++++++++ packages/extension/test/server_auth.test.ts | 69 +++++++++++++++++++++ 3 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 packages/extension/src/server_auth.ts create mode 100644 packages/extension/test/server_auth.test.ts diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 4789e8dc8..380390051 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -17,6 +17,7 @@ import { profileHasIdentity, } from "./opencode_config"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; +import { mintServerPassword, buildServerSpawnEnv } from "./server_auth"; import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; import { RunsManager } from "./runs_manager"; @@ -327,6 +328,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } else throw e; } + // Per-boot server password (#163, ADR 0002 graft 1): arms the fork's route + // auth, which is a no-op without OPENCODE_SERVER_PASSWORD in the spawn env. + // Minted fresh each activation, held in memory only — never persisted, never + // logged. ONE value for the whole activation: respawns (solver switch, vault + // refresh, restart) reuse it, because the open chat iframe carries the boot + // credential and a mid-session rotation would strand it on 401s. + const serverPassword = mintServerPassword(); + if (binary !== undefined) { // amico-run is argv-only (β.1) — no AMICO_* env propagation (S37). The agent // gets the Julia project from AGENTS.md (substituted at session-copy time) @@ -338,8 +347,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } // opencode owns the LLM credential (0.3): amico injects NO key into the // spawn env — opencode resolves its provider from its own env / config / - // auth.json. The spawn env carries only PATH (so amico-run resolves) and the - // amico instructions/permission config. + // auth.json. The spawn env carries only PATH (so amico-run resolves), the + // amico instructions/permission config, and the per-boot server password + // that arms the fork's route auth (#163). const configuredPort = vscode.workspace.getConfiguration("amicode").get("opencodePort", 0); if (configuredPort > 0) { opencodeChannel.appendLine(`[boot] amicode.opencodePort = ${configuredPort} (static)`); @@ -348,14 +358,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { binary, cwd: opencodeProject.projectDir, port: configuredPort > 0 ? configuredPort : undefined, - env: { - PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, + env: buildServerSpawnEnv({ + amicoRunBinDir, + serverPassword, // Inject the amico solve workflow as opencode `instructions` (loaded for // every session regardless of its cwd) — merges over the user's global // config, so the model/provider are preserved. This is what makes the // chat actually author + run solves instead of behaving like vanilla // opencode (the session cwd is the workspace, not opencodeProject.projectDir). - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + configContent: buildOpencodeConfigContent( opencodeProject.agentsPath, opencodeProject.templatePath, runsRoot, @@ -374,7 +385,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // picker still overrides per session. vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), ), - }, + }), channel: opencodeChannel, }); ctx.subscriptions.push({ dispose: () => void serverManager?.stop() }); @@ -409,9 +420,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { binary: binary!, cwd: project2.projectDir, port: configuredPort > 0 ? configuredPort : undefined, - env: { - PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + env: buildServerSpawnEnv({ + amicoRunBinDir, + serverPassword, // per-boot value survives the switch (chat iframe keeps its credential) + configContent: buildOpencodeConfigContent( project2.agentsPath, project2.templatePath, runsRoot, @@ -425,7 +437,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Same pin rule as boot: only an explicit amicode.defaultModel pins. vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), ), - }, + }), channel: opencodeChannel, }); serverManager.onReady((url) => { @@ -533,9 +545,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { binary, cwd: project2.projectDir, port: port > 0 ? port : undefined, - env: { - PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + env: buildServerSpawnEnv({ + amicoRunBinDir, + serverPassword, // per-boot value survives the vault respawn too + configContent: buildOpencodeConfigContent( project2.agentsPath, project2.templatePath, runsRoot, @@ -547,7 +560,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { project2.mounts, vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), ), - }, + }), channel: opencodeChannel, }); serverManager.onReady((url) => { diff --git a/packages/extension/src/server_auth.ts b/packages/extension/src/server_auth.ts new file mode 100644 index 000000000..778006bac --- /dev/null +++ b/packages/extension/src/server_auth.ts @@ -0,0 +1,66 @@ +import { randomBytes } from "node:crypto"; + +// ============================================================================ +// Per-boot server password (#163, ADR 0002 graft 1). +// +// The vendored fork's route auth (packages/opencode/src/server/auth.ts @ +// v1.17.3-amicode.5) only engages when OPENCODE_SERVER_PASSWORD is set in the +// server's env — without it, every route (including the amicode mutation +// routes) is open to any localhost page. The extension therefore mints a +// cryptographically random password once per activation and injects it into +// EVERY `opencode serve` spawn env. +// +// Carriage (all three read base64("opencode:"), verified at the tag): +// - the extension's own HTTP/SSE calls → `Authorization: Basic …` header +// - the chat iframe / fork app → `?auth_token=…` query param, which +// the app's entry consumes for its authenticated-fetch path and strips +// from the URL bar (history.replaceState) before rendering +// +// Lifetime: in-memory only for the life of the activation — never persisted, +// never logged. Respawns within one activation (solver-mode switch, vault +// refresh, restartServer) REUSE the value: the open chat iframe holds the +// boot credential, so a mid-session rotation would strand it on 401s. A new +// activation mints a new value. +// ============================================================================ + +/** The fork's default Basic-auth username (OPENCODE_SERVER_USERNAME unset). */ +const SERVER_USERNAME = "opencode"; + +/** Mint the per-boot server password: 32 random bytes, base64url so it rides + * env vars and the auth_token query param unescaped. */ +export function mintServerPassword(): string { + return randomBytes(32).toString("base64url"); +} + +/** The `?auth_token=` value the fork's auth middleware and the app's entry + * bootstrap both decode: base64("opencode:"). */ +export function serverAuthToken(password: string): string { + return Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64"); +} + +/** The `Authorization` header for the extension's own calls to the server — + * mirrors the fork's ServerAuth.header (Basic, username "opencode"). */ +export function serverAuthHeader(password: string): string { + return `Basic ${serverAuthToken(password)}`; +} + +/** Build the env the extension ADDS to the opencode server spawn (the server + * inherits the host env underneath — ServerManager spreads process.env): + * PATH — amico-run launcher dir prepended, so solves run + * 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. */ +export function buildServerSpawnEnv(opts: { + /** amico-run launcher bin dir; undefined = launcher missing (boot warns). */ + amicoRunBinDir: string | undefined; + /** buildOpencodeConfigContent(...) output for this spawn. */ + configContent: string; + /** The per-boot password from mintServerPassword(). */ + serverPassword: string; +}): Record { + return { + PATH: `${opts.amicoRunBinDir ? opts.amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, + OPENCODE_CONFIG_CONTENT: opts.configContent, + OPENCODE_SERVER_PASSWORD: opts.serverPassword, + }; +} diff --git a/packages/extension/test/server_auth.test.ts b/packages/extension/test/server_auth.test.ts new file mode 100644 index 000000000..5263b39d3 --- /dev/null +++ b/packages/extension/test/server_auth.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { mintServerPassword, serverAuthHeader, serverAuthToken, buildServerSpawnEnv } from "../src/server_auth"; + +// ============================================================================ +// Per-boot server password (#163, ADR 0002 graft 1). The fork's route auth +// (vendored opencode, packages/opencode/src/server/auth.ts @ v1.17.3-amicode.5) +// is a verified no-op unless OPENCODE_SERVER_PASSWORD is set in the server's +// env — any localhost page could drive the amicode routes. The extension mints +// a cryptographically random password per activation and injects it into EVERY +// `opencode serve` spawn env; its own HTTP/SSE calls carry the matching Basic +// credential. In-memory only: no file, no setting, no log line. +// ============================================================================ + +describe("mintServerPassword (per-boot, in-memory only)", () => { + it("mints a fresh value per activation — two mints differ (AC4)", () => { + expect(mintServerPassword()).not.toBe(mintServerPassword()); + }); + it("is cryptographically sized and env/URL-safe (base64url, no padding)", () => { + const pw = mintServerPassword(); + expect(pw.length).toBeGreaterThanOrEqual(43); // 32 random bytes → 43 base64url chars + expect(pw).toMatch(/^[A-Za-z0-9_-]+$/); // survives env + auth_token query carriage unescaped + }); +}); + +describe("serverAuthHeader / serverAuthToken — the fork's Basic-auth contract", () => { + // Shape verified against the fork's ServerAuth.header (default username + // "opencode") and its auth middleware's AUTH_TOKEN_QUERY decoding — both + // accept base64("opencode:"). + it("header is Basic base64(opencode:)", () => { + expect(serverAuthHeader("s3cret")).toBe(`Basic ${Buffer.from("opencode:s3cret").toString("base64")}`); + }); + it("token (the app's ?auth_token= bootstrap) is the bare base64 pair", () => { + expect(serverAuthToken("s3cret")).toBe(Buffer.from("opencode:s3cret").toString("base64")); + // the two carriages must decode to the SAME credential + expect(serverAuthHeader("s3cret")).toBe(`Basic ${serverAuthToken("s3cret")}`); + }); + it("tolerates passwords containing colons (fork splits on the FIRST colon)", () => { + const decoded = Buffer.from(serverAuthToken("se:cr:et"), "base64").toString(); + expect(decoded.slice(decoded.indexOf(":") + 1)).toBe("se:cr:et"); + }); +}); + +describe("buildServerSpawnEnv — the env keys the extension ADDS to the spawn", () => { + const opts = { amicoRunBinDir: "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/ext/bin", configContent: '{"instructions":["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/a/AGENTS.md"]}', serverPassword: "pw" }; + it("adds EXACTLY PATH + OPENCODE_CONFIG_CONTENT + OPENCODE_SERVER_PASSWORD (AC1)", () => { + // Exactly the ADDED keys — the server inherits the host env by platform + // design (ServerManager spreads process.env under these), so full-env + // equality is a known-wrong assertion; the contract is what WE add. + expect(Object.keys(buildServerSpawnEnv(opts)).sort()).toEqual([ + "OPENCODE_CONFIG_CONTENT", + "OPENCODE_SERVER_PASSWORD", + "PATH", + ]); + }); + it("carries a NON-EMPTY password — route auth must not stay a no-op (AC2)", () => { + const env = buildServerSpawnEnv({ ...opts, serverPassword: mintServerPassword() }); + expect(env.OPENCODE_SERVER_PASSWORD.length).toBeGreaterThan(0); + // and it is the minted value verbatim — no transformation on the way in + expect(buildServerSpawnEnv(opts).OPENCODE_SERVER_PASSWORD).toBe("pw"); + }); + it("prepends amicoRunBinDir to PATH so the launcher resolves (existing spawn behavior)", () => { + expect(buildServerSpawnEnv(opts).PATH).toBe(`/ext/bin:${process.env.PATH ?? ""}`); + // no launcher dir → host PATH untouched (chat can author; solves warn at boot) + expect(buildServerSpawnEnv({ ...opts, amicoRunBinDir: undefined }).PATH).toBe(process.env.PATH ?? ""); + }); + it("passes the config content through verbatim (the instructions/permission merge)", () => { + expect(buildServerSpawnEnv(opts).OPENCODE_CONFIG_CONTENT).toBe(opts.configContent); + }); +}); From a6dd108f740542c725d2ed56eabb5eab38145b81 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:30:16 -0400 Subject: [PATCH 07/15] 163/AC2: every extension call to the server carries the boot credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With route auth armed, the extension's own calls 401 without it — found four surfaces, each now authenticated and pinned by tests against real transports: - ServerManager health probe (derives Basic from the spawn env it injected, so probe and server can never drift; real-spawn fake-binary test) - SSE /event subscription (silent retry-loop forever otherwise; live http server captures the Authorization header) - fetchProviderSignal /config* probes (boot signal, healthcheck, chat gate — would read 'server unreachable' forever) - chat iframe src ?auth_token= — the fork app's own credential bootstrap (entry.tsx adopts it for its authenticated-fetch path and strips the URL); no-token path unchanged for the unsecured dev server Also fork parity on OPENCODE_SERVER_USERNAME: the spawned server inherits a host-env override, so the credential builders honor it too. Co-Authored-By: Claude Fable 5 --- packages/extension/src/chat_panel.ts | 19 +++- packages/extension/src/extension.ts | 26 +++-- packages/extension/src/llm_creds.d.mts | 6 +- packages/extension/src/llm_creds.mjs | 8 +- packages/extension/src/server_auth.ts | 10 +- packages/extension/src/server_manager.ts | 19 +++- packages/extension/src/sse_client.ts | 9 +- packages/extension/test/__mocks__/vscode.ts | 3 + packages/extension/test/chat_panel.test.ts | 76 +++++++++++++ packages/extension/test/llm_creds.test.ts | 21 ++++ packages/extension/test/server_auth.test.ts | 14 +++ .../extension/test/server_manager.test.ts | 106 ++++++++++++++++++ packages/extension/test/sse_client.test.ts | 73 ++++++++++++ 13 files changed, 364 insertions(+), 26 deletions(-) create mode 100644 packages/extension/test/chat_panel.test.ts create mode 100644 packages/extension/test/server_manager.test.ts create mode 100644 packages/extension/test/sse_client.test.ts diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 2e88e17e8..6c299cdb2 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -58,8 +58,9 @@ export class ChatPanel { private constructor( private readonly panel: vscode.WebviewPanel, opencodeUrl: URL, + authToken?: string, ) { - this.panel.webview.html = this.renderHtml(opencodeUrl); + this.panel.webview.html = this.renderHtml(opencodeUrl, authToken); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); // Live theme bridge: editor theme changes flow extension → outer relay → // iframe → the app's setColorScheme (boot theme rides ?colorScheme=). @@ -198,7 +199,12 @@ export class ChatPanel { ); } - static openOrReveal(ctx: vscode.ExtensionContext, opencodeUrl: URL): ChatPanel { + /** `authToken` is the per-boot server credential (#163) as the app's + * `?auth_token=` bootstrap value — base64("opencode:"), from + * serverAuthToken(). The app adopts it for its authenticated-fetch path and + * strips it from the URL (entry-level history.replaceState). One value per + * activation, so revealing an existing panel never needs a re-render. */ + static openOrReveal(ctx: vscode.ExtensionContext, opencodeUrl: URL, authToken?: string): ChatPanel { if (ChatPanel.current) { ChatPanel.current.panel.reveal(vscode.ViewColumn.One); return ChatPanel.current; @@ -212,11 +218,11 @@ export class ChatPanel { localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], }); panel.iconPath = tabIconPath(ctx); - ChatPanel.current = new ChatPanel(panel, opencodeUrl); + ChatPanel.current = new ChatPanel(panel, opencodeUrl, authToken); return ChatPanel.current; } - private renderHtml(opencodeUrl: URL): string { + private renderHtml(opencodeUrl: URL, authToken?: string): string { // CSP: allow the iframe to load opencode's localhost origin. The frame // itself is isolated, but VS Code's webview CSP needs to explicitly grant // the localhost frame-src. The nonce authorizes the one relay script below. @@ -234,6 +240,11 @@ export class ChatPanel { // inside the webview iframe reports the OS, not VS Code). const framed = new URL(opencodeUrl.href); framed.searchParams.set("colorScheme", themeKindToScheme(vscode.window.activeColorTheme.kind)); + // Per-boot server credential (#163): ride the app's own ?auth_token= + // bootstrap — its entry adopts it for every authenticated fetch and strips + // it from the URL. The iframe src is the credential's ONLY carriage here; + // it never appears in a log line or any other surface. + if (authToken) framed.searchParams.set("auth_token", authToken); return /* html */ ` diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 380390051..b05a4b68c 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -17,7 +17,7 @@ import { profileHasIdentity, } from "./opencode_config"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; -import { mintServerPassword, buildServerSpawnEnv } from "./server_auth"; +import { mintServerPassword, serverAuthHeader, serverAuthToken, buildServerSpawnEnv } from "./server_auth"; import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; import { RunsManager } from "./runs_manager"; @@ -335,6 +335,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // refresh, restart) reuse it, because the open chat iframe carries the boot // credential and a mid-session rotation would strand it on 401s. const serverPassword = mintServerPassword(); + // The extension's own calls to the server (health probe aside — ServerManager + // derives its own from the spawn env) authenticate with the matching Basic + // credential: SSE /event, the /config* signal probes, and the chat iframe + // (via the app's ?auth_token= bootstrap). + const serverAuthHeaders = { Authorization: serverAuthHeader(serverPassword) }; if (binary !== undefined) { // amico-run is argv-only (β.1) — no AMICO_* env propagation (S37). The agent @@ -481,8 +486,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[boot] no personal vault resolved — distiller disabled, session unpersonalized`); } - // SSE event channel — opens once opencode is healthy. - sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); + // SSE event channel — opens once opencode is healthy. Carries the per-boot + // credential (#163): the fork 401s an anonymous /event. + sseClient = new OpencodeEventClient({ + channel: opencodeChannel, + statusBar, + authorization: serverAuthHeaders.Authorization, + }); ctx.subscriptions.push(sseClient); serverManager.onReady((url) => { @@ -492,12 +502,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Open the chat as soon as the server is up (amicode.chat.autoOpen, // default on) — the chat IS the product's front door. if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { - ChatPanel.openOrReveal(ctx, url); + ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword)); } // Surface ONE explicit LLM-provider signal at boot, read from opencode's // OWN resolution (its live /config/providers) — not a silent hang at the // chat box (Q129). Key-free; never logs a credential. - void fetchProviderSignal(url.toString()).then((sig) => { + void fetchProviderSignal(url.toString(), { headers: serverAuthHeaders }).then((sig) => { opencodeChannel.appendLine( sig.ok ? `[boot] LLM provider: configured (${sig.provider}${sig.source ? ` via ${sig.source}` : ""})` @@ -749,7 +759,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // LLM provider (opencode's own resolution). if (opencodeReadyUrl) { try { - const sig = await fetchProviderSignal(opencodeReadyUrl.toString()); + const sig = await fetchProviderSignal(opencodeReadyUrl.toString(), { headers: serverAuthHeaders }); results.push({ name: "LLM creds", ok: sig.ok, @@ -794,12 +804,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // check and silently hang at the chat box (Q129). Ask opencode's own live // resolution (/config/providers, same signal the healthcheck uses) so the // cause is named, not hidden. Key-free. - const creds = await fetchProviderSignal(readyUrl.toString()); + const creds = await fetchProviderSignal(readyUrl.toString(), { headers: serverAuthHeaders }); if (!creds.ok) { vscode.window.showWarningMessage(`Amicode: ${creds.reason} → ${creds.fix}`); return; } - ChatPanel.openOrReveal(ctx, readyUrl); + ChatPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword)); }), vscode.commands.registerCommand("amicode.openInspector", async () => { await revealInspector(); diff --git a/packages/extension/src/llm_creds.d.mts b/packages/extension/src/llm_creds.d.mts index 14f2978f2..dba9ab037 100644 --- a/packages/extension/src/llm_creds.d.mts +++ b/packages/extension/src/llm_creds.d.mts @@ -19,8 +19,10 @@ export function resolveLlmCreds(args: { providers: ProviderEntry[]; model?: stri /** No-leak boundary: strip the raw /config/providers JSON to key-free {id, source}. */ export function stripProviders(providersJson: unknown): ProviderEntry[]; -/** Async: query a running opencode server for the provider signal (no key ever returned). */ +/** Async: query a running opencode server for the provider signal (no key ever + * returned). `headers` carries the Basic credential for the per-boot server + * password (#163). */ export function fetchProviderSignal( baseUrl: string, - opts?: { fetchImpl?: typeof fetch; timeoutMs?: number }, + opts?: { fetchImpl?: typeof fetch; timeoutMs?: number; headers?: Record }, ): Promise; diff --git a/packages/extension/src/llm_creds.mjs b/packages/extension/src/llm_creds.mjs index abafceba7..2403345d5 100644 --- a/packages/extension/src/llm_creds.mjs +++ b/packages/extension/src/llm_creds.mjs @@ -72,12 +72,14 @@ export function stripProviders(providersJson) { * Used by the chat-not-ready gate (the live extension server) and the boot * probe. A fetch failure yields a not-ok signal (server unreachable) rather * than throwing. `fetchImpl` is injectable so the signal is unit-testable - * without a live server. + * without a live server. `headers` carries the Basic credential for the + * per-boot server password (#163) — without it both probes 401 once route + * auth is armed. */ -export async function fetchProviderSignal(baseUrl, { fetchImpl = fetch, timeoutMs = 4000 } = {}) { +export async function fetchProviderSignal(baseUrl, { fetchImpl = fetch, timeoutMs = 4000, headers } = {}) { const base = String(baseUrl).replace(/\/$/, ""); const getJson = async (path) => { - const r = await fetchImpl(`${base}${path}`, { signal: AbortSignal.timeout(timeoutMs) }); + const r = await fetchImpl(`${base}${path}`, { signal: AbortSignal.timeout(timeoutMs), headers }); if (!r.ok) throw new Error(`${path} → ${r.status}`); return await r.json(); }; diff --git a/packages/extension/src/server_auth.ts b/packages/extension/src/server_auth.ts index 778006bac..61a98e26d 100644 --- a/packages/extension/src/server_auth.ts +++ b/packages/extension/src/server_auth.ts @@ -23,8 +23,12 @@ import { randomBytes } from "node:crypto"; // activation mints a new value. // ============================================================================ -/** The fork's default Basic-auth username (OPENCODE_SERVER_USERNAME unset). */ -const SERVER_USERNAME = "opencode"; +/** The fork resolves its Basic-auth username from OPENCODE_SERVER_USERNAME ?? + * "opencode" — and the spawned server INHERITS the host env, so a dev override + * there must shape our credential too or every extension call 401s. */ +function serverUsername(): string { + return process.env.OPENCODE_SERVER_USERNAME || "opencode"; +} /** Mint the per-boot server password: 32 random bytes, base64url so it rides * env vars and the auth_token query param unescaped. */ @@ -35,7 +39,7 @@ export function mintServerPassword(): string { /** The `?auth_token=` value the fork's auth middleware and the app's entry * bootstrap both decode: base64("opencode:"). */ export function serverAuthToken(password: string): string { - return Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64"); + return Buffer.from(`${serverUsername()}:${password}`).toString("base64"); } /** The `Authorization` header for the extension's own calls to the server — diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index 2b805251a..3cd6760ae 100644 --- a/packages/extension/src/server_manager.ts +++ b/packages/extension/src/server_manager.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import * as cp from "node:child_process"; import * as net from "node:net"; import type { Readable } from "node:stream"; +import { serverAuthHeader } from "./server_auth"; // ============================================================================ // ServerManager — spawn `opencode serve --port=N`, wait for it to come up, @@ -73,7 +74,12 @@ export class ServerManager { this.child = undefined; }); - const ready = await waitForHealth(`http://127.0.0.1:${port}/`, 30_000); + // The probe authenticates with the credential WE injected (#163): with + // OPENCODE_SERVER_PASSWORD armed, the fork 401s an anonymous `GET /`, and + // a healthy boot would read as a 30s timeout. Derived from the same env + // the child gets, so probe and server can never disagree. + const password = this.opts.env.OPENCODE_SERVER_PASSWORD; + const ready = await waitForHealth(`http://127.0.0.1:${port}/`, 30_000, password ? serverAuthHeader(password) : undefined); if (!ready) { this.opts.channel.appendLine(`[server] opencode did not become healthy within 30s`); this.stop(); @@ -132,13 +138,13 @@ function pickFreePort(): Promise { }); } -async function waitForHealth(baseUrl: string, timeoutMs: number): Promise { +async function waitForHealth(baseUrl: string, timeoutMs: number, authorization?: string): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { // opencode 1.3.x serves a redirect or HTML at /; just probe for any // 2xx/3xx response on the base URL with a short timeout. - const r = await fetchWithTimeout(baseUrl, 500); + const r = await fetchWithTimeout(baseUrl, 500, authorization); if (r.ok || (r.status >= 200 && r.status < 400)) return true; } catch { // not ready yet @@ -148,11 +154,14 @@ async function waitForHealth(baseUrl: string, timeoutMs: number): Promise { +async function fetchWithTimeout(url: string, ms: number, authorization?: string): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), ms); try { - return await fetch(url, { signal: ctrl.signal }); + return await fetch(url, { + signal: ctrl.signal, + headers: authorization ? { Authorization: authorization } : undefined, + }); } finally { clearTimeout(timer); } diff --git a/packages/extension/src/sse_client.ts b/packages/extension/src/sse_client.ts index 30673edf9..3de3c668c 100644 --- a/packages/extension/src/sse_client.ts +++ b/packages/extension/src/sse_client.ts @@ -22,6 +22,10 @@ import type { StatusBarManager } from "./status_bar"; export interface SseClientOptions { channel: vscode.OutputChannel; statusBar?: StatusBarManager; + /** `Authorization` header value for the per-boot server password (#163) — + * without it the fork 401s /event and the reconnect loop spins forever. + * Never logged; the value exists only on the wire. */ + authorization?: string; } export class OpencodeEventClient implements vscode.Disposable { @@ -63,7 +67,10 @@ export class OpencodeEventClient implements vscode.Disposable { port: this.url.port, path: this.url.pathname, method: "GET", - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + ...(this.opts.authorization ? { Authorization: this.opts.authorization } : {}), + }, }, (res) => { this.res = res; diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index 0507e05bc..1ad98c76b 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -8,6 +8,8 @@ export const window = { showInputBox: () => Promise.resolve(undefined), createOutputChannel: () => ({ appendLine() {}, append() {}, dispose() {} }), registerWebviewViewProvider: () => ({ dispose() {} }), + activeColorTheme: { kind: 2 }, // ColorThemeKind.Dark + onDidChangeActiveColorTheme: (_cb: unknown, _thisArg?: unknown, _subs?: unknown) => ({ dispose() {} }), createWebviewPanel: (_viewType: string, _title: string, _column?: unknown, _opts?: unknown) => { const disposeCbs: Array<() => void> = []; return { @@ -44,6 +46,7 @@ export const commands = { executeCommand: (id: string, ...a: unknown[]) => Promise.resolve(registeredCommands.get(id)?.(...a)), }; export const ViewColumn = { One: 1, Two: 2 }; +export const ColorThemeKind = { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }; export const workspace = { workspaceFolders: [] as unknown[], getConfiguration: () => ({ get: (_k: string, d?: unknown) => d ?? "" }), diff --git a/packages/extension/test/chat_panel.test.ts b/packages/extension/test/chat_panel.test.ts new file mode 100644 index 000000000..6a48e7855 --- /dev/null +++ b/packages/extension/test/chat_panel.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as vscode from "vscode"; +import { ChatPanel } from "../src/chat_panel"; +import { mintServerPassword, serverAuthToken } from "../src/server_auth"; + +// ============================================================================ +// #163: with the per-boot server password armed, the fork 401s the chat app's +// document and every fetch it makes. The app's EXISTING credential bootstrap +// (verified at v1.17.3-amicode.5, packages/app/src/entry.tsx) reads +// `?auth_token=base64(opencode:pw)` off its URL, adopts it for the +// authenticated-fetch path, and strips it from the URL bar — so the extension +// carries the credential to the app on the iframe src, and ONLY there. +// ============================================================================ + +type CapturedPanel = { webview: { html: string }; dispose(): void }; + +/** Wrap the mock's createWebviewPanel to capture the panel openOrReveal builds + * (ChatPanel keeps it private; the html is the surface under test). */ +function capturePanel(): { created: CapturedPanel[]; restore: () => void } { + const created: CapturedPanel[] = []; + const w = vscode.window as unknown as { createWebviewPanel: (...a: unknown[]) => CapturedPanel }; + const orig = w.createWebviewPanel; + w.createWebviewPanel = (...a: unknown[]) => { + const p = orig(...a); + created.push(p); + return p; + }; + return { created, restore: () => (w.createWebviewPanel = orig) }; +} + +function fakeCtx(): vscode.ExtensionContext { + return { extensionUri: { fsPath: "/ext" } } as unknown as vscode.ExtensionContext; +} + +const iframeSrc = (html: string): URL => { + const m = html.match(/