imprint-mcp 0.7.0 → 0.7.2
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/README.md +66 -2
- package/package.json +1 -1
- package/prompts/auth-compile-agent.md +173 -22
- package/prompts/compile-agent.md +45 -8
- package/prompts/component-recovery.md +40 -0
- package/prompts/master-teach-decision.md +28 -3
- package/prompts/master-teach-focused-planner.md +18 -5
- package/src/cli.ts +26 -18
- package/src/imprint/agent-tool.ts +15 -0
- package/src/imprint/agent.ts +2 -11
- package/src/imprint/audit.ts +35 -13
- package/src/imprint/auth-browser-discovery.ts +276 -0
- package/src/imprint/auth-compile-agent.ts +513 -224
- package/src/imprint/auth-compile-tools.ts +213 -25
- package/src/imprint/auth-discovery-evidence.ts +81 -0
- package/src/imprint/auth-plan.ts +28 -0
- package/src/imprint/auth-verifier.ts +234 -20
- package/src/imprint/authentication-interaction.ts +137 -0
- package/src/imprint/backend-ladder.ts +271 -43
- package/src/imprint/build-plan.ts +2 -22
- package/src/imprint/cdp-browser-fetch.ts +242 -60
- package/src/imprint/chromium.ts +93 -29
- package/src/imprint/claude-cli-compile.ts +2 -0
- package/src/imprint/codex-cli-compile.ts +99 -46
- package/src/imprint/compile-agent-types.ts +9 -5
- package/src/imprint/compile-agent.ts +33 -0
- package/src/imprint/compile-tool-executor.ts +783 -0
- package/src/imprint/compile-tools.ts +42 -9
- package/src/imprint/compile-verification.ts +2 -0
- package/src/imprint/compile.ts +3 -0
- package/src/imprint/compiler-log.ts +16 -0
- package/src/imprint/component-recovery.ts +120 -0
- package/src/imprint/cookie-jar.ts +117 -7
- package/src/imprint/credential-extract.ts +9 -2
- package/src/imprint/credential-lock.ts +147 -0
- package/src/imprint/credential-observation.ts +261 -0
- package/src/imprint/credential-store.ts +284 -44
- package/src/imprint/live-component-recovery.ts +73 -0
- package/src/imprint/llm.ts +4 -3
- package/src/imprint/log.ts +16 -1
- package/src/imprint/login.ts +9 -0
- package/src/imprint/master-teach-agent-contracts.ts +40 -3
- package/src/imprint/master-teach-agents.ts +229 -14
- package/src/imprint/master-teach-checks.ts +8 -1
- package/src/imprint/master-teach-controller.ts +681 -138
- package/src/imprint/master-teach-prompt-projections.ts +2 -0
- package/src/imprint/mcp-cdp-sessions.ts +7 -2
- package/src/imprint/mcp-compile-server.ts +62 -850
- package/src/imprint/mcp-server.ts +667 -91
- package/src/imprint/mcp-session-recovery.ts +167 -0
- package/src/imprint/native-teach-agents.ts +232 -108
- package/src/imprint/runtime.ts +416 -47
- package/src/imprint/stealth-chromium.ts +67 -7
- package/src/imprint/teach-browser-resources.ts +88 -0
- package/src/imprint/teach-stage-progress.ts +38 -0
- package/src/imprint/terminal-user-prompt.ts +42 -0
- package/src/imprint/types.ts +51 -18
- package/src/imprint/user-interaction.ts +836 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
/** Run-local human interaction shared by retained roles and compiler subprocesses. */
|
|
2
|
+
import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import type { AgentTool } from './agent-tool.ts';
|
|
7
|
+
import { AuthToolPlanSchema } from './auth-plan.ts';
|
|
8
|
+
import { abortSignalError, withAbortSignal } from './concurrency.ts';
|
|
9
|
+
import { createLog } from './log.ts';
|
|
10
|
+
import { type RunDeadlineRef, combinedDeadlineSignal } from './provider-retry.ts';
|
|
11
|
+
|
|
12
|
+
const UserPromptSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
message: z.string().trim().min(1).max(8_000),
|
|
15
|
+
options: z.array(z.string().min(1).max(500)).min(2).max(20).optional(),
|
|
16
|
+
sensitive: z.boolean().default(false),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export type UserPrompt = z.infer<typeof UserPromptSchema>;
|
|
20
|
+
export type UserPromptAnswer =
|
|
21
|
+
| { status: 'answered'; value: string }
|
|
22
|
+
| { status: 'cancelled' | 'unavailable'; reason: string };
|
|
23
|
+
export type OnUserPrompt = (prompt: UserPrompt, signal?: AbortSignal) => Promise<UserPromptAnswer>;
|
|
24
|
+
export const AgentInteractionSchema = z.discriminatedUnion('action', [
|
|
25
|
+
z.object({ action: z.literal('prompt_user'), prompt: UserPromptSchema }).strict(),
|
|
26
|
+
z
|
|
27
|
+
.object({
|
|
28
|
+
action: z.literal('authenticate'),
|
|
29
|
+
plan: AuthToolPlanSchema.unwrap()
|
|
30
|
+
.extend({
|
|
31
|
+
toolName: z.string().regex(/^[a-z][a-z0-9_]*$/),
|
|
32
|
+
})
|
|
33
|
+
.strict(),
|
|
34
|
+
})
|
|
35
|
+
.strict(),
|
|
36
|
+
]);
|
|
37
|
+
type AgentInteraction = z.infer<typeof AgentInteractionSchema>;
|
|
38
|
+
type AuthenticationResult = {
|
|
39
|
+
status: 'authenticated' | 'failed' | 'unavailable';
|
|
40
|
+
message: string;
|
|
41
|
+
workflowPath?: string;
|
|
42
|
+
contextRef?: string;
|
|
43
|
+
operationId?: string;
|
|
44
|
+
};
|
|
45
|
+
type Authenticate = (
|
|
46
|
+
plan: Extract<AgentInteraction, { action: 'authenticate' }>['plan'],
|
|
47
|
+
signal?: AbortSignal,
|
|
48
|
+
) => Promise<AuthenticationResult>;
|
|
49
|
+
|
|
50
|
+
const context = new AsyncLocalStorage<UserInteraction>();
|
|
51
|
+
export const currentUserInteraction = () => context.getStore();
|
|
52
|
+
const USER_INTERACTION_ENV = 'IMPRINT_USER_INTERACTION_URL';
|
|
53
|
+
|
|
54
|
+
export const USER_INTERACTION_INSTRUCTIONS = `
|
|
55
|
+
HUMAN INTERACTION: You may ask the user for authentication, MFA approval, an OTP,
|
|
56
|
+
missing information, or another necessary decision. Return only
|
|
57
|
+
{"interaction":{"action":"prompt_user","prompt":{"message":"...","options":["Approved","Cancel"],"sensitive":false}}}
|
|
58
|
+
instead of your role decision, then continue after interactionResult. Omit options
|
|
59
|
+
for free text. Set sensitive:true for passwords, OTPs or other secrets. Secret
|
|
60
|
+
answers are host-held references; pass the returned reference unchanged as a
|
|
61
|
+
verification parameter, never try to inspect or publish its value. Cancelled or
|
|
62
|
+
unavailable input is not approval. Asking a question is not execution proof.
|
|
63
|
+
For a recorded authentication prerequisite, request
|
|
64
|
+
{"interaction":{"action":"authenticate","plan":{"toolName":"authenticate_site","credentialRequestSeqs":[1],"authRequestSeqs":[2],"credentialNames":["username","password"],"captures":[],"notes":"Recording-grounded login, factor and session requirements"}}}.
|
|
65
|
+
Use exact supplied recording sequences and credential names. This invokes the
|
|
66
|
+
auth compiler with run_verification, inspect_verification_page and prompt_user
|
|
67
|
+
on one persistent browser. It can send a recorded push/OTP challenge, ask the
|
|
68
|
+
user, and continue the same login context. Do not declare external MFA impossible
|
|
69
|
+
merely because an ordinary API-test action has no human-input field. After auth
|
|
70
|
+
succeeds, test your own operation again; auth success is not hotel/data proof.
|
|
71
|
+
Native agents and ordinary compilers also have prompt_user and authenticate
|
|
72
|
+
tools. The authenticate tool starts owned work and returns a pending handle
|
|
73
|
+
immediately; use await_authentication or authentication_status to inspect it,
|
|
74
|
+
and cancel_authentication with its ownerRef to detach your ownership. A transport
|
|
75
|
+
wait ending is not an authentication verdict. list_authentication_contexts lists
|
|
76
|
+
factual existing contexts and operations. Choose plan.contextRef to continue one
|
|
77
|
+
explicitly, or plan.newContext:true to request isolation. Without either, the
|
|
78
|
+
legacy per-tool-name context is retained. Different labels are never merged. Do not mix an interaction envelope with a role decision.
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
interface AuthenticationContextInfo {
|
|
82
|
+
contextRef: string;
|
|
83
|
+
label: string;
|
|
84
|
+
createdAt: string;
|
|
85
|
+
recordingRef?: string;
|
|
86
|
+
credentialRevision?: string;
|
|
87
|
+
workflowPath?: string;
|
|
88
|
+
workflowRevision?: string;
|
|
89
|
+
latestAction?: string;
|
|
90
|
+
latestOk?: boolean;
|
|
91
|
+
latestStatus?: number;
|
|
92
|
+
latestError?: string;
|
|
93
|
+
recordingRevision?: string;
|
|
94
|
+
evidenceRefs?: string[];
|
|
95
|
+
}
|
|
96
|
+
interface AuthenticationOperationInfo {
|
|
97
|
+
operationId: string;
|
|
98
|
+
contextRef: string;
|
|
99
|
+
toolName: string;
|
|
100
|
+
state: 'queued' | 'running' | 'cancelling' | 'completed' | 'cancelled' | 'error';
|
|
101
|
+
createdAt: string;
|
|
102
|
+
startedAt?: string;
|
|
103
|
+
settledAt?: string;
|
|
104
|
+
owners: number;
|
|
105
|
+
result?: AuthenticationResult;
|
|
106
|
+
message?: string;
|
|
107
|
+
}
|
|
108
|
+
interface AuthenticationHandle extends AuthenticationOperationInfo {
|
|
109
|
+
status: 'pending';
|
|
110
|
+
/** Deliberate run-owned handle; transient status/await calls do not own the work. */
|
|
111
|
+
ownerRef: string;
|
|
112
|
+
}
|
|
113
|
+
type AuthenticationLifecycleEvent = Omit<AuthenticationOperationInfo, 'result' | 'message'> & {
|
|
114
|
+
event: string;
|
|
115
|
+
elapsedMs: number;
|
|
116
|
+
};
|
|
117
|
+
type AuthenticationOperation = {
|
|
118
|
+
info: AuthenticationOperationInfo;
|
|
119
|
+
controller: AbortController;
|
|
120
|
+
work: Promise<AuthenticationResult>;
|
|
121
|
+
owners: Set<string>;
|
|
122
|
+
created: number;
|
|
123
|
+
};
|
|
124
|
+
const log = createLog('authentication');
|
|
125
|
+
// Bound only one transport wait, not the authentication operation's lifetime.
|
|
126
|
+
const AUTHENTICATION_WAIT_MS = 45_000;
|
|
127
|
+
const AuthenticationControlSchema = z.discriminatedUnion('action', [
|
|
128
|
+
z
|
|
129
|
+
.object({ action: z.literal('start'), plan: AgentInteractionSchema.options[1].shape.plan })
|
|
130
|
+
.strict(),
|
|
131
|
+
z.object({ action: z.literal('contexts') }).strict(),
|
|
132
|
+
z.object({ action: z.literal('status'), operationId: z.string() }).strict(),
|
|
133
|
+
z
|
|
134
|
+
.object({
|
|
135
|
+
action: z.literal('await'),
|
|
136
|
+
operationId: z.string(),
|
|
137
|
+
waitMs: z.number().int().min(0).max(AUTHENTICATION_WAIT_MS).default(AUTHENTICATION_WAIT_MS),
|
|
138
|
+
})
|
|
139
|
+
.strict(),
|
|
140
|
+
z.object({ action: z.literal('cancel'), operationId: z.string(), ownerRef: z.string() }).strict(),
|
|
141
|
+
]);
|
|
142
|
+
type AuthenticationControl = z.infer<typeof AuthenticationControlSchema>;
|
|
143
|
+
|
|
144
|
+
export class UserInteraction {
|
|
145
|
+
readonly #secrets = new Map<string, string>();
|
|
146
|
+
readonly #abort = new AbortController();
|
|
147
|
+
readonly #signal: AbortSignal;
|
|
148
|
+
#deadlineControl?: ReturnType<typeof combinedDeadlineSignal>;
|
|
149
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
150
|
+
#authQueue: Promise<unknown> = Promise.resolve();
|
|
151
|
+
readonly #authRequests = new Map<string, AuthenticationOperation>();
|
|
152
|
+
readonly #authOperations = new Map<string, AuthenticationOperation>();
|
|
153
|
+
readonly #authContexts = new Map<string, AuthenticationContextInfo>();
|
|
154
|
+
readonly #legacyContexts = new Map<string, string>();
|
|
155
|
+
readonly #cleanups = new Set<() => void | Promise<void>>();
|
|
156
|
+
#closing?: Promise<void>;
|
|
157
|
+
#authenticate?: Authenticate;
|
|
158
|
+
|
|
159
|
+
constructor(
|
|
160
|
+
readonly onPrompt?: OnUserPrompt,
|
|
161
|
+
signal?: AbortSignal,
|
|
162
|
+
readonly onEvent?: (event: AuthenticationLifecycleEvent) => void,
|
|
163
|
+
) {
|
|
164
|
+
this.#signal = signal ? AbortSignal.any([signal, this.#abort.signal]) : this.#abort.signal;
|
|
165
|
+
}
|
|
166
|
+
get signal(): AbortSignal {
|
|
167
|
+
return this.#deadlineControl?.signal ?? this.#signal;
|
|
168
|
+
}
|
|
169
|
+
setRunControl(
|
|
170
|
+
deadline?: RunDeadlineRef,
|
|
171
|
+
onDeadlineReached?: () => Promise<number | null | undefined>,
|
|
172
|
+
): void {
|
|
173
|
+
this.#deadlineControl?.dispose();
|
|
174
|
+
this.#deadlineControl = combinedDeadlineSignal(
|
|
175
|
+
deadline,
|
|
176
|
+
undefined,
|
|
177
|
+
this.#signal,
|
|
178
|
+
Date.now,
|
|
179
|
+
undefined,
|
|
180
|
+
onDeadlineReached,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
get hasAuthentication(): boolean {
|
|
184
|
+
return this.#authenticate !== undefined;
|
|
185
|
+
}
|
|
186
|
+
run<T>(work: () => T): T {
|
|
187
|
+
return context.run(this, work);
|
|
188
|
+
}
|
|
189
|
+
/** Bind native-family context as well as the human-interaction context. */
|
|
190
|
+
setAuthenticate(handler: Authenticate): void {
|
|
191
|
+
this.#authenticate = AsyncResource.bind(handler);
|
|
192
|
+
}
|
|
193
|
+
addCleanup(cleanup: () => void | Promise<void>): void {
|
|
194
|
+
if (this.#abort.signal.aborted) {
|
|
195
|
+
void Promise.resolve()
|
|
196
|
+
.then(cleanup)
|
|
197
|
+
.catch(() => {});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.#cleanups.add(cleanup);
|
|
201
|
+
}
|
|
202
|
+
updateAuthenticationContext(
|
|
203
|
+
contextRef: string,
|
|
204
|
+
metadata: Partial<Omit<AuthenticationContextInfo, 'contextRef' | 'label' | 'createdAt'>>,
|
|
205
|
+
): void {
|
|
206
|
+
const info = this.#authContexts.get(contextRef);
|
|
207
|
+
if (info) Object.assign(info, metadata);
|
|
208
|
+
}
|
|
209
|
+
authenticationContexts(): Array<
|
|
210
|
+
AuthenticationContextInfo & { operations: AuthenticationOperationInfo[] }
|
|
211
|
+
> {
|
|
212
|
+
return Array.from(this.#authContexts.values(), (info) => ({
|
|
213
|
+
...info,
|
|
214
|
+
operations: Array.from(this.#authOperations.values())
|
|
215
|
+
.filter((operation) => operation.info.contextRef === info.contextRef)
|
|
216
|
+
.map((operation) => this.#snapshot(operation)),
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
#snapshot(operation: AuthenticationOperation): AuthenticationOperationInfo {
|
|
220
|
+
return { ...operation.info, owners: operation.owners.size };
|
|
221
|
+
}
|
|
222
|
+
#event(event: string, operation: AuthenticationOperation): void {
|
|
223
|
+
const { result: _result, message: _message, ...info } = this.#snapshot(operation);
|
|
224
|
+
const facts = { event, ...info, elapsedMs: Math.round(performance.now() - operation.created) };
|
|
225
|
+
log(JSON.stringify(facts));
|
|
226
|
+
try {
|
|
227
|
+
this.onEvent?.(facts);
|
|
228
|
+
} catch {
|
|
229
|
+
/* Optional diagnostics cannot change execution. */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
#operation(operationId: string): AuthenticationOperation {
|
|
233
|
+
const operation = this.#authOperations.get(operationId);
|
|
234
|
+
if (!operation)
|
|
235
|
+
throw new Error(
|
|
236
|
+
'Unknown authentication operation; inspect list_authentication_contexts for current references.',
|
|
237
|
+
);
|
|
238
|
+
return operation;
|
|
239
|
+
}
|
|
240
|
+
#release(operation: AuthenticationOperation, ownerRef: string): void {
|
|
241
|
+
if (!operation.owners.delete(ownerRef))
|
|
242
|
+
throw new Error('Unknown or already released authentication owner reference.');
|
|
243
|
+
if (operation.owners.size === 0 && ['queued', 'running'].includes(operation.info.state)) {
|
|
244
|
+
operation.info.state = 'cancelling';
|
|
245
|
+
this.#event('cancellation_requested', operation);
|
|
246
|
+
operation.controller.abort(new DOMException('No authentication owners remain', 'AbortError'));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
startAuthentication(
|
|
250
|
+
plan: Extract<AgentInteraction, { action: 'authenticate' }>['plan'],
|
|
251
|
+
): AuthenticationHandle {
|
|
252
|
+
const authenticate = this.#authenticate;
|
|
253
|
+
if (!authenticate) throw new Error('No authentication host is attached to this agent.');
|
|
254
|
+
if (this.signal.aborted) throw abortSignalError(this.signal);
|
|
255
|
+
if (plan.contextRef && plan.newContext)
|
|
256
|
+
throw new Error('Choose contextRef or newContext, not both.');
|
|
257
|
+
let contextRef =
|
|
258
|
+
plan.contextRef ?? (plan.newContext ? undefined : this.#legacyContexts.get(plan.toolName));
|
|
259
|
+
if (contextRef && !this.#authContexts.has(contextRef))
|
|
260
|
+
throw new Error(
|
|
261
|
+
'Unknown or expired authentication contextRef; inspect list_authentication_contexts or request newContext:true.',
|
|
262
|
+
);
|
|
263
|
+
if (!contextRef) {
|
|
264
|
+
contextRef = randomUUID();
|
|
265
|
+
this.#authContexts.set(contextRef, {
|
|
266
|
+
contextRef,
|
|
267
|
+
label: plan.toolName,
|
|
268
|
+
createdAt: new Date().toISOString(),
|
|
269
|
+
});
|
|
270
|
+
if (!plan.newContext) this.#legacyContexts.set(plan.toolName, contextRef);
|
|
271
|
+
}
|
|
272
|
+
// Explicit context identity participates in exact in-flight coalescing. A
|
|
273
|
+
// completed operation is retained for inspection, never reused as proof.
|
|
274
|
+
const { newContext: _newContext, ...prepared } = { ...plan, contextRef };
|
|
275
|
+
const key = JSON.stringify(prepared);
|
|
276
|
+
let operation = this.#authRequests.get(key);
|
|
277
|
+
if (
|
|
278
|
+
!operation ||
|
|
279
|
+
operation.controller.signal.aborted ||
|
|
280
|
+
['completed', 'cancelled', 'error'].includes(operation.info.state)
|
|
281
|
+
) {
|
|
282
|
+
const controller = new AbortController();
|
|
283
|
+
const signal = AbortSignal.any([this.signal, controller.signal]);
|
|
284
|
+
const previous = this.#authQueue;
|
|
285
|
+
operation = {
|
|
286
|
+
info: {
|
|
287
|
+
operationId: randomUUID(),
|
|
288
|
+
contextRef,
|
|
289
|
+
toolName: plan.toolName,
|
|
290
|
+
state: 'queued',
|
|
291
|
+
createdAt: new Date().toISOString(),
|
|
292
|
+
owners: 0,
|
|
293
|
+
},
|
|
294
|
+
controller,
|
|
295
|
+
owners: new Set(),
|
|
296
|
+
created: performance.now(),
|
|
297
|
+
work: undefined as never,
|
|
298
|
+
};
|
|
299
|
+
const current = operation;
|
|
300
|
+
// The public queued operation can cancel promptly, while this queue tail
|
|
301
|
+
// still waits for the predecessor's real teardown before admitting work.
|
|
302
|
+
const queued = previous
|
|
303
|
+
.catch(() => {})
|
|
304
|
+
.then(async () => {
|
|
305
|
+
if (signal.aborted) throw abortSignalError(signal);
|
|
306
|
+
current.info.state = 'running';
|
|
307
|
+
current.info.startedAt = new Date().toISOString();
|
|
308
|
+
this.#event('execution_started', current);
|
|
309
|
+
const result = await authenticate(prepared, signal);
|
|
310
|
+
if (signal.aborted) throw abortSignalError(signal);
|
|
311
|
+
return {
|
|
312
|
+
...result,
|
|
313
|
+
contextRef,
|
|
314
|
+
operationId: current.info.operationId,
|
|
315
|
+
message: this.sanitize(result.message),
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
current.work = withAbortSignal(() => previous.catch(() => {}), signal)
|
|
319
|
+
.then(() => queued)
|
|
320
|
+
.then(
|
|
321
|
+
(result) => {
|
|
322
|
+
current.info.result = result;
|
|
323
|
+
current.info.state = 'completed';
|
|
324
|
+
return result;
|
|
325
|
+
},
|
|
326
|
+
(error: unknown) => {
|
|
327
|
+
current.info.state = signal.aborted ? 'cancelled' : 'error';
|
|
328
|
+
// Provider/transport failures remain errors, never authentication verdicts.
|
|
329
|
+
current.info.message = this.sanitize(
|
|
330
|
+
error instanceof Error ? error.message : String(error),
|
|
331
|
+
);
|
|
332
|
+
throw error;
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
.finally(() => {
|
|
336
|
+
current.info.settledAt = new Date().toISOString();
|
|
337
|
+
if (this.#authRequests.get(key) === current) this.#authRequests.delete(key);
|
|
338
|
+
this.#event('execution_settled', current);
|
|
339
|
+
});
|
|
340
|
+
// Queue ownership ends after actual work/cleanup, including active abort.
|
|
341
|
+
this.#authQueue = queued;
|
|
342
|
+
void queued.catch(() => {});
|
|
343
|
+
void current.work.catch(() => {});
|
|
344
|
+
this.#authRequests.set(key, current);
|
|
345
|
+
this.#authOperations.set(current.info.operationId, current);
|
|
346
|
+
this.#event('enqueued', current);
|
|
347
|
+
}
|
|
348
|
+
const ownerRef = randomUUID();
|
|
349
|
+
operation.owners.add(ownerRef);
|
|
350
|
+
return { status: 'pending', ownerRef, ...this.#snapshot(operation) };
|
|
351
|
+
}
|
|
352
|
+
async authenticationControl(
|
|
353
|
+
input: AuthenticationControl,
|
|
354
|
+
callerSignal = this.signal,
|
|
355
|
+
): Promise<unknown> {
|
|
356
|
+
const signal = AbortSignal.any([this.signal, callerSignal]);
|
|
357
|
+
if (signal.aborted) throw abortSignalError(signal);
|
|
358
|
+
const control = AuthenticationControlSchema.parse(input);
|
|
359
|
+
if (control.action === 'contexts') return { contexts: this.authenticationContexts() };
|
|
360
|
+
if (control.action === 'start') {
|
|
361
|
+
const handle = this.startAuthentication(control.plan);
|
|
362
|
+
// Ownership transfers with the returned handle. A request cancelled before
|
|
363
|
+
// that handoff must not leave deliberately pending work behind.
|
|
364
|
+
await Promise.resolve();
|
|
365
|
+
if (signal.aborted) {
|
|
366
|
+
this.#release(this.#operation(handle.operationId), handle.ownerRef);
|
|
367
|
+
throw abortSignalError(signal);
|
|
368
|
+
}
|
|
369
|
+
return handle;
|
|
370
|
+
}
|
|
371
|
+
const operation = this.#operation(control.operationId);
|
|
372
|
+
if (control.action === 'cancel') {
|
|
373
|
+
this.#release(operation, control.ownerRef);
|
|
374
|
+
return this.#snapshot(operation);
|
|
375
|
+
}
|
|
376
|
+
if (control.action === 'status') return this.#snapshot(operation);
|
|
377
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
378
|
+
try {
|
|
379
|
+
return await withAbortSignal(
|
|
380
|
+
() =>
|
|
381
|
+
Promise.race([
|
|
382
|
+
operation.work.then(
|
|
383
|
+
() => this.#snapshot(operation),
|
|
384
|
+
() => this.#snapshot(operation),
|
|
385
|
+
),
|
|
386
|
+
new Promise<AuthenticationOperationInfo>((resolve) => {
|
|
387
|
+
timer = setTimeout(() => resolve(this.#snapshot(operation)), control.waitMs);
|
|
388
|
+
}),
|
|
389
|
+
]),
|
|
390
|
+
signal,
|
|
391
|
+
);
|
|
392
|
+
} finally {
|
|
393
|
+
if (timer) clearTimeout(timer);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async request(
|
|
397
|
+
input: AgentInteraction,
|
|
398
|
+
callerSignal = this.signal,
|
|
399
|
+
): Promise<UserPromptAnswer | AuthenticationResult> {
|
|
400
|
+
const signal = AbortSignal.any([this.signal, callerSignal]);
|
|
401
|
+
const operation = AgentInteractionSchema.parse(input);
|
|
402
|
+
if (signal.aborted) throw abortSignalError(signal);
|
|
403
|
+
if (operation.action === 'authenticate') {
|
|
404
|
+
if (!this.#authenticate)
|
|
405
|
+
return {
|
|
406
|
+
status: 'unavailable',
|
|
407
|
+
message: 'No authentication host is attached to this agent.',
|
|
408
|
+
};
|
|
409
|
+
const handle = this.startAuthentication(operation.plan);
|
|
410
|
+
const work = this.#operation(handle.operationId);
|
|
411
|
+
try {
|
|
412
|
+
return await withAbortSignal(() => work.work, signal);
|
|
413
|
+
} finally {
|
|
414
|
+
this.#release(work, handle.ownerRef);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const onPrompt = this.onPrompt;
|
|
418
|
+
if (!onPrompt)
|
|
419
|
+
return {
|
|
420
|
+
status: 'unavailable',
|
|
421
|
+
reason:
|
|
422
|
+
'User input is unavailable: run teach in an interactive terminal without --no-interactive, or provide onPrompt.',
|
|
423
|
+
};
|
|
424
|
+
const work = this.#queue
|
|
425
|
+
.catch(() => {})
|
|
426
|
+
.then(async (): Promise<UserPromptAnswer> => {
|
|
427
|
+
if (signal.aborted) throw abortSignalError(signal);
|
|
428
|
+
const answer = await withAbortSignal(() => onPrompt(operation.prompt, signal), signal);
|
|
429
|
+
if (answer.status !== 'answered') return answer;
|
|
430
|
+
if (!operation.prompt.sensitive) return answer;
|
|
431
|
+
const reference = `\${prompt.${randomUUID()}}`;
|
|
432
|
+
this.#secrets.set(reference, answer.value);
|
|
433
|
+
return { status: 'answered', value: reference };
|
|
434
|
+
});
|
|
435
|
+
this.#queue = work;
|
|
436
|
+
return await withAbortSignal(() => work, signal);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
resolve<T>(value: T): T {
|
|
440
|
+
const visit = (entry: unknown): unknown => {
|
|
441
|
+
if (typeof entry === 'string') {
|
|
442
|
+
if (/^\$\{prompt\.[^}]+\}$/.test(entry)) {
|
|
443
|
+
if (!this.#secrets.has(entry)) throw new Error('Unknown or expired user-input reference');
|
|
444
|
+
return this.#secrets.get(entry);
|
|
445
|
+
}
|
|
446
|
+
return entry;
|
|
447
|
+
}
|
|
448
|
+
if (Array.isArray(entry)) return entry.map(visit);
|
|
449
|
+
if (entry && typeof entry === 'object')
|
|
450
|
+
return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, visit(item)]));
|
|
451
|
+
return entry;
|
|
452
|
+
};
|
|
453
|
+
return visit(value) as T;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
sanitize(text: string): string {
|
|
457
|
+
let result = text;
|
|
458
|
+
for (const [reference, value] of this.#secrets) {
|
|
459
|
+
if (value) result = result.split(value).join(reference);
|
|
460
|
+
}
|
|
461
|
+
return result;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
clear(): Promise<void> {
|
|
465
|
+
if (this.#closing) return this.#closing;
|
|
466
|
+
this.#abort.abort(new DOMException('User-interaction host closed', 'AbortError'));
|
|
467
|
+
this.#deadlineControl?.dispose();
|
|
468
|
+
this.#secrets.clear();
|
|
469
|
+
this.#authenticate = undefined;
|
|
470
|
+
for (const operation of this.#authOperations.values()) operation.controller.abort();
|
|
471
|
+
this.#authRequests.clear();
|
|
472
|
+
this.#authContexts.clear();
|
|
473
|
+
this.#legacyContexts.clear();
|
|
474
|
+
const cleanups = Array.from(this.#cleanups);
|
|
475
|
+
this.#closing = this.#authQueue
|
|
476
|
+
.catch(() => {})
|
|
477
|
+
.then(async () => {
|
|
478
|
+
await Promise.allSettled(cleanups.map((cleanup) => Promise.resolve().then(cleanup)));
|
|
479
|
+
this.#authOperations.clear();
|
|
480
|
+
});
|
|
481
|
+
this.#cleanups.clear();
|
|
482
|
+
return this.#closing;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function remoteInteraction(
|
|
487
|
+
path: string,
|
|
488
|
+
input: unknown,
|
|
489
|
+
signal?: AbortSignal,
|
|
490
|
+
): Promise<unknown> {
|
|
491
|
+
const url = process.env[USER_INTERACTION_ENV];
|
|
492
|
+
if (!url) throw new Error('No user-interaction host is attached');
|
|
493
|
+
if (signal?.aborted) throw abortSignalError(signal);
|
|
494
|
+
const requestId = randomUUID();
|
|
495
|
+
let cancellation: Promise<void> | undefined;
|
|
496
|
+
const cancel = (): void => {
|
|
497
|
+
// Bun's fetch cancellation need not close the parent's node:http response
|
|
498
|
+
// promptly. Relay cancellation explicitly; never rely on TCP idle expiry.
|
|
499
|
+
cancellation ??= fetch(`${url}/cancel-request`, {
|
|
500
|
+
method: 'POST',
|
|
501
|
+
headers: { 'x-imprint-request-id': requestId },
|
|
502
|
+
})
|
|
503
|
+
.then(async (response) => {
|
|
504
|
+
await response.arrayBuffer();
|
|
505
|
+
})
|
|
506
|
+
.catch(() => {});
|
|
507
|
+
};
|
|
508
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
509
|
+
try {
|
|
510
|
+
const response = await fetch(`${url}/${path}`, {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
headers: { 'content-type': 'application/json', 'x-imprint-request-id': requestId },
|
|
513
|
+
body: JSON.stringify(input),
|
|
514
|
+
signal,
|
|
515
|
+
});
|
|
516
|
+
if (!response.ok) {
|
|
517
|
+
const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined;
|
|
518
|
+
throw new Error(body?.error ?? `User-interaction host returned HTTP ${response.status}`);
|
|
519
|
+
}
|
|
520
|
+
return await response.json();
|
|
521
|
+
} catch (error) {
|
|
522
|
+
cancel();
|
|
523
|
+
throw error;
|
|
524
|
+
} finally {
|
|
525
|
+
signal?.removeEventListener('abort', cancel);
|
|
526
|
+
// The MCP lifetime drains active handlers, so this short relay settles
|
|
527
|
+
// before a disconnect can terminate the compiler bridge process.
|
|
528
|
+
await cancellation;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export async function requestAgentInteraction(
|
|
533
|
+
input: AgentInteraction,
|
|
534
|
+
signal?: AbortSignal,
|
|
535
|
+
): Promise<UserPromptAnswer | AuthenticationResult> {
|
|
536
|
+
const local = currentUserInteraction();
|
|
537
|
+
if (local) return await local.request(input, signal ?? local.signal);
|
|
538
|
+
if (process.env[USER_INTERACTION_ENV]) {
|
|
539
|
+
const parsed = AgentInteractionSchema.parse(input);
|
|
540
|
+
if (parsed.action === 'prompt_user')
|
|
541
|
+
return (await remoteInteraction('request', parsed, signal)) as UserPromptAnswer;
|
|
542
|
+
// The bridge carries short owned-operation requests instead of one silent
|
|
543
|
+
// multi-minute fetch. Await-style callers still own/cancel their operation.
|
|
544
|
+
const handle = (await remoteInteraction(
|
|
545
|
+
'authentication',
|
|
546
|
+
{ action: 'start', plan: parsed.plan },
|
|
547
|
+
signal,
|
|
548
|
+
)) as AuthenticationHandle;
|
|
549
|
+
try {
|
|
550
|
+
for (;;) {
|
|
551
|
+
const state = (await remoteInteraction(
|
|
552
|
+
'authentication',
|
|
553
|
+
{ action: 'await', operationId: handle.operationId },
|
|
554
|
+
signal,
|
|
555
|
+
)) as AuthenticationOperationInfo;
|
|
556
|
+
if (state.state === 'completed' && state.result) return state.result;
|
|
557
|
+
if (state.state === 'cancelled')
|
|
558
|
+
throw new DOMException(state.message ?? 'Authentication cancelled', 'AbortError');
|
|
559
|
+
if (state.state === 'error')
|
|
560
|
+
throw new Error(state.message ?? 'Authentication provider/transport failed');
|
|
561
|
+
}
|
|
562
|
+
} finally {
|
|
563
|
+
// Cleanup must reach the host even if this caller's request signal aborted.
|
|
564
|
+
await remoteInteraction('authentication', {
|
|
565
|
+
action: 'cancel',
|
|
566
|
+
operationId: handle.operationId,
|
|
567
|
+
ownerRef: handle.ownerRef,
|
|
568
|
+
}).catch(() => {});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return input.action === 'prompt_user'
|
|
572
|
+
? { status: 'unavailable', reason: 'No terminal input host is attached to this agent.' }
|
|
573
|
+
: { status: 'unavailable', message: 'No authentication host is attached to this agent.' };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function resolveUserInput<T>(value: T, signal?: AbortSignal): Promise<T> {
|
|
577
|
+
const local = currentUserInteraction();
|
|
578
|
+
if (local) return local.resolve(value);
|
|
579
|
+
if (process.env[USER_INTERACTION_ENV])
|
|
580
|
+
return (await remoteInteraction('resolve', value, signal)) as T;
|
|
581
|
+
if (JSON.stringify(value)?.includes('${prompt.'))
|
|
582
|
+
throw new Error('User-input reference has no attached host');
|
|
583
|
+
return value;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function requestAuthenticationControl(
|
|
587
|
+
input: AuthenticationControl,
|
|
588
|
+
signal?: AbortSignal,
|
|
589
|
+
): Promise<unknown> {
|
|
590
|
+
const control = AuthenticationControlSchema.parse(input);
|
|
591
|
+
const local = currentUserInteraction();
|
|
592
|
+
if (local) return await local.authenticationControl(control, signal ?? local.signal);
|
|
593
|
+
return await remoteInteraction('authentication', control, signal);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function userInteractionTools(signal?: AbortSignal): AgentTool[] {
|
|
597
|
+
const callSignal = (request?: { signal?: AbortSignal }): AbortSignal | undefined =>
|
|
598
|
+
signal && request?.signal
|
|
599
|
+
? AbortSignal.any([signal, request.signal])
|
|
600
|
+
: (request?.signal ?? signal);
|
|
601
|
+
const controlTool = (
|
|
602
|
+
name: string,
|
|
603
|
+
action: 'contexts' | 'status' | 'await' | 'cancel',
|
|
604
|
+
description: string,
|
|
605
|
+
properties: Record<string, unknown>,
|
|
606
|
+
required: string[] = [],
|
|
607
|
+
): AgentTool => ({
|
|
608
|
+
name,
|
|
609
|
+
description,
|
|
610
|
+
input_schema: { type: 'object', properties, required, additionalProperties: false },
|
|
611
|
+
handler: async (input, request) => ({
|
|
612
|
+
result: JSON.stringify(
|
|
613
|
+
await requestAuthenticationControl(
|
|
614
|
+
AuthenticationControlSchema.parse({ ...(input as object), action }),
|
|
615
|
+
callSignal(request),
|
|
616
|
+
),
|
|
617
|
+
),
|
|
618
|
+
}),
|
|
619
|
+
});
|
|
620
|
+
return [
|
|
621
|
+
{
|
|
622
|
+
name: 'prompt_user',
|
|
623
|
+
description:
|
|
624
|
+
'Ask the human for necessary input, a decision, or an external action. Use sensitive:true for OTPs and secrets; pass its returned reference unchanged to verification. Cancellation or unavailable input is not approval.',
|
|
625
|
+
input_schema: {
|
|
626
|
+
type: 'object',
|
|
627
|
+
properties: {
|
|
628
|
+
message: { type: 'string' },
|
|
629
|
+
options: { type: 'array', items: { type: 'string' } },
|
|
630
|
+
sensitive: { type: 'boolean' },
|
|
631
|
+
},
|
|
632
|
+
required: ['message'],
|
|
633
|
+
additionalProperties: false,
|
|
634
|
+
},
|
|
635
|
+
handler: async (input, request) => ({
|
|
636
|
+
result: JSON.stringify(
|
|
637
|
+
await requestAgentInteraction(
|
|
638
|
+
{ action: 'prompt_user', prompt: UserPromptSchema.parse(input) },
|
|
639
|
+
callSignal(request),
|
|
640
|
+
),
|
|
641
|
+
),
|
|
642
|
+
}),
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
name: 'authenticate',
|
|
646
|
+
description:
|
|
647
|
+
'Start owned recording-grounded authentication work and return a pending operation/context handle immediately. Use await_authentication or authentication_status for its factual result. Choose plan.contextRef to deliberately reuse a listed context or plan.newContext:true for isolation; omitted preserves legacy label reuse. Retain ownerRef to cancel/detach your ownership. The run owns deliberately pending work until its owners cancel or the run ends. Auth success never substitutes for testing the data operation.',
|
|
648
|
+
input_schema: {
|
|
649
|
+
type: 'object',
|
|
650
|
+
properties: {
|
|
651
|
+
plan: {
|
|
652
|
+
type: 'object',
|
|
653
|
+
properties: {
|
|
654
|
+
toolName: { type: 'string' },
|
|
655
|
+
contextRef: { type: 'string' },
|
|
656
|
+
newContext: { type: 'boolean' },
|
|
657
|
+
credentialRequestSeqs: { type: 'array', items: { type: 'integer' } },
|
|
658
|
+
authRequestSeqs: { type: 'array', items: { type: 'integer' } },
|
|
659
|
+
credentialNames: { type: 'array', items: { type: 'string' } },
|
|
660
|
+
captures: { type: 'array', items: { type: 'object' } },
|
|
661
|
+
notes: { type: 'string' },
|
|
662
|
+
},
|
|
663
|
+
required: ['toolName'],
|
|
664
|
+
additionalProperties: false,
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
required: ['plan'],
|
|
668
|
+
additionalProperties: false,
|
|
669
|
+
},
|
|
670
|
+
handler: async (input, request) => {
|
|
671
|
+
const parsed = AgentInteractionSchema.parse({
|
|
672
|
+
...(input as object),
|
|
673
|
+
action: 'authenticate',
|
|
674
|
+
});
|
|
675
|
+
if (parsed.action !== 'authenticate') throw new Error('Expected authentication plan');
|
|
676
|
+
return {
|
|
677
|
+
result: JSON.stringify(
|
|
678
|
+
await requestAuthenticationControl(
|
|
679
|
+
{ action: 'start', plan: parsed.plan },
|
|
680
|
+
callSignal(request),
|
|
681
|
+
),
|
|
682
|
+
),
|
|
683
|
+
};
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
controlTool(
|
|
687
|
+
'list_authentication_contexts',
|
|
688
|
+
'contexts',
|
|
689
|
+
'List factual retained auth contexts and their owned operations before choosing reuse, waiting, cancellation, or a separate context. Labels never imply equivalence.',
|
|
690
|
+
{},
|
|
691
|
+
),
|
|
692
|
+
controlTool(
|
|
693
|
+
'authentication_status',
|
|
694
|
+
'status',
|
|
695
|
+
'Inspect an existing authentication operation. Transport/provider errors are separate from its authentication result.',
|
|
696
|
+
{ operationId: { type: 'string' } },
|
|
697
|
+
['operationId'],
|
|
698
|
+
),
|
|
699
|
+
controlTool(
|
|
700
|
+
'await_authentication',
|
|
701
|
+
'await',
|
|
702
|
+
'Wait for an existing owned operation, returning current state after at most 45s per transport call. Queued/running is pending, not a failed auth verdict. A cancelled wait detaches only this transient wait; the explicit owner handle continues until cancel_authentication or run cancellation.',
|
|
703
|
+
{
|
|
704
|
+
operationId: { type: 'string' },
|
|
705
|
+
waitMs: { type: 'integer', minimum: 0, maximum: AUTHENTICATION_WAIT_MS },
|
|
706
|
+
},
|
|
707
|
+
['operationId'],
|
|
708
|
+
),
|
|
709
|
+
controlTool(
|
|
710
|
+
'cancel_authentication',
|
|
711
|
+
'cancel',
|
|
712
|
+
'Release this explicit owner. Coalesced work continues for remaining owners; the last owner cancels it and its queue ownership remains until real cleanup settles.',
|
|
713
|
+
{ operationId: { type: 'string' }, ownerRef: { type: 'string' } },
|
|
714
|
+
['operationId', 'ownerRef'],
|
|
715
|
+
),
|
|
716
|
+
];
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Child compilers request input from their parent's terminal, never from piped stdin. */
|
|
720
|
+
export async function withUserInteraction<T>(
|
|
721
|
+
interaction: UserInteraction,
|
|
722
|
+
work: () => Promise<T>,
|
|
723
|
+
): Promise<T> {
|
|
724
|
+
const token = `/${randomUUID()}`;
|
|
725
|
+
const bridgeRequests = new Map<string, AbortController>();
|
|
726
|
+
const cancelledRequests = new Set<string>();
|
|
727
|
+
const server = createServer(async (request, response) => {
|
|
728
|
+
if (
|
|
729
|
+
request.method !== 'POST' ||
|
|
730
|
+
![
|
|
731
|
+
`${token}/request`,
|
|
732
|
+
`${token}/resolve`,
|
|
733
|
+
`${token}/authentication`,
|
|
734
|
+
`${token}/cancel-request`,
|
|
735
|
+
].includes(request.url ?? '') ||
|
|
736
|
+
request.headers.origin
|
|
737
|
+
) {
|
|
738
|
+
response.writeHead(404).end();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const requestId = request.headers['x-imprint-request-id'];
|
|
742
|
+
if (typeof requestId !== 'string' || !/^[a-f0-9-]{36}$/.test(requestId)) {
|
|
743
|
+
response.writeHead(400).end();
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (request.url === `${token}/cancel-request`) {
|
|
747
|
+
const pending = bridgeRequests.get(requestId);
|
|
748
|
+
if (pending) pending.abort(new DOMException('Requesting agent cancelled', 'AbortError'));
|
|
749
|
+
else cancelledRequests.add(requestId);
|
|
750
|
+
response.writeHead(200, { 'content-type': 'application/json' }).end('{}');
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const disconnected = new AbortController();
|
|
754
|
+
bridgeRequests.set(requestId, disconnected);
|
|
755
|
+
if (cancelledRequests.delete(requestId))
|
|
756
|
+
disconnected.abort(new DOMException('Requesting agent cancelled', 'AbortError'));
|
|
757
|
+
response.on('close', () => {
|
|
758
|
+
if (!response.writableEnded)
|
|
759
|
+
disconnected.abort(new DOMException('Requesting agent disconnected', 'AbortError'));
|
|
760
|
+
});
|
|
761
|
+
try {
|
|
762
|
+
const chunks: Buffer[] = [];
|
|
763
|
+
let size = 0;
|
|
764
|
+
for await (const chunk of request) {
|
|
765
|
+
size += chunk.length;
|
|
766
|
+
if (size > 64_000) throw new Error('Interaction request is too large');
|
|
767
|
+
chunks.push(Buffer.from(chunk));
|
|
768
|
+
}
|
|
769
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
770
|
+
const result =
|
|
771
|
+
request.url === `${token}/resolve`
|
|
772
|
+
? interaction.resolve(value)
|
|
773
|
+
: request.url === `${token}/authentication`
|
|
774
|
+
? await interaction.authenticationControl(
|
|
775
|
+
AuthenticationControlSchema.parse(value),
|
|
776
|
+
disconnected.signal,
|
|
777
|
+
)
|
|
778
|
+
: await interaction.request(AgentInteractionSchema.parse(value), disconnected.signal);
|
|
779
|
+
if (request.url === `${token}/authentication` && value.action === 'start') {
|
|
780
|
+
const pendingHandle = result as AuthenticationHandle;
|
|
781
|
+
if (disconnected.signal.aborted) {
|
|
782
|
+
await interaction
|
|
783
|
+
.authenticationControl({
|
|
784
|
+
action: 'cancel',
|
|
785
|
+
operationId: pendingHandle.operationId,
|
|
786
|
+
ownerRef: pendingHandle.ownerRef,
|
|
787
|
+
})
|
|
788
|
+
.catch(() => {});
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const handle = pendingHandle;
|
|
792
|
+
response.once('close', () => {
|
|
793
|
+
if (!response.writableEnded)
|
|
794
|
+
void interaction
|
|
795
|
+
.authenticationControl({
|
|
796
|
+
action: 'cancel',
|
|
797
|
+
operationId: handle.operationId,
|
|
798
|
+
ownerRef: handle.ownerRef,
|
|
799
|
+
})
|
|
800
|
+
.catch(() => {});
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
response
|
|
804
|
+
.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
805
|
+
.end(JSON.stringify(result));
|
|
806
|
+
} catch (error) {
|
|
807
|
+
if (!response.destroyed)
|
|
808
|
+
response.writeHead(400, { 'content-type': 'application/json' }).end(
|
|
809
|
+
JSON.stringify({
|
|
810
|
+
error: interaction.sanitize(
|
|
811
|
+
error instanceof Error ? error.message : 'Interaction request failed',
|
|
812
|
+
),
|
|
813
|
+
}),
|
|
814
|
+
);
|
|
815
|
+
} finally {
|
|
816
|
+
bridgeRequests.delete(requestId);
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
await new Promise<void>((resolve, reject) => {
|
|
820
|
+
server.once('error', reject);
|
|
821
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
822
|
+
});
|
|
823
|
+
const address = server.address();
|
|
824
|
+
if (!address || typeof address === 'string') throw new Error('Interaction bridge did not open');
|
|
825
|
+
const previous = process.env[USER_INTERACTION_ENV];
|
|
826
|
+
process.env[USER_INTERACTION_ENV] = `http://127.0.0.1:${address.port}${token}`;
|
|
827
|
+
try {
|
|
828
|
+
return await interaction.run(work);
|
|
829
|
+
} finally {
|
|
830
|
+
if (previous === undefined) delete process.env[USER_INTERACTION_ENV];
|
|
831
|
+
else process.env[USER_INTERACTION_ENV] = previous;
|
|
832
|
+
await interaction.clear();
|
|
833
|
+
server.closeAllConnections();
|
|
834
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
835
|
+
}
|
|
836
|
+
}
|