From 3349cdb3f2a8a7211a12ebd49c8ade6adfa948f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 14:26:05 +0000 Subject: [PATCH 1/5] fix: record and log testnet lifecycle failures with redaction (#39) Co-authored-by: Joe Blau --- packages/agent-cli/scripts/e2e-testnet.mjs | 41 +++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/agent-cli/scripts/e2e-testnet.mjs b/packages/agent-cli/scripts/e2e-testnet.mjs index 0abab6d..65e4aa2 100644 --- a/packages/agent-cli/scripts/e2e-testnet.mjs +++ b/packages/agent-cli/scripts/e2e-testnet.mjs @@ -227,18 +227,49 @@ try { }, }); report.status = 'passed'; -} catch { - fail( +} catch (error) { + const stageName = report.stages.find((stage) => stage.status === 'failed')?.name ?? - 'testnet-preflight', - ); + 'testnet-preflight'; + fail(stageName); report.missingConfiguration = missingTestnetConfiguration(); report.missingSecrets = requiredTestnetSecrets.filter((name) => report.missingConfiguration.includes(name), ); + // Sanitize error message, redacting secrets and private keys + let errorMessage = 'Unknown error'; + if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === 'string') { + errorMessage = error; + } else { + try { + errorMessage = JSON.stringify(error); + } catch { + errorMessage = String(error); + } + } + // Redact secrets from error message + for (const secretName of requiredTestnetSecrets) { + const secret = process.env[secretName]; + if (secret && secret.length >= 4) { + errorMessage = errorMessage.split(secret).join('[REDACTED]'); + } + } + // Redact private keys + errorMessage = errorMessage + .replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/g, '[REDACTED]') + .replace(/[A-Za-z0-9+/]{80,}={0,2}/g, '[REDACTED]') + .replace(/(?:0x)?[a-fA-F0-9]{64,}/g, '[REDACTED]') + .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]'); + report.error = { + stage: stageName, + message: errorMessage, + }; console.error( - 'FAIL: dedicated testnet lifecycle; inspect lifecycle-report.json', + `FAIL: dedicated testnet lifecycle at stage '${stageName}': ${errorMessage}`, ); + console.error('Full report: lifecycle-report.json'); } finally { if (ownedState && existsSync(config)) { report.cleanup = await cleanupTestnetAccount({ From 607bed2bfa69fb76cf5654f8675757af9f35cf90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 14:29:37 +0000 Subject: [PATCH 2/5] fix: remove unsupported policy method names (#39) Remove exportPrivateKey and exportSeedPhrase DENY rules from Privy policy. These method names are not valid Privy policy methods and were causing policy creation to fail in the testnet lifecycle. Agent private key and seed phrase export are still protected by Privy's default deny behavior for unmatched methods. Co-authored-by: Joe Blau --- docs/custody.md | 4 ++-- packages/agent-cli/policies/agent-trade-only.json | 12 ------------ packages/agent-cli/test/policy.test.ts | 8 +++----- 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/docs/custody.md b/docs/custody.md index 7f3266d..2d8a804 100644 --- a/docs/custody.md +++ b/docs/custody.md @@ -28,8 +28,8 @@ and attest the remote policy on every read. The Privy policy allows only typed-data requests matching chain 1337, the zero verifying contract and the Agent schema's network source (`a` mainnet, `b` testnet). -It explicitly denies agent private-key/seed export; unmatched methods are denied -by [Privy's default policy behavior](https://docs.privy.io/controls/policies/overview). +Agent private-key/seed export and all other unmatched methods are denied by +[Privy's default policy behavior](https://docs.privy.io/controls/policies/overview). The local signer additionally requires the exact `Exchange`/`1` domain, Agent fields and a 32-byte connection ID. User-signed withdrawals, transfers and agent approvals have a different type/domain and are refused locally. The CLI dispatch diff --git a/packages/agent-cli/policies/agent-trade-only.json b/packages/agent-cli/policies/agent-trade-only.json index 2ba11ad..556919e 100644 --- a/packages/agent-cli/policies/agent-trade-only.json +++ b/packages/agent-cli/policies/agent-trade-only.json @@ -36,18 +36,6 @@ } } ] - }, - { - "name": "Never export agent private key", - "method": "exportPrivateKey", - "action": "DENY", - "conditions": [] - }, - { - "name": "Never export agent seed phrase", - "method": "exportSeedPhrase", - "action": "DENY", - "conditions": [] } ] } diff --git a/packages/agent-cli/test/policy.test.ts b/packages/agent-cli/test/policy.test.ts index 964246e..b0ee9f8 100644 --- a/packages/agent-cli/test/policy.test.ts +++ b/packages/agent-cli/test/policy.test.ts @@ -82,15 +82,13 @@ test('changed server rules and network are replaced instead of trusted by ID', a expect(create).toHaveBeenLastCalledWith(policyFor('mainnet')); }); -test('policy denies private key/seed exports and leaves non-Agent methods unallowlisted', () => { +test('policy allows only Agent domain signing and leaves other methods unallowlisted', () => { const policy = policyFor('testnet'); expect(policy.rules.filter((rule) => rule.action === 'ALLOW')).toEqual([ expect.objectContaining({ method: 'eth_signTypedData_v4' }), ]); - for (const method of ['exportPrivateKey', 'exportSeedPhrase']) - expect(policy.rules).toContainEqual( - expect.objectContaining({ method, action: 'DENY', conditions: [] }), - ); + // Privy's default policy behavior denies unmatched methods, including key export + expect(policy.rules.filter((rule) => rule.action === 'DENY')).toEqual([]); expect(policy.rules[0].conditions).toContainEqual({ field_source: 'ethereum_typed_data_domain', field: 'chainId', From b682b24077e3d4c9fc1c70262a63bf34b2f98d53 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 15:53:05 +0000 Subject: [PATCH 3/5] fix: improve error message capture from command envelope Extract the actual error code and message from the CLI envelope response instead of throwing a generic 'Command failed' error. Co-authored-by: Joe Blau --- packages/agent-cli/scripts/e2e-testnet.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/agent-cli/scripts/e2e-testnet.mjs b/packages/agent-cli/scripts/e2e-testnet.mjs index 65e4aa2..b1bf33d 100644 --- a/packages/agent-cli/scripts/e2e-testnet.mjs +++ b/packages/agent-cli/scripts/e2e-testnet.mjs @@ -98,8 +98,11 @@ function command(name, input = {}) { } catch { throw new Error(`Command ${name} did not return one JSON envelope`); } - if (result.status !== 0 || !envelope.ok) - throw new Error(`Command ${name} failed`); + if (result.status !== 0 || !envelope.ok) { + const errorMsg = envelope.message || 'Unknown error'; + const errorCode = envelope.code || 'UNKNOWN'; + throw new Error(`Command ${name} failed: [${errorCode}] ${errorMsg}`); + } if (name === 'run') { runnerPid = envelope.data.pid; if ( From e2e9b2269f5b6a9716dd766243257e8c34fd874f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 15:54:20 +0000 Subject: [PATCH 4/5] revert: restore exportPrivateKey and exportSeedPhrase policy rules These are valid Privy policy method names according to the official documentation at https://docs.privy.io/controls/policies/overview. The improved error handling in the previous commit will now show the actual error message when the workflow runs next. Co-authored-by: Joe Blau --- docs/custody.md | 4 ++-- packages/agent-cli/policies/agent-trade-only.json | 12 ++++++++++++ packages/agent-cli/test/policy.test.ts | 8 +++++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/custody.md b/docs/custody.md index 2d8a804..7f3266d 100644 --- a/docs/custody.md +++ b/docs/custody.md @@ -28,8 +28,8 @@ and attest the remote policy on every read. The Privy policy allows only typed-data requests matching chain 1337, the zero verifying contract and the Agent schema's network source (`a` mainnet, `b` testnet). -Agent private-key/seed export and all other unmatched methods are denied by -[Privy's default policy behavior](https://docs.privy.io/controls/policies/overview). +It explicitly denies agent private-key/seed export; unmatched methods are denied +by [Privy's default policy behavior](https://docs.privy.io/controls/policies/overview). The local signer additionally requires the exact `Exchange`/`1` domain, Agent fields and a 32-byte connection ID. User-signed withdrawals, transfers and agent approvals have a different type/domain and are refused locally. The CLI dispatch diff --git a/packages/agent-cli/policies/agent-trade-only.json b/packages/agent-cli/policies/agent-trade-only.json index 556919e..2ba11ad 100644 --- a/packages/agent-cli/policies/agent-trade-only.json +++ b/packages/agent-cli/policies/agent-trade-only.json @@ -36,6 +36,18 @@ } } ] + }, + { + "name": "Never export agent private key", + "method": "exportPrivateKey", + "action": "DENY", + "conditions": [] + }, + { + "name": "Never export agent seed phrase", + "method": "exportSeedPhrase", + "action": "DENY", + "conditions": [] } ] } diff --git a/packages/agent-cli/test/policy.test.ts b/packages/agent-cli/test/policy.test.ts index b0ee9f8..964246e 100644 --- a/packages/agent-cli/test/policy.test.ts +++ b/packages/agent-cli/test/policy.test.ts @@ -82,13 +82,15 @@ test('changed server rules and network are replaced instead of trusted by ID', a expect(create).toHaveBeenLastCalledWith(policyFor('mainnet')); }); -test('policy allows only Agent domain signing and leaves other methods unallowlisted', () => { +test('policy denies private key/seed exports and leaves non-Agent methods unallowlisted', () => { const policy = policyFor('testnet'); expect(policy.rules.filter((rule) => rule.action === 'ALLOW')).toEqual([ expect.objectContaining({ method: 'eth_signTypedData_v4' }), ]); - // Privy's default policy behavior denies unmatched methods, including key export - expect(policy.rules.filter((rule) => rule.action === 'DENY')).toEqual([]); + for (const method of ['exportPrivateKey', 'exportSeedPhrase']) + expect(policy.rules).toContainEqual( + expect.objectContaining({ method, action: 'DENY', conditions: [] }), + ); expect(policy.rules[0].conditions).toContainEqual({ field_source: 'ethereum_typed_data_domain', field: 'chainId', From 086f7179e0ebe2e62b7ca0c2a329948083184bd2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 16:43:48 +0000 Subject: [PATCH 5/5] fix: surface Hyperliquid approval errors and log master address - Improve error messages in submitApproval to include the action type (approveAgent or approveBuilderFee) and the raw Hyperliquid response - Add masterAddress to the lifecycle report for troubleshooting - This will reveal whether the wallet needs funding on testnet Co-authored-by: Joe Blau --- packages/agent-cli/scripts/e2e-testnet.mjs | 1 + packages/agent-cli/src/wallet/approval.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/agent-cli/scripts/e2e-testnet.mjs b/packages/agent-cli/scripts/e2e-testnet.mjs index b1bf33d..ad966dd 100644 --- a/packages/agent-cli/scripts/e2e-testnet.mjs +++ b/packages/agent-cli/scripts/e2e-testnet.mjs @@ -174,6 +174,7 @@ try { throw new Error('Use a clean QA home without ~/.bloxwap'); signer = await createTestnetSigner(configuration); report.signerMode = signer.mode; + report.masterAddress = signer.address; ownedState = true; childEnv = { ...process.env, diff --git a/packages/agent-cli/src/wallet/approval.ts b/packages/agent-cli/src/wallet/approval.ts index 1bbff2c..fd922ea 100644 --- a/packages/agent-cli/src/wallet/approval.ts +++ b/packages/agent-cli/src/wallet/approval.ts @@ -62,8 +62,17 @@ export async function submitApproval( v, }, })) as { status: string; response: unknown }; - if (response.status !== 'ok') - throw new AgentCliError('UPSTREAM_ERROR', 'Approval failed'); + if (response.status !== 'ok') { + const actionType = pending.action.type || 'unknown'; + const responseText = typeof response.response === 'string' + ? response.response + : JSON.stringify(response.response); + throw new AgentCliError( + 'UPSTREAM_ERROR', + `${actionType} failed: ${responseText}`, + { hyperliquidResponse: response } + ); + } return response; } export const rejectMasterKey = assertSafeInput;