Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7518955
feat(kiloclaw): add org-scoped instance management + per-instance Fly…
pandemicsyn Mar 31, 2026
1a191da
Merge branch 'main' into florian/feat/orgs
pandemicsyn Mar 31, 2026
66ce92d
chore: remove .plan files from tracking, add to gitignore
pandemicsyn Mar 31, 2026
df42ad6
fix(kiloclaw): address review feedback — tab URL, error codes, cleanu…
pandemicsyn Mar 31, 2026
fbd62cf
fix(kiloclaw): thread instanceId through access gateway for org insta…
pandemicsyn Mar 31, 2026
f899eb4
fix(kiloclaw): use instance-keyed app name in postgres restore fallback
pandemicsyn Mar 31, 2026
99d2661
fix(kiloclaw): scope CLI runs to instance_id for org isolation
pandemicsyn Apr 1, 2026
fa7ce01
fix(kiloclaw): add instance ownership checks in access gateway
pandemicsyn Apr 1, 2026
f9d831b
fix: remove unused useClawContext import from SettingsTab
pandemicsyn Apr 1, 2026
8ff6bd2
fix(kiloclaw): use requireOrgInstance for config/catalog queries
pandemicsyn Apr 1, 2026
d0b1623
fix(kiloclaw): scope version pins per instance
pandemicsyn Apr 1, 2026
630a4b9
fix(kilo-app): handle instance-scoped version pins
pandemicsyn Apr 1, 2026
c6dc69a
fix(kiloclaw): pass instance-id to google-setup container in org context
pandemicsyn Apr 1, 2026
431a93f
feat(google-setup): support --instance-id for org instance targeting
pandemicsyn Apr 1, 2026
061e23f
fix(kiloclaw): scope personal CLI runs by instance_id
pandemicsyn Apr 1, 2026
c10d31b
fix(kiloclaw): admin pin operations resolve instance from userId
pandemicsyn Apr 1, 2026
b4683fc
fix(kiloclaw): fix pin migration safety and self-pin detection
pandemicsyn Apr 1, 2026
16f473a
fix(kiloclaw): admin instance detail pins target viewed instance
pandemicsyn Apr 1, 2026
2843fe8
fix(kiloclaw): simplify pin migration — drop column + add instead of …
pandemicsyn Apr 1, 2026
7751f77
fix(kiloclaw): cookie-based instance routing for catch-all proxy
pandemicsyn Apr 1, 2026
6cc4889
fix(kiloclaw): add destroy/restore guards and lint fixes to cookie-ro…
pandemicsyn Apr 1, 2026
65ad283
fix(kiloclaw): org getMyPin returns null when no instance exists
pandemicsyn Apr 1, 2026
58f3cda
fix(kiloclaw): route admin and org endpoints to correct instance
pandemicsyn Apr 1, 2026
c3cceb9
fix(kiloclaw): prevent silent fallback to personal instance
pandemicsyn Apr 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,4 @@ supabase/.temp

# @kilocode/trpc build output (rebuilt by: pnpm --filter @kilocode/trpc run build)
packages/trpc/dist/
.plan/
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default function VersionPinScreen() {
const latestVersion = latestVersionQuery.data;
const versions = availableVersionsQuery.data?.items ?? [];

const isPinnedByAdmin = myPin != null && myPin.pinned_by !== myPin.user_id;
const isPinnedByAdmin = myPin != null && !myPin.pinnedBySelf;

function handleUnpin() {
Alert.alert('Unpin Version', 'Switch back to the latest available version?', [
Expand Down
22 changes: 18 additions & 4 deletions kiloclaw/google-setup/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,13 @@ const gmailPushWorkerUrl = gmailPushWorkerUrlArg
? gmailPushWorkerUrlArg.substring(gmailPushWorkerUrlArg.indexOf('=') + 1)
: 'https://kiloclaw-gmail.kiloapps.io';

const instanceIdArg = args.find(a => a.startsWith('--instance-id='));
const instanceId = instanceIdArg?.substring(instanceIdArg.indexOf('=') + 1);

if (!token) {
console.error('Usage: docker run -it ghcr.io/kilo-org/google-setup --token=<session-jwt>');
console.error(
'Usage: docker run -it ghcr.io/kilo-org/google-setup --token=<session-jwt> [--instance-id=<uuid>]'
);
process.exit(1);
}

Expand All @@ -65,6 +70,12 @@ const authHeaders = {
'content-type': 'application/json',
};

/** Build a worker API URL, appending ?instanceId= when targeting an org instance. */
function workerApiUrl(path) {
const base = `${workerUrl}${path}`;
return instanceId ? `${base}?instanceId=${encodeURIComponent(instanceId)}` : base;
}

// APIs to enable in the GCP project
const GCP_APIS = [
'gmail.googleapis.com',
Expand Down Expand Up @@ -117,6 +128,9 @@ function runCommandOutput(cmd, args) {
// Step 1: Validate session token
// ---------------------------------------------------------------------------

if (instanceId) {
console.log(`Targeting instance: ${instanceId}`);
}
console.log('Validating session token...');

const validateRes = await fetch(`${workerUrl}/health`);
Expand All @@ -125,7 +139,7 @@ if (!validateRes.ok) {
process.exit(1);
}

const authCheckRes = await fetch(`${workerUrl}/api/admin/google-credentials`, {
const authCheckRes = await fetch(workerApiUrl('/api/admin/google-credentials'), {
headers: authHeaders,
});

Expand All @@ -146,7 +160,7 @@ console.log('Session token verified.\n');

console.log('Fetching encryption public key...');

const pubKeyRes = await fetch(`${workerUrl}/api/admin/public-key`, { headers: authHeaders });
const pubKeyRes = await fetch(workerApiUrl('/api/admin/public-key'), { headers: authHeaders });
if (!pubKeyRes.ok) {
console.error('Failed to fetch public key from worker.');
process.exit(1);
Expand Down Expand Up @@ -688,7 +702,7 @@ const encryptedBundle = {

console.log('Sending credentials to your kiloclaw instance...');

const postRes = await fetch(`${workerUrl}/api/admin/google-credentials`, {
const postRes = await fetch(workerApiUrl('/api/admin/google-credentials'), {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ googleCredentials: encryptedBundle }),
Expand Down
9 changes: 9 additions & 0 deletions kiloclaw/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export const STARTUP_TIMEOUT_SECONDS = 60;
/** Cookie name for worker auth token (set by worker after access code redemption) */
export const KILOCLAW_AUTH_COOKIE = 'kiloclaw-auth';

/**
* Cookie that tracks which instance the user is currently accessing.
* Set by the access gateway when opening an instance-keyed instance.
* Read by the catch-all proxy to route WebSocket/HTTP traffic to the
* correct instance (the OpenClaw Control UI connects to `/` without
* the `/i/{instanceId}/` prefix).
*/
export const KILOCLAW_ACTIVE_INSTANCE_COOKIE = 'kiloclaw-active-instance';

/** Cookie max age: 24 hours */
export const KILOCLAW_AUTH_COOKIE_MAX_AGE = 60 * 60 * 24;

Expand Down
5 changes: 4 additions & 1 deletion kiloclaw/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,15 @@ export async function getActiveInstance(db: WorkerDb, userId: string) {
.select({
id: kiloclaw_instances.id,
sandbox_id: kiloclaw_instances.sandbox_id,
organization_id: kiloclaw_instances.organization_id,
})
.from(kiloclaw_instances)
.where(and(eq(kiloclaw_instances.user_id, userId), isNull(kiloclaw_instances.destroyed_at)))
.limit(1)
.then(rows => rows[0] ?? null);

if (!row) return null;
return { id: row.id, sandboxId: row.sandbox_id };
return { id: row.id, sandboxId: row.sandbox_id, orgId: row.organization_id };
}

/**
Expand All @@ -86,6 +87,7 @@ export async function getInstanceBySandboxId(db: WorkerDb, sandboxId: string) {
id: kiloclaw_instances.id,
sandbox_id: kiloclaw_instances.sandbox_id,
user_id: kiloclaw_instances.user_id,
organization_id: kiloclaw_instances.organization_id,
})
.from(kiloclaw_instances)
.where(
Expand All @@ -99,6 +101,7 @@ export async function getInstanceBySandboxId(db: WorkerDb, sandboxId: string) {
id: row.id,
sandboxId: row.sandbox_id,
userId: row.user_id,
orgId: row.organization_id,
};
}

Expand Down
8 changes: 4 additions & 4 deletions kiloclaw/src/durable-objects/kiloclaw-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,11 +421,11 @@ describe('ensureEnvKey', () => {
expect(storage._store.get('envKeySet')).toBe(true);
});

it('throws on userId mismatch', async () => {
it('throws on ownerKey mismatch', async () => {
const { appDO } = createAppDO();
await appDO.ensureApp('user-1');

await expect(appDO.ensureEnvKey('user-2')).rejects.toThrow('userId mismatch');
await expect(appDO.ensureEnvKey('user-2')).rejects.toThrow('ownerKey mismatch');
});
});

Expand All @@ -448,10 +448,10 @@ describe('getEnvKey', () => {
expect(key).toBeNull();
});

it('throws on userId mismatch', async () => {
it('throws on ownerKey mismatch', async () => {
const { appDO } = createAppDO();
await appDO.ensureApp('user-1');

await expect(appDO.getEnvKey('user-2')).rejects.toThrow('userId mismatch');
await expect(appDO.getEnvKey('user-2')).rejects.toThrow('ownerKey mismatch');
});
});
56 changes: 35 additions & 21 deletions kiloclaw/src/durable-objects/kiloclaw-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import { setAppSecret } from '../fly/secrets';
import { generateEnvKey } from '../utils/env-encryption';
import { METADATA_KEY_USER_ID } from './machine-config';

/** UUID v4 pattern — used to detect instance-keyed ownerKeys. */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

// -- Persisted state schema --

const AppStateSchema = z.object({
Expand Down Expand Up @@ -80,32 +83,43 @@ export class KiloClawApp extends DurableObject<KiloClawEnv> {
}

/**
* Ensure a Fly App exists for this user with IPs allocated and env key set.
* Ensure a Fly App exists for this owner with IPs allocated and env key set.
* Idempotent: creates the app only if it doesn't exist yet.
* Returns the app name for callers to cache.
*
* @param ownerKey - Either a userId (personal) or `"org:{orgId}"` (org).
* Org keys derive an `oapp-{hash}` app name instead of `acct-{hash}`.
*/
async ensureApp(userId: string): Promise<{ appName: string }> {
async ensureApp(ownerKey: string): Promise<{ appName: string }> {
await this.loadState();

if (this.userId && this.userId !== userId) {
throw new Error(`userId mismatch: DO has ${this.userId}, caller passed ${userId}`);
if (this.userId && this.userId !== ownerKey) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the significance of this.userId? It feels redundant to have both this.userId and the ownerKey.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ownerKey here is the caller-supplied argument, this.userId is what's already stored. userId is actually a bit of a misnomer now - it should be ownerKey moving forward. I can probably migrate that key in a follow up. ownerKey is actually gonna be instanceId for new deploys (that DO should represent the one instance in our pg table)

throw new Error(`ownerKey mismatch: DO has ${this.userId}, caller passed ${ownerKey}`);
}

const apiToken = this.env.FLY_API_TOKEN;
if (!apiToken) throw new Error('FLY_API_TOKEN is not configured');
const orgSlug = this.env.FLY_ORG_SLUG;
if (!orgSlug) throw new Error('FLY_ORG_SLUG is not configured');

// Derive app name (deterministic from userId, with env prefix in dev)
// Derive app name based on ownerKey type:
// - UUID (instanceId) → inst-{hash} (per-instance app)
// - userId string → acct-{hash} (legacy per-user app)
const prefix = this.env.WORKER_ENV === 'development' ? 'dev' : undefined;
const appName = this.flyAppName ?? (await apps.appNameFromUserId(userId, prefix));

// Persist userId + appName early so we can retry on partial failure
const isInstanceKeyed = UUID_RE.test(ownerKey);
const appName = this.flyAppName
? this.flyAppName
: isInstanceKeyed
? await apps.appNameFromInstanceId(ownerKey, prefix)
: await apps.appNameFromUserId(ownerKey, prefix);

// Persist ownerKey + appName early so we can retry on partial failure.
// The `userId` storage field stores the ownerKey (historical name).
if (!this.userId || !this.flyAppName) {
this.userId = userId;
this.userId = ownerKey;
this.flyAppName = appName;
await this.ctx.storage.put({
userId,
userId: ownerKey,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So userId can be set to org:{orgId} in this case?

flyAppName: appName,
} satisfies Partial<AppState>);
}
Expand All @@ -115,8 +129,8 @@ export class KiloClawApp extends DurableObject<KiloClawEnv> {
if (!this.ipv4Allocated || !this.ipv6Allocated) {
const existing = await apps.getApp({ apiToken }, appName);
if (!existing) {
await apps.createApp({ apiToken }, appName, orgSlug, userId, METADATA_KEY_USER_ID);
console.log('[AppDO] Created Fly App:', appName);
await apps.createApp({ apiToken }, appName, orgSlug, ownerKey, METADATA_KEY_USER_ID);
console.log('[AppDO] Created Fly App:', appName, 'owner:', ownerKey);
}
}

Expand All @@ -139,7 +153,7 @@ export class KiloClawApp extends DurableObject<KiloClawEnv> {
// Step 4: Generate and store env encryption key if not done.
// Uses the same locked path as ensureEnvKey() to prevent interleaving.
if (!this.envKeySet) {
await this.ensureEnvKey(userId);
await this.ensureEnvKey(ownerKey);
}
} catch (err) {
// Partial state persisted above — arm a retry alarm so the DO self-heals
Expand All @@ -155,15 +169,15 @@ export class KiloClawApp extends DurableObject<KiloClawEnv> {
}

/**
* Get the env encryption key for this user's app.
* Enforces userId match to prevent cross-user key fetches.
* Get the env encryption key for this owner's app.
* Enforces ownerKey match to prevent cross-owner key fetches.
* Returns null if key hasn't been set yet.
*/
async getEnvKey(userId: string): Promise<string | null> {
async getEnvKey(ownerKey: string): Promise<string | null> {
await this.loadState();

if (this.userId && this.userId !== userId) {
throw new Error(`userId mismatch: DO has ${this.userId}, caller passed ${userId}`);
if (this.userId && this.userId !== ownerKey) {
throw new Error(`ownerKey mismatch: DO has ${this.userId}, caller passed ${ownerKey}`);
}

return this.envKey;
Expand All @@ -182,11 +196,11 @@ export class KiloClawApp extends DurableObject<KiloClawEnv> {
* Called by Instance DO at machine start time. This ensures legacy apps that
* were created before the encryption feature get their key on first start.
*/
async ensureEnvKey(userId: string): Promise<{ key: string; secretsVersion: number }> {
async ensureEnvKey(ownerKey: string): Promise<{ key: string; secretsVersion: number }> {
await this.loadState();

if (this.userId && this.userId !== userId) {
throw new Error(`userId mismatch: DO has ${this.userId}, caller passed ${userId}`);
if (this.userId && this.userId !== ownerKey) {
throw new Error(`ownerKey mismatch: DO has ${this.userId}, caller passed ${ownerKey}`);
}

const apiToken = this.env.FLY_API_TOKEN;
Expand Down
7 changes: 5 additions & 2 deletions kiloclaw/src/durable-objects/kiloclaw-instance/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ENCRYPTED_ENV_PREFIX, encryptEnvValue } from '../../utils/env-encryptio
import { findPepperByUserId, getWorkerDb } from '../../db';
import { KILOCODE_API_KEY_EXPIRY_SECONDS } from '../../config';
import type { InstanceMutableState } from './types';
import { getAppKey } from './types';
import { storageUpdate } from './state';
import { doWarn, toLoggable } from './log';

Expand Down Expand Up @@ -168,8 +169,10 @@ export async function buildUserEnvVars(
}

// Get the env encryption key from the App DO, creating it if needed.
const appStub = env.KILOCLAW_APP.get(env.KILOCLAW_APP.idFromName(state.userId));
const { key: envKey, secretsVersion } = await appStub.ensureEnvKey(state.userId);
// Instance-keyed DOs get per-instance apps, legacy DOs get per-user apps.
const appKey = getAppKey({ userId: state.userId, sandboxId: state.sandboxId });
const appStub = env.KILOCLAW_APP.get(env.KILOCLAW_APP.idFromName(appKey));
const { key: envKey, secretsVersion } = await appStub.ensureEnvKey(appKey);

// Encrypt sensitive values and prefix their names with KILOCLAW_ENC_
const result: Record<string, string> = { ...plainEnv };
Expand Down
16 changes: 11 additions & 5 deletions kiloclaw/src/durable-objects/kiloclaw-instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import type { GatewayProcessStatus } from '../gateway-controller-types';

// Domain modules
import type { InstanceMutableState, InstanceStatus, DestroyResult } from './types';
import { getFlyConfig } from './types';
import { getAppKey, getFlyConfig } from './types';
import { createMutableState, loadState, storageUpdate } from './state';
import { nextAlarmTime, doLog, doError, doWarn, toLoggable, createReconcileContext } from './log';
import { attemptMetadataRecovery } from './reconcile';
Expand Down Expand Up @@ -231,13 +231,17 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
: sandboxIdFromUserId(userId);
const isNew = !this.s.status;

// Ensure per-user Fly App exists on first provision only.
// Ensure Fly App exists on first provision.
// Instance-keyed DOs (ki_ sandboxId) get their own app (inst-{hash}).
// Legacy DOs keep their user-scoped app (acct-{hash}).
if (isNew && !this.s.flyAppName) {
const appStub = this.env.KILOCLAW_APP.get(this.env.KILOCLAW_APP.idFromName(userId));
const { appName } = await appStub.ensureApp(userId);
this.s.orgId = opts?.orgId ?? null;
const appKey = getAppKey({ userId, sandboxId });
const appStub = this.env.KILOCLAW_APP.get(this.env.KILOCLAW_APP.idFromName(appKey));
const { appName } = await appStub.ensureApp(appKey);
this.s.flyAppName = appName;
await this.persist({ flyAppName: appName });
console.log('[DO] Per-user Fly App ensured:', appName);
console.log('[DO] Fly App ensured:', appName, 'key:', appKey);
}

// Create Fly Volume on first provision.
Expand Down Expand Up @@ -1028,6 +1032,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
const identity = {
userId: this.s.userId,
sandboxId: this.s.sandboxId,
orgId: this.s.orgId,
openclawVersion: this.s.openclawVersion,
imageVariant: this.s.imageVariant,
};
Expand Down Expand Up @@ -2151,6 +2156,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
const identity = {
userId: this.s.userId ?? '',
sandboxId: this.s.sandboxId ?? '',
orgId: this.s.orgId,
openclawVersion: this.s.openclawVersion,
imageVariant: this.s.imageVariant,
};
Expand Down
18 changes: 13 additions & 5 deletions kiloclaw/src/durable-objects/kiloclaw-instance/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
getInstanceBySandboxId,
markInstanceDestroyed,
} from '../../db';
import { appNameFromUserId } from '../../fly/apps';
import { appNameFromUserId, appNameFromInstanceId } from '../../fly/apps';
import type { InstanceMutableState } from './types';
import { getFlyConfig } from './types';
import { getAppKey, getFlyConfig } from './types';
import { storageUpdate } from './state';
import { attemptMetadataRecovery } from './reconcile';
import { doError, doWarn, toLoggable, createReconcileContext } from './log';
Expand Down Expand Up @@ -58,15 +58,22 @@ export async function restoreFromPostgres(
const channels = null;

// Recover flyAppName from the App DO or derive deterministically.
const appStub = env.KILOCLAW_APP.get(env.KILOCLAW_APP.idFromName(userId));
// Instance-keyed DOs (ki_ sandboxId) have per-instance apps (inst-{hash}),
// legacy DOs have per-user apps (acct-{hash}).
const appKey = getAppKey({ userId, sandboxId: instance.sandboxId });
const appStub = env.KILOCLAW_APP.get(env.KILOCLAW_APP.idFromName(appKey));
const prefix = env.WORKER_ENV === 'development' ? 'dev' : undefined;
const recoveredAppName =
(await appStub.getAppName()) ?? (await appNameFromUserId(userId, prefix));
const isInstanceKeyed = appKey !== userId;
const fallbackAppName = isInstanceKeyed
? await appNameFromInstanceId(appKey, prefix)
: await appNameFromUserId(userId, prefix);
const recoveredAppName = (await appStub.getAppName()) ?? fallbackAppName;

await ctx.storage.put(
storageUpdate({
userId,
sandboxId: instance.sandboxId,
orgId: instance.orgId ?? null,
status: 'provisioned',
envVars,
encryptedSecrets,
Expand All @@ -92,6 +99,7 @@ export async function restoreFromPostgres(

state.userId = userId;
state.sandboxId = instance.sandboxId;
state.orgId = instance.orgId ?? null;
state.status = 'provisioned';
state.envVars = envVars;
state.encryptedSecrets = encryptedSecrets;
Expand Down
Loading
Loading