toolcraft 0.0.99 → 0.0.101
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/composition.json +6 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +85 -18
- package/dist/composition.json +6 -1
- package/dist/human-in-loop/approval-tasks.d.ts +3 -0
- package/dist/human-in-loop/approval-tasks.js +30 -19
- package/dist/human-in-loop/config.js +3 -0
- package/dist/human-in-loop/gate.js +16 -2
- package/dist/human-in-loop/plan-hash.d.ts +13 -0
- package/dist/human-in-loop/plan-hash.js +73 -0
- package/dist/human-in-loop/runner.js +43 -2
- package/dist/human-in-loop/types.d.ts +5 -0
- package/node_modules/toolcraft-schema/LICENSE +21 -0
- package/node_modules/toolcraft-schema/README.md +89 -0
- package/node_modules/toolcraft-schema/dist/index.compile-check.d.ts +1 -0
- package/node_modules/toolcraft-schema/dist/index.compile-check.js +17 -0
- package/node_modules/toolcraft-schema/dist/index.d.ts +182 -0
- package/node_modules/toolcraft-schema/dist/index.js +294 -0
- package/node_modules/toolcraft-schema/dist/json-schema-document.d.ts +14 -0
- package/node_modules/toolcraft-schema/dist/json-schema-document.js +17 -0
- package/node_modules/toolcraft-schema/dist/json.compile-check.d.ts +1 -0
- package/node_modules/toolcraft-schema/dist/json.compile-check.js +2 -0
- package/node_modules/toolcraft-schema/dist/json.d.ts +10 -0
- package/node_modules/toolcraft-schema/dist/json.js +5 -0
- package/node_modules/toolcraft-schema/dist/oneof.compile-check.d.ts +1 -0
- package/node_modules/toolcraft-schema/dist/oneof.compile-check.js +12 -0
- package/node_modules/toolcraft-schema/dist/oneof.d.ts +15 -0
- package/node_modules/toolcraft-schema/dist/oneof.js +18 -0
- package/node_modules/toolcraft-schema/dist/record.compile-check.d.ts +1 -0
- package/node_modules/toolcraft-schema/dist/record.compile-check.js +2 -0
- package/node_modules/toolcraft-schema/dist/record.d.ts +5 -0
- package/node_modules/toolcraft-schema/dist/record.js +6 -0
- package/node_modules/toolcraft-schema/dist/union.compile-check.d.ts +1 -0
- package/node_modules/toolcraft-schema/dist/union.compile-check.js +9 -0
- package/node_modules/toolcraft-schema/dist/union.d.ts +8 -0
- package/node_modules/toolcraft-schema/dist/union.js +45 -0
- package/node_modules/toolcraft-schema/dist/validate.d.ts +16 -0
- package/node_modules/toolcraft-schema/dist/validate.js +379 -0
- package/node_modules/toolcraft-schema/package.json +32 -0
- package/package.json +5 -4
package/composition.json
CHANGED
|
@@ -48,13 +48,18 @@
|
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"name": "toolcraft",
|
|
51
|
-
"version": "0.0.
|
|
51
|
+
"version": "0.0.101",
|
|
52
52
|
"license": "MIT"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"name": "toolcraft-design",
|
|
56
56
|
"version": "0.0.2",
|
|
57
57
|
"license": "MIT"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "toolcraft-schema",
|
|
61
|
+
"version": "0.0.101",
|
|
62
|
+
"license": "MIT"
|
|
58
63
|
}
|
|
59
64
|
]
|
|
60
65
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface RunCLIOptions<TServices extends object = Record<string, unknown
|
|
|
27
27
|
logLevel?: LogLevel;
|
|
28
28
|
logger?: RuntimeLoggerInput;
|
|
29
29
|
outputEmitter?: (entry: string) => void;
|
|
30
|
+
promptInput?: NodeJS.ReadableStream;
|
|
31
|
+
promptOutput?: NodeJS.WritableStream;
|
|
30
32
|
projectRoot?: string;
|
|
31
33
|
rootDisplayName?: string;
|
|
32
34
|
rootUsageName?: string;
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@ import "./node-require-shim.js";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Command as CommanderCommand, CommanderError, InvalidArgumentError, Option } from "commander";
|
|
5
|
+
import { validate as validateSchema } from "toolcraft-schema";
|
|
5
6
|
import { cancel, configureTheme, confirm, createLogger, formatCommandList, formatOptionList, getTheme, helpFormatterPlain, isCancel, note, promptText, renderTable, resetOutputFormatCache, select, text } from "toolcraft-design";
|
|
6
7
|
import { ApprovalDeclinedError, UserError, assertCommandRequirements, getCommandSourcePath, hasMcpProxyConfig, resolveCommandSecrets } from "./index.js";
|
|
7
8
|
import { hasOwnErrorCode } from "./error-codes.js";
|
|
@@ -1761,7 +1762,18 @@ function enumOptionLabel(schema, value) {
|
|
|
1761
1762
|
}
|
|
1762
1763
|
return schema.labels[key] ?? key;
|
|
1763
1764
|
}
|
|
1764
|
-
|
|
1765
|
+
function withPromptStreams(options, streams) {
|
|
1766
|
+
return {
|
|
1767
|
+
...options,
|
|
1768
|
+
...(streams.input === undefined ? {} : { input: streams.input }),
|
|
1769
|
+
...(streams.output === undefined ? {} : { output: streams.output })
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
function throwPromptCancellation() {
|
|
1773
|
+
cancel("Operation cancelled.");
|
|
1774
|
+
throw new UserError("Operation cancelled.");
|
|
1775
|
+
}
|
|
1776
|
+
async function promptForField(field, streams = {}) {
|
|
1765
1777
|
const schema = field.schema;
|
|
1766
1778
|
if (schema.kind === "enum") {
|
|
1767
1779
|
const options = schema.loadOptions
|
|
@@ -1770,37 +1782,34 @@ async function promptForField(field) {
|
|
|
1770
1782
|
label: enumOptionLabel(schema, value),
|
|
1771
1783
|
value
|
|
1772
1784
|
}));
|
|
1773
|
-
const selected = await select({
|
|
1785
|
+
const selected = await select(withPromptStreams({
|
|
1774
1786
|
message: field.description ?? fieldPromptLabel(field),
|
|
1775
1787
|
options,
|
|
1776
1788
|
initialValue: field.hasDefault ? field.defaultValue : undefined
|
|
1777
|
-
});
|
|
1789
|
+
}, streams));
|
|
1778
1790
|
if (isCancel(selected)) {
|
|
1779
|
-
|
|
1780
|
-
throw new UserError("Operation cancelled.");
|
|
1791
|
+
throwPromptCancellation();
|
|
1781
1792
|
}
|
|
1782
1793
|
return selected;
|
|
1783
1794
|
}
|
|
1784
1795
|
if (field.schema.kind === "boolean") {
|
|
1785
|
-
const selected = await confirm({
|
|
1796
|
+
const selected = await confirm(withPromptStreams({
|
|
1786
1797
|
message: fieldPromptLabel(field),
|
|
1787
1798
|
initialValue: field.hasDefault ? Boolean(field.defaultValue) : undefined
|
|
1788
|
-
});
|
|
1799
|
+
}, streams));
|
|
1789
1800
|
if (isCancel(selected)) {
|
|
1790
|
-
|
|
1791
|
-
throw new UserError("Operation cancelled.");
|
|
1801
|
+
throwPromptCancellation();
|
|
1792
1802
|
}
|
|
1793
1803
|
return selected;
|
|
1794
1804
|
}
|
|
1795
|
-
const entered = await promptText({
|
|
1805
|
+
const entered = await promptText(withPromptStreams({
|
|
1796
1806
|
message: fieldPromptLabel(field),
|
|
1797
1807
|
initialValue: field.hasDefault && field.defaultValue !== undefined
|
|
1798
1808
|
? formatResolvedValue(field.defaultValue)
|
|
1799
1809
|
: undefined
|
|
1800
|
-
});
|
|
1810
|
+
}, streams));
|
|
1801
1811
|
if (isCancel(entered)) {
|
|
1802
|
-
|
|
1803
|
-
throw new UserError("Operation cancelled.");
|
|
1812
|
+
throwPromptCancellation();
|
|
1804
1813
|
}
|
|
1805
1814
|
if (typeof entered !== "string") {
|
|
1806
1815
|
throw new UserError(`Missing required parameter "${field.displayPath}".`);
|
|
@@ -2825,7 +2834,7 @@ async function enforceVariantConstraints(params, fields, dynamicFields, variants
|
|
|
2825
2834
|
}
|
|
2826
2835
|
}
|
|
2827
2836
|
}
|
|
2828
|
-
async function resolveParams(fields, dynamicFields, variants, positionalValues, optionValues, rawArgv, casing, presetPath, shouldPrompt) {
|
|
2837
|
+
async function resolveParams(fields, dynamicFields, variants, positionalValues, optionValues, rawArgv, casing, presetPath, shouldPrompt, missingParameterContext, promptStreams) {
|
|
2829
2838
|
const params = {};
|
|
2830
2839
|
const presetValues = typeof presetPath === "string" && presetPath.length > 0
|
|
2831
2840
|
? await loadPresetValues(fields, presetPath)
|
|
@@ -2835,6 +2844,7 @@ async function resolveParams(fields, dynamicFields, variants, positionalValues,
|
|
|
2835
2844
|
const errors = [];
|
|
2836
2845
|
for (const field of fields) {
|
|
2837
2846
|
let value;
|
|
2847
|
+
let resolvedMissing = false;
|
|
2838
2848
|
let source;
|
|
2839
2849
|
if (field.positionalIndex !== undefined) {
|
|
2840
2850
|
const positionalValue = positionalValues[field.positionalIndex];
|
|
@@ -2885,8 +2895,49 @@ async function resolveParams(fields, dynamicFields, variants, positionalValues,
|
|
|
2885
2895
|
}
|
|
2886
2896
|
value = parsed.value;
|
|
2887
2897
|
}
|
|
2898
|
+
if (value === undefined &&
|
|
2899
|
+
field.optional &&
|
|
2900
|
+
missingParameterContext !== undefined &&
|
|
2901
|
+
field.schema.cli?.resolveMissing !== undefined) {
|
|
2902
|
+
const resolution = await field.schema.cli.resolveMissing({
|
|
2903
|
+
...missingParameterContext,
|
|
2904
|
+
params: { ...params }
|
|
2905
|
+
});
|
|
2906
|
+
const choices = resolution?.choices ?? [];
|
|
2907
|
+
if (choices.length === 1) {
|
|
2908
|
+
value = choices[0]?.value;
|
|
2909
|
+
resolvedMissing = true;
|
|
2910
|
+
source = "prompt";
|
|
2911
|
+
}
|
|
2912
|
+
else if (choices.length > 1) {
|
|
2913
|
+
const selected = await select(withPromptStreams({
|
|
2914
|
+
message: resolution?.message ?? field.description ?? fieldPromptLabel(field),
|
|
2915
|
+
options: choices.map((choice) => ({
|
|
2916
|
+
label: choice.label,
|
|
2917
|
+
value: choice.value
|
|
2918
|
+
}))
|
|
2919
|
+
}, promptStreams));
|
|
2920
|
+
if (isCancel(selected)) {
|
|
2921
|
+
throwPromptCancellation();
|
|
2922
|
+
}
|
|
2923
|
+
value = selected;
|
|
2924
|
+
resolvedMissing = true;
|
|
2925
|
+
source = "prompt";
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
if (resolvedMissing) {
|
|
2929
|
+
const validation = validateSchema(field.schema, value);
|
|
2930
|
+
if (!validation.ok) {
|
|
2931
|
+
errors.push(...validation.issues.map((issue) => ({
|
|
2932
|
+
path: field.displayPath,
|
|
2933
|
+
message: issue.message
|
|
2934
|
+
})));
|
|
2935
|
+
continue;
|
|
2936
|
+
}
|
|
2937
|
+
value = validation.value;
|
|
2938
|
+
}
|
|
2888
2939
|
if (value === undefined && shouldPrompt && !field.optional) {
|
|
2889
|
-
value = await promptForField(field);
|
|
2940
|
+
value = await promptForField(field, promptStreams);
|
|
2890
2941
|
source = "prompt";
|
|
2891
2942
|
}
|
|
2892
2943
|
if (value === undefined && field.hasDefault) {
|
|
@@ -2942,7 +2993,7 @@ function getResolvedFlags(command) {
|
|
|
2942
2993
|
const flags = command.optsWithGlobals();
|
|
2943
2994
|
return flags;
|
|
2944
2995
|
}
|
|
2945
|
-
async function executeCommand(state, services, requirementOptions, runtimeFetch, runtimeOptions, runtimeEnv, runtimeFs, outputEmitter, diagnosticsOptions, onErrorReportContext) {
|
|
2996
|
+
async function executeCommand(state, services, requirementOptions, runtimeFetch, runtimeOptions, runtimeEnv, runtimeFs, outputEmitter, promptStreams, diagnosticsOptions, onErrorReportContext) {
|
|
2946
2997
|
const logger = createLogger(outputEmitter);
|
|
2947
2998
|
const primitives = {
|
|
2948
2999
|
logger,
|
|
@@ -2960,7 +3011,20 @@ async function executeCommand(state, services, requirementOptions, runtimeFetch,
|
|
|
2960
3011
|
: diagnosticsOptions.logLevel),
|
|
2961
3012
|
logger: diagnosticsOptions.logger ?? writeCLIDiagnosticEvent
|
|
2962
3013
|
});
|
|
2963
|
-
const
|
|
3014
|
+
const promptInput = promptStreams.input ?? process.stdin;
|
|
3015
|
+
const promptOutput = promptStreams.output ?? process.stdout;
|
|
3016
|
+
const stdinTTY = Boolean(promptInput.isTTY);
|
|
3017
|
+
const stdoutTTY = Boolean(promptOutput.isTTY);
|
|
3018
|
+
const shouldPrompt = !resolvedFlags.yes && stdinTTY;
|
|
3019
|
+
const missingParameterContext = !resolvedFlags.yes && output === "rich" && stdinTTY && stdoutTTY
|
|
3020
|
+
? {
|
|
3021
|
+
commandPath: state.commandPath,
|
|
3022
|
+
params: {},
|
|
3023
|
+
output,
|
|
3024
|
+
stdinTTY,
|
|
3025
|
+
stdoutTTY
|
|
3026
|
+
}
|
|
3027
|
+
: undefined;
|
|
2964
3028
|
const runtime = await resolveFixtureRuntime(state.command, services, requirementOptions, runtimeFetch, runtimeEnv, runtimeFs);
|
|
2965
3029
|
const preflightContext = {
|
|
2966
3030
|
...runtime.services,
|
|
@@ -2979,7 +3043,7 @@ async function executeCommand(state, services, requirementOptions, runtimeFetch,
|
|
|
2979
3043
|
try {
|
|
2980
3044
|
await withOutputFormat(output, async () => {
|
|
2981
3045
|
await assertCommandRequirements(state.command, preflightContext, runtime.requirementOptions);
|
|
2982
|
-
const params = await resolveParams(state.fields, state.dynamicFields, state.variants, state.positionalValues, optionValues, state.rawArgv, state.casing, state.presetsEnabled ? resolvedFlags.preset : undefined, shouldPrompt);
|
|
3046
|
+
const params = await resolveParams(state.fields, state.dynamicFields, state.variants, state.positionalValues, optionValues, state.rawArgv, state.casing, state.presetsEnabled ? resolvedFlags.preset : undefined, shouldPrompt, missingParameterContext, promptStreams);
|
|
2983
3047
|
resolvedParams = params;
|
|
2984
3048
|
runtimeSecrets = runtime.secrets;
|
|
2985
3049
|
const context = {
|
|
@@ -3607,6 +3671,9 @@ export async function runCLI(roots, options = {}) {
|
|
|
3607
3671
|
lastActionCommand = state.actionCommand;
|
|
3608
3672
|
resolvedCommandPath = formatCliCommandPath(state.commandPath);
|
|
3609
3673
|
await executeCommand(state, servicesWithBuiltIns, requirementOptions, runtimeFetch, runtimeOptions, options.env, options.fs, options.outputEmitter, {
|
|
3674
|
+
input: options.promptInput,
|
|
3675
|
+
output: options.promptOutput
|
|
3676
|
+
}, {
|
|
3610
3677
|
logLevel: options.logLevel,
|
|
3611
3678
|
logger: options.logger,
|
|
3612
3679
|
verboseControlEnabled: controls.verbose
|
package/dist/composition.json
CHANGED
|
@@ -48,13 +48,18 @@
|
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"name": "toolcraft",
|
|
51
|
-
"version": "0.0.
|
|
51
|
+
"version": "0.0.101",
|
|
52
52
|
"license": "MIT"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"name": "toolcraft-design",
|
|
56
56
|
"version": "0.0.2",
|
|
57
57
|
"license": "MIT"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "toolcraft-schema",
|
|
61
|
+
"version": "0.0.101",
|
|
62
|
+
"license": "MIT"
|
|
58
63
|
}
|
|
59
64
|
]
|
|
60
65
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { OpenTaskListOptions, TaskList, Tasks } from "@poe-code/task-list";
|
|
2
2
|
import type { HumanInLoopPending, HumanInLoopRuntimeOptions } from "./types.js";
|
|
3
|
+
import { type ApprovalPlanValue } from "./plan-hash.js";
|
|
3
4
|
export interface ApprovalPayload {
|
|
4
5
|
approvalId?: string;
|
|
5
6
|
commandPath: string;
|
|
6
7
|
params: Record<string, unknown>;
|
|
7
8
|
message: string;
|
|
8
9
|
declineInputPrompt?: string | null;
|
|
10
|
+
plan?: ApprovalPlanValue;
|
|
11
|
+
planHash?: string;
|
|
9
12
|
enqueuedAt?: string;
|
|
10
13
|
pid?: number | null;
|
|
11
14
|
result?: unknown;
|
|
@@ -2,6 +2,7 @@ import { TaskAlreadyExistsError, TaskNotFoundError, openTaskList } from "@poe-co
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { UserError } from "../user-error.js";
|
|
4
4
|
import { approvalStateMachine } from "./state-machine.js";
|
|
5
|
+
import { isApprovalPlanValue } from "./plan-hash.js";
|
|
5
6
|
const DEFAULT_LIST_NAME = "approvals";
|
|
6
7
|
const openedTaskListsByRuntime = new WeakMap();
|
|
7
8
|
const validatedListsByRuntime = new WeakMap();
|
|
@@ -84,28 +85,36 @@ function isListValidated(runtimeOptions, listName) {
|
|
|
84
85
|
}
|
|
85
86
|
function createApprovalRecord(payload, enqueuedAt) {
|
|
86
87
|
const approvalId = `${enqueuedAt.slice(0, 19).replaceAll(":", "-")}-${randomBytes(3).toString("hex")}`;
|
|
87
|
-
|
|
88
|
+
const metadata = {
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
approvalId,
|
|
91
|
+
commandPath: payload.commandPath,
|
|
92
|
+
params: payload.params,
|
|
93
|
+
message: payload.message,
|
|
94
|
+
declineInputPrompt: payload.declineInputPrompt ?? null,
|
|
95
|
+
enqueuedAt,
|
|
96
|
+
pid: null,
|
|
97
|
+
result: null,
|
|
98
|
+
error: null
|
|
99
|
+
};
|
|
100
|
+
const pending = {
|
|
101
|
+
status: "pending-approval",
|
|
102
|
+
approvalId,
|
|
103
|
+
message: payload.message,
|
|
104
|
+
enqueuedAt
|
|
105
|
+
};
|
|
106
|
+
const approval = {
|
|
88
107
|
approvalId,
|
|
89
108
|
name: `${payload.commandPath} (${enqueuedAt})`,
|
|
90
|
-
metadata
|
|
91
|
-
|
|
92
|
-
approvalId,
|
|
93
|
-
commandPath: payload.commandPath,
|
|
94
|
-
params: payload.params,
|
|
95
|
-
message: payload.message,
|
|
96
|
-
declineInputPrompt: payload.declineInputPrompt ?? null,
|
|
97
|
-
enqueuedAt,
|
|
98
|
-
pid: null,
|
|
99
|
-
result: null,
|
|
100
|
-
error: null
|
|
101
|
-
},
|
|
102
|
-
pending: {
|
|
103
|
-
status: "pending-approval",
|
|
104
|
-
approvalId,
|
|
105
|
-
message: payload.message,
|
|
106
|
-
enqueuedAt
|
|
107
|
-
}
|
|
109
|
+
metadata,
|
|
110
|
+
pending
|
|
108
111
|
};
|
|
112
|
+
if (payload.plan !== undefined && payload.planHash !== undefined) {
|
|
113
|
+
approval.metadata.plan = payload.plan;
|
|
114
|
+
approval.metadata.planHash = payload.planHash;
|
|
115
|
+
approval.pending.planHash = payload.planHash;
|
|
116
|
+
}
|
|
117
|
+
return approval;
|
|
109
118
|
}
|
|
110
119
|
async function createApprovalTask(tasks, approval) {
|
|
111
120
|
await tasks.create({
|
|
@@ -198,6 +207,8 @@ function approvalPayloadFromTask(task) {
|
|
|
198
207
|
declineInputPrompt: typeof metadata.declineInputPrompt === "string" || metadata.declineInputPrompt === null
|
|
199
208
|
? metadata.declineInputPrompt
|
|
200
209
|
: undefined,
|
|
210
|
+
plan: isApprovalPlanValue(metadata.plan) ? metadata.plan : undefined,
|
|
211
|
+
planHash: typeof metadata.planHash === "string" ? metadata.planHash : undefined,
|
|
201
212
|
enqueuedAt: metadata.enqueuedAt,
|
|
202
213
|
pid: typeof metadata.pid === "number" || metadata.pid === null ? metadata.pid : undefined,
|
|
203
214
|
result: metadata.result,
|
|
@@ -12,6 +12,9 @@ export function validateHumanInLoopOnDefine(config) {
|
|
|
12
12
|
if (typeof config.humanInLoop.message !== "function") {
|
|
13
13
|
throw new Error(`${label} '${config.name}': humanInLoop.message must be a function`);
|
|
14
14
|
}
|
|
15
|
+
if (config.humanInLoop.plan !== undefined && typeof config.humanInLoop.plan !== "function") {
|
|
16
|
+
throw new Error(`${label} '${config.name}': humanInLoop.plan must be a function`);
|
|
17
|
+
}
|
|
15
18
|
}
|
|
16
19
|
export function mergeHumanInLoopFromGroup(groupHumanInLoop, childHumanInLoop) {
|
|
17
20
|
if (childHumanInLoop !== undefined) {
|
|
@@ -2,6 +2,7 @@ import { enqueueApproval, ensureApprovalList } from "./approval-tasks.js";
|
|
|
2
2
|
import { defaultProviderForPlatform } from "./default-provider.js";
|
|
3
3
|
import { spawnApprovalRunner } from "./spawn.js";
|
|
4
4
|
import { ApprovalDeclinedError } from "./types.js";
|
|
5
|
+
import { assertApprovalPlanHash, createApprovalPlan, formatApprovalMessage } from "./plan-hash.js";
|
|
5
6
|
const providersByRuntime = new WeakMap();
|
|
6
7
|
let providerWithoutRuntime;
|
|
7
8
|
export function resolveProvider(runtimeOptions) {
|
|
@@ -24,10 +25,17 @@ export async function invokeWithHumanInLoop(node, ctx, runtimeOptions, commandPa
|
|
|
24
25
|
if (!node.humanInLoop) {
|
|
25
26
|
return node.handler(ctx);
|
|
26
27
|
}
|
|
27
|
-
const
|
|
28
|
+
const planContext = {
|
|
28
29
|
params: ctx.params,
|
|
29
30
|
commandPath
|
|
30
|
-
}
|
|
31
|
+
};
|
|
32
|
+
const baseMessage = node.humanInLoop.message(planContext);
|
|
33
|
+
const approvalPlan = node.humanInLoop.plan === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: createApprovalPlan(await node.humanInLoop.plan(planContext));
|
|
36
|
+
const message = approvalPlan === undefined
|
|
37
|
+
? baseMessage
|
|
38
|
+
: formatApprovalMessage(baseMessage, approvalPlan);
|
|
31
39
|
if (node.humanInLoop.mode === "async") {
|
|
32
40
|
const { tasks } = await ensureApprovalList(runtimeOptions);
|
|
33
41
|
const { approvalId, pending } = await (options.enqueueApproval ?? enqueueApproval)({
|
|
@@ -36,6 +44,8 @@ export async function invokeWithHumanInLoop(node, ctx, runtimeOptions, commandPa
|
|
|
36
44
|
commandPath,
|
|
37
45
|
params: ctx.params,
|
|
38
46
|
message,
|
|
47
|
+
plan: approvalPlan?.value,
|
|
48
|
+
planHash: approvalPlan?.hash,
|
|
39
49
|
declineInputPrompt: node.humanInLoop.declineInputPrompt
|
|
40
50
|
}
|
|
41
51
|
});
|
|
@@ -55,5 +65,9 @@ export async function invokeWithHumanInLoop(node, ctx, runtimeOptions, commandPa
|
|
|
55
65
|
commandPath
|
|
56
66
|
});
|
|
57
67
|
}
|
|
68
|
+
if (approvalPlan !== undefined && node.humanInLoop.plan !== undefined) {
|
|
69
|
+
const executionPlan = createApprovalPlan(await node.humanInLoop.plan(planContext));
|
|
70
|
+
assertApprovalPlanHash(approvalPlan.hash, executionPlan.hash);
|
|
71
|
+
}
|
|
58
72
|
return node.handler(ctx);
|
|
59
73
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ApprovalPlanValue = null | boolean | number | string | ApprovalPlanValue[] | {
|
|
2
|
+
[key: string]: ApprovalPlanValue;
|
|
3
|
+
};
|
|
4
|
+
export interface ApprovalPlan {
|
|
5
|
+
value: ApprovalPlanValue;
|
|
6
|
+
canonical: string;
|
|
7
|
+
display: string;
|
|
8
|
+
hash: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function isApprovalPlanValue(value: unknown): value is ApprovalPlanValue;
|
|
11
|
+
export declare function createApprovalPlan(value: unknown): ApprovalPlan;
|
|
12
|
+
export declare function formatApprovalMessage(message: string, plan: ApprovalPlan): string;
|
|
13
|
+
export declare function assertApprovalPlanHash(expectedHash: string, actualHash: string): void;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { UserError } from "../user-error.js";
|
|
3
|
+
export function isApprovalPlanValue(value) {
|
|
4
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
if (typeof value === "number") {
|
|
8
|
+
return Number.isFinite(value);
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
return value.every(isApprovalPlanValue);
|
|
12
|
+
}
|
|
13
|
+
if (typeof value !== "object") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const prototype = Object.getPrototypeOf(value);
|
|
17
|
+
return ((prototype === Object.prototype || prototype === null) &&
|
|
18
|
+
Object.values(value).every(isApprovalPlanValue));
|
|
19
|
+
}
|
|
20
|
+
function normalizePlan(value, seen) {
|
|
21
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === "number") {
|
|
25
|
+
if (!Number.isFinite(value)) {
|
|
26
|
+
throw new UserError("Approval plan numbers must be finite.");
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value !== "object") {
|
|
31
|
+
throw new UserError("Approval plan must contain only JSON values.");
|
|
32
|
+
}
|
|
33
|
+
if (seen.has(value)) {
|
|
34
|
+
throw new UserError("Approval plan must not contain circular references.");
|
|
35
|
+
}
|
|
36
|
+
seen.add(value);
|
|
37
|
+
try {
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return value.map((item) => normalizePlan(item, seen));
|
|
40
|
+
}
|
|
41
|
+
const prototype = Object.getPrototypeOf(value);
|
|
42
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
43
|
+
throw new UserError("Approval plan must contain only JSON values.");
|
|
44
|
+
}
|
|
45
|
+
const normalized = {};
|
|
46
|
+
for (const key of Object.keys(value).sort()) {
|
|
47
|
+
normalized[key] = normalizePlan(value[key], seen);
|
|
48
|
+
}
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
seen.delete(value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function createApprovalPlan(value) {
|
|
56
|
+
const normalized = normalizePlan(value, new Set());
|
|
57
|
+
const canonical = JSON.stringify(normalized);
|
|
58
|
+
const digest = createHash("sha256").update(canonical).digest("hex");
|
|
59
|
+
return {
|
|
60
|
+
value: normalized,
|
|
61
|
+
canonical,
|
|
62
|
+
display: JSON.stringify(normalized, null, 2),
|
|
63
|
+
hash: `sha256:${digest}`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function formatApprovalMessage(message, plan) {
|
|
67
|
+
return `${message}\n\nPlan:\n${plan.display}\n\nPlan hash: ${plan.hash}`;
|
|
68
|
+
}
|
|
69
|
+
export function assertApprovalPlanHash(expectedHash, actualHash) {
|
|
70
|
+
if (expectedHash !== actualHash) {
|
|
71
|
+
throw new UserError(`Approval plan changed after approval. Expected ${expectedHash}, received ${actualHash}.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -4,6 +4,7 @@ import { createEnv, createFs } from "../runtime/io.js";
|
|
|
4
4
|
import { ensureApprovalList } from "./approval-tasks.js";
|
|
5
5
|
import { resolveProvider } from "./gate.js";
|
|
6
6
|
import { createRuntimeLogger } from "../runtime-logging.js";
|
|
7
|
+
import { assertApprovalPlanHash, createApprovalPlan, formatApprovalMessage, isApprovalPlanValue } from "./plan-hash.js";
|
|
7
8
|
const MAX_AVAILABLE_COMMAND_PATHS = 20;
|
|
8
9
|
export async function runApproval(approvalId, runtimeOptions, root) {
|
|
9
10
|
const { tasks } = await ensureApprovalList(runtimeOptions);
|
|
@@ -27,6 +28,7 @@ export async function runApproval(approvalId, runtimeOptions, root) {
|
|
|
27
28
|
throw error;
|
|
28
29
|
}
|
|
29
30
|
try {
|
|
31
|
+
verifyStoredApprovalPlan(approval);
|
|
30
32
|
const approvalResult = await provider.requestApproval({
|
|
31
33
|
message: approval.message,
|
|
32
34
|
declineInputPrompt: approval.declineInputPrompt ?? undefined
|
|
@@ -50,10 +52,32 @@ export async function runApproval(approvalId, runtimeOptions, root) {
|
|
|
50
52
|
});
|
|
51
53
|
return;
|
|
52
54
|
}
|
|
55
|
+
let command;
|
|
56
|
+
let ctx;
|
|
57
|
+
try {
|
|
58
|
+
command = findCommand(root, approval.commandPath);
|
|
59
|
+
ctx = createHandlerContext(command, approval.params);
|
|
60
|
+
if (approval.planHash !== undefined) {
|
|
61
|
+
if (command.humanInLoop?.plan === undefined) {
|
|
62
|
+
throw new UserError("Approval plan can no longer be verified by this command.");
|
|
63
|
+
}
|
|
64
|
+
const executionPlan = createApprovalPlan(await command.humanInLoop.plan({
|
|
65
|
+
params: approval.params,
|
|
66
|
+
commandPath: approval.commandPath
|
|
67
|
+
}));
|
|
68
|
+
assertApprovalPlanHash(approval.planHash, executionPlan.hash);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
await tasks.fire(approvalId, "fail", {
|
|
73
|
+
metadataPatch: {
|
|
74
|
+
error: errorMetadataFromUnknown(error)
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
53
79
|
await tasks.fire(approvalId, "start");
|
|
54
80
|
try {
|
|
55
|
-
const command = findCommand(root, approval.commandPath);
|
|
56
|
-
const ctx = createHandlerContext(command, approval.params);
|
|
57
81
|
const result = await command.handler(ctx);
|
|
58
82
|
const serializedResult = serializeJsonResult(result);
|
|
59
83
|
if (!serializedResult.ok) {
|
|
@@ -80,6 +104,21 @@ export async function runApproval(approvalId, runtimeOptions, root) {
|
|
|
80
104
|
});
|
|
81
105
|
}
|
|
82
106
|
}
|
|
107
|
+
function verifyStoredApprovalPlan(approval) {
|
|
108
|
+
const hasPlan = approval.plan !== undefined;
|
|
109
|
+
const hasPlanHash = approval.planHash !== undefined;
|
|
110
|
+
if (!hasPlan && !hasPlanHash) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (approval.plan === undefined || approval.planHash === undefined) {
|
|
114
|
+
throw new UserError("Malformed approval plan metadata.");
|
|
115
|
+
}
|
|
116
|
+
const storedPlan = createApprovalPlan(approval.plan);
|
|
117
|
+
assertApprovalPlanHash(approval.planHash, storedPlan.hash);
|
|
118
|
+
if (!approval.message.endsWith(formatApprovalMessage("", storedPlan))) {
|
|
119
|
+
throw new UserError("Approval prompt does not match its stored plan hash.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
83
122
|
function readApprovalPayload(task) {
|
|
84
123
|
const metadata = task.metadata;
|
|
85
124
|
if (typeof metadata !== "object" || metadata === null) {
|
|
@@ -103,6 +142,8 @@ function readApprovalPayload(task) {
|
|
|
103
142
|
params: metadata.params,
|
|
104
143
|
message: metadata.message,
|
|
105
144
|
declineInputPrompt,
|
|
145
|
+
plan: isApprovalPlanValue(metadata.plan) ? metadata.plan : undefined,
|
|
146
|
+
planHash: typeof metadata.planHash === "string" ? metadata.planHash : undefined,
|
|
106
147
|
enqueuedAt: typeof metadata.enqueuedAt === "string" ? metadata.enqueuedAt : undefined,
|
|
107
148
|
pid: typeof metadata.pid === "number" || metadata.pid === null ? metadata.pid : undefined,
|
|
108
149
|
result: metadata.result,
|
|
@@ -8,6 +8,10 @@ export interface HumanInLoopConfig<TParamsSchema extends ObjectSchema<any>> {
|
|
|
8
8
|
params: Static<TParamsSchema>;
|
|
9
9
|
commandPath: string;
|
|
10
10
|
}) => string;
|
|
11
|
+
plan?: (ctx: {
|
|
12
|
+
params: Static<TParamsSchema>;
|
|
13
|
+
commandPath: string;
|
|
14
|
+
}) => unknown | Promise<unknown>;
|
|
11
15
|
declineInputPrompt?: string;
|
|
12
16
|
}
|
|
13
17
|
export interface HumanInLoopRuntimeOptions {
|
|
@@ -27,6 +31,7 @@ export interface HumanInLoopPending {
|
|
|
27
31
|
approvalId: string;
|
|
28
32
|
message: string;
|
|
29
33
|
enqueuedAt: string;
|
|
34
|
+
planHash?: string;
|
|
30
35
|
}
|
|
31
36
|
export declare class ApprovalDeclinedError extends UserError {
|
|
32
37
|
readonly reason?: string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Poe Platform
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|