@semanticist14/clco 0.1.0

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.
package/src/stream.ts ADDED
@@ -0,0 +1,235 @@
1
+ // OpenAI chunks -> Anthropic SSE, shared by chat and Responses routes.
2
+ import type { OpenAIChoice, OpenAIUsage, OpenAIResponse, StreamEventData } from "./wire"
3
+ import { mapStopReason, usageFromOpenAI } from "./translate"
4
+
5
+ function debugWarn(message: string): void {
6
+ if (process.env.CLCO_DEBUG) console.error("[clco:debug]", message)
7
+ }
8
+
9
+ interface ToolTrack {
10
+ anthropicIndex: number
11
+ id: string
12
+ name: string
13
+ bufferedArgs: string
14
+ started: boolean
15
+ open: boolean
16
+ }
17
+
18
+ export class StreamTranslator {
19
+ private messageStartSent = false
20
+ private textOpen = false
21
+ private textIndex = -1
22
+ private nextIndex = 0
23
+ private toolCalls = new Map<number, ToolTrack>()
24
+ private anyToolStarted = false
25
+ private latestUsage: OpenAIUsage | undefined
26
+ private lastFinishReason: OpenAIChoice["finish_reason"] = null
27
+ private finished = false
28
+
29
+ constructor(private model: string) {}
30
+
31
+ pushChunk(chunk: OpenAIResponse): StreamEventData[] {
32
+ // Nothing may be emitted after the message closed.
33
+ if (this.finished) return []
34
+ const events: StreamEventData[] = []
35
+ // Usage may arrive in a dedicated terminal chunk with empty choices
36
+ // (stream_options include_usage convention); track it from any chunk.
37
+ if (chunk.usage) this.latestUsage = chunk.usage
38
+ const choices = Array.isArray(chunk.choices) ? chunk.choices : []
39
+ const choice = choices[0]
40
+ if (!choice) return events
41
+ const delta = choice.delta
42
+
43
+ if (!this.messageStartSent) {
44
+ events.push(this.messageStart(chunk.model || this.model))
45
+ }
46
+
47
+ if (delta?.content) {
48
+ // Tool blocks must all close before a text block starts (Anthropic
49
+ // blocks are strictly sequential).
50
+ this.closeOpenTools(events)
51
+ if (!this.textOpen) {
52
+ this.textIndex = this.nextIndex++
53
+ events.push({
54
+ event: "content_block_start",
55
+ data: {
56
+ type: "content_block_start",
57
+ index: this.textIndex,
58
+ content_block: { type: "text", text: "" },
59
+ },
60
+ })
61
+ this.textOpen = true
62
+ }
63
+ events.push({
64
+ event: "content_block_delta",
65
+ data: {
66
+ type: "content_block_delta",
67
+ index: this.textIndex,
68
+ delta: { type: "text_delta", text: delta.content },
69
+ },
70
+ })
71
+ }
72
+
73
+ if (delta?.tool_calls) {
74
+ for (const call of delta.tool_calls) {
75
+ let track = this.toolCalls.get(call.index)
76
+ if (!track) {
77
+ track = {
78
+ anthropicIndex: -1,
79
+ id: "",
80
+ name: "",
81
+ bufferedArgs: "",
82
+ started: false,
83
+ open: false,
84
+ }
85
+ this.toolCalls.set(call.index, track)
86
+ }
87
+ // id and name may arrive in separate fragments; only start the block
88
+ // once both are known.
89
+ if (call.id) track.id = call.id
90
+ if (call.function?.name) track.name = call.function.name
91
+ if (!track.started && track.id && track.name) {
92
+ if (this.textOpen) {
93
+ events.push({
94
+ event: "content_block_stop",
95
+ data: { type: "content_block_stop", index: this.textIndex },
96
+ })
97
+ this.textOpen = false
98
+ }
99
+ track.anthropicIndex = this.nextIndex++
100
+ track.started = true
101
+ track.open = true
102
+ this.anyToolStarted = true
103
+ events.push({
104
+ event: "content_block_start",
105
+ data: {
106
+ type: "content_block_start",
107
+ index: track.anthropicIndex,
108
+ content_block: {
109
+ type: "tool_use",
110
+ id: track.id,
111
+ name: track.name,
112
+ input: {},
113
+ },
114
+ },
115
+ })
116
+ if (track.bufferedArgs) {
117
+ events.push(this.jsonDelta(track.anthropicIndex, track.bufferedArgs))
118
+ track.bufferedArgs = ""
119
+ }
120
+ }
121
+ if (call.function?.arguments) {
122
+ if (track.started && track.open) {
123
+ events.push(this.jsonDelta(track.anthropicIndex, call.function.arguments))
124
+ } else if (track.started) {
125
+ debugWarn(
126
+ `dropped ${call.function.arguments.length} chars of late tool arguments (block ${track.anthropicIndex} already closed)`,
127
+ )
128
+ } else {
129
+ // Arguments before id/name: buffer until the block starts.
130
+ track.bufferedArgs += call.function.arguments
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ // Do NOT close here: with stream_options.include_usage the usage-only
137
+ // terminal chunk arrives AFTER the finish_reason chunk, and close()
138
+ // needs it. Store the reason and let finish() (stream end) close.
139
+ if (choice.finish_reason) {
140
+ this.lastFinishReason = choice.finish_reason
141
+ }
142
+ return events
143
+ }
144
+
145
+ // Upstream ended. Use the stored finish_reason; without one the generation
146
+ // may be truncated. Keep max_tokens even when a tool block has started:
147
+ // neither partial arguments nor valid JSON prove the tool turn completed.
148
+ finish(): StreamEventData[] {
149
+ if (this.finished) return []
150
+ return this.close(this.lastFinishReason ?? "length")
151
+ }
152
+
153
+ private jsonDelta(index: number, partialJson: string): StreamEventData {
154
+ return {
155
+ event: "content_block_delta",
156
+ data: {
157
+ type: "content_block_delta",
158
+ index,
159
+ delta: { type: "input_json_delta", partial_json: partialJson },
160
+ },
161
+ }
162
+ }
163
+
164
+ private messageStart(model: string): StreamEventData {
165
+ this.messageStartSent = true
166
+ const usage = this.latestUsage
167
+ const cached = usage?.prompt_tokens_details?.cached_tokens
168
+ return {
169
+ event: "message_start",
170
+ data: {
171
+ type: "message_start",
172
+ message: {
173
+ id: `msg_${crypto.randomUUID()}`,
174
+ type: "message",
175
+ role: "assistant",
176
+ content: [],
177
+ model,
178
+ stop_reason: null,
179
+ stop_sequence: null,
180
+ usage: {
181
+ input_tokens: Math.max(0, (usage?.prompt_tokens ?? 0) - (cached ?? 0)),
182
+ output_tokens: 0,
183
+ ...(cached !== undefined && { cache_read_input_tokens: cached }),
184
+ },
185
+ },
186
+ },
187
+ }
188
+ }
189
+
190
+ private closeOpenTools(events: StreamEventData[]): void {
191
+ const open = [...this.toolCalls.values()]
192
+ .filter((t) => t.open)
193
+ .sort((a, b) => a.anthropicIndex - b.anthropicIndex)
194
+ for (const track of open) {
195
+ events.push({
196
+ event: "content_block_stop",
197
+ data: { type: "content_block_stop", index: track.anthropicIndex },
198
+ })
199
+ track.open = false
200
+ }
201
+ }
202
+
203
+ private close(
204
+ reason: NonNullable<OpenAIChoice["finish_reason"]>,
205
+ ): StreamEventData[] {
206
+ if (this.finished) return []
207
+ this.finished = true
208
+ const events: StreamEventData[] = []
209
+ if (!this.messageStartSent) {
210
+ events.push(this.messageStart(this.model))
211
+ }
212
+ this.closeOpenTools(events)
213
+ if (this.textOpen) {
214
+ events.push({
215
+ event: "content_block_stop",
216
+ data: { type: "content_block_stop", index: this.textIndex },
217
+ })
218
+ this.textOpen = false
219
+ }
220
+ let stopReason = mapStopReason(reason)
221
+ if (stopReason === "tool_use" && !this.anyToolStarted) {
222
+ stopReason = "end_turn"
223
+ }
224
+ events.push({
225
+ event: "message_delta",
226
+ data: {
227
+ type: "message_delta",
228
+ delta: { stop_reason: stopReason, stop_sequence: null },
229
+ usage: usageFromOpenAI(this.latestUsage),
230
+ },
231
+ })
232
+ events.push({ event: "message_stop", data: { type: "message_stop" } })
233
+ return events
234
+ }
235
+ }
package/src/tls.ts ADDED
@@ -0,0 +1,234 @@
1
+ // Trust for TLS-inspecting corporate networks (ZTNA, MITM proxies).
2
+ //
3
+ // Every outbound HTTPS call clco makes happens in THIS process — the claude
4
+ // child only ever talks plaintext to the local adapter. So the CA has to be
5
+ // trusted here, and a failure surfaces to the user as an adapter 502 rendered
6
+ // inside claude's UI rather than as a TLS error from claude itself.
7
+ //
8
+ // We pass the bundle per request via Bun's `tls.ca`, which unions with the
9
+ // default trust store rather than replacing it, and needs nothing decided
10
+ // before the process starts. NODE_USE_SYSTEM_CA is a no-op on Bun, whose
11
+ // default set already merges the bundled and system roots.
12
+ //
13
+ // NODE_EXTRA_CA_CERTS is reported to REPLACE the system store rather than add
14
+ // to it on some builds. That did not reproduce here - measured on bun 1.3.14
15
+ // and node 24, adding a private CA left registry.npmjs.org validating - so it
16
+ // is stated as an unconfirmed report rather than as fact. clco uses it for the
17
+ // MCP child, which has no per-request hook, and `tls.ca` for itself.
18
+
19
+ import { readFileSync } from "node:fs"
20
+ import { homedir } from "node:os"
21
+ import { join, resolve } from "node:path"
22
+ import { getCACertificates } from "node:tls"
23
+
24
+ /**
25
+ * The paths CLCO_CA_BUNDLE names, absolute.
26
+ *
27
+ * The single parser, because there used to be two: this one trimmed the raw
28
+ * value before splitting and the one handing a path to the MCP child did not,
29
+ * so " /a/ca.pem " worked in-process and reached the child as a cwd-joined
30
+ * nonsense path - clco's own probe passing while the child could not fetch,
31
+ * which is the asymmetry that whole code path exists to remove. Absolute
32
+ * because the child resolves relative paths against its own cwd, and `~` is
33
+ * expanded because the README documents that spelling and only an unquoted
34
+ * shell was expanding it.
35
+ */
36
+ export function caPaths(raw = process.env.CLCO_CA_BUNDLE): string[] {
37
+ return (raw ?? "")
38
+ .split(":")
39
+ .map((path) => path.trim())
40
+ // A bare "~" is the home DIRECTORY, which readFileSync then reports as
41
+ // EISDIR - a confusing line for a value that can never be a certificate.
42
+ .filter((path) => path !== "" && path !== "~")
43
+ .map((path) =>
44
+ path === "~" || path.startsWith("~/")
45
+ ? join(homedir(), path.slice(1))
46
+ : resolve(path),
47
+ )
48
+ }
49
+
50
+ let resolved: string[] | null | undefined
51
+ let readablePath: string | undefined
52
+
53
+ /**
54
+ * The default trust store plus any CA named by CLCO_CA_BUNDLE (one path, or
55
+ * several separated by ":"). Undefined when no extra CA is configured, so the
56
+ * request keeps Bun's own default handling.
57
+ */
58
+ export function caBundle(): string[] | undefined {
59
+ if (resolved !== undefined) return resolved ?? undefined
60
+ const paths = caPaths()
61
+ if (paths.length === 0) {
62
+ resolved = null
63
+ return undefined
64
+ }
65
+ const extra: string[] = []
66
+ for (const path of paths) {
67
+ try {
68
+ extra.push(readFileSync(path, "utf8"))
69
+ readablePath ??= path
70
+ } catch (err) {
71
+ console.error(
72
+ `[clco] could not read CA bundle: ${path} (${err instanceof Error ? err.message : String(err)})`,
73
+ )
74
+ }
75
+ }
76
+ if (extra.length === 0) {
77
+ resolved = null
78
+ return undefined
79
+ }
80
+ try {
81
+ // Union, never replace.
82
+ resolved = [...getCACertificates("default"), ...extra]
83
+ } catch {
84
+ // An older Bun without the default store: still better to trust the extra
85
+ // CA than to let the throw turn every upstream request into a 502.
86
+ resolved = extra
87
+ }
88
+ return resolved
89
+ }
90
+
91
+ /**
92
+ * The first CLCO_CA_BUNDLE path that actually read, for handing to a child
93
+ * process that can only take one.
94
+ *
95
+ * "First that read", not "first listed": clco used to log ENOENT for a path
96
+ * and then forward that same path to the MCP server anyway, where bun's only
97
+ * complaint is a warning on a stderr claude's UI does not show.
98
+ */
99
+ export function caChildPath(): string | undefined {
100
+ caBundle()
101
+ return readablePath
102
+ }
103
+
104
+ /** Test seam: forget a cached bundle so a changed env is picked up. */
105
+ export function resetCaBundle(): void {
106
+ resolved = undefined
107
+ readablePath = undefined
108
+ }
109
+
110
+ /**
111
+ * OpenSSL verify codes that mean "I do not trust this chain" - which is what a
112
+ * TLS-inspecting proxy produces, and what CLCO_CA_BUNDLE fixes. Bun populates
113
+ * `code` on the thrown error, so this is the reliable discriminator; the
114
+ * message text is not. Matching on text alone missed
115
+ * UNABLE_TO_VERIFY_LEAF_SIGNATURE, whose message is "unable to verify the
116
+ * first certificate" - a proxy presenting a leaf without shipping its
117
+ * intermediate, i.e. the most ordinary corporate shape there is. It was
118
+ * reported as an unreachable host, sending the user to their firewall team
119
+ * instead of to their CA.
120
+ */
121
+ /**
122
+ * Codes where the chain and the name are fine but the clock is not. No CA
123
+ * bundle fixes these, so they are not trust failures - but the host answered,
124
+ * so they are not "cannot reach" either, which is how an expired MITM
125
+ * certificate used to send the user to their firewall team.
126
+ */
127
+ const VALIDITY_CODES = new Set(["CERT_HAS_EXPIRED", "CERT_NOT_YET_VALID"])
128
+
129
+ /** Whether a failure is an expired or not-yet-valid certificate. */
130
+ export function isCertValidityError(err: unknown): boolean {
131
+ if (typeof err === "object" && err !== null) {
132
+ const code = (err as { code?: unknown }).code
133
+ if (typeof code === "string" && VALIDITY_CODES.has(code)) return true
134
+ }
135
+ const message = typeof err === "string" ? err : String((err as Error)?.message ?? err)
136
+ // The message path matters as much as the code: server.ts classifies a
137
+ // stringified upstream detail, so a code-only check would leave the new
138
+ // wording unreachable there.
139
+ return /certificate has expired|certificate is not yet valid|\bCERT_(HAS_EXPIRED|NOT_YET_VALID)\b/i.test(
140
+ message,
141
+ )
142
+ }
143
+
144
+ const TRUST_CODES = new Set([
145
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
146
+ "SELF_SIGNED_CERT_IN_CHAIN",
147
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
148
+ "UNABLE_TO_GET_ISSUER_CERT",
149
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
150
+ "CERT_UNTRUSTED",
151
+ // Deliberately NOT ERR_TLS_CERT_ALTNAME_INVALID: that chain verified and
152
+ // only the name did not match, so no CA bundle can fix it. Including it made
153
+ // clco tell a user whose bundle worked perfectly that their bundle did not
154
+ // cover the chain, and print the whole CA hint besides.
155
+ ])
156
+
157
+ /**
158
+ * Whether a failure is a broken trust chain rather than an unreachable host.
159
+ *
160
+ * Accepts the thrown error (preferred - it carries `code`) or just a message,
161
+ * since some call sites only have the string.
162
+ */
163
+ export function isTlsTrustError(err: unknown): boolean {
164
+ // The depth budget is deliberately not a public parameter: as a second
165
+ // argument, `msgs.some(isTlsTrustError)` fed it the array index and silently
166
+ // disabled the cause walk from index 4 on.
167
+ return trustError(err, 0)
168
+ }
169
+
170
+ function trustError(err: unknown, depth: number): boolean {
171
+ if (typeof err === "object" && err !== null) {
172
+ const code = (err as { code?: unknown }).code
173
+ if (typeof code === "string" && TRUST_CODES.has(code)) return true
174
+ // Bounded: a cyclic `cause` chain used to overflow the stack, and since
175
+ // every caller is an error handler the RangeError escaped the catch that
176
+ // was about to render a 502 or print the user's real error.
177
+ if (depth < 4) {
178
+ const cause = (err as { cause?: unknown }).cause
179
+ if (cause !== undefined && cause !== err && trustError(cause, depth + 1)) {
180
+ return true
181
+ }
182
+ // undici reports a multi-address failure as an AggregateError, so the
183
+ // real trust error is in `errors`, not in `cause`.
184
+ const nested = (err as { errors?: unknown }).errors
185
+ if (Array.isArray(nested)) {
186
+ for (const one of nested) {
187
+ if (one !== err && trustError(one, depth + 1)) return true
188
+ }
189
+ }
190
+ }
191
+ }
192
+ const message = typeof err === "string" ? err : String((err as Error)?.message ?? err)
193
+ // Fallback for a stringified error, or a Bun/Node build that omits the code.
194
+ // Anchored to the codes and the exact OpenSSL phrasings: a bare /CERT_/ or
195
+ // /certificate chain/ matched ordinary prose, so an upstream error body
196
+ // echoed into the adapter's 502 ("rotating certificate chain nightly") drew
197
+ // the whole CA hint onto a failure that had nothing to do with trust.
198
+ return (
199
+ /self[- ]signed certificate( in certificate chain)?|unable to (get local issuer certificate|get issuer certificate|verify the first certificate)/i.test(
200
+ message,
201
+ ) ||
202
+ /\b(DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT(_LOCALLY)?|CERT_UNTRUSTED)\b/.test(
203
+ message,
204
+ )
205
+ )
206
+ }
207
+
208
+ /**
209
+ * What to tell the user about a trust failure.
210
+ *
211
+ * Adapts to whether a bundle actually loaded: telling someone who already
212
+ * exported a CA to export a CA is the advice this message exists to replace,
213
+ * and the one-line browser status already got this right.
214
+ */
215
+ export function tlsHint(caLoaded = caBundle() !== undefined): string {
216
+ if (caLoaded) {
217
+ return (
218
+ "\nYour CLCO_CA_BUNDLE loaded, but nothing in it signed this chain.\n" +
219
+ " - Export the ISSUING CA, not the leaf certificate the proxy presents.\n" +
220
+ ' - macOS: security find-certificate -a -p -c "<CA name>" > ca.pem\n' +
221
+ " - Several CAs can be joined with \":\" - clco unions them all.\n" +
222
+ "Never disable TLS verification: your GitHub token goes over that connection."
223
+ )
224
+ }
225
+ return (
226
+ "\nThis looks like a corporate proxy re-signing TLS. To fix it:\n" +
227
+ " 1) Get your company CA as a file, then:\n" +
228
+ " CLCO_CA_BUNDLE=/path/ca.pem clco ...\n" +
229
+ " It is ADDED to the OS trust store, never replaces it.\n" +
230
+ ' 2) Export it from the macOS keychain:\n' +
231
+ ' security find-certificate -a -p -c "<CA name>" > ca.pem\n' +
232
+ "Never disable TLS verification: your GitHub token goes over that connection."
233
+ )
234
+ }