@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.
@@ -0,0 +1,66 @@
1
+ // Read what a failed epic-stories run left behind and propose the fixes the next run applies once approved.
2
+ //
3
+ // llm4ts run epic-retro --repo ~/demo/portal # the one open epic, its latest run
4
+ // llm4ts run epic-retro --repo ~/demo/portal -- --epic <id> # one epic
5
+ // llm4ts run epic-retro --repo ~/demo/portal -- --epic <id> --run run-1759… # one run of it
6
+ //
7
+ // The evidence is the run's trace, its transcripts when it was started with
8
+ // --transcript, the board, the report, and each story's plan, findings and
9
+ // judge verdict under .llm4ts/epics/<epic-id>/. Code digests them (the digest
10
+ // is written first, at retro/<runId>.digest.md); one read-only seat
11
+ // (LLM4TS_REASONER, the judge seat) turns the digest into a proposal: per
12
+ // story a diagnosis and exactly one fix — tasks appended to its plan, an
13
+ // edit to its entry (which restarts it), a refine round, or nothing — plus
14
+ // run advice and, where the library misbehaved, advice for llm4ts
15
+ // (retro/<runId>-library.md). Code validates the proposal before writing
16
+ // retro/<runId>.md, which ends in `- [ ] Approved`. Tick it and rerun
17
+ // epic-stories: the approved fixes are applied before any story runs, and
18
+ // the report is marked Applied. Nothing is applied unapproved; nothing in
19
+ // the target's git is touched.
20
+ import { join } from "node:path";
21
+ import * as Console from "effect/Console";
22
+ import * as Effect from "effect/Effect";
23
+ import { ScriptUsage, asReadOnly, nodePlainFileStore, resolveFlowInput, runFlowMain, runNode } from "@llm4ts/runner";
24
+ import { loadTranscript, nodeTranscriptFiles } from "@llm4ts/runner/Transcripts";
25
+ import { parseEpicRetroArgs, resolveRetroTarget, runRetro } from "./lib/epic-retro.js";
26
+ import { chooseEpic, defaultEpic, epicsDir, listEpics, reasonerFromEnvironment } from "./lib/epic-stories.js";
27
+ const program = Effect.gen(function* () {
28
+ const flags = yield* parseEpicRetroArgs(process.argv.slice(2));
29
+ const given = yield* resolveFlowInput("", flags.rest);
30
+ const files = nodePlainFileStore;
31
+ const epics = yield* listEpics(files, given.workDir);
32
+ const choice = yield* chooseEpic({ text: "", epic: flags.epic, epics, defaultEpic });
33
+ if (choice._tag !== "Existing") {
34
+ return yield* ScriptUsage.make({
35
+ message: `no planned epic to look at in ${given.workDir}; run epic-stories first`
36
+ });
37
+ }
38
+ const epicDir = choice.epic.dir;
39
+ const stateDir = join(epicsDir(given.workDir), epicDir);
40
+ const target = yield* resolveRetroTarget(files, stateDir, flags.run);
41
+ const transcriptDir = join(given.workDir, ".llm4ts", "transcripts", target.run.runId);
42
+ const reasoning = asReadOnly(yield* reasonerFromEnvironment(process.env));
43
+ const outcome = yield* runNode({
44
+ workDir: given.workDir,
45
+ workspace: given.workspace,
46
+ userPrompt: `retro of run ${target.run.runId} of epic ${target.plan.epicId}`,
47
+ // No coder turn is ever taken: the read-only reasoning seat fills both.
48
+ coder: reasoning,
49
+ reasoning,
50
+ environment: process.env
51
+ }, (context) => runRetro({
52
+ files,
53
+ events: context.events,
54
+ seat: context.judge ?? context.reasoning,
55
+ workDir: given.workDir,
56
+ epicDir,
57
+ target,
58
+ transcript: (lane) => loadTranscript(nodeTranscriptFiles, transcriptDir, { lane }),
59
+ environment: process.env,
60
+ now: Date.now()
61
+ }));
62
+ if (outcome._tag === "NothingToDo") {
63
+ yield* Console.log(outcome.message);
64
+ }
65
+ });
66
+ runFlowMain(program);
@@ -0,0 +1,150 @@
1
+ /**
2
+ * epic-retro: read what a failed epic-stories run left behind, explain each
3
+ * failed story, and propose the fix the next run applies once approved.
4
+ * The policy (digest, proposal schema, validation, report, apply) lives in
5
+ * `@llm4ts/flow/Retro`; this module finds the run and its files and drives
6
+ * one read-only seat.
7
+ */
8
+ import { join } from "node:path";
9
+ import * as Effect from "effect/Effect";
10
+ import { readEpicRuns } from "@llm4ts/flow/EpicRuns";
11
+ import { Info } from "@llm4ts/flow/FlowEvents";
12
+ import { readTrace } from "@llm4ts/flow/Replay";
13
+ import { RetroProposal, defaultRetroChars, loadEpicFiles, loadStoryFiles, renderRetroDigest, retroCandidates, retroPaths, retroPrompt, retroProposalJsonSchema, validateRetroProposal, writeRetro } from "@llm4ts/flow/Retro";
14
+ import { makeStoryPlanStore } from "@llm4ts/flow/StoryPlan";
15
+ import { structuredAndPublish } from "@llm4ts/flow/Usage";
16
+ import { ScriptUsage } from "@llm4ts/runner/FlowArgs";
17
+ import { roundDir } from "./epic-stories.js";
18
+ export const epicRetroUsage = [
19
+ "usage: llm4ts run epic-retro --repo <target> [-- --epic <id>] [-- --run <runId>]",
20
+ " --epic <id> the epic (folder name or plan id); default: the one open epic",
21
+ " --run <id> the run to look at; default: the epic's latest run",
22
+ "Reads the run's trace, transcripts (when kept), board, report, story plans, findings and",
23
+ "verdicts; writes .llm4ts/epics/<epic>/retro/<runId>.md with proposed fixes behind an",
24
+ "approval line, and <runId>-library.md with advice for llm4ts when there is any."
25
+ ].join("\n");
26
+ export const parseEpicRetroArgs = (argv) => Effect.gen(function* () {
27
+ let epic;
28
+ let run;
29
+ const rest = [];
30
+ for (let index = 0; index < argv.length; index += 1) {
31
+ const argument = argv[index] ?? "";
32
+ const valued = (name) => {
33
+ const value = argument.includes("=") ? argument.slice(name.length + 1) : argv[index + 1];
34
+ if (!argument.includes("=")) {
35
+ index += 1;
36
+ }
37
+ return value === undefined || value.trim().length === 0 || value.startsWith("--")
38
+ ? ScriptUsage.make({ message: `${name} needs a value\n${epicRetroUsage}` })
39
+ : Effect.succeed(value.trim());
40
+ };
41
+ if (argument === "--epic" || argument.startsWith("--epic=")) {
42
+ epic = yield* valued("--epic");
43
+ }
44
+ else if (argument === "--run" || argument.startsWith("--run=")) {
45
+ run = yield* valued("--run");
46
+ }
47
+ else {
48
+ rest.push(argument);
49
+ }
50
+ }
51
+ return { epic, run, rest };
52
+ });
53
+ export const resolveRetroTarget = Effect.fn("epic-retro.resolveTarget")(function* (files, epicStateDir, runId) {
54
+ const runs = yield* readEpicRuns(files, epicStateDir);
55
+ const run = runId === undefined ? runs.at(-1) : runs.find((candidate) => candidate.runId === runId);
56
+ if (run === undefined) {
57
+ return yield* ScriptUsage.make({
58
+ message: runId === undefined
59
+ ? `no run recorded in ${join(epicStateDir, "runs.jsonl")}; run epic-stories first`
60
+ : `no run '${runId}' in ${join(epicStateDir, "runs.jsonl")}; recorded: ${runs.map((r) => r.runId).join(", ") || "none"}`
61
+ });
62
+ }
63
+ const stateDir = run.round !== undefined && (run.action === "RunRound" || run.action === "PlanRound")
64
+ ? roundDir(epicStateDir, run.round)
65
+ : epicStateDir;
66
+ const plan = yield* makeStoryPlanStore(files).load(join(stateDir, "plan.md"));
67
+ if (plan === undefined) {
68
+ return yield* ScriptUsage.make({ message: `no plan at ${join(stateDir, "plan.md")}` });
69
+ }
70
+ return { run, stateDir, plan };
71
+ });
72
+ const retroChars = (environment) => {
73
+ const value = Number.parseInt(environment.LLM4TS_RETRO_CHARS ?? "", 10);
74
+ return Number.isInteger(value) && value > 0 ? value : defaultRetroChars;
75
+ };
76
+ /** The whole retro: evidence, one structured call, validation, the files. */
77
+ export const runRetro = Effect.fn("epic-retro.run")(function* (deps) {
78
+ const { files, target } = deps;
79
+ const { stateDir, plan, run } = target;
80
+ const trace = yield* readTrace(files, run.tracePath);
81
+ const epic = yield* loadEpicFiles(files, stateDir);
82
+ const stories = yield* Effect.forEach(plan.stories, (story) => loadStoryFiles(files, stateDir, story));
83
+ const withoutTranscripts = {
84
+ runId: run.runId,
85
+ plan,
86
+ trace,
87
+ transcripts: undefined,
88
+ report: epic.report,
89
+ board: epic.board,
90
+ stories,
91
+ budget: retroChars(deps.environment)
92
+ };
93
+ const candidates = retroCandidates(withoutTranscripts);
94
+ if (candidates.length === 0) {
95
+ return {
96
+ _tag: "NothingToDo",
97
+ message: `run ${run.runId} of epic ${plan.epicId} left no failed, waiting or unfinished story; nothing to look at`
98
+ };
99
+ }
100
+ // Transcripts are read per candidate lane; the first answer says whether
101
+ // the run kept any at all.
102
+ const transcripts = new Map();
103
+ let kept = false;
104
+ for (const candidate of candidates) {
105
+ const entries = yield* deps.transcript(candidate.story.id);
106
+ if (entries !== undefined) {
107
+ kept = true;
108
+ transcripts.set(candidate.story.id, entries);
109
+ }
110
+ }
111
+ const inputs = { ...withoutTranscripts, transcripts: kept ? transcripts : undefined };
112
+ const digest = renderRetroDigest(inputs);
113
+ const paths = retroPaths(stateDir, run.runId);
114
+ // The digest is on disk before the seat is asked: a failed call still leaves it to read.
115
+ yield* files.writeAtomic(paths.digest, digest);
116
+ yield* deps.events.publish(Info.make({
117
+ message: `retro: ${candidates.length} stor${candidates.length === 1 ? "y" : "ies"} to look at in run ${run.runId}${kept ? "" : " (no transcripts; rerun with --transcript for more)"} — digest at ${paths.digest}`
118
+ }));
119
+ const withPlans = new Set(stories.filter((entry) => entry.plan !== undefined).map((entry) => entry.story.id));
120
+ const proposal = yield* structuredAndPublish(deps.seat, deps.events, retroPrompt(digest, [...withPlans]), RetroProposal, retroProposalJsonSchema, "retro");
121
+ const validated = validateRetroProposal(proposal, plan, withPlans);
122
+ const written = yield* writeRetro(files, stateDir, {
123
+ runId: run.runId,
124
+ plan,
125
+ validated,
126
+ workDir: deps.workDir,
127
+ epicDir: deps.epicDir,
128
+ digest,
129
+ at: deps.now
130
+ });
131
+ const applies = validated.proposal.stories.filter((s) => s.kind === "tasks" || s.kind === "story");
132
+ const summary = [
133
+ `retro of run ${run.runId}: ${validated.proposal.summary.trim()}`,
134
+ ...validated.proposal.stories.map((entry) => ` ${entry.id}: ${entry.kind === "tasks" ? `${entry.tasks?.length ?? 0} task(s)` : entry.kind === "story" ? "edit the story (restart)" : entry.kind === "refine" ? "needs a refine round" : "no change"}`),
135
+ ...(validated.dropped.length === 0
136
+ ? []
137
+ : [` dropped ${validated.dropped.length} item(s) the plan cannot take; see the report`]),
138
+ `report: ${written.report}`,
139
+ ...(validated.proposal.libraryAdvice.length === 0
140
+ ? []
141
+ : [`advice for llm4ts: ${written.library}`]),
142
+ applies.length === 0
143
+ ? "nothing to apply; the report is for reading"
144
+ : `tick '- [x] Approved' in the report, then rerun: llm4ts run epic-stories --repo ${deps.workDir} -- --epic ${deps.epicDir}`
145
+ ];
146
+ for (const line of summary) {
147
+ yield* deps.events.publish(Info.make({ message: line }));
148
+ }
149
+ return { _tag: "Written", paths: written, summary };
150
+ });
@@ -16,6 +16,7 @@ import { structuredAndPublish } from "@llm4ts/flow/Flow";
16
16
  import { EpicBriefInvalid, EpicBriefNotApproved, EpicCheckoutDirty, LandingFailed, RefineRefused } from "@llm4ts/flow/FlowError";
17
17
  import { Info } from "@llm4ts/flow/FlowEvents";
18
18
  import { nodePreflight } from "@llm4ts/flow/NodePreflight";
19
+ import { applyApprovedRetros } from "@llm4ts/flow/Retro";
19
20
  import { statusPaths } from "@llm4ts/flow/GitTool";
20
21
  import { EpicLanded, EpicLandedVersion, landedPath } from "@llm4ts/flow/Landing";
21
22
  import { RefineProposal, assembleRound, countNotPlanned, earlierStories, refineProposalJsonSchema, renderNotPlanned, roundPrefix, roundRepair, runAction } from "@llm4ts/flow/RefineRound";
@@ -1050,7 +1051,7 @@ const withOptionalModel = (config, model) => {
1050
1051
  const withExtraFlags = (config, flags) => Object.keys(flags).length === 0
1051
1052
  ? config
1052
1053
  : CliConnectorConfig.make({ ...config, flags: { ...config.flags, ...flags } });
1053
- const defaultEpic = "Add the retail customer's current account (Conto) with balance and movements, and wire " +
1054
+ export const defaultEpic = "Add the retail customer's current account (Conto) with balance and movements, and wire " +
1054
1055
  "transfers (Bonifico) with beneficiary, review, SCA confirmation, and history.";
1055
1056
  export const runEpicStories = (options) => Effect.gen(function* () {
1056
1057
  const flags = yield* parseEpicArgs(process.argv.slice(2));
@@ -1167,14 +1168,31 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1167
1168
  return;
1168
1169
  }
1169
1170
  const epicBranch = `epic/${plan.epicId}`;
1171
+ // An approved retro of an earlier run (epic-retro) is applied before
1172
+ // any story runs: tasks onto story plans, story edits onto the plan.
1173
+ const retroState = action._tag === "RunRound"
1174
+ ? (rounds.find((round) => round.round === action.round)?.stateDir ?? stateDir)
1175
+ : stateDir;
1176
+ const retroPlan = action._tag === "RunRound"
1177
+ ? rounds.find((round) => round.round === action.round)?.plan
1178
+ : action._tag === "RunPlan"
1179
+ ? plan
1180
+ : undefined;
1181
+ const applied = retroPlan === undefined
1182
+ ? undefined
1183
+ : yield* applyApprovedRetros(files, retroState, retroPlan, events, Date.now());
1170
1184
  // The unit the executor works on: the epic's plan, or one refine round.
1171
1185
  // Same branch, seats, gates and judge; its own plan and state folder.
1172
- let unit = { plan, stateDir, label: `Epic: ${plan.epicId}` };
1186
+ let unit = {
1187
+ plan: action._tag === "RunPlan" && applied !== undefined ? applied.plan : plan,
1188
+ stateDir,
1189
+ label: `Epic: ${plan.epicId}`
1190
+ };
1173
1191
  if (action._tag === "RunRound") {
1174
1192
  const open = rounds.find((round) => round.round === action.round);
1175
1193
  if (open?.plan !== undefined) {
1176
1194
  unit = {
1177
- plan: open.plan,
1195
+ plan: applied?.plan ?? open.plan,
1178
1196
  stateDir: open.stateDir,
1179
1197
  label: `Epic: ${plan.epicId} · round ${open.round}`
1180
1198
  };
@@ -1354,5 +1372,25 @@ export const runEpicStories = (options) => Effect.gen(function* () {
1354
1372
  yield* events.publish(Info.make({
1355
1373
  message: `${unit.label.replace("Epic: ", "epic ")}: ${report.count("done")} done, ${report.count("failed")} failed, ${report.count("waiting")} waiting — report at ${join(unit.stateDir, "report.md")} (usage figures estimated)`
1356
1374
  }));
1375
+ const hint = retroHint(report, input.workDir, epicId);
1376
+ if (hint !== undefined) {
1377
+ yield* events.publish(Info.make({ message: hint }));
1378
+ }
1357
1379
  }));
1358
1380
  });
1381
+ /**
1382
+ * The one command away from a red run: what to type to have the run's trace
1383
+ * and transcripts read and fixes proposed. `undefined` when every story is done.
1384
+ */
1385
+ export const retroHint = (report, workDir, epicDir) => {
1386
+ const failed = report.count("failed");
1387
+ const waiting = report.count("waiting");
1388
+ if (failed === 0 && waiting === 0) {
1389
+ return undefined;
1390
+ }
1391
+ const what = [
1392
+ ...(failed === 0 ? [] : [`${failed} failed`]),
1393
+ ...(waiting === 0 ? [] : [`${waiting} waiting`])
1394
+ ].join(", ");
1395
+ return `${what}: llm4ts run epic-retro --repo ${workDir} -- --epic ${epicDir} reads this run's trace and transcripts and proposes fixes for the next run`;
1396
+ };
@@ -116,6 +116,10 @@ llm4ts run soap-sample --repo . "propose cercaMovimenti" # scena
116
116
  llm4ts run soap-sample --repo . "call cercaMovimenti" # every request of the operation
117
117
  llm4ts run soap-sample --repo . -- --allow-mutating revocaBonifico "call revocaBonifico/gia-eseguito"
118
118
  llm4ts run soap-sample --repo . list
119
+ llm4ts run soap-sample --repo . "check" # the selected profile as decisions, no network
120
+ llm4ts run soap-sample --repo . "trust call" # capture and pin the chain the endpoint presents
121
+ llm4ts run soap-sample --repo . "sts init" # write the STS request template
122
+ llm4ts run soap-sample --repo . -- --env uat "call all" # with auth.uat.json (or LLM4TS_SOAP_ENV=uat)
119
123
  ```
120
124
 
121
125
  A request is a YAML projection of the XSD, one file per scenario under
@@ -312,6 +316,70 @@ read, and errors name the reference, never its value.
312
316
  Persisted exchanges drop `wsse:Security` headers and credential-bearing HTTP
313
317
  headers (`authorization`, cookies, anything named like a token or secret).
314
318
 
319
+ ## Environments, proxies, trust and STS tokens
320
+
321
+ Locked-down environments differ in where the proxy is, whose CA signed the
322
+ ESB, and which STS issues the token (ADR 0024). One file per environment
323
+ holds all of it: `auth.<env>.json` beside `auth.json`, selected by
324
+ `LLM4TS_SOAP_ENV=uat` or `-- --env uat`; a named environment without a
325
+ file is an error. Every flow prints the file it loaded.
326
+
327
+ ```json
328
+ {
329
+ "environment": "uat",
330
+ "endpoint": "https://esb-uat.bank.internal/soap/DemoBank",
331
+ "call": {
332
+ "proxy": "none",
333
+ "tls": { "ca": "file:.llm4ts/soap/DemoBank/trust/esb-uat.bank.internal.pem" }
334
+ },
335
+ "fetch": {
336
+ "proxy": { "url": "http://proxy.bank.internal:3128", "noProxy": ["*.bank.internal"] }
337
+ },
338
+ "sts": {
339
+ "endpoint": "https://sts-uat.bank.internal/trust/13/usernamemixed",
340
+ "auth": { "user": "env:STS_USER", "password": "env:STS_PASSWORD" },
341
+ "appliesTo": "https://esb-uat.bank.internal/soap/DemoBank",
342
+ "token": { "element": "Assertion" },
343
+ "renewBeforeSeconds": 60,
344
+ "timestamp": true
345
+ }
346
+ }
347
+ ```
348
+
349
+ - **Proxies** are explicit per side (`fetch`, `call`, `sts`): `{ "url",
350
+ "noProxy": ["host", "*.suffix"], "auth": {basic, references} }` or
351
+ `"none"` (the default). The transport opens an HTTP CONNECT tunnel; the
352
+ proxy environment variables are never read, and `check` lists them as
353
+ set-and-ignored. A refused tunnel is a `proxy` transport error.
354
+ - **CA material** adds to Node's system roots: `tls.ca` (a PEM file) and
355
+ `tls.caDir` (a directory of `.pem`/`.crt`). `tls.trust: "ca-only"` trusts
356
+ only the profile's. `tls.servername` names the host behind a front door.
357
+ Verification is never switched off.
358
+ - **`trust call|fetch|sts`** connects to that side's endpoint without
359
+ verifying, sends nothing, prints every certificate of the chain (subject,
360
+ issuer, validity, SHA-256 fingerprint) and whether the system already
361
+ trusts it, asks `trust this chain? [y/N]` (`-- --yes` headless), and on
362
+ yes writes `trust/<host>.pem` (gitignored) and sets that side's `tls.ca`
363
+ in the selected profile. A changed certificate is shown as a replacement
364
+ and asked again. A `tls` error that says "capture it with trust" is this
365
+ case.
366
+ - **STS**: with an `sts` section every `call` first obtains a SAML bearer
367
+ token. `sts init` writes `sts.request.xml`, a WS-Trust 1.3 Issue envelope
368
+ with `{{username}}`, `{{password}}`, `{{created}}`, `{{expires}}`,
369
+ `{{nonce}}`, `{{appliesTo}}`; edit it when the STS differs, the kit only
370
+ fills the placeholders. The first element named `token.element` (default
371
+ `Assertion`, any namespace) is taken from the response as its raw bytes,
372
+ so a signed assertion survives, and sent inside `wsse:Security` after the
373
+ `wsu:Timestamp` (`timestamp: true`) and the UsernameToken when
374
+ `wsSecurity` is also set. The token lives in memory for the run and is
375
+ renewed `renewBeforeSeconds` before its `NotOnOrAfter`; it is never
376
+ written anywhere. `StsError` names the fault, the status or the element
377
+ it looked for.
378
+ - **`check`** prints the selected file, each side's endpoint, proxy decision
379
+ and CA sources, the STS and its template's placeholders, the pinned
380
+ chains, and the ignored proxy variables. It makes no connection: run it
381
+ first, then `trust`, then `sts init`, then `call`.
382
+
315
383
  ## Masking
316
384
 
317
385
  Every SOAP sample is masked before it is written anywhere or shown to a
@@ -59,6 +59,21 @@ and set `LLM4TS_SOAP_SERVICE`.
59
59
  }
60
60
  ```
61
61
 
62
+ Per environment, `auth.test.json` / `auth.uat.json` next to it, picked by
63
+ `LLM4TS_SOAP_ENV` or `-- --env`. In a locked-down network run, in order:
64
+
65
+ ```bash
66
+ llm4ts run soap-sample --repo . "check" # the profile as decisions, no network
67
+ llm4ts run soap-sample --repo . "trust call" # pin the ESB's chain after reading its fingerprint
68
+ llm4ts run soap-sample --repo . "trust sts" # and the STS's, when the profile names one
69
+ llm4ts run soap-sample --repo . "sts init" # the WS-Trust template; edit it if the STS differs
70
+ ```
71
+
72
+ Proxies are explicit per side in the profile (`proxy`), never read from
73
+ the environment; CA PEMs add to the system roots; the SAML token the STS
74
+ issues is carried byte for byte and never persisted (README, "Environments,
75
+ proxies, trust and STS tokens").
76
+
62
77
  ## 3. Samples
63
78
 
64
79
  ```bash
@@ -1,4 +1,5 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
2
3
  import * as Effect from "effect/Effect";
3
4
  import * as Redacted from "effect/Redacted";
4
5
  import * as Schema from "effect/Schema";
@@ -31,20 +32,75 @@ export class NoAuth extends Schema.Class("NoAuth")({
31
32
  }) {
32
33
  }
33
34
  export const HttpAuth = Schema.Union([NoAuth, BasicAuth, BearerAuth]);
34
- /** Client TLS: PEM cert+key, or a PKCS#12 bundle; an optional CA bundle. */
35
+ /** How the profile's CA material combines with Node's system roots. */
36
+ export const TlsTrust = Schema.Literals(["system+ca", "ca-only"]);
37
+ /**
38
+ * Client TLS: PEM cert+key, or a PKCS#12 bundle; CA material as a PEM file
39
+ * (`ca`) and/or a directory of PEMs (`caDir`), added to the system roots
40
+ * unless `trust` is `ca-only`; `servername` for a host behind a front door.
41
+ */
35
42
  export class TlsConfig extends Schema.Class("TlsConfig")({
36
43
  cert: Schema.optionalKey(SecretRef),
37
44
  key: Schema.optionalKey(SecretRef),
38
45
  pfx: Schema.optionalKey(SecretRef),
39
46
  passphrase: Schema.optionalKey(SecretRef),
40
- ca: Schema.optionalKey(SecretRef)
47
+ ca: Schema.optionalKey(SecretRef),
48
+ caDir: Schema.optionalKey(Schema.String),
49
+ trust: Schema.optionalKey(TlsTrust),
50
+ servername: Schema.optionalKey(Schema.String)
41
51
  }) {
42
52
  }
53
+ const ProxyUrl = Schema.String.check(Schema.isPattern(/^https?:\/\/[^/?#@]+\/?$/i, {
54
+ message: "an http(s) proxy origin without credentials or path; credentials go in auth"
55
+ }));
56
+ /**
57
+ * An explicit HTTP CONNECT proxy for one side, with the hosts that bypass
58
+ * it (`*.suffix` wildcards). Environment proxy variables are never read.
59
+ */
60
+ export class ProxyConfig extends Schema.Class("ProxyConfig")({
61
+ url: ProxyUrl,
62
+ noProxy: Schema.optionalKey(Schema.Array(Schema.String)),
63
+ auth: Schema.optionalKey(BasicAuth)
64
+ }) {
65
+ }
66
+ export const ProxySetting = Schema.Union([Schema.Literal("none"), ProxyConfig]);
43
67
  export class HttpSide extends Schema.Class("HttpSide")({
44
68
  auth: Schema.optionalKey(HttpAuth),
45
- tls: Schema.optionalKey(TlsConfig)
69
+ tls: Schema.optionalKey(TlsConfig),
70
+ proxy: Schema.optionalKey(ProxySetting)
71
+ }) {
72
+ }
73
+ /** The credential the STS request template is filled with. */
74
+ export class StsCredential extends Schema.Class("StsCredential")({
75
+ user: Principal,
76
+ password: SecretRef
77
+ }) {
78
+ }
79
+ /**
80
+ * A SAML bearer token from an STS: the template under the service folder
81
+ * is filled and posted to `endpoint` with this side's auth, TLS and proxy;
82
+ * the first element named `token.element` in the answer is carried, bytes
83
+ * unchanged, in every call's WS-Security header.
84
+ */
85
+ export class StsConfig extends Schema.Class("StsConfig")({
86
+ endpoint: Schema.String.check(Schema.isPattern(/^https?:\/\/[^/?#@]+(?:[/?#][^@]*)?$/i, {
87
+ message: "an http(s) URL without credentials; secrets go in references"
88
+ })),
89
+ soapVersion: Schema.optionalKey(Schema.Literals(["1.1", "1.2"])),
90
+ /** Relative to the service folder; `sts init` writes the default. */
91
+ template: Schema.optionalKey(Schema.String),
92
+ auth: StsCredential,
93
+ appliesTo: Schema.optionalKey(Schema.String),
94
+ token: Schema.optionalKey(Schema.Struct({ element: Schema.optionalKey(Schema.String) })),
95
+ renewBeforeSeconds: Schema.optionalKey(Schema.Int),
96
+ timestamp: Schema.optionalKey(Schema.Boolean),
97
+ tls: Schema.optionalKey(TlsConfig),
98
+ proxy: Schema.optionalKey(ProxySetting),
99
+ timeoutSeconds: Schema.optionalKey(Schema.Int)
46
100
  }) {
47
101
  }
102
+ export const defaultStsTemplate = "sts.request.xml";
103
+ export const defaultTokenElement = "Assertion";
48
104
  export class UsernameToken extends Schema.Class("UsernameToken")({
49
105
  user: Principal,
50
106
  password: SecretRef,
@@ -69,10 +125,14 @@ export class AuthProfile extends Schema.Class("AuthProfile")({
69
125
  /** Calling operations. */
70
126
  call: Schema.optionalKey(HttpSide),
71
127
  wsSecurity: Schema.optionalKey(UsernameToken),
128
+ /** A SAML bearer token from an STS, carried by every call. */
129
+ sts: Schema.optionalKey(StsConfig),
72
130
  mutating: Schema.optionalKey(MutatingPolicies),
73
131
  timeoutSeconds: Schema.optionalKey(Schema.Int)
74
132
  }) {
75
133
  }
134
+ /** `auth.json`, or `auth.<env>.json` when an environment is selected. */
135
+ export const profileFileName = (env) => env === undefined || env.trim() === "" ? "auth.json" : `auth.${env.trim()}.json`;
76
136
  export const defaultMutatingPolicy = (environment) => environment === "dev" ? "confirm" : "flag";
77
137
  export const mutatingPolicy = (profile) => profile.mutating?.[profile.environment] ?? defaultMutatingPolicy(profile.environment);
78
138
  export class AuthProfileError extends Schema.TaggedError()("AuthProfileError", {
@@ -84,11 +144,11 @@ export class AuthProfileError extends Schema.TaggedError()("AuthProfileError", {
84
144
  return `auth profile: ${this.subject}: ${this.detail}`;
85
145
  }
86
146
  }
87
- export const decodeAuthProfile = (text) => Schema.decodeUnknownEffect(Schema.fromJsonString(AuthProfile))(text).pipe(Effect.mapError((error) =>
147
+ export const decodeAuthProfile = (text, file = "auth.json") => Schema.decodeUnknownEffect(Schema.fromJsonString(AuthProfile))(text).pipe(Effect.mapError((error) =>
88
148
  // Schema issues quote the offending input; a mistyped secret could be
89
149
  // in it, so only the paths of the issue are kept.
90
150
  new AuthProfileError({
91
- subject: "auth.json",
151
+ subject: file,
92
152
  detail: `invalid profile (${issuePaths(error.message)})`
93
153
  })));
94
154
  /** Keep only the `at ["a"]["b"]` locations of a schema message, dropping any quoted value. */
@@ -137,27 +197,81 @@ export const resolveTls = (source, tls) => tls === undefined
137
197
  const cert = yield* optionalSecret(source, "cert", tls.cert);
138
198
  const key = yield* optionalSecret(source, "key", tls.key);
139
199
  const pfx = yield* optionalSecret(source, "pfx", tls.pfx);
140
- const ca = yield* optionalSecret(source, "ca", tls.ca);
200
+ const caFile = tls.ca === undefined ? undefined : Redacted.value(yield* resolveSecret(source, tls.ca));
201
+ let caDir;
202
+ if (tls.caDir !== undefined) {
203
+ if (source.readPemDirectory === undefined) {
204
+ return yield* new AuthProfileError({
205
+ subject: "tls.caDir",
206
+ detail: "this source cannot read a directory"
207
+ });
208
+ }
209
+ caDir = yield* source.readPemDirectory(tls.caDir);
210
+ }
211
+ const ca = caFile === undefined && caDir === undefined
212
+ ? {}
213
+ : { ca: Redacted.make(concatPem(caFile, caDir)) };
141
214
  const passphrase = tls.passphrase === undefined
142
215
  ? {}
143
216
  : { passphrase: yield* resolveText(source, tls.passphrase) };
144
- return { ...cert, ...key, ...pfx, ...ca, ...passphrase };
217
+ return {
218
+ ...cert,
219
+ ...key,
220
+ ...pfx,
221
+ ...ca,
222
+ ...passphrase,
223
+ ...(tls.trust === undefined ? {} : { trust: tls.trust }),
224
+ ...(tls.servername === undefined ? {} : { servername: tls.servername })
225
+ };
226
+ });
227
+ const concatPem = (...parts) => {
228
+ const present = parts.filter((part) => part !== undefined);
229
+ const joined = present.map((part) => new TextDecoder().decode(part).trimEnd()).join("\n");
230
+ return new TextEncoder().encode(`${joined}\n`);
231
+ };
232
+ /** Whether a host bypasses the proxy: exact match, or `*.suffix` / `.suffix`. */
233
+ export const bypassesProxy = (host, noProxy) => {
234
+ const lower = host.toLowerCase();
235
+ return noProxy.some((entry) => {
236
+ const pattern = entry.trim().toLowerCase();
237
+ if (pattern === "")
238
+ return false;
239
+ if (pattern === "*")
240
+ return true;
241
+ if (pattern.startsWith("*.") || pattern.startsWith(".")) {
242
+ const suffix = pattern.replace(/^\*/, "");
243
+ return lower.endsWith(suffix) && lower.length > suffix.length;
244
+ }
245
+ return lower === pattern;
246
+ });
247
+ };
248
+ export const resolveProxy = (source, proxy) => proxy === undefined || proxy === "none"
249
+ ? Effect.succeed(undefined)
250
+ : Effect.gen(function* () {
251
+ const authorization = proxy.auth === undefined
252
+ ? {}
253
+ : {
254
+ authorization: basicHeader(yield* resolvePrincipal(source, proxy.auth.user), yield* resolveText(source, proxy.auth.password))
255
+ };
256
+ return { url: proxy.url, noProxy: proxy.noProxy ?? [], ...authorization };
145
257
  });
146
258
  const basicHeader = (user, password) => Redacted.make(`Basic ${Buffer.from(`${user}:${Redacted.value(password)}`, "utf8").toString("base64")}`);
147
259
  export const resolveSide = (source, side) => Effect.gen(function* () {
148
260
  const auth = side?.auth;
149
261
  const tls = yield* resolveTls(source, side?.tls);
262
+ const proxy = yield* resolveProxy(source, side?.proxy);
150
263
  if (auth === undefined || auth.scheme === "none")
151
- return { headers: [], tls };
264
+ return { headers: [], tls, proxy };
152
265
  if (auth.scheme === "basic") {
153
266
  const user = yield* resolvePrincipal(source, auth.user);
154
267
  const password = yield* resolveText(source, auth.password);
155
- return { headers: [["authorization", basicHeader(user, password)]], tls };
268
+ return { headers: [["authorization", basicHeader(user, password)]], tls, proxy };
156
269
  }
157
270
  const token = yield* resolveText(source, auth.token);
158
271
  return {
159
272
  headers: [["authorization", Redacted.make(`Bearer ${Redacted.value(token)}`)]],
160
- tls
273
+ tls,
274
+ proxy
161
275
  };
162
276
  });
163
277
  export const resolveUsernameToken = (source, token) => token === undefined
@@ -175,5 +289,13 @@ export const nodeSecretSource = (environment = process.env) => ({
175
289
  readFile: (path) => Effect.tryPromise({
176
290
  try: async () => new Uint8Array(await readFile(path)),
177
291
  catch: () => new AuthProfileError({ subject: `file:${path}`, detail: "not readable" })
292
+ }),
293
+ readPemDirectory: (path) => Effect.tryPromise({
294
+ try: async () => {
295
+ const names = (await readdir(path)).filter((name) => /\.(pem|crt|cer)$/i.test(name)).sort();
296
+ const parts = await Promise.all(names.map((name) => readFile(join(path, name))));
297
+ return concatPem(...parts.map((part) => new Uint8Array(part)));
298
+ },
299
+ catch: () => new AuthProfileError({ subject: `tls.caDir ${path}`, detail: "not readable" })
178
300
  })
179
301
  });
@@ -7,7 +7,7 @@ import { resolveSide, resolveUsernameToken } from "./Auth.js";
7
7
  import { checkCall } from "./CallPolicy.js";
8
8
  import { elementByName, operationByName } from "./Catalog.js";
9
9
  import { effectiveClass } from "./Classification.js";
10
- import { buildEnvelope, readEnvelope, safeHeaders, soapHeaders, stripSecurity, usernameTokenHeader } from "./Envelope.js";
10
+ import { buildEnvelope, readEnvelope, safeHeaders, soapHeaders, securityHeader, stripSecurity } from "./Envelope.js";
11
11
  import { instanceFromXml, instanceToXml, validateInstance } from "./Instance.js";
12
12
  import { kindsFromCatalog, maskDocument, MaskingReport, mergeReports } from "./Masking.js";
13
13
  import { Exchange, issueRecords, loadMaskingKey, loadMaskingOverrides, readRequestFile, RecordedMessage, RecordedResponse, samplePaths, writeExchange } from "./Samples.js";
@@ -92,12 +92,23 @@ export const callOperation = (options) => Effect.gen(function* () {
92
92
  });
93
93
  }
94
94
  const side = yield* resolveSide(options.secrets, profile.call);
95
- const token = yield* resolveUsernameToken(options.secrets, profile.wsSecurity);
95
+ const usernameToken = yield* resolveUsernameToken(options.secrets, profile.wsSecurity);
96
96
  const now = options.now ?? (() => new Date());
97
97
  const random = options.random ?? ((size) => new Uint8Array(randomBytes(size)));
98
- const header = token === undefined
99
- ? undefined
100
- : usernameTokenHeader(token, { nonce: random(16), created: now().toISOString() });
98
+ if (profile.sts !== undefined && options.token === undefined) {
99
+ return yield* new CallSetupError({
100
+ operation: operation.name,
101
+ detail: "the profile names an STS but the call has no token source"
102
+ });
103
+ }
104
+ const saml = options.token === undefined ? undefined : yield* options.token.current;
105
+ const created = now().toISOString();
106
+ const header = securityHeader({
107
+ ...(usernameToken === undefined ? {} : { usernameToken }),
108
+ ...(saml === undefined ? {} : { token: saml.token }),
109
+ ...(profile.sts?.timestamp === false || saml === undefined ? {} : { timestamp: { created } }),
110
+ material: { nonce: random(16), created }
111
+ });
101
112
  const bodyXml = instanceToXml(catalog, input, request.body);
102
113
  const envelope = buildEnvelope(operation.soapVersion, bodyXml, header);
103
114
  const headers = soapHeaders(operation.soapVersion, operation.soapAction);
@@ -108,6 +119,7 @@ export const callOperation = (options) => Effect.gen(function* () {
108
119
  secretHeaders: side.headers,
109
120
  body: envelope,
110
121
  ...(side.tls === undefined ? {} : { tls: side.tls }),
122
+ ...(side.proxy === undefined ? {} : { proxy: side.proxy }),
111
123
  timeout: Duration.seconds(profile.timeoutSeconds ?? 60)
112
124
  });
113
125
  const key = yield* loadMaskingKey(workspace, service, random);