@wichayutdew/pi-workflows 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +752 -0
- package/agents/step.md +17 -0
- package/dist/index.js +4576 -0
- package/examples/mr-comments.workflow.yaml +115 -0
- package/examples/prompts/mr-comments/implement.md +8 -0
- package/examples/prompts/mr-comments/inspect.md +5 -0
- package/examples/prompts/mr-comments/plan.md +13 -0
- package/examples/prompts/mr-comments/verify.md +7 -0
- package/examples/settings.yaml +19 -0
- package/package.json +81 -0
- package/schemas/settings.schema.json +22 -0
- package/schemas/workflow.schema.json +585 -0
- package/src/command-names.ts +46 -0
- package/src/commands.ts +80 -0
- package/src/config/ceiling.ts +153 -0
- package/src/config/command-conflicts.ts +31 -0
- package/src/config/load.ts +327 -0
- package/src/config/types.ts +187 -0
- package/src/config/validate.ts +1145 -0
- package/src/digest.ts +23 -0
- package/src/engine/checkpoint.ts +30 -0
- package/src/engine/resume.ts +44 -0
- package/src/engine/state.ts +186 -0
- package/src/engine/transitions.ts +426 -0
- package/src/harness.ts +1676 -0
- package/src/index.ts +15 -0
- package/src/integrations/plannotator.ts +235 -0
- package/src/integrations/prompt-gate.ts +54 -0
- package/src/integrations/subagents/child-runtime.ts +306 -0
- package/src/integrations/subagents/client.ts +239 -0
- package/src/integrations/subagents/protocol.ts +304 -0
- package/src/policy/approved-commands.ts +225 -0
- package/src/policy/bash.ts +355 -0
- package/src/policy/completion-batch.ts +36 -0
- package/src/policy/immutable-input.ts +18 -0
- package/src/policy/tools.ts +150 -0
- package/src/preflight.ts +76 -0
- package/src/prompt.ts +146 -0
- package/src/runtime/completion-tool.ts +22 -0
- package/src/runtime/main-step-runtime.ts +227 -0
- package/src/runtime/serial-task-queue.ts +17 -0
- package/src/runtime/step-result.ts +85 -0
- package/src/workflow-list.ts +25 -0
- package/src/workflow-status.ts +611 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SUBAGENT_DELEGATION_CANCEL_EVENT,
|
|
3
|
+
SUBAGENT_DELEGATION_PROTOCOL_VERSION,
|
|
4
|
+
SUBAGENT_DELEGATION_REQUEST_EVENT,
|
|
5
|
+
SUBAGENT_DELEGATION_RESPONSE_EVENT,
|
|
6
|
+
SUBAGENT_DELEGATION_STARTED_EVENT,
|
|
7
|
+
SUBAGENT_DELEGATION_UPDATE_EVENT,
|
|
8
|
+
type SubagentDelegationRequest,
|
|
9
|
+
type SubagentDelegationResponse,
|
|
10
|
+
type SubagentDelegationStatus,
|
|
11
|
+
type SubagentDelegationUpdate,
|
|
12
|
+
} from './protocol.ts';
|
|
13
|
+
|
|
14
|
+
export interface SubagentEventBus {
|
|
15
|
+
on(event: string, handler: (data: unknown) => void): (() => void) | void;
|
|
16
|
+
emit(event: string, data: unknown): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DelegateOptions {
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
startTimeoutMs?: number;
|
|
22
|
+
onUpdate?: (update: SubagentDelegationUpdate) => void;
|
|
23
|
+
/**
|
|
24
|
+
* Called when a terminal response arrives after the delegate promise already
|
|
25
|
+
* rejected locally. The child was still potentially alive until this event.
|
|
26
|
+
*/
|
|
27
|
+
onLateTerminal?: (response: SubagentDelegationResponse) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ActiveDelegation {
|
|
31
|
+
requestId: string;
|
|
32
|
+
requestCancellation: () => void;
|
|
33
|
+
terminal: Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DELEGATION_STATUSES = new Set<SubagentDelegationStatus>([
|
|
37
|
+
'completed',
|
|
38
|
+
'failed',
|
|
39
|
+
'timed_out',
|
|
40
|
+
'cancelled',
|
|
41
|
+
'interrupted',
|
|
42
|
+
'turn_budget_exhausted',
|
|
43
|
+
'tool_budget_exhausted',
|
|
44
|
+
'acceptance_failed',
|
|
45
|
+
'invalid_request',
|
|
46
|
+
'unavailable_context',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function requestIdOf(value: unknown): string | undefined {
|
|
50
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
51
|
+
const requestId = (value as { requestId?: unknown }).requestId;
|
|
52
|
+
return typeof requestId === 'string' ? requestId : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseResponse(value: unknown): SubagentDelegationResponse | undefined {
|
|
56
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
57
|
+
const response = value as Partial<SubagentDelegationResponse>;
|
|
58
|
+
if (
|
|
59
|
+
response.version !== SUBAGENT_DELEGATION_PROTOCOL_VERSION ||
|
|
60
|
+
typeof response.requestId !== 'string' ||
|
|
61
|
+
typeof response.status !== 'string' ||
|
|
62
|
+
!DELEGATION_STATUSES.has(response.status as SubagentDelegationStatus)
|
|
63
|
+
) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
return response as SubagentDelegationResponse;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseUpdate(value: unknown): SubagentDelegationUpdate | undefined {
|
|
70
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
71
|
+
const update = value as Partial<SubagentDelegationUpdate>;
|
|
72
|
+
if (
|
|
73
|
+
update.version !== SUBAGENT_DELEGATION_PROTOCOL_VERSION ||
|
|
74
|
+
typeof update.requestId !== 'string'
|
|
75
|
+
) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return update as SubagentDelegationUpdate;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class SubagentDelegationClient {
|
|
82
|
+
private readonly events: SubagentEventBus;
|
|
83
|
+
private active: ActiveDelegation | undefined;
|
|
84
|
+
|
|
85
|
+
constructor(events: SubagentEventBus) {
|
|
86
|
+
this.events = events;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get activeRequestId(): string | undefined {
|
|
90
|
+
return this.active?.requestId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
delegate(
|
|
94
|
+
request: SubagentDelegationRequest,
|
|
95
|
+
options: DelegateOptions = {},
|
|
96
|
+
): Promise<SubagentDelegationResponse> {
|
|
97
|
+
if (this.active) {
|
|
98
|
+
return Promise.reject(
|
|
99
|
+
new Error(
|
|
100
|
+
`subagent request "${this.active.requestId}" is still active`,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (options.signal?.aborted) {
|
|
105
|
+
return Promise.reject(new Error('subagent delegation was cancelled'));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let start: () => void = () => undefined;
|
|
109
|
+
let requestCancellation: () => void = () => undefined;
|
|
110
|
+
let resolveTerminal: () => void = () => undefined;
|
|
111
|
+
const terminal = new Promise<void>((resolve) => {
|
|
112
|
+
resolveTerminal = resolve;
|
|
113
|
+
});
|
|
114
|
+
const delegation = new Promise<SubagentDelegationResponse>(
|
|
115
|
+
(resolve, reject) => {
|
|
116
|
+
let settled = false;
|
|
117
|
+
let cancellationRequested = false;
|
|
118
|
+
const subscriptions: Array<() => void> = [];
|
|
119
|
+
const startTimeoutMs = options.startTimeoutMs ?? 3_000;
|
|
120
|
+
const overallTimeoutMs = (request.timeoutMs ?? 900_000) + 5_000;
|
|
121
|
+
|
|
122
|
+
const subscribe = (
|
|
123
|
+
event: string,
|
|
124
|
+
handler: (data: unknown) => void,
|
|
125
|
+
): void => {
|
|
126
|
+
const unsubscribe = this.events.on(event, handler);
|
|
127
|
+
if (typeof unsubscribe === 'function')
|
|
128
|
+
subscriptions.push(unsubscribe);
|
|
129
|
+
};
|
|
130
|
+
const stopLocalWatchers = (): void => {
|
|
131
|
+
clearTimeout(startTimer);
|
|
132
|
+
clearTimeout(overallTimer);
|
|
133
|
+
options.signal?.removeEventListener('abort', abort);
|
|
134
|
+
};
|
|
135
|
+
const cleanup = (): void => {
|
|
136
|
+
stopLocalWatchers();
|
|
137
|
+
for (const unsubscribe of subscriptions) unsubscribe();
|
|
138
|
+
if (this.active?.requestId === request.requestId)
|
|
139
|
+
this.active = undefined;
|
|
140
|
+
};
|
|
141
|
+
const finish = (
|
|
142
|
+
result: { response: SubagentDelegationResponse } | { error: Error },
|
|
143
|
+
): void => {
|
|
144
|
+
if (settled) return;
|
|
145
|
+
settled = true;
|
|
146
|
+
cleanup();
|
|
147
|
+
if ('response' in result) resolve(result.response);
|
|
148
|
+
else reject(result.error);
|
|
149
|
+
};
|
|
150
|
+
const emitCancel = (): void => {
|
|
151
|
+
if (cancellationRequested || settled) return;
|
|
152
|
+
cancellationRequested = true;
|
|
153
|
+
this.events.emit(SUBAGENT_DELEGATION_CANCEL_EVENT, {
|
|
154
|
+
version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
|
|
155
|
+
requestId: request.requestId,
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
const failAndCancel = (reason: string): void => {
|
|
159
|
+
if (settled) return;
|
|
160
|
+
emitCancel();
|
|
161
|
+
// A local timeout is not proof that the child process terminated. Reject
|
|
162
|
+
// the caller, but retain correlation listeners and active ownership
|
|
163
|
+
// until pi-subagents emits the terminal response.
|
|
164
|
+
if (settled) return;
|
|
165
|
+
settled = true;
|
|
166
|
+
stopLocalWatchers();
|
|
167
|
+
reject(new Error(reason));
|
|
168
|
+
};
|
|
169
|
+
const abort = (): void => {
|
|
170
|
+
failAndCancel('subagent delegation was cancelled');
|
|
171
|
+
};
|
|
172
|
+
requestCancellation = emitCancel;
|
|
173
|
+
|
|
174
|
+
subscribe(SUBAGENT_DELEGATION_STARTED_EVENT, (data) => {
|
|
175
|
+
if (requestIdOf(data) !== request.requestId) return;
|
|
176
|
+
clearTimeout(startTimer);
|
|
177
|
+
});
|
|
178
|
+
subscribe(SUBAGENT_DELEGATION_UPDATE_EVENT, (data) => {
|
|
179
|
+
const update = parseUpdate(data);
|
|
180
|
+
if (!update || update.requestId !== request.requestId) return;
|
|
181
|
+
options.onUpdate?.(update);
|
|
182
|
+
});
|
|
183
|
+
subscribe(SUBAGENT_DELEGATION_RESPONSE_EVENT, (data) => {
|
|
184
|
+
const response = parseResponse(data);
|
|
185
|
+
if (!response || response.requestId !== request.requestId) return;
|
|
186
|
+
resolveTerminal();
|
|
187
|
+
if (settled) {
|
|
188
|
+
cleanup();
|
|
189
|
+
options.onLateTerminal?.(response);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
finish({ response });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const startTimer = setTimeout(() => {
|
|
196
|
+
failAndCancel(
|
|
197
|
+
'pi-subagents did not accept the delegation request; verify it is installed and loaded',
|
|
198
|
+
);
|
|
199
|
+
}, startTimeoutMs);
|
|
200
|
+
const overallTimer = setTimeout(() => {
|
|
201
|
+
failAndCancel(
|
|
202
|
+
'pi-subagents did not settle the delegation request before its deadline',
|
|
203
|
+
);
|
|
204
|
+
}, overallTimeoutMs);
|
|
205
|
+
startTimer.unref?.();
|
|
206
|
+
overallTimer.unref?.();
|
|
207
|
+
|
|
208
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
209
|
+
start = () =>
|
|
210
|
+
this.events.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, request);
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
this.active = {
|
|
215
|
+
requestId: request.requestId,
|
|
216
|
+
requestCancellation,
|
|
217
|
+
terminal,
|
|
218
|
+
};
|
|
219
|
+
start();
|
|
220
|
+
return delegation;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async cancelActiveAndWait(waitMs = 5_000): Promise<boolean> {
|
|
224
|
+
const active = this.active;
|
|
225
|
+
if (!active) return true;
|
|
226
|
+
active.requestCancellation();
|
|
227
|
+
return new Promise<boolean>((resolve) => {
|
|
228
|
+
let finished = false;
|
|
229
|
+
const finish = (confirmed: boolean): void => {
|
|
230
|
+
if (finished) return;
|
|
231
|
+
finished = true;
|
|
232
|
+
clearTimeout(timer);
|
|
233
|
+
resolve(confirmed);
|
|
234
|
+
};
|
|
235
|
+
const timer = setTimeout(() => finish(false), waitMs);
|
|
236
|
+
void active.terminal.then(() => finish(true));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { tmpdir } from 'node:os';
|
|
2
|
+
import { basename, dirname, relative, resolve } from 'node:path';
|
|
3
|
+
import type {
|
|
4
|
+
SubagentDelegationRequest as UpstreamDelegationRequest,
|
|
5
|
+
SubagentDelegationResponse as UpstreamDelegationResponse,
|
|
6
|
+
SubagentDelegationStatus as UpstreamDelegationStatus,
|
|
7
|
+
SubagentDelegationUpdate as UpstreamDelegationUpdate,
|
|
8
|
+
} from 'pi-subagents/delegation';
|
|
9
|
+
import {
|
|
10
|
+
SUBAGENT_RUNTIME_NAME_PATTERN,
|
|
11
|
+
type StepPermissions,
|
|
12
|
+
} from '../../config/types.ts';
|
|
13
|
+
import {
|
|
14
|
+
parseWorkflowStepResult,
|
|
15
|
+
type WorkflowStepResult,
|
|
16
|
+
} from '../../runtime/step-result.ts';
|
|
17
|
+
|
|
18
|
+
// These released v1 transport values are duplicated as literals because
|
|
19
|
+
// pi-subagents 0.35.1 exports TypeScript source. Node's native type stripping
|
|
20
|
+
// cannot execute TypeScript below node_modules; the public types above remain
|
|
21
|
+
// the compile-time compatibility check.
|
|
22
|
+
export const SUBAGENT_DELEGATION_PROTOCOL_VERSION = 1 as const;
|
|
23
|
+
export const SUBAGENT_DELEGATION_REQUEST_EVENT =
|
|
24
|
+
'prompt-template:subagent:request';
|
|
25
|
+
export const SUBAGENT_DELEGATION_STARTED_EVENT =
|
|
26
|
+
'prompt-template:subagent:started';
|
|
27
|
+
export const SUBAGENT_DELEGATION_UPDATE_EVENT =
|
|
28
|
+
'prompt-template:subagent:update';
|
|
29
|
+
export const SUBAGENT_DELEGATION_RESPONSE_EVENT =
|
|
30
|
+
'prompt-template:subagent:response';
|
|
31
|
+
export const SUBAGENT_DELEGATION_CANCEL_EVENT =
|
|
32
|
+
'prompt-template:subagent:cancel';
|
|
33
|
+
|
|
34
|
+
const CHILD_POLICY_OPEN = '<pi-workflows-policy-v1>';
|
|
35
|
+
const CHILD_POLICY_CLOSE = '</pi-workflows-policy-v1>';
|
|
36
|
+
const FORK_TASK_BOUNDARY = '\n\nTask:\n';
|
|
37
|
+
const POLICY_DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
38
|
+
const CAPABILITY_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
|
39
|
+
const RESULT_FILE_NAME = 'result.json';
|
|
40
|
+
const CAPABILITY_FILE_NAME = 'capability';
|
|
41
|
+
const RESULT_DIRECTORY_PREFIX = 'pi-workflows-step-';
|
|
42
|
+
|
|
43
|
+
export type SubagentDelegationRequest = UpstreamDelegationRequest;
|
|
44
|
+
export type SubagentDelegationUpdate = UpstreamDelegationUpdate;
|
|
45
|
+
export type SubagentDelegationStatus = UpstreamDelegationStatus;
|
|
46
|
+
export type SubagentDelegationResponse = UpstreamDelegationResponse;
|
|
47
|
+
|
|
48
|
+
export interface ChildStepPolicy {
|
|
49
|
+
version: 1;
|
|
50
|
+
requestId: string;
|
|
51
|
+
agent: string;
|
|
52
|
+
workflowId: string;
|
|
53
|
+
runId: string;
|
|
54
|
+
stepId: string;
|
|
55
|
+
stepTitle: string;
|
|
56
|
+
policyDigest: string;
|
|
57
|
+
capabilityPath: string;
|
|
58
|
+
capabilityToken: string;
|
|
59
|
+
resultPath: string;
|
|
60
|
+
permissions: StepPermissions;
|
|
61
|
+
/** Exact Bash command strings extracted from a reviewed gate artifact. */
|
|
62
|
+
approvedBashCommands?: string[];
|
|
63
|
+
outcomes: string[];
|
|
64
|
+
summaryMaxChars: number;
|
|
65
|
+
gateSubmitOutcome?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type DelegatedStepResult = WorkflowStepResult;
|
|
69
|
+
|
|
70
|
+
export interface ExtractedChildPolicy {
|
|
71
|
+
policy: ChildStepPolicy;
|
|
72
|
+
task: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
76
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isStringArray(value: unknown): value is string[] {
|
|
80
|
+
return (
|
|
81
|
+
Array.isArray(value) && value.every((item) => typeof item === 'string')
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isStepPermissions(value: unknown): value is StepPermissions {
|
|
86
|
+
if (!isObject(value) || !isObject(value.bash)) return false;
|
|
87
|
+
const bash = value.bash;
|
|
88
|
+
const modeIsValid =
|
|
89
|
+
bash.mode === 'deny' ||
|
|
90
|
+
bash.mode === 'read-only' ||
|
|
91
|
+
bash.mode === 'allow-list' ||
|
|
92
|
+
bash.mode === 'unrestricted';
|
|
93
|
+
const rulesAreValid =
|
|
94
|
+
Array.isArray(bash.allow) &&
|
|
95
|
+
bash.allow.every(
|
|
96
|
+
(rule) =>
|
|
97
|
+
isObject(rule) &&
|
|
98
|
+
typeof rule.executable === 'string' &&
|
|
99
|
+
isStringArray(rule.argsPrefix),
|
|
100
|
+
);
|
|
101
|
+
const approvedSourcesAreValid =
|
|
102
|
+
bash.approvedSources === undefined ||
|
|
103
|
+
(isStringArray(bash.approvedSources) &&
|
|
104
|
+
new Set(bash.approvedSources).size === bash.approvedSources.length &&
|
|
105
|
+
bash.approvedSources.every(
|
|
106
|
+
(source) =>
|
|
107
|
+
source === 'verification-worker' ||
|
|
108
|
+
source === 'verification-reviewer' ||
|
|
109
|
+
source === 'remote-actions',
|
|
110
|
+
));
|
|
111
|
+
const approvalShapeIsValid =
|
|
112
|
+
bash.mode === 'allow-list'
|
|
113
|
+
? (bash.allow as unknown[]).length > 0 ||
|
|
114
|
+
(Array.isArray(bash.approvedSources) && bash.approvedSources.length > 0)
|
|
115
|
+
: bash.approvedSources === undefined;
|
|
116
|
+
return (
|
|
117
|
+
isStringArray(value.tools) &&
|
|
118
|
+
isStringArray(value.mcp) &&
|
|
119
|
+
isStringArray(value.extensions) &&
|
|
120
|
+
isStringArray(value.skills) &&
|
|
121
|
+
modeIsValid &&
|
|
122
|
+
rulesAreValid &&
|
|
123
|
+
approvedSourcesAreValid &&
|
|
124
|
+
approvalShapeIsValid
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isSafeStepFilePath(path: string, expectedName: string): boolean {
|
|
129
|
+
const base = resolve(tmpdir());
|
|
130
|
+
const candidate = resolve(path);
|
|
131
|
+
const fromBase = relative(base, candidate);
|
|
132
|
+
return (
|
|
133
|
+
fromBase !== '' &&
|
|
134
|
+
!fromBase.startsWith('..') &&
|
|
135
|
+
!fromBase.includes('\0') &&
|
|
136
|
+
basename(candidate) === expectedName &&
|
|
137
|
+
basename(dirname(candidate)).startsWith(RESULT_DIRECTORY_PREFIX)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isSafeStepResultPath(path: string): boolean {
|
|
142
|
+
return isSafeStepFilePath(path, RESULT_FILE_NAME);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function isSafeStepCapabilityPath(path: string): boolean {
|
|
146
|
+
return isSafeStepFilePath(path, CAPABILITY_FILE_NAME);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function isSubagentRuntimeName(
|
|
150
|
+
name: string | undefined,
|
|
151
|
+
): name is string {
|
|
152
|
+
return Boolean(name && SUBAGENT_RUNTIME_NAME_PATTERN.test(name));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseChildPolicy(value: unknown): ChildStepPolicy {
|
|
156
|
+
if (!isObject(value)) throw new Error('child policy must be an object');
|
|
157
|
+
const allowedKeys = new Set([
|
|
158
|
+
'version',
|
|
159
|
+
'requestId',
|
|
160
|
+
'agent',
|
|
161
|
+
'workflowId',
|
|
162
|
+
'runId',
|
|
163
|
+
'stepId',
|
|
164
|
+
'stepTitle',
|
|
165
|
+
'policyDigest',
|
|
166
|
+
'capabilityPath',
|
|
167
|
+
'capabilityToken',
|
|
168
|
+
'resultPath',
|
|
169
|
+
'permissions',
|
|
170
|
+
'approvedBashCommands',
|
|
171
|
+
'outcomes',
|
|
172
|
+
'summaryMaxChars',
|
|
173
|
+
'gateSubmitOutcome',
|
|
174
|
+
]);
|
|
175
|
+
const unknownKey = Object.keys(value).find((key) => !allowedKeys.has(key));
|
|
176
|
+
if (unknownKey) {
|
|
177
|
+
throw new Error(`child policy has unknown property "${unknownKey}"`);
|
|
178
|
+
}
|
|
179
|
+
const stringFields = [
|
|
180
|
+
'requestId',
|
|
181
|
+
'agent',
|
|
182
|
+
'workflowId',
|
|
183
|
+
'runId',
|
|
184
|
+
'stepId',
|
|
185
|
+
'stepTitle',
|
|
186
|
+
'policyDigest',
|
|
187
|
+
'capabilityPath',
|
|
188
|
+
'capabilityToken',
|
|
189
|
+
'resultPath',
|
|
190
|
+
] as const;
|
|
191
|
+
for (const field of stringFields) {
|
|
192
|
+
if (typeof value[field] !== 'string' || !value[field]) {
|
|
193
|
+
throw new Error(`child policy ${field} must be a non-empty string`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (value.version !== 1) throw new Error('unsupported child policy version');
|
|
197
|
+
if (!POLICY_DIGEST_PATTERN.test(value.policyDigest as string)) {
|
|
198
|
+
throw new Error('child policy digest is invalid');
|
|
199
|
+
}
|
|
200
|
+
if (!isSubagentRuntimeName(value.agent as string)) {
|
|
201
|
+
throw new Error('child policy agent is not a valid subagent runtime name');
|
|
202
|
+
}
|
|
203
|
+
if (!CAPABILITY_TOKEN_PATTERN.test(value.capabilityToken as string)) {
|
|
204
|
+
throw new Error('child policy capability token is invalid');
|
|
205
|
+
}
|
|
206
|
+
if (!isSafeStepCapabilityPath(value.capabilityPath as string)) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'child policy capability path is outside its temporary directory',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (!isSafeStepResultPath(value.resultPath as string)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
'child policy result path is outside its temporary directory',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (
|
|
217
|
+
dirname(resolve(value.capabilityPath as string)) !==
|
|
218
|
+
dirname(resolve(value.resultPath as string))
|
|
219
|
+
) {
|
|
220
|
+
throw new Error('child policy files must share one temporary directory');
|
|
221
|
+
}
|
|
222
|
+
if (!isStepPermissions(value.permissions)) {
|
|
223
|
+
throw new Error('child policy permissions are invalid');
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
value.approvedBashCommands !== undefined &&
|
|
227
|
+
(!isStringArray(value.approvedBashCommands) ||
|
|
228
|
+
new Set(value.approvedBashCommands).size !==
|
|
229
|
+
value.approvedBashCommands.length)
|
|
230
|
+
) {
|
|
231
|
+
throw new Error('child policy approved Bash commands are invalid');
|
|
232
|
+
}
|
|
233
|
+
if (
|
|
234
|
+
!isStringArray(value.outcomes) ||
|
|
235
|
+
value.outcomes.length === 0 ||
|
|
236
|
+
new Set(value.outcomes).size !== value.outcomes.length
|
|
237
|
+
) {
|
|
238
|
+
throw new Error('child policy outcomes are invalid');
|
|
239
|
+
}
|
|
240
|
+
if (
|
|
241
|
+
!Number.isInteger(value.summaryMaxChars) ||
|
|
242
|
+
(value.summaryMaxChars as number) < 100 ||
|
|
243
|
+
(value.summaryMaxChars as number) > 50_000
|
|
244
|
+
) {
|
|
245
|
+
throw new Error('child policy summaryMaxChars is invalid');
|
|
246
|
+
}
|
|
247
|
+
if (
|
|
248
|
+
value.gateSubmitOutcome !== undefined &&
|
|
249
|
+
(typeof value.gateSubmitOutcome !== 'string' ||
|
|
250
|
+
!value.outcomes.includes(value.gateSubmitOutcome))
|
|
251
|
+
) {
|
|
252
|
+
throw new Error('child policy gate outcome is invalid');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return value as unknown as ChildStepPolicy;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function encodeChildPolicy(policy: ChildStepPolicy): string {
|
|
259
|
+
const encoded = Buffer.from(JSON.stringify(policy), 'utf8').toString(
|
|
260
|
+
'base64url',
|
|
261
|
+
);
|
|
262
|
+
return `${CHILD_POLICY_OPEN}${encoded}${CHILD_POLICY_CLOSE}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function extractChildPolicy(
|
|
266
|
+
text: string,
|
|
267
|
+
): ExtractedChildPolicy | undefined {
|
|
268
|
+
let start = 0;
|
|
269
|
+
if (!text.startsWith(CHILD_POLICY_OPEN)) {
|
|
270
|
+
const forkStart = text.indexOf(`${FORK_TASK_BOUNDARY}${CHILD_POLICY_OPEN}`);
|
|
271
|
+
if (forkStart === -1) return undefined;
|
|
272
|
+
start = forkStart + FORK_TASK_BOUNDARY.length;
|
|
273
|
+
}
|
|
274
|
+
const payloadStart = start + CHILD_POLICY_OPEN.length;
|
|
275
|
+
const end = text.indexOf(CHILD_POLICY_CLOSE, payloadStart);
|
|
276
|
+
if (end === -1 || text.indexOf(CHILD_POLICY_OPEN, payloadStart) !== -1) {
|
|
277
|
+
throw new Error('delegated task contains an invalid child policy envelope');
|
|
278
|
+
}
|
|
279
|
+
const encoded = text.slice(payloadStart, end);
|
|
280
|
+
let decoded: unknown;
|
|
281
|
+
try {
|
|
282
|
+
decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
283
|
+
} catch {
|
|
284
|
+
throw new Error('delegated task child policy cannot be decoded');
|
|
285
|
+
}
|
|
286
|
+
const task =
|
|
287
|
+
`${text.slice(0, start)}${text.slice(end + CHILD_POLICY_CLOSE.length)}`.trim();
|
|
288
|
+
if (!task) throw new Error('delegated task is empty after policy extraction');
|
|
289
|
+
return { policy: parseChildPolicy(decoded), task };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function parseDelegatedStepResult(
|
|
293
|
+
value: unknown,
|
|
294
|
+
policy: ChildStepPolicy,
|
|
295
|
+
): DelegatedStepResult {
|
|
296
|
+
try {
|
|
297
|
+
return parseWorkflowStepResult(value, policy);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
300
|
+
throw new Error(message.replaceAll('workflow step', 'delegated step'), {
|
|
301
|
+
cause: error,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|