From fe0ffea5b512d3fe9424334b3700bd8f1ab3e7ad Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 17 Aug 2026 16:59:31 -0500 Subject: [PATCH 1/3] fix(onboard): accept schema-owned messaging plan fields --- .../onboard/managed-startup-profile.test.ts | 101 ++++++++++++++++++ src/lib/onboard/managed-startup/profile.ts | 64 +++++++++-- 2 files changed, 159 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/managed-startup-profile.test.ts b/src/lib/onboard/managed-startup-profile.test.ts index 9c4f473247..da50ca1ca0 100644 --- a/src/lib/onboard/managed-startup-profile.test.ts +++ b/src/lib/onboard/managed-startup-profile.test.ts @@ -729,6 +729,107 @@ describe("managed startup profile", () => { ).toThrow(/credential-shaped field name/); }); + it("accepts schema-owned messaging package pins and credential placeholder lines (#9355)", () => { + expect(() => + validateManagedStartupProfile({ + ...OPENCLAW_PROFILE, + messaging: { + plan: { + ...OPENCLAW_PROFILE.messaging.plan, + buildSteps: [ + { + channelId: "slack", + kind: "package-install", + outputId: "slack-openclaw-plugin", + required: true, + value: { + manager: "npm", + spec: "@slack/web-api@7.9.3", + pin: true, + }, + }, + ], + agentRender: [ + ...OPENCLAW_PROFILE.messaging.plan.agentRender, + { + channelId: "slack", + agent: "hermes", + target: "~/.hermes/.env", + kind: "env-lines", + lines: [ + "SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + "DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN", + ], + templateRefs: ["credential.slackBotToken.placeholder"], + }, + ], + }, + }, + }), + ).not.toThrow(); + }); + + it.each([ + ["a raw credential", `SLACK_BOT_TOKEN=xoxb-${"a".repeat(32)}`], + ["a malformed assignment", "SLACK_BOT_TOKEN =openshell:resolve:env:SLACK_BOT_TOKEN"], + [ + "a placeholder for a different environment key", + "SLACK_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN", + ], + ])("rejects %s in messaging environment lines (#9355)", (_label, line) => { + expect(() => + validateManagedStartupProfile({ + ...OPENCLAW_PROFILE, + messaging: { + plan: { + ...OPENCLAW_PROFILE.messaging.plan, + agentRender: [ + { + channelId: "slack", + agent: "hermes", + target: "~/.hermes/.env", + kind: "env-lines", + lines: [line], + templateRefs: ["credential.slackBotToken.placeholder"], + }, + ], + }, + }, + }), + ).toThrow(/credential-shaped string data/); + }); + + it.each([ + [ + "a package pin outside buildSteps[*].value", + { + ...OPENCLAW_PROFILE.messaging.plan, + buildSteps: [{ pin: true }], + }, + ], + [ + "a non-boolean package pin", + { + ...OPENCLAW_PROFILE.messaging.plan, + buildSteps: [{ value: { pin: "true" } }], + }, + ], + [ + "a credential placeholder assignment outside agentRender[*].lines[*]", + { + ...OPENCLAW_PROFILE.messaging.plan, + note: "SLACK_BOT_TOKEN=openshell:resolve:env:SLACK_BOT_TOKEN", + }, + ], + ])("rejects %s (#9355)", (_label, plan) => { + expect(() => + validateManagedStartupProfile({ + ...OPENCLAW_PROFILE, + messaging: { plan }, + }), + ).toThrow(/credential-shaped/); + }); + it.each([ ["routed inference", "inference", "routedBaseUrl"], ["upstream inference", "inference", "upstreamEndpointUrl"], diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index 6601bf3b59..078906e4a6 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -59,6 +59,7 @@ const NON_SECRET_KEY_METADATA_NAMES = new Set([ ]); const MESSAGING_CREDENTIAL_PLACEHOLDER_RE = /^(?:openshell:resolve:env:|[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-)(?:v[0-9]+_)?[A-Z][A-Z0-9_]*$/u; +const JSON_ARRAY_INDEX_SEGMENT_RE = /^\[(?:0|[1-9][0-9]*)\]$/u; const SECRET_VALUE_PATTERNS: readonly RegExp[] = [ /nvapi-[A-Za-z0-9_-]{10,}/u, /nvcf-[A-Za-z0-9_-]{10,}/u, @@ -1002,6 +1003,54 @@ function isMessagingCredentialPlaceholder(path: readonly string[], value: unknow ); } +function messagingCredentialPlaceholderEnvKey(value: string): string | null { + if (!MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value)) return null; + const marker = value.startsWith("openshell:resolve:env:") + ? "openshell:resolve:env:" + : "-OPENSHELL-RESOLVE-ENV-"; + const key = value.slice(value.indexOf(marker) + marker.length); + return key.replace(/^v[0-9]+_/u, ""); +} + +function containsMessagingCredentialPlaceholder(value: string): boolean { + return value.includes("openshell:resolve:env:") || value.includes("-OPENSHELL-RESOLVE-ENV-"); +} + +function isMessagingCredentialPlaceholderAssignment( + path: readonly string[], + value: string, +): boolean { + if ( + path.length !== 6 || + path[0] !== "messaging" || + path[1] !== "plan" || + path[2] !== "agentRender" || + !JSON_ARRAY_INDEX_SEGMENT_RE.test(path[3] ?? "") || + path[4] !== "lines" || + !JSON_ARRAY_INDEX_SEGMENT_RE.test(path[5] ?? "") + ) { + return false; + } + const separator = value.indexOf("="); + if (separator <= 0 || value.indexOf("=", separator + 1) !== -1) return false; + const envKey = value.slice(0, separator); + const placeholderEnvKey = messagingCredentialPlaceholderEnvKey(value.slice(separator + 1)); + return CREDENTIAL_ENV_NAME_PATTERN.test(envKey) && envKey === placeholderEnvKey; +} + +function isMessagingPackagePin(path: readonly string[], value: unknown): boolean { + return ( + path.length === 6 && + path[0] === "messaging" && + path[1] === "plan" && + path[2] === "buildSteps" && + JSON_ARRAY_INDEX_SEGMENT_RE.test(path[3] ?? "") && + path[4] === "value" && + path[5] === "pin" && + typeof value === "boolean" + ); +} + function containsUrlWithCredentialMaterial(value: string): boolean { const candidates = value.match(URL_CANDIDATE_RE) ?? []; for (let index = 0; index < candidates.length; index += 1) { @@ -1367,7 +1416,9 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void { observeText(current.value); if ( !isMessagingCredentialPlaceholder(current.path, current.value) && - valueLooksLikeSecret(current.value) + !isMessagingCredentialPlaceholderAssignment(current.path, current.value) && + (valueLooksLikeSecret(current.value) || + containsMessagingCredentialPlaceholder(current.value)) ) { invalid( `payload field ${payloadPath(current.path)} contains credential-shaped string data`, @@ -1450,7 +1501,11 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void { invalid("payload must contain only JSON data properties"); } const child = descriptor.value; - if (isCredentialShapedName(key) && !isMessagingCredentialPlaceholder(current.path, child)) { + if ( + isCredentialShapedName(key) && + !isMessagingCredentialPlaceholder(current.path, child) && + !isMessagingPackagePin([...current.path, key], child) + ) { invalid( `payload field ${payloadPath([...current.path, key])} has a credential-shaped field name`, ); @@ -1775,10 +1830,7 @@ function validateInference(value: unknown, agent: ManagedStartupAgent): ManagedS if (primaryModelRef !== null || compatibility !== null || inputModalities !== null) { invalid(`${agent} does not support primaryModelRef, compatibility, or inputModalities`); } - if ( - agent === "langchain-deepagents-code" && - !isValidDcodeUpstreamProvider(upstreamProvider) - ) { + if (agent === "langchain-deepagents-code" && !isValidDcodeUpstreamProvider(upstreamProvider)) { invalid( "inference.upstreamProvider must start with an ASCII letter or digit and contain 1-64 ASCII letters, digits, dots, underscores, or hyphens for DCode", ); From 02da612ee8947463510cc5f70cb3dc28dd3a6caa Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 17 Aug 2026 17:59:30 -0500 Subject: [PATCH 2/3] fix(onboard): restrict messaging placeholder paths --- .../onboard/managed-startup-profile.test.ts | 12 ++++++++++ src/lib/onboard/managed-startup/profile.ts | 23 ++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/managed-startup-profile.test.ts b/src/lib/onboard/managed-startup-profile.test.ts index da50ca1ca0..2382f043b4 100644 --- a/src/lib/onboard/managed-startup-profile.test.ts +++ b/src/lib/onboard/managed-startup-profile.test.ts @@ -759,6 +759,7 @@ describe("managed startup profile", () => { lines: [ "SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", "DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN", + "TELEGRAM_BOT_TOKEN=openshell:resolve:env:v1_TELEGRAM_BOT_TOKEN", ], templateRefs: ["credential.slackBotToken.placeholder"], }, @@ -776,6 +777,10 @@ describe("managed startup profile", () => { "a placeholder for a different environment key", "SLACK_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN", ], + [ + "a versioned placeholder for a different environment key", + "SLACK_BOT_TOKEN=openshell:resolve:env:v1_DISCORD_BOT_TOKEN", + ], ])("rejects %s in messaging environment lines (#9355)", (_label, line) => { expect(() => validateManagedStartupProfile({ @@ -821,6 +826,13 @@ describe("managed startup profile", () => { note: "SLACK_BOT_TOKEN=openshell:resolve:env:SLACK_BOT_TOKEN", }, ], + [ + "a direct credential placeholder outside schema-owned fields", + { + ...OPENCLAW_PROFILE.messaging.plan, + note: "openshell:resolve:env:SLACK_BOT_TOKEN", + }, + ], ])("rejects %s (#9355)", (_label, plan) => { expect(() => validateManagedStartupProfile({ diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index 078906e4a6..092df52ab2 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -994,13 +994,24 @@ function valueLooksLikeSecret(value: string): boolean { } function isMessagingCredentialPlaceholder(path: readonly string[], value: unknown): boolean { - return ( - path.length >= 2 && + if (typeof value !== "string" || !MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value)) { + return false; + } + const isCredentialBindingPlaceholder = + path.length === 5 && path[0] === "messaging" && path[1] === "plan" && - typeof value === "string" && - MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value) - ); + path[2] === "credentialBindings" && + JSON_ARRAY_INDEX_SEGMENT_RE.test(path[3] ?? "") && + path[4] === "placeholder"; + const isAgentRenderValuePlaceholder = + path.length >= 5 && + path[0] === "messaging" && + path[1] === "plan" && + path[2] === "agentRender" && + JSON_ARRAY_INDEX_SEGMENT_RE.test(path[3] ?? "") && + path[4] === "value"; + return isCredentialBindingPlaceholder || isAgentRenderValuePlaceholder; } function messagingCredentialPlaceholderEnvKey(value: string): string | null { @@ -1503,7 +1514,7 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void { const child = descriptor.value; if ( isCredentialShapedName(key) && - !isMessagingCredentialPlaceholder(current.path, child) && + !isMessagingCredentialPlaceholder([...current.path, key], child) && !isMessagingPackagePin([...current.path, key], child) ) { invalid( From 512a2fc0942516fb2533252fe2bedc931992444c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 17 Aug 2026 18:12:41 -0500 Subject: [PATCH 3/3] chore(onboard): keep validator diff scoped --- src/lib/onboard/managed-startup/profile.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index 092df52ab2..d9f332ed0c 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -1841,7 +1841,10 @@ function validateInference(value: unknown, agent: ManagedStartupAgent): ManagedS if (primaryModelRef !== null || compatibility !== null || inputModalities !== null) { invalid(`${agent} does not support primaryModelRef, compatibility, or inputModalities`); } - if (agent === "langchain-deepagents-code" && !isValidDcodeUpstreamProvider(upstreamProvider)) { + if ( + agent === "langchain-deepagents-code" && + !isValidDcodeUpstreamProvider(upstreamProvider) + ) { invalid( "inference.upstreamProvider must start with an ASCII letter or digit and contain 1-64 ASCII letters, digits, dots, underscores, or hyphens for DCode", );