@openviking/openclaw-plugin 2026.9.8 → 2026.9.10-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/README.md CHANGED
@@ -23,7 +23,7 @@ The agent runs install → setup → restart → verify automatically. See [INST
23
23
 
24
24
  | Stage | What happens |
25
25
  |-------|-------------|
26
- | **Every turn** (`afterTurn`) | New messages are appended to an OpenViking session; commit/extraction is threshold-triggered |
26
+ | **Every turn** (`afterTurn` or `commitTurn`, depending on host/runner) | New messages are appended to an OpenViking session; commit/extraction is threshold-triggered |
27
27
  | **Explicit remember** (`memory_store`) | Important long-term facts can be written and committed immediately |
28
28
  | **On `/compact`** (`compact`) | Pending session messages are committed and extracted into long-term memories |
29
29
  | **Before each reply** (`assemble`) | Relevant memories are auto-retrieved and injected into context |
@@ -202,6 +202,17 @@ That means OpenClaw sees "compressed history summary + archive index + active me
202
202
 
203
203
  `afterTurn()` has a narrower job: append only the new turn into the OpenViking session.
204
204
 
205
+ Capture ownership depends on the **host** version from `api.runtime.version`:
206
+
207
+ - Before OpenClaw 2026.9.3, `afterTurn` captures messages; `commitTurn` only acknowledges the turn to avoid writing those messages twice.
208
+ - Starting with 2026.9.3, admitted/deferred turns are captured inside `commitTurn` before acknowledgment. Standalone runners still capture via `afterTurn`.
209
+ - The minimum supported OpenClaw version remains 2026.5.27. Missing/unparseable versions and ambiguous 2026.9.3 prereleases reject `commitTurn` rather than silently discarding queued messages; `afterTurn` remains available. Numeric packaging revisions such as `2026.9.3-1` use the 9.3 behavior.
210
+
211
+ Durable capture propagates client/write/commit failures to the host for retry and shares one in-flight write between concurrent deliveries of the same advancement key. Disabled capture, heartbeats, bypassed sessions, and empty turns are intentional skips. The legacy `afterTurn` callback retains best-effort error handling.
212
+
213
+ The host's durable delivery does not supply the model token budget or sender runtime context: this path uses the 128,000-token fallback and cannot attach the runtime sender ID to user messages. Message writes remain non-transactional: replay after partial writes or a process crash can duplicate messages. The in-memory advancement-key cache does not provide server-side exactly-once delivery.
214
+
215
+
205
216
  - it slices only the newly added messages
206
217
  - it keeps only `user` / `assistant` capture text
207
218
  - it preserves `toolCall` / `toolResult` content in the serialized turn text
@@ -214,7 +225,7 @@ After that, the plugin checks `pending_tokens`. Once it reaches `commitTokenThre
214
225
  - the current turn is not blocked waiting for extraction
215
226
  - if `logFindRequests` is enabled, the logs include the task id and follow-up extraction detail
216
227
 
217
- This automatic path is best-effort and commit-dependent. Short but important facts can stay only in the live session until a threshold commit, `/compact`, or an explicit store happens.
228
+ Memory extraction on this automatic path is commit-dependent. Short but important facts can stay only in the live session until a threshold commit, `/compact`, or an explicit store happens.
218
229
 
219
230
  ### Explicit long-term memory writes
220
231
 
package/auto-recall.ts CHANGED
@@ -331,6 +331,7 @@ export async function buildAutoRecallContext(params: {
331
331
  rawUserTextPreview?: string;
332
332
  queryTruncated?: boolean;
333
333
  resourceTypes?: RecallResourceType[];
334
+ dedupTurns?: number;
334
335
  }): Promise<{ block?: string; memoryCount: number; estimatedTokens: number }> {
335
336
  const { cfg, client, agentId, actorPeerId, queryText, logger, verbose } = params;
336
337
  const queryConfig = params.queryConfig;
@@ -373,7 +374,7 @@ export async function buildAutoRecallContext(params: {
373
374
  queryExpansion: "auto",
374
375
  maxTokens,
375
376
  detail: recallPreferAbstract ? "abstract" : undefined,
376
- dedupTurns: params.ovSessionId ? AUTO_RECALL_DEDUP_TURNS : undefined,
377
+ dedupTurns: params.dedupTurns ?? (params.ovSessionId ? AUTO_RECALL_DEDUP_TURNS : undefined),
377
378
  peerScope: "actor",
378
379
  actorPeerId,
379
380
  requestTimeoutMs: cfg.autoRecallTimeoutMs,
package/client.ts CHANGED
@@ -508,6 +508,7 @@ export class OpenVikingClient {
508
508
  };
509
509
  const body = {
510
510
  ...buildContextSearchBody(contractConfig, { sessionId: options.sessionId }),
511
+ ...(options.dedupTurns === 0 ? { dedup_turns: 0 } : {}),
511
512
  query,
512
513
  ...(options.contextType !== undefined ? { context_type: options.contextType } : {}),
513
514
  ...(options.detail !== undefined ? { detail: options.detail } : {}),
package/commands/setup.ts CHANGED
@@ -6,9 +6,13 @@ import { fileURLToPath } from "node:url";
6
6
  import { getEnv } from "../runtime-utils.js";
7
7
  import {
8
8
  cleanOpenVikingRequestHeaders,
9
- resolveOpenVikingRequestHeaders,
10
9
  type OpenVikingRequestHeaders,
11
10
  } from "../request-headers.js";
11
+ import {
12
+ createSetupNetworkProbes,
13
+ type SetupApiKeyProbeResult,
14
+ type SetupHealthResult,
15
+ } from "../services/setup/probe-service.js";
12
16
 
13
17
  const HOME = os.homedir();
14
18
  const OPENCLAW_DIR = getEnv("OPENCLAW_STATE_DIR") || path.join(HOME, ".openclaw");
@@ -269,14 +273,7 @@ function ask(rl: readline.Interface, prompt: string, defaultValue = ""): Promise
269
273
 
270
274
  type VersionCompatibility = "compatible" | "server_too_old" | "server_too_new" | "unknown";
271
275
 
272
- type HealthResult = {
273
- ok: boolean;
274
- version: string;
275
- error: string;
276
- compatibility: VersionCompatibility;
277
- pluginVersion: string;
278
- compatRange: string;
279
- };
276
+ type HealthResult = SetupHealthResult;
280
277
 
281
278
  function parseVersionTuple(v: string): number[] | null {
282
279
  const cleaned = v.replace(/^v/i, "").split("-")[0];
@@ -317,132 +314,13 @@ function formatCompatRange(): string {
317
314
  return "any";
318
315
  }
319
316
 
320
- type ApiKeyProbeResult = {
321
- keyType: "user_key" | "root_key" | "no_key" | "unknown";
322
- needsAccountId: boolean;
323
- needsUserId: boolean;
324
- detail: string;
325
- };
326
-
327
- async function probeApiKeyType(
328
- baseUrl: string,
329
- apiKey?: string,
330
- configuredHeaders?: OpenVikingRequestHeaders,
331
- ): Promise<ApiKeyProbeResult> {
332
- if (!apiKey) return { keyType: "no_key", needsAccountId: false, needsUserId: false, detail: "No API key configured" };
333
-
334
- const controller = new AbortController();
335
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
336
- const sessionsUrl = `${baseUrl.replace(/\/+$/, "")}/api/v1/sessions?limit=1`;
337
- try {
338
- const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
339
- const headers: Record<string, string> = {};
340
- headers["X-API-Key"] = apiKey;
341
- Object.assign(headers, configuredRequestHeaders);
342
- const response = await fetch(sessionsUrl, {
343
- headers,
344
- signal: controller.signal,
345
- });
346
-
347
- if (response.ok) {
348
- return { keyType: "user_key", needsAccountId: false, needsUserId: false, detail: "API key has full user context" };
349
- }
350
-
351
- // Server raises InvalidArgumentError (-> HTTP 400) when a ROOT key calls
352
- // tenant-scoped endpoints without X-OpenViking-Account / X-OpenViking-User.
353
- // 401/403 may also be returned by older versions, FastAPI validation can
354
- // surface as 422. Treat all four as candidates for tenant-context errors.
355
- if ([400, 401, 403, 422].includes(response.status)) {
356
- let body = "";
357
- try {
358
- body = await response.text();
359
- } catch { /* ignore parse errors */ }
360
- const lower = body.toLowerCase();
361
- const needsAccount = /x-openviking-account|account[_ ]?id|account context|tenant/.test(lower);
362
- const needsUser = /x-openviking-user|user[_ ]?id|user context|user key/.test(lower);
363
- if (needsAccount || needsUser) {
364
- return {
365
- keyType: "root_key",
366
- needsAccountId: needsAccount,
367
- needsUserId: needsUser,
368
- detail: body.slice(0, 200),
369
- };
370
- }
371
-
372
- // Body did not name account/user/tenant explicitly (custom auth middleware,
373
- // localized message, etc.). Re-probe with placeholder tenant headers; if
374
- // the failure was due to missing tenant headers the response will change.
375
- try {
376
- const probeHeaders: Record<string, string> = {
377
- "X-API-Key": apiKey,
378
- "X-OpenViking-Account": "__probe__",
379
- "X-OpenViking-User": "__probe__",
380
- ...configuredRequestHeaders,
381
- };
382
- const probe2 = await fetch(sessionsUrl, {
383
- headers: probeHeaders,
384
- signal: controller.signal,
385
- });
386
- if (probe2.status !== response.status) {
387
- return {
388
- keyType: "root_key",
389
- needsAccountId: true,
390
- needsUserId: true,
391
- detail: body.slice(0, 200) || `HTTP ${response.status} -> ${probe2.status} after adding tenant headers`,
392
- };
393
- }
394
- } catch { /* ignore probe errors, fall through to unknown */ }
395
-
396
- if (response.status === 401 || response.status === 403) {
397
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status} - authentication failed, verify your API key` };
398
- }
399
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}${body ? ` - ${body.slice(0, 160)}` : ""}` };
400
- }
401
-
402
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}` };
403
- } catch (err) {
404
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: String(err instanceof Error ? err.message : err) };
405
- } finally {
406
- clearTimeout(timeoutId);
407
- }
408
- }
317
+ type ApiKeyProbeResult = SetupApiKeyProbeResult;
409
318
 
410
- async function checkServiceHealth(
411
- baseUrl: string,
412
- apiKey?: string,
413
- configuredHeaders?: OpenVikingRequestHeaders,
414
- ): Promise<HealthResult> {
415
- const controller = new AbortController();
416
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
417
- try {
418
- const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
419
- const headers: Record<string, string> = {};
420
- if (apiKey) {
421
- headers["X-API-Key"] = apiKey;
422
- }
423
- Object.assign(headers, configuredRequestHeaders);
424
- const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/health`, {
425
- headers,
426
- signal: controller.signal,
427
- });
428
- if (response.ok) {
429
- try {
430
- const data = await response.json() as Record<string, unknown>;
431
- const result = (data.result ?? data) as Record<string, unknown>;
432
- const version = String(result.version ?? data.version ?? "");
433
- const compatibility = checkVersionCompatibility(version);
434
- return { ok: true, version, error: "", compatibility, pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
435
- } catch {
436
- return { ok: true, version: "", error: "", compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
437
- }
438
- }
439
- return { ok: false, version: "", error: `HTTP ${response.status}`, compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
440
- } catch (err) {
441
- return { ok: false, version: "", error: String(err instanceof Error ? err.message : err), compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
442
- } finally {
443
- clearTimeout(timeoutId);
444
- }
445
- }
319
+ const setupProbes = createSetupNetworkProbes({
320
+ pluginVersion: PLUGIN_VERSION,
321
+ compatRange: formatCompatRange(),
322
+ checkVersionCompatibility,
323
+ });
446
324
 
447
325
  function readOpenClawConfig(configPath: string): Record<string, unknown> {
448
326
  if (!fs.existsSync(configPath)) return {};
@@ -813,7 +691,7 @@ async function runRemoteCheck(
813
691
  const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
814
692
  const headers = nonEmptyOpenVikingRequestHeaders(existing.headers);
815
693
  console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
816
- const health = await checkServiceHealth(baseUrl, apiKey, headers);
694
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey, headers);
817
695
  if (health.ok) {
818
696
  const ver = health.version ? ` (version: ${health.version})` : "";
819
697
  console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
@@ -847,7 +725,7 @@ async function setupNonInteractive(
847
725
  }
848
726
 
849
727
  // Phase 1: validate connectivity and key type BEFORE writing config
850
- const health = await checkServiceHealth(baseUrl, apiKey);
728
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey);
851
729
 
852
730
  if (!health.ok && !allowOffline) {
853
731
  return {
@@ -860,7 +738,7 @@ async function setupNonInteractive(
860
738
  };
861
739
  }
862
740
 
863
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey) : undefined;
741
+ const keyProbe = health.ok ? await setupProbes.probeApiKeyType(baseUrl, apiKey) : undefined;
864
742
 
865
743
  if (keyProbe?.keyType === "root_key" && (!accountId || !userId)) {
866
744
  const missing: string[] = [];
@@ -996,8 +874,8 @@ async function getStatus(configPath: string): Promise<StatusResult> {
996
874
  const baseUrl = String(existing.baseUrl ?? DEFAULT_REMOTE_URL);
997
875
  const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
998
876
  const headers = nonEmptyOpenVikingRequestHeaders(existing.headers);
999
- const health = await checkServiceHealth(baseUrl, apiKey, headers);
1000
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey, headers) : undefined;
877
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey, headers);
878
+ const keyProbe = health.ok ? await setupProbes.probeApiKeyType(baseUrl, apiKey, headers) : undefined;
1001
879
 
1002
880
  const peerPrefix = resolveExistingPeerPrefix(existing);
1003
881
  return {
@@ -1119,7 +997,7 @@ async function setupRemote(
1119
997
 
1120
998
  if (apiKey) {
1121
999
  console.log(tr(zh, " Detecting API key type...", " 正在检测 API Key 类型..."));
1122
- const probe = await probeApiKeyType(baseUrl, apiKey, headers);
1000
+ const probe = await setupProbes.probeApiKeyType(baseUrl, apiKey, headers);
1123
1001
  if (probe.keyType === "root_key") {
1124
1002
  console.log(tr(zh,
1125
1003
  " ⚠ Root API key detected. accountId and userId are required.",
@@ -1140,7 +1018,7 @@ async function setupRemote(
1140
1018
  console.log("");
1141
1019
 
1142
1020
  console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
1143
- const health = await checkServiceHealth(baseUrl, apiKey || undefined, headers);
1021
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey || undefined, headers);
1144
1022
  if (health.ok) {
1145
1023
  const ver = health.version ? ` (version: ${health.version})` : "";
1146
1024
  console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
@@ -1204,7 +1082,6 @@ export const __test__ = {
1204
1082
  checkVersionCompatibility,
1205
1083
  parseVersionTuple,
1206
1084
  compareVersions,
1207
- probeApiKeyType,
1208
1085
  ensureInstallRecord,
1209
1086
  findPluginPackageRoot,
1210
1087
  readCompatRangeFromManifest,
package/context-engine.ts CHANGED
@@ -245,10 +245,34 @@ function validTokenBudget(raw: unknown): number | undefined {
245
245
  return undefined;
246
246
  }
247
247
 
248
+ // OpenClaw 9.1/9.2 still capture via afterTurn even when commitTurn exists.
249
+ // 9.3 (#140024) defers admitted turns to commitTurn; standalone runners keep
250
+ // afterTurn. The registration API's runtime.version is the HOST version.
251
+ function usesDeferredTurnCapture(version: string | undefined): boolean | undefined {
252
+ const match = version?.trim().match(
253
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/,
254
+ );
255
+ if (!match) return undefined;
256
+ const parts = match.slice(1, 4).map(Number);
257
+ if (!parts.every(Number.isSafeInteger)) return undefined;
258
+ const [year, month, day] = parts;
259
+ // OpenClaw uses 0.0.0 when version metadata cannot be resolved. More generally,
260
+ // versions outside our supported range cannot prove who owns capture.
261
+ if (year < 2026 || (year === 2026 && (month < 5 || (month === 5 && day < 27)))) {
262
+ return undefined;
263
+ }
264
+ const boundary = year === 2026 && month === 9 && day === 3;
265
+ // The 9.3 prerelease train can straddle the host change. Numeric packaging
266
+ // revisions (-1, -2, ...) refer to the stable release; do not guess for betas.
267
+ if (boundary && match[4] && !/^\d+$/.test(match[4])) return undefined;
268
+ return year > 2026 || (year === 2026 && (month > 9 || (month === 9 && day >= 3)));
269
+ }
270
+
248
271
  export function createMemoryOpenVikingContextEngine(params: {
249
272
  id: string;
250
273
  name: string;
251
274
  version?: string;
275
+ hostVersion?: string;
252
276
  cfg: ParsedMemoryOpenVikingConfig;
253
277
  logger: Logger;
254
278
  getClient: () => Promise<OpenVikingClient>;
@@ -333,6 +357,8 @@ export function createMemoryOpenVikingContextEngine(params: {
333
357
  }
334
358
 
335
359
  const committedTurnKeys = new Set<string>();
360
+ const inFlightTurns = new Map<string, Promise<void>>();
361
+ const deferredTurnCapture = usesDeferredTurnCapture(params.hostVersion);
336
362
 
337
363
  return {
338
364
  info: {
@@ -368,6 +394,7 @@ export function createMemoryOpenVikingContextEngine(params: {
368
394
  sessionId: assembleParams.sessionId,
369
395
  sessionKey: resolveSessionKey(assembleParams),
370
396
  messages: assembleParams.messages,
397
+ prompt: assembleParams.prompt,
371
398
  tokenBudget,
372
399
  runtimeContext: assembleParams.runtimeContext,
373
400
  isMainAssemble,
@@ -388,20 +415,56 @@ export function createMemoryOpenVikingContextEngine(params: {
388
415
  });
389
416
  },
390
417
 
391
- // Capture still happens in afterTurn (host calls it per LLM call + on finalize);
392
- // commitTurn only acknowledges the accepted turn so OpenClaw drains its outbox.
393
- // ponytail: in-memory key set, not durable across restarts — the host outbox is.
394
- async commitTurn({ advancementKey, sessionId }): Promise<{ status: "committed" | "duplicate" }> {
418
+ async commitTurn(commitParams): Promise<{ status: "committed" | "duplicate" }> {
419
+ const { advancementKey, sessionId } = commitParams;
420
+ if (deferredTurnCapture === undefined) {
421
+ // ACKing with an unknown capture owner can discard the only copy in the
422
+ // host outbox. Reject instead; legacy afterTurn remains available.
423
+ throw new Error(`openviking: cannot select turn capture for OpenClaw version ${params.hostVersion ?? "(missing)"}`);
424
+ }
395
425
  if (committedTurnKeys.has(advancementKey)) {
396
426
  diag("commitTurn_duplicate", sessionId, { advancementKey });
397
427
  return { status: "duplicate" };
398
428
  }
399
- committedTurnKeys.add(advancementKey);
400
- if (committedTurnKeys.size > 1024) {
401
- committedTurnKeys.delete(committedTurnKeys.values().next().value as string);
429
+ const pending = inFlightTurns.get(advancementKey);
430
+ if (pending) {
431
+ await pending;
432
+ return { status: "duplicate" };
433
+ }
434
+ // Publish the promise before capture begins so concurrent deliveries share
435
+ // both success and failure. Completion keys are intentionally process-local.
436
+ const capture = Promise.resolve().then(async () => {
437
+ if (deferredTurnCapture) {
438
+ await afterTurnOpenVikingSession({
439
+ sessionId,
440
+ sessionKey: resolveSessionKey(commitParams),
441
+ messages: commitParams.messages,
442
+ prePromptMessageCount: 0, // Host already supplies the closed-turn range.
443
+ isHeartbeat: commitParams.isHeartbeat,
444
+ tokenBudget: 128_000, // Durable delivery does not carry a token budget.
445
+ throwOnError: true,
446
+ cfg,
447
+ getClient,
448
+ logger,
449
+ resolveAgentId,
450
+ rememberSessionAgentId,
451
+ isBypassedSession,
452
+ diag,
453
+ });
454
+ }
455
+ committedTurnKeys.add(advancementKey);
456
+ if (committedTurnKeys.size > 1024) {
457
+ committedTurnKeys.delete(committedTurnKeys.values().next().value as string);
458
+ }
459
+ diag("commitTurn", sessionId, { advancementKey, deferredTurnCapture });
460
+ });
461
+ inFlightTurns.set(advancementKey, capture);
462
+ try {
463
+ await capture;
464
+ return { status: "committed" };
465
+ } finally {
466
+ inFlightTurns.delete(advancementKey);
402
467
  }
403
- diag("commitTurn", sessionId, { advancementKey });
404
- return { status: "committed" };
405
468
  },
406
469
 
407
470
  async afterTurn(afterTurnParams): Promise<void> {
@@ -244,7 +244,7 @@ export async function buildAutoRecallContext(params) {
244
244
  queryExpansion: "auto",
245
245
  maxTokens,
246
246
  detail: recallPreferAbstract ? "abstract" : undefined,
247
- dedupTurns: params.ovSessionId ? AUTO_RECALL_DEDUP_TURNS : undefined,
247
+ dedupTurns: params.dedupTurns ?? (params.ovSessionId ? AUTO_RECALL_DEDUP_TURNS : undefined),
248
248
  peerScope: "actor",
249
249
  actorPeerId,
250
250
  requestTimeoutMs: cfg.autoRecallTimeoutMs,
package/dist/client.js CHANGED
@@ -205,6 +205,7 @@ export class OpenVikingClient {
205
205
  };
206
206
  const body = {
207
207
  ...buildContextSearchBody(contractConfig, { sessionId: options.sessionId }),
208
+ ...(options.dedupTurns === 0 ? { dedup_turns: 0 } : {}),
208
209
  query,
209
210
  ...(options.contextType !== undefined ? { context_type: options.contextType } : {}),
210
211
  ...(options.detail !== undefined ? { detail: options.detail } : {}),
@@ -4,7 +4,8 @@ import * as path from "node:path";
4
4
  import * as readline from "node:readline";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { getEnv } from "../runtime-utils.js";
7
- import { cleanOpenVikingRequestHeaders, resolveOpenVikingRequestHeaders, } from "../request-headers.js";
7
+ import { cleanOpenVikingRequestHeaders, } from "../request-headers.js";
8
+ import { createSetupNetworkProbes, } from "../services/setup/probe-service.js";
8
9
  const HOME = os.homedir();
9
10
  const OPENCLAW_DIR = getEnv("OPENCLAW_STATE_DIR") || path.join(HOME, ".openclaw");
10
11
  const DEFAULT_REMOTE_URL = "http://127.0.0.1:1933";
@@ -247,118 +248,11 @@ function formatCompatRange() {
247
248
  return `<= ${COMPATIBLE_SERVER_MAX}`;
248
249
  return "any";
249
250
  }
250
- async function probeApiKeyType(baseUrl, apiKey, configuredHeaders) {
251
- if (!apiKey)
252
- return { keyType: "no_key", needsAccountId: false, needsUserId: false, detail: "No API key configured" };
253
- const controller = new AbortController();
254
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
255
- const sessionsUrl = `${baseUrl.replace(/\/+$/, "")}/api/v1/sessions?limit=1`;
256
- try {
257
- const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
258
- const headers = {};
259
- headers["X-API-Key"] = apiKey;
260
- Object.assign(headers, configuredRequestHeaders);
261
- const response = await fetch(sessionsUrl, {
262
- headers,
263
- signal: controller.signal,
264
- });
265
- if (response.ok) {
266
- return { keyType: "user_key", needsAccountId: false, needsUserId: false, detail: "API key has full user context" };
267
- }
268
- // Server raises InvalidArgumentError (-> HTTP 400) when a ROOT key calls
269
- // tenant-scoped endpoints without X-OpenViking-Account / X-OpenViking-User.
270
- // 401/403 may also be returned by older versions, FastAPI validation can
271
- // surface as 422. Treat all four as candidates for tenant-context errors.
272
- if ([400, 401, 403, 422].includes(response.status)) {
273
- let body = "";
274
- try {
275
- body = await response.text();
276
- }
277
- catch { /* ignore parse errors */ }
278
- const lower = body.toLowerCase();
279
- const needsAccount = /x-openviking-account|account[_ ]?id|account context|tenant/.test(lower);
280
- const needsUser = /x-openviking-user|user[_ ]?id|user context|user key/.test(lower);
281
- if (needsAccount || needsUser) {
282
- return {
283
- keyType: "root_key",
284
- needsAccountId: needsAccount,
285
- needsUserId: needsUser,
286
- detail: body.slice(0, 200),
287
- };
288
- }
289
- // Body did not name account/user/tenant explicitly (custom auth middleware,
290
- // localized message, etc.). Re-probe with placeholder tenant headers; if
291
- // the failure was due to missing tenant headers the response will change.
292
- try {
293
- const probeHeaders = {
294
- "X-API-Key": apiKey,
295
- "X-OpenViking-Account": "__probe__",
296
- "X-OpenViking-User": "__probe__",
297
- ...configuredRequestHeaders,
298
- };
299
- const probe2 = await fetch(sessionsUrl, {
300
- headers: probeHeaders,
301
- signal: controller.signal,
302
- });
303
- if (probe2.status !== response.status) {
304
- return {
305
- keyType: "root_key",
306
- needsAccountId: true,
307
- needsUserId: true,
308
- detail: body.slice(0, 200) || `HTTP ${response.status} -> ${probe2.status} after adding tenant headers`,
309
- };
310
- }
311
- }
312
- catch { /* ignore probe errors, fall through to unknown */ }
313
- if (response.status === 401 || response.status === 403) {
314
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status} - authentication failed, verify your API key` };
315
- }
316
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}${body ? ` - ${body.slice(0, 160)}` : ""}` };
317
- }
318
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}` };
319
- }
320
- catch (err) {
321
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: String(err instanceof Error ? err.message : err) };
322
- }
323
- finally {
324
- clearTimeout(timeoutId);
325
- }
326
- }
327
- async function checkServiceHealth(baseUrl, apiKey, configuredHeaders) {
328
- const controller = new AbortController();
329
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
330
- try {
331
- const configuredRequestHeaders = resolveOpenVikingRequestHeaders({ headers: configuredHeaders });
332
- const headers = {};
333
- if (apiKey) {
334
- headers["X-API-Key"] = apiKey;
335
- }
336
- Object.assign(headers, configuredRequestHeaders);
337
- const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/health`, {
338
- headers,
339
- signal: controller.signal,
340
- });
341
- if (response.ok) {
342
- try {
343
- const data = await response.json();
344
- const result = (data.result ?? data);
345
- const version = String(result.version ?? data.version ?? "");
346
- const compatibility = checkVersionCompatibility(version);
347
- return { ok: true, version, error: "", compatibility, pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
348
- }
349
- catch {
350
- return { ok: true, version: "", error: "", compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
351
- }
352
- }
353
- return { ok: false, version: "", error: `HTTP ${response.status}`, compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
354
- }
355
- catch (err) {
356
- return { ok: false, version: "", error: String(err instanceof Error ? err.message : err), compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
357
- }
358
- finally {
359
- clearTimeout(timeoutId);
360
- }
361
- }
251
+ const setupProbes = createSetupNetworkProbes({
252
+ pluginVersion: PLUGIN_VERSION,
253
+ compatRange: formatCompatRange(),
254
+ checkVersionCompatibility,
255
+ });
362
256
  function readOpenClawConfig(configPath) {
363
257
  if (!fs.existsSync(configPath))
364
258
  return {};
@@ -624,7 +518,7 @@ async function runRemoteCheck(zh, existing) {
624
518
  const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
625
519
  const headers = nonEmptyOpenVikingRequestHeaders(existing.headers);
626
520
  console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
627
- const health = await checkServiceHealth(baseUrl, apiKey, headers);
521
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey, headers);
628
522
  if (health.ok) {
629
523
  const ver = health.version ? ` (version: ${health.version})` : "";
630
524
  console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
@@ -644,7 +538,7 @@ async function setupNonInteractive(configPath, params) {
644
538
  throw new Error("peer_prefix may only contain letters, digits, underscores, and hyphens, or be empty");
645
539
  }
646
540
  // Phase 1: validate connectivity and key type BEFORE writing config
647
- const health = await checkServiceHealth(baseUrl, apiKey);
541
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey);
648
542
  if (!health.ok && !allowOffline) {
649
543
  return {
650
544
  success: false,
@@ -655,7 +549,7 @@ async function setupNonInteractive(configPath, params) {
655
549
  error: `Server unreachable: ${health.error}. Use --allow-offline to save config anyway.`,
656
550
  };
657
551
  }
658
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey) : undefined;
552
+ const keyProbe = health.ok ? await setupProbes.probeApiKeyType(baseUrl, apiKey) : undefined;
659
553
  if (keyProbe?.keyType === "root_key" && (!accountId || !userId)) {
660
554
  const missing = [];
661
555
  if (!accountId)
@@ -793,8 +687,8 @@ async function getStatus(configPath) {
793
687
  const baseUrl = String(existing.baseUrl ?? DEFAULT_REMOTE_URL);
794
688
  const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
795
689
  const headers = nonEmptyOpenVikingRequestHeaders(existing.headers);
796
- const health = await checkServiceHealth(baseUrl, apiKey, headers);
797
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey, headers) : undefined;
690
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey, headers);
691
+ const keyProbe = health.ok ? await setupProbes.probeApiKeyType(baseUrl, apiKey, headers) : undefined;
798
692
  const peerPrefix = resolveExistingPeerPrefix(existing);
799
693
  return {
800
694
  configured: true,
@@ -887,7 +781,7 @@ async function setupRemote(zh, configPath, existing, q) {
887
781
  let userId = existing?.userId ? String(existing.userId) : "";
888
782
  if (apiKey) {
889
783
  console.log(tr(zh, " Detecting API key type...", " 正在检测 API Key 类型..."));
890
- const probe = await probeApiKeyType(baseUrl, apiKey, headers);
784
+ const probe = await setupProbes.probeApiKeyType(baseUrl, apiKey, headers);
891
785
  if (probe.keyType === "root_key") {
892
786
  console.log(tr(zh, " ⚠ Root API key detected. accountId and userId are required.", " ⚠ 检测到 Root API Key,需要提供 accountId 和 userId。"));
893
787
  accountId = await q(tr(zh, "Account ID (required for root key)", "Account ID(root key 必填)"), accountId);
@@ -903,7 +797,7 @@ async function setupRemote(zh, configPath, existing, q) {
903
797
  : "";
904
798
  console.log("");
905
799
  console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
906
- const health = await checkServiceHealth(baseUrl, apiKey || undefined, headers);
800
+ const health = await setupProbes.checkServiceHealth(baseUrl, apiKey || undefined, headers);
907
801
  if (health.ok) {
908
802
  const ver = health.version ? ` (version: ${health.version})` : "";
909
803
  console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
@@ -969,7 +863,6 @@ export const __test__ = {
969
863
  checkVersionCompatibility,
970
864
  parseVersionTuple,
971
865
  compareVersions,
972
- probeApiKeyType,
973
866
  ensureInstallRecord,
974
867
  findPluginPackageRoot,
975
868
  readCompatRangeFromManifest,