diff --git a/README.md b/README.md index 5a662f2..5bc0df0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ This plugin is intentionally narrow after the Claude Code Direction-2 lane updat For requests routed through the `cloudsigma` or `cloudsigma-staging` provider IDs, the plugin: -- derives a stable affinity session ID with the form `oc:` +- passes OpenClaw's native `ctx.sessionId` through unchanged when available +- generates a stable `oc:` only as a deprecated compatibility fallback from `OPENCLAW_SESSION_ID` - injects `metadata.session_id` when absent - injects `metadata.sticky_key` when absent - injects a sanitized `metadata.requester_runtime` envelope when absent @@ -65,7 +66,7 @@ Example injected metadata: "openclaw_correlation": { "schema_version": "2026-06-05", "source": "openclaw-taas-affinity", - "plugin_version": "0.5.2", + "plugin_version": "0.6.0", "session_id": "oc:0123456789abcdef", "sticky_key": "oc:0123456789abcdef", "session_source_hint": "source:1a2b3c4d5e6f7890", @@ -127,11 +128,11 @@ openclaw gateway call taas.autorouter.lastRoute \ --json ``` -Query by workspace path, deriving the same affinity session ID as the wrapper: +Query by native OpenClaw session ID: ```bash openclaw gateway call taas.autorouter.lastRoute \ - --params '{"workspaceDir":"/home/cloudsigma/.openclaw/workspace-new-agent-2"}' \ + --params '{"localSessionId":"a5add102-d79b-4168-8a2a-6dd75135f73b"}' \ --json ``` @@ -190,8 +191,8 @@ Current tests cover: | Variable | Default | Purpose | |---|---:|---| | `OPENCLAW_DEBUG` | unset | Emit debug logs for session source and autorouter capture | -| `OPENCLAW_SESSION_ID` | unset | Preferred stable session source when supplied by OpenClaw | -| `OPENCLAW_AGENT_ID` / `OPENCLAW_RUN_ID` | unset | Fallback stable agent/session source | -| `OPENCLAW_STATE_DIR` | `~/.openclaw` | Last-resort stable fallback source | +| `OPENCLAW_SESSION_ID` | unset | Deprecated compatibility fallback for runtimes that do not supply native `ctx.sessionId` | +| `OPENCLAW_AGENT_ID` / `OPENCLAW_RUN_ID` | unset | Agent label used only for autorouter capture lookup; never a session-identity source | +| `OPENCLAW_STATE_DIR` | `~/.openclaw` | Used only to construct a hashed diagnostic source hint | Requester bridge variables such as `TAAS_REQUESTER_BRIDGE_PLUGIN_ENABLED`, `TAAS_REQUESTER_BRIDGE_LEASE_URL`, and `TAAS_REQUESTER_BRIDGE_POLL_INTERVAL_MS` are obsolete and ignored by this plugin version. diff --git a/index.ts b/index.ts index 442e58d..6abe286 100644 --- a/index.ts +++ b/index.ts @@ -29,7 +29,7 @@ const REQUESTER_RUNTIME_SCHEMA_VERSION = "2026-06-04" const REQUESTER_RUNTIME_SOURCE = "openclaw-taas-affinity" const GIT_PROBE_TIMEOUT_MS = 250 const LAST_ROUTE_LIMIT = 256 -const PLUGIN_VERSION = "0.5.3" +const PLUGIN_VERSION = "0.6.0" // OpenClaw stores active registry state (including workspaceDir) on globalThis // under this well-known symbol key. @@ -67,73 +67,59 @@ function stableHash(value: string, prefix: string, length = 16): string { return `${prefix}:${hex.slice(0, length)}` } -function deriveSessionId(source: string): string { - const normalised = source.startsWith("env:") || source.startsWith("agent:") || source.startsWith("session:") - ? source - : path.resolve(source) - const hex = createHash("sha256").update(normalised, "utf8").digest("hex") +function deriveFallbackSessionId(source: string): string { + const hex = createHash("sha256").update(source, "utf8").digest("hex") return `${SESSION_ID_PREFIX}${hex.slice(0, 16)}` } -const BOOT_SALT = createHash("sha256").update(`${process.pid}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 12) - -function deriveEphemeralSessionId(source: string): string { - const hex = createHash("sha256").update(`ephemeral:${BOOT_SALT}:${source}`, "utf8").digest("hex") - return `${SESSION_ID_PREFIX}${hex.slice(0, 16)}` -} - -function resolveLocalConversationSource(ctx: { sessionId?: unknown }): string | undefined { - const sessionId = safeString(ctx.sessionId) - return sessionId ? `session:${sessionId}` : undefined -} - -function getActiveSessionSource(): string | undefined { - const envSessionId = process.env.OPENCLAW_SESSION_ID - if (envSessionId) return `env:${envSessionId}` - - const envAgentId = process.env.OPENCLAW_AGENT_ID ?? process.env.OPENCLAW_RUN_ID - if (envAgentId) return `agent:${envAgentId}` - - const state = (globalThis as Record)[PLUGIN_REGISTRY_STATE] as - | { workspaceDir?: string } - | null - | undefined - return state?.workspaceDir -} - -function fallbackSessionSource(): string { - const stateDir = process.env.OPENCLAW_STATE_DIR ?? path.join(os.homedir(), ".openclaw") - return `stateDir:${stateDir}` +type ResolvedSessionIdentity = { + sessionId: string + source: string + sourceHint: string + localSessionScoped: boolean + identityMode: "native" | "legacy_env" } -function resolveSessionId(workspaceDirFromCtx?: string, sessionIdFromCtx?: unknown): { sessionId: string; source: string; sourceHint: string; localSessionScoped: boolean } { - const conversationSource = resolveLocalConversationSource({ sessionId: sessionIdFromCtx }) - if (conversationSource) { - const diagnosticSource = workspaceDirFromCtx ? `workspaceDir:${workspaceDirFromCtx}` : (getActiveSessionSource() ?? fallbackSessionSource()) +/** + * Prefer OpenClaw's native conversation identity exactly as supplied by the + * current provider runtime contract. Generation is retained only for older + * runtimes that expose OPENCLAW_SESSION_ID but do not populate ctx.sessionId. + * + * We intentionally do not derive identity from workspace, agent, run, or state + * paths: those scopes can outlive a conversation and accidentally join two + * otherwise independent sessions in TaaS. + */ +function resolveSessionIdentity( + workspaceDirFromCtx?: string, + sessionIdFromCtx?: unknown, +): ResolvedSessionIdentity | null { + const nativeSessionId = safeString(sessionIdFromCtx) + const sourceHint = workspaceDirFromCtx + ? `workspaceDir:${workspaceDirFromCtx}` + : `stateDir:${process.env.OPENCLAW_STATE_DIR ?? path.join(os.homedir(), ".openclaw")}` + + if (nativeSessionId) { return { - sessionId: deriveSessionId(conversationSource), - source: conversationSource, - sourceHint: diagnosticSource, + sessionId: nativeSessionId, + source: "openclaw:ctx.sessionId", + sourceHint, localSessionScoped: true, + identityMode: "native", } } - if (workspaceDirFromCtx) { + const legacySessionId = safeString(process.env.OPENCLAW_SESSION_ID) + if (legacySessionId) { return { - sessionId: deriveEphemeralSessionId(`workspaceDir:${workspaceDirFromCtx}`), - source: `workspaceDir:${workspaceDirFromCtx}`, - sourceHint: `workspaceDir:${workspaceDirFromCtx}`, - localSessionScoped: false, + sessionId: deriveFallbackSessionId(`legacy-env:${legacySessionId}`), + source: "openclaw:env.OPENCLAW_SESSION_ID", + sourceHint, + localSessionScoped: true, + identityMode: "legacy_env", } } - const activeSource = getActiveSessionSource() ?? fallbackSessionSource() - return { - sessionId: deriveEphemeralSessionId(activeSource), - source: activeSource, - sourceHint: activeSource, - localSessionScoped: false, - } + return null } function findRepoRoot(startDir?: string): string | undefined { @@ -212,7 +198,7 @@ function buildCorrelationMetadata( session_id: sessionId, sticky_key: sessionId, session_source_hint: stableHash(sourceHint, "source"), - session_identity_scope: source.startsWith("session:") ? "local_session" : "legacy_source", + session_identity_scope: source === "openclaw:ctx.sessionId" ? "native_openclaw_session" : "legacy_generated_session", ...(agentId && { agent_id: agentId }), ...(provider && { provider }), ...(modelId && { model_id: modelId }), @@ -264,7 +250,7 @@ function buildRequesterRuntime( ...(provider && { provider }), ...(modelId && { model_id: modelId }), session_source_hint: stableHash(sourceHint, "source"), - session_identity_scope: source.startsWith("session:") ? "local_session" : "legacy_source", + session_identity_scope: source === "openclaw:ctx.sessionId" ? "native_openclaw_session" : "legacy_generated_session", tool_execution: "direction_2_gateway", metadata_classification: { identifiers: "hashed", @@ -378,12 +364,12 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) { const { streamFn } = ctx if (!streamFn) return undefined - const { sessionId, source, sourceHint, localSessionScoped } = resolveSessionId(ctx.workspaceDir, (ctx as { sessionId?: unknown }).sessionId) + const identity = resolveSessionIdentity(ctx.workspaceDir, (ctx as { sessionId?: unknown }).sessionId) const agentIdForCapture = deriveAgentIdForCapture(ctx as { agentDir?: string; workspaceDir?: string }) - const requesterRuntime = localSessionScoped ? buildRequesterRuntime(ctx, sessionId, source, sourceHint) : undefined - const correlation = localSessionScoped ? buildCorrelationMetadata(sessionId, source, sourceHint, ctx, agentIdForCapture) : undefined + const requesterRuntime = identity ? buildRequesterRuntime(ctx, identity.sessionId, identity.source, identity.sourceHint) : undefined + const correlation = identity ? buildCorrelationMetadata(identity.sessionId, identity.source, identity.sourceHint, ctx, agentIdForCapture) : undefined - if (isDev) console.debug(`[taas-affinity] wrapStreamFn sessionId=${sessionId} source=${source}`) + if (isDev) console.debug(`[taas-affinity] wrapStreamFn sessionId=${identity?.sessionId ?? "none"} mode=${identity?.identityMode ?? "none"}`) const inner = streamFn return function taasAffinityStreamFn(...args: Parameters) { @@ -392,13 +378,15 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) { const onPayload: NonNullable["onPayload"] = async (payload, payloadModel) => { const payloadRecord = asRecord(payload) if (!payloadRecord) return prevOnPayload ? prevOnPayload(payload, payloadModel) : payload - const patched = patchPayloadMetadata(payloadRecord, sessionId, requesterRuntime, correlation, localSessionScoped) + const patched = identity + ? patchPayloadMetadata(payloadRecord, identity.sessionId, requesterRuntime, correlation, true) + : payloadRecord return prevOnPayload ? prevOnPayload(patched, payloadModel) : patched } const prevOnResponse = options?.onResponse const onResponse: NonNullable["onResponse"] = async (response, responseModel) => { try { - captureAutorouterFromHeaders(sessionId, response?.headers ?? {}, agentIdForCapture) + identity && captureAutorouterFromHeaders(identity.sessionId, response?.headers ?? {}, agentIdForCapture) } catch (err) { if (isDev) console.debug(`[taas-affinity] onResponse capture failed: ${(err as Error)?.message ?? err}`) } @@ -409,20 +397,19 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) { } function buildTransportTurnState(ctx: ProviderResolveTransportTurnStateContext): ProviderTransportTurnState | null { - const localSource = resolveLocalConversationSource(ctx as { sessionId?: unknown }) - if (!localSource) { - if (isDev) console.debug(`[taas-affinity] no ctx.sessionId; skipping X-Session-Id injection turnId=${ctx.turnId}`) + const identity = resolveSessionIdentity(undefined, (ctx as { sessionId?: unknown }).sessionId) + if (!identity) { + if (isDev) console.debug(`[taas-affinity] no native or legacy session identity; skipping affinity headers turnId=${ctx.turnId}`) return null } - const sessionId = deriveSessionId(localSource) const agentId = deriveAgentIdForCapture(ctx as unknown as { agentDir?: string; workspaceDir?: string }) if (isDev) { console.debug( - `[taas-affinity] resolveTransportTurnState sessionId=${sessionId} ` + - `source=${localSource} turnId=${ctx.turnId} attempt=${ctx.attempt}` + `[taas-affinity] resolveTransportTurnState sessionId=${identity.sessionId} ` + + `mode=${identity.identityMode} turnId=${ctx.turnId} attempt=${ctx.attempt}` ) } - return { headers: buildCorrelationHeaders({ sessionId, turnId: ctx.turnId, attempt: ctx.attempt, agentId }) } + return { headers: buildCorrelationHeaders({ sessionId: identity.sessionId, turnId: ctx.turnId, attempt: ctx.attempt, agentId }) } } export default { @@ -460,10 +447,13 @@ export default { return } - const resolvedSessionId = directSessionId ?? resolveSessionId(workspaceDir, safeString(pp.localSessionId)).sessionId + const resolvedIdentity = directSessionId + ? null + : resolveSessionIdentity(workspaceDir, safeString(pp.localSessionId)) + const resolvedSessionId = directSessionId ?? resolvedIdentity?.sessionId ?? null respond(true, { sessionId: resolvedSessionId, - capture: getLastRouteForSession(resolvedSessionId), + capture: resolvedSessionId ? getLastRouteForSession(resolvedSessionId) : null, }) }, { scope: "operator.read" } @@ -473,7 +463,8 @@ export default { _testExports: { buildRequesterRuntime, patchPayloadMetadata, - resolveSessionId, + resolveSessionIdentity, + deriveFallbackSessionId, captureAutorouterFromHeaders, buildCorrelationHeaders, buildCorrelationMetadata, diff --git a/package-lock.json b/package-lock.json index f0f56ea..fc4eaad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openclaw-taas-affinity", - "version": "0.5.2", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openclaw-taas-affinity", - "version": "0.5.2", + "version": "0.6.0", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/package.json b/package.json index adf2b53..2450d8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-taas-affinity", - "version": "0.5.3", + "version": "0.6.0", "description": "OpenClaw provider plugin — CloudSigma TaaS session affinity. Injects a stable X-Session-Id header per conversation so TaaS can pin the session to the same OAuth token / Bedrock region / Claude Code node, maximising prompt-cache hit rates.", "type": "module", "main": "dist/index.js", diff --git a/test/requester-runtime.test.ts b/test/requester-runtime.test.ts index 28eac29..655ef6b 100644 --- a/test/requester-runtime.test.ts +++ b/test/requester-runtime.test.ts @@ -108,3 +108,48 @@ test("existing affinity metadata is never overwritten", async () => { restore() } }) + + +test("native OpenClaw session IDs pass through unchanged", async () => { + const { plugin, restore } = await loadPlugin({ OPENCLAW_SESSION_ID: "legacy-env-id" }) + try { + const provider = captureProvider(plugin) + const nativeId = "a5add102-d79b-4168-8a2a-6dd75135f73b" + const payload = await runPayload(provider, { messages: [], metadata: {} }, { sessionId: nativeId }) + assert.equal(payload.metadata.session_id, nativeId) + assert.equal(payload.metadata.sticky_key, nativeId) + assert.equal(payload.metadata.requester_runtime.openclaw_session_id, nativeId) + assert.equal(payload.metadata.requester_runtime.session_identity_scope, "native_openclaw_session") + assert.equal(payload.metadata.openclaw_correlation.session_id, nativeId) + const transport = provider.resolveTransportTurnState({ sessionId: nativeId, turnId: "turn-native", attempt: 1 }) + assert.equal(transport.headers["X-Session-Id"], nativeId) + } finally { + restore() + } +}) + +test("legacy environment identity is generated only when native session ID is unavailable", async () => { + const { plugin, restore } = await loadPlugin({ OPENCLAW_SESSION_ID: "legacy-env-id" }) + try { + const provider = captureProvider(plugin) + const payload = await runPayload(provider, { messages: [], metadata: {} }) + assert.match(payload.metadata.session_id, /^oc:[a-f0-9]{16}$/) + assert.equal(payload.metadata.requester_runtime.session_identity_scope, "legacy_generated_session") + const transport = provider.resolveTransportTurnState({ turnId: "turn-legacy", attempt: 1 }) + assert.equal(transport.headers["X-Session-Id"], payload.metadata.session_id) + } finally { + restore() + } +}) + +test("no session identity means no affinity injection", async () => { + const { plugin, restore } = await loadPlugin({ OPENCLAW_SESSION_ID: undefined }) + try { + const provider = captureProvider(plugin) + const payload = await runPayload(provider, { messages: [], metadata: { existing: true } }) + assert.deepEqual(payload.metadata, { existing: true }) + assert.equal(provider.resolveTransportTurnState({ turnId: "turn-none", attempt: 1 }), null) + } finally { + restore() + } +}) diff --git a/test/smoke.mjs b/test/smoke.mjs index e916562..ae9aad3 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -66,9 +66,9 @@ const transportState = provider.resolveTransportTurnState({ sessionId: "smoke-lo transport: "stream", }) -assert.match(transportState.headers["X-Session-Id"], /^oc:[a-f0-9]{16}$/) +assert.equal(transportState.headers["X-Session-Id"], "smoke-local-session") assert.equal(transportState.headers["X-OpenClaw-Session-Id"], transportState.headers["X-Session-Id"]) -assert.equal(transportState.headers["X-OpenClaw-Plugin-Version"], "0.5.3") +assert.equal(transportState.headers["X-OpenClaw-Plugin-Version"], "0.6.0") assert.equal(transportState.headers["X-OpenClaw-Turn-Id"], "turn-smoke") assert.equal(transportState.headers["X-OpenClaw-Attempt"], "1") @@ -82,7 +82,7 @@ const localSessionWrapped = provider.wrapStreamFn({ }) await localSessionWrapped("model", { messages: [] }, {}) const localSessionA = capturedPayload.metadata.session_id -assert.match(localSessionA, /^oc:[a-f0-9]{16}$/) +assert.equal(localSessionA, "local-session-a") assert.equal(capturedPayload.metadata.sticky_key, localSessionA) assert.equal(capturedPayload.metadata.requester_runtime.source, "openclaw-taas-affinity") assert.equal(capturedPayload.metadata.requester_runtime.session_key, localSessionA) @@ -93,7 +93,7 @@ assert.equal(capturedPayload.metadata.requester_runtime.tool_execution, "directi assert.equal("available_bridges" in capturedPayload.metadata.requester_runtime, false) assert.equal(capturedPayload.metadata.openclaw_correlation.schema_version, "2026-06-05") assert.equal(capturedPayload.metadata.openclaw_correlation.source, "openclaw-taas-affinity") -assert.equal(capturedPayload.metadata.openclaw_correlation.plugin_version, "0.5.3") +assert.equal(capturedPayload.metadata.openclaw_correlation.plugin_version, "0.6.0") assert.equal(capturedPayload.metadata.openclaw_correlation.session_id, localSessionA) assert.equal(capturedPayload.metadata.openclaw_correlation.sticky_key, localSessionA) assert.equal(capturedPayload.metadata.openclaw_correlation.provider, "cloudsigma") @@ -107,8 +107,8 @@ await provider.wrapStreamFn({ model: { id: "cloudsigma/test-model" }, })("model", { messages: [] }, {}) assert.notEqual(capturedPayload.metadata.session_id, localSessionA) -assert.equal(capturedPayload.metadata.openclaw_correlation.session_identity_scope, "local_session") -assert.equal(capturedPayload.metadata.requester_runtime.session_identity_scope, "local_session") +assert.equal(capturedPayload.metadata.openclaw_correlation.session_identity_scope, "native_openclaw_session") +assert.equal(capturedPayload.metadata.requester_runtime.session_identity_scope, "native_openclaw_session") const localTransportState = provider.resolveTransportTurnState({ provider: "cloudsigma", @@ -166,6 +166,7 @@ const captureStreamFn = async (_model, _context, options = {}) => { } const captureWrapped = provider.wrapStreamFn({ streamFn: captureStreamFn, + sessionId: "capture-smoke-session", workspaceDir: "/tmp/openclaw-token-cache-optimizer-smoke", provider: "cloudsigma", modelId: "cloudsigma/auto", @@ -178,7 +179,7 @@ let respondedOk let respondedPayload await registeredHandler({ req: { id: "test" }, - params: { workspaceDir: "/tmp/openclaw-token-cache-optimizer-smoke" }, + params: { sessionId: "capture-smoke-session" }, client: null, isWebchatConnect: () => false, respond: (ok, payload) => { @@ -189,7 +190,7 @@ await registeredHandler({ }) assert.equal(respondedOk, true, "handler responded ok") assert.ok(respondedPayload, "payload present") -assert.match(respondedPayload.sessionId, /^oc:[a-f0-9]{16}$/, "sessionId looks valid") +assert.equal(respondedPayload.sessionId, "capture-smoke-session", "native sessionId preserved") assert.ok(respondedPayload.capture, "capture present") assert.equal(respondedPayload.capture.autorouterModel, "cloudsigma/gpt-5") assert.equal(respondedPayload.capture.autorouterAlgo, "best_fit") @@ -203,7 +204,7 @@ assert.equal(respondedPayload.capture.taasTraceId, "taas-trace-456") await captureWrapped("model", { messages: [] }, {}) await registeredHandler({ req: { id: "t2" }, - params: { workspaceDir: "/tmp/openclaw-token-cache-optimizer-smoke" }, + params: { sessionId: "capture-smoke-session" }, client: null, isWebchatConnect: () => false, respond: (_ok, payload) => { @@ -238,6 +239,7 @@ console.log("autorouter capture smoke ok") ) } }, + sessionId: "new-agent-3-native-session", workspaceDir: "/home/cloudsigma/.openclaw/workspace-new-agent-3", agentDir: "/home/cloudsigma/.openclaw/workspace-new-agent-3", provider: "cloudsigma",