@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.
- package/flows/epic-retro.js +66 -0
- package/flows/lib/epic-retro.js +150 -0
- package/flows/lib/epic-stories.js +41 -3
- package/kits/soap-ace/README.md +68 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +15 -0
- package/kits/soap-ace/flows/lib/soap/Auth.js +133 -11
- package/kits/soap-ace/flows/lib/soap/Call.js +17 -5
- package/kits/soap-ace/flows/lib/soap/Discover.js +31 -3
- package/kits/soap-ace/flows/lib/soap/Envelope.js +29 -4
- package/kits/soap-ace/flows/lib/soap/Explore.js +1 -0
- package/kits/soap-ace/flows/lib/soap/Setup.js +202 -0
- package/kits/soap-ace/flows/lib/soap/Sts.js +216 -0
- package/kits/soap-ace/flows/lib/soap/Stub.js +8 -1
- package/kits/soap-ace/flows/lib/soap/Transport.js +245 -75
- package/kits/soap-ace/flows/lib/soap/Xml.js +4 -2
- package/kits/soap-ace/flows/soap-discover.js +1 -1
- package/kits/soap-ace/flows/soap-explore.js +20 -3
- package/kits/soap-ace/flows/soap-sample.js +143 -12
- package/package.json +4 -4
|
@@ -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 = {
|
|
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
|
+
};
|
package/kits/soap-ace/README.md
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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);
|