feat(open-swe): let any org member post to a thread, with attribution (#1594)

* feat(dashboard): let any org member post to a thread, with attribution

Posting to an Agents chat thread from the web UI was restricted to the
thread owner. Open it to any authenticated org member (login is already
org-gated by OAuth) on both write paths — the queued follow-up
(send_dashboard_message) and the idle-thread run.start
(_enrich_run_start_command). Non-owner messages are prefixed with the
poster's verified GitHub login (@login:) so the agent and owner can tell
who sent them. Thread management (cancel/delete/resolve) stays owner-only,
and the UI now shows the composer to non-owners.

* fix(dashboard): keep non-run.start commands owner-only

Non-owner posting is allowed only via the attributed run.start path. Other
write commands (e.g. input.respond) carry unattributed user input, so the
commands proxy keeps them owner-only instead of readable-by-any-org-member.

* docs(e2e): drop per-test details from the E2E README
This commit is contained in:
Ramon Nogueira 2026-06-23 14:06:53 -04:00 • committed by GitHub
parent 450a25d33f
commit 6117780982
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 279 additions and 92 deletions

View file

@ -248,6 +248,18 @@ def _assert_thread_owner(metadata: dict[str, Any], login: str, email: str | None
raise HTTPException(404, "thread not found")
def _attribution_prefix(metadata: dict[str, Any], login: str, email: str | None) -> str:
"""Attribution prefix for a message; empty when the poster owns the thread.
Teammates can post into any surfaced-source thread (read access is already
org-gated). Their messages are tagged with the verified session login so the
agent and the thread owner can tell who sent them.
"""
if _user_owns_thread(metadata, login, email):
return ""
return f"@{login}: "
def _thread_is_readable(metadata: dict[str, Any]) -> bool:
"""Any surfaced-source thread is readable by authenticated users.
@ -1006,6 +1018,28 @@ def _command_message_content(params: dict[str, Any]) -> Any:
return last.get("content") if isinstance(last, dict) else None
def _set_command_last_message_content(params: dict[str, Any], content: Any) -> None:
run_input = params.get("input")
if not isinstance(run_input, dict):
return
messages = run_input.get("messages")
if not isinstance(messages, list) or not messages:
return
last = messages[-1]
if isinstance(last, dict):
last["content"] = content
def _prefix_message_content(content: Any, prefix: str) -> Any:
if not prefix:
return content
if isinstance(content, str):
return f"{prefix}{content}"
if isinstance(content, list):
return [{"type": "text", "text": prefix.rstrip()}, *content]
return content
def _command_prompt_text(content: Any) -> str:
if isinstance(content, str):
return content.strip()
@ -1062,6 +1096,7 @@ async def _enrich_run_start_command(
metadata: dict[str, Any],
thread_busy: bool = False,
creating: bool = False,
email: str | None = None,
) -> dict[str, Any]:
if command.get("method") != "run.start":
return command
@ -1114,6 +1149,9 @@ async def _enrich_run_start_command(
overrides["agent_effort"] = chosen_effort
else:
_validate_command_images(content, model_id=chosen_model or _metadata_model_id(metadata))
prefix = _attribution_prefix(metadata, login, email)
if prefix:
_set_command_last_message_content(params, _prefix_message_content(content, prefix))
metadata_update: dict[str, Any] = {}
if chosen_model and chosen_effort:
overrides["agent_model_id"] = chosen_model
@ -1159,9 +1197,9 @@ async def send_dashboard_message(
raise HTTPException(404, "thread not found") from exc
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
_assert_thread_owner(metadata, login, email)
_assert_thread_readable(metadata)
prompt = body.content.strip()
prompt = f"{_attribution_prefix(metadata, login, email)}{body.content.strip()}"
now_ms = _now_ms()
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
metadata_update: dict[str, Any] = {"source": _DASHBOARD_SOURCE, "updated_at_ms": now_ms}
@ -1426,9 +1464,11 @@ async def proxy_dashboard_thread_commands(
# The dashboard mints the thread id client-side and submits straight away,
# so the very first ``run.start`` may target a thread that doesn't exist
# yet. That command lazily creates + stamps + owns the thread (in
# ``_enrich_run_start_command``); any other command against a missing
# thread — or a command from a non-owner against an existing thread — is a
# 404.
# ``_enrich_run_start_command``); any other command against a missing thread
# is a 404. On an existing thread, ``run.start`` (the posting path) is open
# to any org member and attributed in ``_enrich_run_start_command``; every
# other write command carries unattributed input (e.g. ``input.respond``),
# so it stays owner-only.
method = parsed.get("method")
try:
thread = await langgraph_client().threads.get(thread_id)
@ -1444,7 +1484,10 @@ async def proxy_dashboard_thread_commands(
thread_busy = False
else:
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
_assert_thread_owner(metadata, login, email)
if method == "run.start":
_assert_thread_readable(metadata)
else:
_assert_thread_owner(metadata, login, email)
metadata_run_status = metadata.get("latest_run_status")
thread_busy = _thread_is_busy(thread) or metadata_run_status in {"pending", "running"}
@ -1458,6 +1501,7 @@ async def proxy_dashboard_thread_commands(
metadata=metadata,
thread_busy=thread_busy,
creating=creating,
email=email,
)
outgoing = json.dumps(enriched).encode()

View file

@ -42,29 +42,15 @@ so what Playwright asserts on is exactly what the real agent produced.
- `static/{slack,github}.html` — the mock Slack/GitHub UIs (external SaaS we can't
run locally). The dashboard is **not** mocked — it's the real `ui/` app.
- `global-setup.ts` — builds the real `ui/` SPA (once) so the harness can serve it.
- `tests/full_flow.spec.ts` — Slack → implement → PR → reply.
- `tests/dashboard.spec.ts` — the Slack → web handoff (below).
## Slack → web handoff (dashboard.spec.ts) — the REAL ui/ app
## The dashboard — the real `ui/` app
After the Slack run, the bot posts an "Open in Web" link
(`DASHBOARD_BASE_URL/agents/{thread_id}`). The test clicks that real link, which
loads the **actual built `ui/` React app** — served same-origin from the harness
so the session cookie and `/dashboard/api/*` calls work without CORS. The signed
session cookie is real (minted via `/control/login`), so the per-user
authorization is genuine:
- **Same user** (session email = the Slack triggerer = thread owner): the real
`AgentThreadView` shows the transcript (incl. the PR link), the `AgentPromptBar`
composer is present, and submitting a follow-up streams a new agent reply into
the same thread.
- **Different user** (any other org login): the same transcript renders, but the
real UI shows **no composer** (`AgentThreadView` gates it on `thread.isOwner`).
Ownership is by `github_login` / `triggering_user_email` on the thread metadata;
`GET /dashboard/api/threads/{id}` returns `isOwner`, which the real UI uses to
gate the composer. The only extra fake here is the OAuth-token store (an external
credential); the authorization logic itself is real.
The dashboard is **not** mocked. The bot's "Open in Web" link
(`DASHBOARD_BASE_URL/agents/{thread_id}`) loads the **actual built `ui/` React
app** — served same-origin from the harness so the session cookie and
`/dashboard/api/*` calls work without CORS. The signed session cookie is real
(minted via `/control/login`), so per-user authorization is genuine; the only
extra fake is the OAuth-token store (an external credential).
The UI is built by `global-setup.ts` with `VITE_DASHBOARD_API_BASE_URL` pointed at
the harness. It builds once; set `E2E_FORCE_UI_BUILD=1` to rebuild (e.g. after a

View file

@ -56,15 +56,29 @@ test.describe("Slack → web handoff (real dashboard UI)", () => {
await expect(page.getByRole("link", { name: "Add greet() helper" }).first()).toBeVisible();
});
test("a DIFFERENT user sees the thread read-only (no composer)", async ({ page }) => {
test("a DIFFERENT user can post, and their message is attributed", async ({ page }) => {
await loginAs(page, OTHER_USER);
await openThreadViaSlackLink(page);
// The same thread + transcript is visible…
await expectTranscriptVisible(page);
// …but a non-owner gets no composer.
await expect(page.getByPlaceholder("Add a follow up")).toHaveCount(0);
await expect(page.getByPlaceholder("Send the first message")).toHaveCount(0);
await expect(page.getByLabel("Send message")).toHaveCount(0);
// …and a non-owner now gets a composer too (owner-only restriction removed).
const composer = page.getByPlaceholder(/Add a follow up|Send the first message/);
await expect(composer).toBeVisible();
// Posting starts a new run — the agent's follow-up reply streams in.
await composer.fill("Can you also add a docstring?");
await composer.press("Enter");
await expect(page.getByText(/anything else you'd like changed/)).toBeVisible();
// The non-owner's message is tagged server-side with their GitHub login, so
// the owner can tell who sent it. Visible once the transcript re-hydrates.
await expect(async () => {
await page.reload();
await expect(
page.getByText(new RegExp(`@${OTHER_USER.login}`)).first(),
).toBeVisible({ timeout: 8000 });
}).toPass({ timeout: 60000 });
});
});

View file

@ -324,26 +324,83 @@ async def test_proxy_commands_lazily_creates_missing_thread_only_for_run_start(
assert exc_info.value.status_code == 404
async def test_proxy_commands_run_start_by_non_owner_is_rejected(monkeypatch) -> None:
class OwnedThreads:
async def get(self, thread_id: str) -> dict[str, object]:
return {
"thread_id": thread_id,
"metadata": {"source": "dashboard", "github_login": "owner"},
}
async def test_enrich_run_start_command_attributes_non_owner_message(monkeypatch) -> None:
class FakeThreads:
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
pass
class OwnedClient:
threads = OwnedThreads()
class FakeClient:
threads = FakeThreads()
monkeypatch.setattr(thread_api, "langgraph_client", lambda: OwnedClient())
async def fake_get_profile(login: str) -> dict[str, object]:
return {}
# An existing thread owned by someone else is never lazily re-created — a
# run.start from a non-owner is a 404, not a takeover.
with pytest.raises(HTTPException) as exc_info:
await thread_api.proxy_dashboard_thread_commands(
"tid", "intruder", b'{"method": "run.start"}'
)
assert exc_info.value.status_code == 404
async def fake_ensure_token(login: str) -> None:
pass
async def fake_resolve_email(login: str, profile: dict[str, object]) -> str:
return f"{login}@example.com"
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
command = {
"method": "run.start",
"params": {"input": {"messages": [{"role": "user", "content": "fix the bug"}]}},
}
enriched = await thread_api._enrich_run_start_command(
"tid",
"teammate",
command,
metadata={"source": "dashboard", "github_login": "owner"},
email="teammate@example.com",
)
# A non-owner's message is forwarded but tagged with their login.
last = enriched["params"]["input"]["messages"][-1]
assert last["content"] == "@teammate: fix the bug"
async def test_enrich_run_start_command_does_not_attribute_owner_message(monkeypatch) -> None:
class FakeThreads:
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
pass
class FakeClient:
threads = FakeThreads()
async def fake_get_profile(login: str) -> dict[str, object]:
return {}
async def fake_ensure_token(login: str) -> None:
pass
async def fake_resolve_email(login: str, profile: dict[str, object]) -> str:
return f"{login}@example.com"
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
command = {
"method": "run.start",
"params": {"input": {"messages": [{"role": "user", "content": "fix the bug"}]}},
}
enriched = await thread_api._enrich_run_start_command(
"tid",
"owner",
command,
metadata={"source": "dashboard", "github_login": "owner"},
email="owner@example.com",
)
last = enriched["params"]["input"]["messages"][-1]
assert last["content"] == "fix the bug"
async def test_enrich_run_start_command_allowlists_client_configurable(monkeypatch) -> None:
@ -431,8 +488,31 @@ async def test_proxy_commands_rejects_non_object_body(monkeypatch) -> None:
assert exc_info.value.status_code == 400
async def test_proxy_endpoints_enforce_thread_ownership(monkeypatch) -> None:
"""Write endpoints (commands, run_cancel) still require thread ownership."""
async def test_proxy_commands_non_run_start_by_non_owner_is_rejected(monkeypatch) -> None:
"""Non-owners may only post via the attributed run.start path; other write
commands (e.g. input.respond) carry unattributed input and stay owner-only."""
class OwnedThreads:
async def get(self, thread_id: str) -> dict[str, object]:
return {
"thread_id": thread_id,
"metadata": {"source": "dashboard", "github_login": "owner"},
}
class OwnedClient:
threads = OwnedThreads()
monkeypatch.setattr(thread_api, "langgraph_client", lambda: OwnedClient())
with pytest.raises(HTTPException) as exc_info:
await thread_api.proxy_dashboard_thread_commands(
"tid", "intruder", b'{"method": "input.respond"}'
)
assert exc_info.value.status_code == 404
async def test_run_cancel_enforces_thread_ownership(monkeypatch) -> None:
"""Cancelling a run still requires thread ownership (it is not "posting")."""
class FakeThreads:
async def get(self, thread_id: str) -> dict[str, object]:
@ -447,10 +527,6 @@ async def test_proxy_endpoints_enforce_thread_ownership(monkeypatch) -> None:
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
with pytest.raises(HTTPException) as exc_info:
await thread_api.proxy_dashboard_thread_commands("tid", "intruder", b"{}")
assert exc_info.value.status_code == 404
with pytest.raises(HTTPException) as exc_info:
await thread_api.proxy_dashboard_thread_run_cancel("tid", "run-1", "intruder")
assert exc_info.value.status_code == 404
@ -559,6 +635,78 @@ async def test_send_dashboard_message_returns_502_when_activity_unknown(monkeypa
assert exc_info.value.status_code == 502
async def test_send_dashboard_message_attributes_non_owner(monkeypatch) -> None:
captured: dict[str, object] = {}
class FakeThreads:
async def get(self, thread_id: str) -> dict[str, object]:
return {
"thread_id": "tid",
"metadata": {"source": "dashboard", "github_login": "owner"},
}
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
pass
class FakeClient:
threads = FakeThreads()
async def active(thread_id: str) -> bool:
return True
async def fake_queue(thread_id: str, payload: dict[str, object]) -> bool:
captured["payload"] = payload
return True
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
monkeypatch.setattr(thread_api, "get_thread_active_status", active)
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue)
await thread_api.send_dashboard_message(
"tid",
"teammate",
thread_api.ThreadMessageBody(content="ship it"),
)
assert captured["payload"]["text"] == "@teammate: ship it"
async def test_send_dashboard_message_does_not_attribute_owner(monkeypatch) -> None:
captured: dict[str, object] = {}
class FakeThreads:
async def get(self, thread_id: str) -> dict[str, object]:
return {
"thread_id": "tid",
"metadata": {"source": "dashboard", "github_login": "owner"},
}
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
pass
class FakeClient:
threads = FakeThreads()
async def active(thread_id: str) -> bool:
return True
async def fake_queue(thread_id: str, payload: dict[str, object]) -> bool:
captured["payload"] = payload
return True
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
monkeypatch.setattr(thread_api, "get_thread_active_status", active)
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue)
await thread_api.send_dashboard_message(
"tid",
"owner",
thread_api.ThreadMessageBody(content="ship it"),
)
assert captured["payload"]["text"] == "ship it"
def test_thread_summary_exposes_resolved_state() -> None:
summary = thread_api._thread_summary(
{

View file

@ -24,7 +24,6 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
const sendMessage = useSubmitAgentMessage(thread.id)
const stream = useAgentThreadStream()
const isMobile = useIsMobile()
const isReadOnly = thread.isOwner === false
const { models, defaultSelection } = useModelOptions()
const threadSelection = useMemo<ModelSelection | null>(() => {
@ -78,42 +77,10 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
settingUpSandbox={settingUpSandbox}
contentWidthClass="max-w-3xl"
/>
{!isReadOnly && (
<div className="shrink-0 px-4 pb-4">
<div className="mx-auto w-full min-w-0 max-w-3xl">
<AgentPromptBar
placeholder="Add a follow up"
compact
busy={isStreaming}
onSubmit={(content, images) =>
sendMessage.mutateAsync({
content,
images,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
})
}
models={models}
selection={activeSelection}
onSelectionChange={setSelection}
/>
</div>
</div>
)}
</div>
) : isHydrating ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<p className="text-xs text-[var(--ui-text-dim)]">Loading conversation…</p>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<p className="text-xs text-[var(--ui-text-dim)]">
This thread has no messages yet.
</p>
{!isReadOnly && (
<div className="w-full max-w-3xl">
<div className="shrink-0 px-4 pb-4">
<div className="mx-auto w-full min-w-0 max-w-3xl">
<AgentPromptBar
placeholder="Send the first message"
placeholder="Add a follow up"
compact
busy={isStreaming}
onSubmit={(content, images) =>
@ -129,7 +96,35 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
onSelectionChange={setSelection}
/>
</div>
)}
</div>
</div>
) : isHydrating ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<p className="text-xs text-[var(--ui-text-dim)]">Loading conversation…</p>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<p className="text-xs text-[var(--ui-text-dim)]">
This thread has no messages yet.
</p>
<div className="w-full max-w-3xl">
<AgentPromptBar
placeholder="Send the first message"
compact
busy={isStreaming}
onSubmit={(content, images) =>
sendMessage.mutateAsync({
content,
images,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
})
}
models={models}
selection={activeSelection}
onSelectionChange={setSelection}
/>
</div>
</div>
)}
</div>