Skip to content

Commit 30be658

Browse files
authored
Merge pull request #12 from Kilo-Org/feat/channel-forwarding
feat(osa) send request channel with payload
2 parents 8416e4f + 6a6182c commit 30be658

3 files changed

Lines changed: 92 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Plugin now forwards the active chat surface to the server as `source.channel` on every checkup request. The slash-command path reads `PluginCommandContext.channel` and the tool/natural-language path reads `OpenClawPluginToolContext.messageChannel` (tool registration converted to factory form so the ctx is accessible at tool-instantiation and closed over by `execute()`). Server uses this hint to pick a channel-appropriate format (e.g. collapsible `<details>` blocks on capable UIs, flat markdown on Telegram/Slack). Backward-compatible with older servers: the field is optional in the client payload and servers that don't declare it in their zod schema silently drop it at parse time (no coordinated release required).
13+
14+
### Removed
15+
16+
- `maybeAppendUpdateReminder()` and the plugin-side update-reminder footer introduced in 0.1.3. The footer was presentation logic in the wrong layer — it forced a plugin release to change cadence, copy, or enablement, and only the plugin could decide when to show it. The reminder moves to the server (owner of all report rendering), where it can key off the reported `source.pluginVersion` to show a reminder only when the client is actually behind, and where admins can edit copy/cadence via the content catalog without a plugin release.
17+
1018
### Fixed
1119

1220
- KiloClaw platform detection now uses four independent signals instead of relying on a single env var, so detection holds across KiloClaw deployments of varying age. `detectPlatform()` now walks (in order, short-circuiting on the first hit): (1) `plugins.entries.kiloclaw-customizer.enabled` in `openclaw.json`, (2) `plugins.load.paths` containing the kiloclaw customizer install path, (3) `process.env.KILOCLAW_SANDBOX_ID`, (4) `process.env.KILOCODE_FEATURE === "kiloclaw"`. The two config-side signals are written by the KiloClaw controller at boot and are present on every KiloClaw instance since the customizer plugin was introduced, so they catch older deployments that predate the env-var signals. Internal signature change: `detectPlatform()` now takes the loaded openclaw config so it can inspect the config-side signals.

index.ts

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,6 @@ import pkg from "./package.json" with { type: "json" };
1818
const PLUGIN_VERSION: string = pkg.version;
1919
const DEFAULT_API_BASE = "https://api.kilo.ai";
2020

21-
// Roughly 1-in-5 successful checkups append an update-check footer. This is
22-
// intentionally path-agnostic — applied at the markdown layer in doCheckup —
23-
// so both the LLM-driven `kilocode_security_advisor` tool and the
24-
// LLM-bypassing `/security-checkup` slash command surface the reminder at the
25-
// same cadence. Random rather than stateful because the plugin has no
26-
// cross-invocation counter to key off.
27-
const UPDATE_REMINDER_PROBABILITY = 0.2;
28-
29-
function maybeAppendUpdateReminder(reportMarkdown: string): string {
30-
if (Math.random() >= UPDATE_REMINDER_PROBABILITY) {
31-
return reportMarkdown;
32-
}
33-
return (
34-
reportMarkdown +
35-
"\n\n---\n\n" +
36-
"**Tip — stay current:** check the latest plugin version with " +
37-
"`npm view @kilocode/openclaw-security-advisor version` and compare " +
38-
"against the `pluginVersion` shown above. If you're behind, upgrade " +
39-
"with `openclaw plugins install @kilocode/openclaw-security-advisor` " +
40-
"followed by `openclaw gateway restart`."
41-
);
42-
}
43-
4421
type ToolResult = {
4522
content: Array<{ type: "text"; text: string }>;
4623
};
@@ -56,11 +33,33 @@ type ToolRegistration = {
5633
execute: () => Promise<ToolResult>;
5734
};
5835

36+
/**
37+
* Minimal shape of the SDK's OpenClawPluginToolContext that we actually
38+
* read. The full type lives in the SDK and is not re-exported to plugins;
39+
* we only need the active chat surface (if any) to forward to the server
40+
* for channel-aware report formatting. Declared structurally so we stay
41+
* decoupled from internal SDK type evolution.
42+
*/
43+
type PluginToolContext = {
44+
messageChannel?: string;
45+
};
46+
47+
type ToolFactory = (ctx: PluginToolContext) => ToolRegistration;
48+
49+
/**
50+
* Minimal shape of the SDK's PluginCommandContext that we actually read.
51+
* Same rationale as PluginToolContext — we only need the chat surface
52+
* for the server-side formatter hint.
53+
*/
54+
type PluginCommandContext = {
55+
channel?: string;
56+
};
57+
5958
type CommandRegistration = {
6059
name: string;
6160
description: string;
6261
acceptsArgs: boolean;
63-
handler: (ctx: unknown) => Promise<CommandResult>;
62+
handler: (ctx: PluginCommandContext) => Promise<CommandResult>;
6463
};
6564

6665
/**
@@ -76,10 +75,26 @@ type PluginApi = {
7675
runtime: {
7776
config: PluginRuntimeConfig;
7877
};
79-
registerTool: (tool: ToolRegistration) => void;
78+
// SDK accepts either a tool object or a factory that returns one. We
79+
// use the factory form so we can capture `messageChannel` from the
80+
// runtime-provided tool context at tool-creation time and forward it
81+
// to the server on every invocation.
82+
registerTool: (tool: ToolRegistration | ToolFactory) => void;
8083
registerCommand: (cmd: CommandRegistration) => void;
8184
};
8285

86+
/**
87+
* Coerce a chat-surface string from the SDK into the value we forward to
88+
* the server. Trims, and treats empty-after-trim as "no channel known"
89+
* so we don't send `source.channel: ""` and trigger server-side handling
90+
* of an ambiguous signal.
91+
*/
92+
function normalizeChannel(raw: string | undefined): string | undefined {
93+
if (typeof raw !== "string") return undefined;
94+
const trimmed = raw.trim();
95+
return trimmed.length > 0 ? trimmed : undefined;
96+
}
97+
8398
function resolveEnvToken(): string | null {
8499
return process.env.KILOCODE_API_KEY ?? process.env.KILO_API_KEY ?? null;
85100
}
@@ -114,9 +129,13 @@ function toolResult(content: string): ToolResult {
114129
* non-zero exit code) are already handled inside the flow and return
115130
* their own specific messages; this is the last-resort safety net.
116131
*/
117-
async function runFlowSafe(api: PluginApi, apiBase: string): Promise<string> {
132+
async function runFlowSafe(
133+
api: PluginApi,
134+
apiBase: string,
135+
channel: string | undefined,
136+
): Promise<string> {
118137
try {
119-
return await runSecurityAdvisorFlow(api, apiBase);
138+
return await runSecurityAdvisorFlow(api, apiBase, channel);
120139
} catch (err) {
121140
const message = err instanceof Error ? err.message : String(err);
122141
api.logger.error?.(`security-advisor: unexpected failure: ${message}`);
@@ -138,6 +157,7 @@ async function runFlowSafe(api: PluginApi, apiBase: string): Promise<string> {
138157
async function runSecurityAdvisorFlow(
139158
api: PluginApi,
140159
apiBase: string,
160+
channel: string | undefined,
141161
): Promise<string> {
142162
// Path 0: user explicit config. If `plugins.entries.openclaw-security-advisor.config.authToken`
143163
// is set (as a plain string directly, or as a SecretRef resolved by
@@ -149,7 +169,7 @@ async function runSecurityAdvisorFlow(
149169
const configToken = api.pluginConfig?.authToken;
150170
if (typeof configToken === "string" && configToken.length > 0) {
151171
try {
152-
return await doCheckup(api, apiBase, configToken);
172+
return await doCheckup(api, apiBase, configToken, channel);
153173
} catch (err) {
154174
if (err instanceof AuthExpiredError) {
155175
return (
@@ -167,7 +187,7 @@ async function runSecurityAdvisorFlow(
167187
const envToken = resolveEnvToken();
168188
if (envToken) {
169189
try {
170-
return await doCheckup(api, apiBase, envToken);
190+
return await doCheckup(api, apiBase, envToken, channel);
171191
} catch (err) {
172192
if (err instanceof AuthExpiredError) {
173193
return (
@@ -187,7 +207,7 @@ async function runSecurityAdvisorFlow(
187207
const savedToken = await readTokenFromFile();
188208
if (savedToken) {
189209
try {
190-
return await doCheckup(api, apiBase, savedToken);
210+
return await doCheckup(api, apiBase, savedToken, channel);
191211
} catch (err) {
192212
if (!(err instanceof AuthExpiredError)) throw err;
193213
await clearStoredToken();
@@ -213,7 +233,7 @@ async function runSecurityAdvisorFlow(
213233
// subsequent invocations skip device auth and go straight to Path B.
214234
const reportMarkdown = await (async (): Promise<string> => {
215235
try {
216-
return await doCheckup(api, apiBase, pollResult.token);
236+
return await doCheckup(api, apiBase, pollResult.token, channel);
217237
} catch (err) {
218238
if (err instanceof AuthExpiredError) {
219239
// Edge case: server approved the token but immediately
@@ -296,6 +316,7 @@ async function doCheckup(
296316
api: PluginApi,
297317
apiBase: string,
298318
token: string,
319+
channel: string | undefined,
299320
): Promise<string> {
300321
const auditResult = await runAudit();
301322
if (!auditResult.ok) {
@@ -311,9 +332,13 @@ async function doCheckup(
311332
platform: detectPlatform(api.runtime.config.loadConfig()),
312333
method: "plugin",
313334
pluginVersion: PLUGIN_VERSION,
335+
// Only include `channel` when we actually know it. Sending an empty
336+
// string would force the server to special-case unknown-vs-absent;
337+
// absent + zod's unknown-key strip on older servers are both safe.
338+
...(channel !== undefined ? { channel } : {}),
314339
},
315340
});
316-
return maybeAppendUpdateReminder(response.report.markdown);
341+
return response.report.markdown;
317342
}
318343

319344
export default definePluginEntry({
@@ -356,7 +381,16 @@ export default definePluginEntry({
356381
// models (e.g. gpt-4.1-nano) may paraphrase the report instead of
357382
// displaying it verbatim. For those models, the slash command path
358383
// below is deterministic.
359-
api.registerTool({
384+
//
385+
// Registered as a factory (`(ctx) => toolDef`) rather than a bare
386+
// tool object so the SDK's OpenClawPluginToolContext is available.
387+
// We read `ctx.messageChannel` once at tool-instantiation and close
388+
// over it; every subsequent `execute()` forwards the same channel to
389+
// the server for channel-aware report formatting. The factory is
390+
// re-invoked per tool-collection event (session start, agent spawn),
391+
// so long-running sessions that outlive a channel switch get the
392+
// refreshed channel automatically.
393+
api.registerTool((toolCtx: PluginToolContext) => ({
360394
name: "kilocode_security_advisor",
361395
description:
362396
"Run a comprehensive security checkup of this OpenClaw instance. " +
@@ -379,10 +413,11 @@ export default definePluginEntry({
379413
parameters: {},
380414
async execute() {
381415
const apiBase = resolveApiBase(pluginConfig);
382-
const markdown = await runFlowSafe(api, apiBase);
416+
const channel = normalizeChannel(toolCtx.messageChannel);
417+
const markdown = await runFlowSafe(api, apiBase, channel);
383418
return toolResult(markdown);
384419
},
385-
});
420+
}));
386421

387422
// Entry point 2: slash command for deterministic invocation that
388423
// bypasses the LLM. When the user types /security-checkup in a
@@ -394,9 +429,10 @@ export default definePluginEntry({
394429
description:
395430
"Run a KiloCode security checkup of this OpenClaw instance and display the full report.",
396431
acceptsArgs: false,
397-
handler: async (_ctx: unknown) => {
432+
handler: async (ctx: PluginCommandContext) => {
398433
const apiBase = resolveApiBase(pluginConfig);
399-
const markdown = await runFlowSafe(api, apiBase);
434+
const channel = normalizeChannel(ctx.channel);
435+
const markdown = await runFlowSafe(api, apiBase, channel);
400436
return { text: markdown };
401437
},
402438
});

src/client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ export interface SubmitAuditPayload {
3434
method: "plugin" | "api" | "webhook" | "cloud-agent";
3535
pluginVersion?: string;
3636
openclawVersion?: string;
37+
/**
38+
* Chat surface that invoked the plugin (e.g. "control-ui", "telegram",
39+
* "slack", "discord", "kilocode-chat"). Sent when the plugin SDK exposes
40+
* it — from `PluginCommandContext.channel` on the slash-command path and
41+
* `OpenClawPluginToolContext.messageChannel` on the tool/natural-language
42+
* path. The server uses this to pick a channel-appropriate format (e.g.
43+
* collapsible `<details>` blocks on capable surfaces, flat markdown on
44+
* Telegram/Slack). Older servers that don't know this field just drop
45+
* it during zod parse — no coordinated release needed.
46+
*/
47+
channel?: string;
3748
};
3849
}
3950

0 commit comments

Comments
 (0)