Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<sha256-prefix>`
- passes OpenClaw's native `ctx.sessionId` through unchanged when available
- generates a stable `oc:<sha256-prefix>` 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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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.
135 changes: 63 additions & 72 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<symbol, unknown>)[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 {
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<typeof inner>) {
Expand All @@ -392,13 +378,15 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) {
const onPayload: NonNullable<typeof options>["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<typeof options>["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}`)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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" }
Expand All @@ -473,7 +463,8 @@ export default {
_testExports: {
buildRequesterRuntime,
patchPayloadMetadata,
resolveSessionId,
resolveSessionIdentity,
deriveFallbackSessionId,
captureAutorouterFromHeaders,
buildCorrelationHeaders,
buildCorrelationMetadata,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
45 changes: 45 additions & 0 deletions test/requester-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
Loading
Loading