diff --git a/.github/workflows/deploy-kiloclaw.yml b/.github/workflows/deploy-kiloclaw.yml index 007dcba3f9..b97c1bbfc7 100644 --- a/.github/workflows/deploy-kiloclaw.yml +++ b/.github/workflows/deploy-kiloclaw.yml @@ -50,7 +50,7 @@ jobs: # - container/ (COPY container/TOOLS.md → /usr/local/share/kiloclaw/) # - plugins/kiloclaw-customizer/ (COPY plugin package for image install) # - plugins/kilo-chat/ (COPY plugin package for image install) - # - openclaw-pairing-list.js, openclaw-device-pairing-list.js (COPY) + # - openclaw-pairing-list.js, openclaw-device-pairing-list.js, openclaw-gateway-client-approve.js (COPY) # - skills/ (COPY skills/ → /root/clawd/skills/) # # NOT included (not in the image): @@ -63,7 +63,7 @@ jobs: run: | # Validate all expected paths exist before hashing for path in Dockerfile controller container plugins/kiloclaw-customizer plugins/kilo-chat plugins/kiloclaw-morning-briefing skills \ - openclaw-pairing-list.js openclaw-device-pairing-list.js; do + openclaw-pairing-list.js openclaw-device-pairing-list.js openclaw-gateway-client-approve.js; do if [ ! -e "$path" ]; then echo "::error::Required path not found: $path" exit 1 @@ -72,7 +72,7 @@ jobs: CONTENT_HASH=$( find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kilo-chat/ plugins/kiloclaw-morning-briefing/ skills/ \ - openclaw-pairing-list.js openclaw-device-pairing-list.js \ + openclaw-pairing-list.js openclaw-device-pairing-list.js openclaw-gateway-client-approve.js \ -type f \ | sort \ | xargs sha256sum \ diff --git a/.github/workflows/push-dev-kiloclaw.yml b/.github/workflows/push-dev-kiloclaw.yml index fba2b640cb..60884f6f9c 100644 --- a/.github/workflows/push-dev-kiloclaw.yml +++ b/.github/workflows/push-dev-kiloclaw.yml @@ -49,7 +49,7 @@ jobs: working-directory: services/kiloclaw run: | for path in Dockerfile controller container plugins/kiloclaw-customizer plugins/kilo-chat plugins/kiloclaw-morning-briefing skills \ - openclaw-pairing-list.js openclaw-device-pairing-list.js; do + openclaw-pairing-list.js openclaw-device-pairing-list.js openclaw-gateway-client-approve.js; do if [ ! -e "$path" ]; then echo "::error::Required path not found: $path" exit 1 @@ -58,7 +58,7 @@ jobs: CONTENT_HASH=$( find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kilo-chat/ plugins/kiloclaw-morning-briefing/ skills/ \ - openclaw-pairing-list.js openclaw-device-pairing-list.js \ + openclaw-pairing-list.js openclaw-device-pairing-list.js openclaw-gateway-client-approve.js \ -type f \ | sort \ | xargs sha256sum \ diff --git a/.specs/kiloclaw-controller.md b/.specs/kiloclaw-controller.md index a5ae90805a..60f4e78b8e 100644 --- a/.specs/kiloclaw-controller.md +++ b/.specs/kiloclaw-controller.md @@ -136,15 +136,16 @@ endpoint. The `phase` field during `bootstrapping` progresses through: -| Phase | What is happening | -| --------------- | ------------------------------------------------- | -| `init` | HTTP server started, bootstrap not yet begun | -| `decrypting` | Decrypting `KILOCLAW_ENC_*` env vars | -| `directories` | Creating config/workspace dirs, setting env vars | -| `feature-flags` | Applying instance feature flags | -| `github` | Configuring GitHub access (best-effort) | -| `onboard` | Running `openclaw onboard` (first boot) | -| `doctor` | Running `openclaw doctor --fix` (subsequent boot) | +| Phase | What is happening | +| ------------------------------ | ------------------------------------------------- | +| `init` | HTTP server started, bootstrap not yet begun | +| `decrypting` | Decrypting `KILOCLAW_ENC_*` env vars | +| `directories` | Creating config/workspace dirs, setting env vars | +| `feature-flags` | Applying instance feature flags | +| `github` | Configuring GitHub access (best-effort) | +| `gateway-client-device-scopes` | Remediating gateway-client device approvals | +| `onboard` | Running `openclaw onboard` (first boot) | +| `doctor` | Running `openclaw doctor --fix` (subsequent boot) | ### Endpoint Availability by Phase diff --git a/services/kiloclaw/Dockerfile b/services/kiloclaw/Dockerfile index 888a6788b6..cfec797643 100644 --- a/services/kiloclaw/Dockerfile +++ b/services/kiloclaw/Dockerfile @@ -226,10 +226,16 @@ RUN mkdir -p /root/.openclaw \ && mkdir -p /root/clawd/skills # Copy helper scripts (used at runtime by the controller/gateway) -# Build cache bust: 2026-04-24-v66-openclaw-2026.4.15-add-kilo-chat-plugin -RUN echo "12" +# Build cache bust: 2026-04-29-v67-gateway-client-local-approve +RUN echo "13" COPY openclaw-pairing-list.js /usr/local/bin/openclaw-pairing-list.js COPY openclaw-device-pairing-list.js /usr/local/bin/openclaw-device-pairing-list.js +COPY openclaw-gateway-client-approve.js /usr/local/bin/openclaw-gateway-client-approve.js +RUN chmod +x /usr/local/bin/openclaw-gateway-client-approve.js \ + && (node /usr/local/bin/openclaw-gateway-client-approve.js not-a-uuid >/tmp/gateway-client-approve-smoke.json || test "$?" -eq 2) \ + && grep -q '"status":"invalid-request-id"' /tmp/gateway-client-approve-smoke.json \ + && node --input-type=module -e 'const fs=await import("node:fs"); const path=await import("node:path"); const url=await import("node:url"); const dir="/usr/local/lib/node_modules/openclaw/dist"; const files=fs.readdirSync(dir).filter(name=>/^device-pairing-.*\\.js$/.test(name)); if (files.length !== 1) throw new Error(`expected one device-pairing chunk, got ${files.length}`); const mod=await import(url.pathToFileURL(path.join(dir, files[0])).href); if (!Object.values(mod).some(value=>typeof value === "function" && value.name === "approveDevicePairing")) throw new Error("approveDevicePairing export not found");' \ + && rm -f /tmp/gateway-client-approve-smoke.json # Copy custom skills COPY skills/ /root/clawd/skills/ diff --git a/services/kiloclaw/controller/src/bootstrap.test.ts b/services/kiloclaw/controller/src/bootstrap.test.ts index 37d50ace46..c2e31c6032 100644 --- a/services/kiloclaw/controller/src/bootstrap.test.ts +++ b/services/kiloclaw/controller/src/bootstrap.test.ts @@ -1400,7 +1400,14 @@ describe('bootstrapNonCritical', () => { ); expect(result).toEqual({ ok: true }); - expect(phases).toEqual(['github', 'linear', 'onboard', 'tools-md', 'mcporter']); + expect(phases).toEqual([ + 'github', + 'linear', + 'gateway-client-device-scopes', + 'onboard', + 'tools-md', + 'mcporter', + ]); }); it('returns tools-md failure and stops before mcporter', async () => { @@ -1429,7 +1436,13 @@ describe('bootstrapNonCritical', () => { ); expect(result).toEqual({ ok: false, phase: 'tools-md', error: 'tools read failed' }); - expect(phases).toEqual(['github', 'linear', 'onboard', 'tools-md']); + expect(phases).toEqual([ + 'github', + 'linear', + 'gateway-client-device-scopes', + 'onboard', + 'tools-md', + ]); }); it('returns ok when doctor/onboard succeeds', async () => { @@ -1450,7 +1463,14 @@ describe('bootstrapNonCritical', () => { ); expect(result).toEqual({ ok: true }); - expect(phases).toEqual(['github', 'linear', 'onboard', 'tools-md', 'mcporter']); + expect(phases).toEqual([ + 'github', + 'linear', + 'gateway-client-device-scopes', + 'onboard', + 'tools-md', + 'mcporter', + ]); }); it('returns a doctor failure instead of throwing', async () => { @@ -1480,7 +1500,49 @@ describe('bootstrapNonCritical', () => { ); expect(result).toEqual({ ok: false, phase: 'doctor', error: 'doctor exited 1' }); - expect(phases).toEqual(['github', 'linear', 'doctor']); + expect(phases).toEqual(['github', 'linear', 'gateway-client-device-scopes', 'doctor']); + }); + + it('continues when doctor times out on an existing config', async () => { + const harness = fakeDeps(); + const phases: string[] = []; + (harness.deps.existsSync as ReturnType).mockImplementation((p: string) => { + if (p.endsWith('openclaw.json')) return true; + if (p.endsWith('TOOLS.md')) return true; + return false; + }); + (harness.deps.readFileSync as ReturnType).mockReturnValue( + JSON.stringify({ gateway: { port: 3001, mode: 'local' } }) + ); + (harness.deps.execFileSync as ReturnType).mockImplementation( + (cmd: string, args: string[], opts?: { timeout?: number }) => { + if (cmd === 'openclaw' && args.includes('doctor')) { + expect(opts?.timeout).toBe(120_000); + throw Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }); + } + return ''; + } + ); + + const result = await bootstrapNonCritical( + { + KILOCODE_API_KEY: 'api-key', + OPENCLAW_GATEWAY_TOKEN: 'gw-token', + AUTO_APPROVE_DEVICES: 'true', + }, + phase => phases.push(phase), + harness.deps + ); + + expect(result).toEqual({ ok: true }); + expect(phases).toEqual([ + 'github', + 'linear', + 'gateway-client-device-scopes', + 'doctor', + 'tools-md', + 'mcporter', + ]); }); }); @@ -1512,6 +1574,7 @@ describe('bootstrap', () => { 'feature-flags', 'github', 'linear', + 'gateway-client-device-scopes', 'onboard', 'tools-md', 'mcporter', @@ -1542,6 +1605,51 @@ describe('bootstrap', () => { expect(phases).not.toContain('onboard'); }); + it('runs gateway-client paired-device remediation before doctor', async () => { + const phases: string[] = []; + const harness = fakeDeps(); + const order: string[] = []; + + (harness.deps.existsSync as ReturnType).mockImplementation((p: string) => { + if (p.endsWith('openclaw.json')) return true; + if (p.endsWith('devices/paired.json')) return true; + return false; + }); + (harness.deps.readFileSync as ReturnType).mockImplementation((p: string) => { + if (p.endsWith('devices/paired.json')) { + order.push('remediation-read'); + return JSON.stringify({ + gatewayDevice: { + clientId: 'gateway-client', + scopes: ['operator.read'], + approvedScopes: ['operator.read'], + tokens: { operator: { scopes: ['operator.read'] } }, + }, + }); + } + return JSON.stringify({ gateway: { port: 3001, mode: 'local' } }); + }); + (harness.deps.execFileSync as ReturnType).mockImplementation( + (cmd: string, args: string[]) => { + if (cmd === 'openclaw' && args.includes('doctor')) order.push('doctor'); + return ''; + } + ); + + await bootstrap( + { + KILOCODE_API_KEY: 'api-key', + OPENCLAW_GATEWAY_TOKEN: 'gw-token', + AUTO_APPROVE_DEVICES: 'true', + }, + phase => phases.push(phase), + harness.deps + ); + + expect(phases).toContain('gateway-client-device-scopes'); + expect(order).toEqual(['remediation-read', 'doctor']); + }); + it('sets KILOCLAW_GATEWAY_ARGS after all steps complete', async () => { const harness = fakeDeps(); (harness.deps.readFileSync as ReturnType).mockReturnValue( diff --git a/services/kiloclaw/controller/src/bootstrap.ts b/services/kiloclaw/controller/src/bootstrap.ts index e6b779efd7..92c213d90e 100644 --- a/services/kiloclaw/controller/src/bootstrap.ts +++ b/services/kiloclaw/controller/src/bootstrap.ts @@ -45,6 +45,7 @@ const GATEWAY_CLIENT_OPERATOR_SCOPES = [ 'operator.pairing', 'operator.write', ]; +export const DOCTOR_TIMEOUT_MS = 120_000; // ---- Types ---- @@ -56,6 +57,7 @@ type ExecOpts = { env?: NodeJS.ProcessEnv; stdio?: 'inherit' | 'pipe'; input?: string; + timeout?: number; }; export type BootstrapDeps = { @@ -91,6 +93,7 @@ const defaultDeps: BootstrapDeps = { stdio: opts?.stdio ?? 'pipe', env: opts?.env, input: opts?.input, + timeout: opts?.timeout, }), }; @@ -119,6 +122,11 @@ function setScopeList(record: JsonRecord, key: 'scopes' | 'approvedScopes'): boo return true; } +function isTimeoutError(err: unknown): boolean { + if (!isJsonRecord(err)) return false; + return err.code === 'ETIMEDOUT' || err.signal === 'SIGTERM'; +} + // ---- Step 1: Env decryption ---- /** @@ -683,9 +691,17 @@ export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDe } } else { console.log('Using existing config, running doctor...'); - deps.execFileSync('openclaw', ['doctor', '--fix', '--non-interactive'], { - stdio: 'inherit', - }); + try { + deps.execFileSync('openclaw', ['doctor', '--fix', '--non-interactive'], { + stdio: 'inherit', + timeout: DOCTOR_TIMEOUT_MS, + }); + } catch (err) { + if (!isTimeoutError(err)) throw err; + console.warn( + `[controller] openclaw doctor timed out after ${Math.round(DOCTOR_TIMEOUT_MS / 1000)}s; continuing with config patching` + ); + } // Patch the config with env-var-derived fields const config = generateBaseConfig(env, CONFIG_PATH, cwDeps); @@ -729,6 +745,14 @@ export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDe ); } + writeBotIdentityFile(env, deps); + writeUserProfileFile(env, deps); + ensureWeatherSkillInstalled(env, deps); +} + +export function runGatewayClientDeviceScopeRemediation( + deps: BootstrapDeps = defaultDeps +): GatewayClientDeviceScopeRemediationResult { try { const remediation = remediateGatewayClientDeviceScopes(deps); if (remediation.updated > 0) { @@ -736,13 +760,11 @@ export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDe `[controller] gateway-client device scopes remediated: ${remediation.updated}/${remediation.checked} paired device(s)` ); } + return remediation; } catch (err) { console.warn('[controller] Failed to remediate gateway-client device scopes:', err); + return { checked: 0, updated: 0 }; } - - writeBotIdentityFile(env, deps); - writeUserProfileFile(env, deps); - ensureWeatherSkillInstalled(env, deps); } // ---- exec-approvals.json seeder ---- @@ -1096,6 +1118,10 @@ export async function bootstrapNonCritical( const steps: BootstrapStep[] = [ { phase: 'github', run: () => configureGitHub(env, deps) }, { phase: 'linear', run: () => configureLinear(env) }, + { + phase: 'gateway-client-device-scopes', + run: () => runGatewayClientDeviceScopeRemediation(deps), + }, { phase: configPhase, run: () => runOnboardOrDoctor(env, deps) }, { phase: 'tools-md', diff --git a/services/kiloclaw/controller/src/pairing-cache.test.ts b/services/kiloclaw/controller/src/pairing-cache.test.ts index 0ffb867b12..f2b7ffd1b4 100644 --- a/services/kiloclaw/controller/src/pairing-cache.test.ts +++ b/services/kiloclaw/controller/src/pairing-cache.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createPairingCache, OPENCLAW_BIN, + GATEWAY_CLIENT_APPROVE_BIN, DEBOUNCE_DELAY_MS, PERIODIC_INTERVAL_MS, FAILURE_RETRY_BASE_MS, @@ -13,6 +14,7 @@ import { } from './pairing-cache'; type ExecImpl = (command: string, args: string[]) => Promise<{ stdout: string; stderr: string }>; +type ApproveGatewayClientDeviceImpl = (requestId: string) => Promise; type ReadTextFileImpl = (filePath: string) => Promise; type WriteTextFileAtomicImpl = (filePath: string, data: string) => Promise; @@ -23,6 +25,7 @@ const RECENT_TS = NOW_MS - 60_000; function createTestHarness(overrides?: { execImpl?: ExecImpl; + approveGatewayClientDeviceImpl?: ApproveGatewayClientDeviceImpl; readConfigImpl?: () => unknown; readChannelPairingImpl?: ReadChannelPairingImpl; readDevicePairingImpl?: ReadDevicePairingImpl; @@ -30,6 +33,9 @@ function createTestHarness(overrides?: { writeTextFileAtomicImpl?: WriteTextFileAtomicImpl; }) { const execImpl = overrides?.execImpl ?? vi.fn(); + const approveGatewayClientDeviceImpl = + overrides?.approveGatewayClientDeviceImpl ?? + vi.fn().mockResolvedValue(); const readConfigImpl = overrides?.readConfigImpl ?? vi.fn(() => ({ @@ -52,6 +58,7 @@ function createTestHarness(overrides?: { const cache = createPairingCache({ execImpl, + approveGatewayClientDeviceImpl, readConfigImpl, readChannelPairingImpl, readDevicePairingImpl, @@ -64,6 +71,7 @@ function createTestHarness(overrides?: { return { cache, execImpl, + approveGatewayClientDeviceImpl, readConfigImpl, readChannelPairingImpl, readDevicePairingImpl, @@ -631,8 +639,11 @@ describe('createPairingCache', () => { }); describe('autoApproveGatewayClient', () => { - it('auto-approves pending gateway-client devices on refresh', async () => { + it('auto-approves pending gateway-client devices locally on refresh', async () => { const execImpl = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + const approveGatewayClientDeviceImpl = vi + .fn() + .mockResolvedValue(); const readTextFileImpl = vi.fn().mockResolvedValue( JSON.stringify({ 'a1b2c3d4-e5f6-7890-abcd-ef1234567890': { @@ -667,6 +678,7 @@ describe('createPairingCache', () => { const cache = createPairingCache({ execImpl, + approveGatewayClientDeviceImpl, readConfigImpl: () => ({ channels: {} }), readChannelPairingImpl: vi.fn().mockResolvedValue({ requests: [] }), readDevicePairingImpl, @@ -679,11 +691,10 @@ describe('createPairingCache', () => { await cache.refreshDevicePairing(); - expect(execImpl).toHaveBeenCalledWith(OPENCLAW_BIN, [ - 'devices', - 'approve', - 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - ]); + expect(execImpl).not.toHaveBeenCalled(); + expect(approveGatewayClientDeviceImpl).toHaveBeenCalledWith( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ); expect(writeTextFileAtomicImpl).toHaveBeenCalledTimes(1); const written = JSON.parse(writeTextFileAtomicImpl.mock.calls[0]?.[1] ?? '{}') as Record< string, @@ -695,8 +706,15 @@ describe('createPairingCache', () => { expect(cache.getDevicePairing().requests).toEqual([]); }); + it('exports the dedicated gateway-client approval helper path', () => { + expect(GATEWAY_CLIENT_APPROVE_BIN).toBe('/usr/local/bin/openclaw-gateway-client-approve.js'); + }); + it('does not auto-approve non-gateway-client devices', async () => { const execImpl = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + const approveGatewayClientDeviceImpl = vi + .fn() + .mockResolvedValue(); const readTextFileImpl = vi.fn().mockResolvedValue('{}'); const writeTextFileAtomicImpl = vi.fn().mockResolvedValue(); const readDevicePairingImpl = vi.fn().mockResolvedValue({ @@ -711,6 +729,7 @@ describe('createPairingCache', () => { const cache = createPairingCache({ execImpl, + approveGatewayClientDeviceImpl, readConfigImpl: () => ({ channels: {} }), readChannelPairingImpl: vi.fn().mockResolvedValue({ requests: [] }), readDevicePairingImpl, @@ -724,6 +743,7 @@ describe('createPairingCache', () => { await cache.refreshDevicePairing(); expect(execImpl).not.toHaveBeenCalled(); + expect(approveGatewayClientDeviceImpl).not.toHaveBeenCalled(); expect(readTextFileImpl).not.toHaveBeenCalled(); expect(writeTextFileAtomicImpl).not.toHaveBeenCalled(); expect(cache.getDevicePairing().requests).toHaveLength(1); @@ -731,6 +751,9 @@ describe('createPairingCache', () => { it('does not auto-approve gateway-client requests without operator role', async () => { const execImpl = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + const approveGatewayClientDeviceImpl = vi + .fn() + .mockResolvedValue(); const readTextFileImpl = vi.fn().mockResolvedValue('{}'); const writeTextFileAtomicImpl = vi.fn().mockResolvedValue(); const readDevicePairingImpl = vi.fn().mockResolvedValue({ @@ -746,6 +769,7 @@ describe('createPairingCache', () => { const cache = createPairingCache({ execImpl, + approveGatewayClientDeviceImpl, readConfigImpl: () => ({ channels: {} }), readChannelPairingImpl: vi.fn().mockResolvedValue({ requests: [] }), readDevicePairingImpl, @@ -759,6 +783,7 @@ describe('createPairingCache', () => { await cache.refreshDevicePairing(); expect(execImpl).not.toHaveBeenCalled(); + expect(approveGatewayClientDeviceImpl).not.toHaveBeenCalled(); expect(readTextFileImpl).not.toHaveBeenCalled(); expect(writeTextFileAtomicImpl).not.toHaveBeenCalled(); expect(cache.getDevicePairing().requests).toHaveLength(1); @@ -766,6 +791,9 @@ describe('createPairingCache', () => { it('skips auto-approval when pending request disappears before widening', async () => { const execImpl = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + const approveGatewayClientDeviceImpl = vi + .fn() + .mockResolvedValue(); const readTextFileImpl = vi.fn().mockResolvedValue('{}'); const writeTextFileAtomicImpl = vi.fn().mockResolvedValue(); const readDevicePairingImpl = vi @@ -784,6 +812,7 @@ describe('createPairingCache', () => { const cache = createPairingCache({ execImpl, + approveGatewayClientDeviceImpl, readConfigImpl: () => ({ channels: {} }), readChannelPairingImpl: vi.fn().mockResolvedValue({ requests: [] }), readDevicePairingImpl, @@ -797,12 +826,16 @@ describe('createPairingCache', () => { await cache.refreshDevicePairing(); expect(execImpl).not.toHaveBeenCalled(); + expect(approveGatewayClientDeviceImpl).not.toHaveBeenCalled(); expect(writeTextFileAtomicImpl).not.toHaveBeenCalled(); expect(cache.getDevicePairing().requests).toEqual([]); }); it('does not auto-approve when option is disabled', async () => { const execImpl = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + const approveGatewayClientDeviceImpl = vi + .fn() + .mockResolvedValue(); const readDevicePairingImpl = vi.fn().mockResolvedValue({ 'req-1': { requestId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', @@ -815,6 +848,7 @@ describe('createPairingCache', () => { const cache = createPairingCache({ execImpl, + approveGatewayClientDeviceImpl, readConfigImpl: () => ({ channels: {} }), readChannelPairingImpl: vi.fn().mockResolvedValue({ requests: [] }), readDevicePairingImpl, @@ -826,6 +860,106 @@ describe('createPairingCache', () => { await cache.refreshDevicePairing(); expect(execImpl).not.toHaveBeenCalled(); + expect(approveGatewayClientDeviceImpl).not.toHaveBeenCalled(); + }); + + it('backs off failed gateway-client auto-approval attempts', async () => { + const approveGatewayClientDeviceImpl = vi + .fn() + .mockRejectedValueOnce(new Error('local approval failed')) + .mockResolvedValue(); + const request = { + requestId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + deviceId: 'dev1', + clientId: 'gateway-client', + role: 'operator', + roles: ['operator'], + ts: RECENT_TS, + }; + let nowMs = NOW_MS; + const readTextFileImpl = vi.fn().mockResolvedValue( + JSON.stringify({ + [request.requestId]: { ...request, scopes: GATEWAY_CLIENT_OPERATOR_SCOPES }, + }) + ); + const readDevicePairingImpl = vi.fn().mockResolvedValue({ + [request.requestId]: request, + }); + + const cache = createPairingCache({ + approveGatewayClientDeviceImpl, + readConfigImpl: () => ({ channels: {} }), + readChannelPairingImpl: vi.fn().mockResolvedValue({ requests: [] }), + readDevicePairingImpl, + readTextFileImpl, + writeTextFileAtomicImpl: vi.fn().mockResolvedValue(), + nowImpl: () => '2026-03-12T00:00:00.000Z', + nowMsImpl: () => nowMs, + autoApproveGatewayClient: true, + }); + + await cache.refreshDevicePairing(); + await cache.refreshDevicePairing(); + expect(approveGatewayClientDeviceImpl).toHaveBeenCalledTimes(1); + + nowMs += 30_000; + await cache.refreshDevicePairing(); + expect(approveGatewayClientDeviceImpl).toHaveBeenCalledTimes(2); + }); + + it('prunes retry backoff for disappeared gateway-client requests', async () => { + const approveGatewayClientDeviceImpl = vi + .fn() + .mockRejectedValueOnce(new Error('local approval failed')) + .mockResolvedValue(); + const oldRequest = { + requestId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + deviceId: 'dev1', + clientId: 'gateway-client', + role: 'operator', + roles: ['operator'], + ts: RECENT_TS, + }; + const newRequest = { + requestId: 'b1b2c3d4-e5f6-7890-abcd-ef1234567890', + deviceId: 'dev1', + clientId: 'gateway-client', + role: 'operator', + roles: ['operator'], + ts: RECENT_TS, + }; + let currentRequest = oldRequest; + const readTextFileImpl = vi.fn().mockImplementation(async () => + JSON.stringify({ + [currentRequest.requestId]: { + ...currentRequest, + scopes: GATEWAY_CLIENT_OPERATOR_SCOPES, + }, + }) + ); + const readDevicePairingImpl = vi.fn().mockImplementation(async () => ({ + [currentRequest.requestId]: currentRequest, + })); + + const cache = createPairingCache({ + approveGatewayClientDeviceImpl, + readConfigImpl: () => ({ channels: {} }), + readChannelPairingImpl: vi.fn().mockResolvedValue({ requests: [] }), + readDevicePairingImpl, + readTextFileImpl, + writeTextFileAtomicImpl: vi.fn().mockResolvedValue(), + nowImpl: () => '2026-03-12T00:00:00.000Z', + nowMsImpl: () => NOW_MS, + autoApproveGatewayClient: true, + }); + + await cache.refreshDevicePairing(); + expect(approveGatewayClientDeviceImpl).toHaveBeenCalledWith(oldRequest.requestId); + currentRequest = newRequest; + await cache.refreshDevicePairing(); + + expect(approveGatewayClientDeviceImpl).toHaveBeenCalledTimes(2); + expect(approveGatewayClientDeviceImpl).toHaveBeenLastCalledWith(newRequest.requestId); }); }); diff --git a/services/kiloclaw/controller/src/pairing-cache.ts b/services/kiloclaw/controller/src/pairing-cache.ts index 692ff38285..e4b7bb2e4f 100644 --- a/services/kiloclaw/controller/src/pairing-cache.ts +++ b/services/kiloclaw/controller/src/pairing-cache.ts @@ -48,6 +48,7 @@ export type PairingCache = { }; type ExecImpl = (command: string, args: string[]) => Promise<{ stdout: string; stderr: string }>; +type ApproveGatewayClientDeviceImpl = (requestId: string) => Promise; type ReadTextFileImpl = (filePath: string) => Promise; type WriteTextFileAtomicImpl = (filePath: string, data: string) => Promise; @@ -56,6 +57,7 @@ export type ReadDevicePairingImpl = () => Promise; type PairingCacheOptions = { execImpl?: ExecImpl; + approveGatewayClientDeviceImpl?: ApproveGatewayClientDeviceImpl; readConfigImpl?: () => unknown; nowImpl?: () => string; readChannelPairingImpl?: ReadChannelPairingImpl; @@ -104,6 +106,7 @@ function approveFail(message: string, statusHint: 400 | 500): ApproveResult { } export const OPENCLAW_BIN = '/usr/local/bin/openclaw'; +export const GATEWAY_CLIENT_APPROVE_BIN = '/usr/local/bin/openclaw-gateway-client-approve.js'; export const GATEWAY_CLIENT_ID = 'gateway-client'; export const OPERATOR_ROLE = 'operator'; export const GATEWAY_CLIENT_OPERATOR_SCOPES = [ @@ -113,6 +116,8 @@ export const GATEWAY_CLIENT_OPERATOR_SCOPES = [ 'operator.pairing', 'operator.write', ]; +export const GATEWAY_CLIENT_APPROVE_TIMEOUT_MS = 15_000; +export const GATEWAY_CLIENT_APPROVE_RETRY_MS = 30_000; // Mirrors resolveStateDir() / resolveOAuthDir() in openclaw/src/config/paths.ts // Note: openclaw's full resolveStateDir() also does filesystem-existence checks for @@ -143,6 +148,14 @@ function defaultExecImpl( }); } +async function defaultApproveGatewayClientDeviceImpl(requestId: string): Promise { + await execFileAsync(GATEWAY_CLIENT_APPROVE_BIN, [requestId], { + encoding: 'utf8', + timeout: GATEWAY_CLIENT_APPROVE_TIMEOUT_MS, + env: { ...process.env, HOME: '/root' }, + }); +} + function defaultReadConfigImpl(): unknown { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } @@ -304,6 +317,7 @@ export function detectChannels(config: unknown): string[] { export function createPairingCache(options?: PairingCacheOptions): PairingCache { const { execImpl = defaultExecImpl, + approveGatewayClientDeviceImpl = defaultApproveGatewayClientDeviceImpl, readConfigImpl = defaultReadConfigImpl, nowImpl = () => new Date().toISOString(), readChannelPairingImpl = async (channel: string) => { @@ -332,6 +346,8 @@ export function createPairingCache(options?: PairingCacheOptions): PairingCache let nextAllowedRefreshAt = 0; let hasCompletedInitialRefresh = false; let consecutiveFailureCount = 0; + const gatewayClientApprovalsInFlight = new Set(); + const gatewayClientApprovalRetryAfter = new Map(); // Generation counters prevent stale concurrent refreshes from overwriting // newer data. Each refresh captures the counter at start; if another @@ -426,7 +442,9 @@ export function createPairingCache(options?: PairingCacheOptions): PairingCache return true; }; - const refreshDevicePairingInternal = async (): Promise => { + const refreshDevicePairingInternal = async (options?: { + autoApprove?: boolean; + }): Promise => { if (stopped) return false; const gen = ++deviceGeneration; try { @@ -456,10 +474,34 @@ export function createPairingCache(options?: PairingCacheOptions): PairingCache } console.log(`[pairing-cache] devices: read ok, ${requests.length} pending`); - if (autoApproveGatewayClient) { + const currentRequestIds = new Set(requests.map(req => req.requestId)); + for (const requestId of gatewayClientApprovalRetryAfter.keys()) { + if (!currentRequestIds.has(requestId)) { + gatewayClientApprovalRetryAfter.delete(requestId); + } + } + + if (autoApproveGatewayClient && options?.autoApprove !== false) { const gatewayRequests = requests.filter(isGatewayClientOperatorRequest); + let shouldRefreshAfterApproval = false; for (const req of gatewayRequests) { + const retryAfter = gatewayClientApprovalRetryAfter.get(req.requestId) ?? 0; + const now = nowMsImpl(); + if (now < retryAfter) { + console.log( + `[pairing-cache] skipping gateway-client device ${req.requestId} auto-approval (retry in ${Math.ceil((retryAfter - now) / 1000)}s)` + ); + continue; + } + if (gatewayClientApprovalsInFlight.has(req.requestId)) { + console.log( + `[pairing-cache] gateway-client device ${req.requestId} auto-approval already in flight` + ); + continue; + } + console.log(`[pairing-cache] auto-approving gateway-client device ${req.requestId}`); + gatewayClientApprovalsInFlight.add(req.requestId); try { const widened = await widenGatewayClientPendingRequestScopes({ requestId: req.requestId, @@ -470,6 +512,7 @@ export function createPairingCache(options?: PairingCacheOptions): PairingCache console.log( `[pairing-cache] gateway-client pending request ${req.requestId} disappeared before approval` ); + shouldRefreshAfterApproval = true; continue; } if (widened.changed) { @@ -477,14 +520,22 @@ export function createPairingCache(options?: PairingCacheOptions): PairingCache `[pairing-cache] widened gateway-client device ${req.requestId} approval scopes` ); } - await execImpl(OPENCLAW_BIN, ['devices', 'approve', req.requestId]); + await approveGatewayClientDeviceImpl(req.requestId); + gatewayClientApprovalRetryAfter.delete(req.requestId); + shouldRefreshAfterApproval = true; } catch (err) { console.error(`[pairing-cache] auto-approve failed for ${req.requestId}:`, err); + gatewayClientApprovalRetryAfter.set( + req.requestId, + nowMsImpl() + GATEWAY_CLIENT_APPROVE_RETRY_MS + ); + } finally { + gatewayClientApprovalsInFlight.delete(req.requestId); } } - if (gatewayRequests.length > 0) { + if (shouldRefreshAfterApproval) { // Re-read after approvals so the cache reflects the updated state. - await refreshDevicePairingInternal(); + await refreshDevicePairingInternal({ autoApprove: false }); } } diff --git a/services/kiloclaw/openclaw-gateway-client-approve.js b/services/kiloclaw/openclaw-gateway-client-approve.js new file mode 100644 index 0000000000..53beb7b53e --- /dev/null +++ b/services/kiloclaw/openclaw-gateway-client-approve.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +// Locally approve KiloClaw's internal gateway-client device pairing request. +// This intentionally bypasses `openclaw devices approve`, which first attempts +// gateway RPC and can deadlock when gateway-client itself is blocked on pairing. + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { randomUUID } from 'node:crypto'; + +const OPENCLAW_DIST_DIR = + process.env.OPENCLAW_DIST_DIR || '/usr/local/lib/node_modules/openclaw/dist'; +const STATE_DIR = process.env.OPENCLAW_STATE_DIR || '/root/.openclaw'; +const PENDING_PATH = path.join(STATE_DIR, 'devices', 'pending.json'); +const GATEWAY_CLIENT_ID = 'gateway-client'; +const OPERATOR_ROLE = 'operator'; +const FULL_OPERATOR_SCOPES = [ + 'operator.read', + 'operator.admin', + 'operator.approvals', + 'operator.pairing', + 'operator.write', +]; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function roleList(entry) { + const roles = new Set(); + if (Array.isArray(entry.roles)) { + for (const role of entry.roles) { + if (typeof role !== 'string') continue; + const trimmed = role.trim(); + if (trimmed) roles.add(trimmed); + } + } + if (typeof entry.role === 'string') { + const trimmed = entry.role.trim(); + if (trimmed) roles.add(trimmed); + } + return [...roles]; +} + +function sameScopeSet(value) { + if (!Array.isArray(value) || value.length !== FULL_OPERATOR_SCOPES.length) return false; + const expected = new Set(FULL_OPERATOR_SCOPES); + return value.every(scope => typeof scope === 'string' && expected.has(scope)); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJsonAtomic(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const tmp = `${filePath}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tmp, filePath); + fs.chmodSync(filePath, 0o600); + } finally { + try { + fs.rmSync(tmp, { force: true }); + } catch { + // best-effort temp cleanup + } + } +} + +function loadPendingRequest(requestId) { + if (!fs.existsSync(PENDING_PATH)) { + return { status: 'missing-pending-file' }; + } + const pendingFile = readJsonFile(PENDING_PATH); + if (!isRecord(pendingFile)) { + return { status: 'invalid-pending-file' }; + } + const entry = pendingFile[requestId]; + if (!isRecord(entry)) { + return { status: 'missing-request' }; + } + return { status: 'found', pendingFile, entry }; +} + +function validateGatewayClientRequest(entry) { + if (entry.clientId !== GATEWAY_CLIENT_ID) { + return { ok: false, reason: 'not-gateway-client' }; + } + if (!roleList(entry).includes(OPERATOR_ROLE)) { + return { ok: false, reason: 'not-operator' }; + } + return { ok: true }; +} + +function widenPendingRequestScopes(requestId, pendingFile, entry) { + if (sameScopeSet(entry.scopes)) return false; + pendingFile[requestId] = { + ...entry, + scopes: [...FULL_OPERATOR_SCOPES], + }; + writeJsonAtomic(PENDING_PATH, pendingFile); + return true; +} + +async function importApproveDevicePairing() { + const files = fs + .readdirSync(OPENCLAW_DIST_DIR) + .filter(name => /^device-pairing-.*\.js$/.test(name)) + .sort(); + if (files.length !== 1) { + throw new Error( + `expected exactly one OpenClaw device-pairing dist chunk, found ${files.length}` + ); + } + const modulePath = path.join(OPENCLAW_DIST_DIR, files[0]); + const mod = await import(pathToFileURL(modulePath).href); + for (const value of Object.values(mod)) { + if (typeof value === 'function' && value.name === 'approveDevicePairing') { + return value; + } + } + throw new Error(`approveDevicePairing export not found in ${files[0]}`); +} + +function printResult(result) { + console.log(JSON.stringify(result)); +} + +async function main() { + process.env.HOME = '/root'; + const requestId = process.argv[2]; + if (!requestId || !UUID_RE.test(requestId)) { + printResult({ approved: false, status: 'invalid-request-id' }); + process.exitCode = 2; + return; + } + + const pending = loadPendingRequest(requestId); + if (pending.status !== 'found') { + printResult({ approved: false, status: pending.status, requestId }); + return; + } + + const validation = validateGatewayClientRequest(pending.entry); + if (!validation.ok) { + printResult({ approved: false, status: validation.reason, requestId }); + process.exitCode = 2; + return; + } + + const scopesWidened = widenPendingRequestScopes(requestId, pending.pendingFile, pending.entry); + const approveDevicePairing = await importApproveDevicePairing(); + const approved = await approveDevicePairing(requestId, { callerScopes: FULL_OPERATOR_SCOPES }); + + if (!approved) { + printResult({ approved: false, status: 'missing-request', requestId, scopesWidened }); + return; + } + if (approved.status !== 'approved') { + printResult({ + approved: false, + status: approved.status, + reason: approved.reason, + scope: approved.scope, + role: approved.role, + requestId, + scopesWidened, + }); + process.exitCode = 2; + return; + } + + printResult({ + approved: true, + status: 'approved', + requestId, + deviceId: approved.device.deviceId, + scopesWidened, + }); +} + +main().catch(err => { + process.stderr.write( + `[gateway-client-approve] fatal: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exitCode = 1; +});