Skip to content

Commit 2b56961

Browse files
authored
fix: contain async loggers and redact real preview tokens (#7)
Answers the GPT-5.6 Sol review of the error codes: safeLogger contains async-rejecting loggers and throwing getters; the exact preview token (a JWT) and its encoded forms are redacted from every message; the SDK cause is a redacted clone that keeps prototype, status, code and the nested cause chain; isConnected() is guarded. Opus verification pass plus one fix round.
1 parent a80d020 commit 2b56961

9 files changed

Lines changed: 888 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
Everything `raiseHand` throws now carries a `code` you can branch on —
1414
`missing_api_key`, `invalid_mode`, `empty_action`, `browser_unusable`,
1515
`relay_start_failed`, `concurrency_limit`, `relay_not_ready` — plus the
16-
original SDK, CDP or network error as `cause`. `concurrency_limit` is the
16+
SDK, CDP or network error as `cause`, with any credential in it redacted. `concurrency_limit` is the
1717
one worth retrying: it means your Solari account is at its concurrent
1818
session cap, not that anything is broken. When to expect each code, and what
1919
to do about it, is in the README's [Errors](README.md#errors) table. The
2020
messages were never a contract; they can still be reworded in any release.
2121
Outcomes are unchanged and still values: a human who never came, a session
2222
that died mid-handoff and a webhook that 500s are not exceptions.
23-
- **A logger that throws can no longer end a handoff.** `logger` is your
24-
object — a pino instance over a closed transport throws — and handraise
25-
calls it from `catch` blocks and promise callbacks. One of those was the
26-
webhook notification, which `raiseHand` fires and only awaits minutes later:
27-
a throw there was an unhandled rejection (node ends the process for that)
28-
and then an uncoded `Error` out of `raiseHand`, long after the URL existed.
29-
Log calls are now wrapped where the logger enters handraise. A broken logger
30-
costs a log line.
23+
- **A broken logger can no longer end a handoff.** `logger` is your object,
24+
and handraise calls it from `catch` blocks and promise callbacks. Three ways
25+
it breaks are contained where the logger enters handraise: a method that
26+
throws (a pino instance over a closed transport), a method that is a getter
27+
and throws on the property read, and a method that is `async` and rejects —
28+
TypeScript accepts one where `Logger` declares `void`, and the rejection then
29+
belongs to a promise nobody holds, which ends the process. One of the call
30+
sites is the webhook notification, which `raiseHand` fires and only awaits
31+
minutes later, long after the handoff URL exists. A broken logger costs a log
32+
line.
3133
- **The relay health poll enforces its deadline.** Each attempt carries
3234
`AbortSignal.timeout`, so a preview URL that accepts the connection and never
3335
answers ends as `relay_not_ready` at the deadline instead of blocking
34-
`raiseHand` for minutes with a live sandbox burning its idle window.
35-
- **No preview token can reach an error message.** Anything a gateway or proxy
36-
says is redacted before it is quoted — `pt_token=…` in any case or
37-
separator, percent-encoded inside a `?next=` parameter, or the bare
38-
credential in prose.
36+
`raiseHand` for minutes with a live sandbox burning its idle window. The
37+
"Last answer" in that message is now the URL's own — a 401 from the preview
38+
proxy, a refused connection — instead of the abort of a final request that
39+
had no time left to make.
40+
- **The preview token is redacted out of error messages and out of `cause`.**
41+
It is a live bearer credential for the relay, and a proxy that echoes the
42+
request URI in its 401 body would otherwise put it in an exception message.
43+
Where the exact value is known — the health poll, the teardown failure and
44+
the wrapped start failure all hold the URL that carries it — that value is
45+
removed by comparison in each of the forms an escaping proxy produces: bare,
46+
percent-encoded, and with its dots written `%2E`, `%2e` or `.`. Three
47+
patterns are the net for foreign text where the value is not known:
48+
`pt_token=…` in any case or separator, a `pt_`-prefixed value, and the JWT
49+
shape the preview token actually has (three base64url segments, separator
50+
literal or escaped — see `docs/measurements/01-preview-transport.md` §3). The
51+
SDK error attached as `cause` goes through the same redaction, because every
52+
error serialiser prints the whole chain. A proxy that invents an encoding
53+
none of those cover — folding the value across lines, say — is still a leak;
54+
this is a net, not a proof.
55+
3956
### Changed
4057

4158
- **A page that is already dead is now refused instead of handed off.**
@@ -51,9 +68,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5168
wrapped in a `HandraiseError`, with the SDK's error kept as `error.cause`.
5269
Branch on `error.code === "concurrency_limit"`; if you must have the class,
5370
it is `error.cause`, and `error.cause.status === 429` is the check that
54-
survives a second copy of `@solarisdk/core` in your tree. Apart from the
55-
page check above, nothing throws that did not throw before, and no outcome
56-
became an exception.
71+
survives a second copy of `@solarisdk/core` in your tree. `cause` is the SDK's
72+
error with credentials redacted: a copy carrying the same prototype and the
73+
same property descriptors — so `name`, `status`, `code`, the non-enumerable
74+
`message` and `stack`, and the `cause` chain hanging off it all survive, and
75+
`JSON.stringify(cause)` still produces what it did — with `message`, `stack`,
76+
the parsed `body` and every nested `cause` rewritten. An error that cannot be
77+
copied without running its own code (a throwing getter, a body that
78+
references itself) becomes a plain redacted `Error` rather than an exception.
79+
Apart from the page check above, nothing throws that did not throw before,
80+
and no outcome became an exception.
5781

5882
## [0.5.1] - 2026-09-02
5983

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,11 @@ asked for anything yet. Everything after that is an `outcome`, never an
296296
exception. What it throws is a `HandraiseError` with a `code`: the code is the
297297
contract, the message is for whoever reads the log and may be reworded in any
298298
release. `isHandraiseError` narrows a `catch` binding, and `cause` keeps the
299-
original SDK, CDP or network error whenever there was one.
299+
original SDK, CDP or network error whenever there was one — the same class,
300+
`name`, `status` and `code`, its own non-enumerable properties, and its own
301+
`cause` chain — with credentials redacted out of every `message`, `stack` and
302+
response body along it. Every error serialiser prints the whole chain, so a
303+
clean outer message on its own would not be worth much.
300304

301305
The first thing `raiseHand` does is look at your page, before it creates
302306
anything: a page you have closed, or a browser you have disconnected, is
@@ -323,7 +327,7 @@ try {
323327
| `invalid_mode` | `mode` is neither `"takeover"` nor `"approval"`. | Fix the call. TypeScript already refuses it; this is for JavaScript callers. |
324328
| `empty_action` | `mode: "approval"` without a non-empty `action`. | Name the step the human says yes or no to. |
325329
| `browser_unusable` | The page is closed, or its browser has disconnected — checked before anything is created. | Open a new page or relaunch the session (restore `storageState` if you kept it) and retry. |
326-
| `relay_start_failed` | The relay sandbox could not be created or deployed. | Read `cause` — it is the Solari SDK's own error. Retry. Nothing is left behind unless you also see `relay_release_failed` (below). |
330+
| `relay_start_failed` | The relay sandbox could not be created or deployed. | Read `cause` — it is the Solari SDK's own error, redacted. Retry. Nothing is left behind unless you also see `relay_release_failed` (below). |
327331
| `concurrency_limit` | Your Solari account is at its concurrent session cap (429). | Free a session, or wait and retry. The one relay failure that is purely temporary. |
328332
| `relay_not_ready` | The sandbox started but its public URL never answered. | Retry. Persisting means the preview proxy or the region is unhealthy. |
329333

src/core/handoff.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,34 @@ test("a page whose state cannot be read at all is refused too", async () => {
688688
})
689689
})
690690

691+
test("a browser whose liveness accessor throws is refused too", async () => {
692+
// The last unguarded read in the pre-flight check: a browser proxy — a
693+
// remote-CDP wrapper, a pooled session object, a page handed over between
694+
// processes — whose `isConnected()` throws instead of answering. Outside the
695+
// `try` that would leave `raiseHand` rejecting with a plain `Error`, which
696+
// is exactly what typed codes exist to stop.
697+
const browserPartial: Partial<Browser> = {
698+
isConnected: () => {
699+
throw new Error("Browser has been closed")
700+
},
701+
}
702+
const contextPartial: Partial<BrowserContext> = {
703+
// SAFETY: the guard reads only `browser()` off the context.
704+
browser: () => browserPartial as Browser,
705+
}
706+
const pagePartial: Partial<Page> = {
707+
isClosed: () => false,
708+
// SAFETY: the guard reads only `context().browser()` on the page.
709+
context: () => contextPartial as BrowserContext,
710+
}
711+
712+
// SAFETY: the guard touches `isClosed` and `context` and nothing else.
713+
await expect(askOn(pagePartial as Page)).rejects.toMatchObject({
714+
name: "HandraiseError",
715+
code: "browser_unusable",
716+
})
717+
})
718+
691719
test("an open page whose browser has disconnected is refused too", async () => {
692720
// The Solari session hit its ~10-minute hard lifetime while the agent was
693721
// still working. The page is not closed and `context()` answers — only the
@@ -1520,3 +1548,73 @@ test("a logger that throws does not break the handoff", async () => {
15201548
// take `onEvent` with it.
15211549
expect(events).toHaveLength(1)
15221550
})
1551+
1552+
test("a logger whose methods reject does not break the handoff either", async () => {
1553+
// The same option, one shape further out: `debug(event, fields): void`
1554+
// accepts an `async` implementation, so the failure arrives as a rejected
1555+
// promise nobody holds rather than as a throw. Unhandled, that ends the
1556+
// agent's process mid-handoff — before the relay sandbox is released, which
1557+
// leaves a public URL and its last frame reachable until the idle timeout.
1558+
//
1559+
// The gate here is the runner: `bun test` fails a test that leaves an
1560+
// unhandled rejection behind, which is how this was watched failing against
1561+
// the unfixed wrapper. The listener below is NOT that gate — bun claims the
1562+
// rejection first and never calls it, so `unhandled` stays empty either way.
1563+
// It is kept because it costs nothing and states the invariant for a runner
1564+
// that only warns; do not read it as the thing that catches a regression.
1565+
const port = await startRelayProcess()
1566+
const human = await connectHuman(port)
1567+
const cdp = fakeCdp()
1568+
const unhandled: string[] = []
1569+
const record = (cause: unknown): void => {
1570+
unhandled.push(String(cause))
1571+
}
1572+
process.on("unhandledRejection", record)
1573+
try {
1574+
let calls = 0
1575+
const down = async (): Promise<never> => {
1576+
calls += 1
1577+
throw new Error("log shipper is gone (async)")
1578+
}
1579+
const rejecting: Logger = {
1580+
debug: down,
1581+
info: down,
1582+
warn: down,
1583+
error: down,
1584+
}
1585+
const events: HandoffEvent[] = []
1586+
1587+
const handoff = runHandoff({
1588+
page: fakePage(cdp.cdp),
1589+
agentWsUrl: `ws://127.0.0.1:${port}/ws?role=agent`,
1590+
options: {
1591+
reason: "the logger ships its lines over a socket that went away",
1592+
logger: rejecting,
1593+
onEvent: (event) => events.push(event),
1594+
},
1595+
timeoutMs: 5000,
1596+
url: "https://relay.example/?pt_token=x",
1597+
handoffId: "async-rejecting-logger",
1598+
relayColdStartMs: 5,
1599+
logger: rejecting,
1600+
})
1601+
1602+
await until("the phone to connect", () => human.inbox.length >= 0)
1603+
human.send({ type: "handback" })
1604+
1605+
const end = await handoff
1606+
expect(end.outcome).toBe("resolved")
1607+
// The wide event still reaches the caller, and the logger was really
1608+
// called — a containment that stopped logging would pass vacuously.
1609+
expect(events).toHaveLength(1)
1610+
expect(calls).toBeGreaterThan(0)
1611+
1612+
// Long enough for the loop turn on which an unhandled rejection is
1613+
// reported, after the handoff has fully torn down. Inert under bun — see
1614+
// the note above the listener.
1615+
await Bun.sleep(50)
1616+
expect(unhandled).toEqual([])
1617+
} finally {
1618+
process.off("unhandledRejection", record)
1619+
}
1620+
})

src/core/raise-hand.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -632,17 +632,26 @@ function checkedMode(options: RaiseHandOptions): HandoffMode {
632632
* connected? `context()` is a field read and throws nothing in Playwright, so
633633
* it is `isClosed()` that catches a closed page; the try/catch is for the page
634634
* object that is not a working Playwright page at all.
635+
*
636+
* Every read is inside the `try` and every branch after it. A browser proxy —
637+
* a remote-CDP wrapper, a pooled session, a page handed between processes —
638+
* can throw from its liveness accessor too, and a plain `Error` out of the
639+
* guard whose whole job is to produce `browser_unusable` would be the last
640+
* uncoded rejection on this path.
635641
*/
636642
function checkedPage(page: Page): void {
637643
let closed: boolean
638-
let browser: Browser | null
644+
let connected: boolean
639645
try {
640646
closed = page.isClosed()
641-
browser = page.context().browser()
647+
const browser: Browser | null = page.context().browser()
648+
// A context with no browser is a persistent context: there is no session
649+
// object to ask, and `isClosed()` above has already spoken for the page.
650+
connected = browser?.isConnected() ?? true
642651
} catch (cause) {
643652
throw new HandraiseError(
644653
"browser_unusable",
645-
`handraise: this page cannot be handed to a human — reading its state (page.isClosed(), page.context()) threw. A dead CDP connection does that, and so does a page-like object that is not a Playwright page. ${String(cause)}`,
654+
`handraise: this page cannot be handed to a human — reading its state (page.isClosed(), page.context().browser().isConnected()) threw. A dead CDP connection does that, and so does a page-like object that is not a Playwright page. ${String(cause)}`,
646655
{ cause },
647656
)
648657
}
@@ -652,7 +661,7 @@ function checkedPage(page: Page): void {
652661
"handraise: this page is already closed, so there is nothing for a human to take over. Open a new page (its `storageState` from an earlier handoff, if you kept it, restores the human's work) and retry.",
653662
)
654663
}
655-
if (browser && !browser.isConnected()) {
664+
if (!connected) {
656665
throw new HandraiseError(
657666
"browser_unusable",
658667
"handraise: the browser session behind this page is already disconnected, so there is nothing for a human to take over. Relaunch the session (its `storageState` from an earlier handoff, if you kept it, restores the human's work) and retry.",

src/errors.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
* session that has died server-side while the CDP socket is still up looks
3232
* alive here and still ends as the `disconnected` outcome.
3333
* - `relay_start_failed` — the relay sandbox could not be created or deployed;
34-
* `cause` holds the SDK error.
34+
* `cause` holds the SDK error, with credentials redacted.
3535
* - `concurrency_limit` — the Solari account is at its concurrent session cap
3636
* (HTTP 429). The one relay failure that is worth retrying later.
3737
* - `relay_not_ready` — the sandbox started but its public URL never answered.
@@ -52,7 +52,11 @@ export type HandraiseErrorCode =
5252

5353
/**
5454
* Everything handraise throws on purpose. `cause` carries the original SDK,
55-
* CDP or network error whenever there was one, so the wrapping hides nothing.
55+
* CDP or network error whenever there was one — same class, same `name`,
56+
* `status` and `code`, same own properties and same `cause` chain — so the
57+
* wrapping hides nothing. Every `message`, `stack` and response body along
58+
* that chain is redacted, because the relay's preview token is a live bearer
59+
* credential and every error serialiser prints the whole chain.
5660
*/
5761
export class HandraiseError extends Error {
5862
override readonly name = "HandraiseError"

src/logger.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type Logger,
1414
noopLogger,
1515
quietLogger,
16+
safeLogger,
1617
} from "./logger"
1718

1819
/** The console methods, captured so a test can restore them. */
@@ -117,3 +118,93 @@ test("quietLogger drops debug/info but forwards warn/error — the library defau
117118
expect(err[0]?.parsed.event).toBe("w")
118119
expect(err[1]?.parsed.event).toBe("e")
119120
})
121+
122+
// --- safeLogger ----------------------------------------------------------
123+
//
124+
// `Logger` is the caller's object, and the two ways it breaks that a plain
125+
// `try` does not cover are a method that is `async` — TypeScript accepts one
126+
// where the interface declares `void` — and a property that is a getter.
127+
128+
test("safeLogger contains a logger whose methods reject", async () => {
129+
// The gap a `try` cannot see: `debug(event, fields): void` accepts an
130+
// `async` implementation, so the throw happens after `safeLogger` has
131+
// already returned. The rejection then belongs to a promise nobody holds,
132+
// and the runtime ends the process for that — mid-handoff, before the relay
133+
// sandbox is released. `bun test` fails a test that leaves one behind, so
134+
// the red signal here is this test failing with "log shipper is gone".
135+
//
136+
// Deliberately not inside `expect(...).not.toThrow()`: that wrapper marks
137+
// rejections raised during the call as handled, which would hide exactly
138+
// what is under test.
139+
let calls = 0
140+
const down = async (): Promise<never> => {
141+
calls += 1
142+
throw new Error("log shipper is gone (async)")
143+
}
144+
const rejecting: Logger = {
145+
debug: down,
146+
info: down,
147+
warn: down,
148+
error: down,
149+
}
150+
const safe = safeLogger(rejecting)
151+
152+
safe.debug("d", { a: 1 })
153+
safe.info("i")
154+
safe.warn("w")
155+
safe.error("e")
156+
157+
// Long enough for the microtask queue to settle and for the loop turn on
158+
// which an unhandled rejection is reported.
159+
await Bun.sleep(50)
160+
// The wrapper still calls the logger — containment is not silence.
161+
expect(calls).toBe(4)
162+
})
163+
164+
test("safeLogger survives a logger whose method is a throwing getter", () => {
165+
// A proxy over a closed transport, or a class that builds its methods
166+
// lazily: the throw happens on the property read, before any call.
167+
const exploding = (): never => {
168+
throw new Error("the sink was torn down")
169+
}
170+
const brokenGetters: Logger = {
171+
get debug(): never {
172+
return exploding()
173+
},
174+
get info(): never {
175+
return exploding()
176+
},
177+
get warn(): never {
178+
return exploding()
179+
},
180+
get error(): never {
181+
return exploding()
182+
},
183+
}
184+
const safe = safeLogger(brokenGetters)
185+
186+
expect(() => {
187+
safe.debug("d")
188+
safe.info("i")
189+
safe.warn("w")
190+
safe.error("e")
191+
}).not.toThrow()
192+
})
193+
194+
test("safeLogger still forwards to a working logger", () => {
195+
// The containment above may not turn the wrapper into a second noopLogger.
196+
const seen: string[] = []
197+
const inner: Logger = {
198+
debug: (event) => seen.push(`debug:${event}`),
199+
info: (event) => seen.push(`info:${event}`),
200+
warn: (event) => seen.push(`warn:${event}`),
201+
error: (event) => seen.push(`error:${event}`),
202+
}
203+
const safe = safeLogger(inner)
204+
safe.debug("d")
205+
safe.info("i")
206+
safe.warn("w")
207+
safe.error("e")
208+
209+
expect(seen).toEqual(["debug:d", "info:i", "warn:w", "error:e"])
210+
})

0 commit comments

Comments
 (0)