privateer-agent 0.12.18 → 0.12.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,49 +1,41 @@
1
1
  /**
2
- * Level 2 report-binding checks (§10.1 checks 2–6), minus the hardware root of
3
- * trust. This verifies the *cryptographic binding* of the report — that its
4
- * `workload_id`, keyset digest, `report_data`, and endorsement are internally
5
- * consistent and endorsed by the identity key — for the nonce the client
6
- * supplied. It does NOT do §10.1 check 1 (the TEE quote verifies to the vendor
7
- * root) or the "hardware evidence binds `report_data`" half of check 4: parsing
8
- * and checking a TDX/SEV-SNP quote is verifier-profile territory and needs
9
- * primitives outside the Web Crypto API. Compose this with a quote verifier and
10
- * the custody/provenance/channel checks (§10.1 checks 1, 7–10) for full Level 2.
2
+ * Report binding checks a verifier can run with pure Web Crypto — §9.1 check 2
3
+ * (binding and freshness: keyset bytes → digest → statement → `report_data`)
4
+ * and check 3 (expiry), plus the aci/1 protocol gate. Check 1 (the hardware
5
+ * quote verifies to the vendor root and binds `report_data`) is done by
6
+ * ../../phalaSeal.ts with @phala/dcap-qvl; checks 5–6 (custody, channel) stay
7
+ * policy / caller territory.
8
+ *
9
+ * Local omission (see VENDORED.md): upstream's `verifyQuote` and
10
+ * `verifyComposeMeasurement` live in this file too. They are not carried here —
11
+ * phalaSeal.ts owns the quote (it also gates TCB status and pins measurements)
12
+ * and measurements.ts owns the event-log replay across all four RTMRs. Leaving
13
+ * them out keeps this tree dependency-free and @phala/dcap-qvl off the import
14
+ * path of every startup.
11
15
  */
12
16
 
13
- import {
14
- computeWorkloadId,
15
- computeKeysetDigest,
16
- computeReportData,
17
- keysetEndorsementPayload,
18
- } from './digest';
19
- import { verifySignature, fromHex } from './crypto';
20
- import type { AttestationReport, Check, ReportVerification } from './types';
17
+ import { computeKeysetDigest, computeReportData } from './digest';
18
+ import type { AttestationReport, Check, ReportVerification, WorkloadKeyset } from './types';
21
19
 
22
20
  /** Options for {@link verifyReportBinding}. */
23
21
  export interface ReportBindingOptions {
24
22
  /**
25
- * Current time in Unix seconds for the freshness check (§10.1 check 6).
26
- * Defaults to the local clock. Pass an explicit value for deterministic tests.
23
+ * Current time in Unix seconds for the expiry check (§9.1 check 3).
24
+ * Defaults to the local clock; pass an explicit value for deterministic tests.
27
25
  */
28
26
  now?: number;
29
- /**
30
- * Whether the profile trusts the platform's declared validity window
31
- * (`freshness.fetched_at`/`stale_after`, §5.1). Off by default — recency comes
32
- * from the nonce binding, and `fetched_at`/`stale_after` need a securely
33
- * synced TEE clock to mean anything.
34
- */
35
- trustPlatformClock?: boolean;
36
27
  }
37
28
 
38
29
  /**
39
- * Verify the report's cryptographic bindings for `nonce` (§10.1 checks 2–6).
40
- * `nonce` is the value the verifier supplied to `GET /v1/aci/attestation`, or
41
- * `null`/`undefined` when it requested no nonce (§4.4).
30
+ * Verify the report's cryptographic bindings for `nonce` — the value this
31
+ * client sent to `GET /v1/aci/attestation`, or `null`/`undefined` when it sent
32
+ * none (§3.2). One recomputation establishes that the keyset is exactly what
33
+ * the quote bound and that the quote postdates the challenge (§9.1 check 2).
42
34
  *
43
- * Returns a per-check result and the identity recomputed from the report's
44
- * keyset; a failed check is `ok: false`, never thrown. Throws
45
- * {@link UnsupportedAlgorithmError} when the identity key algorithm is outside
46
- * Web Crypto scope (e.g. `ecdsa-secp256k1`).
35
+ * Returns per-check results plus the established keyset (digest, exact bytes,
36
+ * parsed form); a failed check on the served report is `ok: false`, never
37
+ * thrown. The one exception is the caller's own input: a nonce that is not
38
+ * 64 lowercase hex throws {@link AciFormatError} (§3.2).
47
39
  */
48
40
  export async function verifyReportBinding(
49
41
  report: AttestationReport,
@@ -53,71 +45,56 @@ export async function verifyReportBinding(
53
45
  const now = options.now ?? Math.floor(Date.now() / 1000);
54
46
  const checks: Check[] = [];
55
47
 
56
- const keyset = report.attestation.workload_keyset;
57
- const identityKey = keyset.workload_identity.public_key;
58
-
59
- // Check 2: workload_id == digest of the identity public key in the report's keyset.
60
- const workloadId = await computeWorkloadId(identityKey);
61
- pushEqual(checks, 'workload_id', report.workload_id, workloadId);
48
+ // Protocol gate (Appendix B): artifacts with another version are rejected.
49
+ const versionOk = report.api_version === 'aci/1';
50
+ checks.push({
51
+ name: 'api_version',
52
+ ok: versionOk,
53
+ ...(versionOk ? {} : { detail: `api_version "${report.api_version}" is not "aci/1"` }),
54
+ });
62
55
 
63
- // Check 3: workload_keyset_digest == digest of the report's keyset.
64
- const workloadKeysetDigest = await computeKeysetDigest(keyset);
65
- pushEqual(checks, 'workload_keyset_digest', report.workload_keyset_digest, workloadKeysetDigest);
56
+ const keysetValue = report.attestation.workload_keyset;
57
+ if (keysetValue === null || typeof keysetValue !== 'object' || Array.isArray(keysetValue)) {
58
+ const detail = 'workload_keyset is not a JSON object';
59
+ for (const name of ['workload_keyset_digest', 'report_data', 'not_after']) {
60
+ checks.push({ name, ok: false, detail });
61
+ }
62
+ return { ok: false, checks };
63
+ }
66
64
 
67
- // Check 4 (binding half): report_data == the §4.4 statement digest for this nonce.
68
- // The hardware-evidence-binds-report_data half is out of scope (see file header).
69
- const expectedReportData = await computeReportData(workloadId, workloadKeysetDigest, nonce);
65
+ // §9.1 check 2: recompute the whole chain from the served keyset object —
66
+ // canonicalize exactly what was parsed, unknown members included. The
67
+ // recomputed digest is authoritative (Appendix A) — the report's restated copy is
68
+ // checked for consistency but never feeds the statement.
69
+ const digest = await computeKeysetDigest(keysetValue);
70
+ pushEqual(checks, 'workload_keyset_digest', report.workload_keyset_digest, digest);
71
+ const expectedReportData = await computeReportData(digest, nonce);
70
72
  pushEqual(checks, 'report_data', report.attestation.report_data, expectedReportData);
71
73
 
72
- // Check 5: keyset endorsement verifies under the identity key, algo matching.
73
- const endorsement = report.attestation.keyset_endorsement;
74
- if (endorsement.algo !== identityKey.algo) {
74
+ const keyset = keysetValue as WorkloadKeyset;
75
+
76
+ // §9.1 check 3: now < not_after in the decoded keyset.
77
+ if (typeof keyset.not_after !== 'number') {
75
78
  checks.push({
76
- name: 'keyset_endorsement',
79
+ name: 'not_after',
77
80
  ok: false,
78
- detail: `endorsement.algo "${endorsement.algo}" != identity key algo "${identityKey.algo}"`,
81
+ detail: 'keyset has no numeric not_after',
79
82
  });
80
83
  } else {
81
- const ok = await verifySignature(
82
- identityKey.algo,
83
- fromHex(identityKey.public_key),
84
- fromHex(endorsement.value),
85
- keysetEndorsementPayload(workloadKeysetDigest),
86
- 'keyset endorsement (§4.3)',
87
- );
84
+ const ok = now < keyset.not_after;
88
85
  checks.push({
89
- name: 'keyset_endorsement',
86
+ name: 'not_after',
90
87
  ok,
91
- ...(ok ? {} : { detail: 'endorsement signature failed under identity key' }),
92
- });
93
- }
94
-
95
- // Check 6: freshness. Nonce binding is check 4; here bound the epoch and,
96
- // when trusted, the declared validity window.
97
- const notAfter = keyset.keyset_epoch.not_after;
98
- const epochOk = now < notAfter;
99
- checks.push({
100
- name: 'keyset_epoch.not_after',
101
- ok: epochOk,
102
- ...(epochOk ? {} : { detail: `now ${now} >= not_after ${notAfter}` }),
103
- });
104
- if (options.trustPlatformClock) {
105
- const freshness = report.attestation.freshness;
106
- const fetchedAt = freshness?.fetched_at;
107
- const staleAfter = freshness?.stale_after;
108
- const windowOk =
109
- typeof fetchedAt === 'number' &&
110
- typeof staleAfter === 'number' &&
111
- fetchedAt <= now &&
112
- now < staleAfter;
113
- checks.push({
114
- name: 'freshness_window',
115
- ok: windowOk,
116
- ...(windowOk ? {} : { detail: `now ${now} outside [${fetchedAt}, ${staleAfter})` }),
88
+ ...(ok ? {} : { detail: `now ${now} >= not_after ${keyset.not_after}` }),
117
89
  });
118
90
  }
119
91
 
120
- return { ok: checks.every((c) => c.ok), checks, workloadId, workloadKeysetDigest };
92
+ return {
93
+ ok: checks.every((c) => c.ok),
94
+ checks,
95
+ workloadKeysetDigest: digest,
96
+ keyset,
97
+ };
121
98
  }
122
99
 
123
100
  function pushEqual(checks: Check[], name: string, actual: string, expected: string): void {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Attested-session helpers (§8, §9.3). A session is content-addressed: its id
3
+ * is the SHA-256 of the exact served document bytes, and the signed receipt
4
+ * commits to that id — there is no session signature.
5
+ */
6
+
7
+ import { sha256Hex, sha256Prefixed, fromBase64 } from './crypto';
8
+ import { jcsBytes } from './jcs';
9
+ import type { SessionRecord, SessionEvidence } from './types';
10
+
11
+ /** `session_id` (§8): bare 64-hex sha256 of the JCS form of the parsed document. */
12
+ export async function computeSessionId(record: unknown): Promise<string> {
13
+ return sha256Hex(jcsBytes(record));
14
+ }
15
+
16
+ /** Appendix B: reject session documents whose `api_version` is not `aci/1`. */
17
+ export function checkSessionApiVersion(record: Pick<SessionRecord, 'api_version'>): boolean {
18
+ return record.api_version === 'aci/1';
19
+ }
20
+
21
+ /**
22
+ * §9.2(2): `evidence.data` decodes and hashes to `evidence.digest`.
23
+ * Returns false when the data URI is absent, malformed, or does not hash.
24
+ */
25
+ export async function checkSessionEvidence(evidence: SessionEvidence): Promise<boolean> {
26
+ if (evidence == null || typeof evidence !== 'object') return false;
27
+ const { digest, data } = evidence;
28
+ if (typeof digest !== 'string' || typeof data !== 'string') return false;
29
+ const comma = data.indexOf(',');
30
+ if (!data.startsWith('data:') || comma < 0 || !data.slice(0, comma).endsWith(';base64')) {
31
+ return false;
32
+ }
33
+ let bytes: Uint8Array;
34
+ try {
35
+ bytes = fromBase64(data.slice(comma + 1));
36
+ } catch {
37
+ return false;
38
+ }
39
+ return (await sha256Prefixed(bytes)) === digest;
40
+ }
@@ -1,139 +1,149 @@
1
1
  /**
2
2
  * Wire shapes for the ACI artifacts this verifier reads, plus the result types
3
- * it returns. These mirror spec/aci.md §4, §5, §8, §9; only the fields the
3
+ * it returns. These mirror spec/aci.md §3, §4, §7, §8; only the fields the
4
4
  * verifier touches are typed precisely, with an index signature left open so
5
- * unknown extension fields (§3.2) survive canonicalization untouched.
5
+ * extension fields (Appendix B) are visible to callers.
6
6
  */
7
7
 
8
- import type { JcsValue } from './jcs';
9
-
10
- /** A public key object: `{ algo, public_key }` (§4.1). */
11
- export interface PublicKey {
8
+ /** A keyed public-key entry (§3.1) — receipt signing and E2EE keys. */
9
+ export interface KeysetKey {
10
+ key_id: string;
12
11
  algo: string;
13
12
  public_key: string;
14
- [key: string]: JcsValue | undefined;
13
+ [key: string]: unknown;
15
14
  }
16
15
 
17
- /** The workload identity (§4.2): the identity public key plus an optional subject. */
18
- export interface WorkloadIdentity {
19
- public_key: PublicKey;
20
- subject?: string | null;
21
- [key: string]: JcsValue | undefined;
16
+ /** A TLS pin entry (§3.1): the certificate SPKI digest, optionally domain-scoped. */
17
+ export interface TlsKeyPin {
18
+ spki_sha256: string;
19
+ domain?: string;
20
+ [key: string]: unknown;
22
21
  }
23
22
 
24
- /** A receipt signing key entry (§4.2). */
25
- export interface ReceiptSigningKey {
26
- key_id: string;
27
- algo: string;
28
- public_key: string;
29
- [key: string]: JcsValue | undefined;
30
- }
31
-
32
- /** The workload keyset (§4.2). Digested with JCS to yield `workload_keyset_digest`. */
23
+ /**
24
+ * The workload keyset (§3.1) — the unit of workload identity. It travels as
25
+ * `workload_keyset`; its digest is the SHA-256 of the JCS form of the
26
+ * parsed object (Appendix A).
27
+ */
33
28
  export interface WorkloadKeyset {
34
- workload_identity: WorkloadIdentity;
35
- keyset_epoch: { version: number; not_after: number; [key: string]: JcsValue | undefined };
36
- receipt_signing_keys: ReceiptSigningKey[];
37
- e2ee_public_keys?: JcsValue[];
38
- tls_public_keys?: JcsValue[];
39
- [key: string]: JcsValue | undefined;
29
+ subject?: string | null;
30
+ not_after: number;
31
+ receipt_signing_keys: KeysetKey[];
32
+ e2ee_public_keys: KeysetKey[];
33
+ tls_public_keys?: TlsKeyPin[];
34
+ [key: string]: unknown;
40
35
  }
41
36
 
42
- /** A receipt signature block (§8.2). `value` is dropped for canonical signing bytes (§8.5). */
43
- export interface ReceiptSignature {
44
- algo: string;
45
- key_id: string;
46
- value: string;
47
- [key: string]: JcsValue | undefined;
37
+ /** Source provenance (§4.1); each field is `null` when unknown. */
38
+ export interface SourceProvenance {
39
+ repo_url?: string | null;
40
+ repo_commit?: string | null;
41
+ image_digest?: string | null;
42
+ image_provenance?: unknown;
43
+ [key: string]: unknown;
48
44
  }
49
45
 
50
- /** A single receipt event (§8.3). Only `seq`/`type` are fixed; other fields are type-specific. */
51
- export interface ReceiptEvent {
52
- seq: number;
53
- type: string;
54
- [key: string]: JcsValue | undefined;
46
+ /** The `attestation` object of a report (§4.1). `evidence` is policy-defined (§4.2). */
47
+ export interface Attestation {
48
+ tee_type: string;
49
+ workload_keyset: unknown;
50
+ report_data: string;
51
+ source_provenance?: SourceProvenance | null;
52
+ evidence?: unknown;
53
+ [key: string]: unknown;
55
54
  }
56
55
 
57
- /** An inference receipt (§8.2). */
58
- export interface Receipt {
56
+ /** An attestation report (§4.1). */
57
+ export interface AttestationReport {
59
58
  api_version: string;
60
- receipt_id: string;
61
- workload_id: string;
62
59
  workload_keyset_digest: string;
63
- event_log: ReceiptEvent[];
64
- signature: ReceiptSignature;
65
- [key: string]: JcsValue | undefined;
60
+ attestation: Attestation;
61
+ service_capabilities?: { supported_e2ee_versions?: string[]; [key: string]: unknown };
62
+ [key: string]: unknown;
66
63
  }
67
64
 
68
- /** The keyset endorsement / revocation signature block (§4.3, §5.1). */
69
- export interface Endorsement {
70
- algo: string;
71
- value: string;
72
- [key: string]: JcsValue | undefined;
65
+ /**
66
+ * The receipt document served by `GET /v1/aci/receipts/{id}` (§7.2): the
67
+ * §7.3 payload members plus `key_id` and `signature`. The signature covers
68
+ * JCS(document minus `signature`).
69
+ */
70
+ export interface ReceiptEnvelope {
71
+ key_id: string;
72
+ signature: string;
73
+ [key: string]: unknown;
73
74
  }
74
75
 
75
- /** The `attestation` object of a report (§5.1); only the fields Level 1 reads are typed. */
76
- export interface Attestation {
77
- workload_keyset: WorkloadKeyset;
78
- report_data: string;
79
- keyset_endorsement: Endorsement;
80
- freshness?: { fetched_at?: number; stale_after?: number; [key: string]: JcsValue | undefined };
81
- [key: string]: JcsValue | undefined;
76
+ /** A receipt event (§7.3): `type` plus type-specific fields; order is array order. */
77
+ export interface ReceiptEvent {
78
+ type: string;
79
+ body_hash?: string;
80
+ [key: string]: unknown;
82
81
  }
83
82
 
84
- /** An attestation report (§5.1). */
85
- export interface AttestationReport {
83
+ /** The receipt payload the envelope signs (§7.3). */
84
+ export interface ReceiptPayload {
86
85
  api_version: string;
87
- workload_id: string;
86
+ receipt_id: string;
87
+ chat_id?: string | null;
88
+ model?: string | null;
88
89
  workload_keyset_digest: string;
89
- attestation: Attestation;
90
- [key: string]: JcsValue | undefined;
90
+ endpoint: string;
91
+ method: string;
92
+ served_at: number;
93
+ event_log: ReceiptEvent[];
94
+ [key: string]: unknown;
91
95
  }
92
96
 
93
- /** A verifier-provided evidence block on a session record (§9.2). */
97
+ /** A session evidence block (§8.2): a base64 data URI plus the digest of its decoded bytes. */
94
98
  export interface SessionEvidence {
95
- digest?: string | null;
99
+ digest: string;
96
100
  data?: string;
97
- [key: string]: JcsValue | undefined;
101
+ [key: string]: unknown;
98
102
  }
99
103
 
100
104
  /**
101
- * An attested session record (§9.2). The `session_id` is recomputed from the
102
- * named fields; absent optional fields (`endpoint`, `identity`, `evidence.digest`)
103
- * are restored as JSON `null` in the content-addressing material.
105
+ * An attested session record (§8.2). Its id is the SHA-256 of the JCS form
106
+ * of the parsed document ({@link computeSessionId}).
104
107
  */
105
108
  export interface SessionRecord {
109
+ api_version: string;
106
110
  upstream_name: string;
107
111
  endpoint?: string | null;
108
112
  verifier_id: string;
109
- identity?: JcsValue;
110
- channel_binding: JcsValue[];
111
- claims: JcsValue;
112
- evidence?: SessionEvidence | null;
113
- [key: string]: JcsValue | undefined;
113
+ established_at: number;
114
+ expires_at: number;
115
+ identity?: unknown;
116
+ channel_binding: unknown[];
117
+ claims: unknown;
118
+ evidence: SessionEvidence;
119
+ [key: string]: unknown;
114
120
  }
115
121
 
116
122
  /** Outcome of one named verification check. */
117
123
  export interface Check {
118
- /** Stable machine-readable id, e.g. `signature`, `workload_id`. */
124
+ /** Stable machine-readable id, e.g. `signature`, `report_data`. */
119
125
  name: string;
120
126
  ok: boolean;
121
127
  /** Human-readable detail, present when the check fails. */
122
128
  detail?: string;
123
129
  }
124
130
 
125
- /** Result of {@link verifyReceipt}: overall pass plus the individual §10.2 checks. */
131
+ /** Result of {@link verifyReceipt}: overall pass plus the individual §9.3 checks. */
126
132
  export interface ReceiptVerification {
127
133
  ok: boolean;
128
134
  checks: Check[];
135
+ /** The receipt document read as its §7.3 payload members. */
136
+ payload?: ReceiptPayload;
129
137
  }
130
138
 
131
- /** Result of {@link verifyReportBinding}: overall pass, the §10.1 checks, and the derived identity. */
139
+ /**
140
+ * Result of {@link verifyReportBinding}: overall pass, the checks, and the
141
+ * keyset established from the report — the digest is recomputed over the
142
+ * served keyset object's JCS form (§3.1).
143
+ */
132
144
  export interface ReportVerification {
133
145
  ok: boolean;
134
146
  checks: Check[];
135
- /** `workload_id` recomputed from the report's keyset (§4.1). */
136
- workloadId: string;
137
- /** `workload_keyset_digest` recomputed from the report's keyset (§4.2). */
138
- workloadKeysetDigest: string;
147
+ workloadKeysetDigest?: string;
148
+ keyset?: WorkloadKeyset;
139
149
  }
@@ -8,13 +8,16 @@
8
8
  // (`${server}/api/sealed/phala`, treeview/server/routes/sealed.js) injects PHALA_API_KEY
9
9
  // and forwards ciphertext — it can't read prompts or responses.
10
10
  //
11
- // Crypto is the vendored @dstack/aci-verifier (./phala/aci-verifier), pure Web Crypto
11
+ // Crypto is the vendored aci-verifier (./phala/aci-verifier), pure Web Crypto
12
12
  // (X25519/HKDF/AES-GCM/Ed25519). Node ≥ 22 provides all of it on globalThis.crypto —
13
13
  // no polyfills, unlike the RN app.
14
14
  //
15
15
  // Two-layer attestation, fail-secure:
16
- // (1) verifyAciReportBinding — the report's crypto binding (keyset digest,
17
- // report_data == statement(nonce), endorsement sig). Self-attesting alone.
16
+ // (1) verifyReportBinding — the report's crypto binding (§9.1 checks 2–3): the
17
+ // served keyset canonicalizes to the digest that the attestation statement for
18
+ // OUR nonce hashes into report_data, and the keyset has not expired. Every key
19
+ // we go on to use — the X25519 key we seal to above all — is a member of that
20
+ // one object, so the binding covers them without a separate signature.
18
21
  // (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
19
22
  // against Intel collateral and binds the quote's report_data to (1)'s statement
20
23
  // digest. requireQuote defaults TRUE; PRIVATEER_PHALA_REQUIRE_QUOTE=0 drops it
@@ -23,17 +26,13 @@
23
26
  import type { Report } from "@phala/dcap-qvl";
24
27
  import {
25
28
  openE2eeChannel,
29
+ verifyReportBinding,
26
30
  toHex,
27
31
  fromHex,
28
32
  type AttestationReport,
29
33
  type ReportVerification,
30
34
  type E2eeChannel,
31
35
  } from "./phala/aci-verifier/index.ts";
32
- // Not the vendored verifyReportBinding directly: the deployed gateway signs its
33
- // keyset endorsement with ecdsa-secp256k1, which upstream's Web-Crypto-only verifier
34
- // refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
35
- // the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
36
- import { verifyAciReportBinding } from "./phala/reportBinding.ts";
37
36
  import {
38
37
  parseEventLog,
39
38
  appIdentityFrom,
@@ -153,7 +152,7 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
153
152
  if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
154
153
  const report = (await res.json()) as AttestationReport;
155
154
 
156
- const verification = await verifyAciReportBinding(report, nonce);
155
+ const verification = await verifyReportBinding(report, nonce);
157
156
  if (!verification.ok) {
158
157
  const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
159
158
  throw new Error(`phala attestation binding failed: ${failed}`);
@@ -383,6 +383,11 @@ export class RelayClient {
383
383
  private reconnectDelay = RECONNECT_MS;
384
384
  // Last refusal reason reported, so a 4xx is logged once instead of on every retry.
385
385
  private refusal: string | null = null;
386
+ // Why the last connection attempt failed, kept for connectionStatus(). A refusal
387
+ // (4xx) outranks it — that one is a decision, not a hiccup — but without either,
388
+ // every un-connected relay reports as a bare "connecting…", which is the same
389
+ // sentence whether the socket opens in two seconds or never opens again.
390
+ private lastFailure: string | null = null;
386
391
  // Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
387
392
  private bufKind: "text" | "reasoning" | null = null;
388
393
  private buf = "";
@@ -518,6 +523,7 @@ export class RelayClient {
518
523
  ws.on("open", () => {
519
524
  opened = true;
520
525
  this.refusal = null; // a later refusal is news again
526
+ this.lastFailure = null; // whatever kept us out is history
521
527
  this.reconnectDelay = RECONNECT_MS; // reachable again — next blip retries fast
522
528
  this.connectedAt = Date.now();
523
529
  this.startHeartbeat(ws);
@@ -537,6 +543,9 @@ export class RelayClient {
537
543
  // attach/detach frame said. Re-learned on the next attach or inbound frame.
538
544
  this.controllerHere = false;
539
545
  this.cb.onDisconnected?.();
546
+ // A socket that never opened failed for a reason worth reporting; one that
547
+ // opened and dropped is an ordinary blip the reconnect handles.
548
+ this.lastFailure = opened ? null : lastErr || "the relay connection closed before it opened";
540
549
  if (!this.closed) {
541
550
  this.cb.onStatus?.(
542
551
  opened
@@ -560,6 +569,7 @@ export class RelayClient {
560
569
  const status = (err as { status?: number })?.status;
561
570
  const refused = typeof status === "number" && status >= 400 && status < 500;
562
571
  if (refused) {
572
+ this.lastFailure = msg;
563
573
  this.settleFirstConnect(err instanceof Error ? err : new Error(msg));
564
574
  // A refusal is a decision, not a hiccup: hammering the same request every few
565
575
  // seconds can't change it, and for a harbor — whose onStatus goes to a log file,
@@ -578,6 +588,7 @@ export class RelayClient {
578
588
  }
579
589
  // Transient (network/route/5xx): stay on the fast retry.
580
590
  this.refusal = null;
591
+ this.lastFailure = msg;
581
592
  this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
582
593
  this.scheduleReconnect();
583
594
  } finally {
@@ -970,8 +981,19 @@ export class RelayClient {
970
981
  // quiet for longer than the server's 25s ping cadence is the shape of the half-open
971
982
  // failure the watchdog exists to catch, so it is worth showing rather than a bare
972
983
  // "connected".
973
- connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number } {
974
- if (!this.isConnected()) return { connected: false };
984
+ connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number; detail?: string; refused?: boolean } {
985
+ // Not connected: say WHY when we know. A standing refusal (the plan's agent cap,
986
+ // a rejected ticket) is the answer that matters most — it will not clear on its
987
+ // own, and it's the one case a caller should present as a decision rather than a
988
+ // wait — so it wins over the last transient error and is flagged as itself.
989
+ if (!this.isConnected()) {
990
+ const detail = this.refusal ?? this.lastFailure;
991
+ return {
992
+ connected: false,
993
+ ...(detail ? { detail } : {}),
994
+ ...(this.refusal ? { refused: true } : {}),
995
+ };
996
+ }
975
997
  const now = Date.now();
976
998
  return {
977
999
  connected: true,