Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- 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.
- First-time device auth no longer triggers a brief gateway restart after the token is captured. The plugin now registers `reload.noopPrefixes` for `plugins.entries.openclaw-security-advisor.config.authToken`, so the SecretRef patch written to `openclaw.json` after device auth is classified as a noop by the gateway reload planner instead of falling through to the default `plugins.* → restart` rule. The security checkup report is returned in the same response with no connection interruption. Scope is intentionally limited to the `authToken` field — `apiBaseUrl` and other config changes still take effect via the normal restart path.
- Release workflow: consolidated post-publish git/GitHub operations into a single atomic step with retries, eliminating a race condition where the version bump commit and tag could be pushed separately. Registry verification is now informational-only and never blocks tag/release steps.
- Release workflow: added a `Reconcile latest dist-tag` step that automatically repoints `npm dist-tags.latest` back to the highest stable version after a dev publish, preventing npm's first-publish auto-assign behavior from routing plain `npm install` users to a prerelease.
Expand Down
16 changes: 10 additions & 6 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async function runSecurityAdvisorFlow(
const configToken = api.pluginConfig?.authToken;
if (typeof configToken === "string" && configToken.length > 0) {
try {
return await doCheckup(apiBase, configToken);
return await doCheckup(api, apiBase, configToken);
} catch (err) {
if (err instanceof AuthExpiredError) {
return (
Expand All @@ -144,7 +144,7 @@ async function runSecurityAdvisorFlow(
const envToken = resolveEnvToken();
if (envToken) {
try {
return await doCheckup(apiBase, envToken);
return await doCheckup(api, apiBase, envToken);
} catch (err) {
if (err instanceof AuthExpiredError) {
return (
Expand All @@ -164,7 +164,7 @@ async function runSecurityAdvisorFlow(
const savedToken = await readTokenFromFile();
if (savedToken) {
try {
return await doCheckup(apiBase, savedToken);
return await doCheckup(api, apiBase, savedToken);
} catch (err) {
if (!(err instanceof AuthExpiredError)) throw err;
await clearStoredToken();
Expand All @@ -190,7 +190,7 @@ async function runSecurityAdvisorFlow(
// subsequent invocations skip device auth and go straight to Path B.
const reportMarkdown = await (async (): Promise<string> => {
try {
return await doCheckup(apiBase, pollResult.token);
return await doCheckup(api, apiBase, pollResult.token);
} catch (err) {
if (err instanceof AuthExpiredError) {
// Edge case: server approved the token but immediately
Expand Down Expand Up @@ -269,7 +269,11 @@ async function runSecurityAdvisorFlow(
);
}

async function doCheckup(apiBase: string, token: string): Promise<string> {
async function doCheckup(
api: PluginApi,
apiBase: string,
token: string,
): Promise<string> {
const auditResult = await runAudit();
if (!auditResult.ok) {
return auditResult.error;
Expand All @@ -281,7 +285,7 @@ async function doCheckup(apiBase: string, token: string): Promise<string> {
audit: auditResult.audit,
publicIp,
source: {
platform: detectPlatform(),
platform: detectPlatform(api.runtime.config.loadConfig()),
method: "plugin",
pluginVersion: PLUGIN_VERSION,
},
Expand Down
70 changes: 68 additions & 2 deletions src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,73 @@
* network send as potential credential harvesting. By keeping the env
* read here and the network send in audit.ts, we stay on the safe
* side of that check.
*
* Detection walks multiple independent signals in order of decreasing
* reliability across deployment age. The goal is that at least one
* signal fires on every KiloClaw instance ever deployed, regardless
* of whether the instance predates a given env var. Any hit short-
* circuits to "kiloclaw".
*
* Ordering (stopping at the first hit):
* 2. openclaw.json has `plugins.entries["kiloclaw-customizer"].enabled`
* truthy — the kiloclaw controller writes this at boot for every
* kiloclaw instance, predating any of the env-var signals. Most
* durable universal signal today.
* 3. openclaw.json `plugins.load.paths` contains the kiloclaw
* customizer install path — same writer, redundant cross-check.
* 4. `process.env.KILOCLAW_SANDBOX_ID` is set — present on every
* kiloclaw instance since 2026-03-22.
* 5. `process.env.KILOCODE_FEATURE === "kiloclaw"` — the original
* env-var signal, present on kiloclaw since 2026-02-17.
*
* We intentionally do NOT add a loose `KILOCLAW_*`-prefix heuristic;
* the four signals above are precise and one of them will hit on any
* real kiloclaw deployment.
*/
export function detectPlatform(): "kiloclaw" | "openclaw" {
return process.env.KILOCODE_FEATURE === "kiloclaw" ? "kiloclaw" : "openclaw";

export type Platform = "kiloclaw" | "openclaw";

const CUSTOMIZER_ID = "kiloclaw-customizer";
const CUSTOMIZER_LOAD_PATH =
"/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer";

export function detectPlatform(
config: unknown,
env: NodeJS.ProcessEnv = process.env,
): Platform {
if (hasKiloclawCustomizerEntry(config)) return "kiloclaw";
if (hasKiloclawCustomizerLoadPath(config)) return "kiloclaw";
if (hasKiloclawSandboxIdEnv(env)) return "kiloclaw";
if (hasKilocodeFeatureEnv(env)) return "kiloclaw";
return "openclaw";
}

function hasKiloclawCustomizerEntry(config: unknown): boolean {
const entry = getPath(config, ["plugins", "entries", CUSTOMIZER_ID]);
if (!entry || typeof entry !== "object") return false;
const enabled = (entry as Record<string, unknown>).enabled;
return enabled === true;
}

function hasKiloclawCustomizerLoadPath(config: unknown): boolean {
const paths = getPath(config, ["plugins", "load", "paths"]);
return Array.isArray(paths) && paths.includes(CUSTOMIZER_LOAD_PATH);
}

function hasKiloclawSandboxIdEnv(env: NodeJS.ProcessEnv): boolean {
const v = env.KILOCLAW_SANDBOX_ID;
return typeof v === "string" && v.length > 0;
}

function hasKilocodeFeatureEnv(env: NodeJS.ProcessEnv): boolean {
return env.KILOCODE_FEATURE === "kiloclaw";
}

function getPath(root: unknown, path: string[]): unknown {
let cur: unknown = root;
for (const key of path) {
if (!cur || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
127 changes: 127 additions & 0 deletions test/platform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, test, expect } from "bun:test";
import { detectPlatform } from "../src/platform";

const CUSTOMIZER_LOAD_PATH =
"/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer";

// Every test passes an explicit `env` so tests never accidentally
// inherit KILOCODE_FEATURE / KILOCLAW_SANDBOX_ID from the developer's
// shell (which would mask an openclaw-classification bug).
const EMPTY_ENV: NodeJS.ProcessEnv = {};

describe("detectPlatform", () => {
describe("returns openclaw when no signals hit", () => {
test("null config, empty env", () => {
expect(detectPlatform(null, EMPTY_ENV)).toBe("openclaw");
});

test("undefined config, empty env", () => {
expect(detectPlatform(undefined, EMPTY_ENV)).toBe("openclaw");
});

test("empty object config, empty env", () => {
expect(detectPlatform({}, EMPTY_ENV)).toBe("openclaw");
});

test("unrelated plugin entries, empty env", () => {
const cfg = {
plugins: {
entries: {
brave: { enabled: true },
openai: { enabled: true },
},
load: { paths: ["/some/other/path"] },
},
};
expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw");
});

test("customizer entry present but disabled does not count", () => {
const cfg = {
plugins: {
entries: {
"kiloclaw-customizer": { enabled: false },
},
},
};
expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw");
});

test("KILOCLAW_SANDBOX_ID set to empty string does not count", () => {
expect(detectPlatform(null, { KILOCLAW_SANDBOX_ID: "" })).toBe(
"openclaw",
);
});

test("KILOCODE_FEATURE set to some other value does not count", () => {
expect(detectPlatform(null, { KILOCODE_FEATURE: "something-else" })).toBe(
"openclaw",
);
});
});

describe("returns kiloclaw on any single signal hit", () => {
test("signal 2: plugins.entries.kiloclaw-customizer.enabled === true", () => {
const cfg = {
plugins: { entries: { "kiloclaw-customizer": { enabled: true } } },
};
expect(detectPlatform(cfg, EMPTY_ENV)).toBe("kiloclaw");
});

test("signal 3: plugins.load.paths contains the customizer path", () => {
const cfg = {
plugins: { load: { paths: [CUSTOMIZER_LOAD_PATH] } },
};
expect(detectPlatform(cfg, EMPTY_ENV)).toBe("kiloclaw");
});

test("signal 4: KILOCLAW_SANDBOX_ID env", () => {
expect(
detectPlatform(null, { KILOCLAW_SANDBOX_ID: "sandbox-abc123" }),
).toBe("kiloclaw");
});

test("signal 5: KILOCODE_FEATURE=kiloclaw env", () => {
expect(detectPlatform(null, { KILOCODE_FEATURE: "kiloclaw" })).toBe(
"kiloclaw",
);
});
});

describe("short-circuits on the first hit", () => {
test("customizer entry hits even if env vars are absent", () => {
const cfg = {
plugins: { entries: { "kiloclaw-customizer": { enabled: true } } },
};
expect(detectPlatform(cfg, EMPTY_ENV)).toBe("kiloclaw");
});

test("env-only hit works when config is absent (older deployments)", () => {
expect(detectPlatform(null, { KILOCODE_FEATURE: "kiloclaw" })).toBe(
"kiloclaw",
);
});
});

describe("defensive against malformed config", () => {
test("plugins.entries missing is safe", () => {
const cfg = { plugins: {} };
expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw");
});

test("plugins.entries is a non-object is safe", () => {
const cfg = { plugins: { entries: "not-an-object" } };
expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw");
});

test("plugins.load.paths is a non-array is safe", () => {
const cfg = { plugins: { load: { paths: "not-an-array" } } };
expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw");
});

test("deeply nested non-object path is safe", () => {
const cfg = { plugins: { entries: { "kiloclaw-customizer": "scalar" } } };
expect(detectPlatform(cfg, EMPTY_ENV)).toBe("openclaw");
});
});
});
Loading