Skip to content

feat(channels): expose IM channel_user_id to sandbox commands as DEERFLOW_CHANNEL_USER_ID - #3926

Merged
WillemJiang merged 5 commits into
bytedance:mainfrom
fancyboi999:feat/3914-channel-user-id
Jul 4, 2026
Merged

WillemJiang merged 5 commits into
bytedance:mainfrom
fancyboi999:feat/3914-channel-user-id

Conversation

@fancyboi999

Copy link
Copy Markdown
Collaborator

Problem

Skills running for IM-channel users cannot see who is talking to them. The channel workers already write the sender's raw platform id (channel_user_id: Feishu open_id, Slack Uxxx, ...) into the run's body.context, but the Gateway's merge_run_context_overrides whitelist drops it, and nothing exposes it to sandbox commands. Gap 3 of #3914.

Solution

Two halves, following the design converged on in the issue thread:

  1. Gateway passthrough — merge_run_context_overrides forwards channel_user_id into the runtime context only, symmetric with the existing user_id handling: setdefault (a server-stamped value wins over the client-supplied one) and never into configurable, which is checkpointed with the thread.
  2. Sandbox exposure — bash_tool prepends a shell-quoted export DEERFLOW_CHANNEL_USER_ID=<id>; to the command string.

Why a command prefix and not execute_command(env=...): the env= channel is reserved for request-scoped secrets (#3861/#3871) and has a hard side effect on AioSandbox — any non-empty env switches execution to the bash.exec API, which (a) 404s on sandbox images older than 1.9.3 (the #3921/#3922 capability gap: every IM bash command would have failed on the default image) and (b) abandons the persistent-shell session semantics. An export prefix keeps the legacy shell path byte-identical for non-IM runs, is fine to have visible in audit logs (an identifier, not a secret), and is per-call correct in group chats, where one thread/sandbox is shared by senders with different platform ids.

Also from the pre-PR verification pass:

  • Subagent propagation: task_tool captures the dispatching turn's channel_user_id and the subagent executor forwards it into the subagent's runtime context (mirroring the guardrail-attribution fields), so bash commands delegated via task keep the sender identity.
  • Length cap: body.context is client-writable on web requests, so values over 256 chars are ignored rather than bloating every command string.
  • Windows local sandbox: injection is skipped (os.name == "nt") because the local sandbox may fall back to PowerShell/cmd.exe, where POSIX export is not valid syntax.

Trust note (documented in AGENTS.md): the var is informational, never authorization-grade — any bash command can overwrite it and web clients can set body.context.channel_user_id. Skills must not treat it as authenticated identity.

Validation

Unit tests (12 new in tests/test_channel_user_id_env.py + 1 each in test_task_tool_core_logic.py / test_subagent_executor.py; all failed on main before the implementation):

$ uv run pytest tests/test_channel_user_id_env.py tests/test_task_tool_core_logic.py \
    tests/test_subagent_executor.py tests/test_gateway_services.py \
    tests/test_sandbox_tools_security.py tests/test_skill_request_scoped_secrets.py -q
350+ passed (all affected suites)
$ make lint
All checks passed!

Full backend suite: 5793 passed; the only 7 failures are pre-existing on clean main (verified by reverting the changed files in-place and re-running: identical failures).

Real-channel evidence — the actual bash_tool driven against a real Local sandbox and a real AIO container (image 1.11.0):

== Local + channel_user_id: identity=[ou_feishu_e2e_001]
== Local + no channel ctx  : identity=[]
== AIO   + channel_user_id: identity=[U_SLACK_E2E_002]
== AIO   + sender B (group): identity=[U_SLACK_E2E_003]   # per-call identity, same sandbox
== AIO   + no channel ctx  : identity=[]                  # no leakage across calls

Independent adversarial verification (fresh reviewer agent, pre-PR) — all contract items passed:

  • Identity never rides env=: all identity calls hit the sandbox with env=None; combined identity + active-skill-secrets kept secrets in env (masked in output) and identity in the command string.
  • Hostile id (x'; touch /tmp/pwned; ' + backticks + $(id) + newline) against the real container: no canary file created, $(id) not executed, value round-tripped verbatim — shlex.quote held.
  • channel_user_id never enters configurable; non-IM commands are byte-for-byte unchanged.
  • End-to-end chain traced in code: channels/manager.py → runs.stream/wait(context=...) → merge_run_context_overrides → worker._build_runtime_context → runtime.context → bash_tool.

Not verified (fail-loud): real Windows host behavior (unit-simulated only via the _is_windows seam); a true platform-webhook-to-sandbox IM run (each link verified individually — code-traced up to runtime.context, live from there to the sandbox — but no real Feishu/Slack message was driven end to end); provisioner/K8s sandbox flavor (the change is sandbox-agnostic string prepending).

Part of #3914 (gap 3). Gap 1 (autonomous skill-invocation secrets binding) stays open pending maintainer input on the binding-point question; gap 2 was withdrawn by the issue author.

…W_CHANNEL_USER_ID

IM-channel skills need the sender's platform identity (Feishu open_id,
Slack Uxxx, ...). The channel manager already writes channel_user_id into
body.context, but the Gateway whitelist dropped it. Forward it into the
runtime context only (never configurable, which is checkpointed), and have
bash_tool export it as a fixed env var through a shell-quoted command
prefix.

The identity deliberately does not ride execute_command(env=...): that
channel is reserved for request-scoped secrets, and a non-empty env
switches AioSandbox onto the bash.exec path (fresh session per call,
image >= 1.9.3 required), which would have broken every IM bash command
on older sandbox images and abandoned persistent-shell semantics on new
ones. A command-string export keeps the legacy path, stays visible in
audit logs (it is an identifier, not a secret), and gives per-call
correctness in group chats where one thread and sandbox are shared by
senders with different platform ids.

Skipped on the Windows local sandbox, whose PowerShell/cmd.exe fallback
has no POSIX export.

Part of bytedance#3914
Review findings from the pre-PR verification pass:

- Subagent delegation dropped the sender identity: task_tool now captures
  channel_user_id from the parent runtime context and the executor
  forwards it into the subagent's context, mirroring the guardrail
  attribution fields (user_role/oauth_*/run_id). Without this, bash
  commands delegated via task lost the group-chat sender's id.
- body.context is client-writable on web requests, so values over 256
  chars are ignored instead of bloating every command string sent to the
  sandbox.
@github-actions github-actions Bot added area:agents Agents, subagents, graph wiring, prompts, langgraph.json area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only area:sandbox Sandboxed execution and docker/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/L PR changes 300-700 lines labels Jul 3, 2026
@WillemJiang
WillemJiang requested a review from Copilot July 4, 2026 00:46

Copilot AI left a comment

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.

Pull request overview

This PR adds end-to-end propagation of IM-channel sender identity (channel_user_id) from incoming IM runs into the runtime context and exposes it to sandbox bash commands via DEERFLOW_CHANNEL_USER_ID, including propagation through task delegation to subagents. This fits the DeerFlow harness/gateway boundary by keeping the identifier in runtime-only context (not checkpointed state) while enabling skills/scripts to read the sender identity inside the sandbox.

Changes:

  • Gateway: forward body.context.channel_user_id into config["context"] (runtime context only, setdefault semantics; not written to configurable).
  • Sandbox: prepend an identity export prefix for DEERFLOW_CHANNEL_USER_ID to bash commands (with quoting + length cap + Windows-local-sandbox skip).
  • Subagents: capture channel_user_id in task_tool and propagate it into the subagent’s runtime context; add unit tests covering the propagation chain.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
backend/app/gateway/services.py Forwards channel_user_id from body.context into runtime context only.
backend/packages/harness/deerflow/sandbox/tools.py Exposes channel_user_id to bash commands via DEERFLOW_CHANNEL_USER_ID prefix logic.
backend/packages/harness/deerflow/tools/builtins/task_tool.py Captures and forwards channel_user_id into subagent executor kwargs.
backend/packages/harness/deerflow/subagents/executor.py Accepts channel_user_id and propagates it into subagent execution context.
backend/tests/test_channel_user_id_env.py Adds unit tests for gateway passthrough + bash-tool identity exposure behavior.
backend/tests/test_task_tool_core_logic.py Adds a test ensuring task_tool forwards channel_user_id to the executor.
backend/tests/test_subagent_executor.py Adds a test ensuring executor propagates channel_user_id into subagent context.
backend/AGENTS.md Documents the new propagation and exposure behavior and its trust model.

Comment thread backend/packages/harness/deerflow/sandbox/tools.py
Comment thread backend/tests/test_channel_user_id_env.py
Comment thread backend/tests/test_channel_user_id_env.py
Comment thread backend/packages/harness/deerflow/sandbox/tools.py
…egardless of AIO session persistence

Review (willem-bd): the identity export could leak across senders in a
shared group-chat AIO sandbox. The AIO no-env path reuses a persistent
shell session (the class-lock reason, bytedance#1433), and the 256-char/type guard
made some commands carry no prefix — so a dropped-id command could resolve
the id a previous sender exported.

Make per-call correctness independent of session semantics: an IM-channel
command (channel_user_id present in context) now always carries an explicit
prefix — export VAR=<quoted> for a valid id, or unset VAR for an unusable
one (empty / non-str / over the cap). Non-IM runs (no key) are untouched.
A prefix unset has none of the '& ; unset' suffix hazard raised earlier.

Verified on a real AIO 1.11.0 container: the no-id shell path auto-creates
a session per call (does not persist today), but an explicit shared session
DOES persist (export stale-A -> readback [stale-A]); the unset prefix
clears it (-> []). So the fix holds even on an image whose no-id path
persists. Regression tests cover the dropped-id group-chat window and the
non-IM passthrough.

Part of bytedance#3914
…rn shape

The merge from main changed task_tool to return a Command(update=...)
instead of a plain string; update the assertion to extract the tool
message via the existing _task_tool_message helper, matching the sibling
tests. Fixes the CI backend-unit-tests failure introduced by the merge.
@WillemJiang
WillemJiang merged commit 576577b into bytedance:main Jul 4, 2026
14 checks passed
@WillemJiang WillemJiang added this to the 2.1.0 milestone Jul 4, 2026
marvin9551 pushed a commit to marvin9551/deer-flow that referenced this pull request Aug 21, 2026
…FLOW_CHANNEL_USER_ID (bytedance#3926)

* feat(channels): expose channel_user_id to sandbox commands as DEERFLOW_CHANNEL_USER_ID

IM-channel skills need the sender's platform identity (Feishu open_id,
Slack Uxxx, ...). The channel manager already writes channel_user_id into
body.context, but the Gateway whitelist dropped it. Forward it into the
runtime context only (never configurable, which is checkpointed), and have
bash_tool export it as a fixed env var through a shell-quoted command
prefix.

The identity deliberately does not ride execute_command(env=...): that
channel is reserved for request-scoped secrets, and a non-empty env
switches AioSandbox onto the bash.exec path (fresh session per call,
image >= 1.9.3 required), which would have broken every IM bash command
on older sandbox images and abandoned persistent-shell semantics on new
ones. A command-string export keeps the legacy path, stays visible in
audit logs (it is an identifier, not a secret), and gives per-call
correctness in group chats where one thread and sandbox are shared by
senders with different platform ids.

Skipped on the Windows local sandbox, whose PowerShell/cmd.exe fallback
has no POSIX export.

Part of bytedance#3914

* feat(channels): propagate channel_user_id to subagents; cap value length

Review findings from the pre-PR verification pass:

- Subagent delegation dropped the sender identity: task_tool now captures
  channel_user_id from the parent runtime context and the executor
  forwards it into the subagent's context, mirroring the guardrail
  attribution fields (user_role/oauth_*/run_id). Without this, bash
  commands delegated via task lost the group-chat sender's id.
- body.context is client-writable on web requests, so values over 256
  chars are ignored instead of bloating every command string sent to the
  sandbox.

* fix(channels): set-or-unset channel_user_id so identity is per-call regardless of AIO session persistence

Review (willem-bd): the identity export could leak across senders in a
shared group-chat AIO sandbox. The AIO no-env path reuses a persistent
shell session (the class-lock reason, bytedance#1433), and the 256-char/type guard
made some commands carry no prefix — so a dropped-id command could resolve
the id a previous sender exported.

Make per-call correctness independent of session semantics: an IM-channel
command (channel_user_id present in context) now always carries an explicit
prefix — export VAR=<quoted> for a valid id, or unset VAR for an unusable
one (empty / non-str / over the cap). Non-IM runs (no key) are untouched.
A prefix unset has none of the '& ; unset' suffix hazard raised earlier.

Verified on a real AIO 1.11.0 container: the no-id shell path auto-creates
a session per call (does not persist today), but an explicit shared session
DOES persist (export stale-A -> readback [stale-A]); the unset prefix
clears it (-> []). So the fix holds even on an image whose no-id path
persists. Regression tests cover the dropped-id group-chat window and the
non-IM passthrough.

Part of bytedance#3914

* test(channels): align channel_user_id task test with new Command return shape

The merge from main changed task_tool to return a Command(update=...)
instead of a plain string; update the assertion to extract the tool
message via the existing _task_tool_message helper, matching the sibling
tests. Fixes the CI backend-unit-tests failure introduced by the merge.
jihtsan pushed a commit to jihtsan/dnx-deer-flow that referenced this pull request Aug 29, 2026
…FLOW_CHANNEL_USER_ID (bytedance#3926)

* feat(channels): expose channel_user_id to sandbox commands as DEERFLOW_CHANNEL_USER_ID

IM-channel skills need the sender's platform identity (Feishu open_id,
Slack Uxxx, ...). The channel manager already writes channel_user_id into
body.context, but the Gateway whitelist dropped it. Forward it into the
runtime context only (never configurable, which is checkpointed), and have
bash_tool export it as a fixed env var through a shell-quoted command
prefix.

The identity deliberately does not ride execute_command(env=...): that
channel is reserved for request-scoped secrets, and a non-empty env
switches AioSandbox onto the bash.exec path (fresh session per call,
image >= 1.9.3 required), which would have broken every IM bash command
on older sandbox images and abandoned persistent-shell semantics on new
ones. A command-string export keeps the legacy path, stays visible in
audit logs (it is an identifier, not a secret), and gives per-call
correctness in group chats where one thread and sandbox are shared by
senders with different platform ids.

Skipped on the Windows local sandbox, whose PowerShell/cmd.exe fallback
has no POSIX export.

Part of bytedance#3914

* feat(channels): propagate channel_user_id to subagents; cap value length

Review findings from the pre-PR verification pass:

- Subagent delegation dropped the sender identity: task_tool now captures
  channel_user_id from the parent runtime context and the executor
  forwards it into the subagent's context, mirroring the guardrail
  attribution fields (user_role/oauth_*/run_id). Without this, bash
  commands delegated via task lost the group-chat sender's id.
- body.context is client-writable on web requests, so values over 256
  chars are ignored instead of bloating every command string sent to the
  sandbox.

* fix(channels): set-or-unset channel_user_id so identity is per-call regardless of AIO session persistence

Review (willem-bd): the identity export could leak across senders in a
shared group-chat AIO sandbox. The AIO no-env path reuses a persistent
shell session (the class-lock reason, bytedance#1433), and the 256-char/type guard
made some commands carry no prefix — so a dropped-id command could resolve
the id a previous sender exported.

Make per-call correctness independent of session semantics: an IM-channel
command (channel_user_id present in context) now always carries an explicit
prefix — export VAR=<quoted> for a valid id, or unset VAR for an unusable
one (empty / non-str / over the cap). Non-IM runs (no key) are untouched.
A prefix unset has none of the '& ; unset' suffix hazard raised earlier.

Verified on a real AIO 1.11.0 container: the no-id shell path auto-creates
a session per call (does not persist today), but an explicit shared session
DOES persist (export stale-A -> readback [stale-A]); the unset prefix
clears it (-> []). So the fix holds even on an image whose no-id path
persists. Regression tests cover the dropped-id group-chat window and the
non-IM passthrough.

Part of bytedance#3914

* test(channels): align channel_user_id task test with new Command return shape

The merge from main changed task_tool to return a Command(update=...)
instead of a plain string; update the assertion to extract the tool
message via the existing _task_tool_message helper, matching the sibling
tests. Fixes the CI backend-unit-tests failure introduced by the merge.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agents Agents, subagents, graph wiring, prompts, langgraph.json area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only area:sandbox Sandboxed execution and docker/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/L PR changes 300-700 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants