@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/LICENSE +21 -0
- package/README.md +279 -0
- package/bin/clco +27 -0
- package/bun.lock +39 -0
- package/install.sh +161 -0
- package/package.json +35 -0
- package/scripts/mock-upstream.ts +129 -0
- package/scripts/write-launcher.sh +47 -0
- package/src/api.ts +90 -0
- package/src/auth.ts +130 -0
- package/src/blocks.ts +198 -0
- package/src/browsermcp.ts +430 -0
- package/src/catalog.ts +147 -0
- package/src/claudehome.ts +269 -0
- package/src/cli.ts +889 -0
- package/src/config.ts +164 -0
- package/src/responses.ts +435 -0
- package/src/route.ts +52 -0
- package/src/server.ts +641 -0
- package/src/setup.ts +218 -0
- package/src/spawn.ts +453 -0
- package/src/stream.ts +235 -0
- package/src/tls.ts +234 -0
- package/src/token.ts +298 -0
- package/src/tokens.ts +55 -0
- package/src/translate.ts +384 -0
- package/src/wire.ts +149 -0
- package/uninstall.sh +42 -0
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
|
+
}
|