@llm4ts/shell 2.26.0 → 2.28.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.
@@ -1,15 +1,18 @@
1
1
  import * as http from "node:http";
2
2
  import * as https from "node:https";
3
+ import * as net from "node:net";
4
+ import * as tls from "node:tls";
3
5
  import * as Context from "effect/Context";
4
6
  import * as Duration from "effect/Duration";
5
7
  import * as Effect from "effect/Effect";
6
8
  import * as Redacted from "effect/Redacted";
7
9
  import * as Ref from "effect/Ref";
8
10
  import * as Schema from "effect/Schema";
11
+ import { bypassesProxy } from "./Auth.js";
9
12
  import { DocumentLoadError, makeFileDocumentLoader } from "./Wsdl.js";
10
13
  export class TransportError extends Schema.TaggedError()("TransportError", {
11
14
  url: Schema.String,
12
- reason: Schema.Literals(["scheme", "network", "tls", "timeout", "aborted"]),
15
+ reason: Schema.Literals(["scheme", "network", "tls", "timeout", "aborted", "proxy"]),
13
16
  detail: Schema.String
14
17
  }) {
15
18
  get message() {
@@ -32,32 +35,166 @@ const errorCode = (cause) => typeof cause === "object" && cause !== null && "cod
32
35
  ? cause.code
33
36
  : "request failed";
34
37
  const tlsCodes = /CERT|SSL|TLS|SELF_SIGNED|UNABLE_TO|DEPTH_ZERO|ERR_OSSL/;
38
+ const unknownIssuer = /SELF_SIGNED|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER|DEPTH_ZERO|CERT_UNTRUSTED|UNABLE_TO_GET_LOCAL_ISSUER/;
39
+ export const trustHint = 'the chain is not trusted; capture it with soap-sample "trust <side>"';
40
+ const describeTlsFailure = (code) => unknownIssuer.test(code) ? `${code} (${trustHint})` : code;
41
+ class ProxyRefused extends Error {
42
+ status;
43
+ constructor(status) {
44
+ super(`proxy refused the tunnel: HTTP ${status}`);
45
+ this.status = status;
46
+ }
47
+ }
35
48
  const flatHeaders = (headers) => Object.fromEntries(Object.entries(headers).flatMap(([name, value]) => value === undefined ? [] : [[name, Array.isArray(value) ? value.join(", ") : value]]));
36
49
  const bufferOf = (secret) => secret === undefined ? undefined : Buffer.from(Redacted.value(secret));
50
+ /** The CA list a connection verifies against: system roots plus the profile's, or the profile's alone. */
51
+ export const caListFor = (resolved) => {
52
+ if (resolved?.ca === undefined) {
53
+ return undefined;
54
+ }
55
+ const own = new TextDecoder().decode(Redacted.value(resolved.ca));
56
+ return resolved.trust === "ca-only" ? [own] : [...tls.rootCertificates, own];
57
+ };
58
+ const tlsOptionsOf = (resolved, host) => {
59
+ const ca = caListFor(resolved);
60
+ return {
61
+ ...(resolved === undefined
62
+ ? {}
63
+ : {
64
+ cert: bufferOf(resolved.cert),
65
+ key: bufferOf(resolved.key),
66
+ pfx: bufferOf(resolved.pfx),
67
+ passphrase: resolved.passphrase === undefined ? undefined : Redacted.value(resolved.passphrase)
68
+ }),
69
+ // A pinned chain may hold only what the server sent (a leaf signed by a
70
+ // CA it never presents): trust it as captured, not only self-signed roots.
71
+ ...(ca === undefined ? {} : { ca: [...ca], allowPartialTrustChain: true }),
72
+ servername: resolved?.servername ?? (net.isIP(host) === 0 ? host : undefined)
73
+ };
74
+ };
75
+ const portOf = (url) => url.port !== "" ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
76
+ /** Whether this request goes through the proxy: configured and the host not excluded. */
77
+ export const proxyFor = (url, proxy) => proxy === undefined || bypassesProxy(url.hostname, proxy.noProxy) ? undefined : proxy;
78
+ /** An HTTP CONNECT tunnel to `target` through `proxy`; the raw socket, not yet TLS. */
79
+ const connectTunnel = (proxy, target, signal) => new Promise((resolve, reject) => {
80
+ const proxyUrl = new URL(proxy.url);
81
+ const headers = { host: `${target.hostname}:${portOf(target)}` };
82
+ if (proxy.authorization !== undefined) {
83
+ headers["proxy-authorization"] = Redacted.value(proxy.authorization);
84
+ }
85
+ const request = http.request({
86
+ host: proxyUrl.hostname,
87
+ port: portOf(proxyUrl),
88
+ method: "CONNECT",
89
+ path: `${target.hostname}:${portOf(target)}`,
90
+ headers,
91
+ signal
92
+ });
93
+ request.on("connect", (response, socket) => {
94
+ if (response.statusCode === 200) {
95
+ resolve(socket);
96
+ }
97
+ else {
98
+ socket.destroy();
99
+ reject(new ProxyRefused(response.statusCode ?? 0));
100
+ }
101
+ });
102
+ request.on("error", reject);
103
+ request.end();
104
+ });
105
+ /** The socket a request runs on: direct, or through the tunnel; TLS-wrapped for https. */
106
+ const openSocket = async (url, resolved, proxy, signal) => {
107
+ const via = proxyFor(url, proxy);
108
+ if (via === undefined) {
109
+ return undefined;
110
+ }
111
+ const tunnel = await connectTunnel(via, url, signal);
112
+ if (url.protocol !== "https:") {
113
+ return tunnel;
114
+ }
115
+ return new Promise((resolve, reject) => {
116
+ const secure = tls.connect({ ...tlsOptionsOf(resolved, url.hostname), socket: tunnel }, () => resolve(secure));
117
+ secure.on("error", reject);
118
+ });
119
+ };
120
+ const transportFailure = (shown, cause) => {
121
+ if (cause instanceof ProxyRefused) {
122
+ return new TransportError({ url: shown, reason: "proxy", detail: `HTTP ${cause.status}` });
123
+ }
124
+ const code = errorCode(cause);
125
+ return new TransportError({
126
+ url: shown,
127
+ reason: code === "ABORT_ERR" ? "aborted" : tlsCodes.test(code) ? "tls" : "network",
128
+ detail: tlsCodes.test(code) ? describeTlsFailure(code) : code
129
+ });
130
+ };
131
+ const withTimeout = (shown, timeout) => (effect) => effect.pipe(Effect.timeoutOrElse({
132
+ duration: timeout,
133
+ orElse: () => Effect.fail(new TransportError({
134
+ url: shown,
135
+ reason: "timeout",
136
+ detail: `no response within ${Duration.format(timeout)}`
137
+ }))
138
+ }));
139
+ const parseTarget = (raw, needsTls) => {
140
+ const shown = displayUrl(raw);
141
+ let url;
142
+ try {
143
+ url = new URL(raw);
144
+ }
145
+ catch {
146
+ return new TransportError({ url: shown, reason: "scheme", detail: "invalid URL" });
147
+ }
148
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
149
+ return new TransportError({
150
+ url: shown,
151
+ reason: "scheme",
152
+ detail: `${url.protocol} is not http(s)`
153
+ });
154
+ }
155
+ if (url.protocol === "http:" && needsTls) {
156
+ return new TransportError({
157
+ url: shown,
158
+ reason: "scheme",
159
+ detail: "client TLS needs an https URL"
160
+ });
161
+ }
162
+ return url;
163
+ };
164
+ const pemOf = (der) => `-----BEGIN CERTIFICATE-----\n${der
165
+ .toString("base64")
166
+ .replace(/(.{64})/g, "$1\n")
167
+ .trimEnd()}\n-----END CERTIFICATE-----\n`;
168
+ const nameOf = (subject) => Object.entries(subject ?? {})
169
+ .map(([key, value]) => `${key}=${Array.isArray(value) ? value.join("+") : String(value)}`)
170
+ .join(", ");
171
+ /** Walk `issuerCertificate` links, leaf first, stopping at the self-signed root. */
172
+ const chainOf = (leaf) => {
173
+ const out = [];
174
+ const seen = new Set();
175
+ let current = leaf;
176
+ while (current !== undefined && current.raw !== undefined && !seen.has(current.fingerprint256)) {
177
+ seen.add(current.fingerprint256);
178
+ out.push({
179
+ subject: nameOf(current.subject),
180
+ issuer: nameOf(current.issuer),
181
+ validFrom: current.valid_from,
182
+ validTo: current.valid_to,
183
+ fingerprint256: current.fingerprint256,
184
+ pem: pemOf(current.raw)
185
+ });
186
+ current = current.issuerCertificate === current ? undefined : current.issuerCertificate;
187
+ }
188
+ return out;
189
+ };
37
190
  export const makeNodeSoapTransport = () => ({
38
191
  send: (request) => {
39
192
  const shown = displayUrl(request.url);
40
- let url;
41
- try {
42
- url = new URL(request.url);
43
- }
44
- catch {
45
- return Effect.fail(new TransportError({ url: shown, reason: "scheme", detail: "invalid URL" }));
46
- }
47
- if (url.protocol !== "https:" && url.protocol !== "http:") {
48
- return Effect.fail(new TransportError({
49
- url: shown,
50
- reason: "scheme",
51
- detail: `${url.protocol} is not http(s)`
52
- }));
53
- }
54
- if (url.protocol === "http:" && request.tls !== undefined) {
55
- return Effect.fail(new TransportError({
56
- url: shown,
57
- reason: "scheme",
58
- detail: "client TLS needs an https URL"
59
- }));
193
+ const target = parseTarget(request.url, request.tls !== undefined);
194
+ if (target instanceof TransportError) {
195
+ return Effect.fail(target);
60
196
  }
197
+ const url = target;
61
198
  const headers = { ...request.headers };
62
199
  for (const [name, value] of request.secretHeaders)
63
200
  headers[name] = Redacted.value(value);
@@ -65,67 +202,99 @@ export const makeNodeSoapTransport = () => ({
65
202
  if (body !== undefined)
66
203
  headers["content-length"] = String(body.length);
67
204
  return Effect.tryPromise({
68
- try: (signal) => new Promise((resolve, reject) => {
205
+ try: (signal) => {
69
206
  const started = performance.now();
70
- const tls = request.tls;
71
- const options = {
72
- method: request.method,
73
- headers,
74
- signal,
75
- ...(tls === undefined
76
- ? {}
77
- : {
78
- cert: bufferOf(tls.cert),
79
- key: bufferOf(tls.key),
80
- pfx: bufferOf(tls.pfx),
81
- ca: bufferOf(tls.ca),
82
- passphrase: tls.passphrase === undefined ? undefined : Redacted.value(tls.passphrase)
83
- })
84
- };
85
- const handler = (response) => {
86
- const chunks = [];
87
- response.on("data", (chunk) => chunks.push(chunk));
88
- response.on("error", reject);
89
- response.on("end", () => resolve({
90
- status: response.statusCode ?? 0,
91
- headers: flatHeaders(response.headers),
92
- body: new Uint8Array(Buffer.concat(chunks)),
93
- elapsedMs: Math.round(performance.now() - started)
94
- }));
95
- };
96
- const outgoing = url.protocol === "https:"
97
- ? https.request(url, options, handler)
98
- : http.request(url, options, handler);
99
- outgoing.on("error", reject);
100
- if (body !== undefined)
101
- outgoing.write(body);
102
- outgoing.end();
103
- }),
104
- catch: (cause) => {
105
- const code = errorCode(cause);
106
- return new TransportError({
107
- url: shown,
108
- reason: code === "ABORT_ERR" ? "aborted" : tlsCodes.test(code) ? "tls" : "network",
109
- detail: code
110
- });
111
- }
112
- }).pipe(Effect.timeoutOrElse({
113
- duration: request.timeout,
114
- orElse: () => Effect.fail(new TransportError({
207
+ return openSocket(url, request.tls, request.proxy, signal).then((socket) => new Promise((resolve, reject) => {
208
+ const options = {
209
+ method: request.method,
210
+ headers,
211
+ signal,
212
+ ...(url.protocol === "https:" ? tlsOptionsOf(request.tls, url.hostname) : {}),
213
+ ...(socket === undefined ? {} : { createConnection: () => socket })
214
+ };
215
+ const handler = (response) => {
216
+ const chunks = [];
217
+ response.on("data", (chunk) => chunks.push(chunk));
218
+ response.on("error", reject);
219
+ response.on("end", () => resolve({
220
+ status: response.statusCode ?? 0,
221
+ headers: flatHeaders(response.headers),
222
+ body: new Uint8Array(Buffer.concat(chunks)),
223
+ elapsedMs: Math.round(performance.now() - started)
224
+ }));
225
+ };
226
+ const outgoing = url.protocol === "https:"
227
+ ? https.request(url, options, handler)
228
+ : http.request(url, options, handler);
229
+ outgoing.on("error", reject);
230
+ if (body !== undefined)
231
+ outgoing.write(body);
232
+ outgoing.end();
233
+ }));
234
+ },
235
+ catch: (cause) => transportFailure(shown, cause)
236
+ }).pipe(withTimeout(shown, request.timeout));
237
+ },
238
+ peerChain: (raw, options) => {
239
+ const shown = displayUrl(raw);
240
+ const target = parseTarget(raw, false);
241
+ if (target instanceof TransportError) {
242
+ return Effect.fail(target);
243
+ }
244
+ const url = target;
245
+ if (url.protocol !== "https:") {
246
+ return Effect.fail(new TransportError({
115
247
  url: shown,
116
- reason: "timeout",
117
- detail: `no response within ${Duration.format(request.timeout)}`
118
- }))
119
- }));
248
+ reason: "scheme",
249
+ detail: "only an https endpoint has a chain"
250
+ }));
251
+ }
252
+ return Effect.tryPromise({
253
+ try: (signal) => {
254
+ const via = proxyFor(url, options.proxy);
255
+ const tunnelled = via === undefined ? Promise.resolve(undefined) : connectTunnel(via, url, signal);
256
+ return tunnelled.then((tunnel) => new Promise((resolve, reject) => {
257
+ const socket = tls.connect({
258
+ ...(tunnel === undefined
259
+ ? { host: url.hostname, port: portOf(url) }
260
+ : { socket: tunnel }),
261
+ servername: options.servername ?? (net.isIP(url.hostname) === 0 ? url.hostname : undefined),
262
+ rejectUnauthorized: false
263
+ }, () => {
264
+ const leaf = socket.getPeerCertificate(true);
265
+ const chain = {
266
+ host: url.hostname,
267
+ certificates: chainOf(leaf),
268
+ trustedBySystem: socket.authorized,
269
+ authorizationError: socket.authorizationError === undefined || socket.authorizationError === null
270
+ ? undefined
271
+ : String(socket.authorizationError)
272
+ };
273
+ socket.end();
274
+ resolve(chain);
275
+ });
276
+ socket.on("error", reject);
277
+ signal.addEventListener("abort", () => socket.destroy());
278
+ }));
279
+ },
280
+ catch: (cause) => transportFailure(shown, cause)
281
+ }).pipe(withTimeout(shown, options.timeout));
120
282
  }
121
283
  });
122
284
  /**
123
285
  * A scripted transport for tests: each request goes to `respond`, and every
124
- * request is recorded (secrets still redacted) for assertions.
286
+ * request is recorded (secrets still redacted) for assertions. `chain`
287
+ * answers `peerChain`; by default an endpoint presents nothing.
125
288
  */
126
- export const makeFakeSoapTransport = (respond) => Effect.map(Ref.make([]), (log) => ({
289
+ export const makeFakeSoapTransport = (respond, chain = (url) => Effect.succeed({
290
+ host: new URL(url).hostname,
291
+ certificates: [],
292
+ trustedBySystem: true,
293
+ authorizationError: undefined
294
+ })) => Effect.map(Ref.make([]), (log) => ({
127
295
  transport: {
128
- send: (request) => Effect.andThen(Ref.update(log, (all) => [...all, request]), respond(request))
296
+ send: (request) => Effect.andThen(Ref.update(log, (all) => [...all, request]), respond(request)),
297
+ peerChain: (url) => chain(url)
129
298
  },
130
299
  requests: Ref.get(log)
131
300
  }));
@@ -138,7 +307,7 @@ export const xmlResponse = (xml, status = 200) => ({
138
307
  const isUrl = (location) => /^https?:\/\//i.test(location);
139
308
  /**
140
309
  * The document loader for discovery: local paths from disk, http(s) URLs
141
- * through the transport with the profile's fetch-side auth and TLS.
310
+ * through the transport with the profile's fetch-side auth, TLS and proxy.
142
311
  */
143
312
  export const makeTransportDocumentLoader = (transport, side, timeout = Duration.seconds(30)) => {
144
313
  const files = makeFileDocumentLoader();
@@ -152,6 +321,7 @@ export const makeTransportDocumentLoader = (transport, side, timeout = Duration.
152
321
  headers: { accept: "text/xml, application/xml, */*" },
153
322
  secretHeaders: side.headers,
154
323
  ...(side.tls === undefined ? {} : { tls: side.tls }),
324
+ ...(side.proxy === undefined ? {} : { proxy: side.proxy }),
155
325
  timeout
156
326
  })
157
327
  .pipe(Effect.mapError((error) => new DocumentLoadError({
@@ -297,7 +297,8 @@ class Parser {
297
297
  attributes,
298
298
  children: [],
299
299
  scope,
300
- line
300
+ line,
301
+ span: { start, end: this.position }
301
302
  };
302
303
  }
303
304
  if (this.text[this.position] !== ">")
@@ -340,7 +341,8 @@ class Parser {
340
341
  attributes,
341
342
  children,
342
343
  scope,
343
- line
344
+ line,
345
+ span: { start, end: this.position }
344
346
  };
345
347
  }
346
348
  if (this.text.startsWith("<![CDATA[", this.position)) {
@@ -47,7 +47,7 @@ const program = Effect.gen(function* () {
47
47
  const workspace = yield* makeNodeWorkspace(input.workDir);
48
48
  const profile = service === undefined || service === ""
49
49
  ? undefined
50
- : yield* loadAuthProfile(workspace, service);
50
+ : yield* loadAuthProfile(workspace, service, process.env.LLM4TS_SOAP_ENV?.trim() || undefined);
51
51
  if (isUrl(location) && profile === undefined) {
52
52
  yield* say("fetching without credentials (set LLM4TS_SOAP_SERVICE and write its auth.json for an authenticated WSDL)");
53
53
  }
@@ -37,8 +37,9 @@ import { FlowAborted, Info, makeNodeWorkspace, mock, resolveFlowInput, runFlowMa
37
37
  import { coderFor, judgmentConnectorFromEnvironment } from "@llm4ts/runner/Connectors";
38
38
  import { nodeSecretSource, resolveSide } from "./lib/soap/Auth.js";
39
39
  import { heuristicClass, judgeClass } from "./lib/soap/Classification.js";
40
- import { discoverService, loadAuthProfile, loadCatalog, loadOperationsFile } from "./lib/soap/Discover.js";
40
+ import { discoverService, loadAuthProfile, profilePath, loadCatalog, loadOperationsFile } from "./lib/soap/Discover.js";
41
41
  import { designStep, explorePaths, exploreService, renderExploreReport, writeEvidence } from "./lib/soap/Explore.js";
42
+ import { makeTokenSource } from "./lib/soap/Sts.js";
42
43
  import { makeDirectoryStubTransport } from "./lib/soap/Stub.js";
43
44
  import { makeNodeSoapTransport, makeTransportDocumentLoader } from "./lib/soap/Transport.js";
44
45
  import { DocumentLoader } from "./lib/soap/Wsdl.js";
@@ -94,7 +95,9 @@ const program = Effect.gen(function* () {
94
95
  let catalog;
95
96
  let judged;
96
97
  if (location !== undefined) {
97
- const fetchProfile = requested === undefined ? undefined : yield* loadAuthProfile(workspace, requested);
98
+ const fetchProfile = requested === undefined
99
+ ? undefined
100
+ : yield* loadAuthProfile(workspace, requested, environment.LLM4TS_SOAP_ENV?.trim() || undefined);
98
101
  const fetchSide = yield* resolveSide(nodeSecretSource(environment), fetchProfile?.fetch);
99
102
  const discovered = yield* discoverService({
100
103
  workspace,
@@ -124,7 +127,10 @@ const program = Effect.gen(function* () {
124
127
  catalog = yield* loadCatalog(workspace, service);
125
128
  yield* say(`continuing ${service}: ${catalog.operations.length} operations`);
126
129
  }
127
- const profile = yield* loadAuthProfile(workspace, service);
130
+ const env = environment.LLM4TS_SOAP_ENV?.trim() || undefined;
131
+ const profile = yield* loadAuthProfile(workspace, service, env);
132
+ if (profile !== undefined)
133
+ yield* say(`profile: ${profilePath(service, env)}`);
128
134
  const operations = yield* loadOperationsFile(workspace, service);
129
135
  // Provisional probing asks the judgment only where the listed class
130
136
  // and the heuristic already say read.
@@ -149,6 +155,17 @@ const program = Effect.gen(function* () {
149
155
  judged,
150
156
  secrets: nodeSecretSource(environment),
151
157
  transport,
158
+ ...(profile?.sts === undefined
159
+ ? {}
160
+ : {
161
+ token: yield* makeTokenSource({
162
+ workspace,
163
+ service,
164
+ config: profile.sts,
165
+ secrets: nodeSecretSource(environment),
166
+ transport
167
+ })
168
+ }),
152
169
  refresh: flags.refresh,
153
170
  progress: (line) => say(` ${line}`)
154
171
  });