@harapter/adapter-openclaw 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 +229 -0
- package/dist/adapter.d.ts +31 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +1205 -0
- package/dist/adapter.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +37 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +220 -0
- package/dist/protocol.js.map +1 -0
- package/package.json +54 -0
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { ExtensionRegistry, HarnessError, assertSessionCompatibility, assertSessionOwnership, providerSessionId, runId, } from '@harapter/core';
|
|
6
|
+
import { AcpClient, AcpClientError, } from '@harapter/transport-acp';
|
|
7
|
+
import { JsonRpcRemoteError, JsonRpcTransportError, } from '@harapter/transport-jsonrpc-stdio';
|
|
8
|
+
import { OPENCLAW_OBSERVATION_EXTENSION, OPENCLAW_PROVIDER_ID, OPENCLAW_SESSION_COMPATIBILITY_REF, mapOpenClawSessionUpdate, openClawCompatibilityIdentity, parseOpenClawRuntime, prepareOpenClawPrompt, redactOpenClawObservation, } from './protocol.js';
|
|
9
|
+
const descriptor = {
|
|
10
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
11
|
+
displayName: 'OpenClaw ACP Gateway',
|
|
12
|
+
connectionKinds: ['process'],
|
|
13
|
+
documentationUrl: 'https://docs.openclaw.ai/cli/acp',
|
|
14
|
+
};
|
|
15
|
+
const defaultOperationTimeoutMs = 30_000;
|
|
16
|
+
const defaultCancelSettlementTimeoutMs = 10_000;
|
|
17
|
+
const defaultMaxRunEvents = 128;
|
|
18
|
+
const maximumRunEvents = 4_096;
|
|
19
|
+
const maximumTimerMilliseconds = 2_147_483_647;
|
|
20
|
+
const childTerminationTimeoutMs = 2_000;
|
|
21
|
+
const forbiddenRoutingArguments = [
|
|
22
|
+
'--require-existing',
|
|
23
|
+
'--reset-session',
|
|
24
|
+
'--session',
|
|
25
|
+
'--session-label',
|
|
26
|
+
];
|
|
27
|
+
/** Create a fresh OpenClaw ACP Provider Adapter factory. */
|
|
28
|
+
export function createOpenClawProviderFactory() {
|
|
29
|
+
return {
|
|
30
|
+
descriptor: () => ({
|
|
31
|
+
...descriptor,
|
|
32
|
+
connectionKinds: [...descriptor.connectionKinds],
|
|
33
|
+
}),
|
|
34
|
+
connect: async (profile) => connectOpenClaw(profile),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function connectOpenClaw(profile) {
|
|
38
|
+
validateProfile(profile);
|
|
39
|
+
const options = connectionOptions(profile.providerOptions, profile);
|
|
40
|
+
let connected;
|
|
41
|
+
let acp;
|
|
42
|
+
try {
|
|
43
|
+
acp = await spawnAcp(profile, options.acp, (request) => connected?.requestPermission(request) ?? { outcome: 'cancelled' });
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw mapError(error, profile, 'spawn', true);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const initialized = await acp.initialize({
|
|
50
|
+
clientInfo: {
|
|
51
|
+
name: 'harapter',
|
|
52
|
+
title: 'Harapter',
|
|
53
|
+
version: '0.0.0',
|
|
54
|
+
},
|
|
55
|
+
}, { timeoutMs: options.operationTimeoutMs });
|
|
56
|
+
const runtime = parseOpenClawRuntime(initialized);
|
|
57
|
+
connected = new OpenClawClient(snapshotProfile(profile), acp, runtime, options);
|
|
58
|
+
connected.startPump();
|
|
59
|
+
return connected;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
await acp.close().catch(() => undefined);
|
|
63
|
+
throw mapError(error, profile, 'initialize', true);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
class OpenClawClient {
|
|
67
|
+
profile;
|
|
68
|
+
acp;
|
|
69
|
+
runtime;
|
|
70
|
+
options;
|
|
71
|
+
extensionRegistry = new ExtensionRegistry(OPENCLAW_PROVIDER_ID);
|
|
72
|
+
nativeClient;
|
|
73
|
+
observationListeners = new Set();
|
|
74
|
+
unknownListeners = new Set();
|
|
75
|
+
sessions = new Map();
|
|
76
|
+
sessionReservations = new Set();
|
|
77
|
+
pendingApprovals = new Map();
|
|
78
|
+
activeRun;
|
|
79
|
+
approvalObserved = false;
|
|
80
|
+
closed = false;
|
|
81
|
+
closePromise;
|
|
82
|
+
interactionSerial = 0;
|
|
83
|
+
runSerial = 0;
|
|
84
|
+
constructor(profile, acp, runtime, options) {
|
|
85
|
+
this.profile = profile;
|
|
86
|
+
this.acp = acp;
|
|
87
|
+
this.runtime = runtime;
|
|
88
|
+
this.options = options;
|
|
89
|
+
const observer = Object.freeze({
|
|
90
|
+
onObservation: (listener) => {
|
|
91
|
+
this.observationListeners.add(listener);
|
|
92
|
+
return () => this.observationListeners.delete(listener);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
this.extensionRegistry.register({
|
|
96
|
+
name: OPENCLAW_OBSERVATION_EXTENSION,
|
|
97
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
98
|
+
displayName: 'OpenClaw ACP observation channel',
|
|
99
|
+
description: 'Bounded, redacted unknown ACP observations.',
|
|
100
|
+
documentationUrl: 'https://docs.openclaw.ai/cli/acp',
|
|
101
|
+
stability: 'experimental',
|
|
102
|
+
}, observer);
|
|
103
|
+
this.nativeClient = Object.freeze({
|
|
104
|
+
runtimeIdentity: this.runtimeIdentity(),
|
|
105
|
+
requestExtension: (method, params, requestOptions) => this.acp.requestExtension(method, params, requestOptions),
|
|
106
|
+
notifyExtension: (method, params) => this.acp.notifyExtension(method, params),
|
|
107
|
+
onUnknownEvent: (listener) => {
|
|
108
|
+
this.unknownListeners.add(listener);
|
|
109
|
+
return () => this.unknownListeners.delete(listener);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
descriptor() {
|
|
114
|
+
return Promise.resolve({
|
|
115
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
116
|
+
profileId: this.profile.profileId,
|
|
117
|
+
displayName: this.profile.displayName,
|
|
118
|
+
connectionKind: 'process',
|
|
119
|
+
runtime: {
|
|
120
|
+
name: this.runtime.name,
|
|
121
|
+
version: this.runtime.version,
|
|
122
|
+
protocol: 'ACP over stdio JSON-RPC 2.0',
|
|
123
|
+
protocolVersion: '1',
|
|
124
|
+
},
|
|
125
|
+
compatibility: 'supported',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
capabilities() {
|
|
129
|
+
return Promise.resolve(this.capabilityManifest());
|
|
130
|
+
}
|
|
131
|
+
async createSession(input = {}) {
|
|
132
|
+
this.assertOpen();
|
|
133
|
+
const cwd = prepareSessionInput(input, this.profile);
|
|
134
|
+
const sessionKey = `acp-bridge:harapter-${randomUUID()}`;
|
|
135
|
+
try {
|
|
136
|
+
const created = await this.acp.newSession({
|
|
137
|
+
cwd,
|
|
138
|
+
mcpServers: [],
|
|
139
|
+
_meta: { sessionKey },
|
|
140
|
+
}, { timeoutMs: this.options.operationTimeoutMs });
|
|
141
|
+
const sessionId = providerSessionId(created.sessionId);
|
|
142
|
+
this.assertSessionReusable(sessionId);
|
|
143
|
+
const session = new OpenClawSession(this, sessionId, {
|
|
144
|
+
strategy: 'isolated',
|
|
145
|
+
sessionKey,
|
|
146
|
+
cwd,
|
|
147
|
+
});
|
|
148
|
+
this.sessions.set(sessionId, session);
|
|
149
|
+
return session;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw this.operationError(error, 'create Session', 'session_create_uncertain');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async resumeSession(ref) {
|
|
156
|
+
this.assertOpen();
|
|
157
|
+
assertSessionOwnership(ref, OPENCLAW_PROVIDER_ID, this.profile.profileId);
|
|
158
|
+
assertSessionCompatibility(ref, OPENCLAW_SESSION_COMPATIBILITY_REF);
|
|
159
|
+
const state = sessionStateFromRef(ref);
|
|
160
|
+
this.reserveSession(ref.providerSessionId);
|
|
161
|
+
try {
|
|
162
|
+
await this.acp.resumeSession({
|
|
163
|
+
sessionId: ref.providerSessionId,
|
|
164
|
+
cwd: state.cwd,
|
|
165
|
+
_meta: {
|
|
166
|
+
sessionKey: state.sessionKey,
|
|
167
|
+
requireExisting: true,
|
|
168
|
+
},
|
|
169
|
+
}, { timeoutMs: this.options.operationTimeoutMs });
|
|
170
|
+
const session = new OpenClawSession(this, ref.providerSessionId, state);
|
|
171
|
+
this.sessions.set(ref.providerSessionId, session);
|
|
172
|
+
this.sessionReservations.delete(ref.providerSessionId);
|
|
173
|
+
return session;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
this.sessionReservations.delete(ref.providerSessionId);
|
|
177
|
+
throw this.operationError(error, 'resume Session', 'session_resume_uncertain');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
extensions() {
|
|
181
|
+
return this.extensionRegistry;
|
|
182
|
+
}
|
|
183
|
+
native(guard) {
|
|
184
|
+
const value = this.nativeClient;
|
|
185
|
+
return guard !== undefined && !guard(value) ? undefined : value;
|
|
186
|
+
}
|
|
187
|
+
close() {
|
|
188
|
+
this.closePromise ??= this.closeOnce('client_closed');
|
|
189
|
+
return this.closePromise;
|
|
190
|
+
}
|
|
191
|
+
startPump() {
|
|
192
|
+
void this.pump();
|
|
193
|
+
}
|
|
194
|
+
sessionRef(sessionId, state) {
|
|
195
|
+
return {
|
|
196
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
197
|
+
profileId: this.profile.profileId,
|
|
198
|
+
providerSessionId: sessionId,
|
|
199
|
+
compatibilityRef: OPENCLAW_SESSION_COMPATIBILITY_REF,
|
|
200
|
+
providerState: { ...state },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
capabilityManifest() {
|
|
204
|
+
return openClawCapabilities(this.profile, this.runtime.capabilities, this.runtimeIdentity(), this.approvalObserved);
|
|
205
|
+
}
|
|
206
|
+
hasActiveRun(sessionId) {
|
|
207
|
+
return (this.activeRun !== undefined &&
|
|
208
|
+
(sessionId === undefined || this.activeRun.ref().sessionId === sessionId));
|
|
209
|
+
}
|
|
210
|
+
startRun(sessionId, input, options = {}) {
|
|
211
|
+
this.assertOpen();
|
|
212
|
+
if (this.activeRun !== undefined) {
|
|
213
|
+
throw new HarnessError('run_conflict', 'OpenClaw ACP allows one active Harapter Run per connection.', {
|
|
214
|
+
retryable: false,
|
|
215
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
216
|
+
profileId: this.profile.profileId,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
validateRunOptions(options);
|
|
220
|
+
const prompt = prepareOpenClawPrompt(input, {
|
|
221
|
+
image: this.runtime.capabilities.prompt.image,
|
|
222
|
+
});
|
|
223
|
+
const run = new OpenClawRun({
|
|
224
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
225
|
+
profileId: this.profile.profileId,
|
|
226
|
+
sessionId,
|
|
227
|
+
runId: runId(`openclaw-run-${String(++this.runSerial)}`),
|
|
228
|
+
}, this.options.maxRunEvents, options.timeoutMs, this.options.cancelSettlementTimeoutMs, () => this.acp.cancelSession(sessionId), (reason) => {
|
|
229
|
+
this.abortConnection(reason);
|
|
230
|
+
}, (terminal) => {
|
|
231
|
+
this.settleApprovals(terminal);
|
|
232
|
+
if (this.activeRun === terminal)
|
|
233
|
+
this.activeRun = undefined;
|
|
234
|
+
});
|
|
235
|
+
this.activeRun = run;
|
|
236
|
+
void this.acp
|
|
237
|
+
.prompt({ sessionId, prompt })
|
|
238
|
+
.then((result) => {
|
|
239
|
+
run.finishPrompt(result.stopReason);
|
|
240
|
+
})
|
|
241
|
+
.catch((error) => {
|
|
242
|
+
this.failPrompt(run, error);
|
|
243
|
+
});
|
|
244
|
+
return Promise.resolve(run);
|
|
245
|
+
}
|
|
246
|
+
respond(sessionId, requestId, response) {
|
|
247
|
+
this.assertOpen();
|
|
248
|
+
const pending = this.pendingApprovals.get(requestId);
|
|
249
|
+
if (pending?.run.ref().sessionId !== sessionId ||
|
|
250
|
+
response.kind !== 'approval') {
|
|
251
|
+
throw new HarnessError('invalid_request', 'OpenClaw interaction response does not match an active approval.', {
|
|
252
|
+
retryable: false,
|
|
253
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
254
|
+
profileId: this.profile.profileId,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const option = selectPermissionOption(pending.request.options, response);
|
|
258
|
+
this.pendingApprovals.delete(requestId);
|
|
259
|
+
pending.run.resolveInteraction(requestId, response.decision);
|
|
260
|
+
pending.settle({ outcome: 'selected', optionId: option.optionId });
|
|
261
|
+
return Promise.resolve();
|
|
262
|
+
}
|
|
263
|
+
async closeSession(session) {
|
|
264
|
+
const sessionId = session.ref().providerSessionId;
|
|
265
|
+
if (!this.sessions.has(sessionId))
|
|
266
|
+
return;
|
|
267
|
+
if (this.hasActiveRun(sessionId)) {
|
|
268
|
+
throw new HarnessError('run_conflict', 'Cannot close an OpenClaw Session with an active Run.', {
|
|
269
|
+
retryable: false,
|
|
270
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
271
|
+
profileId: this.profile.profileId,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
this.assertOpen();
|
|
275
|
+
this.sessionReservations.add(sessionId);
|
|
276
|
+
try {
|
|
277
|
+
await this.acp.closeSession(sessionId, {
|
|
278
|
+
timeoutMs: this.options.operationTimeoutMs,
|
|
279
|
+
});
|
|
280
|
+
this.sessions.delete(sessionId);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
throw this.operationError(error, 'close Session', 'session_close_uncertain');
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
this.sessionReservations.delete(sessionId);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
requestPermission(request) {
|
|
290
|
+
const run = this.activeRun;
|
|
291
|
+
if (this.closed ||
|
|
292
|
+
run?.ref().sessionId !== request.sessionId ||
|
|
293
|
+
request.options.length === 0) {
|
|
294
|
+
return { outcome: 'cancelled' };
|
|
295
|
+
}
|
|
296
|
+
this.approvalObserved = true;
|
|
297
|
+
const localId = `openclaw-approval-${String(++this.interactionSerial)}`;
|
|
298
|
+
return new Promise((settle) => {
|
|
299
|
+
this.pendingApprovals.set(localId, {
|
|
300
|
+
localId,
|
|
301
|
+
request,
|
|
302
|
+
run,
|
|
303
|
+
settle,
|
|
304
|
+
});
|
|
305
|
+
run.requestInteraction(localId, request);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
async pump() {
|
|
309
|
+
try {
|
|
310
|
+
for await (const event of this.acp.events())
|
|
311
|
+
this.handleEvent(event);
|
|
312
|
+
if (!this.closed)
|
|
313
|
+
this.abortConnection('transport_ended');
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (this.closed)
|
|
317
|
+
return;
|
|
318
|
+
if (isProtocolFailure(error)) {
|
|
319
|
+
this.activeRun?.failProtocol('provider_api_incompatible');
|
|
320
|
+
this.abortConnection('protocol_incompatible');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
this.abortConnection('transport_ended');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
handleEvent(event) {
|
|
327
|
+
if (event.kind === 'unknown') {
|
|
328
|
+
const observation = redactOpenClawObservation(event.observation);
|
|
329
|
+
emitToListeners(this.observationListeners, observation);
|
|
330
|
+
emitToListeners(this.unknownListeners, observation);
|
|
331
|
+
this.activeRun?.receiveUnknown(event.observation, observation);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const run = this.activeRun;
|
|
335
|
+
if (run?.ref().sessionId !== event.sessionId)
|
|
336
|
+
return;
|
|
337
|
+
for (const mapped of mapOpenClawSessionUpdate(event.update)) {
|
|
338
|
+
run.receive(mapped);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
failPrompt(run, error) {
|
|
342
|
+
if (run.isTerminal())
|
|
343
|
+
return;
|
|
344
|
+
if (isProtocolFailure(error)) {
|
|
345
|
+
run.failProtocol('provider_api_incompatible');
|
|
346
|
+
this.abortConnection('protocol_incompatible');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const mapped = mapError(error, this.profile, 'prompt');
|
|
350
|
+
if (mapped.code === 'connection_aborted' || mapped.code === 'timeout') {
|
|
351
|
+
this.abortConnection(mapped.code === 'timeout' ? 'prompt_wait_uncertain' : 'transport_ended');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
run.failProtocol(mapped.code);
|
|
355
|
+
}
|
|
356
|
+
settleApprovals(run) {
|
|
357
|
+
for (const pending of [...this.pendingApprovals.values()]) {
|
|
358
|
+
if (pending.run !== run)
|
|
359
|
+
continue;
|
|
360
|
+
this.pendingApprovals.delete(pending.localId);
|
|
361
|
+
pending.run.resolveInteraction(pending.localId, 'cancelled');
|
|
362
|
+
pending.settle({ outcome: 'cancelled' });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
abortConnection(reason) {
|
|
366
|
+
if (this.closed)
|
|
367
|
+
return;
|
|
368
|
+
this.closed = true;
|
|
369
|
+
const active = this.activeRun;
|
|
370
|
+
if (active !== undefined) {
|
|
371
|
+
this.settleApprovals(active);
|
|
372
|
+
active.abortConnection(reason);
|
|
373
|
+
this.activeRun = undefined;
|
|
374
|
+
}
|
|
375
|
+
void this.acp.close().catch(() => undefined);
|
|
376
|
+
}
|
|
377
|
+
async closeOnce(reason) {
|
|
378
|
+
if (!this.closed) {
|
|
379
|
+
this.closed = true;
|
|
380
|
+
const active = this.activeRun;
|
|
381
|
+
if (active !== undefined) {
|
|
382
|
+
this.settleApprovals(active);
|
|
383
|
+
active.abortConnection(reason);
|
|
384
|
+
this.activeRun = undefined;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await this.acp.close();
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
throw mapError(error, this.profile, 'close');
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
assertSessionReusable(sessionId) {
|
|
395
|
+
if (this.sessions.has(sessionId) ||
|
|
396
|
+
this.sessionReservations.has(sessionId)) {
|
|
397
|
+
throw new HarnessError('session_provider_mismatch', 'OpenClaw returned a Session identifier already active on this connection.', {
|
|
398
|
+
retryable: false,
|
|
399
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
400
|
+
profileId: this.profile.profileId,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
reserveSession(sessionId) {
|
|
405
|
+
this.assertSessionReusable(sessionId);
|
|
406
|
+
this.sessionReservations.add(sessionId);
|
|
407
|
+
}
|
|
408
|
+
operationError(error, phase, uncertaintyReason) {
|
|
409
|
+
const mapped = mapError(error, this.profile, phase);
|
|
410
|
+
if (mapped.code !== 'timeout' && mapped.code !== 'connection_aborted') {
|
|
411
|
+
return mapped;
|
|
412
|
+
}
|
|
413
|
+
this.abortConnection(uncertaintyReason);
|
|
414
|
+
return new HarnessError('connection_aborted', `OpenClaw ${phase} did not establish an authoritative outcome.`, {
|
|
415
|
+
retryable: false,
|
|
416
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
417
|
+
profileId: this.profile.profileId,
|
|
418
|
+
...(mapped.providerCode === undefined
|
|
419
|
+
? {}
|
|
420
|
+
: { providerCode: mapped.providerCode }),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
runtimeIdentity() {
|
|
424
|
+
return openClawCompatibilityIdentity(this.runtime.version);
|
|
425
|
+
}
|
|
426
|
+
assertOpen() {
|
|
427
|
+
if (this.closed || !this.acp.isOpen()) {
|
|
428
|
+
throw new HarnessError('connection_aborted', 'The OpenClaw ACP connection is closed.', {
|
|
429
|
+
retryable: false,
|
|
430
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
431
|
+
profileId: this.profile.profileId,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
class OpenClawSession {
|
|
437
|
+
client;
|
|
438
|
+
sessionId;
|
|
439
|
+
state;
|
|
440
|
+
lifecycleState = 'open';
|
|
441
|
+
closePromise;
|
|
442
|
+
constructor(client, sessionId, state) {
|
|
443
|
+
this.client = client;
|
|
444
|
+
this.sessionId = sessionId;
|
|
445
|
+
this.state = state;
|
|
446
|
+
}
|
|
447
|
+
ref() {
|
|
448
|
+
return this.client.sessionRef(this.sessionId, this.state);
|
|
449
|
+
}
|
|
450
|
+
capabilities() {
|
|
451
|
+
return Promise.resolve(this.client.capabilityManifest());
|
|
452
|
+
}
|
|
453
|
+
async start(input, options) {
|
|
454
|
+
this.assertOpen();
|
|
455
|
+
return this.client.startRun(this.sessionId, input, options);
|
|
456
|
+
}
|
|
457
|
+
async respond(requestId, response) {
|
|
458
|
+
this.assertOpen();
|
|
459
|
+
return this.client.respond(this.sessionId, requestId, response);
|
|
460
|
+
}
|
|
461
|
+
close() {
|
|
462
|
+
if (this.closePromise)
|
|
463
|
+
return this.closePromise;
|
|
464
|
+
this.lifecycleState = 'closing';
|
|
465
|
+
const attempt = this.closeOnce();
|
|
466
|
+
this.closePromise = attempt;
|
|
467
|
+
void attempt.catch((error) => {
|
|
468
|
+
if (this.closePromise === attempt) {
|
|
469
|
+
if (error instanceof HarnessError &&
|
|
470
|
+
error.code === 'connection_aborted') {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
this.closePromise = undefined;
|
|
474
|
+
this.lifecycleState = 'open';
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
return attempt;
|
|
478
|
+
}
|
|
479
|
+
async closeOnce() {
|
|
480
|
+
await this.client.closeSession(this);
|
|
481
|
+
this.lifecycleState = 'closed';
|
|
482
|
+
}
|
|
483
|
+
assertOpen() {
|
|
484
|
+
if (this.lifecycleState !== 'open') {
|
|
485
|
+
throw new HarnessError('session_not_found', 'The OpenClaw Session is closed.', {
|
|
486
|
+
retryable: false,
|
|
487
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
488
|
+
profileId: this.ref().profileId,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
class OpenClawRun {
|
|
494
|
+
reference;
|
|
495
|
+
cancelSettlementTimeoutMs;
|
|
496
|
+
sendCancel;
|
|
497
|
+
abortOwnerConnection;
|
|
498
|
+
onTerminal;
|
|
499
|
+
eventQueue;
|
|
500
|
+
settlement;
|
|
501
|
+
timeout;
|
|
502
|
+
cancelPromise;
|
|
503
|
+
cancelReason;
|
|
504
|
+
finalMessage = '';
|
|
505
|
+
finalResult;
|
|
506
|
+
reasoning = '';
|
|
507
|
+
resolveSettlement;
|
|
508
|
+
sequence = 0;
|
|
509
|
+
usage;
|
|
510
|
+
constructor(reference, maxRunEvents, timeoutMs, cancelSettlementTimeoutMs, sendCancel, abortOwnerConnection, onTerminal) {
|
|
511
|
+
this.reference = reference;
|
|
512
|
+
this.cancelSettlementTimeoutMs = cancelSettlementTimeoutMs;
|
|
513
|
+
this.sendCancel = sendCancel;
|
|
514
|
+
this.abortOwnerConnection = abortOwnerConnection;
|
|
515
|
+
this.onTerminal = onTerminal;
|
|
516
|
+
validateRunTimeout(timeoutMs);
|
|
517
|
+
this.eventQueue = new EventQueue(maxRunEvents);
|
|
518
|
+
this.settlement = new Promise((resolveSettlement) => {
|
|
519
|
+
this.resolveSettlement = resolveSettlement;
|
|
520
|
+
});
|
|
521
|
+
this.timeout =
|
|
522
|
+
timeoutMs === undefined
|
|
523
|
+
? undefined
|
|
524
|
+
: setTimeout(() => {
|
|
525
|
+
this.cancelReason = 'timeout';
|
|
526
|
+
void this.cancel().catch(() => {
|
|
527
|
+
this.abortOwnerConnection('timeout_cancellation_failed');
|
|
528
|
+
});
|
|
529
|
+
}, timeoutMs);
|
|
530
|
+
this.timeout?.unref();
|
|
531
|
+
this.emit({ type: 'run.started', data: {} });
|
|
532
|
+
}
|
|
533
|
+
ref() {
|
|
534
|
+
return { ...this.reference };
|
|
535
|
+
}
|
|
536
|
+
events() {
|
|
537
|
+
return this.eventQueue.iterable();
|
|
538
|
+
}
|
|
539
|
+
cancel() {
|
|
540
|
+
if (this.isTerminal())
|
|
541
|
+
return Promise.resolve({ mode: 'already_terminal' });
|
|
542
|
+
this.cancelPromise ??= this.cancelOnce();
|
|
543
|
+
return this.cancelPromise;
|
|
544
|
+
}
|
|
545
|
+
result() {
|
|
546
|
+
return this.settlement;
|
|
547
|
+
}
|
|
548
|
+
receive(mapped) {
|
|
549
|
+
if (this.isTerminal())
|
|
550
|
+
return;
|
|
551
|
+
if (mapped.messageDelta !== undefined) {
|
|
552
|
+
this.finalMessage += mapped.messageDelta;
|
|
553
|
+
}
|
|
554
|
+
if (mapped.reasoningDelta !== undefined)
|
|
555
|
+
this.reasoning += mapped.reasoningDelta;
|
|
556
|
+
if (mapped.usage !== undefined)
|
|
557
|
+
this.usage = mapped.usage;
|
|
558
|
+
this.emit(mapped);
|
|
559
|
+
}
|
|
560
|
+
receiveUnknown(observation, redacted) {
|
|
561
|
+
if (this.isTerminal())
|
|
562
|
+
return;
|
|
563
|
+
this.emit({
|
|
564
|
+
type: 'provider',
|
|
565
|
+
data: { method: observation.method },
|
|
566
|
+
providerEventType: observation.method,
|
|
567
|
+
raw: redacted,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
requestInteraction(requestId, request) {
|
|
571
|
+
if (this.isTerminal())
|
|
572
|
+
return;
|
|
573
|
+
this.emit({
|
|
574
|
+
type: 'interaction.requested',
|
|
575
|
+
data: {
|
|
576
|
+
requestId,
|
|
577
|
+
kind: 'approval',
|
|
578
|
+
...(request.toolCall.title === undefined ||
|
|
579
|
+
request.toolCall.title === null
|
|
580
|
+
? {}
|
|
581
|
+
: { title: request.toolCall.title.slice(0, 128) }),
|
|
582
|
+
schema: {
|
|
583
|
+
options: request.options.map(({ kind, name, optionId }) => ({
|
|
584
|
+
kind,
|
|
585
|
+
name: name.slice(0, 128),
|
|
586
|
+
optionId,
|
|
587
|
+
})),
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
resolveInteraction(requestId, decision) {
|
|
593
|
+
if (this.isTerminal())
|
|
594
|
+
return;
|
|
595
|
+
this.emit({
|
|
596
|
+
type: 'interaction.resolved',
|
|
597
|
+
data: { requestId, kind: 'approval', decision },
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
finishPrompt(stopReason) {
|
|
601
|
+
if (this.isTerminal())
|
|
602
|
+
return;
|
|
603
|
+
if (stopReason === 'end_turn') {
|
|
604
|
+
this.finish({
|
|
605
|
+
status: 'completed',
|
|
606
|
+
...(this.finalMessage.length === 0
|
|
607
|
+
? {}
|
|
608
|
+
: { finalMessage: this.finalMessage }),
|
|
609
|
+
...(this.usage === undefined ? {} : { usage: this.usage }),
|
|
610
|
+
providerResult: { stopReason },
|
|
611
|
+
}, 'run.completed');
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (stopReason === 'cancelled') {
|
|
615
|
+
this.finish({
|
|
616
|
+
status: 'cancelled',
|
|
617
|
+
...(this.usage === undefined ? {} : { usage: this.usage }),
|
|
618
|
+
providerResult: {
|
|
619
|
+
stopReason,
|
|
620
|
+
...(this.cancelReason === undefined
|
|
621
|
+
? {}
|
|
622
|
+
: { reason: this.cancelReason }),
|
|
623
|
+
},
|
|
624
|
+
}, 'run.cancelled');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (stopReason === 'refusal' ||
|
|
628
|
+
stopReason === 'max_tokens' ||
|
|
629
|
+
stopReason === 'max_turn_requests') {
|
|
630
|
+
this.finish({ status: 'failed', providerResult: { stopReason } }, 'run.failed');
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
this.failProtocol('provider_api_incompatible');
|
|
634
|
+
}
|
|
635
|
+
failProtocol(reason) {
|
|
636
|
+
if (this.isTerminal())
|
|
637
|
+
return;
|
|
638
|
+
this.finish({ status: 'failed', providerResult: { reason } }, 'run.failed');
|
|
639
|
+
}
|
|
640
|
+
abortConnection(reason) {
|
|
641
|
+
if (this.isTerminal())
|
|
642
|
+
return;
|
|
643
|
+
this.finish({ status: 'connection_aborted', providerResult: { reason } }, 'connection.aborted');
|
|
644
|
+
}
|
|
645
|
+
isTerminal() {
|
|
646
|
+
return this.finalResult !== undefined;
|
|
647
|
+
}
|
|
648
|
+
async cancelOnce() {
|
|
649
|
+
try {
|
|
650
|
+
await this.sendCancel();
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
this.abortOwnerConnection('cancel_notification_failed');
|
|
654
|
+
return { mode: 'connection_aborted' };
|
|
655
|
+
}
|
|
656
|
+
const result = await withTimeout(this.settlement, this.cancelSettlementTimeoutMs);
|
|
657
|
+
if (result === undefined) {
|
|
658
|
+
this.abortOwnerConnection('cancel_terminal_timeout');
|
|
659
|
+
return { mode: 'connection_aborted' };
|
|
660
|
+
}
|
|
661
|
+
if (result.status === 'cancelled')
|
|
662
|
+
return { mode: 'native' };
|
|
663
|
+
if (result.status === 'connection_aborted') {
|
|
664
|
+
return { mode: 'connection_aborted' };
|
|
665
|
+
}
|
|
666
|
+
return { mode: 'already_terminal' };
|
|
667
|
+
}
|
|
668
|
+
finish(result, type) {
|
|
669
|
+
if (this.isTerminal())
|
|
670
|
+
return;
|
|
671
|
+
if (this.timeout !== undefined)
|
|
672
|
+
clearTimeout(this.timeout);
|
|
673
|
+
let terminalResult = result;
|
|
674
|
+
let terminalType = type;
|
|
675
|
+
let overflowed = false;
|
|
676
|
+
if (this.finalMessage.length > 0) {
|
|
677
|
+
overflowed = !this.eventQueue.push(this.portableEvent({
|
|
678
|
+
type: 'message.completed',
|
|
679
|
+
data: { text: this.finalMessage },
|
|
680
|
+
}));
|
|
681
|
+
}
|
|
682
|
+
if (!overflowed && this.reasoning.length > 0) {
|
|
683
|
+
overflowed = !this.eventQueue.push(this.portableEvent({
|
|
684
|
+
type: 'reasoning.completed',
|
|
685
|
+
data: { text: this.reasoning },
|
|
686
|
+
}));
|
|
687
|
+
}
|
|
688
|
+
if (overflowed) {
|
|
689
|
+
terminalResult = {
|
|
690
|
+
status: 'connection_aborted',
|
|
691
|
+
providerResult: { reason: 'event_buffer_overflow' },
|
|
692
|
+
};
|
|
693
|
+
terminalType = 'connection.aborted';
|
|
694
|
+
}
|
|
695
|
+
this.finalResult = terminalResult;
|
|
696
|
+
this.onTerminal(this);
|
|
697
|
+
this.eventQueue.pushTerminal(this.portableEvent({ type: terminalType, data: terminalResult }));
|
|
698
|
+
this.eventQueue.close();
|
|
699
|
+
this.resolveSettlement(terminalResult);
|
|
700
|
+
if (overflowed)
|
|
701
|
+
this.abortOwnerConnection('event_buffer_overflow');
|
|
702
|
+
}
|
|
703
|
+
emit(mapped) {
|
|
704
|
+
if (this.finalResult !== undefined)
|
|
705
|
+
return;
|
|
706
|
+
if (!this.eventQueue.push(this.portableEvent(mapped))) {
|
|
707
|
+
this.abortOwnerConnection('event_buffer_overflow');
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
portableEvent(mapped) {
|
|
711
|
+
const sequence = this.sequence++;
|
|
712
|
+
return {
|
|
713
|
+
id: `${this.reference.runId}:event:${String(sequence)}`,
|
|
714
|
+
type: mapped.type,
|
|
715
|
+
providerId: this.reference.providerId,
|
|
716
|
+
profileId: this.reference.profileId,
|
|
717
|
+
sessionId: this.reference.sessionId,
|
|
718
|
+
runId: this.reference.runId,
|
|
719
|
+
sequence,
|
|
720
|
+
timestamp: new Date().toISOString(),
|
|
721
|
+
data: mapped.data,
|
|
722
|
+
...(mapped.providerEventType === undefined
|
|
723
|
+
? {}
|
|
724
|
+
: { providerEventType: mapped.providerEventType }),
|
|
725
|
+
...(mapped.raw === undefined ? {} : { raw: mapped.raw }),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
class EventQueue {
|
|
730
|
+
capacity;
|
|
731
|
+
values = [];
|
|
732
|
+
closed = false;
|
|
733
|
+
consumed = false;
|
|
734
|
+
waiter;
|
|
735
|
+
constructor(capacity) {
|
|
736
|
+
this.capacity = capacity;
|
|
737
|
+
}
|
|
738
|
+
push(event) {
|
|
739
|
+
if (this.closed)
|
|
740
|
+
return false;
|
|
741
|
+
if (this.waiter !== undefined) {
|
|
742
|
+
const waiter = this.waiter;
|
|
743
|
+
this.waiter = undefined;
|
|
744
|
+
waiter({ done: false, value: event });
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
if (this.values.length >= this.capacity - 1)
|
|
748
|
+
return false;
|
|
749
|
+
this.values.push(event);
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
pushTerminal(event) {
|
|
753
|
+
if (this.waiter !== undefined) {
|
|
754
|
+
const waiter = this.waiter;
|
|
755
|
+
this.waiter = undefined;
|
|
756
|
+
waiter({ done: false, value: event });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
this.values.push(event);
|
|
760
|
+
}
|
|
761
|
+
close() {
|
|
762
|
+
this.closed = true;
|
|
763
|
+
}
|
|
764
|
+
iterable() {
|
|
765
|
+
if (this.consumed) {
|
|
766
|
+
return {
|
|
767
|
+
[Symbol.asyncIterator]: () => ({
|
|
768
|
+
next: () => Promise.reject(new HarnessError('run_conflict', 'OpenClaw Run events already have a consumer.', { retryable: false, providerId: OPENCLAW_PROVIDER_ID })),
|
|
769
|
+
}),
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
this.consumed = true;
|
|
773
|
+
return {
|
|
774
|
+
[Symbol.asyncIterator]: () => ({ next: () => this.next() }),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
next() {
|
|
778
|
+
const event = this.values.shift();
|
|
779
|
+
if (event !== undefined)
|
|
780
|
+
return Promise.resolve({ done: false, value: event });
|
|
781
|
+
if (this.closed)
|
|
782
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
783
|
+
if (this.waiter !== undefined) {
|
|
784
|
+
return Promise.reject(new HarnessError('run_conflict', 'An OpenClaw Run event read is already pending.', { retryable: false, providerId: OPENCLAW_PROVIDER_ID }));
|
|
785
|
+
}
|
|
786
|
+
return new Promise((resolveNext) => {
|
|
787
|
+
this.waiter = resolveNext;
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
async function spawnAcp(profile, options, requestPermission) {
|
|
792
|
+
if (profile.connection.kind !== 'process')
|
|
793
|
+
throw profileInvalid(profile);
|
|
794
|
+
const child = spawn(profile.connection.command, [...(profile.connection.args ?? [])], {
|
|
795
|
+
cwd: profile.connection.cwd,
|
|
796
|
+
shell: false,
|
|
797
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
798
|
+
});
|
|
799
|
+
await processStarted(child);
|
|
800
|
+
try {
|
|
801
|
+
return new AcpClient({
|
|
802
|
+
...options,
|
|
803
|
+
readable: child.stdout,
|
|
804
|
+
writable: child.stdin,
|
|
805
|
+
requestPermission,
|
|
806
|
+
cleanup: () => terminateChild(child),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
await terminateChild(child);
|
|
811
|
+
throw error;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
function processStarted(child) {
|
|
815
|
+
return new Promise((resolveStarted, rejectStarted) => {
|
|
816
|
+
const onSpawn = () => {
|
|
817
|
+
child.off('error', onError);
|
|
818
|
+
child.on('error', () => undefined);
|
|
819
|
+
resolveStarted();
|
|
820
|
+
};
|
|
821
|
+
const onError = (error) => {
|
|
822
|
+
child.off('spawn', onSpawn);
|
|
823
|
+
rejectStarted(error);
|
|
824
|
+
};
|
|
825
|
+
child.once('spawn', onSpawn);
|
|
826
|
+
child.once('error', onError);
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
async function terminateChild(child) {
|
|
830
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
831
|
+
return;
|
|
832
|
+
child.kill();
|
|
833
|
+
if (await waitForChildExit(child, childTerminationTimeoutMs))
|
|
834
|
+
return;
|
|
835
|
+
child.kill('SIGKILL');
|
|
836
|
+
if (await waitForChildExit(child, childTerminationTimeoutMs))
|
|
837
|
+
return;
|
|
838
|
+
throw new Error('OpenClaw ACP child process did not exit.');
|
|
839
|
+
}
|
|
840
|
+
function waitForChildExit(child, timeoutMs) {
|
|
841
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
842
|
+
return Promise.resolve(true);
|
|
843
|
+
}
|
|
844
|
+
return new Promise((resolveExit) => {
|
|
845
|
+
let settled = false;
|
|
846
|
+
const onExit = () => {
|
|
847
|
+
finish(true);
|
|
848
|
+
};
|
|
849
|
+
const timer = setTimeout(() => {
|
|
850
|
+
finish(false);
|
|
851
|
+
}, timeoutMs);
|
|
852
|
+
function finish(exited) {
|
|
853
|
+
if (settled)
|
|
854
|
+
return;
|
|
855
|
+
settled = true;
|
|
856
|
+
clearTimeout(timer);
|
|
857
|
+
child.off('exit', onExit);
|
|
858
|
+
resolveExit(exited);
|
|
859
|
+
}
|
|
860
|
+
child.once('exit', onExit);
|
|
861
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
862
|
+
finish(true);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
function openClawCapabilities(profile, capabilities, runtimeIdentity, approvalObserved) {
|
|
866
|
+
const native = { mode: 'native', source: 'handshake' };
|
|
867
|
+
const unsupported = {
|
|
868
|
+
mode: 'unsupported',
|
|
869
|
+
source: 'handshake',
|
|
870
|
+
};
|
|
871
|
+
return {
|
|
872
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
873
|
+
profileId: profile.profileId,
|
|
874
|
+
capabilities: {
|
|
875
|
+
'session.create': native,
|
|
876
|
+
'session.resume': capabilities.session.resume ? native : unsupported,
|
|
877
|
+
'session.close': capabilities.session.close ? native : unsupported,
|
|
878
|
+
'session.workspace': {
|
|
879
|
+
mode: 'unknown',
|
|
880
|
+
reason: 'ACP accepts cwd, but Gateway tool execution in that workspace lacks live evidence.',
|
|
881
|
+
source: 'schema',
|
|
882
|
+
},
|
|
883
|
+
'session.fork': unsupported,
|
|
884
|
+
'run.stream': native,
|
|
885
|
+
'run.cancel': { mode: 'native', source: 'schema' },
|
|
886
|
+
'run.timeout': {
|
|
887
|
+
mode: 'emulated',
|
|
888
|
+
reason: 'A local timer requests native ACP cancellation.',
|
|
889
|
+
source: 'configuration',
|
|
890
|
+
},
|
|
891
|
+
'run.concurrent': {
|
|
892
|
+
mode: 'unsupported',
|
|
893
|
+
limits: { maxActiveRunsPerConnection: 1 },
|
|
894
|
+
source: 'configuration',
|
|
895
|
+
},
|
|
896
|
+
'connection.abort': {
|
|
897
|
+
mode: 'adapter_controlled',
|
|
898
|
+
source: 'configuration',
|
|
899
|
+
},
|
|
900
|
+
'input.text': native,
|
|
901
|
+
'input.image': capabilities.prompt.image ? native : unsupported,
|
|
902
|
+
'input.file': unsupported,
|
|
903
|
+
'interaction.approval': approvalObserved
|
|
904
|
+
? { mode: 'native', source: 'schema' }
|
|
905
|
+
: {
|
|
906
|
+
mode: 'unknown',
|
|
907
|
+
reason: 'ACP initialization does not advertise permission requests.',
|
|
908
|
+
source: 'handshake',
|
|
909
|
+
},
|
|
910
|
+
'interaction.user_input': unsupported,
|
|
911
|
+
'interaction.provider': unsupported,
|
|
912
|
+
'event.raw': { mode: 'adapter_controlled', source: 'configuration' },
|
|
913
|
+
'native.client': { mode: 'native', source: 'schema' },
|
|
914
|
+
},
|
|
915
|
+
observedAt: new Date().toISOString(),
|
|
916
|
+
runtimeIdentity,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
function connectionOptions(value, profile) {
|
|
920
|
+
const options = value ?? {};
|
|
921
|
+
const allowed = new Set([
|
|
922
|
+
'cancelSettlementTimeoutMs',
|
|
923
|
+
'maxBufferedEvents',
|
|
924
|
+
'maxBufferedMessages',
|
|
925
|
+
'maxMessageBytes',
|
|
926
|
+
'maxPendingInboundRequests',
|
|
927
|
+
'maxPendingRequests',
|
|
928
|
+
'maxPendingWrites',
|
|
929
|
+
'maxRunEvents',
|
|
930
|
+
'operationTimeoutMs',
|
|
931
|
+
'requestTimeoutMs',
|
|
932
|
+
]);
|
|
933
|
+
if (Object.keys(options).some((key) => !allowed.has(key))) {
|
|
934
|
+
throw profileInvalid(profile);
|
|
935
|
+
}
|
|
936
|
+
const acp = {
|
|
937
|
+
requestTimeoutMs: options['requestTimeoutMs'] === undefined
|
|
938
|
+
? maximumTimerMilliseconds
|
|
939
|
+
: positiveProfileTimer(options['requestTimeoutMs'], 'requestTimeoutMs'),
|
|
940
|
+
};
|
|
941
|
+
for (const name of [
|
|
942
|
+
'maxBufferedEvents',
|
|
943
|
+
'maxBufferedMessages',
|
|
944
|
+
'maxMessageBytes',
|
|
945
|
+
'maxPendingInboundRequests',
|
|
946
|
+
'maxPendingRequests',
|
|
947
|
+
'maxPendingWrites',
|
|
948
|
+
]) {
|
|
949
|
+
if (options[name] !== undefined) {
|
|
950
|
+
acp[name] = positiveProfileInteger(options[name], name);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
acp,
|
|
955
|
+
cancelSettlementTimeoutMs: options['cancelSettlementTimeoutMs'] === undefined
|
|
956
|
+
? defaultCancelSettlementTimeoutMs
|
|
957
|
+
: positiveProfileTimer(options['cancelSettlementTimeoutMs'], 'cancelSettlementTimeoutMs'),
|
|
958
|
+
maxRunEvents: runEventCapacity(options['maxRunEvents']),
|
|
959
|
+
operationTimeoutMs: options['operationTimeoutMs'] === undefined
|
|
960
|
+
? defaultOperationTimeoutMs
|
|
961
|
+
: positiveProfileTimer(options['operationTimeoutMs'], 'operationTimeoutMs'),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
function validateProfile(profile) {
|
|
965
|
+
const connection = profile.connection;
|
|
966
|
+
if (profile.providerId !== OPENCLAW_PROVIDER_ID ||
|
|
967
|
+
connection.kind !== 'process' ||
|
|
968
|
+
connection.ownership !== 'adapter' ||
|
|
969
|
+
connection.command.trim().length === 0 ||
|
|
970
|
+
connection.envRefs !== undefined ||
|
|
971
|
+
(connection.args ?? []).some((argument) => forbiddenRoutingArguments.some((name) => argument === name || argument.startsWith(`${name}=`)))) {
|
|
972
|
+
throw profileInvalid(profile);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
function prepareSessionInput(input, profile) {
|
|
976
|
+
if (input.systemContext !== undefined ||
|
|
977
|
+
input.model !== undefined ||
|
|
978
|
+
input.providerOptions !== undefined ||
|
|
979
|
+
input.metadata !== undefined) {
|
|
980
|
+
throw new HarnessError('unsupported_capability', 'OpenClaw ACP does not map portable Session model or context controls.', {
|
|
981
|
+
retryable: false,
|
|
982
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
983
|
+
profileId: profile.profileId,
|
|
984
|
+
details: { capability: 'session.options' },
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
if (input.workspace === undefined) {
|
|
988
|
+
return resolve(profile.connection.kind === 'process'
|
|
989
|
+
? (profile.connection.cwd ?? process.cwd())
|
|
990
|
+
: process.cwd());
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
const url = new URL(input.workspace.uri);
|
|
994
|
+
if (url.protocol !== 'file:')
|
|
995
|
+
throw new Error('not file');
|
|
996
|
+
return resolve(fileURLToPath(url));
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
throw new HarnessError('invalid_request', 'OpenClaw workspace must be an absolute file URI.', {
|
|
1000
|
+
retryable: false,
|
|
1001
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
1002
|
+
profileId: profile.profileId,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
function sessionStateFromRef(ref) {
|
|
1007
|
+
const state = record(ref.providerState);
|
|
1008
|
+
if (state?.['strategy'] !== 'isolated' ||
|
|
1009
|
+
typeof state['sessionKey'] !== 'string' ||
|
|
1010
|
+
!state['sessionKey'].startsWith('acp-bridge:harapter-') ||
|
|
1011
|
+
typeof state['cwd'] !== 'string' ||
|
|
1012
|
+
!state['cwd'].startsWith('/')) {
|
|
1013
|
+
throw new HarnessError('session_provider_mismatch', 'OpenClaw Session reference does not contain valid isolated route state.', {
|
|
1014
|
+
retryable: false,
|
|
1015
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
1016
|
+
profileId: ref.profileId,
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
strategy: 'isolated',
|
|
1021
|
+
sessionKey: state['sessionKey'],
|
|
1022
|
+
cwd: state['cwd'],
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
function validateRunOptions(options) {
|
|
1026
|
+
validateRunTimeout(options.timeoutMs);
|
|
1027
|
+
if (options.providerOptions !== undefined || options.metadata !== undefined) {
|
|
1028
|
+
throw new HarnessError('invalid_request', 'OpenClaw Run Provider options and metadata are not mapped.', { retryable: false, providerId: OPENCLAW_PROVIDER_ID });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function selectPermissionOption(options, response) {
|
|
1032
|
+
const permittedKinds = response.decision === 'approve'
|
|
1033
|
+
? new Set(['allow_once', 'allow_always'])
|
|
1034
|
+
: new Set(['reject_once', 'reject_always']);
|
|
1035
|
+
if (response.providerOptions !== undefined) {
|
|
1036
|
+
const providerOptions = record(response.providerOptions);
|
|
1037
|
+
const explicit = providerOptions?.['optionId'];
|
|
1038
|
+
if (providerOptions === undefined ||
|
|
1039
|
+
Object.keys(providerOptions).length !== 1 ||
|
|
1040
|
+
typeof explicit !== 'string') {
|
|
1041
|
+
throw invalidPermissionOption();
|
|
1042
|
+
}
|
|
1043
|
+
const selected = options.find(({ optionId }) => optionId === explicit);
|
|
1044
|
+
if (selected === undefined || !permittedKinds.has(selected.kind)) {
|
|
1045
|
+
throw invalidPermissionOption();
|
|
1046
|
+
}
|
|
1047
|
+
return selected;
|
|
1048
|
+
}
|
|
1049
|
+
const defaultKind = response.decision === 'approve' ? 'allow_once' : 'reject_once';
|
|
1050
|
+
const selected = options.find(({ kind }) => kind === defaultKind);
|
|
1051
|
+
if (selected === undefined) {
|
|
1052
|
+
throw invalidPermissionOption();
|
|
1053
|
+
}
|
|
1054
|
+
return selected;
|
|
1055
|
+
}
|
|
1056
|
+
function invalidPermissionOption() {
|
|
1057
|
+
return new HarnessError('invalid_request', 'OpenClaw approval decision has no compatible Provider option.', { retryable: false, providerId: OPENCLAW_PROVIDER_ID });
|
|
1058
|
+
}
|
|
1059
|
+
function runEventCapacity(value) {
|
|
1060
|
+
if (value === undefined)
|
|
1061
|
+
return defaultMaxRunEvents;
|
|
1062
|
+
const capacity = positiveProfileInteger(value, 'maxRunEvents');
|
|
1063
|
+
if (capacity < 2 || capacity > maximumRunEvents) {
|
|
1064
|
+
throw new HarnessError('profile_invalid', `OpenClaw maxRunEvents must be between 2 and ${String(maximumRunEvents)}.`, { retryable: false, providerId: OPENCLAW_PROVIDER_ID });
|
|
1065
|
+
}
|
|
1066
|
+
return capacity;
|
|
1067
|
+
}
|
|
1068
|
+
function validateRunTimeout(timeoutMs) {
|
|
1069
|
+
if (timeoutMs === undefined)
|
|
1070
|
+
return;
|
|
1071
|
+
if (!Number.isSafeInteger(timeoutMs) ||
|
|
1072
|
+
timeoutMs <= 0 ||
|
|
1073
|
+
timeoutMs > maximumTimerMilliseconds) {
|
|
1074
|
+
throw new HarnessError('invalid_request', 'OpenClaw Run timeoutMs must be a positive supported timer value.', { retryable: false, providerId: OPENCLAW_PROVIDER_ID });
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
function positiveProfileInteger(value, label) {
|
|
1078
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
1079
|
+
throw new HarnessError('profile_invalid', `OpenClaw ${label} must be a positive integer.`, { retryable: false, providerId: OPENCLAW_PROVIDER_ID });
|
|
1080
|
+
}
|
|
1081
|
+
return value;
|
|
1082
|
+
}
|
|
1083
|
+
function positiveProfileTimer(value, label) {
|
|
1084
|
+
const timeout = positiveProfileInteger(value, label);
|
|
1085
|
+
if (timeout > maximumTimerMilliseconds) {
|
|
1086
|
+
throw new HarnessError('profile_invalid', `OpenClaw ${label} exceeds the supported timer range.`, { retryable: false, providerId: OPENCLAW_PROVIDER_ID });
|
|
1087
|
+
}
|
|
1088
|
+
return timeout;
|
|
1089
|
+
}
|
|
1090
|
+
function profileInvalid(profile) {
|
|
1091
|
+
return new HarnessError('profile_invalid', 'OpenClaw requires an adapter-owned process Profile without unresolved secrets or session-routing arguments.', {
|
|
1092
|
+
retryable: false,
|
|
1093
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
1094
|
+
profileId: profile.profileId,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
function snapshotProfile(profile) {
|
|
1098
|
+
return {
|
|
1099
|
+
...profile,
|
|
1100
|
+
connection: {
|
|
1101
|
+
...profile.connection,
|
|
1102
|
+
...(profile.connection.kind === 'process' && profile.connection.args
|
|
1103
|
+
? { args: [...profile.connection.args] }
|
|
1104
|
+
: {}),
|
|
1105
|
+
},
|
|
1106
|
+
...(profile.providerOptions === undefined
|
|
1107
|
+
? {}
|
|
1108
|
+
: { providerOptions: { ...profile.providerOptions } }),
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
function emitToListeners(listeners, event) {
|
|
1112
|
+
for (const listener of [...listeners]) {
|
|
1113
|
+
try {
|
|
1114
|
+
listener(structuredClone(event));
|
|
1115
|
+
}
|
|
1116
|
+
catch {
|
|
1117
|
+
// Provider observers cannot affect lifecycle processing.
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function mapError(error, profile, phase, connecting = false) {
|
|
1122
|
+
if (error instanceof HarnessError)
|
|
1123
|
+
return error;
|
|
1124
|
+
if (error instanceof JsonRpcRemoteError) {
|
|
1125
|
+
const remote = error.getRemoteError();
|
|
1126
|
+
return new HarnessError(remote.code === -32_601 ? 'provider_api_incompatible' : 'provider_error', `OpenClaw rejected ${phase}.`, {
|
|
1127
|
+
retryable: false,
|
|
1128
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
1129
|
+
profileId: profile.profileId,
|
|
1130
|
+
providerCode: String(remote.code),
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
if (error instanceof AcpClientError) {
|
|
1134
|
+
const code = isProtocolFailure(error)
|
|
1135
|
+
? 'provider_api_incompatible'
|
|
1136
|
+
: error.code === 'capability_not_advertised'
|
|
1137
|
+
? 'unsupported_capability'
|
|
1138
|
+
: error.code === 'client_closed'
|
|
1139
|
+
? connecting
|
|
1140
|
+
? 'connection_failed'
|
|
1141
|
+
: 'connection_aborted'
|
|
1142
|
+
: 'provider_error';
|
|
1143
|
+
return new HarnessError(code, `OpenClaw ${phase} did not complete.`, {
|
|
1144
|
+
retryable: false,
|
|
1145
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
1146
|
+
profileId: profile.profileId,
|
|
1147
|
+
providerCode: error.code,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
if (error instanceof JsonRpcTransportError) {
|
|
1151
|
+
const code = error.code === 'request_timeout'
|
|
1152
|
+
? 'timeout'
|
|
1153
|
+
: connecting
|
|
1154
|
+
? 'connection_failed'
|
|
1155
|
+
: 'connection_aborted';
|
|
1156
|
+
return new HarnessError(code, `OpenClaw ${phase} did not complete.`, {
|
|
1157
|
+
retryable: error.code === 'request_timeout',
|
|
1158
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
1159
|
+
profileId: profile.profileId,
|
|
1160
|
+
providerCode: error.code,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
const systemCode = typeof error === 'object' && error !== null && 'code' in error
|
|
1164
|
+
? error.code
|
|
1165
|
+
: undefined;
|
|
1166
|
+
if (connecting && systemCode === 'ENOENT') {
|
|
1167
|
+
return new HarnessError('runtime_not_found', 'The configured OpenClaw runtime was not found.', {
|
|
1168
|
+
retryable: false,
|
|
1169
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
1170
|
+
profileId: profile.profileId,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
return new HarnessError(connecting ? 'connection_failed' : 'provider_error', `OpenClaw ${phase} did not complete.`, {
|
|
1174
|
+
retryable: false,
|
|
1175
|
+
providerId: OPENCLAW_PROVIDER_ID,
|
|
1176
|
+
profileId: profile.profileId,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
function isProtocolFailure(error) {
|
|
1180
|
+
return (error instanceof AcpClientError &&
|
|
1181
|
+
(error.code === 'invalid_message' ||
|
|
1182
|
+
error.code === 'unsupported_protocol_version'));
|
|
1183
|
+
}
|
|
1184
|
+
function record(value) {
|
|
1185
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
1186
|
+
? value
|
|
1187
|
+
: undefined;
|
|
1188
|
+
}
|
|
1189
|
+
async function withTimeout(promise, timeoutMs) {
|
|
1190
|
+
let timer;
|
|
1191
|
+
const timeout = new Promise((resolveTimeout) => {
|
|
1192
|
+
timer = setTimeout(() => {
|
|
1193
|
+
resolveTimeout(undefined);
|
|
1194
|
+
}, timeoutMs);
|
|
1195
|
+
timer.unref();
|
|
1196
|
+
});
|
|
1197
|
+
try {
|
|
1198
|
+
return await Promise.race([promise, timeout]);
|
|
1199
|
+
}
|
|
1200
|
+
finally {
|
|
1201
|
+
if (timer !== undefined)
|
|
1202
|
+
clearTimeout(timer);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
//# sourceMappingURL=adapter.js.map
|