mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
feat(openclaw): support gateway protocol v4 (#68)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ea99ca93fd
commit
77d88c543f
@@ -12,9 +12,16 @@ import {
|
||||
type ConnectChallengePayload,
|
||||
createConnectParams,
|
||||
} from './protocol'
|
||||
import { buildSignedDevice } from './device'
|
||||
import {
|
||||
buildSignedDevice,
|
||||
clearStoredDeviceToken,
|
||||
getOrCreateIdentity,
|
||||
loadStoredDeviceToken,
|
||||
saveStoredDeviceToken,
|
||||
} from './device'
|
||||
|
||||
const DEFAULT_GATEWAY_URL = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
|
||||
const DEFAULT_SCOPES = ['operator.read'] as const
|
||||
|
||||
type EventCallback = (event: EventFrame) => void
|
||||
export type GatewayAuthState =
|
||||
@@ -29,6 +36,98 @@ interface PairingInfo {
|
||||
message?: string
|
||||
}
|
||||
|
||||
function normalized(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value.trim() || undefined : undefined
|
||||
}
|
||||
|
||||
function isTrustedLoopback(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline of openclaw selectGatewayConnectAuth / buildGatewayConnectAuth (operator subset). */
|
||||
function selectConnectAuth(params: {
|
||||
envToken?: string
|
||||
storedToken?: string
|
||||
storedScopes?: string[]
|
||||
pendingDeviceTokenRetry?: boolean
|
||||
trustedDeviceTokenRetry?: boolean
|
||||
}) {
|
||||
const authToken = normalized(params.envToken)
|
||||
const storedToken = normalized(params.storedToken)
|
||||
const useRetryToken =
|
||||
params.pendingDeviceTokenRetry === true &&
|
||||
Boolean(authToken && storedToken && params.trustedDeviceTokenRetry)
|
||||
// Reference: resolved when retry OR (!(authToken) && stored)
|
||||
const resolvedDeviceToken =
|
||||
useRetryToken || (!authToken && storedToken) ? storedToken : undefined
|
||||
const usingStoredDeviceToken =
|
||||
Boolean(resolvedDeviceToken && storedToken) && resolvedDeviceToken === storedToken
|
||||
const selectedToken = authToken ?? resolvedDeviceToken
|
||||
return {
|
||||
authToken: selectedToken,
|
||||
// buildGatewayConnectAuth: deviceToken = authDeviceToken ?? resolvedDeviceToken
|
||||
// select sets authDeviceToken only on retry; resolved covers stored-as-primary
|
||||
authDeviceToken: (useRetryToken ? storedToken : undefined) ?? resolvedDeviceToken,
|
||||
signatureToken: selectedToken ?? null,
|
||||
usingStoredDeviceToken,
|
||||
/** Stored token is auth.token (no env) — for clear-and-retry path. */
|
||||
usingStoredAsPrimary: Boolean(!authToken && selectedToken && selectedToken === storedToken),
|
||||
storedScopes: params.storedScopes,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetryWithDeviceToken(params: {
|
||||
retryBudgetUsed: boolean
|
||||
currentDeviceToken?: string
|
||||
explicitToken?: string
|
||||
storedToken?: string
|
||||
trustedEndpoint: boolean
|
||||
error?: { code?: string; message?: string; details?: unknown }
|
||||
}): boolean {
|
||||
if (
|
||||
params.retryBudgetUsed ||
|
||||
params.currentDeviceToken ||
|
||||
!params.explicitToken ||
|
||||
!params.storedToken ||
|
||||
!params.trustedEndpoint
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const code = params.error?.code ?? ''
|
||||
const message = (params.error?.message ?? '').toLowerCase()
|
||||
const details = JSON.stringify(params.error?.details ?? '')
|
||||
return (
|
||||
code === 'AUTH_TOKEN_MISMATCH' ||
|
||||
message.includes('auth_token_mismatch') ||
|
||||
message.includes('retry_with_device_token') ||
|
||||
details.includes('retry_with_device_token') ||
|
||||
details.includes('AUTH_TOKEN_MISMATCH')
|
||||
)
|
||||
}
|
||||
|
||||
function shouldRetryClearStoredToken(params: {
|
||||
retryBudgetUsed: boolean
|
||||
usingStoredAsPrimary: boolean
|
||||
envToken?: string
|
||||
error?: { code?: string; message?: string; details?: unknown }
|
||||
}): boolean {
|
||||
if (params.retryBudgetUsed || !params.usingStoredAsPrimary || !params.envToken) return false
|
||||
const code = params.error?.code ?? ''
|
||||
const message = (params.error?.message ?? '').toLowerCase()
|
||||
const details = JSON.stringify(params.error?.details ?? '')
|
||||
return (
|
||||
code === 'AUTH_TOKEN_MISMATCH' ||
|
||||
message.includes('auth_token_mismatch') ||
|
||||
message.includes('retry_with_device_token') ||
|
||||
details.includes('AUTH_TOKEN_MISMATCH')
|
||||
)
|
||||
}
|
||||
|
||||
export class ClawdbotClient {
|
||||
private ws: WebSocket | null = null
|
||||
private requestId = 0
|
||||
@@ -45,6 +144,16 @@ export class ClawdbotClient {
|
||||
private _pairingInfo: PairingInfo | null = null
|
||||
private _connectPromiseSettled = false
|
||||
private readonly debugEnabled = process.env.CRABWALK_DEBUG_OPENCLAW === '1'
|
||||
/** One-shot AUTH_TOKEN_MISMATCH retry within a connect attempt. */
|
||||
private _authRetryUsed = false
|
||||
private _pendingDeviceTokenRetry = false
|
||||
private _lastConnectAuth: {
|
||||
authDeviceToken?: string
|
||||
usingStoredAsPrimary: boolean
|
||||
} | null = null
|
||||
private _connectResolve?: (v: HelloOk) => void
|
||||
private _connectReject?: (e: Error) => void
|
||||
private _connectTimeout?: ReturnType<typeof setTimeout>
|
||||
|
||||
constructor(
|
||||
private url: string = DEFAULT_GATEWAY_URL,
|
||||
@@ -69,11 +178,16 @@ export class ClawdbotClient {
|
||||
|
||||
async connect(): Promise<HelloOk> {
|
||||
if (this._connecting || this._connected) {
|
||||
return { type: 'hello-ok', protocol: 3 } as HelloOk
|
||||
return { type: 'hello-ok', protocol: 4 } as HelloOk
|
||||
}
|
||||
this._connecting = true
|
||||
this._connectPromiseSettled = false
|
||||
this._authRetryUsed = false
|
||||
this._pendingDeviceTokenRetry = false
|
||||
this._lastConnectAuth = null
|
||||
return new Promise((resolve, reject) => {
|
||||
this._connectResolve = resolve
|
||||
this._connectReject = reject
|
||||
const timeout = setTimeout(() => {
|
||||
this._connecting = false
|
||||
this.ws?.close()
|
||||
@@ -82,6 +196,7 @@ export class ClawdbotClient {
|
||||
reject(new Error('Connection timeout - is openclaw gateway running?'))
|
||||
}
|
||||
}, 10000)
|
||||
this._connectTimeout = timeout
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
@@ -106,7 +221,7 @@ export class ClawdbotClient {
|
||||
return
|
||||
}
|
||||
|
||||
this.handleMessage(msg, resolve, reject, timeout)
|
||||
this.handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('[openclaw] Failed to parse message:', e)
|
||||
}
|
||||
@@ -155,25 +270,59 @@ export class ClawdbotClient {
|
||||
return
|
||||
}
|
||||
|
||||
let params = createConnectParams(this.token)
|
||||
const stored = loadStoredDeviceToken()
|
||||
const selected = selectConnectAuth({
|
||||
envToken: this.token,
|
||||
storedToken: stored?.token,
|
||||
storedScopes: stored?.scopes,
|
||||
pendingDeviceTokenRetry: this._pendingDeviceTokenRetry,
|
||||
trustedDeviceTokenRetry: isTrustedLoopback(this.url),
|
||||
})
|
||||
|
||||
const scopes =
|
||||
selected.usingStoredDeviceToken && stored?.scopes?.length
|
||||
? stored.scopes
|
||||
: [...DEFAULT_SCOPES]
|
||||
|
||||
let params = createConnectParams({
|
||||
token: selected.authToken,
|
||||
deviceToken: selected.authDeviceToken,
|
||||
scopes,
|
||||
})
|
||||
|
||||
// Payload platform must match client.platform after normalize (createConnectParams sets raw platform)
|
||||
try {
|
||||
const device = buildSignedDevice({
|
||||
challenge,
|
||||
token: this.token ?? null,
|
||||
token: selected.signatureToken,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
clientId: params.client.id,
|
||||
clientMode: params.client.mode,
|
||||
platform: params.client.platform,
|
||||
})
|
||||
params = createConnectParams({
|
||||
token: selected.authToken,
|
||||
deviceToken: selected.authDeviceToken,
|
||||
scopes,
|
||||
device,
|
||||
})
|
||||
params = createConnectParams(this.token, device)
|
||||
} catch (error) {
|
||||
console.error('[openclaw] Failed to create signed device identity:', error)
|
||||
}
|
||||
|
||||
this._lastConnectAuth = {
|
||||
authDeviceToken: selected.authDeviceToken,
|
||||
usingStoredAsPrimary: selected.usingStoredAsPrimary,
|
||||
}
|
||||
|
||||
this.debugLog('sending connect', {
|
||||
hasToken: Boolean(params.auth?.token),
|
||||
hasDeviceToken: Boolean(params.auth?.deviceToken),
|
||||
hasDevice: Boolean(params.device),
|
||||
deviceId: params.device?.id,
|
||||
usingStored: selected.usingStoredDeviceToken,
|
||||
pendingRetry: this._pendingDeviceTokenRetry,
|
||||
clientMode: params.client.mode,
|
||||
clientPlatform: params.client.platform,
|
||||
scopes: params.scopes,
|
||||
@@ -189,32 +338,149 @@ export class ClawdbotClient {
|
||||
this.ws.send(JSON.stringify(response))
|
||||
}
|
||||
|
||||
private handleMessage(
|
||||
msg: GatewayFrame | HelloOk,
|
||||
connectResolve?: (v: HelloOk) => void,
|
||||
_connectReject?: (e: Error) => void,
|
||||
connectTimeout?: ReturnType<typeof setTimeout>
|
||||
) {
|
||||
private handleConnectFailure(error?: { code?: string; message?: string; details?: unknown }) {
|
||||
const stored = loadStoredDeviceToken()
|
||||
const trusted = isTrustedLoopback(this.url)
|
||||
const last = this._lastConnectAuth
|
||||
|
||||
// Env primary failed → retry once with cached device token
|
||||
if (
|
||||
shouldRetryWithDeviceToken({
|
||||
retryBudgetUsed: this._authRetryUsed,
|
||||
currentDeviceToken: last?.authDeviceToken,
|
||||
explicitToken: this.token,
|
||||
storedToken: stored?.token,
|
||||
trustedEndpoint: trusted,
|
||||
error,
|
||||
})
|
||||
) {
|
||||
this._authRetryUsed = true
|
||||
this._pendingDeviceTokenRetry = true
|
||||
this.debugLog('AUTH_TOKEN_MISMATCH — retrying with device token')
|
||||
this.reopenForAuthRetry()
|
||||
return
|
||||
}
|
||||
|
||||
// Stored primary failed → clear token file, retry once with env token only
|
||||
if (
|
||||
shouldRetryClearStoredToken({
|
||||
retryBudgetUsed: this._authRetryUsed,
|
||||
usingStoredAsPrimary: Boolean(last?.usingStoredAsPrimary),
|
||||
envToken: this.token,
|
||||
error,
|
||||
})
|
||||
) {
|
||||
this._authRetryUsed = true
|
||||
this._pendingDeviceTokenRetry = false
|
||||
clearStoredDeviceToken()
|
||||
this.debugLog('AUTH_TOKEN_MISMATCH — cleared stored device token, retrying with env')
|
||||
this.reopenForAuthRetry()
|
||||
return
|
||||
}
|
||||
|
||||
const message = error?.message || 'Connect failed'
|
||||
this.updateAuthStateFromError(message)
|
||||
if (this._connectTimeout) clearTimeout(this._connectTimeout)
|
||||
this._connecting = false
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connectReject?.(new Error(message))
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-open socket for one-shot auth retry without settling the outer connect promise. */
|
||||
private reopenForAuthRetry() {
|
||||
// Detach old socket so its close handler cannot reject the connect promise.
|
||||
const old = this.ws
|
||||
this.ws = null
|
||||
if (old) {
|
||||
old.removeAllListeners()
|
||||
old.on('error', () => {})
|
||||
try {
|
||||
old.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
this._connected = false
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
} catch (e) {
|
||||
this._connecting = false
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connectReject?.(new Error(`Failed to create WebSocket for auth retry: ${e}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.ws.once('open', () => {
|
||||
this.debugLog('auth-retry socket open, waiting for connect.challenge')
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
this.handleChallenge(msg.payload as ConnectChallengePayload)
|
||||
return
|
||||
}
|
||||
this.handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('[openclaw] Failed to parse message:', e)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
this.debugLog('auth-retry socket error', err)
|
||||
if (!this._connectPromiseSettled) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connecting = false
|
||||
this._connectReject?.(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('close', (code, reason) => {
|
||||
this.debugLog('auth-retry socket close', {
|
||||
code,
|
||||
reason: reason?.toString?.() ?? '',
|
||||
})
|
||||
if (this._connecting && !this._connectPromiseSettled && !this._connected) {
|
||||
this._connectPromiseSettled = true
|
||||
this._connecting = false
|
||||
this._connectReject?.(
|
||||
new Error(
|
||||
`Gateway closed during auth retry (code ${code}${reason ? `, reason: ${reason.toString()}` : ''})`
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(msg: GatewayFrame | HelloOk) {
|
||||
if ('type' in msg) {
|
||||
switch (msg.type) {
|
||||
case 'hello-ok':
|
||||
if (connectTimeout) clearTimeout(connectTimeout)
|
||||
if (this._connectTimeout) clearTimeout(this._connectTimeout)
|
||||
this.updateAuthStateFromHello(msg)
|
||||
this._connected = true
|
||||
this._connecting = false
|
||||
this._connectPromiseSettled = true
|
||||
connectResolve?.(msg)
|
||||
this._connectResolve?.(msg)
|
||||
break
|
||||
|
||||
case 'res':
|
||||
// Check if this is the hello-ok response to our connect request
|
||||
if (msg.ok && (msg.payload as HelloOk)?.type === 'hello-ok') {
|
||||
if (connectTimeout) clearTimeout(connectTimeout)
|
||||
if (this._connectTimeout) clearTimeout(this._connectTimeout)
|
||||
this.updateAuthStateFromHello(msg.payload as HelloOk)
|
||||
this._connected = true
|
||||
this._connecting = false
|
||||
this._connectPromiseSettled = true
|
||||
connectResolve?.(msg.payload as HelloOk)
|
||||
this._connectResolve?.(msg.payload as HelloOk)
|
||||
} else if (!msg.ok && String(msg.id).startsWith('connect-')) {
|
||||
this.handleConnectFailure(msg.error)
|
||||
} else {
|
||||
this.handleResponse(msg)
|
||||
}
|
||||
@@ -333,6 +599,18 @@ export class ClawdbotClient {
|
||||
const scopes = hello.auth?.scopes
|
||||
this._scopes = scopes ? [...scopes] : []
|
||||
|
||||
if (hello.auth?.deviceToken) {
|
||||
const identity = getOrCreateIdentity()
|
||||
saveStoredDeviceToken({
|
||||
deviceId: identity.id,
|
||||
token: hello.auth.deviceToken,
|
||||
role: hello.auth.role,
|
||||
scopes: hello.auth.scopes,
|
||||
updatedAtMs: Date.now(),
|
||||
})
|
||||
this.debugLog('persisted device token')
|
||||
}
|
||||
|
||||
if (!scopes) {
|
||||
this._authState = 'authorized'
|
||||
return
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Self-check for v3 device-auth payload (no test framework).
|
||||
* Run: node --experimental-strip-types src/integrations/openclaw/device-auth.selfcheck.ts
|
||||
*/
|
||||
import {
|
||||
buildDeviceAuthPayloadV3,
|
||||
normalizeDeviceMetadataForAuth,
|
||||
} from './device.ts'
|
||||
|
||||
function assert(cond: unknown, msg: string): asserts cond {
|
||||
if (!cond) throw new Error(msg)
|
||||
}
|
||||
|
||||
const platform = normalizeDeviceMetadataForAuth('Linux')
|
||||
assert(platform === 'linux', `normalize DeviceMetadata: expected 'linux', got '${platform}'`)
|
||||
|
||||
const payload = buildDeviceAuthPayloadV3({
|
||||
deviceId: 'abc123',
|
||||
clientId: 'cli',
|
||||
clientMode: 'cli',
|
||||
role: 'operator',
|
||||
scopes: ['operator.read'],
|
||||
signedAtMs: 1700000000000,
|
||||
token: 'tok',
|
||||
nonce: 'nonce-1',
|
||||
platform: 'Linux',
|
||||
deviceFamily: '',
|
||||
})
|
||||
|
||||
const expected =
|
||||
'v3|abc123|cli|cli|operator|operator.read|1700000000000|tok|nonce-1|linux|'
|
||||
|
||||
assert(payload === expected, `payload mismatch:\n got: ${payload}\n exp: ${expected}`)
|
||||
|
||||
console.log('device-auth.selfcheck: ok')
|
||||
@@ -10,6 +10,7 @@ import type { ConnectChallengePayload, ConnectDevice } from './protocol'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data')
|
||||
const DEVICE_IDENTITY_FILE = path.join(DATA_DIR, 'device-identity.json')
|
||||
const DEVICE_TOKEN_FILE = path.join(DATA_DIR, 'device-token.json')
|
||||
|
||||
interface StoredDeviceIdentity {
|
||||
id: string
|
||||
@@ -19,6 +20,14 @@ interface StoredDeviceIdentity {
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export interface StoredDeviceToken {
|
||||
deviceId: string
|
||||
token: string
|
||||
role?: string
|
||||
scopes?: string[]
|
||||
updatedAtMs: number
|
||||
}
|
||||
|
||||
function base64UrlToBuffer(value: string): Buffer {
|
||||
const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=')
|
||||
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
@@ -76,7 +85,12 @@ function loadStoredIdentity(): StoredDeviceIdentity | null {
|
||||
|
||||
function saveStoredIdentity(identity: StoredDeviceIdentity) {
|
||||
ensureDataDir()
|
||||
fs.writeFileSync(DEVICE_IDENTITY_FILE, JSON.stringify(identity, null, 2))
|
||||
fs.writeFileSync(DEVICE_IDENTITY_FILE, JSON.stringify(identity, null, 2), { mode: 0o600 })
|
||||
try {
|
||||
fs.chmodSync(DEVICE_IDENTITY_FILE, 0o600)
|
||||
} catch {
|
||||
// ponytail: chmod best-effort on platforms that ignore mode
|
||||
}
|
||||
}
|
||||
|
||||
function generateStoredIdentity(): StoredDeviceIdentity {
|
||||
@@ -99,31 +113,36 @@ function generateStoredIdentity(): StoredDeviceIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
interface BuildSignedDeviceParams {
|
||||
challenge: ConnectChallengePayload
|
||||
token: string | null
|
||||
role: string
|
||||
scopes: string[]
|
||||
clientId: string
|
||||
clientMode: string
|
||||
/** Verbatim from openclaw gateway-client device-auth.ts */
|
||||
export function normalizeDeviceMetadataForAuth(value?: string | null): string {
|
||||
if (typeof value !== 'string') {
|
||||
return ''
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32))
|
||||
}
|
||||
|
||||
function buildDeviceAuthPayload(params: {
|
||||
export function buildDeviceAuthPayloadV3(params: {
|
||||
deviceId: string
|
||||
clientId: string
|
||||
clientMode: string
|
||||
role: string
|
||||
scopes: string[]
|
||||
signedAtMs: number
|
||||
token: string | null
|
||||
nonce?: string
|
||||
version?: 'v1' | 'v2'
|
||||
token?: string | null
|
||||
nonce: string
|
||||
platform?: string | null
|
||||
deviceFamily?: string | null
|
||||
}): string {
|
||||
const version = params.version ?? (params.nonce ? 'v2' : 'v1')
|
||||
const scopes = params.scopes.join(',')
|
||||
const token = params.token ?? ''
|
||||
const parts = [
|
||||
version,
|
||||
const platform = normalizeDeviceMetadataForAuth(params.platform)
|
||||
const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily)
|
||||
return [
|
||||
'v3',
|
||||
params.deviceId,
|
||||
params.clientId,
|
||||
params.clientMode,
|
||||
@@ -131,11 +150,21 @@ function buildDeviceAuthPayload(params: {
|
||||
scopes,
|
||||
String(params.signedAtMs),
|
||||
token,
|
||||
]
|
||||
if (version === 'v2') {
|
||||
parts.push(params.nonce ?? '')
|
||||
}
|
||||
return parts.join('|')
|
||||
params.nonce,
|
||||
platform,
|
||||
deviceFamily,
|
||||
].join('|')
|
||||
}
|
||||
|
||||
interface BuildSignedDeviceParams {
|
||||
challenge: ConnectChallengePayload
|
||||
token: string | null
|
||||
role: string
|
||||
scopes: string[]
|
||||
clientId: string
|
||||
clientMode: string
|
||||
platform?: string | null
|
||||
deviceFamily?: string | null
|
||||
}
|
||||
|
||||
export function getOrCreateIdentity(): StoredDeviceIdentity {
|
||||
@@ -149,10 +178,15 @@ export function getOrCreateIdentity(): StoredDeviceIdentity {
|
||||
}
|
||||
|
||||
export function buildSignedDevice(params: BuildSignedDeviceParams): ConnectDevice {
|
||||
const nonce = params.challenge.nonce?.trim()
|
||||
if (!nonce) {
|
||||
throw new Error('connect.challenge nonce is empty — refusing to sign (would fall back to v1)')
|
||||
}
|
||||
|
||||
const identity = getOrCreateIdentity()
|
||||
const privateKey = createPrivateKey(identity.privateKeyPem)
|
||||
const signedAt = Date.now()
|
||||
const payload = buildDeviceAuthPayload({
|
||||
const payload = buildDeviceAuthPayloadV3({
|
||||
deviceId: identity.id,
|
||||
clientId: params.clientId,
|
||||
clientMode: params.clientMode,
|
||||
@@ -160,7 +194,9 @@ export function buildSignedDevice(params: BuildSignedDeviceParams): ConnectDevic
|
||||
scopes: params.scopes,
|
||||
signedAtMs: signedAt,
|
||||
token: params.token,
|
||||
nonce: params.challenge.nonce || undefined,
|
||||
nonce,
|
||||
platform: params.platform,
|
||||
deviceFamily: params.deviceFamily ?? '',
|
||||
})
|
||||
const signature = base64UrlEncode(sign(null, Buffer.from(payload, 'utf8'), privateKey))
|
||||
|
||||
@@ -172,6 +208,35 @@ export function buildSignedDevice(params: BuildSignedDeviceParams): ConnectDevic
|
||||
publicKey: identity.publicKey,
|
||||
signature,
|
||||
signedAt,
|
||||
nonce: params.challenge.nonce,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredDeviceToken(): StoredDeviceToken | null {
|
||||
try {
|
||||
if (!fs.existsSync(DEVICE_TOKEN_FILE)) return null
|
||||
const data = JSON.parse(fs.readFileSync(DEVICE_TOKEN_FILE, 'utf-8')) as StoredDeviceToken
|
||||
if (!data.token || !data.deviceId) return null
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoredDeviceToken(entry: StoredDeviceToken) {
|
||||
ensureDataDir()
|
||||
fs.writeFileSync(DEVICE_TOKEN_FILE, JSON.stringify(entry, null, 2), { mode: 0o600 })
|
||||
try {
|
||||
fs.chmodSync(DEVICE_TOKEN_FILE, 0o600)
|
||||
} catch {
|
||||
// ponytail: chmod best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredDeviceToken() {
|
||||
try {
|
||||
if (fs.existsSync(DEVICE_TOKEN_FILE)) fs.unlinkSync(DEVICE_TOKEN_FILE)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,13 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.message) {
|
||||
// v4: deltaText when replace===true or message absent; else cumulative message path
|
||||
if (
|
||||
typeof event.deltaText === 'string' &&
|
||||
(event.replace === true || event.message == null)
|
||||
) {
|
||||
action.content = event.deltaText
|
||||
} else if (event.message) {
|
||||
if (typeof event.message === 'string') {
|
||||
action.content = event.message
|
||||
} else if (typeof event.message === 'object') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Clawdbot Gateway Protocol v3 types
|
||||
// Clawdbot Gateway Protocol v4 types
|
||||
|
||||
// Frame types
|
||||
export interface RequestFrame {
|
||||
@@ -13,7 +13,7 @@ export interface ResponseFrame {
|
||||
id: string
|
||||
ok: boolean
|
||||
payload?: unknown
|
||||
error?: { code: string; message: string }
|
||||
error?: { code: string; message: string; details?: unknown }
|
||||
}
|
||||
|
||||
export interface EventFrame {
|
||||
@@ -36,10 +36,10 @@ export interface ClientInfo {
|
||||
}
|
||||
|
||||
export interface ConnectParams {
|
||||
minProtocol: 3
|
||||
maxProtocol: 3
|
||||
minProtocol: 3 | 4
|
||||
maxProtocol: 3 | 4
|
||||
client: ClientInfo
|
||||
auth?: { token?: string }
|
||||
auth?: { token?: string; deviceToken?: string }
|
||||
device?: ConnectDevice
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface ConnectDevice {
|
||||
export interface HelloAuth {
|
||||
role?: string
|
||||
scopes?: string[]
|
||||
deviceToken?: string
|
||||
}
|
||||
|
||||
export interface HelloOk {
|
||||
@@ -79,14 +80,15 @@ export interface PresenceEntry {
|
||||
connectedAt: number
|
||||
}
|
||||
|
||||
// Chat events
|
||||
// Note: gateway sends cumulative message content with each delta, not incremental chars
|
||||
// Chat events — v4 may send deltaText (incremental) and/or cumulative message
|
||||
export interface ChatEvent {
|
||||
runId: string
|
||||
sessionKey: string
|
||||
seq: number
|
||||
state: 'delta' | 'final' | 'aborted' | 'error'
|
||||
message?: unknown
|
||||
deltaText?: string
|
||||
replace?: boolean
|
||||
errorMessage?: string
|
||||
usage?: {
|
||||
inputTokens?: number
|
||||
@@ -251,8 +253,15 @@ export function parseSessionKey(key: string): {
|
||||
return { agentId, platform, recipient, isGroup }
|
||||
}
|
||||
|
||||
export type CreateConnectOptions = {
|
||||
token?: string
|
||||
deviceToken?: string
|
||||
device?: ConnectDevice
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
export function createConnectParams(
|
||||
token?: string,
|
||||
tokenOrOpts?: string | CreateConnectOptions,
|
||||
device?: ConnectDevice
|
||||
): ConnectParams & {
|
||||
role: string
|
||||
@@ -263,6 +272,12 @@ export function createConnectParams(
|
||||
locale: string
|
||||
userAgent: string
|
||||
} {
|
||||
// ponytail: overload keeps call sites short; prefer opts object for deviceToken/scopes
|
||||
const opts: CreateConnectOptions =
|
||||
typeof tokenOrOpts === 'string' || tokenOrOpts === undefined
|
||||
? { token: tokenOrOpts, device }
|
||||
: tokenOrOpts
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
win32: 'windows',
|
||||
darwin: 'macos',
|
||||
@@ -270,23 +285,34 @@ export function createConnectParams(
|
||||
}
|
||||
const platform = platformMap[process.platform] ?? process.platform
|
||||
|
||||
const authToken = opts.token
|
||||
const authDeviceToken = opts.deviceToken
|
||||
const auth =
|
||||
authToken || authDeviceToken
|
||||
? {
|
||||
...(authToken ? { token: authToken } : {}),
|
||||
...(authDeviceToken ? { deviceToken: authDeviceToken } : {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
minProtocol: 4,
|
||||
maxProtocol: 4,
|
||||
client: {
|
||||
id: 'cli',
|
||||
displayName: 'crabwalk-monitor',
|
||||
version: '0.1.0',
|
||||
platform,
|
||||
mode: 'cli',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read'],
|
||||
scopes: opts.scopes ?? ['operator.read'],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
locale: 'en-US',
|
||||
userAgent: 'crabwalk-monitor/0.1.0',
|
||||
auth: token ? { token } : undefined,
|
||||
device,
|
||||
auth,
|
||||
device: opts.device,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user