Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
38329e8
feat(onboard): add managed startup profile schema
ericksoa Jul 30, 2026
7f3859a
docs(onboard): clarify startup profile transport encoding
ericksoa Jul 30, 2026
32561a9
fix(onboard): harden managed startup profile
ericksoa Jul 30, 2026
087a9a5
chore(stack): sync PR3.2 with current main
ericksoa Jul 30, 2026
e1845dd
fix(onboard): close startup profile contract gaps
ericksoa Jul 30, 2026
244e3e6
chore(stack): sync PR3.2 with current main
ericksoa Jul 30, 2026
2403316
chore(stack): sync PR3.2 with current main
ericksoa Jul 30, 2026
ea5bd8a
chore(stack): sync PR3.2 with current main
ericksoa Jul 31, 2026
357081a
feat(onboard): construct managed startup profiles
ericksoa Jul 31, 2026
25d32e0
feat(onboard): map and coordinate startup profiles
ericksoa Jul 31, 2026
094d177
feat(onboard): apply startup profiles in managed images
ericksoa Jul 31, 2026
2a048ae
chore(stack): restack PR3.3 on merged PR3.2
ericksoa Jul 31, 2026
498e521
chore(stack): restack PR3.4a on PR3.3
ericksoa Jul 31, 2026
3206926
chore(stack): restack PR3.4b on PR3.4a
ericksoa Jul 31, 2026
6da5eb2
test(onboard): keep managed startup tests branchless
ericksoa Jul 31, 2026
bb7a260
chore(stack): restack PR3.4a after PR3.3 merge
ericksoa Jul 31, 2026
fafdbd1
chore(stack): restack PR3.4b after PR3.3 merge
ericksoa Jul 31, 2026
b14013f
test(onboard): cover managed startup fixture CLI
ericksoa Jul 31, 2026
46fbe19
feat(onboard): apply startup profiles in managed images
ericksoa Jul 31, 2026
c95bdf7
fix(onboard): serialize managed startup transactions
ericksoa Jul 31, 2026
3f3bdbb
feat(onboard): apply startup profiles in managed images
ericksoa Jul 31, 2026
7d1668b
test(onboard): keep transaction races branchless
ericksoa Jul 31, 2026
540494c
chore(stack): inherit PR3.4a guardrail fix
ericksoa Jul 31, 2026
3a03eeb
merge: resolve conflicts with main
github-actions[bot] Jul 31, 2026
deb1581
chore(stack): refresh PR3.4b validation
ericksoa Jul 31, 2026
e41521b
chore(stack): refresh PR3.4b on current main
ericksoa Jul 31, 2026
13932b9
chore(stack): refresh PR3.4b on current main
ericksoa Jul 31, 2026
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
4 changes: 4 additions & 0 deletions ci/env-var-doc-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,9 @@
{
"name": "NEMOCLAW_DOCKER_GPU_PATCH_NETWORK",
"reason": "Internal one-process handoff from Docker GPU patch preparation into sandbox creation. Rebuild scopes and restores it; users must not set it."
},
{
"name": "NEMOCLAW_MANAGED_HERMES_HASH_B64",
"reason": "Internal one-process transport from the root image applicator to its sandbox-owned Hermes compatibility-hash writer. The value is a bounded base64-encoded hash receipt that only the private internal writer consumes; users must not set it."
}
]
140 changes: 140 additions & 0 deletions scripts/lib/entrypoint-env-wrapper.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Normalize OpenShell's sandbox-create command when an OCI runtime invokes the
# image ENTRYPOINT with the literal argv:
#
# env NAME=value ... nemoclaw-start [agent command...]
#
# This runs before any managed-startup gate. Only environment names emitted by
# NemoClaw's launch renderer are promoted into the root entrypoint process;
# interpreter/loader variables such as NODE_OPTIONS, BASH_ENV, PATH, and
# LD_PRELOAD therefore cannot be smuggled into the trusted profile applicator.
#
# Result: NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV contains the command tail.
nemoclaw_normalize_entrypoint_env_wrapper() {
NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV=("$@")
NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGC="$#"
[ "$#" -gt 0 ] || return 0

case "$1" in
nemoclaw-start | /usr/local/bin/nemoclaw-start)
shift
NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV=("$@")
NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGC="$#"
return 0
;;
env) ;;
*) return 0 ;;
esac

local -a _nemoclaw_original_argv=("$@")
local -a _nemoclaw_assignments=()
local _nemoclaw_self_index=-1
local _nemoclaw_index
local _nemoclaw_token
local _nemoclaw_name
local _nemoclaw_seen_names="|"

# Locate only the exact self-wrapper grammar. A normal explicit command such
# as `env FOO=bar printenv` remains a user command and is not interpreted by
# this root entrypoint normalization.
for ((_nemoclaw_index = 1; _nemoclaw_index < ${#_nemoclaw_original_argv[@]}; _nemoclaw_index += 1)); do
_nemoclaw_token="${_nemoclaw_original_argv[$_nemoclaw_index]}"
case "$_nemoclaw_token" in
nemoclaw-start | /usr/local/bin/nemoclaw-start)
_nemoclaw_self_index="$_nemoclaw_index"
break
;;
*=*) ;;
*) break ;;
esac
done

if [ "$_nemoclaw_self_index" -lt 0 ]; then
# A managed handoff must never silently degrade into an unmanaged command
# because the self-wrapper was absent or malformed.
for _nemoclaw_token in "${_nemoclaw_original_argv[@]:1}"; do
case "$_nemoclaw_token" in
NEMOCLAW_STARTUP_PROFILE_B64=* | NEMOCLAW_CORPORATE_CA_B64=*)
printf '%s\n' \
'[SECURITY] Malformed managed startup env wrapper; expected nemoclaw-start after assignments.' >&2
return 1
;;
esac
done
return 0
fi

if [ "$_nemoclaw_self_index" -gt 65 ]; then
printf '%s\n' '[SECURITY] Managed startup env wrapper has too many assignments.' >&2
return 1
fi

for ((_nemoclaw_index = 1; _nemoclaw_index < _nemoclaw_self_index; _nemoclaw_index += 1)); do
_nemoclaw_token="${_nemoclaw_original_argv[$_nemoclaw_index]}"
_nemoclaw_name="${_nemoclaw_token%%=*}"
if [ "${#_nemoclaw_token}" -gt 122880 ] \
|| [[ ! "$_nemoclaw_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
|| [[ "$_nemoclaw_token" == *$'\n'* ]] \
|| [[ "$_nemoclaw_token" == *$'\r'* ]]; then
printf '%s\n' '[SECURITY] Managed startup env wrapper contains a malformed assignment.' >&2
return 1
fi
case "$_nemoclaw_name" in
AWS_EC2_METADATA_DISABLED | \
CHAT_UI_URL | \
HTTP_PROXY | HTTPS_PROXY | NO_PROXY | \
http_proxy | https_proxy | no_proxy | \
OPENCLAW_HOME | OPENCLAW_STATE_DIR | OPENCLAW_WORKSPACE_DIR | \
NEMOCLAW_AUTO_PAIR_DEADLINE_SECS | \
NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS | \
NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS | \
NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS | \
NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS | \
NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS | \
NEMOCLAW_CORPORATE_CA_B64 | \
NEMOCLAW_DASHBOARD_BIND | NEMOCLAW_DASHBOARD_PORT | \
NEMOCLAW_EXTRA_PLACEHOLDER_KEYS | \
NEMOCLAW_HERMES_DASHBOARD | \
NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT | \
NEMOCLAW_HERMES_DASHBOARD_PORT | \
NEMOCLAW_HERMES_DASHBOARD_TUI | \
NEMOCLAW_MINIMAL_BOOTSTRAP | \
NEMOCLAW_OBSERVABILITY | \
NEMOCLAW_PROXY_HOST | NEMOCLAW_PROXY_PORT | \
NEMOCLAW_SANDBOX_NAME | \
NEMOCLAW_STARTUP_PROFILE_B64) ;;
*)
printf '%s\n' \
"[SECURITY] Managed startup env wrapper contains unsupported variable '${_nemoclaw_name}'." >&2
return 1
;;
esac
case "$_nemoclaw_seen_names" in
*"|${_nemoclaw_name}|"*)
printf '%s\n' \
"[SECURITY] Managed startup env wrapper repeats variable '${_nemoclaw_name}'." >&2
return 1
;;
esac
_nemoclaw_assignments+=("$_nemoclaw_token")
_nemoclaw_seen_names="${_nemoclaw_seen_names}${_nemoclaw_name}|"
done

# Export only after the complete vector has passed validation so malformed
# input cannot leave a partially mutated root process.
if [ "$_nemoclaw_self_index" -gt 1 ]; then
for _nemoclaw_token in "${_nemoclaw_assignments[@]}"; do
export "${_nemoclaw_token?}"
done
fi
# shellcheck disable=SC2034 # output array is consumed by the sourcing entrypoint
NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV=(
"${_nemoclaw_original_argv[@]:$((_nemoclaw_self_index + 1))}"
)
# shellcheck disable=SC2034 # output count is consumed by the sourcing entrypoint
NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGC=$((\
${#_nemoclaw_original_argv[@]} - _nemoclaw_self_index - 1))
}
62 changes: 41 additions & 21 deletions src/lib/messaging/applier/build/messaging-build-applier.mts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import { telegramManifest } from "../../channels/telegram/manifest.ts";
import { wechatManifest } from "../../channels/wechat/manifest.ts";
import { whatsappManifest } from "../../channels/whatsapp/manifest.ts";
import type { ChannelAgentPackageRuntimeLockSpec, ChannelManifest } from "../../manifest/types.ts";
import {
selectActiveMessagingChannelIds,
selectEnabledMessagingAgentRender,
selectEnabledPostAgentInstallBuildFiles,
} from "../../post-agent-install-selection.ts";

type Env = Record<string, string | undefined>;
type JsonObject = Record<string, any>;
Expand Down Expand Up @@ -341,19 +346,7 @@ export function applyMessagingAgentRenderToLocalFiles(

export function activeChannels(plan: MessagingBuildPlan | null): string[] {
if (!plan) return [];
const seen = new Set<string>();
const channels: string[] = [];
for (const item of plan.channels) {
const channel = String(item.channelId || "")
.trim()
.toLowerCase();
if (!channel || seen.has(channel)) continue;
if (item.active === true && item.disabled !== true) {
seen.add(channel);
channels.push(channel);
}
}
return channels;
return selectActiveMessagingChannelIds(plan);
}

export function messagingRuntimePlanPath(env: Env = process.env): string {
Expand Down Expand Up @@ -974,17 +967,17 @@ function resolveAgentRenderTarget(
}

function enabledAgentRender(plan: MessagingBuildPlan): MessagingRenderEntry[] {
const active = new Set(activeChannels(plan));
return plan.agentRender.filter(
(render) => render.agent === plan.agent && active.has(render.channelId),
);
return selectEnabledMessagingAgentRender(plan);
}

function enabledBuildStepsForPhase(
plan: MessagingBuildPlan | null,
phase: MessagingHookPhase,
): MessagingBuildStep[] {
if (!plan) return [];
if (phase === "post-agent-install") {
return selectEnabledPostAgentInstallBuildFiles(plan);
}
return enabledBuildSteps(plan).filter((step) => buildStepMatchesPhase(plan, step, phase));
}

Expand Down Expand Up @@ -1714,11 +1707,25 @@ function formatError(error: unknown): string {

export type MessagingBuildPhase = "runtime-setup" | "agent-install" | "post-agent-install";

export interface MessagingBuildPhaseOptions {
/**
* A managed image already contains the reviewed capability union. Apply only
* the explicit render and build-file plan to its durable home directory.
*/
readonly managedStartupRuntime?: boolean;
}

export function applyMessagingBuildPhase(
plan: MessagingBuildPlan | null,
phase: MessagingBuildPhase,
env: Env = process.env,
options: MessagingBuildPhaseOptions = {},
): readonly string[] {
if (options.managedStartupRuntime && phase !== "post-agent-install") {
throw new MessagingBuildApplierError(
"Managed startup runtime mode is only valid for post-agent-install",
);
}
if (phase === "runtime-setup") {
const target = writeMessagingRuntimePlanArtifact(plan, messagingRuntimePlanPath(env));
return target ? [target] : [];
Expand All @@ -1732,7 +1739,7 @@ export function applyMessagingBuildPhase(
...applyPostAgentInstallBuildFilesToLocalFiles(plan),
];
const appliedTargets = applyPostAgentInstallOutputs();
if (plan?.agent === "openclaw") {
if (plan?.agent === "openclaw" && !options.managedStartupRuntime) {
runOpenClawMessagingDoctor(plan, env);
return uniqueStrings([...appliedTargets, ...applyPostAgentInstallOutputs()]);
}
Expand Down Expand Up @@ -1802,30 +1809,36 @@ export function describeMessagingBuildPhase(
}

export function main(argv: readonly string[] = process.argv.slice(2)): void {
const { agent, phase, dryRun } = parseMessagingBuildArgs(argv);
const { agent, phase, dryRun, managedStartupRuntime } = parseMessagingBuildArgs(argv);
const plan = readMessagingBuildPlanFromEnv(process.env, agent);
if (dryRun) {
console.log(JSON.stringify(describeMessagingBuildPhase(plan, phase, process.env), null, 2));
return;
}
applyMessagingBuildPhase(plan, phase, process.env);
applyMessagingBuildPhase(plan, phase, process.env, { managedStartupRuntime });
}

function parseMessagingBuildArgs(argv: readonly string[]): {
readonly agent: MessagingAgentId;
readonly phase: MessagingBuildPhase;
readonly dryRun: boolean;
readonly managedStartupRuntime: boolean;
} {
let agent: MessagingAgentId | undefined;
let phase: MessagingBuildPhase | undefined;
let dryRun = false;
let managedStartupRuntime = false;

for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--dry-run") {
dryRun = true;
continue;
}
if (arg === "--managed-startup-runtime") {
managedStartupRuntime = true;
continue;
}
if (arg === "--agent") {
agent = readAgentArg(argv[index + 1]);
index += 1;
Expand All @@ -1851,10 +1864,17 @@ function parseMessagingBuildArgs(argv: readonly string[]): {
throw new MessagingBuildApplierError(`Unknown messaging build applier argument: ${arg}`);
}

const resolvedPhase = phase ?? "post-agent-install";
if (managedStartupRuntime && resolvedPhase !== "post-agent-install") {
throw new MessagingBuildApplierError(
"--managed-startup-runtime requires --phase post-agent-install",
);
}
return {
agent: agent ?? "openclaw",
phase: phase ?? "post-agent-install",
phase: resolvedPhase,
dryRun,
managedStartupRuntime,
};
}

Expand Down
77 changes: 77 additions & 0 deletions src/lib/messaging/post-agent-install-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

interface SelectionHook {
readonly id: string;
readonly phase: string;
}

interface SelectionChannel {
readonly channelId: string;
readonly active?: boolean;
readonly disabled?: boolean;
readonly hooks?: readonly SelectionHook[];
}

interface SelectionPlanBase {
readonly channels: readonly SelectionChannel[];
}

/**
* Canonical active-channel selection for the image applier. Each selection
* consumer must resolve the same active channels and mutable outputs.
*/
export function selectActiveMessagingChannelIds(plan: SelectionPlanBase): string[] {
const seen = new Set<string>();
const channels: string[] = [];
for (const item of plan.channels) {
const channel = String(item.channelId || "")
.trim()
.toLowerCase();
if (!channel || seen.has(channel)) continue;
if (item.active === true && item.disabled !== true) {
seen.add(channel);
channels.push(channel);
}
}
return channels;
}
Comment on lines +4 to +38

@coderabbitai coderabbitai Bot Jul 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the existing enabled-channel helpers and compare their filter semantics.
set -uo pipefail
rg -nP --type=ts -C 12 '\b(enabledPlanChannels|filterEnabledPlanEntries)\s*(<|\()' src/lib/messaging
rg -nP --type=ts -C 4 '\b(enabledPlanChannels|filterEnabledPlanEntries)\s*\(' src | rg -v '\.test\.'

Repository: NVIDIA/NemoClaw

Length of output: 23824


🏁 Script executed:

#!/bin/bash
set -uo pipefail
printf '%s\n' '--- target file ---'
cat -n src/lib/messaging/post-agent-install-selection.ts
printf '%s\n' '--- usages and imports ---'
rg -n -C 8 'selectActiveMessagingChannelIds|post-agent-install-selection|getActiveChannelIdsFromPlan|disabledChannels' src/lib/messaging
printf '%s\n' '--- manifest declarations ---'
rg -n -C 8 'interface SandboxMessaging(ChannelPlan|Plan)|type SandboxMessaging(ChannelPlan|Plan)|disabledChannels' src/lib/messaging/manifest*

Repository: NVIDIA/NemoClaw

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -uo pipefail
printf '%s\n' '--- target imports/usages outside its declaration ---'
rg -n 'post-agent-install-selection|selectActiveMessagingChannelIds|selectEnabledMessagingAgentRender|selectEnabledPostAgentInstallBuildFiles' --glob '!src/lib/messaging/post-agent-install-selection.ts' .
printf '%s\n' '--- shared filter and plan types ---'
cat -n src/lib/messaging/applier/plan-filter.ts
rg -n -C 4 'export interface SandboxMessaging(ChannelPlan|Plan)|export type MessagingChannelId|disabledChannels:' src/lib/messaging/manifest.ts
printf '%s\n' '--- all active-channel helper definitions ---'
rg -n -C 6 'function (getActiveChannelIdsFromPlan|enabledPlanChannels|selectActiveMessagingChannelIds)|export function (getActiveChannelIdsFromPlan|enabledPlanChannels|selectActiveMessagingChannelIds)' src/lib/messaging

Repository: NVIDIA/NemoClaw

Length of output: 6881


🏁 Script executed:

#!/bin/bash
set -uo pipefail
printf '%s\n' '--- build-applier call sites ---'
sed -n '330,360p' src/lib/messaging/applier/build/messaging-build-applier.mts
sed -n '950,990p' src/lib/messaging/applier/build/messaging-build-applier.mts
printf '%s\n' '--- behavioral comparison ---'
python3 - <<'PY'
def local_selector(plan):
    seen, out = set(), []
    for item in plan["channels"]:
        channel = str(item.get("channelId") or "").strip().lower()
        if not channel or channel in seen:
            continue
        if item.get("active") is True and item.get("disabled") is not True:
            seen.add(channel)
            out.append(channel)
    return out

def shared_selector(plan):
    disabled = set(plan["disabledChannels"])
    return [
        item["channelId"]
        for item in plan["channels"]
        if item.get("active") and not item.get("disabled") and item["channelId"] not in disabled
    ]

cases = [
    {
        "name": "disabledChannels-only exclusion",
        "plan": {
            "channels": [{"channelId": "telegram", "active": True, "disabled": False}],
            "disabledChannels": ["telegram"],
        },
    },
    {
        "name": "normal active channel",
        "plan": {
            "channels": [{"channelId": "telegram", "active": True, "disabled": False}],
            "disabledChannels": [],
        },
    },
]
for case in cases:
    local = local_selector(case["plan"])
    shared = shared_selector(case["plan"])
    print(case["name"], "local=", local, "shared=", shared, "different=", local != shared)
PY

Repository: NVIDIA/NemoClaw

Length of output: 2639


Use the shared enabled-channel helper.

selectActiveMessagingChannelIds() ignores plan.disabledChannels, so build rendering can apply effects for disabled channels. Use enabledPlanChannels(plan).map(({ channelId }) => channelId) and remove the duplicate local shapes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/messaging/post-agent-install-selection.ts` around lines 4 - 38,
Update selectActiveMessagingChannelIds to return
enabledPlanChannels(plan).map(({ channelId }) => channelId), ensuring
plan.disabledChannels is respected. Remove the duplicate local interfaces and
channel-selection logic, while preserving the helper’s canonical enabled-channel
ordering.

Source: Coding guidelines

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in stacked follow-up #7976 (27c72f5f888e9301b3cce1894431ef5de3669425) so #7961’s already-green head remains stable. The focused/local validation for this exact five-file debt slice passed; exact-head GitHub qualification is running. Leaving this thread open until #7976 is green.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ericksoa, acknowledged. The fix is in stacked follow-up #7976. I will keep this thread open until the exact-head GitHub qualification completes successfully.

You are interacting with an AI system.


export function selectEnabledMessagingAgentRender<
Render extends {
readonly agent: string;
readonly channelId: string;
},
>(
plan: SelectionPlanBase & {
readonly agent: string;
readonly agentRender: readonly Render[];
},
): Render[] {
const active = new Set(selectActiveMessagingChannelIds(plan));
return plan.agentRender.filter(
(render) => render.agent === plan.agent && active.has(render.channelId),
);
}

export function selectEnabledPostAgentInstallBuildFiles<
Step extends {
readonly channelId: string;
readonly kind: string;
readonly hookId?: string;
},
>(
plan: SelectionPlanBase & {
readonly buildSteps: readonly Step[];
},
): Step[] {
const active = new Set(selectActiveMessagingChannelIds(plan));
return plan.buildSteps.filter((step) => {
if (!active.has(step.channelId) || step.kind !== "build-file") return false;
if (!step.hookId) return true;
const hookPhase = plan.channels
.find((channel) => channel.channelId === step.channelId)
?.hooks?.find((hook) => hook.id === step.hookId)?.phase;
return hookPhase === undefined || hookPhase === "post-agent-install";
});
}
Comment on lines +24 to +77

@coderabbitai coderabbitai Bot Jul 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Normalize channel ids on both sides of every comparison.

selectActiveMessagingChannelIds stores trimmed, lowercased ids at lines 28-30. The consumers compare raw ids: line 53 (active.has(render.channelId)), line 70 (active.has(step.channelId)), and line 73 (channel.channelId === step.channelId). If a plan carries a channel id with different case or surrounding whitespace in agentRender or buildSteps, the selection returns empty and the render and build-file effects are dropped without an error. Extract one normalizer and apply it to both sides.

🐛 Proposed fix for asymmetric normalization
+function normalizeChannelId(channelId: string): string {
+  return channelId.trim().toLowerCase();
+}
+
 export function selectActiveMessagingChannelIds(plan: SelectionPlanBase): string[] {
   const seen = new Set<string>();
   const channels: string[] = [];
   for (const item of plan.channels) {
-    const channel = String(item.channelId || "")
-      .trim()
-      .toLowerCase();
+    const channel = normalizeChannelId(item.channelId ?? "");
   const active = new Set(selectActiveMessagingChannelIds(plan));
   return plan.agentRender.filter(
-    (render) => render.agent === plan.agent && active.has(render.channelId),
+    (render) => render.agent === plan.agent && active.has(normalizeChannelId(render.channelId)),
   );
   const active = new Set(selectActiveMessagingChannelIds(plan));
   return plan.buildSteps.filter((step) => {
-    if (!active.has(step.channelId) || step.kind !== "build-file") return false;
+    const channelId = normalizeChannelId(step.channelId);
+    if (!active.has(channelId) || step.kind !== "build-file") return false;
     if (!step.hookId) return true;
     const hookPhase = plan.channels
-      .find((channel) => channel.channelId === step.channelId)
+      .find((channel) => normalizeChannelId(channel.channelId) === channelId)
       ?.hooks?.find((hook) => hook.id === step.hookId)?.phase;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function selectActiveMessagingChannelIds(plan: SelectionPlanBase): string[] {
const seen = new Set<string>();
const channels: string[] = [];
for (const item of plan.channels) {
const channel = String(item.channelId || "")
.trim()
.toLowerCase();
if (!channel || seen.has(channel)) continue;
if (item.active === true && item.disabled !== true) {
seen.add(channel);
channels.push(channel);
}
}
return channels;
}
export function selectEnabledMessagingAgentRender<
Render extends {
readonly agent: string;
readonly channelId: string;
},
>(
plan: SelectionPlanBase & {
readonly agent: string;
readonly agentRender: readonly Render[];
},
): Render[] {
const active = new Set(selectActiveMessagingChannelIds(plan));
return plan.agentRender.filter(
(render) => render.agent === plan.agent && active.has(render.channelId),
);
}
export function selectEnabledPostAgentInstallBuildFiles<
Step extends {
readonly channelId: string;
readonly kind: string;
readonly hookId?: string;
},
>(
plan: SelectionPlanBase & {
readonly buildSteps: readonly Step[];
},
): Step[] {
const active = new Set(selectActiveMessagingChannelIds(plan));
return plan.buildSteps.filter((step) => {
if (!active.has(step.channelId) || step.kind !== "build-file") return false;
if (!step.hookId) return true;
const hookPhase = plan.channels
.find((channel) => channel.channelId === step.channelId)
?.hooks?.find((hook) => hook.id === step.hookId)?.phase;
return hookPhase === undefined || hookPhase === "post-agent-install";
});
}
function normalizeChannelId(channelId: string): string {
return channelId.trim().toLowerCase();
}
export function selectActiveMessagingChannelIds(plan: SelectionPlanBase): string[] {
const seen = new Set<string>();
const channels: string[] = [];
for (const item of plan.channels) {
const channel = normalizeChannelId(item.channelId ?? "");
if (!channel || seen.has(channel)) continue;
if (item.active === true && item.disabled !== true) {
seen.add(channel);
channels.push(channel);
}
}
return channels;
}
export function selectEnabledMessagingAgentRender<
Render extends {
readonly agent: string;
readonly channelId: string;
},
>(
plan: SelectionPlanBase & {
readonly agent: string;
readonly agentRender: readonly Render[];
},
): Render[] {
const active = new Set(selectActiveMessagingChannelIds(plan));
return plan.agentRender.filter(
(render) => render.agent === plan.agent && active.has(normalizeChannelId(render.channelId)),
);
}
export function selectEnabledPostAgentInstallBuildFiles<
Step extends {
readonly channelId: string;
readonly kind: string;
readonly hookId?: string;
},
>(
plan: SelectionPlanBase & {
readonly buildSteps: readonly Step[];
},
): Step[] {
const active = new Set(selectActiveMessagingChannelIds(plan));
return plan.buildSteps.filter((step) => {
const channelId = normalizeChannelId(step.channelId);
if (!active.has(channelId) || step.kind !== "build-file") return false;
if (!step.hookId) return true;
const hookPhase = plan.channels
.find((channel) => normalizeChannelId(channel.channelId) === channelId)
?.hooks?.find((hook) => hook.id === step.hookId)?.phase;
return hookPhase === undefined || hookPhase === "post-agent-install";
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/messaging/post-agent-install-selection.ts` around lines 24 - 77,
Extract a shared channel-id normalizer from selectActiveMessagingChannelIds and
use it consistently in selectEnabledMessagingAgentRender and
selectEnabledPostAgentInstallBuildFiles. Normalize render.channelId and
step.channelId before checking membership, and normalize both channel.channelId
and step.channelId when locating the matching channel and hook phase, while
preserving the existing filtering behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in stacked follow-up #7976 (27c72f5f888e9301b3cce1894431ef5de3669425) so #7961’s already-green head remains stable. The focused/local validation for this exact five-file debt slice passed; exact-head GitHub qualification is running. Leaving this thread open until #7976 is green.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ericksoa, acknowledged. Keep this thread open until #7976 completes GitHub qualification. The finding remains pending on that follow-up.

You are interacting with an AI system.

Loading
Loading