From f448d1bded3fd52760257392f404fd9f8d0bcd8f Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Fri, 29 May 2026 13:12:47 -0600 Subject: [PATCH 1/8] fix(kiloclaw): align upgrade banner rollout subject --- .../lib/kiloclaw/kiloclaw-internal-client.ts | 5 ++- apps/web/src/routers/kiloclaw-router.ts | 25 ++++++--------- .../organization-kiloclaw-router.ts | 11 +++++-- .../durable-objects/kiloclaw-instance.test.ts | 32 ++++++++++++++++++- services/kiloclaw/src/lib/version-rollout.ts | 2 +- services/kiloclaw/src/routes/platform.ts | 23 ++++++------- 6 files changed, 63 insertions(+), 35 deletions(-) diff --git a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts index b9664d66cf..1566eb75c8 100644 --- a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts +++ b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts @@ -154,14 +154,13 @@ export class KiloClawInternalClient { async getLatestVersion(opts?: { instanceId?: string; + userId?: string; currentImageTag?: string | null; }): Promise { - // Note: Early Access is resolved server-side from the instance's owning - // user — callers do NOT pass it as a param. Trying to set it here would - // be ignored. let path = '/api/platform/versions/latest'; if (opts?.instanceId) { const params = new URLSearchParams({ instanceId: opts.instanceId }); + if (opts.userId) params.set('userId', opts.userId); if (opts.currentImageTag) params.set('currentImageTag', opts.currentImageTag); path += `?${params.toString()}`; } diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 24daca1c8b..5d0bef3907 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -54,6 +54,10 @@ import { } from '@kilocode/db/schema'; import { and, asc, eq, ne, desc, isNull, inArray, sql, like, or } from 'drizzle-orm'; import { ImpactReferralProduct, ImpactReferralRewardKind } from '@kilocode/db/schema-types'; +import { + instanceIdFromSandboxId, + isInstanceKeyedSandboxId, +} from '@kilocode/worker-utils/instance-id'; import { alias } from 'drizzle-orm/pg-core'; import { deleteWorkerTrigger } from '@/lib/webhook-agent/webhook-agent-client'; import { sentryLogger } from '@/lib/utils.server'; @@ -2857,26 +2861,15 @@ export const kiloclawRouter = createTRPCRouter({ latestVersion: baseProcedure .input(z.object({ currentImageTag: z.string().min(1).optional() }).optional()) .query(async ({ ctx, input }) => { - // Pass instance + currentImageTag through; Early Access is resolved - // server-side from the instance's owning user (the platform endpoint - // does the kilocode_users lookup itself, so callers can't fake it). - const [instance] = await db - .select({ id: kiloclaw_instances.id }) - .from(kiloclaw_instances) - .where( - and( - eq(kiloclaw_instances.user_id, ctx.user.id), - isNull(kiloclaw_instances.organization_id), - isNull(kiloclaw_instances.destroyed_at) - ) - ) - .limit(1); - + const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawInternalClient(); if (!instance) return client.getLatestVersion(); return client.getLatestVersion({ - instanceId: instance.id, + instanceId: isInstanceKeyedSandboxId(instance.sandboxId) + ? instanceIdFromSandboxId(instance.sandboxId) + : ctx.user.id, + userId: ctx.user.id, currentImageTag: input?.currentImageTag ?? null, }); }), diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts index af901bb40c..ce218335b2 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts @@ -25,6 +25,10 @@ import { KILOCLAW_API_URL, KILOCLAW_INSTANCE_URL_TEMPLATE } from '@/lib/config.s import { workerUrlForInstance } from '@/lib/kiloclaw/instance-url'; import { sentryLogger } from '@/lib/utils.server'; import { db } from '@/lib/drizzle'; +import { + instanceIdFromSandboxId, + isInstanceKeyedSandboxId, +} from '@kilocode/worker-utils/instance-id'; import { kiloclaw_version_pins, kiloclaw_image_catalog, @@ -308,10 +312,11 @@ export const organizationKiloclawRouter = createTRPCRouter({ const client = new KiloClawInternalClient(); const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId); if (!instance) return client.getLatestVersion(); - // Early Access is resolved server-side via the platform endpoint - // (instance → owner → kiloclaw_early_access lookup), not passed by us. return client.getLatestVersion({ - instanceId: instance.id, + instanceId: isInstanceKeyedSandboxId(instance.sandboxId) + ? instanceIdFromSandboxId(instance.sandboxId) + : ctx.user.id, + userId: ctx.user.id, currentImageTag: input.currentImageTag ?? null, }); }), diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts index b91e407276..98ea85774a 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts @@ -7821,12 +7821,42 @@ describe('restartMachine image tag override', () => { const result = await instance.restartMachine({ imageTag: 'latest' }); expect(result.success).toBe(true); - expect(selectImageVersionForInstance).toHaveBeenCalledOnce(); + expect(selectImageVersionForInstance).toHaveBeenCalledWith( + expect.objectContaining({ instanceId: 'user-1' }) + ); expect(storage._store.get('trackedImageTag')).toBe('new-tag-from-kv'); expect(storage._store.get('openclawVersion')).toBe('2.0.0'); expect(storage._store.get('imageVariant')).toBe('default'); }); + it('resolves latest with the instance UUID for instance-keyed sandboxes', async () => { + const { instance, storage } = createInstance(); + const instanceId = '123e4567-e89b-12d3-a456-426614174000'; + await seedRunning(storage, { + sandboxId: 'ki_123e4567e89b12d3a456426614174000', + trackedImageTag: 'old-tag', + openclawVersion: '1.0.0', + imageVariant: 'default', + }); + + (selectImageVersionForInstance as Mock).mockResolvedValueOnce({ + openclawVersion: '2.0.0', + variant: 'default', + imageTag: 'new-tag-from-kv', + imageDigest: null, + publishedAt: new Date().toISOString(), + rolloutPercent: 0, + isLatest: true, + }); + + const result = await instance.restartMachine({ imageTag: 'latest' }); + + expect(result.success).toBe(true); + expect(selectImageVersionForInstance).toHaveBeenCalledWith( + expect.objectContaining({ instanceId }) + ); + }); + it('falls back gracefully when "latest" but selector returns null', async () => { const { instance, storage } = createInstance(); await seedRunning(storage, { trackedImageTag: 'old-tag' }); diff --git a/services/kiloclaw/src/lib/version-rollout.ts b/services/kiloclaw/src/lib/version-rollout.ts index 92a6824a7d..6018ad3475 100644 --- a/services/kiloclaw/src/lib/version-rollout.ts +++ b/services/kiloclaw/src/lib/version-rollout.ts @@ -46,7 +46,7 @@ async function readPointer(kv: KVNamespace, key: string): Promise { // Resolves the image version this caller should be on next. // // Query params (all optional): -// instanceId — bucket subject for rollout candidate selection +// instanceId — rollout subject used for candidate selection +// userId — owning user; used only for Early Access lookup // currentImageTag — caller's current image; used to suppress self-upgrades // // Without instanceId, returns the current :latest pointer (back-compat for @@ -4448,13 +4449,14 @@ platform.get('/versions', async c => { // in cohort, the :latest baseline when not, or 404 when the caller is already // on the newest applicable image (banner: "no upgrade"). // -// The Early Access flag is looked up server-side from the instance's owning -// user — callers cannot pass it as a query param. This keeps the service as -// the single authoritative source: even an internal-key-holding caller can't -// claim Early Access for an arbitrary instance. +// The caller is responsible for passing the same rollout subject that the +// restart path would use: userId for legacy user-keyed instances and instanceId +// for instance-keyed instances. Early Access is still resolved server-side from +// the owning userId, matching the DO upgrade path. platform.get('/versions/latest', async c => { try { const instanceId = c.req.query('instanceId'); + const userId = c.req.query('userId'); const currentImageTag = c.req.query('currentImageTag') ?? null; if (!instanceId) { @@ -4463,14 +4465,13 @@ platform.get('/versions/latest', async c => { return c.json(latest); } - // Resolve Early Access from the instance's owner. This requires Hyperdrive; - // without it we degrade gracefully to autoEnroll=false (the bucket math - // still works correctly — only the staff/beta-tester override is missing). + // Resolve Early Access the same way restartMachine does inside the DO: + // from the owning userId, not from caller-provided eligibility state. let autoEnroll = false; const connectionString = c.env.HYPERDRIVE?.connectionString; - if (connectionString) { + if (userId && connectionString) { try { - autoEnroll = await lookupKiloclawEarlyAccessByInstanceId(connectionString, instanceId); + autoEnroll = await lookupKiloclawEarlyAccess(connectionString, userId); } catch (err) { console.warn( '[platform] Early Access lookup failed; treating as false:', From fb2244aef4b7edadc3170e27bb55e300e15ecca3 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Fri, 29 May 2026 13:38:39 -0600 Subject: [PATCH 2/8] refactor(kiloclaw): share image rollout subject helper --- apps/web/src/routers/kiloclaw-router.ts | 9 ++------ .../organization-kiloclaw-router.ts | 9 ++------ packages/worker-utils/src/instance-id.test.ts | 22 +++++++++++++++++++ packages/worker-utils/src/instance-id.ts | 15 +++++++++++++ .../kiloclaw-instance/index.ts | 9 ++++---- 5 files changed, 46 insertions(+), 18 deletions(-) create mode 100644 packages/worker-utils/src/instance-id.test.ts diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 5d0bef3907..0ed2ca04aa 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -54,10 +54,7 @@ import { } from '@kilocode/db/schema'; import { and, asc, eq, ne, desc, isNull, inArray, sql, like, or } from 'drizzle-orm'; import { ImpactReferralProduct, ImpactReferralRewardKind } from '@kilocode/db/schema-types'; -import { - instanceIdFromSandboxId, - isInstanceKeyedSandboxId, -} from '@kilocode/worker-utils/instance-id'; +import { imageRolloutSubjectFromSandboxId } from '@kilocode/worker-utils/instance-id'; import { alias } from 'drizzle-orm/pg-core'; import { deleteWorkerTrigger } from '@/lib/webhook-agent/webhook-agent-client'; import { sentryLogger } from '@/lib/utils.server'; @@ -2866,9 +2863,7 @@ export const kiloclawRouter = createTRPCRouter({ if (!instance) return client.getLatestVersion(); return client.getLatestVersion({ - instanceId: isInstanceKeyedSandboxId(instance.sandboxId) - ? instanceIdFromSandboxId(instance.sandboxId) - : ctx.user.id, + instanceId: imageRolloutSubjectFromSandboxId(instance.sandboxId, ctx.user.id), userId: ctx.user.id, currentImageTag: input?.currentImageTag ?? null, }); diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts index ce218335b2..ebc876a568 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts @@ -25,10 +25,7 @@ import { KILOCLAW_API_URL, KILOCLAW_INSTANCE_URL_TEMPLATE } from '@/lib/config.s import { workerUrlForInstance } from '@/lib/kiloclaw/instance-url'; import { sentryLogger } from '@/lib/utils.server'; import { db } from '@/lib/drizzle'; -import { - instanceIdFromSandboxId, - isInstanceKeyedSandboxId, -} from '@kilocode/worker-utils/instance-id'; +import { imageRolloutSubjectFromSandboxId } from '@kilocode/worker-utils/instance-id'; import { kiloclaw_version_pins, kiloclaw_image_catalog, @@ -313,9 +310,7 @@ export const organizationKiloclawRouter = createTRPCRouter({ const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId); if (!instance) return client.getLatestVersion(); return client.getLatestVersion({ - instanceId: isInstanceKeyedSandboxId(instance.sandboxId) - ? instanceIdFromSandboxId(instance.sandboxId) - : ctx.user.id, + instanceId: imageRolloutSubjectFromSandboxId(instance.sandboxId, ctx.user.id), userId: ctx.user.id, currentImageTag: input.currentImageTag ?? null, }); diff --git a/packages/worker-utils/src/instance-id.test.ts b/packages/worker-utils/src/instance-id.test.ts new file mode 100644 index 0000000000..1f658717fb --- /dev/null +++ b/packages/worker-utils/src/instance-id.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { imageRolloutSubjectFromSandboxId, sandboxIdFromInstanceId } from './instance-id'; + +describe('imageRolloutSubjectFromSandboxId', () => { + it('uses userId for legacy sandboxIds', () => { + expect(imageRolloutSubjectFromSandboxId('dXNlci1sZWdhY3k', 'user-legacy')).toBe('user-legacy'); + }); + + it('decodes the rollout subject from ki_ sandboxIds', () => { + const instanceId = '11111111-2222-4333-8444-555555555555'; + + expect( + imageRolloutSubjectFromSandboxId(sandboxIdFromInstanceId(instanceId), 'user-instance-keyed') + ).toBe(instanceId); + }); + + it('uses userId when sandboxId is absent', () => { + expect(imageRolloutSubjectFromSandboxId(null, 'user-missing-sandbox')).toBe( + 'user-missing-sandbox' + ); + }); +}); diff --git a/packages/worker-utils/src/instance-id.ts b/packages/worker-utils/src/instance-id.ts index acfe50382c..9c1dd2b2ca 100644 --- a/packages/worker-utils/src/instance-id.ts +++ b/packages/worker-utils/src/instance-id.ts @@ -51,3 +51,18 @@ export function instanceIdFromSandboxId(sandboxId: string): string { const hex = sandboxId.slice(3); // strip "ki_" return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } + +/** + * Return the subject used to bucket image-version rollouts. + * + * Legacy rows are user-keyed, so they bucket by userId. Instance-keyed rows + * bucket by the UUID encoded in the `ki_` sandboxId. This mirrors + * KiloClawInstance.restartMachine({ imageTag: 'latest' }). + */ +export function imageRolloutSubjectFromSandboxId( + sandboxId: string | null | undefined, + userId: string +): string { + if (!sandboxId) return userId; + return isInstanceKeyedSandboxId(sandboxId) ? instanceIdFromSandboxId(sandboxId) : userId; +} diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts index 17b150cd42..f910cfb3dc 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -37,6 +37,7 @@ import type { KiloclawStopReason, } from '@kilocode/worker-utils'; import { + imageRolloutSubjectFromSandboxId, isInstanceKeyedSandboxId, instanceIdFromSandboxId, } from '@kilocode/worker-utils/instance-id'; @@ -4066,10 +4067,10 @@ export class KiloClawInstance extends DurableObject { if (options?.imageTag) { if (options.imageTag === 'latest') { const variant: ImageVariant = 'default'; - const instanceIdForBucket = - this.s.sandboxId && isInstanceKeyedSandboxId(this.s.sandboxId) - ? instanceIdFromSandboxId(this.s.sandboxId) - : (this.s.userId ?? ''); + const instanceIdForBucket = imageRolloutSubjectFromSandboxId( + this.s.sandboxId, + this.s.userId ?? '' + ); let autoEnroll = false; if (this.s.userId && this.env.HYPERDRIVE?.connectionString) { try { From c0606884aed229d5479dfbfab39901c9635f2fae Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Fri, 29 May 2026 14:05:10 -0600 Subject: [PATCH 3/8] refactor(kiloclaw): clarify latest version subject API --- .../lib/kiloclaw/kiloclaw-internal-client.ts | 27 ++++++++++++------- apps/web/src/routers/kiloclaw-router.ts | 5 ++-- .../organization-kiloclaw-router.ts | 5 ++-- services/kiloclaw/src/lib/user-flags.ts | 2 -- services/kiloclaw/src/routes/platform.ts | 25 +++++------------ 5 files changed, 28 insertions(+), 36 deletions(-) diff --git a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts index 1566eb75c8..16a7e0d350 100644 --- a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts +++ b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts @@ -5,6 +5,7 @@ import type { KiloclawStartReason, KiloclawStopReason, } from '@kilocode/worker-utils'; +import { imageRolloutSubjectFromSandboxId } from '@kilocode/worker-utils/instance-id'; import { INTERNAL_API_SECRET, KILOCLAW_API_URL } from '@/lib/config.server'; import type { ImageVersionEntry, @@ -152,18 +153,24 @@ export class KiloClawInternalClient { return this.request('/api/platform/versions'); } - async getLatestVersion(opts?: { - instanceId?: string; - userId?: string; + async getLatestVersion(): Promise { + return this.requestLatestVersion('/api/platform/versions/latest'); + } + + async getLatestVersionForInstance(opts: { + sandboxId: string | null | undefined; + userId: string; currentImageTag?: string | null; }): Promise { - let path = '/api/platform/versions/latest'; - if (opts?.instanceId) { - const params = new URLSearchParams({ instanceId: opts.instanceId }); - if (opts.userId) params.set('userId', opts.userId); - if (opts.currentImageTag) params.set('currentImageTag', opts.currentImageTag); - path += `?${params.toString()}`; - } + const params = new URLSearchParams({ + rolloutSubject: imageRolloutSubjectFromSandboxId(opts.sandboxId, opts.userId), + userId: opts.userId, + }); + if (opts.currentImageTag) params.set('currentImageTag', opts.currentImageTag); + return this.requestLatestVersion(`/api/platform/versions/latest?${params.toString()}`); + } + + private async requestLatestVersion(path: string): Promise { try { return await this.request(path); } catch (err) { diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 0ed2ca04aa..79ac22fdf3 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -54,7 +54,6 @@ import { } from '@kilocode/db/schema'; import { and, asc, eq, ne, desc, isNull, inArray, sql, like, or } from 'drizzle-orm'; import { ImpactReferralProduct, ImpactReferralRewardKind } from '@kilocode/db/schema-types'; -import { imageRolloutSubjectFromSandboxId } from '@kilocode/worker-utils/instance-id'; import { alias } from 'drizzle-orm/pg-core'; import { deleteWorkerTrigger } from '@/lib/webhook-agent/webhook-agent-client'; import { sentryLogger } from '@/lib/utils.server'; @@ -2862,8 +2861,8 @@ export const kiloclawRouter = createTRPCRouter({ const client = new KiloClawInternalClient(); if (!instance) return client.getLatestVersion(); - return client.getLatestVersion({ - instanceId: imageRolloutSubjectFromSandboxId(instance.sandboxId, ctx.user.id), + return client.getLatestVersionForInstance({ + sandboxId: instance.sandboxId, userId: ctx.user.id, currentImageTag: input?.currentImageTag ?? null, }); diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts index ebc876a568..28aa890e4d 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts @@ -25,7 +25,6 @@ import { KILOCLAW_API_URL, KILOCLAW_INSTANCE_URL_TEMPLATE } from '@/lib/config.s import { workerUrlForInstance } from '@/lib/kiloclaw/instance-url'; import { sentryLogger } from '@/lib/utils.server'; import { db } from '@/lib/drizzle'; -import { imageRolloutSubjectFromSandboxId } from '@kilocode/worker-utils/instance-id'; import { kiloclaw_version_pins, kiloclaw_image_catalog, @@ -309,8 +308,8 @@ export const organizationKiloclawRouter = createTRPCRouter({ const client = new KiloClawInternalClient(); const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId); if (!instance) return client.getLatestVersion(); - return client.getLatestVersion({ - instanceId: imageRolloutSubjectFromSandboxId(instance.sandboxId, ctx.user.id), + return client.getLatestVersionForInstance({ + sandboxId: instance.sandboxId, userId: ctx.user.id, currentImageTag: input.currentImageTag ?? null, }); diff --git a/services/kiloclaw/src/lib/user-flags.ts b/services/kiloclaw/src/lib/user-flags.ts index 320aa2089f..a05033c908 100644 --- a/services/kiloclaw/src/lib/user-flags.ts +++ b/services/kiloclaw/src/lib/user-flags.ts @@ -39,8 +39,6 @@ export async function setKiloclawEarlyAccess( /** * Resolve the Early Access flag for the user who owns the given instance. - * The platform `/versions/latest` endpoint uses this so callers cannot - * forge Early Access by passing it as a query param. * * Returns false when the instance row doesn't exist (e.g. provisioning * race) or the user has the flag disabled. diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index 3bcb52fb98..4f4ded4065 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -4438,28 +4438,17 @@ platform.get('/versions', async c => { // GET /api/platform/versions/latest // Resolves the image version this caller should be on next. // -// Query params (all optional): -// instanceId — rollout subject used for candidate selection -// userId — owning user; used only for Early Access lookup -// currentImageTag — caller's current image; used to suppress self-upgrades -// -// Without instanceId, returns the current :latest pointer (back-compat for -// anonymous callers — public version banner, CI, etc.). With instanceId, runs -// the rollout-aware selector and returns the candidate when the instance falls -// in cohort, the :latest baseline when not, or 404 when the caller is already -// on the newest applicable image (banner: "no upgrade"). -// -// The caller is responsible for passing the same rollout subject that the -// restart path would use: userId for legacy user-keyed instances and instanceId -// for instance-keyed instances. Early Access is still resolved server-side from -// the owning userId, matching the DO upgrade path. +// Without rolloutSubject, returns the current :latest pointer for anonymous +// callers. With rolloutSubject, runs the same rollout selector used by +// restartMachine({ imageTag: 'latest' }). userId is used only to resolve Early +// Access server-side. platform.get('/versions/latest', async c => { try { - const instanceId = c.req.query('instanceId'); + const rolloutSubject = c.req.query('rolloutSubject'); const userId = c.req.query('userId'); const currentImageTag = c.req.query('currentImageTag') ?? null; - if (!instanceId) { + if (!rolloutSubject) { const latest = await resolveLatestVersion(c.env.KV_CLAW_CACHE, 'default'); if (!latest) return c.json({ error: 'No latest version registered' }, 404); return c.json(latest); @@ -4483,7 +4472,7 @@ platform.get('/versions/latest', async c => { const selected = await selectImageVersionForInstance({ kv: c.env.KV_CLAW_CACHE, variant: 'default', - instanceId, + instanceId: rolloutSubject, currentImageTag, autoEnroll, }); From 2ee2b6e1907b42b5c71c9cd524ec7cfad7088760 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Fri, 29 May 2026 14:45:29 -0600 Subject: [PATCH 4/8] fix(kiloclaw): scope upgrade banner rollout lookup --- .../lib/kiloclaw/kiloclaw-internal-client.ts | 3 +- apps/web/src/routers/kiloclaw-router.test.ts | 44 +++++++++ apps/web/src/routers/kiloclaw-router.ts | 1 + .../organization-kiloclaw-router.test.ts | 42 +++++++++ .../organization-kiloclaw-router.ts | 1 + .../routes/platform-versions-latest.test.ts | 94 +++++++++++++++++++ services/kiloclaw/src/routes/platform.ts | 14 ++- 7 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 services/kiloclaw/src/routes/platform-versions-latest.test.ts diff --git a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts index 16a7e0d350..a8b0a24760 100644 --- a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts +++ b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts @@ -158,13 +158,14 @@ export class KiloClawInternalClient { } async getLatestVersionForInstance(opts: { + instanceId: string; sandboxId: string | null | undefined; userId: string; currentImageTag?: string | null; }): Promise { const params = new URLSearchParams({ + instanceId: opts.instanceId, rolloutSubject: imageRolloutSubjectFromSandboxId(opts.sandboxId, opts.userId), - userId: opts.userId, }); if (opts.currentImageTag) params.set('currentImageTag', opts.currentImageTag); return this.requestLatestVersion(`/api/platform/versions/latest?${params.toString()}`); diff --git a/apps/web/src/routers/kiloclaw-router.test.ts b/apps/web/src/routers/kiloclaw-router.test.ts index 26e51718b4..9af61542ed 100644 --- a/apps/web/src/routers/kiloclaw-router.test.ts +++ b/apps/web/src/routers/kiloclaw-router.test.ts @@ -43,6 +43,8 @@ type AnyMock = jest.Mock<(...args: any[]) => any>; type KiloClawClientMock = { KiloClawInternalClient: AnyMock; __getStatusMock: AnyMock; + __getLatestVersionMock: AnyMock; + __getLatestVersionForInstanceMock: AnyMock; __destroyMock: AnyMock; __startMock: AnyMock; }; @@ -109,11 +111,15 @@ jest.mock('@/lib/config.server', () => { jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { const getStatusMock = jest.fn(); + const getLatestVersionMock = jest.fn(); + const getLatestVersionForInstanceMock = jest.fn(); const destroyMock = jest.fn(); const startMock = jest.fn(); return { KiloClawInternalClient: jest.fn().mockImplementation(() => ({ getStatus: getStatusMock, + getLatestVersion: getLatestVersionMock, + getLatestVersionForInstance: getLatestVersionForInstanceMock, start: startMock, destroy: destroyMock, })), @@ -127,6 +133,8 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { } }, __getStatusMock: getStatusMock, + __getLatestVersionMock: getLatestVersionMock, + __getLatestVersionForInstanceMock: getLatestVersionForInstanceMock, __destroyMock: destroyMock, __startMock: startMock, }; @@ -134,6 +142,7 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { let createCaller: (ctx: { user: Awaited> }) => { getStatus: () => Promise; + latestVersion: (input?: { currentImageTag?: string }) => Promise; validateWeatherLocation: (input: { location: string }) => Promise<{ location: string; currentWeatherText: string; @@ -489,6 +498,41 @@ describe('kiloclawRouter getStatus', () => { }); }); +describe('kiloclawRouter latestVersion', () => { + beforeEach(async () => { + await cleanupDbForTest(); + kiloclawClientMock.KiloClawInternalClient.mockClear(); + kiloclawClientMock.__getLatestVersionMock.mockReset(); + kiloclawClientMock.__getLatestVersionForInstanceMock.mockReset(); + }); + + it('passes the active instance row and sandbox-derived rollout subject inputs', async () => { + kiloclawClientMock.__getLatestVersionForInstanceMock.mockResolvedValue({ + imageTag: 'candidate-tag', + }); + const user = await insertTestUser({ + google_user_email: `kiloclaw-latest-version-${crypto.randomUUID()}@example.com`, + }); + const instanceId = crypto.randomUUID(); + await db.insert(kiloclaw_instances).values({ + id: instanceId, + user_id: user.id, + sandbox_id: `ki_${instanceId.replace(/-/g, '')}`, + }); + + const caller = createCaller({ user }); + await caller.latestVersion({ currentImageTag: 'current-tag' }); + + expect(kiloclawClientMock.__getLatestVersionForInstanceMock).toHaveBeenCalledWith({ + instanceId, + sandboxId: `ki_${instanceId.replace(/-/g, '')}`, + userId: user.id, + currentImageTag: 'current-tag', + }); + expect(kiloclawClientMock.__getLatestVersionMock).not.toHaveBeenCalled(); + }); +}); + describe('kiloclawRouter start', () => { beforeEach(async () => { await cleanupDbForTest(); diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 79ac22fdf3..a17b1ce380 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -2862,6 +2862,7 @@ export const kiloclawRouter = createTRPCRouter({ if (!instance) return client.getLatestVersion(); return client.getLatestVersionForInstance({ + instanceId: instance.id, sandboxId: instance.sandboxId, userId: ctx.user.id, currentImageTag: input?.currentImageTag ?? null, diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts index b7080a592f..e74c25823c 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts @@ -26,6 +26,8 @@ type AnyMock = jest.Mock<(...args: any[]) => any>; type KiloClawClientMock = { __destroyMock: AnyMock; + __getLatestVersionMock: AnyMock; + __getLatestVersionForInstanceMock: AnyMock; __patchWebSearchConfigMock: AnyMock; __provisionMock: AnyMock; __restartGatewayProcessMock: AnyMock; @@ -73,6 +75,8 @@ jest.mock('@/lib/config.server', () => { jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { const destroyMock = jest.fn(); + const getLatestVersionMock = jest.fn(); + const getLatestVersionForInstanceMock = jest.fn(); const patchWebSearchConfigMock = jest.fn(); const provisionMock = jest.fn(); const restartGatewayProcessMock = jest.fn(); @@ -82,6 +86,8 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { return { KiloClawInternalClient: jest.fn().mockImplementation(() => ({ destroy: destroyMock, + getLatestVersion: getLatestVersionMock, + getLatestVersionForInstance: getLatestVersionForInstanceMock, patchWebSearchConfig: patchWebSearchConfigMock, provision: provisionMock, restartGatewayProcess: restartGatewayProcessMock, @@ -99,6 +105,8 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { } }, __destroyMock: destroyMock, + __getLatestVersionMock: getLatestVersionMock, + __getLatestVersionForInstanceMock: getLatestVersionForInstanceMock, __patchWebSearchConfigMock: patchWebSearchConfigMock, __provisionMock: provisionMock, __restartGatewayProcessMock: restartGatewayProcessMock, @@ -169,6 +177,40 @@ async function addOrganizationSeatEntitlement(organizationId: string): Promise { + beforeEach(async () => { + await cleanupDbForTest(); + kiloclawClientMock.__getLatestVersionMock.mockReset(); + kiloclawClientMock.__getLatestVersionForInstanceMock.mockReset(); + }); + + it('passes the active org instance row and sandbox-derived rollout subject inputs', async () => { + kiloclawClientMock.__getLatestVersionForInstanceMock.mockResolvedValue({ + imageTag: 'candidate-tag', + }); + const user = await insertTestUser({ + google_user_email: `org-kiloclaw-latest-version-${crypto.randomUUID()}@example.com`, + }); + const organization = await createOrganization('Org Latest Version Test', user.id); + const instanceId = await createActiveOrgInstance(user.id, organization.id); + const sandboxId = `ki_${instanceId.replace(/-/g, '')}`; + + const caller = await createCallerForUser(user.id); + await caller.organizations.kiloclaw.latestVersion({ + organizationId: organization.id, + currentImageTag: 'current-tag', + }); + + expect(kiloclawClientMock.__getLatestVersionForInstanceMock).toHaveBeenCalledWith({ + instanceId, + sandboxId, + userId: user.id, + currentImageTag: 'current-tag', + }); + expect(kiloclawClientMock.__getLatestVersionMock).not.toHaveBeenCalled(); + }); +}); + describe('organizations.kiloclaw.listActiveInstances', () => { beforeEach(async () => { await cleanupDbForTest(); diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts index 28aa890e4d..d2d06ca32a 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts @@ -309,6 +309,7 @@ export const organizationKiloclawRouter = createTRPCRouter({ const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId); if (!instance) return client.getLatestVersion(); return client.getLatestVersionForInstance({ + instanceId: instance.id, sandboxId: instance.sandboxId, userId: ctx.user.id, currentImageTag: input.currentImageTag ?? null, diff --git a/services/kiloclaw/src/routes/platform-versions-latest.test.ts b/services/kiloclaw/src/routes/platform-versions-latest.test.ts new file mode 100644 index 0000000000..496ecce81c --- /dev/null +++ b/services/kiloclaw/src/routes/platform-versions-latest.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { platform } from './platform'; +import { lookupKiloclawEarlyAccessByInstanceId } from '../lib/user-flags'; +import { resolveLatestVersion } from '../lib/image-version'; +import { selectImageVersionForInstance } from '../lib/version-rollout'; +import type { ImageVersionEntry } from '../schemas/image-version'; + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class {}, + waitUntil: (promise: Promise) => promise, +})); + +vi.mock('../lib/image-version', () => ({ + listAllVersions: vi.fn(), + resolveLatestVersion: vi.fn(), + updateTagIndex: vi.fn(), +})); + +vi.mock('../lib/user-flags', () => ({ + setKiloclawEarlyAccess: vi.fn(), + lookupKiloclawEarlyAccessByInstanceId: vi.fn(), +})); + +vi.mock('../lib/version-rollout', () => ({ + selectImageVersionForInstance: vi.fn(), + setRolloutPercent: vi.fn(), + markImageAsLatest: vi.fn(), + disableImageAndClearRollout: vi.fn(), +})); + +function makeEnv() { + return { + KV_CLAW_CACHE: {}, + HYPERDRIVE: { connectionString: 'postgres://test' }, + } as never; +} + +const selectedVersion: ImageVersionEntry = { + openclawVersion: '2.0.0', + variant: 'default', + imageTag: 'candidate-tag', + imageDigest: null, + publishedAt: '2026-05-29T00:00:00.000Z', + rolloutPercent: 50, + isLatest: false, +}; + +describe('platform /versions/latest', () => { + beforeEach(() => { + vi.mocked(resolveLatestVersion).mockReset(); + vi.mocked(selectImageVersionForInstance).mockReset(); + vi.mocked(lookupKiloclawEarlyAccessByInstanceId).mockReset(); + }); + + it('uses rolloutSubject for bucket math and instanceId for Early Access lookup', async () => { + vi.mocked(lookupKiloclawEarlyAccessByInstanceId).mockResolvedValue(true); + vi.mocked(selectImageVersionForInstance).mockResolvedValue(selectedVersion); + + const response = await platform.request( + '/versions/latest?rolloutSubject=bucket-subject&instanceId=instance-row-id&userId=forged-user¤tImageTag=current-tag', + undefined, + makeEnv() + ); + + expect(response.status).toBe(200); + expect(lookupKiloclawEarlyAccessByInstanceId).toHaveBeenCalledWith( + 'postgres://test', + 'instance-row-id' + ); + expect(selectImageVersionForInstance).toHaveBeenCalledWith({ + kv: {}, + variant: 'default', + instanceId: 'bucket-subject', + currentImageTag: 'current-tag', + autoEnroll: true, + }); + }); + + it('does not grant Early Access when rolloutSubject has no authoritative instanceId', async () => { + vi.mocked(selectImageVersionForInstance).mockResolvedValue(selectedVersion); + + const response = await platform.request( + '/versions/latest?rolloutSubject=bucket-subject&userId=early-access-user', + undefined, + makeEnv() + ); + + expect(response.status).toBe(200); + expect(lookupKiloclawEarlyAccessByInstanceId).not.toHaveBeenCalled(); + expect(selectImageVersionForInstance).toHaveBeenCalledWith( + expect.objectContaining({ autoEnroll: false }) + ); + }); +}); diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index 4f4ded4065..e824727a7a 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -37,7 +37,7 @@ import { markImageAsLatest, disableImageAndClearRollout, } from '../lib/version-rollout'; -import { setKiloclawEarlyAccess, lookupKiloclawEarlyAccess } from '../lib/user-flags'; +import { setKiloclawEarlyAccess, lookupKiloclawEarlyAccessByInstanceId } from '../lib/user-flags'; import { upsertCatalogVersion } from '../lib/catalog-registration'; import { runScheduledActionNoticesSweep } from '../scheduled/scheduled-action-notices'; import { flattenError, z } from 'zod'; @@ -4440,12 +4440,12 @@ platform.get('/versions', async c => { // // Without rolloutSubject, returns the current :latest pointer for anonymous // callers. With rolloutSubject, runs the same rollout selector used by -// restartMachine({ imageTag: 'latest' }). userId is used only to resolve Early -// Access server-side. +// restartMachine({ imageTag: 'latest' }). instanceId is the authoritative DB +// row used to resolve Early Access server-side. platform.get('/versions/latest', async c => { try { const rolloutSubject = c.req.query('rolloutSubject'); - const userId = c.req.query('userId'); + const instanceId = c.req.query('instanceId'); const currentImageTag = c.req.query('currentImageTag') ?? null; if (!rolloutSubject) { @@ -4454,13 +4454,11 @@ platform.get('/versions/latest', async c => { return c.json(latest); } - // Resolve Early Access the same way restartMachine does inside the DO: - // from the owning userId, not from caller-provided eligibility state. let autoEnroll = false; const connectionString = c.env.HYPERDRIVE?.connectionString; - if (userId && connectionString) { + if (instanceId && connectionString) { try { - autoEnroll = await lookupKiloclawEarlyAccess(connectionString, userId); + autoEnroll = await lookupKiloclawEarlyAccessByInstanceId(connectionString, instanceId); } catch (err) { console.warn( '[platform] Early Access lookup failed; treating as false:', From 9ae8e30f4f6752a309397306a1abb971e6e2dba9 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Fri, 29 May 2026 15:37:10 -0600 Subject: [PATCH 5/8] fix(kiloclaw): derive rollout subject server-side --- .../lib/kiloclaw/kiloclaw-internal-client.ts | 4 -- apps/web/src/routers/kiloclaw-router.test.ts | 4 +- apps/web/src/routers/kiloclaw-router.ts | 2 - .../organization-kiloclaw-router.test.ts | 5 +-- .../organization-kiloclaw-router.ts | 2 - services/kiloclaw/src/lib/user-flags.ts | 28 +++++++++---- .../routes/platform-versions-latest.test.ts | 41 +++++++++++++------ services/kiloclaw/src/routes/platform.ts | 30 ++++++++++---- 8 files changed, 73 insertions(+), 43 deletions(-) diff --git a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts index a8b0a24760..557da41343 100644 --- a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts +++ b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts @@ -5,7 +5,6 @@ import type { KiloclawStartReason, KiloclawStopReason, } from '@kilocode/worker-utils'; -import { imageRolloutSubjectFromSandboxId } from '@kilocode/worker-utils/instance-id'; import { INTERNAL_API_SECRET, KILOCLAW_API_URL } from '@/lib/config.server'; import type { ImageVersionEntry, @@ -159,13 +158,10 @@ export class KiloClawInternalClient { async getLatestVersionForInstance(opts: { instanceId: string; - sandboxId: string | null | undefined; - userId: string; currentImageTag?: string | null; }): Promise { const params = new URLSearchParams({ instanceId: opts.instanceId, - rolloutSubject: imageRolloutSubjectFromSandboxId(opts.sandboxId, opts.userId), }); if (opts.currentImageTag) params.set('currentImageTag', opts.currentImageTag); return this.requestLatestVersion(`/api/platform/versions/latest?${params.toString()}`); diff --git a/apps/web/src/routers/kiloclaw-router.test.ts b/apps/web/src/routers/kiloclaw-router.test.ts index 9af61542ed..02e3f22b87 100644 --- a/apps/web/src/routers/kiloclaw-router.test.ts +++ b/apps/web/src/routers/kiloclaw-router.test.ts @@ -506,7 +506,7 @@ describe('kiloclawRouter latestVersion', () => { kiloclawClientMock.__getLatestVersionForInstanceMock.mockReset(); }); - it('passes the active instance row and sandbox-derived rollout subject inputs', async () => { + it('passes the active instance row for server-derived rollout lookup', async () => { kiloclawClientMock.__getLatestVersionForInstanceMock.mockResolvedValue({ imageTag: 'candidate-tag', }); @@ -525,8 +525,6 @@ describe('kiloclawRouter latestVersion', () => { expect(kiloclawClientMock.__getLatestVersionForInstanceMock).toHaveBeenCalledWith({ instanceId, - sandboxId: `ki_${instanceId.replace(/-/g, '')}`, - userId: user.id, currentImageTag: 'current-tag', }); expect(kiloclawClientMock.__getLatestVersionMock).not.toHaveBeenCalled(); diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index a17b1ce380..aa6e263a2c 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -2863,8 +2863,6 @@ export const kiloclawRouter = createTRPCRouter({ return client.getLatestVersionForInstance({ instanceId: instance.id, - sandboxId: instance.sandboxId, - userId: ctx.user.id, currentImageTag: input?.currentImageTag ?? null, }); }), diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts index e74c25823c..eb8cef229b 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts @@ -184,7 +184,7 @@ describe('organizations.kiloclaw.latestVersion', () => { kiloclawClientMock.__getLatestVersionForInstanceMock.mockReset(); }); - it('passes the active org instance row and sandbox-derived rollout subject inputs', async () => { + it('passes the active org instance row for server-derived rollout lookup', async () => { kiloclawClientMock.__getLatestVersionForInstanceMock.mockResolvedValue({ imageTag: 'candidate-tag', }); @@ -193,7 +193,6 @@ describe('organizations.kiloclaw.latestVersion', () => { }); const organization = await createOrganization('Org Latest Version Test', user.id); const instanceId = await createActiveOrgInstance(user.id, organization.id); - const sandboxId = `ki_${instanceId.replace(/-/g, '')}`; const caller = await createCallerForUser(user.id); await caller.organizations.kiloclaw.latestVersion({ @@ -203,8 +202,6 @@ describe('organizations.kiloclaw.latestVersion', () => { expect(kiloclawClientMock.__getLatestVersionForInstanceMock).toHaveBeenCalledWith({ instanceId, - sandboxId, - userId: user.id, currentImageTag: 'current-tag', }); expect(kiloclawClientMock.__getLatestVersionMock).not.toHaveBeenCalled(); diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts index d2d06ca32a..86bf23cba3 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts @@ -310,8 +310,6 @@ export const organizationKiloclawRouter = createTRPCRouter({ if (!instance) return client.getLatestVersion(); return client.getLatestVersionForInstance({ instanceId: instance.id, - sandboxId: instance.sandboxId, - userId: ctx.user.id, currentImageTag: input.currentImageTag ?? null, }); }), diff --git a/services/kiloclaw/src/lib/user-flags.ts b/services/kiloclaw/src/lib/user-flags.ts index a05033c908..3c6678d890 100644 --- a/services/kiloclaw/src/lib/user-flags.ts +++ b/services/kiloclaw/src/lib/user-flags.ts @@ -9,6 +9,12 @@ import { getWorkerDb } from '@kilocode/db/client'; import { kilocode_users, kiloclaw_instances } from '@kilocode/db'; import { eq } from 'drizzle-orm'; +import { imageRolloutSubjectFromSandboxId } from '@kilocode/worker-utils/instance-id'; + +export type KiloclawRolloutContext = { + rolloutSubject: string; + earlyAccess: boolean; +}; export async function lookupKiloclawEarlyAccess( hyperdriveConnectionString: string, @@ -38,21 +44,29 @@ export async function setKiloclawEarlyAccess( } /** - * Resolve the Early Access flag for the user who owns the given instance. + * Resolve the rollout subject and Early Access flag from the authoritative + * instance row. * - * Returns false when the instance row doesn't exist (e.g. provisioning - * race) or the user has the flag disabled. + * Returns null when the instance row doesn't exist (e.g. provisioning race). */ -export async function lookupKiloclawEarlyAccessByInstanceId( +export async function lookupKiloclawRolloutContextByInstanceId( hyperdriveConnectionString: string, instanceId: string -): Promise { +): Promise { const db = getWorkerDb(hyperdriveConnectionString); const [row] = await db - .select({ early_access: kilocode_users.kiloclaw_early_access }) + .select({ + early_access: kilocode_users.kiloclaw_early_access, + sandbox_id: kiloclaw_instances.sandbox_id, + user_id: kiloclaw_instances.user_id, + }) .from(kiloclaw_instances) .innerJoin(kilocode_users, eq(kiloclaw_instances.user_id, kilocode_users.id)) .where(eq(kiloclaw_instances.id, instanceId)) .limit(1); - return row?.early_access ?? false; + if (!row) return null; + return { + rolloutSubject: imageRolloutSubjectFromSandboxId(row.sandbox_id, row.user_id), + earlyAccess: row.early_access ?? false, + }; } diff --git a/services/kiloclaw/src/routes/platform-versions-latest.test.ts b/services/kiloclaw/src/routes/platform-versions-latest.test.ts index 496ecce81c..3c231c34ce 100644 --- a/services/kiloclaw/src/routes/platform-versions-latest.test.ts +++ b/services/kiloclaw/src/routes/platform-versions-latest.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { platform } from './platform'; -import { lookupKiloclawEarlyAccessByInstanceId } from '../lib/user-flags'; +import { lookupKiloclawRolloutContextByInstanceId } from '../lib/user-flags'; import { resolveLatestVersion } from '../lib/image-version'; import { selectImageVersionForInstance } from '../lib/version-rollout'; import type { ImageVersionEntry } from '../schemas/image-version'; @@ -18,7 +18,7 @@ vi.mock('../lib/image-version', () => ({ vi.mock('../lib/user-flags', () => ({ setKiloclawEarlyAccess: vi.fn(), - lookupKiloclawEarlyAccessByInstanceId: vi.fn(), + lookupKiloclawRolloutContextByInstanceId: vi.fn(), })); vi.mock('../lib/version-rollout', () => ({ @@ -49,46 +49,61 @@ describe('platform /versions/latest', () => { beforeEach(() => { vi.mocked(resolveLatestVersion).mockReset(); vi.mocked(selectImageVersionForInstance).mockReset(); - vi.mocked(lookupKiloclawEarlyAccessByInstanceId).mockReset(); + vi.mocked(lookupKiloclawRolloutContextByInstanceId).mockReset(); }); - it('uses rolloutSubject for bucket math and instanceId for Early Access lookup', async () => { - vi.mocked(lookupKiloclawEarlyAccessByInstanceId).mockResolvedValue(true); + it('uses instanceId-only callers for rollout selection, current tag suppression, and Early Access lookup', async () => { + vi.mocked(lookupKiloclawRolloutContextByInstanceId).mockResolvedValue({ + rolloutSubject: 'instance-row-id', + earlyAccess: true, + }); vi.mocked(selectImageVersionForInstance).mockResolvedValue(selectedVersion); const response = await platform.request( - '/versions/latest?rolloutSubject=bucket-subject&instanceId=instance-row-id&userId=forged-user¤tImageTag=current-tag', + '/versions/latest?instanceId=instance-row-id¤tImageTag=current-tag', undefined, makeEnv() ); expect(response.status).toBe(200); - expect(lookupKiloclawEarlyAccessByInstanceId).toHaveBeenCalledWith( + expect(resolveLatestVersion).not.toHaveBeenCalled(); + expect(lookupKiloclawRolloutContextByInstanceId).toHaveBeenCalledWith( 'postgres://test', 'instance-row-id' ); expect(selectImageVersionForInstance).toHaveBeenCalledWith({ kv: {}, variant: 'default', - instanceId: 'bucket-subject', + instanceId: 'instance-row-id', currentImageTag: 'current-tag', autoEnroll: true, }); }); - it('does not grant Early Access when rolloutSubject has no authoritative instanceId', async () => { + it('does not let caller-supplied rolloutSubject borrow Early Access from an unrelated instance', async () => { + vi.mocked(lookupKiloclawRolloutContextByInstanceId).mockResolvedValue({ + rolloutSubject: 'authoritative-row-subject', + earlyAccess: true, + }); vi.mocked(selectImageVersionForInstance).mockResolvedValue(selectedVersion); const response = await platform.request( - '/versions/latest?rolloutSubject=bucket-subject&userId=early-access-user', + '/versions/latest?rolloutSubject=caller-controlled-subject&instanceId=authoritative-early-access-instance', undefined, makeEnv() ); expect(response.status).toBe(200); - expect(lookupKiloclawEarlyAccessByInstanceId).not.toHaveBeenCalled(); - expect(selectImageVersionForInstance).toHaveBeenCalledWith( - expect.objectContaining({ autoEnroll: false }) + expect(lookupKiloclawRolloutContextByInstanceId).toHaveBeenCalledWith( + 'postgres://test', + 'authoritative-early-access-instance' ); + expect(selectImageVersionForInstance).toHaveBeenCalledWith({ + kv: {}, + variant: 'default', + instanceId: 'authoritative-row-subject', + currentImageTag: null, + autoEnroll: true, + }); }); }); diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index e824727a7a..4a7f28d002 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -37,7 +37,10 @@ import { markImageAsLatest, disableImageAndClearRollout, } from '../lib/version-rollout'; -import { setKiloclawEarlyAccess, lookupKiloclawEarlyAccessByInstanceId } from '../lib/user-flags'; +import { + setKiloclawEarlyAccess, + lookupKiloclawRolloutContextByInstanceId, +} from '../lib/user-flags'; import { upsertCatalogVersion } from '../lib/catalog-registration'; import { runScheduledActionNoticesSweep } from '../scheduled/scheduled-action-notices'; import { flattenError, z } from 'zod'; @@ -4438,16 +4441,17 @@ platform.get('/versions', async c => { // GET /api/platform/versions/latest // Resolves the image version this caller should be on next. // -// Without rolloutSubject, returns the current :latest pointer for anonymous -// callers. With rolloutSubject, runs the same rollout selector used by -// restartMachine({ imageTag: 'latest' }). instanceId is the authoritative DB -// row used to resolve Early Access server-side. +// Without rolloutSubject or instanceId, returns the current :latest pointer for +// anonymous callers. With a rollout subject, runs the same rollout selector used +// by restartMachine({ imageTag: 'latest' }). instanceId is the authoritative DB +// row used to resolve Early Access and the rollout subject server-side. platform.get('/versions/latest', async c => { try { - const rolloutSubject = c.req.query('rolloutSubject'); + const requestedRolloutSubject = c.req.query('rolloutSubject'); const instanceId = c.req.query('instanceId'); const currentImageTag = c.req.query('currentImageTag') ?? null; + let rolloutSubject = instanceId ?? requestedRolloutSubject; if (!rolloutSubject) { const latest = await resolveLatestVersion(c.env.KV_CLAW_CACHE, 'default'); if (!latest) return c.json({ error: 'No latest version registered' }, 404); @@ -4458,12 +4462,22 @@ platform.get('/versions/latest', async c => { const connectionString = c.env.HYPERDRIVE?.connectionString; if (instanceId && connectionString) { try { - autoEnroll = await lookupKiloclawEarlyAccessByInstanceId(connectionString, instanceId); + const rolloutContext = await lookupKiloclawRolloutContextByInstanceId( + connectionString, + instanceId + ); + if (rolloutContext) { + rolloutSubject = rolloutContext.rolloutSubject; + autoEnroll = rolloutContext.earlyAccess; + } else { + rolloutSubject = instanceId; + } } catch (err) { console.warn( - '[platform] Early Access lookup failed; treating as false:', + '[platform] Instance rollout context lookup failed; treating as false:', err instanceof Error ? err.message : err ); + rolloutSubject = instanceId; } } From 9b52cabad02eedd54ab046e44792724651b32ec1 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Fri, 29 May 2026 16:00:56 -0600 Subject: [PATCH 6/8] test(kiloclaw): cover anonymous latest version lookup --- apps/web/src/routers/kiloclaw-router.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/web/src/routers/kiloclaw-router.test.ts b/apps/web/src/routers/kiloclaw-router.test.ts index 02e3f22b87..8de4504af5 100644 --- a/apps/web/src/routers/kiloclaw-router.test.ts +++ b/apps/web/src/routers/kiloclaw-router.test.ts @@ -529,6 +529,22 @@ describe('kiloclawRouter latestVersion', () => { }); expect(kiloclawClientMock.__getLatestVersionMock).not.toHaveBeenCalled(); }); + + it('uses anonymous latest version lookup when the user has no active instance', async () => { + kiloclawClientMock.__getLatestVersionMock.mockResolvedValue({ + imageTag: 'anonymous-tag', + }); + const user = await insertTestUser({ + google_user_email: `kiloclaw-latest-version-${crypto.randomUUID()}@example.com`, + }); + + const caller = createCaller({ user }); + const result = await caller.latestVersion({ currentImageTag: 'current-tag' }); + + expect(result).toEqual({ imageTag: 'anonymous-tag' }); + expect(kiloclawClientMock.__getLatestVersionMock).toHaveBeenCalledWith(); + expect(kiloclawClientMock.__getLatestVersionForInstanceMock).not.toHaveBeenCalled(); + }); }); describe('kiloclawRouter start', () => { From a101f2c81a7cace8459b2d8937925ce9023b5917 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Fri, 29 May 2026 16:05:37 -0600 Subject: [PATCH 7/8] test(kiloclaw): cover latest version fallback paths --- .../routes/platform-versions-latest.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/services/kiloclaw/src/routes/platform-versions-latest.test.ts b/services/kiloclaw/src/routes/platform-versions-latest.test.ts index 3c231c34ce..c36a531b23 100644 --- a/services/kiloclaw/src/routes/platform-versions-latest.test.ts +++ b/services/kiloclaw/src/routes/platform-versions-latest.test.ts @@ -35,6 +35,12 @@ function makeEnv() { } as never; } +function makeEnvWithoutHyperdrive() { + return { + KV_CLAW_CACHE: {}, + } as never; +} + const selectedVersion: ImageVersionEntry = { openclawVersion: '2.0.0', variant: 'default', @@ -106,4 +112,37 @@ describe('platform /versions/latest', () => { autoEnroll: true, }); }); + + it('uses instanceId directly with autoEnroll disabled when Hyperdrive is unavailable', async () => { + vi.mocked(selectImageVersionForInstance).mockResolvedValue(selectedVersion); + + const response = await platform.request( + '/versions/latest?instanceId=instance-row-id¤tImageTag=current-tag', + undefined, + makeEnvWithoutHyperdrive() + ); + + expect(response.status).toBe(200); + expect(resolveLatestVersion).not.toHaveBeenCalled(); + expect(lookupKiloclawRolloutContextByInstanceId).not.toHaveBeenCalled(); + expect(selectImageVersionForInstance).toHaveBeenCalledWith({ + kv: {}, + variant: 'default', + instanceId: 'instance-row-id', + currentImageTag: 'current-tag', + autoEnroll: false, + }); + }); + + it('returns :latest for anonymous callers without rollout parameters', async () => { + vi.mocked(resolveLatestVersion).mockResolvedValue(selectedVersion); + + const response = await platform.request('/versions/latest', undefined, makeEnv()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual(selectedVersion); + expect(resolveLatestVersion).toHaveBeenCalledWith({}, 'default'); + expect(lookupKiloclawRolloutContextByInstanceId).not.toHaveBeenCalled(); + expect(selectImageVersionForInstance).not.toHaveBeenCalled(); + }); }); From 3c6098d0b71e570529b9846a69b20e0e99454b74 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Fri, 29 May 2026 16:54:48 -0600 Subject: [PATCH 8/8] refactor(kiloclaw): rename rollout selector subject --- .../durable-objects/kiloclaw-instance.test.ts | 4 ++-- .../kiloclaw-instance/index.ts | 12 +++++----- .../kiloclaw/src/lib/version-rollout.test.ts | 22 +++++++++---------- services/kiloclaw/src/lib/version-rollout.ts | 6 ++--- .../routes/platform-versions-latest.test.ts | 6 ++--- services/kiloclaw/src/routes/platform.ts | 2 +- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts index 98ea85774a..fa52b73bd9 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts @@ -7822,7 +7822,7 @@ describe('restartMachine image tag override', () => { expect(result.success).toBe(true); expect(selectImageVersionForInstance).toHaveBeenCalledWith( - expect.objectContaining({ instanceId: 'user-1' }) + expect.objectContaining({ rolloutSubject: 'user-1' }) ); expect(storage._store.get('trackedImageTag')).toBe('new-tag-from-kv'); expect(storage._store.get('openclawVersion')).toBe('2.0.0'); @@ -7853,7 +7853,7 @@ describe('restartMachine image tag override', () => { expect(result.success).toBe(true); expect(selectImageVersionForInstance).toHaveBeenCalledWith( - expect.objectContaining({ instanceId }) + expect.objectContaining({ rolloutSubject: instanceId }) ); }); diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts index f910cfb3dc..cdee619849 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -246,7 +246,7 @@ export class KiloClawInstance extends DurableObject { private async resolveImageStateForPin( pinnedImageTag: string | null, userId: string, - instanceId: string, + rolloutSubject: string, opts: { isNew: boolean; ignoreCurrentImageTag?: boolean } ): Promise { if (pinnedImageTag) { @@ -333,7 +333,7 @@ export class KiloClawInstance extends DurableObject { const selected = await selectImageVersionForInstance({ kv: this.env.KV_CLAW_CACHE, variant, - instanceId, + rolloutSubject, currentImageTag: selectorCurrentImageTag, autoEnroll, }); @@ -1934,8 +1934,8 @@ export class KiloClawInstance extends DurableObject { throw Object.assign(new Error('Cannot apply pin: instance has no userId'), { status: 404 }); } - const resolvedInstanceId = instanceId ?? this.s.userId; - await this.resolveImageStateForPin(imageTag, this.s.userId, resolvedInstanceId, { + const rolloutSubject = instanceId ?? this.s.userId; + await this.resolveImageStateForPin(imageTag, this.s.userId, rolloutSubject, { isNew: false, // When clearing a pin (imageTag === null), force a fresh rollout // decision instead of preserving the currently-tracked tag. Without @@ -4067,7 +4067,7 @@ export class KiloClawInstance extends DurableObject { if (options?.imageTag) { if (options.imageTag === 'latest') { const variant: ImageVariant = 'default'; - const instanceIdForBucket = imageRolloutSubjectFromSandboxId( + const rolloutSubject = imageRolloutSubjectFromSandboxId( this.s.sandboxId, this.s.userId ?? '' ); @@ -4087,7 +4087,7 @@ export class KiloClawInstance extends DurableObject { const latest = await selectImageVersionForInstance({ kv: this.env.KV_CLAW_CACHE, variant, - instanceId: instanceIdForBucket, + rolloutSubject, currentImageTag: this.s.trackedImageTag, autoEnroll, }); diff --git a/services/kiloclaw/src/lib/version-rollout.test.ts b/services/kiloclaw/src/lib/version-rollout.test.ts index f6bc484491..d12310db8b 100644 --- a/services/kiloclaw/src/lib/version-rollout.test.ts +++ b/services/kiloclaw/src/lib/version-rollout.test.ts @@ -44,7 +44,7 @@ describe('selectImageVersionForInstance', () => { const result = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: 'instance-1', + rolloutSubject: 'instance-1', }); expect(result).toBeNull(); }); @@ -56,7 +56,7 @@ describe('selectImageVersionForInstance', () => { const result = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: 'instance-1', + rolloutSubject: 'instance-1', currentImageTag: 'img-old', }); expect(result?.imageTag).toBe('img-stable'); @@ -69,7 +69,7 @@ describe('selectImageVersionForInstance', () => { const result = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: 'instance-1', + rolloutSubject: 'instance-1', currentImageTag: 'img-stable', }); expect(result).toBeNull(); @@ -86,7 +86,7 @@ describe('selectImageVersionForInstance', () => { const result = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: `synthetic-${i}`, + rolloutSubject: `synthetic-${i}`, currentImageTag: 'img-old', }); if (result?.imageTag === 'img-candidate') inCohort++; @@ -108,7 +108,7 @@ describe('selectImageVersionForInstance', () => { const result = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: `synthetic-${i}`, + rolloutSubject: `synthetic-${i}`, currentImageTag: 'img-old', autoEnroll: true, }); @@ -124,7 +124,7 @@ describe('selectImageVersionForInstance', () => { const result = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: 'instance-1', + rolloutSubject: 'instance-1', currentImageTag: 'img-candidate', }); // Critical: must NOT fall through to :latest — that would downgrade an @@ -142,7 +142,7 @@ describe('selectImageVersionForInstance', () => { const result = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: 'instance-1', + rolloutSubject: 'instance-1', currentImageTag: 'img-old', }); expect(result?.imageTag).toBe('img-stable'); @@ -157,7 +157,7 @@ describe('selectImageVersionForInstance', () => { const result = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: `synthetic-${i}`, + rolloutSubject: `synthetic-${i}`, currentImageTag: 'img-old', }); // Always falls through to :latest, never picks the 0% candidate. @@ -174,7 +174,7 @@ describe('selectImageVersionForInstance', () => { const result = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: `synthetic-${i}`, + rolloutSubject: `synthetic-${i}`, currentImageTag: 'img-old', }); expect(result?.imageTag).toBe('img-candidate'); @@ -189,13 +189,13 @@ describe('selectImageVersionForInstance', () => { const first = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: 'instance-stable', + rolloutSubject: 'instance-stable', currentImageTag: 'img-old', }); const second = await selectImageVersionForInstance({ kv, variant: 'default', - instanceId: 'instance-stable', + rolloutSubject: 'instance-stable', currentImageTag: 'img-old', }); expect(first?.imageTag).toBe(second?.imageTag); diff --git a/services/kiloclaw/src/lib/version-rollout.ts b/services/kiloclaw/src/lib/version-rollout.ts index 6018ad3475..ab4e71358a 100644 --- a/services/kiloclaw/src/lib/version-rollout.ts +++ b/services/kiloclaw/src/lib/version-rollout.ts @@ -46,8 +46,8 @@ async function readPointer(kv: KVNamespace, key: string): Promise 0) { - const bucket = await rolloutBucket(`${candidate.imageTag}:instance:${opts.instanceId}`); + const bucket = await rolloutBucket(`${candidate.imageTag}:instance:${opts.rolloutSubject}`); if (bucket < candidate.rolloutPercent) { return candidate; } diff --git a/services/kiloclaw/src/routes/platform-versions-latest.test.ts b/services/kiloclaw/src/routes/platform-versions-latest.test.ts index c36a531b23..1269ded0fd 100644 --- a/services/kiloclaw/src/routes/platform-versions-latest.test.ts +++ b/services/kiloclaw/src/routes/platform-versions-latest.test.ts @@ -80,7 +80,7 @@ describe('platform /versions/latest', () => { expect(selectImageVersionForInstance).toHaveBeenCalledWith({ kv: {}, variant: 'default', - instanceId: 'instance-row-id', + rolloutSubject: 'instance-row-id', currentImageTag: 'current-tag', autoEnroll: true, }); @@ -107,7 +107,7 @@ describe('platform /versions/latest', () => { expect(selectImageVersionForInstance).toHaveBeenCalledWith({ kv: {}, variant: 'default', - instanceId: 'authoritative-row-subject', + rolloutSubject: 'authoritative-row-subject', currentImageTag: null, autoEnroll: true, }); @@ -128,7 +128,7 @@ describe('platform /versions/latest', () => { expect(selectImageVersionForInstance).toHaveBeenCalledWith({ kv: {}, variant: 'default', - instanceId: 'instance-row-id', + rolloutSubject: 'instance-row-id', currentImageTag: 'current-tag', autoEnroll: false, }); diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index 4a7f28d002..c142862438 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -4484,7 +4484,7 @@ platform.get('/versions/latest', async c => { const selected = await selectImageVersionForInstance({ kv: c.env.KV_CLAW_CACHE, variant: 'default', - instanceId: rolloutSubject, + rolloutSubject, currentImageTag, autoEnroll, });