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
113 changes: 113 additions & 0 deletions src/lib/onboard/managed-startup-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,119 @@ 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",
"TELEGRAM_BOT_TOKEN=openshell:resolve:env:v1_TELEGRAM_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",
],
[
"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({
...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",
},
],
[
"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({
...OPENCLAW_PROFILE,
messaging: { plan },
}),
).toThrow(/credential-shaped/);
});

it.each([
["routed inference", "inference", "routedBaseUrl"],
["upstream inference", "inference", "upstreamEndpointUrl"],
Expand Down
76 changes: 71 additions & 5 deletions src/lib/onboard/managed-startup/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -993,12 +994,71 @@ function valueLooksLikeSecret(value: string): boolean {
}

function isMessagingCredentialPlaceholder(path: readonly string[], value: unknown): boolean {
if (typeof value !== "string" || !MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value)) {
return false;
}
const isCredentialBindingPlaceholder =
path.length === 5 &&
path[0] === "messaging" &&
path[1] === "plan" &&
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 {
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 >= 2 &&
path.length === 6 &&
path[0] === "messaging" &&
path[1] === "plan" &&
typeof value === "string" &&
MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value)
path[2] === "buildSteps" &&
JSON_ARRAY_INDEX_SEGMENT_RE.test(path[3] ?? "") &&
path[4] === "value" &&
path[5] === "pin" &&
typeof value === "boolean"
);
}

Expand Down Expand Up @@ -1367,7 +1427,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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
invalid(
`payload field ${payloadPath(current.path)} contains credential-shaped string data`,
Expand Down Expand Up @@ -1450,7 +1512,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, key], child) &&
!isMessagingPackagePin([...current.path, key], child)
) {
invalid(
`payload field ${payloadPath([...current.path, key])} has a credential-shaped field name`,
);
Expand Down
Loading