Skip to content

Commit 73e9669

Browse files
committed
refactor(kiloclaw): use per-sandbox KILOCHAT_SANDBOX_TOKEN, drop x-kilo-sandbox-id
Pair with the kilo-chat backend's per-sandbox token rewrite. Each Fly machine now carries its own KILOCHAT_SANDBOX_TOKEN (minted by the kilo-chat admin API at provisioning time) instead of the shared KILOCHAT_API_TOKEN. The per-sandbox token identifies the caller on its own, so the controller no longer sends x-kilo-sandbox-id upstream and KiloChatRouteOptions no longer carries a sandboxId field. Touched files: - Rename KILOCHAT_API_TOKEN → KILOCHAT_SANDBOX_TOKEN across kiloclaw-secret-catalog, gateway/env, controller/config-writer, and controller/index. - Drop x-kilo-sandbox-id header from all five kilo-chat proxy routes; tests assert the header is NOT present in the upstream call. - Update openclaw plugin manifest channelEnvVars to advertise the new var name. - Update README / LOCAL_E2E docs (sample fake upstream server, env tables, the Docker run example).
1 parent 30b1e45 commit 73e9669

13 files changed

Lines changed: 57 additions & 60 deletions

File tree

packages/kiloclaw-secret-catalog/src/__tests__/catalog.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ describe('Secret Catalog', () => {
470470
});
471471

472472
it('contains Kilo Chat operator-provisioned tokens', () => {
473-
expect(INTERNAL_SENSITIVE_ENV_VARS.has('KILOCHAT_API_TOKEN')).toBe(true);
473+
expect(INTERNAL_SENSITIVE_ENV_VARS.has('KILOCHAT_SANDBOX_TOKEN')).toBe(true);
474474
});
475475

476476
it('does not overlap with catalog-derived ALL_SECRET_ENV_VARS', () => {
@@ -533,7 +533,7 @@ describe('Secret Catalog', () => {
533533
expect(isValidCustomSecretKey('NEXTAUTH_SECRET')).toBe(false);
534534
expect(isValidCustomSecretKey('NODE_OPTIONS')).toBe(false);
535535
expect(isValidCustomSecretKey('STREAM_CHAT_API_KEY')).toBe(false);
536-
expect(isValidCustomSecretKey('KILOCHAT_API_TOKEN')).toBe(false);
536+
expect(isValidCustomSecretKey('KILOCHAT_SANDBOX_TOKEN')).toBe(false);
537537
expect(isValidCustomSecretKey('KILOCHAT_ANYTHING')).toBe(false);
538538
});
539539

packages/kiloclaw-secret-catalog/src/catalog.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,11 @@ export const ALL_SECRET_ENV_VARS: ReadonlySet<string> = new Set(
280280
*/
281281
export const INTERNAL_SENSITIVE_ENV_VARS: ReadonlySet<string> = new Set([
282282
'KILOCLAW_GOG_CONFIG_TARBALL',
283-
// Kilo Chat — operator-provisioned, not user-configurable. Pushed from
284-
// worker env via buildEnvVars; the KILOCHAT_ prefix is reserved in
285-
// DENIED_ENV_VAR_PREFIXES to keep users from shadowing them.
286-
'KILOCHAT_API_TOKEN',
283+
// Kilo Chat — operator-provisioned per-sandbox token. Minted via the
284+
// kilo-chat admin API, distributed to each Fly machine as a Fly secret.
285+
// The KILOCHAT_ prefix is reserved in DENIED_ENV_VAR_PREFIXES to keep
286+
// users from shadowing it.
287+
'KILOCHAT_SANDBOX_TOKEN',
287288
]);
288289

289290
/**

services/kiloclaw/controller/src/config-writer.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -764,11 +764,11 @@ describe('generateBaseConfig', () => {
764764

765765
// ─── Kilo Chat ───────────────────────────────────────────────────────────
766766

767-
it('configures kilo-chat channel and plugin when both KILOCHAT_API_TOKEN and KILOCHAT_BASE_URL are set', () => {
767+
it('configures kilo-chat channel and plugin when both KILOCHAT_SANDBOX_TOKEN and KILOCHAT_BASE_URL are set', () => {
768768
const { deps } = fakeDeps();
769769
const env = {
770770
...minimalEnv(),
771-
KILOCHAT_API_TOKEN: 'tok',
771+
KILOCHAT_SANDBOX_TOKEN: 'tok',
772772
KILOCHAT_BASE_URL: 'https://chat.example.test',
773773
};
774774
const config = generateBaseConfig(env, '/tmp/openclaw.json', deps);
@@ -786,7 +786,7 @@ describe('generateBaseConfig', () => {
786786
const { deps } = fakeDeps();
787787
const env = {
788788
...minimalEnv(),
789-
KILOCHAT_API_TOKEN: 'tok',
789+
KILOCHAT_SANDBOX_TOKEN: 'tok',
790790
KILOCHAT_BASE_URL: 'https://chat.example.test',
791791
KILOCHAT_REACTION_LEVEL: 'extensive',
792792
};
@@ -798,7 +798,7 @@ describe('generateBaseConfig', () => {
798798
const { deps } = fakeDeps();
799799
const env = {
800800
...minimalEnv(),
801-
KILOCHAT_API_TOKEN: 'tok',
801+
KILOCHAT_SANDBOX_TOKEN: 'tok',
802802
KILOCHAT_BASE_URL: 'https://chat.example.test',
803803
};
804804
const config = generateBaseConfig(env, '/tmp/openclaw.json', deps);
@@ -809,7 +809,7 @@ describe('generateBaseConfig', () => {
809809
const { deps } = fakeDeps();
810810
const env = {
811811
...minimalEnv(),
812-
KILOCHAT_API_TOKEN: 'tok',
812+
KILOCHAT_SANDBOX_TOKEN: 'tok',
813813
KILOCHAT_BASE_URL: 'https://chat.example.test',
814814
KILOCHAT_REACTION_LEVEL: 'bogus',
815815
};
@@ -822,7 +822,7 @@ describe('generateBaseConfig', () => {
822822
const { deps } = fakeDeps();
823823
const env = {
824824
...minimalEnv(),
825-
KILOCHAT_API_TOKEN: 'tok',
825+
KILOCHAT_SANDBOX_TOKEN: 'tok',
826826
KILOCHAT_BASE_URL: 'https://chat.example.test',
827827
KILOCHAT_REACTION_LEVEL: level,
828828
};
@@ -831,7 +831,7 @@ describe('generateBaseConfig', () => {
831831
}
832832
});
833833

834-
it('leaves kilo-chat channel unconfigured when KILOCHAT_API_TOKEN is missing', () => {
834+
it('leaves kilo-chat channel unconfigured when KILOCHAT_SANDBOX_TOKEN is missing', () => {
835835
const { deps } = fakeDeps();
836836
const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps);
837837

@@ -840,7 +840,7 @@ describe('generateBaseConfig', () => {
840840

841841
it('leaves kilo-chat channel unconfigured when KILOCHAT_BASE_URL is missing (token-only is not enough)', () => {
842842
const { deps } = fakeDeps();
843-
const env = { ...minimalEnv(), KILOCHAT_API_TOKEN: 'tok' };
843+
const env = { ...minimalEnv(), KILOCHAT_SANDBOX_TOKEN: 'tok' };
844844
const config = generateBaseConfig(env, '/tmp/openclaw.json', deps);
845845

846846
expect(config.channels?.['kilo-chat']).toBeUndefined();

services/kiloclaw/controller/src/config-writer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ export function generateBaseConfig(
448448
// section has a key other than `enabled`. Without that, the plugin loads in
449449
// `setup-runtime` mode instead of `full`, and registerFull (which registers
450450
// the webhook HTTP route) is never called.
451-
if (env.KILOCHAT_API_TOKEN && env.KILOCHAT_BASE_URL) {
451+
if (env.KILOCHAT_SANDBOX_TOKEN && env.KILOCHAT_BASE_URL) {
452452
config.channels['kilo-chat'] = config.channels['kilo-chat'] ?? {};
453453
config.channels['kilo-chat'].enabled = true;
454454
config.channels['kilo-chat'].baseUrl = env.KILOCHAT_BASE_URL;

services/kiloclaw/controller/src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -364,11 +364,13 @@ export async function startController(env: NodeJS.ProcessEnv = process.env): Pro
364364
registerPairingRoutes(honoApp, pairingCache, config.expectedToken);
365365
registerEnvRoutes(honoApp, supervisor, config.expectedToken);
366366
registerGmailPushRoute(honoApp, gmailWatchSupervisor ?? null, config.expectedToken);
367-
if (env.KILOCHAT_API_TOKEN && env.KILOCHAT_BASE_URL) {
367+
if (env.KILOCHAT_SANDBOX_TOKEN && env.KILOCHAT_BASE_URL) {
368+
// The per-sandbox token identifies the caller on the kilo-chat backend —
369+
// no x-kilo-sandbox-id header is sent, and the shared KILOCHAT_API_TOKEN
370+
// concept no longer exists.
368371
const kiloChatOpts = {
369372
expectedToken: config.expectedToken,
370-
sandboxId: env.KILOCLAW_SANDBOX_ID ?? '',
371-
apiToken: env.KILOCHAT_API_TOKEN,
373+
apiToken: env.KILOCHAT_SANDBOX_TOKEN,
372374
baseUrl: env.KILOCHAT_BASE_URL,
373375
};
374376
registerKiloChatSendRoute(honoApp, kiloChatOpts);

services/kiloclaw/controller/src/routes/kilo-chat.test.ts

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,11 @@ import {
1010
} from './kilo-chat';
1111

1212
const TOKEN = 'expected-gateway-token';
13-
const SANDBOX_ID = 'sbx_test';
1413

1514
function makeApp(fetchImpl: typeof fetch) {
1615
const app = new Hono();
1716
registerKiloChatSendRoute(app, {
1817
expectedToken: TOKEN,
19-
sandboxId: SANDBOX_ID,
2018
apiToken: 'api_token',
2119
baseUrl: 'https://chat.example.test',
2220
fetchImpl,
@@ -52,7 +50,7 @@ describe('POST /_kilo/kilo-chat/send', () => {
5250
expect(res.status).toBe(401);
5351
});
5452

55-
it('forwards authorized requests with sandbox id header and api token', async () => {
53+
it('forwards authorized requests with api token only; no x-kilo-sandbox-id header', async () => {
5654
let capturedUrl = '';
5755
let capturedInit: RequestInit | undefined;
5856
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
@@ -80,7 +78,7 @@ describe('POST /_kilo/kilo-chat/send', () => {
8078
expect(capturedUrl).toBe('https://chat.example.test/v1/messages');
8179
const headers = new Headers(capturedInit?.headers);
8280
expect(headers.get('authorization')).toBe('Bearer api_token');
83-
expect(headers.get('x-kilo-sandbox-id')).toBe(SANDBOX_ID);
81+
expect(headers.get('x-kilo-sandbox-id')).toBeNull();
8482
const body = JSON.parse((capturedInit?.body as string) ?? '{}');
8583
expect(body).toEqual({ conversationId: 'c1', text: 'hi' });
8684
});
@@ -106,7 +104,6 @@ function makeEditApp(fetchImpl: typeof fetch) {
106104
const app = new Hono();
107105
registerKiloChatEditRoute(app, {
108106
expectedToken: TOKEN,
109-
sandboxId: SANDBOX_ID,
110107
apiToken: 'api_token',
111108
baseUrl: 'https://chat.example.test',
112109
fetchImpl,
@@ -156,7 +153,7 @@ describe('PATCH /_kilo/kilo-chat/messages/:id', () => {
156153
expect(capturedInit?.method).toBe('PATCH');
157154
const headers = new Headers(capturedInit?.headers);
158155
expect(headers.get('authorization')).toBe('Bearer api_token');
159-
expect(headers.get('x-kilo-sandbox-id')).toBe(SANDBOX_ID);
156+
expect(headers.get('x-kilo-sandbox-id')).toBeNull();
160157
expect(JSON.parse((capturedInit?.body as string) ?? '{}')).toEqual({
161158
conversationId: 'c1',
162159
text: 'Hel',
@@ -186,7 +183,6 @@ function makeDeleteApp(fetchImpl: typeof fetch) {
186183
const app = new Hono();
187184
registerKiloChatDeleteRoute(app, {
188185
expectedToken: TOKEN,
189-
sandboxId: SANDBOX_ID,
190186
apiToken: 'api_token',
191187
baseUrl: 'https://chat.example.test',
192188
fetchImpl,
@@ -227,15 +223,14 @@ describe('DELETE /_kilo/kilo-chat/messages/:id', () => {
227223
expect(capturedInit?.method).toBe('DELETE');
228224
const headers = new Headers(capturedInit?.headers);
229225
expect(headers.get('authorization')).toBe('Bearer api_token');
230-
expect(headers.get('x-kilo-sandbox-id')).toBe(SANDBOX_ID);
226+
expect(headers.get('x-kilo-sandbox-id')).toBeNull();
231227
});
232228
});
233229

234230
function makeTypingApp(fetchImpl: typeof fetch) {
235231
const app = new Hono();
236232
registerKiloChatTypingRoute(app, {
237233
expectedToken: TOKEN,
238-
sandboxId: SANDBOX_ID,
239234
apiToken: 'api_token',
240235
baseUrl: 'https://chat.example.test',
241236
fetchImpl,
@@ -296,7 +291,7 @@ describe('POST /_kilo/kilo-chat/typing', () => {
296291
expect(capturedUrl).toBe('https://chat.example.test/v1/conversations/c1/typing');
297292
const headers = new Headers(capturedInit?.headers);
298293
expect(headers.get('authorization')).toBe('Bearer api_token');
299-
expect(headers.get('x-kilo-sandbox-id')).toBe(SANDBOX_ID);
294+
expect(headers.get('x-kilo-sandbox-id')).toBeNull();
300295
expect(capturedInit?.method).toBe('POST');
301296
});
302297

@@ -363,7 +358,6 @@ describe('POST /_kilo/kilo-chat/messages/:id/reactions', () => {
363358

364359
registerKiloChatReactionPostRoute(app, {
365360
expectedToken: 'gw',
366-
sandboxId: 'sbx',
367361
apiToken: 'api',
368362
baseUrl: 'http://svc',
369363
fetchImpl,
@@ -380,7 +374,7 @@ describe('POST /_kilo/kilo-chat/messages/:id/reactions', () => {
380374
expect(calls[0].url).toBe('http://svc/v1/messages/MID/reactions');
381375
const headers = calls[0].init.headers as Record<string, string>;
382376
expect(headers.authorization).toBe('Bearer api');
383-
expect(headers['x-kilo-sandbox-id']).toBe('sbx');
377+
expect(headers['x-kilo-sandbox-id']).toBeUndefined();
384378
expect(calls[0].init.method).toBe('POST');
385379
expect(calls[0].init.body).toBe(JSON.stringify({ conversationId: 'C', emoji: '\u{1F44D}' }));
386380
});
@@ -391,7 +385,6 @@ describe('POST /_kilo/kilo-chat/messages/:id/reactions', () => {
391385
new Response(JSON.stringify({ id: 'RXULIDXXX' }), { status: 200 })) as typeof fetch;
392386
registerKiloChatReactionPostRoute(app, {
393387
expectedToken: 'gw',
394-
sandboxId: 's',
395388
apiToken: 'a',
396389
baseUrl: 'http://svc',
397390
fetchImpl,
@@ -410,7 +403,6 @@ describe('POST /_kilo/kilo-chat/messages/:id/reactions', () => {
410403
const app = new Hono();
411404
registerKiloChatReactionPostRoute(app, {
412405
expectedToken: 'gw',
413-
sandboxId: 's',
414406
apiToken: 'a',
415407
baseUrl: 'http://svc',
416408
fetchImpl: (async () => new Response()) as typeof fetch,
@@ -429,7 +421,6 @@ describe('POST /_kilo/kilo-chat/messages/:id/reactions', () => {
429421
const app = new Hono();
430422
registerKiloChatReactionPostRoute(app, {
431423
expectedToken: 'gw',
432-
sandboxId: 's',
433424
apiToken: 'a',
434425
baseUrl: 'http://svc',
435426
fetchImpl: (async () => new Response()) as typeof fetch,
@@ -456,7 +447,6 @@ describe('DELETE /_kilo/kilo-chat/messages/:id/reactions', () => {
456447

457448
registerKiloChatReactionDeleteRoute(app, {
458449
expectedToken: 'gw',
459-
sandboxId: 'sbx',
460450
apiToken: 'api',
461451
baseUrl: 'http://svc',
462452
fetchImpl,
@@ -474,14 +464,13 @@ describe('DELETE /_kilo/kilo-chat/messages/:id/reactions', () => {
474464
expect(calls[0].url).toBe('http://svc/v1/messages/MID/reactions');
475465
const headers = calls[0].init.headers as Record<string, string>;
476466
expect(headers.authorization).toBe('Bearer api');
477-
expect(headers['x-kilo-sandbox-id']).toBe('sbx');
467+
expect(headers['x-kilo-sandbox-id']).toBeUndefined();
478468
});
479469

480470
it('401 on missing bearer', async () => {
481471
const app = new Hono();
482472
registerKiloChatReactionDeleteRoute(app, {
483473
expectedToken: 'gw',
484-
sandboxId: 's',
485474
apiToken: 'a',
486475
baseUrl: 'http://svc',
487476
fetchImpl: (async () => new Response()) as typeof fetch,
@@ -498,7 +487,6 @@ describe('body size limits', () => {
498487
const app = new Hono();
499488
register(app, {
500489
expectedToken: TOKEN,
501-
sandboxId: SANDBOX_ID,
502490
apiToken: 'api_token',
503491
baseUrl: 'https://chat.example.test',
504492
fetchImpl,

services/kiloclaw/controller/src/routes/kilo-chat.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ import { timingSafeTokenEqual } from '../auth';
33
import { getBearerToken } from './gateway';
44

55
export type KiloChatRouteOptions = {
6+
/** Expected controller-gateway token (inbound from the plugin). */
67
expectedToken: string;
7-
sandboxId: string;
8+
/**
9+
* Per-sandbox token for the kilo-chat backend. The backend derives the
10+
* caller's sandbox identity from this token alone — no x-kilo-sandbox-id
11+
* header is sent upstream.
12+
*/
813
apiToken: string;
914
baseUrl: string;
1015
fetchImpl?: typeof fetch;
@@ -56,7 +61,6 @@ export function registerKiloChatSendRoute(app: Hono, options: KiloChatRouteOptio
5661
headers: {
5762
'content-type': c.req.header('content-type') ?? 'application/json',
5863
authorization: `Bearer ${options.apiToken}`,
59-
'x-kilo-sandbox-id': options.sandboxId,
6064
},
6165
body: rawBody,
6266
});
@@ -94,7 +98,6 @@ export function registerKiloChatEditRoute(app: Hono, options: KiloChatRouteOptio
9498
headers: {
9599
'content-type': c.req.header('content-type') ?? 'application/json',
96100
authorization: `Bearer ${options.apiToken}`,
97-
'x-kilo-sandbox-id': options.sandboxId,
98101
},
99102
body: rawBody,
100103
}
@@ -141,7 +144,6 @@ export function registerKiloChatTypingRoute(app: Hono, options: KiloChatRouteOpt
141144
headers: {
142145
'content-type': 'application/json',
143146
authorization: `Bearer ${options.apiToken}`,
144-
'x-kilo-sandbox-id': options.sandboxId,
145147
},
146148
}
147149
);
@@ -179,7 +181,6 @@ export function registerKiloChatReactionPostRoute(app: Hono, options: KiloChatRo
179181
headers: {
180182
'content-type': c.req.header('content-type') ?? 'application/json',
181183
authorization: `Bearer ${options.apiToken}`,
182-
'x-kilo-sandbox-id': options.sandboxId,
183184
},
184185
body: rawBody,
185186
}
@@ -219,7 +220,6 @@ export function registerKiloChatReactionDeleteRoute(
219220
headers: {
220221
'content-type': c.req.header('content-type') ?? 'application/json',
221222
authorization: `Bearer ${options.apiToken}`,
222-
'x-kilo-sandbox-id': options.sandboxId,
223223
},
224224
body: rawBody || undefined,
225225
}
@@ -256,7 +256,6 @@ export function registerKiloChatDeleteRoute(app: Hono, options: KiloChatRouteOpt
256256
headers: {
257257
'content-type': c.req.header('content-type') ?? 'application/json',
258258
authorization: `Bearer ${options.apiToken}`,
259-
'x-kilo-sandbox-id': options.sandboxId,
260259
},
261260
body: rawBody || undefined,
262261
}

services/kiloclaw/plugins/kilo-chat/LOCAL_E2E.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Save this as `/tmp/fake-kilo-chat-upstream.mjs`:
4848
```js
4949
import { createServer } from 'node:http';
5050

51-
const EXPECTED_BEARER = process.env.KILOCHAT_API_TOKEN ?? 'upstream-token';
51+
const EXPECTED_BEARER = process.env.KILOCHAT_SANDBOX_TOKEN ?? 'upstream-token';
5252
const msgs = new Map();
5353
let counter = 0;
5454

@@ -68,11 +68,10 @@ const j = (res, status, obj) => {
6868
createServer(async (req, res) => {
6969
const url = new URL(req.url, 'http://x');
7070
const auth = req.headers['authorization'];
71-
const sandbox = req.headers['x-kilo-sandbox-id'];
7271
const body = await readBody(req);
73-
console.log(
74-
`[upstream] ${req.method} ${url.pathname} sandbox=${sandbox} auth=${auth} body=${body}`
75-
);
72+
// The per-sandbox token in Authorization is the sole identity — the real
73+
// backend looks it up in the sandbox registry. No x-kilo-sandbox-id.
74+
console.log(`[upstream] ${req.method} ${url.pathname} auth=${auth} body=${body}`);
7675

7776
if (auth !== `Bearer ${EXPECTED_BEARER}`) return j(res, 401, { error: 'bad token' });
7877

@@ -108,7 +107,7 @@ createServer(async (req, res) => {
108107
Run it in a second terminal and leave it running:
109108
110109
```bash
111-
KILOCHAT_API_TOKEN=upstream-token node /tmp/fake-kilo-chat-upstream.mjs
110+
KILOCHAT_SANDBOX_TOKEN=upstream-token node /tmp/fake-kilo-chat-upstream.mjs
112111
```
113112
114113
## 3. Start the container (Terminal 1)
@@ -125,7 +124,7 @@ docker run -d --rm --name kiloclaw-test \
125124
-e KILOCLAW_FRESH_INSTALL=true \
126125
-e KILOCODE_DEFAULT_MODEL='kilocode/kilo-auto/free' \
127126
-e REQUIRE_PROXY_TOKEN=false \
128-
-e KILOCHAT_API_TOKEN=upstream-token \
127+
-e KILOCHAT_SANDBOX_TOKEN=upstream-token \
129128
-e KILOCHAT_WEBHOOK_SECRET=webhook-secret-xyz \
130129
-e KILOCHAT_BASE_URL=http://host.docker.internal:9999 \
131130
-v "$ROOTDIR:/root" \
@@ -141,7 +140,7 @@ Required env, summarised:
141140
| `KILOCLAW_FRESH_INSTALL=true` | forces onboard even if `/root` is non-empty |
142141
| `KILOCODE_DEFAULT_MODEL` | default model; `kilocode/kilo-auto/free` is free-tier |
143142
| `REQUIRE_PROXY_TOKEN=false` | skip `x-kiloclaw-proxy-token` on the catch-all proxy so webhook curls don't need it |
144-
| `KILOCHAT_API_TOKEN` | controller → upstream Bearer |
143+
| `KILOCHAT_SANDBOX_TOKEN` | controller → upstream Bearer |
145144
| `KILOCHAT_WEBHOOK_SECRET` | HMAC-SHA256 key for inbound webhooks |
146145
| `KILOCHAT_BASE_URL` | upstream for the controller's PATCH/POST/DELETE routes |
147146

0 commit comments

Comments
 (0)