From 9c90ff277b998ebc1f9527f53eba9e9ea8db176b Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Sun, 28 Jun 2026 20:39:20 -0700 Subject: [PATCH 001/198] refactor: route tool shims through centaur-tools (#808) * refactor: route tool shims through centaur-tools * refactor: keep centaur-tools run as cli runner --- services/sandbox/install_tool_shims.py | 35 ++++--- services/sandbox/test_install_tool_shims.py | 106 ++++++++++++++++++++ 2 files changed, 126 insertions(+), 15 deletions(-) diff --git a/services/sandbox/install_tool_shims.py b/services/sandbox/install_tool_shims.py index ae49f1295..cdb0745e6 100644 --- a/services/sandbox/install_tool_shims.py +++ b/services/sandbox/install_tool_shims.py @@ -404,18 +404,11 @@ def _write_executable(path: Path, content: str) -> None: path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) -def _write_tool_shim(path: Path, script: dict[str, str], pythonpath: str) -> None: +def _write_tool_shim(path: Path, script: dict[str, str], _pythonpath: str) -> None: + catalog = path.parent / "centaur-tools" content = f"""#!/bin/sh set -e -_centaur_tool_pythonpath={shlex.quote(pythonpath)} -if [ -n "$_centaur_tool_pythonpath" ]; then - if [ -n "${{PYTHONPATH:-}}" ]; then - export PYTHONPATH="$_centaur_tool_pythonpath:$PYTHONPATH" - else - export PYTHONPATH="$_centaur_tool_pythonpath" - fi -fi -exec uvx --from {shlex.quote(script["project_dir"])} {shlex.quote(script["name"])} "$@" +exec {shlex.quote(str(catalog))} run {shlex.quote(script["name"])} "$@" """ _write_executable(path, content) @@ -502,15 +495,27 @@ def usage(): ''' -def call_tool(tool, method, payload): - project_dir = Path(tool["project_dir"]) - client_module = tool.get("client_module", "client.py") +def tool_env(): env = os.environ.copy() if PYTHONPATH_VALUE: if env.get("PYTHONPATH"): env["PYTHONPATH"] = f"{{PYTHONPATH_VALUE}}:{{env['PYTHONPATH']}}" else: env["PYTHONPATH"] = PYTHONPATH_VALUE + return env + + +def run_tool(tool, args): + project_dir = Path(tool["project_dir"]) + return subprocess.call( + ["uvx", "--from", str(project_dir), tool["name"], *args], + env=tool_env(), + ) + + +def call_tool(tool, method, payload): + project_dir = Path(tool["project_dir"]) + client_module = tool.get("client_module", "client.py") return subprocess.run( [ "uvx", @@ -527,7 +532,7 @@ def call_tool(tool, method, payload): check=False, text=True, capture_output=True, - env=env, + env=tool_env(), ) @@ -556,7 +561,7 @@ def main(argv): if name not in by_name: print(f"unknown tool: {{name}}", file=sys.stderr) return 1 - return subprocess.call([name, *argv[3:]]) + return run_tool(by_name[name], argv[3:]) if command == "call" and len(argv) >= 4: # Internal compatibility for Python workflow ctx.call_tool(...). Agents # should use direct tool CLIs (` --help`, ` ...`) instead. diff --git a/services/sandbox/test_install_tool_shims.py b/services/sandbox/test_install_tool_shims.py index 8f187ebf7..bd9847c9f 100644 --- a/services/sandbox/test_install_tool_shims.py +++ b/services/sandbox/test_install_tool_shims.py @@ -2,6 +2,9 @@ import contextlib import io +import json +import os +import subprocess import tempfile import unittest from pathlib import Path @@ -142,5 +145,108 @@ def test_discover_scripts_respects_blocklist(self) -> None: self.assertIn("websearch", scripts) +class GeneratedShimTest(unittest.TestCase): + def test_tool_shim_delegates_to_centaur_tools_exec(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + bin_dir = Path(tmp) + script = { + "name": "websearch", + "project_dir": "/app/tools/research/websearch", + "package": "websearch", + "entrypoint": "websearch.cli:app", + "client_module": "client.py", + } + + install_tool_shims._write_tool_shim(bin_dir / "websearch", script, "/opt/centaur") + + content = (bin_dir / "websearch").read_text() + self.assertIn(f"exec {bin_dir / 'centaur-tools'} run websearch", content) + self.assertNotIn("uvx --from", content) + self.assertNotIn("/app/tools/research/websearch", content) + + def test_centaur_tools_run_uses_catalog_entry_directly(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bin_dir = root / "bin" + fake_bin = root / "fake-bin" + project_dir = root / "tools" / "research" / "websearch" + bin_dir.mkdir() + fake_bin.mkdir() + project_dir.mkdir(parents=True) + + index_path = bin_dir / ".centaur-tools.json" + index_path.write_text( + json.dumps( + [ + { + "name": "websearch", + "project_dir": str(project_dir), + "package": "websearch", + "entrypoint": "websearch.cli:app", + "client_module": "client.py", + } + ] + ) + + "\n" + ) + install_tool_shims._write_catalog( + bin_dir / "centaur-tools", + index_path, + os.pathsep.join(["/opt/centaur", "/opt/extra"]), + ) + + uvx_log = root / "uvx.log" + pythonpath_log = root / "pythonpath.log" + fake_uvx = fake_bin / "uvx" + fake_uvx.write_text( + "#!/usr/bin/env python3\n" + "from pathlib import Path\n" + "import os\n" + "import sys\n" + "Path(os.environ['UVX_LOG']).write_text('\\n'.join(sys.argv[1:]) + '\\n')\n" + "Path(os.environ['PYTHONPATH_LOG']).write_text(os.environ.get('PYTHONPATH', ''))\n" + ) + fake_uvx.chmod(0o755) + + path_websearch = fake_bin / "websearch" + path_websearch.write_text("#!/bin/sh\nexit 42\n") + path_websearch.chmod(0o755) + + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}" + env["UVX_LOG"] = str(uvx_log) + env["PYTHONPATH_LOG"] = str(pythonpath_log) + env["PYTHONPATH"] = "existing" + + result = subprocess.run( + [str(bin_dir / "centaur-tools"), "run", "websearch", "search", "hello"], + check=False, + env=env, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + uvx_log.read_text().splitlines(), + ["--from", str(project_dir), "websearch", "search", "hello"], + ) + self.assertEqual( + pythonpath_log.read_text(), + f"/opt/centaur{os.pathsep}/opt/extra{os.pathsep}existing", + ) + + result = subprocess.run( + [str(bin_dir / "centaur-tools"), "exec", "websearch"], + check=False, + env=env, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("usage: centaur-tools", result.stderr) + + if __name__ == "__main__": unittest.main() From a78d60eeb482612f6a6fb10c3a653b83dd53c4c4 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Sun, 28 Jun 2026 21:22:00 -0700 Subject: [PATCH 002/198] Log sandbox tool shim calls (#809) feat: log tool shim calls --- services/sandbox/install_tool_shims.py | 100 +++++++++++++++----- services/sandbox/test_install_tool_shims.py | 38 +++++++- 2 files changed, 114 insertions(+), 24 deletions(-) diff --git a/services/sandbox/install_tool_shims.py b/services/sandbox/install_tool_shims.py index cdb0745e6..7a8aca6ed 100644 --- a/services/sandbox/install_tool_shims.py +++ b/services/sandbox/install_tool_shims.py @@ -422,6 +422,8 @@ def _write_catalog(path: Path, index_path: Path, pythonpath: str) -> None: from pathlib import Path import subprocess import sys +from datetime import datetime, timezone +import time INDEX = {str(index_path)!r} PYTHONPATH_VALUE = {pythonpath!r} @@ -505,35 +507,89 @@ def tool_env(): return env +def analytics_log_path(): + configured = os.environ.get("CENTAUR_TOOL_ANALYTICS_LOG_PATH") + if configured is not None: + return configured.strip() + return "/proc/1/fd/2" + + +def emit_tool_call_event(event, tool, method, started_at=None, returncode=None): + path = analytics_log_path() + if path.lower() in {{"", "0", "false", "none", "off"}}: + return + + payload = {{ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "info", + "service": "sandbox", + "component": "tool_shim", + "event": event, + "msg": f"Centaur tool shim {{event}}", + "tool_name": str(tool.get("name") or "unknown"), + "tool_method": method, + }} + thread_key = os.environ.get("CENTAUR_THREAD_KEY", "").strip() + if thread_key: + payload["thread_key"] = thread_key + if started_at is not None: + payload["duration_ms"] = round((time.monotonic() - started_at) * 1000, 3) + if returncode is not None: + payload["exit_code"] = returncode + payload["success"] = "true" if returncode == 0 else "false" + + try: + with open(path, "a", encoding="utf-8") as log_file: + log_file.write(json.dumps(payload, separators=(",", ":"), default=str) + "\\n") + except Exception: + pass + + def run_tool(tool, args): project_dir = Path(tool["project_dir"]) - return subprocess.call( - ["uvx", "--from", str(project_dir), tool["name"], *args], - env=tool_env(), - ) + started_at = time.monotonic() + emit_tool_call_event("tool_call_started", tool, "cli") + try: + returncode = subprocess.call( + ["uvx", "--from", str(project_dir), tool["name"], *args], + env=tool_env(), + ) + except Exception: + emit_tool_call_event("tool_call_completed", tool, "cli", started_at, 1) + raise + emit_tool_call_event("tool_call_completed", tool, "cli", started_at, returncode) + return returncode def call_tool(tool, method, payload): project_dir = Path(tool["project_dir"]) client_module = tool.get("client_module", "client.py") - return subprocess.run( - [ - "uvx", - "--from", - str(project_dir), - "python", - "-c", - CALL_RUNNER, - str(project_dir), - client_module, - method, - json.dumps(payload, separators=(",", ":")), - ], - check=False, - text=True, - capture_output=True, - env=tool_env(), - ) + started_at = time.monotonic() + emit_tool_call_event("tool_call_started", tool, method) + try: + result = subprocess.run( + [ + "uvx", + "--from", + str(project_dir), + "python", + "-c", + CALL_RUNNER, + str(project_dir), + client_module, + method, + json.dumps(payload, separators=(",", ":")), + ], + check=False, + text=True, + capture_output=True, + env=tool_env(), + ) + except Exception: + emit_tool_call_event("tool_call_completed", tool, method, started_at, 1) + raise + emit_tool_call_event("tool_call_completed", tool, method, started_at, result.returncode) + return result def main(argv): diff --git a/services/sandbox/test_install_tool_shims.py b/services/sandbox/test_install_tool_shims.py index bd9847c9f..09529ea59 100644 --- a/services/sandbox/test_install_tool_shims.py +++ b/services/sandbox/test_install_tool_shims.py @@ -197,6 +197,7 @@ def test_centaur_tools_run_uses_catalog_entry_directly(self) -> None: uvx_log = root / "uvx.log" pythonpath_log = root / "pythonpath.log" + analytics_log = root / "tool-analytics.log" fake_uvx = fake_bin / "uvx" fake_uvx.write_text( "#!/usr/bin/env python3\n" @@ -217,9 +218,17 @@ def test_centaur_tools_run_uses_catalog_entry_directly(self) -> None: env["UVX_LOG"] = str(uvx_log) env["PYTHONPATH_LOG"] = str(pythonpath_log) env["PYTHONPATH"] = "existing" + env["CENTAUR_THREAD_KEY"] = "cli:test-thread" + env["CENTAUR_TOOL_ANALYTICS_LOG_PATH"] = str(analytics_log) result = subprocess.run( - [str(bin_dir / "centaur-tools"), "run", "websearch", "search", "hello"], + [ + str(bin_dir / "centaur-tools"), + "run", + "websearch", + "lookup", + "sensitive-payload", + ], check=False, env=env, text=True, @@ -229,12 +238,37 @@ def test_centaur_tools_run_uses_catalog_entry_directly(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( uvx_log.read_text().splitlines(), - ["--from", str(project_dir), "websearch", "search", "hello"], + [ + "--from", + str(project_dir), + "websearch", + "lookup", + "sensitive-payload", + ], ) self.assertEqual( pythonpath_log.read_text(), f"/opt/centaur{os.pathsep}/opt/extra{os.pathsep}existing", ) + analytics_events = [ + json.loads(line) for line in analytics_log.read_text().splitlines() + ] + self.assertEqual( + [event["event"] for event in analytics_events], + ["tool_call_started", "tool_call_completed"], + ) + for event in analytics_events: + self.assertEqual(event["service"], "sandbox") + self.assertEqual(event["component"], "tool_shim") + self.assertEqual(event["tool_name"], "websearch") + self.assertEqual(event["tool_method"], "cli") + self.assertEqual(event["thread_key"], "cli:test-thread") + self.assertEqual(analytics_events[1]["exit_code"], 0) + self.assertEqual(analytics_events[1]["success"], "true") + self.assertIn("duration_ms", analytics_events[1]) + serialized_analytics = json.dumps(analytics_events, sort_keys=True) + self.assertNotIn("lookup", serialized_analytics) + self.assertNotIn("sensitive-payload", serialized_analytics) result = subprocess.run( [str(bin_dir / "centaur-tools"), "exec", "websearch"], From db23abb8a54913d8694c1ef1b72fe3b71ca32027 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:56:20 +0300 Subject: [PATCH 003/198] [codex] keep Slack handoff waits best-effort (#810) * fix slackbot best-effort handoff waits * reuse slack API timeout config * centralize slack timeout wrapper --- services/slackbotv2/src/index.ts | 185 +++++++++++++----- services/slackbotv2/src/session-api.ts | 44 ++++- .../slackbotv2/test/chat-sdk-emulate.test.ts | 82 +++++++- services/slackbotv2/test/session-api.test.ts | 19 ++ 4 files changed, 273 insertions(+), 57 deletions(-) diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 6805fbf5e..32422d9cd 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -33,7 +33,8 @@ import { serializeAttachment, serializeMessageLinks, serializeMessage, - sessionStreamError + sessionStreamError, + withSlackApiTimeout } from './session-api' import { extractMessageOverrides } from './overrides' import { isAllowedSlackMessage, isAllowedSlackWebhookBody } from './slack-events' @@ -277,20 +278,29 @@ async function handleSlackMessageHandoff( subscribe: input.subscribe === true, trigger: input.trigger }) + let initialAssistantStatusVisible = false const assistantStatus = input.assistantStatusRequested ? setInitialAssistantStatus(thread, input.options, trace) + .then(visible => { + initialAssistantStatusVisible = visible + return visible + }) : Promise.resolve(false) + if (input.assistantStatusRequested) { + backgroundWaitUntil(assistantStatus.then(() => undefined).catch(() => undefined)) + } try { if (input.subscribe) { await subscribeSlackThreadForHandoff(thread, input.options, trace, input.trigger) } - const assistantStatusVisible = await assistantStatus traceLog(input.options, 'slackbotv2_handoff_sync_starting', trace, { - initial_assistant_status_visible: assistantStatusVisible, + initial_assistant_status_deferred: + input.assistantStatusRequested && !initialAssistantStatusVisible, + initial_assistant_status_visible: initialAssistantStatusVisible, trigger: input.trigger }) await syncThreadMessageToSession(thread, message, { - initialAssistantStatusVisible: assistantStatusVisible, + initialAssistantStatusVisible, mode: input.mode, options: input.options, state: input.state @@ -303,7 +313,14 @@ async function handleSlackMessageHandoff( error: errorMessage(error), trigger: input.trigger }) - if (await assistantStatus) await setAssistantStatus(thread, '', input.options, trace) + backgroundWaitUntil( + assistantStatus + .then(visible => + visible ? setAssistantStatus(thread, '', input.options, trace) : undefined + ) + .then(() => undefined) + .catch(() => undefined) + ) throw error } } @@ -535,15 +552,21 @@ async function syncThreadMessageToSession( history_forwarded: state.historyForwarded === true }) const assistantStatusVisible = shouldStartExecution - ? input.initialAssistantStatusVisible ?? - (await setInitialAssistantStatus(thread, input.options, trace)) + ? input.initialAssistantStatusVisible === true : false + if (shouldStartExecution && input.initialAssistantStatusVisible === undefined) { + backgroundWaitUntil( + setInitialAssistantStatus(thread, input.options, trace) + .then(() => undefined) + .catch(() => undefined) + ) + } if (!shouldStartExecution && input.initialAssistantStatusVisible) { await setAssistantStatus(thread, '', input.options, trace) } const serializeStartedAtMs = nowMs() - const serializedMessage = await serializeMessage(message) + const serializedMessage = await serializeMessage(message, input.options) const overrides = extractMessageOverrides(serializedMessage.text) setMessageText(serializedMessage, overrides.cleanedText) if (overrides.harnessType || overrides.model || overrides.provider || overrides.reasoning) { @@ -563,18 +586,33 @@ async function syncThreadMessageToSession( phase_ms: elapsedMs(serializeStartedAtMs) }) let context: SlackbotV2ApiMessage[] | undefined + let contextDegraded = false if (shouldIncludeContext) { const contextStartedAtMs = nowMs() - context = shouldRefreshThreadContext - ? await collectSlackThreadContext(input.options, message) - : await collectInitialContext(thread, message) + try { + context = shouldRefreshThreadContext + ? await withSlackApiTimeout(input.options, 'collect Slack thread context', () => + collectSlackThreadContext(input.options, message) + ) + : await withSlackApiTimeout(input.options, 'collect initial thread context', () => + collectInitialContext(thread, message, input.options) + ) + } catch (error) { + contextDegraded = true + context = [serializedMessage] + traceWarn(input.options, 'slackbotv2_forward_context_degraded', trace, { + error: errorMessage(error), + phase_ms: elapsedMs(contextStartedAtMs) + }) + } // collectInitialContext re-serializes the current message; mirror the // flag-stripped text on that copy too. for (const item of context) { if (item.id === serializedMessage.id) copyMessageTextFields(item, serializedMessage) } traceLog(input.options, 'slackbotv2_forward_context_collected', trace, { + degraded: contextDegraded, message_count: context.length, phase_ms: elapsedMs(contextStartedAtMs) }) @@ -612,9 +650,28 @@ async function syncThreadMessageToSession( // The previous harness's conversation state dies with its sandbox on a // restart, so re-feed the Slack thread transcript with this turn. const handleSessionRestarted = async (): Promise => { - const history = context ?? (await collectInitialContext(thread, message)) + let history = context + let restartContextDegraded = contextDegraded + if (!history) { + const restartContextStartedAtMs = nowMs() + try { + history = await withSlackApiTimeout( + input.options, + 'collect restart thread context', + () => collectInitialContext(thread, message, input.options) + ) + } catch (error) { + restartContextDegraded = true + history = [serializedMessage] + traceWarn(input.options, 'slackbotv2_forward_restart_context_degraded', trace, { + error: errorMessage(error), + phase_ms: elapsedMs(restartContextStartedAtMs) + }) + } + } forwardInput.contextPreamble = harnessRestartPreamble(history, serializedMessage.id) traceLog(input.options, 'slackbotv2_forward_restart_context_built', trace, { + degraded: restartContextDegraded, history_message_count: history.length, preamble_chars: forwardInput.contextPreamble?.length ?? 0 }) @@ -626,7 +683,7 @@ async function syncThreadMessageToSession( for (const item of messagesToAppend) latestMessageIds.add(item.id) await thread.setState({ forwardedMessageIds: Array.from(latestMessageIds).slice(-1000), - historyForwarded: latest.historyForwarded || shouldIncludeContext, + historyForwarded: latest.historyForwarded || (shouldIncludeContext && !contextDegraded), lastEventId }) traceLog(input.options, 'slackbotv2_forward_messages_committed', trace, { @@ -1688,7 +1745,7 @@ async function renderExecutionStream( return { diverged: false } } const titleStartedAtMs = nowMs() - await setAssistantTitle(thread, titleFromMessage(promptText, options.userName)) + await setAssistantTitle(thread, titleFromMessage(promptText, options.userName), options, trace) if (!assistantStatusVisible) { await setAssistantStatus(thread, options.assistantStatus ?? 'Thinking...', options, trace) } @@ -1738,7 +1795,7 @@ async function renderRecoveredExecutionStream( return { diverged: false } } const titleStartedAtMs = nowMs() - await setAssistantTitle(thread, titleFromMessage(promptText, options.userName)) + await setAssistantTitle(thread, titleFromMessage(promptText, options.userName), options, trace) await setAssistantStatus(thread, options.assistantStatus ?? 'Thinking...', options, trace) traceLog(options, 'slackbotv2_render_slack_metadata_set', trace, { phase_ms: elapsedMs(titleStartedAtMs) @@ -1781,7 +1838,12 @@ async function renderPlainTextExecutionStream( ): Promise { const fallback = new SlackRenderFallback() const titleStartedAtMs = nowMs() - await setAssistantTitle(thread, titleFromMessage(slackMessagePromptText(message), options.userName)) + await setAssistantTitle( + thread, + titleFromMessage(slackMessagePromptText(message), options.userName), + options, + trace + ) if (!assistantStatusVisible) { await setAssistantStatus(thread, options.assistantStatus ?? 'Thinking...', options, trace) } @@ -2025,19 +2087,21 @@ async function collectSlackThreadContext( const channel = stringField(raw.channel) const threadTs = stringField(raw.thread_ts) const currentTs = stringField(raw.ts) || currentMessage.id - if (!channel || !threadTs) return [await serializeMessage(currentMessage)] + if (!channel || !threadTs) return [await serializeMessage(currentMessage, options)] const messages: SlackbotV2ApiMessage[] = [] let cursor: string | undefined do { - const response = await fetchSlackThreadReplies({ - apiUrl: options.slackApiUrl, - channel, - cursor, - limit: 200, - token: options.botToken, - ts: threadTs - }) + const response = await withSlackApiTimeout(options, 'fetch Slack thread replies', () => + fetchSlackThreadReplies({ + apiUrl: options.slackApiUrl, + channel, + cursor, + limit: 200, + token: options.botToken, + ts: threadTs + }) + ) const slackMessages = Array.isArray(response.messages) ? response.messages : [] for (const rawMessage of slackMessages) { const message = rawMessage as Record @@ -2050,7 +2114,7 @@ async function collectSlackThreadContext( } while (cursor) const currentIndex = messages.findIndex(message => message.id === currentMessage.id) - const serializedCurrent = await serializeMessage(currentMessage) + const serializedCurrent = await serializeMessage(currentMessage, options) if (currentIndex >= 0) { messages[currentIndex] = serializedCurrent } else { @@ -2112,7 +2176,7 @@ async function slackApiAttachmentsFromFiles( || stringField(rawCurrent.team_id) const attachments: SlackbotV2ApiAttachment[] = [] for (const file of files.slice(0, MAX_SLACK_MESSAGE_ATTACHMENTS)) { - attachments.push(await serializeAttachment(slackFileAttachment(options, file, teamId))) + attachments.push(await serializeAttachment(slackFileAttachment(options, file, teamId), options)) } if (files.length > MAX_SLACK_MESSAGE_ATTACHMENTS) { attachments.push({ @@ -2158,13 +2222,25 @@ function slackFileAttachment( async function fetchSlackFile(options: SlackbotV2Options, url: string): Promise { const fetchFn = options.fetch ?? fetch - const response = await fetchFn(url, { - headers: { authorization: `Bearer ${options.botToken}` } - }) - if (!response.ok) { - throw new Error(`failed to fetch Slack file: ${response.status} ${response.statusText}`) + const controller = new AbortController() + try { + const response = await withSlackApiTimeout(options, 'fetch Slack file', () => + fetchFn(url, { + headers: { authorization: `Bearer ${options.botToken}` }, + signal: controller.signal + }) + ) + if (!response.ok) { + throw new Error(`failed to fetch Slack file: ${response.status} ${response.statusText}`) + } + const body = await withSlackApiTimeout(options, 'read Slack file', () => + response.arrayBuffer() + ) + return Buffer.from(body) + } catch (error) { + controller.abort() + throw error } - return Buffer.from(await response.arrayBuffer()) } function slackFileAttachmentType(mimeType: string): Attachment['type'] { @@ -2270,7 +2346,7 @@ function rendererOptions( async onRendererEvent(event: RendererEvent) { await mapper?.onRendererEvent?.(event) if (event.type === 'renderer.title.update') { - await setAssistantTitle(thread, event.title) + await setAssistantTitle(thread, event.title, options) } } } @@ -2359,12 +2435,14 @@ async function setAssistantStatus( ) : () => undefined try { - const visible = await ignoreAssistantError(() => - adapter.setAssistantStatus!( - target.channel, - target.threadTs, - status, - status ? [status] : undefined + const visible = await withSlackApiTimeout(options, 'set assistant status', () => + ignoreAssistantError(() => + adapter.setAssistantStatus!( + target.channel, + target.threadTs, + status, + status ? [status] : undefined + ) ) ) if (options) { @@ -2383,21 +2461,38 @@ async function setAssistantStatus( phase_ms: elapsedMs(startedAtMs) }) } - throw error + return false } finally { stopPendingLog() } } -async function setAssistantTitle(thread: Thread, title: string | undefined): Promise { +async function setAssistantTitle( + thread: Thread, + title: string | undefined, + options?: SlackbotV2Options, + trace?: SlackbotV2Trace +): Promise { const normalized = title?.trim() if (!normalized) return + const startedAtMs = nowMs() const target = slackAssistantTarget(thread) const adapter = thread.adapter as SlackAssistantAdapter if (!target || !adapter.setAssistantTitle) return - await ignoreAssistantError(() => - adapter.setAssistantTitle!(target.channel, target.threadTs, clipOneLine(normalized, 80)) - ) + try { + await withSlackApiTimeout(options, 'set assistant title', () => + ignoreAssistantError(() => + adapter.setAssistantTitle!(target.channel, target.threadTs, clipOneLine(normalized, 80)) + ) + ) + } catch (error) { + if (options) { + traceWarn(options, 'slackbotv2_assistant_title_failed', trace, { + error: errorMessage(error), + phase_ms: elapsedMs(startedAtMs) + }) + } + } } async function ignoreAssistantError(fn: () => Promise): Promise { diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index cf05b816f..b8305e7bb 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -140,10 +140,18 @@ function sessionApiTimeoutMs(options: SlackbotV2Options): number { return options.sessionApiTimeoutMs ?? DEFAULT_SESSION_API_TIMEOUT_MS } -function slackApiTimeoutMs(options: SlackbotV2Options): number { +export function slackApiTimeoutMs(options: SlackbotV2Options): number { return options.slackApiTimeoutMs ?? DEFAULT_SLACK_API_TIMEOUT_MS } +export async function withSlackApiTimeout( + options: SlackbotV2Options | undefined, + action: string, + fn: () => Promise +): Promise { + return options ? withTimeout(action, slackApiTimeoutMs(options), fn) : fn() +} + type ForwardSessionApiCallbacks = { onExecutionStarted?(execution: SlackbotV2ExecuteSessionResponse): Promise onMessagesAppended?(): Promise @@ -158,7 +166,8 @@ type ForwardSessionApiCallbacks = { export async function collectInitialContext( thread: { allMessages: AsyncIterable }, - currentMessage: Message + currentMessage: Message, + options?: SlackbotV2Options ): Promise { const messages: Message[] = [] try { @@ -167,7 +176,7 @@ export async function collectInitialContext( } } catch (error) { if (!isSlackThreadNotFoundError(error)) throw error - return [await serializeMessage(currentMessage)] + return [await serializeMessage(currentMessage, options)] } const currentIndex = messages.findIndex(message => message.id === currentMessage.id) @@ -179,7 +188,7 @@ export async function collectInitialContext( const serialized: SlackbotV2ApiMessage[] = [] for (const message of messages) { - serialized.push(await serializeMessage(message)) + serialized.push(await serializeMessage(message, options)) } return serialized } @@ -196,10 +205,13 @@ function isSlackThreadNotFoundError(error: unknown): boolean { return error instanceof Error && error.message.includes('thread_not_found') } -export async function serializeMessage(message: Message): Promise { +export async function serializeMessage( + message: Message, + options?: SlackbotV2Options +): Promise { const attachments: SlackbotV2ApiAttachment[] = [] for (const attachment of message.attachments) { - attachments.push(await serializeAttachment(attachment)) + attachments.push(await serializeAttachment(attachment, options)) } const displayText = renderSlackDisplayText({ raw: message.raw, text: message.text }) @@ -512,7 +524,10 @@ export const MAX_INLINE_ATTACHMENT_BYTES = 100 * 1024 * 1024 const MAX_CODEX_INPUT_LINE_CHARS = 900 * 1024 const STAGED_ATTACHMENT_CHUNK_CHARS = 700 * 1024 -export async function serializeAttachment(attachment: Attachment): Promise { +export async function serializeAttachment( + attachment: Attachment, + options?: SlackbotV2Options +): Promise { const serialized: SlackbotV2ApiAttachment = { fetchMetadata: attachment.fetchMetadata, height: attachment.height, @@ -530,7 +545,7 @@ export async function serializeAttachment(attachment: Attachment): Promise { + if (!attachment.fetchData) return undefined + if (!options) return attachment.fetchData() + return withSlackApiTimeout(options, 'fetch Slack attachment', () => + attachment.fetchData?.() ?? Promise.resolve(undefined) + ) +} + function attachmentTooLargeError(bytes: number): string { return `attachment too large to inline (${bytes} bytes > ${MAX_INLINE_ATTACHMENT_BYTES} byte limit)` } @@ -941,7 +967,7 @@ async function slackApiGet( ): Promise { const url = slackApiMethodUrl(options.slackApiUrl, method) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value) - return withTimeout(`Slack API ${method}`, slackApiTimeoutMs(options), async () => { + return withSlackApiTimeout(options, `Slack API ${method}`, async () => { const response = await fetchWithTimeout( fetch, url, diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 09deb152b..d92c97b9b 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -294,7 +294,10 @@ describe('slackbotv2', () => { const assistantStatuses = slackApi.calls .filter(call => call.method === 'assistant.threads.setStatus') .map(call => stringField(call.body.status)) - expect(assistantStatuses).toEqual(['Thinking...', '', 'Thinking...', '']) + expect(assistantStatuses[0]).toBe('Thinking...') + expect(assistantStatuses.at(-1)).toBe('') + expect(assistantStatuses.filter(status => status === 'Thinking...').length).toBeGreaterThanOrEqual(2) + expect(assistantStatuses.filter(status => status === '').length).toBeGreaterThanOrEqual(2) expect( slackApi.calls .filter(call => call.method === 'assistant.threads.setTitle') @@ -2662,7 +2665,7 @@ describe('slackbotv2', () => { ) expect(logData(logs, 'slackbotv2_handoff_sync_starting')).toEqual( expect.objectContaining({ - initial_assistant_status_visible: true, + initial_assistant_status_visible: expect.any(Boolean), trigger: 'new_mention' }) ) @@ -2696,7 +2699,59 @@ describe('slackbotv2', () => { slackApi.calls .filter(call => call.method === 'assistant.threads.setStatus') .map(call => stringField(call.body.status)) - ).toEqual(['Thinking...', '']) + ).toEqual(expect.arrayContaining(['Thinking...', ''])) + }) + + it('does not wait for hung assistant status before creating Slack sessions', async () => { + const logs: CapturedLog[] = [] + bot = createTestBot({ logger: captureLogger(logs), slackApiTimeoutMs: 25 }) + const releaseStatus = slackApi.holdAssistantStatus() + const waits: Promise[] = [] + + try { + const parent = await postUserMessage('Context before hung status.') + const mention = await postUserMessage(`<@${BOT_USER_ID}> keep going`, parent.ts) + const responsePromise = bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-hung-status', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> keep going` + } + }), + {}, + waitUntilContext(waits) + ) + + await waitFor(() => codexApi.creates.length === 1 && codexApi.executes.length === 1) + const response = await responsePromise + expect(response.status).toBe(200) + expect(codexApi.creates[0]?.threadKey).toBe(threadKey(parent.ts)) + expect(codexApi.executes[0]?.threadKey).toBe(threadKey(parent.ts)) + expect(logData(logs, 'slackbotv2_handoff_sync_starting')).toEqual( + expect.objectContaining({ + initial_assistant_status_deferred: true, + initial_assistant_status_visible: false, + trigger: 'new_mention' + }) + ) + await waitFor(() => hasLog(logs, 'slackbotv2_assistant_status_failed')) + expect(logData(logs, 'slackbotv2_assistant_status_failed')).toEqual( + expect.objectContaining({ + error: 'set assistant status timed out after 25ms', + operation: 'set' + }) + ) + } finally { + releaseStatus() + } + await Promise.all(waits) }) it('recovers unfinished render obligations from Chat SDK state on startup', async () => { @@ -4082,6 +4137,7 @@ type PatchedSlackApi = { failRepliesWithThreadNotFound(channel: string, ts: string): void failStreamAppendsAfter(count: number, error: string): void failStreamStopsLongerThan(maxChars: number): void + holdAssistantStatus(): () => void reset(): void setUserProfile(userId: string, profile: Record): void userProfileMethodRequestCount(userId: string, method: string): number @@ -4123,13 +4179,22 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise>() const userProfileRequests = new Map() const threadNotFoundReplies = new Set() + let assistantStatusGate: Promise | null = null + let releaseAssistantStatusGate: (() => void) | null = null let maxStreamStopChars: number | null = null const appendFailure: { error: string; remaining: number } = { error: '', remaining: -1 } const streams = new Map() + const releaseCurrentAssistantStatusGate = () => { + const release = releaseAssistantStatusGate + assistantStatusGate = null + releaseAssistantStatusGate = null + release?.() + } const port = await availablePort(4053) const server = createServer((req, res) => { void handlePatchedSlackRequest(req, res, { appendFailure, + assistantStatusGate: () => assistantStatusGate, calls, maxStreamStopChars, port, @@ -4162,7 +4227,15 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise { + releaseAssistantStatusGate = resolve + }) + return releaseCurrentAssistantStatusGate + }, reset() { + releaseCurrentAssistantStatusGate() calls.length = 0 maxStreamStopChars = null appendFailure.remaining = -1 @@ -4191,6 +4264,7 @@ async function handlePatchedSlackRequest( res: ServerResponse, input: { appendFailure: { error: string; remaining: number } + assistantStatusGate: () => Promise | null calls: StreamCall[] maxStreamStopChars: number | null port: number @@ -4232,6 +4306,8 @@ async function handlePatchedSlackRequest( if (path === '/api/assistant.threads.setStatus') { const body = await requestBody(request) input.calls.push({ method: 'assistant.threads.setStatus', body }) + const gate = input.assistantStatusGate() + if (gate) await gate await sendWebResponse(res, Response.json({ ok: true })) return } diff --git a/services/slackbotv2/test/session-api.test.ts b/services/slackbotv2/test/session-api.test.ts index 6768b1093..ae8d11de1 100644 --- a/services/slackbotv2/test/session-api.test.ts +++ b/services/slackbotv2/test/session-api.test.ts @@ -4,6 +4,7 @@ import { clearRequesterIdentityCacheForTests, forwardToSessionApi, harnessRestartPreamble, + serializeAttachment, serializeMessage } from '../src/session-api' import { renderSlackDisplayText } from '../src/slack-display-text' @@ -370,6 +371,24 @@ describe('Slack display text fallback', () => { }) }) +describe('Slack attachment serialization', () => { + test('records timeout errors when attachment fetchData hangs', async () => { + const fetchFn = (async () => Response.json({ ok: true })) as SlackbotV2Options['fetch'] + const startedAt = Date.now() + const attachment = await serializeAttachment( + { + fetchData: () => new Promise(() => undefined), + name: 'hung.txt', + type: 'file' + } as Parameters[0], + { ...options(fetchFn), slackApiTimeoutMs: 25 } + ) + + expect(Date.now() - startedAt).toBeLessThan(500) + expect(attachment.fetchError).toBe('fetch Slack attachment timed out after 25ms') + }) +}) + describe('forwardToSessionApi overrides', () => { test('creates session with default codex harness', async () => { const { fetchFn, requests } = fakeApi() From 6cd8c650dae1063cdc58d620cb5ff7d3f86f74ef Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Mon, 29 Jun 2026 09:11:32 -0700 Subject: [PATCH 004/198] feat: expose tool CLI args in analytics (#814) feat: expose tool cli args in analytics --- services/sandbox/install_tool_shims.py | 75 +++++++++++-- services/sandbox/test_install_tool_shims.py | 34 +++++- tools/infra/vlogs/client.py | 47 +++++++- tools/infra/vlogs/tests/test_client.py | 112 +++++++++++++++++++- 4 files changed, 254 insertions(+), 14 deletions(-) diff --git a/services/sandbox/install_tool_shims.py b/services/sandbox/install_tool_shims.py index 7a8aca6ed..50796bbf9 100644 --- a/services/sandbox/install_tool_shims.py +++ b/services/sandbox/install_tool_shims.py @@ -427,6 +427,9 @@ def _write_catalog(path: Path, index_path: Path, pythonpath: str) -> None: INDEX = {str(index_path)!r} PYTHONPATH_VALUE = {pythonpath!r} +MAX_ANALYTICS_ARGS = 32 +MAX_ANALYTICS_ARGS_LENGTH = 512 +TRUNCATION_SUFFIX = "..." def load(): @@ -514,7 +517,35 @@ def analytics_log_path(): return "/proc/1/fd/2" -def emit_tool_call_event(event, tool, method, started_at=None, returncode=None): +def analytics_tool_args(args): + normalized = [] + truncated = False + raw_args = list(args or []) + remaining_length = MAX_ANALYTICS_ARGS_LENGTH + for arg in raw_args[:MAX_ANALYTICS_ARGS]: + if remaining_length <= 0: + truncated = True + break + value = str(arg) + if len(value) > remaining_length: + truncated = True + if remaining_length <= len(TRUNCATION_SUFFIX): + value = TRUNCATION_SUFFIX[:remaining_length] + else: + value = value[: remaining_length - len(TRUNCATION_SUFFIX)] + TRUNCATION_SUFFIX + normalized.append(value) + remaining_length = 0 + break + normalized.append(value) + remaining_length -= len(value) + if len(raw_args) > MAX_ANALYTICS_ARGS: + truncated = True + if len(normalized) < len(raw_args): + truncated = True + return normalized, len(raw_args), truncated + + +def emit_tool_call_event(event, tool, method, tool_args=None, started_at=None, returncode=None): path = analytics_log_path() if path.lower() in {{"", "0", "false", "none", "off"}}: return @@ -529,6 +560,12 @@ def emit_tool_call_event(event, tool, method, started_at=None, returncode=None): "tool_name": str(tool.get("name") or "unknown"), "tool_method": method, }} + if tool_args is not None: + normalized_args, arg_count, args_truncated = analytics_tool_args(tool_args) + payload["tool_args"] = normalized_args + payload["tool_args_count"] = arg_count + if args_truncated: + payload["tool_args_truncated"] = "true" thread_key = os.environ.get("CENTAUR_THREAD_KEY", "").strip() if thread_key: payload["thread_key"] = thread_key @@ -548,16 +585,30 @@ def emit_tool_call_event(event, tool, method, started_at=None, returncode=None): def run_tool(tool, args): project_dir = Path(tool["project_dir"]) started_at = time.monotonic() - emit_tool_call_event("tool_call_started", tool, "cli") + emit_tool_call_event("tool_call_started", tool, "cli", tool_args=args) try: returncode = subprocess.call( ["uvx", "--from", str(project_dir), tool["name"], *args], env=tool_env(), ) except Exception: - emit_tool_call_event("tool_call_completed", tool, "cli", started_at, 1) + emit_tool_call_event( + "tool_call_completed", + tool, + "cli", + tool_args=args, + started_at=started_at, + returncode=1, + ) raise - emit_tool_call_event("tool_call_completed", tool, "cli", started_at, returncode) + emit_tool_call_event( + "tool_call_completed", + tool, + "cli", + tool_args=args, + started_at=started_at, + returncode=returncode, + ) return returncode @@ -586,9 +637,21 @@ def call_tool(tool, method, payload): env=tool_env(), ) except Exception: - emit_tool_call_event("tool_call_completed", tool, method, started_at, 1) + emit_tool_call_event( + "tool_call_completed", + tool, + method, + started_at=started_at, + returncode=1, + ) raise - emit_tool_call_event("tool_call_completed", tool, method, started_at, result.returncode) + emit_tool_call_event( + "tool_call_completed", + tool, + method, + started_at=started_at, + returncode=result.returncode, + ) return result diff --git a/services/sandbox/test_install_tool_shims.py b/services/sandbox/test_install_tool_shims.py index 09529ea59..5da15bbba 100644 --- a/services/sandbox/test_install_tool_shims.py +++ b/services/sandbox/test_install_tool_shims.py @@ -262,13 +262,43 @@ def test_centaur_tools_run_uses_catalog_entry_directly(self) -> None: self.assertEqual(event["component"], "tool_shim") self.assertEqual(event["tool_name"], "websearch") self.assertEqual(event["tool_method"], "cli") + self.assertEqual(event["tool_args"], ["lookup", "sensitive-payload"]) + self.assertEqual(event["tool_args_count"], 2) self.assertEqual(event["thread_key"], "cli:test-thread") self.assertEqual(analytics_events[1]["exit_code"], 0) self.assertEqual(analytics_events[1]["success"], "true") self.assertIn("duration_ms", analytics_events[1]) serialized_analytics = json.dumps(analytics_events, sort_keys=True) - self.assertNotIn("lookup", serialized_analytics) - self.assertNotIn("sensitive-payload", serialized_analytics) + self.assertIn("lookup", serialized_analytics) + self.assertIn("sensitive-payload", serialized_analytics) + + analytics_log.write_text("") + first_arg = "a" * 400 + second_arg = "b" * 400 + result = subprocess.run( + [ + str(bin_dir / "centaur-tools"), + "run", + "websearch", + first_arg, + second_arg, + ], + check=False, + env=env, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + analytics_events = [ + json.loads(line) for line in analytics_log.read_text().splitlines() + ] + for event in analytics_events: + self.assertEqual(event["tool_args_count"], 2) + self.assertEqual(event["tool_args"][0], first_arg) + self.assertEqual(event["tool_args"][1], ("b" * 109) + "...") + self.assertEqual(sum(len(arg) for arg in event["tool_args"]), 512) + self.assertEqual(event["tool_args_truncated"], "true") result = subprocess.run( [str(bin_dir / "centaur-tools"), "exec", "websearch"], diff --git a/tools/infra/vlogs/client.py b/tools/infra/vlogs/client.py index e080f3a1e..97c4883ea 100644 --- a/tools/infra/vlogs/client.py +++ b/tools/infra/vlogs/client.py @@ -3,6 +3,7 @@ import json import os import re +import shlex from typing import Any import httpx @@ -224,6 +225,27 @@ def _coerce_float(value: Any) -> float: return 0.0 return 0.0 + @staticmethod + def _format_tool_args(value: Any) -> str: + if value is None: + return "(no args)" + if isinstance(value, list | tuple): + if not value: + return "(no args)" + return shlex.join(str(item) for item in value) + if isinstance(value, dict): + if not value: + return "(no args)" + return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) + text = str(value) + return text if text else "(no args)" + + @classmethod + def _tool_args_label(cls, entry: dict) -> str: + if "tool_args" not in entry: + return "(not captured)" + return cls._format_tool_args(entry.get("tool_args")) + @classmethod def _hits_step(cls, start: str) -> str: """Choose a reasonable bucket size for `/select/logsql/hits`.""" @@ -405,6 +427,9 @@ def tool_calls( "_msg", "duration_ms", "success", + "tool_args", + "tool_args_count", + "tool_args_truncated", "tool_name", "tool_method", "thread_key", @@ -493,6 +518,7 @@ def tool_analytics( "calls": 0, "failures": 0, "total_duration_ms": 0, + "args": defaultdict(int), "methods": defaultdict(int), "threads": set(), } @@ -502,6 +528,7 @@ def tool_analytics( continue tool = entry.get("tool_name", "unknown") method = entry.get("tool_method", "unknown") + args = self._tool_args_label(entry) success = entry.get("success", "true") == "true" duration = round(self._coerce_float(entry.get("duration_ms", 0))) thread = entry.get("thread_key", "") @@ -510,6 +537,7 @@ def tool_analytics( if not success: stats[tool]["failures"] += 1 stats[tool]["total_duration_ms"] += duration + stats[tool]["args"][args] += 1 stats[tool]["methods"][method] += 1 if thread: stats[tool]["threads"].add(thread) @@ -526,6 +554,9 @@ def tool_analytics( "failure_rate_pct": failure_rate, "avg_duration_ms": avg_ms, "unique_threads": len(s["threads"]), + "args": dict( + sorted(s["args"].items(), key=lambda item: item[1], reverse=True) + ), "methods": dict(s["methods"]), } ) @@ -550,12 +581,18 @@ def tool_usage_by_thread( f"AND {_field_expr('thread_key', thread_key)}" ) results = self.query(q, limit=limit, **self._time_params(start)) + keep_fields = { + "_time", + "tool_args", + "tool_args_count", + "tool_args_truncated", + "tool_name", + "tool_method", + "duration_ms", + "success", + } return [ - { - k: v - for k, v in self._clean_entry(e).items() - if k in ("_time", "tool_name", "tool_method", "duration_ms", "success") - } + {k: v for k, v in self._clean_entry(e).items() if k in keep_fields} for e in results if "_note" not in e ] diff --git a/tools/infra/vlogs/tests/test_client.py b/tools/infra/vlogs/tests/test_client.py index 4a595437f..7da347cad 100644 --- a/tools/infra/vlogs/tests/test_client.py +++ b/tools/infra/vlogs/tests/test_client.py @@ -1,6 +1,23 @@ from __future__ import annotations -from vlogs.client import _field_expr, _quote_logsql_value +from typing import Any + +from vlogs.client import VictoriaLogsClient, _field_expr, _quote_logsql_value + + +class StubVictoriaLogsClient(VictoriaLogsClient): + def __init__(self, entries: list[dict[str, Any]]) -> None: + super().__init__(url="http://unused") + self.entries = entries + + def query( + self, + query: str, + limit: int = 100, + start: str | None = None, + end: str | None = None, + ) -> list[dict]: + return self.entries def test_quote_logsql_value_handles_slack_thread_key() -> None: @@ -12,3 +29,96 @@ def test_quote_logsql_value_handles_slack_thread_key() -> None: def test_quote_logsql_value_escapes_quotes_and_backslashes() -> None: assert _quote_logsql_value('a"b\\c') == '"a\\"b\\\\c"' + + +def test_tool_calls_exposes_tool_args() -> None: + client = StubVictoriaLogsClient( + [ + { + "_time": "2026-06-29T12:00:00Z", + "_stream": "ignored", + "event": "tool_call_completed", + "tool_name": "websearch", + "tool_method": "cli", + "tool_args": ["lookup", "openai"], + "tool_args_count": 2, + "duration_ms": "42", + "success": "true", + "thread_key": "cli:test-thread", + } + ] + ) + + assert client.tool_calls() == [ + { + "_time": "2026-06-29T12:00:00Z", + "duration_ms": "42", + "success": "true", + "tool_args": ["lookup", "openai"], + "tool_args_count": 2, + "tool_name": "websearch", + "tool_method": "cli", + "thread_key": "cli:test-thread", + } + ] + + +def test_tool_analytics_counts_cli_arg_patterns() -> None: + client = StubVictoriaLogsClient( + [ + { + "tool_name": "websearch", + "tool_method": "cli", + "tool_args": ["lookup", "openai"], + "duration_ms": "10", + "success": "true", + "thread_key": "cli:test-thread-a", + }, + { + "tool_name": "websearch", + "tool_method": "cli", + "tool_args": ["lookup", "openai"], + "duration_ms": "20", + "success": "true", + "thread_key": "cli:test-thread-b", + }, + { + "tool_name": "websearch", + "tool_method": "cli", + "tool_args": ["lookup", "anthropic"], + "duration_ms": "30", + "success": "false", + "thread_key": "cli:test-thread-b", + }, + { + "tool_name": "slack", + "tool_method": "cli", + "tool_args": [], + "duration_ms": "5", + "success": "true", + }, + ] + ) + + assert client.tool_analytics() == [ + { + "tool": "websearch", + "calls": 3, + "failures": 1, + "failure_rate_pct": 33.3, + "avg_duration_ms": 20, + "unique_threads": 2, + "args": {"lookup openai": 2, "lookup anthropic": 1}, + "methods": {"cli": 3}, + }, + { + "tool": "slack", + "calls": 1, + "failures": 0, + "failure_rate_pct": 0.0, + "avg_duration_ms": 5, + "unique_threads": 0, + "args": {"(no args)": 1}, + "methods": {"cli": 1}, + }, + ] From 039f416e43e5470fee8c4c2e12d38cd6ab0c6624 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Mon, 29 Jun 2026 13:53:52 -0700 Subject: [PATCH 005/198] fix: make workflow host API first-class (#819) --- docs/pages/extend/workflows-v2.mdx | 28 +- docs/public/md/extend/workflows-v2.md | 28 +- .../crates/centaur-workflows/src/lib.rs | 74 +- services/sandbox/Dockerfile | 3 + services/workflow-python/api/__init__.py | 5 + services/workflow-python/api/app.py | 146 +++ services/workflow-python/api/metrics.py | 203 ++++ .../workflow-python/api/runtime_control.py | 44 + .../workflow-python/api/workflow_engine.py | 132 +++ services/workflow-python/pyproject.toml | 2 + .../tests/test_workflow_host.py | 124 +++ services/workflow-python/workflow_host.py | 995 +----------------- workflows/company_context_documents.py | 4 +- workflows/company_context_metrics.py | 50 + workflows/etl_metrics.py | 96 ++ workflows/gsuite/calendar_sync.py | 2 +- workflows/gsuite/drive_sync.py | 2 +- workflows/linear/sync.py | 2 +- workflows/slack/archive_import.py | 2 +- workflows/slack/backfill.py | 8 +- workflows/slack/metrics.py | 257 +++++ workflows/slack/retention.py | 2 +- workflows/slack/shared.py | 4 +- workflows/slack/sync.py | 4 +- workflows/slack/tests/test_archive_import.py | 13 +- workflows/slack/tests/test_retention.py | 20 +- .../slack/tests/test_shared_attachments.py | 37 +- workflows/slack/tests/test_sync_cold_start.py | 21 +- ...t_company_context_documents_attachments.py | 13 +- 29 files changed, 1245 insertions(+), 1076 deletions(-) create mode 100644 services/workflow-python/api/__init__.py create mode 100644 services/workflow-python/api/app.py create mode 100644 services/workflow-python/api/metrics.py create mode 100644 services/workflow-python/api/runtime_control.py create mode 100644 services/workflow-python/api/workflow_engine.py create mode 100644 workflows/company_context_metrics.py create mode 100644 workflows/etl_metrics.py create mode 100644 workflows/slack/metrics.py diff --git a/docs/pages/extend/workflows-v2.mdx b/docs/pages/extend/workflows-v2.mdx index 33a375954..8e56d5343 100644 --- a/docs/pages/extend/workflows-v2.mdx +++ b/docs/pages/extend/workflows-v2.mdx @@ -69,25 +69,23 @@ Supported v2 primitives: ### Keep imports narrow -Workflow files should import only the workflow context compatibility module: +Workflow files should import only the supported workflow-host API surface they +need: ```python from api.workflow_engine import WorkflowContext +from api.runtime_control import ControlPlaneError ``` -Do not import Python API internals such as: +Supported workflow-host modules are `api.workflow_engine`, +`api.runtime_control`, `api.app`, and `api.metrics`. -```python -from api.runtime_control import canonical_json -from api.vm_metrics import workflow_counter -``` - -Those modules were implementation details of the Python API service. In v2, -the workflow host provides a small compatibility surface instead of the whole -Python API package. +Do not import unrelated API-service internals or another workflow domain's local +helpers. Domain-specific helpers should live next to the workflows that own +them, for example `workflows/slack/metrics.py`. If a workflow needs a helper, move it into the workflow file, a shared overlay -module, or a supported workflow-host compatibility shim. +module, or a supported workflow-host API module. ### Put side effects behind steps @@ -194,10 +192,10 @@ For each existing workflow: ## Known gaps -The v2 POC supports the workflow model, but it does not yet emulate the full -Python API package. Workflows that import `api.runtime_control`, `api.vm_metrics`, -or other Python API internals need a compatibility shim or a small local helper -before they are v2-ready. +The v2 workflow host intentionally exposes a narrow Python API package. +Workflows that import unrelated API-service internals should move that behavior +into the workflow-host API surface or a small local helper owned by the workflow +domain before they are v2-ready. `ctx.call_tool(...)` is a compatibility surface in the Python workflow host. It uses the generated `centaur-tools call` bridge against the installed tool diff --git a/docs/public/md/extend/workflows-v2.md b/docs/public/md/extend/workflows-v2.md index 0b62ed756..c21658575 100644 --- a/docs/public/md/extend/workflows-v2.md +++ b/docs/public/md/extend/workflows-v2.md @@ -69,25 +69,23 @@ Supported v2 primitives: ### Keep imports narrow -Workflow files should import only the workflow context compatibility module: +Workflow files should import only the supported workflow-host API surface they +need: ```python from api.workflow_engine import WorkflowContext +from api.runtime_control import ControlPlaneError ``` -Do not import Python API internals such as: +Supported workflow-host modules are `api.workflow_engine`, +`api.runtime_control`, `api.app`, and `api.metrics`. -```python -from api.runtime_control import canonical_json -from api.vm_metrics import workflow_counter -``` - -Those modules were implementation details of the Python API service. In v2, -the workflow host provides a small compatibility surface instead of the whole -Python API package. +Do not import unrelated API-service internals or another workflow domain's local +helpers. Domain-specific helpers should live next to the workflows that own +them, for example `workflows/slack/metrics.py`. If a workflow needs a helper, move it into the workflow file, a shared overlay -module, or a supported workflow-host compatibility shim. +module, or a supported workflow-host API module. ### Put side effects behind steps @@ -194,10 +192,10 @@ For each existing workflow: ## Known gaps -The v2 POC supports the workflow model, but it does not yet emulate the full -Python API package. Workflows that import `api.runtime_control`, `api.vm_metrics`, -or other Python API internals need a compatibility shim or a small local helper -before they are v2-ready. +The v2 workflow host intentionally exposes a narrow Python API package. +Workflows that import unrelated API-service internals should move that behavior +into the workflow-host API surface or a small local helper owned by the workflow +domain before they are v2-ready. The tool runtime is also still proxied. `ctx.call_tool(...)` works through the configured tool API, but a fully native `api-rs` tool runtime is a separate diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index a60b2c91c..d60c21fbd 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -2692,7 +2692,17 @@ async fn run_python_workflow_host_local( } Some(message_type) if message_type.starts_with("ctx.") => { let response = - handle_python_context_request(&message, &ctx, &session_runtime, &input).await; + match handle_python_context_request(&message, &ctx, &session_runtime, &input) + .await + { + Ok(response) => response, + Err(error) => { + drop(stdin); + let _ = child.start_kill(); + let _ = child.wait().await; + return Err(error); + } + }; write_host_message(&mut stdin, &response).await?; } other => { @@ -2824,7 +2834,7 @@ where } Some(message_type) if message_type.starts_with("ctx.") => { let response = - handle_python_context_request(&message, &ctx, &session_runtime, &input).await; + handle_python_context_request(&message, &ctx, &session_runtime, &input).await?; write_host_message(stdin, &response).await?; } other => { @@ -2954,7 +2964,7 @@ async fn handle_python_context_request( ctx: &TaskContext, session_runtime: &SessionRuntime, input: &WorkflowTaskInput, -) -> Value { +) -> Result { let request_id = message .get("request_id") .and_then(Value::as_str) @@ -3001,6 +3011,34 @@ async fn handle_python_context_request( } } } + Some("ctx.sleep") => { + let step = message + .get("step") + .and_then(Value::as_str) + .unwrap_or("sleep"); + match parse_python_duration_seconds(message) { + Ok(duration) => match ctx.sleep_for(step, duration).await { + Ok(()) => Ok(json!({"slept": true})), + Err(absurd::Error::Suspend) => return Err(WorkflowRuntimeError::Suspend), + Err(error) => Err(error.to_string()), + }, + Err(error) => Err(error), + } + } + Some("ctx.sleep_until") => { + let step = message + .get("step") + .and_then(Value::as_str) + .unwrap_or("sleep_until"); + match parse_python_wake_at(message) { + Ok(wake_at) => match ctx.sleep_until(step, wake_at).await { + Ok(()) => Ok(json!({"slept": true})), + Err(absurd::Error::Suspend) => return Err(WorkflowRuntimeError::Suspend), + Err(error) => Err(error.to_string()), + }, + Err(error) => Err(error), + } + } Some("ctx.agent_turn") => { let args = message.get("args").cloned().unwrap_or_else(|| json!({})); match run_python_agent_turn(session_runtime.clone(), ctx, input, args, &request_id) @@ -3022,7 +3060,7 @@ async fn handle_python_context_request( } other => Err(format!("unsupported context request type {other:?}")), }; - match result { + Ok(match result { Ok(value) => json!({ "type": "ctx.response", "request_id": request_id, @@ -3035,7 +3073,28 @@ async fn handle_python_context_request( "ok": false, "error": error, }), + }) +} + +fn parse_python_duration_seconds(message: &Value) -> Result { + let seconds = message + .get("duration_seconds") + .and_then(Value::as_f64) + .ok_or_else(|| "ctx.sleep missing numeric duration_seconds".to_owned())?; + if !seconds.is_finite() || seconds < 0.0 { + return Err("ctx.sleep duration_seconds must be a finite non-negative number".to_owned()); } + Ok(Duration::from_secs_f64(seconds)) +} + +fn parse_python_wake_at(message: &Value) -> Result, String> { + let raw = message + .get("wake_at") + .and_then(Value::as_str) + .ok_or_else(|| "ctx.sleep_until missing wake_at".to_owned())?; + DateTime::parse_from_rfc3339(raw) + .map(|value| value.with_timezone(&Utc)) + .map_err(|error| format!("ctx.sleep_until invalid wake_at: {error}")) } async fn run_python_agent_turn( @@ -3573,11 +3632,16 @@ fn workflow_run_from_row(row: sqlx::postgres::PgRow) -> Result absurd::Error { - absurd::Error::TaskFailed(Box::new(error)) + match error { + WorkflowRuntimeError::Suspend => absurd::Error::Suspend, + other => absurd::Error::TaskFailed(Box::new(other)), + } } #[derive(Debug, Error)] pub enum WorkflowRuntimeError { + #[error("workflow suspended")] + Suspend, /// The caller supplied an invalid request or workflow configuration. /// Maps to HTTP 400. #[error("{0}")] diff --git a/services/sandbox/Dockerfile b/services/sandbox/Dockerfile index 52c3c4396..7d2048335 100644 --- a/services/sandbox/Dockerfile +++ b/services/sandbox/Dockerfile @@ -35,10 +35,12 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ google-api-python-client>=2.100.0 \ google-auth-httplib2>=0.2.0 \ google-auth-oauthlib>=1.2.0 \ + feedparser>=6.0.0 \ httplib2>=0.20.0 \ httpx>=0.28.0 \ opentelemetry-proto==1.42.1 \ pysocks>=1.7.1 \ + rich>=13.0.0 \ slack-sdk==3.39.0 \ tomli-w==1.2.0 \ && find /usr/local/lib/python3.12 /usr/lib/python3 -type d -name __pycache__ -prune -exec rm -rf '{}' + @@ -220,6 +222,7 @@ COPY --link --chmod=644 services/sandbox/codex-auth.json /etc/centaur/codex-auth COPY --link --chmod=644 services/sandbox/claude-credentials.json /etc/centaur/claude-credentials.default.json COPY --link --chmod=755 services/sandbox/md2docx.py /usr/local/bin/md2docx COPY --link --chmod=755 services/workflow-python/workflow_host.py /usr/local/bin/workflow-host +COPY --link services/workflow-python/api/ /usr/local/bin/api/ COPY --link --chmod=755 services/sandbox/git-branch.sh /usr/local/bin/git-branch COPY --link --chmod=755 services/sandbox/install_tool_shims.py /usr/local/bin/install-tool-shims COPY --link --chmod=755 services/sandbox/entrypoint.sh /entrypoint.sh diff --git a/services/workflow-python/api/__init__.py b/services/workflow-python/api/__init__.py new file mode 100644 index 000000000..396201de1 --- /dev/null +++ b/services/workflow-python/api/__init__.py @@ -0,0 +1,5 @@ +"""First-class Python workflow runtime API. + +This package is intentionally small: it contains the stable surface that +workflow files can import when they run under the api-rs workflow host. +""" diff --git a/services/workflow-python/api/app.py b/services/workflow-python/api/app.py new file mode 100644 index 000000000..fd4d45ce3 --- /dev/null +++ b/services/workflow-python/api/app.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import asyncio +import json +import shutil +import subprocess +import sys +from contextvars import ContextVar, Token +from pathlib import Path +from typing import Any + + +_ACTIVE_RPC: ContextVar[Any | None] = ContextVar("centaur_workflow_active_rpc", default=None) + + +def bind_context_rpc(rpc: Any) -> Token[Any | None]: + return _ACTIVE_RPC.set(rpc) + + +def reset_context_rpc(token: Token[Any | None]) -> None: + _ACTIVE_RPC.reset(token) + + +def resolve_tool_shim() -> str | None: + if tool_shim := shutil.which("centaur-tools"): + return tool_shim + fallback = Path("/home/agent/.local/bin/centaur-tools") + if fallback.exists(): + return str(fallback) + installer = Path("/usr/local/bin/install-tool-shims") + if installer.exists(): + subprocess.run( + [str(installer)], + check=False, + stdout=sys.stderr, + stderr=sys.stderr, + ) + if tool_shim := shutil.which("centaur-tools"): + return tool_shim + if fallback.exists(): + return str(fallback) + return None + + +async def call_tool_shim( + tool_shim: str, + tool: str, + method: str, + args: dict[str, Any], +) -> Any: + proc = await asyncio.create_subprocess_exec( + tool_shim, + "call", + tool, + method, + json.dumps(args, separators=(",", ":"), default=str), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + text = stdout.decode(errors="replace").strip() + err = stderr.decode(errors="replace").strip() + if proc.returncode != 0: + detail = err or text or f"exit code {proc.returncode}" + raise RuntimeError(f"centaur-tools call {tool}.{method} failed: {detail}") + if not text: + return None + return json.loads(text) + + +class WorkflowToolManager: + def __init__(self, rpc: Any | None = None) -> None: + self._rpc = rpc + + async def call_tool_raw( + self, + tool: str, + method: str, + args: dict[str, Any] | None = None, + ) -> Any: + tool_shim = resolve_tool_shim() + if tool_shim is not None: + return await call_tool_shim(tool_shim, tool, method, args or {}) + if self._rpc is not None: + return await self._rpc.request( + { + "type": "ctx.call_tool", + "tool": tool, + "method": method, + "args": args or {}, + } + ) + raise RuntimeError( + "centaur-tools is not installed and no active workflow context RPC is available" + ) + + async def call_tool( + self, + tool: str, + method: str, + args: dict[str, Any] | None = None, + ) -> Any: + return await self.call_tool_raw(tool, method, args) + + +def get_tool_manager() -> WorkflowToolManager: + return WorkflowToolManager(_ACTIVE_RPC.get()) + + +class WorkflowToolMethod: + def __init__(self, manager: WorkflowToolManager, tool: str, method: str) -> None: + self._manager = manager + self._tool = tool + self._method = method + + async def __call__(self, *args: Any, **kwargs: Any) -> Any: + if args and kwargs: + raise TypeError("tool method calls accept either one dict positional arg or keywords") + if not args: + payload = kwargs + elif len(args) == 1 and isinstance(args[0], dict): + payload = args[0] + else: + raise TypeError("tool method calls accept at most one positional dict arg") + return await self._manager.call_tool_raw(self._tool, self._method, payload) + + +class WorkflowToolProxy: + def __init__(self, manager: WorkflowToolManager, tool: str) -> None: + self._manager = manager + self._tool = tool + + def __getattr__(self, method: str) -> WorkflowToolMethod: + if method.startswith("_"): + raise AttributeError(method) + return WorkflowToolMethod(self._manager, self._tool, method) + + +class WorkflowTools: + def __init__(self, manager: WorkflowToolManager) -> None: + self._manager = manager + + def __getattr__(self, tool: str) -> WorkflowToolProxy: + if tool.startswith("_"): + raise AttributeError(tool) + return WorkflowToolProxy(self._manager, tool) diff --git a/services/workflow-python/api/metrics.py b/services/workflow-python/api/metrics.py new file mode 100644 index 000000000..8d6e40b36 --- /dev/null +++ b/services/workflow-python/api/metrics.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import os +import sys +import urllib.request +from pathlib import Path +from typing import Any + + +_METRIC_RPC: Any | None = None +_METRIC_COUNTERS: dict[tuple[str, tuple[tuple[str, str], ...]], float] = {} +_METRIC_GAUGES: dict[tuple[str, tuple[tuple[str, str], ...]], float] = {} +_METRIC_HISTOGRAMS: dict[tuple[str, tuple[tuple[str, str], ...]], dict[str, Any]] = {} + + +def set_metric_rpc(rpc: Any | None) -> None: + global _METRIC_RPC + _METRIC_RPC = rpc + + +def get_metric_rpc() -> Any | None: + return _METRIC_RPC + + +def increment_metric(metric: str, count: int | float, **labels: str) -> None: + if count < 0: + return + labels = metric_runtime_labels(labels) + key = metric_key(metric, labels) + _METRIC_COUNTERS[key] = _METRIC_COUNTERS.get(key, 0.0) + float(count) + emit_metric_event("counter", metric, count, labels) + push_metric_lines([format_metric_line(metric, labels, _METRIC_COUNTERS[key])]) + + +def set_gauge(metric: str, value: float, **labels: str) -> None: + labels = metric_runtime_labels(labels) + key = metric_key(metric, labels) + _METRIC_GAUGES[key] = float(value) + emit_metric_event("gauge", metric, float(value), labels) + push_metric_lines([format_metric_line(metric, labels, _METRIC_GAUGES[key])]) + + +def observe_histogram( + metric: str, + value: int | float, + buckets: list[int], + **labels: str, +) -> None: + labels = metric_runtime_labels(labels) + key = metric_key(metric, labels) + histogram = _METRIC_HISTOGRAMS.setdefault( + key, + {"buckets": {bucket: 0 for bucket in buckets}, "count": 0, "sum": 0.0}, + ) + numeric = float(value) + histogram["count"] += 1 + histogram["sum"] += numeric + emit_metric_event("histogram", metric, numeric, labels) + for bucket in buckets: + if numeric <= bucket: + histogram["buckets"][bucket] += 1 + + lines = [] + for bucket in buckets: + lines.append( + format_metric_line( + f"{metric}_bucket", + {**labels, "le": str(float(bucket))}, + histogram["buckets"][bucket], + ) + ) + lines.append(format_metric_line(f"{metric}_bucket", {**labels, "le": "+Inf"}, histogram["count"])) + lines.append(format_metric_line(f"{metric}_count", labels, histogram["count"])) + lines.append(format_metric_line(f"{metric}_sum", labels, histogram["sum"])) + push_metric_lines(lines) + + +def metric_key(metric: str, labels: dict[str, str]) -> tuple[str, tuple[tuple[str, str], ...]]: + return (metric, tuple(sorted((key, str(value)) for key, value in labels.items()))) + + +def emit_metric_event(kind: str, metric: str, value: int | float, labels: dict[str, str]) -> None: + if _METRIC_RPC is None: + return + _METRIC_RPC.notify( + { + "type": "ctx.metric", + "kind": kind, + "name": metric, + "value": value, + "labels": labels, + } + ) + + +def metric_runtime_labels(labels: dict[str, str]) -> dict[str, str]: + runtime_labels = dict(labels) + for key, value in default_metric_runtime_labels().items(): + runtime_labels.setdefault(key, value) + return runtime_labels + + +def default_metric_runtime_labels() -> dict[str, str]: + labels: dict[str, str] = {} + namespace = runtime_namespace() + if namespace: + labels["namespace"] = namespace + environment = runtime_environment() + if environment: + labels["environment"] = environment + return labels + + +def runtime_namespace() -> str | None: + for name in ( + "METRICS_NAMESPACE", + "KUBERNETES_NAMESPACE", + "POD_NAMESPACE", + "SESSION_SANDBOX_K8S_NAMESPACE", + ): + value = clean_metric_label_value(os.environ.get(name)) + if value: + return value + + try: + namespace = Path("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + if namespace.exists(): + return clean_metric_label_value(namespace.read_text(encoding="utf-8")) + except OSError: + return None + return None + + +def runtime_environment() -> str | None: + for name in ("METRICS_ENVIRONMENT", "ENVIRONMENT", "DEPLOYMENT_ENVIRONMENT"): + value = clean_metric_label_value(os.environ.get(name)) + if value: + return value + + for attr in os.environ.get("OTEL_RESOURCE_ATTRIBUTES", "").split(","): + key, separator, value = attr.partition("=") + if separator and key.strip() in {"deployment.environment", "deployment.environment.name"}: + cleaned = clean_metric_label_value(value) + if cleaned: + return cleaned + + namespace = runtime_namespace() + if namespace == "centaur-system": + return "production" + if namespace and namespace.startswith("stg-"): + return "staging" + return None + + +def clean_metric_label_value(value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + return value or None + + +def format_metric_line(metric: str, labels: dict[str, str], value: float) -> str: + if labels: + label_text = ",".join( + f'{key}="{escape_label_value(str(label_value))}"' + for key, label_value in sorted(labels.items()) + ) + return f"{metric}{{{label_text}}} {value}" + return f"{metric} {value}" + + +def escape_label_value(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + + +def push_metric_lines(lines: list[str]) -> None: + if not victoria_metrics_push_enabled(): + return + payload = ("\n".join(lines) + "\n").encode("utf-8") + request = urllib.request.Request( + f"{victoria_metrics_url().rstrip('/')}/api/v1/import/prometheus", + data=payload, + headers={"Content-Type": "text/plain"}, + method="POST", + ) + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + opener.open(request, timeout=2).close() + except Exception as exc: + print(f"workflow_metric_push_error error={exc}", file=sys.stderr) + + +def victoria_metrics_url() -> str: + return os.environ.get("VICTORIAMETRICS_URL", "http://victoriametrics:8428") + + +def victoria_metrics_push_enabled() -> bool: + return os.environ.get("VICTORIAMETRICS_PUSH_ENABLED", "1").strip().lower() not in { + "0", + "false", + "no", + "off", + } diff --git a/services/workflow-python/api/runtime_control.py b/services/workflow-python/api/runtime_control.py new file mode 100644 index 000000000..82b8cbfcf --- /dev/null +++ b/services/workflow-python/api/runtime_control.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json +from typing import Any + + +class ControlPlaneError(RuntimeError): + def __init__( + self, + code: str, + message: str | None = None, + status_code: int = 500, + details: Any = None, + ) -> None: + self.code = str(code) + self.message = str(message or code) + self.status_code = int(status_code) + self.details = details + super().__init__(f"{self.code}: {self.message}") + + def to_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "code": self.code, + "message": self.message, + "status_code": self.status_code, + } + if self.details is not None: + payload["details"] = self.details + return payload + + +def canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) + + +def decode_jsonb(value: Any, fallback: Any) -> Any: + if value is None: + return fallback + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return fallback + return value diff --git a/services/workflow-python/api/workflow_engine.py b/services/workflow-python/api/workflow_engine.py new file mode 100644 index 000000000..389a6e399 --- /dev/null +++ b/services/workflow-python/api/workflow_engine.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import dataclasses +import datetime as dt +import inspect +from typing import Any + +from api.app import WorkflowToolManager, WorkflowTools, bind_context_rpc, reset_context_rpc + + +@dataclasses.dataclass +class Delivery: + channel: str = "" + thread_ts: str = "" + mode: str = "" + metadata: dict[str, Any] = dataclasses.field(default_factory=dict) + + +class WorkflowContext: + def __init__( + self, + rpc: Any, + *, + run_id: str, + task_id: str, + workflow_name: str, + pool: Any = None, + ) -> None: + self._rpc = rpc + self.run_id = run_id + self.task_id = task_id + self.workflow_name = workflow_name + self._pool = pool + self.tools = WorkflowTools(WorkflowToolManager(self._rpc)) + + def log(self, event: str, **fields: Any) -> None: + self._rpc.notify( + { + "type": "ctx.log", + "message": event, + "fields": fields, + } + ) + + async def step( + self, + name: str, + fn: Any, + *, + retry: Any = None, + timeout: Any = None, + step_kind: str | None = None, + ) -> Any: + del retry, timeout + request: dict[str, Any] = {"type": "ctx.step.get", "step": name} + if step_kind: + request["step_kind"] = step_kind + started = await self._rpc.request(request) + if started.get("done"): + return started.get("value") + + token = bind_context_rpc(self._rpc) + try: + value = fn() + if inspect.isawaitable(value): + value = await value + finally: + reset_context_rpc(token) + await self._rpc.request( + { + "type": "ctx.step.put", + "checkpoint_name": started["checkpoint_name"], + "value": value, + **({"step_kind": step_kind} if step_kind else {}), + } + ) + return value + + async def sleep(self, name: str, duration: dt.timedelta | int | float) -> None: + await self._rpc.request( + { + "type": "ctx.sleep", + "step": name, + "duration_seconds": duration_seconds(duration), + } + ) + + async def sleep_until(self, name: str, when: dt.datetime) -> None: + if when.tzinfo is None: + when = when.replace(tzinfo=dt.timezone.utc) + await self._rpc.request( + { + "type": "ctx.sleep_until", + "step": name, + "wake_at": when.astimezone(dt.timezone.utc).isoformat(), + } + ) + + async def agent_turn(self, text: str | None = None, **kwargs: Any) -> Any: + args = dict(kwargs) + if text is not None: + args.setdefault("text", text) + return await self._rpc.request({"type": "ctx.agent_turn", "args": args}) + + async def run_agent(self, *args: Any, text: str | None = None, **kwargs: Any) -> Any: + if args: + kwargs.setdefault("name", args[0]) + if len(args) > 1: + raise TypeError("run_agent accepts at most one positional name argument") + return await self.agent_turn(text, **kwargs) + + async def start_agent(self, *args: Any, text: str | None = None, **kwargs: Any) -> Any: + return await self.run_agent(*args, text=text, **kwargs) + + async def call_tool(self, tool: str, method: str, args: dict[str, Any] | None = None) -> Any: + return await WorkflowToolManager(self._rpc).call_tool_raw(tool, method, args or {}) + + async def post_to_slack(self, channel: str, text: str, **kwargs: Any) -> Any: + return await self._rpc.request( + { + "type": "ctx.post_to_slack", + "channel": channel, + "text": text, + "args": kwargs, + } + ) + + +def duration_seconds(value: dt.timedelta | int | float) -> float: + if isinstance(value, dt.timedelta): + return max(value.total_seconds(), 0.0) + return max(float(value), 0.0) diff --git a/services/workflow-python/pyproject.toml b/services/workflow-python/pyproject.toml index 0ae0dc0ee..a63eabfe6 100644 --- a/services/workflow-python/pyproject.toml +++ b/services/workflow-python/pyproject.toml @@ -5,11 +5,13 @@ requires-python = ">=3.11" dependencies = [ "asyncpg>=0.30.0", "boto3>=1.40.0", + "feedparser>=6.0.0", "google-api-python-client>=2.100.0", "google-auth-httplib2>=0.2.0", "google-auth-oauthlib>=1.2.0", "httplib2>=0.20.0", "httpx>=0.28.0", "pysocks>=1.7.1", + "rich>=13.0.0", "slack-sdk>=3.39.0", ] diff --git a/services/workflow-python/tests/test_workflow_host.py b/services/workflow-python/tests/test_workflow_host.py index bbe9bc701..8a109fbd0 100644 --- a/services/workflow-python/tests/test_workflow_host.py +++ b/services/workflow-python/tests/test_workflow_host.py @@ -10,6 +10,7 @@ def load_workflow_host(): module_path = Path(__file__).resolve().parents[1] / "workflow_host.py" + sys.path.insert(0, str(module_path.parent)) spec = importlib.util.spec_from_file_location("workflow_host_under_test", module_path) assert spec is not None assert spec.loader is not None @@ -35,7 +36,130 @@ async def drain_notifications(self) -> None: self.drained = True +class RequestRpc(FakeRpc): + def __init__(self) -> None: + super().__init__() + self.requests = [] + + async def request(self, payload): + self.requests.append(payload) + message_type = payload["type"] + if message_type == "ctx.step.get": + return {"done": False, "checkpoint_name": "checkpoint-1"} + if message_type == "ctx.step.put": + return payload["value"] + if message_type == "ctx.call_tool": + return { + "tool": payload["tool"], + "method": payload["method"], + "args": payload["args"], + "via": "rpc", + } + if message_type == "ctx.agent_turn": + return payload["args"] + if message_type == "ctx.sleep": + return {"slept": True} + raise AssertionError(f"unexpected request {payload}") + + class WorkflowHostTests(unittest.TestCase): + def test_workflow_api_modules_are_importable(self) -> None: + load_workflow_host() + + from api.runtime_control import ControlPlaneError, canonical_json, decode_jsonb + from api.workflow_engine import Delivery, WorkflowContext + + self.assertEqual(canonical_json({"b": 1, "a": 2}), '{"a":2,"b":1}') + self.assertEqual(decode_jsonb('{"ok": true}', {}), {"ok": True}) + self.assertEqual(Delivery().metadata, {}) + self.assertTrue(WorkflowContext) + + error = ControlPlaneError("INVALID", "bad input", 422) + self.assertEqual(error.to_dict()["status_code"], 422) + self.assertIn("INVALID", str(error)) + + def test_step_accepts_step_kind_and_binds_tool_manager_rpc(self) -> None: + host = load_workflow_host() + from api import app as workflow_app + + rpc = RequestRpc() + ctx = host.WorkflowContext( + rpc, + run_id="run-123", + task_id="task-456", + workflow_name="sample", + ) + + async def run_step(): + async def call_tool(): + manager = workflow_app.get_tool_manager() + return await manager.call_tool_raw("demo", "method", {"x": 1}) + + return await ctx.step("call_tool", call_tool, step_kind="tool_call") + + with patch.object(workflow_app, "resolve_tool_shim", return_value=None): + result = asyncio.run(run_step()) + + self.assertEqual(result["via"], "rpc") + self.assertEqual(rpc.requests[0]["type"], "ctx.step.get") + self.assertEqual(rpc.requests[0]["step_kind"], "tool_call") + self.assertEqual(rpc.requests[-1]["type"], "ctx.step.put") + self.assertEqual(rpc.requests[-1]["step_kind"], "tool_call") + + def test_sleep_sends_duration_seconds(self) -> None: + host = load_workflow_host() + rpc = RequestRpc() + ctx = host.WorkflowContext( + rpc, + run_id="run-123", + task_id="task-456", + workflow_name="sample", + ) + + asyncio.run(ctx.sleep("pause", 2.5)) + + self.assertEqual( + rpc.requests, + [{"type": "ctx.sleep", "step": "pause", "duration_seconds": 2.5}], + ) + + def test_tools_proxy_calls_tool_manager(self) -> None: + host = load_workflow_host() + rpc = RequestRpc() + ctx = host.WorkflowContext( + rpc, + run_id="run-123", + task_id="task-456", + workflow_name="sample", + ) + + async def call_tool(): + return await ctx.tools.demo.method(x=1) + + from api import app as workflow_app + + with patch.object(workflow_app, "resolve_tool_shim", return_value=None): + result = asyncio.run(call_tool()) + + self.assertEqual( + result, + {"tool": "demo", "method": "method", "args": {"x": 1}, "via": "rpc"}, + ) + + def test_run_agent_accepts_positional_step_name_with_text(self) -> None: + host = load_workflow_host() + rpc = RequestRpc() + ctx = host.WorkflowContext( + rpc, + run_id="run-123", + task_id="task-456", + workflow_name="sample", + ) + + result = asyncio.run(ctx.run_agent("draft_summary", text="summarize this")) + + self.assertEqual(result, {"name": "draft_summary", "text": "summarize this"}) + def test_workflow_result_includes_grouping_identifiers(self) -> None: host = load_workflow_host() pool = FakePool() diff --git a/services/workflow-python/workflow_host.py b/services/workflow-python/workflow_host.py index 5376b7876..f2a01b5ee 100644 --- a/services/workflow-python/workflow_host.py +++ b/services/workflow-python/workflow_host.py @@ -15,100 +15,20 @@ import inspect import json import os -import shutil -import subprocess import sys import traceback -import types import typing -import urllib.request from pathlib import Path from typing import Any +from api import metrics +from api.workflow_engine import WorkflowContext + class ProtocolError(RuntimeError): pass -class WorkflowContext: - def __init__( - self, - rpc: "RpcClient", - *, - run_id: str, - task_id: str, - workflow_name: str, - pool: Any = None, - ) -> None: - self._rpc = rpc - self.run_id = run_id - self.task_id = task_id - self.workflow_name = workflow_name - self._pool = pool - - def log(self, event: str, **fields: Any) -> None: - self._rpc.notify( - { - "type": "ctx.log", - "message": event, - "fields": fields, - } - ) - - async def step(self, name: str, fn: Any, *, retry: Any = None, timeout: Any = None) -> Any: - del retry, timeout - started = await self._rpc.request({"type": "ctx.step.get", "step": name}) - if started.get("done"): - return started.get("value") - - value = fn() - if inspect.isawaitable(value): - value = await value - await self._rpc.request( - { - "type": "ctx.step.put", - "checkpoint_name": started["checkpoint_name"], - "value": value, - } - ) - return value - - async def agent_turn(self, text: str | None = None, **kwargs: Any) -> Any: - args = dict(kwargs) - if text is not None: - args.setdefault("text", text) - return await self._rpc.request({"type": "ctx.agent_turn", "args": args}) - - async def run_agent(self, text: str | None = None, **kwargs: Any) -> Any: - return await self.agent_turn(text, **kwargs) - - async def call_tool(self, tool: str, method: str, args: dict[str, Any] | None = None) -> Any: - tool_shim = resolve_tool_shim() - if tool_shim is not None: - # Sandboxed workflow hosts cannot rely on api-rs having a /tools - # backend. Use the generated catalog's method bridge for durable - # workflow ctx.call_tool(...); interactive agents use tool CLIs. - return await call_tool_shim(tool_shim, tool, method, args or {}) - return await self._rpc.request( - { - "type": "ctx.call_tool", - "tool": tool, - "method": method, - "args": args or {}, - } - ) - - async def post_to_slack(self, channel: str, text: str, **kwargs: Any) -> Any: - return await self._rpc.request( - { - "type": "ctx.post_to_slack", - "channel": channel, - "text": text, - "args": kwargs, - } - ) - - class RpcClient: def __init__(self) -> None: self._next_request_id = 1 @@ -152,53 +72,6 @@ def resolve(self, response: dict[str, Any]) -> None: fut.set_exception(RuntimeError(str(response.get("error") or "context RPC failed"))) -_METRIC_RPC: RpcClient | None = None - - -def resolve_tool_shim() -> str | None: - if tool_shim := shutil.which("centaur-tools"): - return tool_shim - fallback = Path("/home/agent/.local/bin/centaur-tools") - if fallback.exists(): - return str(fallback) - installer = Path("/usr/local/bin/install-tool-shims") - if installer.exists(): - subprocess.run( - [str(installer)], - check=False, - stdout=sys.stderr, - stderr=sys.stderr, - ) - if tool_shim := shutil.which("centaur-tools"): - return tool_shim - if fallback.exists(): - return str(fallback) - return None - - -async def call_tool_shim( - tool_shim: str, tool: str, method: str, args: dict[str, Any] -) -> Any: - proc = await asyncio.create_subprocess_exec( - tool_shim, - "call", - tool, - method, - json.dumps(args, separators=(",", ":"), default=str), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate() - text = stdout.decode(errors="replace").strip() - err = stderr.decode(errors="replace").strip() - if proc.returncode != 0: - detail = err or text or f"exit code {proc.returncode}" - raise RuntimeError(f"centaur-tools call {tool}.{method} failed: {detail}") - if not text: - return None - return json.loads(text) - - @dataclasses.dataclass class RegisteredWorkflow: workflow_name: str @@ -209,860 +82,6 @@ class RegisteredWorkflow: schedule: Any -def install_api_compat_module() -> None: - api_mod = sys.modules.get("api") - if api_mod is None: - try: - import api as imported_api # type: ignore - - api_mod = imported_api - except ImportError: - api_mod = types.ModuleType("api") - api_mod.__path__ = [] # Mark as package so compat submodules can import. - sys.modules["api"] = api_mod - - workflow_engine = types.ModuleType("api.workflow_engine") - workflow_engine.WorkflowContext = WorkflowContext - sys.modules["api.workflow_engine"] = workflow_engine - setattr(api_mod, "workflow_engine", workflow_engine) - - runtime_control = types.ModuleType("api.runtime_control") - runtime_control.canonical_json = canonical_json - runtime_control.decode_jsonb = decode_jsonb - sys.modules.setdefault("api.runtime_control", runtime_control) - setattr(api_mod, "runtime_control", runtime_control) - - install_vm_metrics_compat_module(api_mod) - - install_centaur_sdk_compat_module() - - -def canonical_json(value: Any) -> str: - return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) - - -def decode_jsonb(value: Any, fallback: Any) -> Any: - if value is None: - return fallback - if isinstance(value, str): - try: - return json.loads(value) - except json.JSONDecodeError: - return fallback - return value - - -def install_centaur_sdk_compat_module() -> None: - if "centaur_sdk" in sys.modules: - return - try: - __import__("centaur_sdk") - return - except ImportError: - pass - centaur_sdk = types.ModuleType("centaur_sdk") - - def secret(name: str, default: str | None = None) -> str: - return os.getenv(name, default or "") - - centaur_sdk.secret = secret - sys.modules["centaur_sdk"] = centaur_sdk - - -_METRIC_COUNTERS: dict[tuple[str, tuple[tuple[str, str], ...]], float] = {} -_METRIC_GAUGES: dict[tuple[str, tuple[tuple[str, str], ...]], float] = {} -_METRIC_HISTOGRAMS: dict[tuple[str, tuple[tuple[str, str], ...]], dict[str, Any]] = {} -_COMPANY_CONTEXT_DOCUMENT_SIZE_BUCKETS = [ - 100, - 500, - 1_000, - 5_000, - 10_000, - 25_000, - 50_000, - 100_000, - 250_000, - 500_000, -] -_SLACK_ARCHIVE_IMPORT_BATCH_SIZE_BUCKETS = [ - 1, - 10, - 100, - 500, - 1_000, - 5_000, - 10_000, -] -_SLACK_ARCHIVE_IMPORT_DURATION_BUCKETS = [ - 1, - 5, - 10, - 30, - 60, - 120, - 300, - 600, - 1_200, - 3_600, -] -_SLACK_RETENTION_DURATION_BUCKETS = [ - 1, - 5, - 10, - 30, - 60, - 120, - 300, - 600, - 1_200, -] - - -def install_vm_metrics_compat_module(api_mod: types.ModuleType) -> None: - if "api.vm_metrics" in sys.modules: - setattr(api_mod, "vm_metrics", sys.modules["api.vm_metrics"]) - return - try: - import api.vm_metrics as vm_metrics # type: ignore - - setattr(api_mod, "vm_metrics", vm_metrics) - return - except ImportError: - pass - - vm_metrics = types.ModuleType("api.vm_metrics") - vm_metrics.record_etl_items_deleted = record_etl_items_deleted - vm_metrics.record_etl_items_enqueued = record_etl_items_enqueued - vm_metrics.record_etl_items_failed = record_etl_items_failed - vm_metrics.record_etl_items_seen = record_etl_items_seen - vm_metrics.record_etl_items_upserted = record_etl_items_upserted - vm_metrics.record_slack_etl_rate_limit = record_slack_etl_rate_limit - vm_metrics.record_slack_archive_import_batch_failure = ( - record_slack_archive_import_batch_failure - ) - vm_metrics.record_slack_archive_import_batch_size = ( - record_slack_archive_import_batch_size - ) - vm_metrics.record_slack_archive_import_bytes = record_slack_archive_import_bytes - vm_metrics.record_slack_archive_import_channels = ( - record_slack_archive_import_channels - ) - vm_metrics.record_slack_archive_import_failure = ( - record_slack_archive_import_failure - ) - vm_metrics.record_slack_archive_import_message_files = ( - record_slack_archive_import_message_files - ) - vm_metrics.record_slack_archive_import_messages = ( - record_slack_archive_import_messages - ) - vm_metrics.record_slack_archive_import_attachments = ( - record_slack_archive_import_attachments - ) - vm_metrics.record_slack_archive_import_run = record_slack_archive_import_run - vm_metrics.record_slack_archive_import_skipped_items = ( - record_slack_archive_import_skipped_items - ) - vm_metrics.record_slack_archive_import_users = record_slack_archive_import_users - vm_metrics.observe_slack_archive_import_batch_duration = ( - observe_slack_archive_import_batch_duration - ) - vm_metrics.observe_slack_archive_import_duration = ( - observe_slack_archive_import_duration - ) - vm_metrics.record_slack_retention_backfill_job = ( - record_slack_retention_backfill_job - ) - vm_metrics.record_slack_retention_backfill_job_failure = ( - record_slack_retention_backfill_job_failure - ) - vm_metrics.record_slack_retention_backfill_terminal_skip = ( - record_slack_retention_backfill_terminal_skip - ) - vm_metrics.record_slack_retention_channel_failure = ( - record_slack_retention_channel_failure - ) - vm_metrics.record_slack_retention_failure = record_slack_retention_failure - vm_metrics.record_slack_retention_messages_processed = ( - record_slack_retention_messages_processed - ) - vm_metrics.record_slack_retention_api_request = ( - record_slack_retention_api_request - ) - vm_metrics.record_slack_retention_api_rate_limited = ( - record_slack_retention_api_rate_limited - ) - vm_metrics.record_slack_retention_run = record_slack_retention_run - vm_metrics.observe_slack_retention_run_duration = ( - observe_slack_retention_run_duration - ) - vm_metrics.set_slack_archive_import_last_failure_timestamp = ( - set_slack_archive_import_last_failure_timestamp - ) - vm_metrics.set_slack_retention_last_failure_timestamp = ( - set_slack_retention_last_failure_timestamp - ) - vm_metrics.set_slack_retention_watermark_lag_seconds = ( - set_slack_retention_watermark_lag_seconds - ) - vm_metrics.set_etl_active_scopes = set_etl_active_scopes - vm_metrics.set_etl_backfill_job_age_seconds = set_etl_backfill_job_age_seconds - vm_metrics.set_etl_backfill_jobs = set_etl_backfill_jobs - vm_metrics.set_etl_failed_scopes = set_etl_failed_scopes - vm_metrics.set_etl_scope_sync_freshness_seconds = ( - set_etl_scope_sync_freshness_seconds - ) - vm_metrics.record_company_context_documents_changed = ( - record_company_context_documents_changed - ) - vm_metrics.observe_company_context_document_size = ( - observe_company_context_document_size - ) - vm_metrics.set_company_context_projection_lag = ( - set_company_context_projection_lag - ) - sys.modules["api.vm_metrics"] = vm_metrics - setattr(api_mod, "vm_metrics", vm_metrics) - - -def record_etl_items_seen( - source: str, source_type: str, item_type: str, count: int -) -> None: - increment_metric( - "etl_items_seen_total", - count, - source=source, - source_type=source_type, - item_type=item_type, - ) - - -def record_etl_items_enqueued( - source: str, source_type: str, item_type: str, count: int -) -> None: - increment_metric( - "etl_items_enqueued_total", - count, - source=source, - source_type=source_type, - item_type=item_type, - ) - - -def record_etl_items_upserted( - source: str, source_type: str, item_type: str, count: int -) -> None: - increment_metric( - "etl_items_upserted_total", - count, - source=source, - source_type=source_type, - item_type=item_type, - ) - - -def record_etl_items_deleted( - source: str, source_type: str, item_type: str, count: int -) -> None: - increment_metric( - "etl_items_deleted_total", - count, - source=source, - source_type=source_type, - item_type=item_type, - ) - - -def record_etl_items_failed( - source: str, - source_type: str, - item_type: str, - reason: str, - count: int = 1, -) -> None: - increment_metric( - "etl_items_failed_total", - count, - source=source, - source_type=source_type, - item_type=item_type, - reason=reason, - ) - - -def record_slack_etl_rate_limit( - workflow: str, - method: str, - outcome: str, - retry_after_seconds: int | float, -) -> None: - retry_after = max(float(retry_after_seconds), 0.0) - labels = { - "workflow": workflow, - "method": method, - "outcome": outcome, - } - increment_metric("slack_etl_rate_limits_total", 1, **labels) - increment_metric( - "slack_etl_rate_limit_retry_after_seconds_total", - retry_after, - **labels, - ) - - -def record_slack_archive_import_run( - status: str, - reason: str = "none", - count: int = 1, -) -> None: - increment_metric( - "slack_archive_import_runs_total", - count, - status=status, - reason=reason, - ) - - -def observe_slack_archive_import_duration(status: str, duration_s: float) -> None: - observe_histogram( - "slack_archive_import_duration_seconds", - max(duration_s, 0.0), - _SLACK_ARCHIVE_IMPORT_DURATION_BUCKETS, - status=status, - ) - - -def record_slack_archive_import_bytes(count: int) -> None: - increment_metric("slack_archive_import_bytes_total", count) - - -def record_slack_archive_import_channels(result: str, count: int) -> None: - increment_metric("slack_archive_import_channels_total", count, result=result) - - -def record_slack_archive_import_users(result: str, count: int) -> None: - increment_metric("slack_archive_import_users_total", count, result=result) - - -def record_slack_archive_import_messages(result: str, count: int) -> None: - increment_metric("slack_archive_import_messages_total", count, result=result) - - -def record_slack_archive_import_message_files(result: str, count: int) -> None: - increment_metric("slack_archive_import_message_files_total", count, result=result) - - -def record_slack_archive_import_attachments(result: str, count: int) -> None: - increment_metric("slack_archive_import_attachments_total", count, result=result) - - -def observe_slack_archive_import_batch_duration(entity: str, duration_s: float) -> None: - observe_histogram( - "slack_archive_import_batch_duration_seconds", - max(duration_s, 0.0), - _SLACK_ARCHIVE_IMPORT_DURATION_BUCKETS, - entity=entity, - ) - - -def record_slack_archive_import_batch_size(entity: str, count: int) -> None: - observe_histogram( - "slack_archive_import_batch_size", - max(count, 0), - _SLACK_ARCHIVE_IMPORT_BATCH_SIZE_BUCKETS, - entity=entity, - ) - - -def record_slack_archive_import_failure( - stage: str, - reason: str, - count: int = 1, -) -> None: - increment_metric( - "slack_archive_import_failures_total", - count, - stage=stage, - reason=reason, - ) - - -def record_slack_archive_import_skipped_items( - item_type: str, - reason: str, - count: int = 1, -) -> None: - increment_metric( - "slack_archive_import_skipped_items_total", - count, - item_type=item_type, - reason=reason, - ) - - -def record_slack_archive_import_batch_failure( - entity: str, - reason: str, - count: int = 1, -) -> None: - increment_metric( - "slack_archive_import_batch_failures_total", - count, - entity=entity, - reason=reason, - ) - - -def set_slack_archive_import_last_failure_timestamp(timestamp_s: float) -> None: - set_gauge("slack_archive_import_last_failure_timestamp_seconds", timestamp_s) - - -def record_slack_retention_run( - workflow: str, - status: str, - mode: str, - reason: str = "none", - count: int = 1, -) -> None: - increment_metric( - "slack_retention_runs_total", - count, - workflow=workflow, - status=status, - mode=mode, - reason=reason, - ) - - -def observe_slack_retention_run_duration( - workflow: str, - mode: str, - status: str, - duration_s: float, -) -> None: - observe_histogram( - "slack_retention_run_duration_seconds", - max(duration_s, 0.0), - _SLACK_RETENTION_DURATION_BUCKETS, - workflow=workflow, - mode=mode, - status=status, - ) - - -def record_slack_retention_messages_processed( - workflow: str, - mode: str, - result: str, - count: int, -) -> None: - increment_metric( - "slack_retention_messages_processed_total", - count, - workflow=workflow, - mode=mode, - result=result, - ) - - -def record_slack_retention_backfill_job( - job_type: str, - result: str, - reason: str = "none", - count: int = 1, -) -> None: - increment_metric( - "slack_retention_backfill_jobs_total", - count, - job_type=job_type, - result=result, - reason=reason, - ) - - -def record_slack_retention_failure( - workflow: str, - operation: str, - reason: str, - count: int = 1, -) -> None: - increment_metric( - "slack_retention_failures_total", - count, - workflow=workflow, - operation=operation, - reason=reason, - ) - - -def record_slack_retention_api_request( - operation: str, - result: str, - reason: str = "none", - count: int = 1, -) -> None: - increment_metric( - "slack_retention_api_requests_total", - count, - operation=operation, - result=result, - reason=reason, - ) - - -def record_slack_retention_api_rate_limited(operation: str, count: int = 1) -> None: - increment_metric( - "slack_retention_api_rate_limited_total", - count, - operation=operation, - ) - - -def record_slack_retention_backfill_job_failure( - job_type: str, - reason: str, - count: int = 1, -) -> None: - increment_metric( - "slack_retention_backfill_job_failures_total", - count, - job_type=job_type, - reason=reason, - ) - - -def record_slack_retention_backfill_terminal_skip( - job_type: str, - reason: str, - count: int = 1, -) -> None: - increment_metric( - "slack_retention_backfill_terminal_skips_total", - count, - job_type=job_type, - reason=reason, - ) - - -def record_slack_retention_channel_failure( - workflow: str, - reason: str, - count: int = 1, -) -> None: - increment_metric( - "slack_retention_channel_failures_total", - count, - workflow=workflow, - reason=reason, - ) - - -def set_slack_retention_last_failure_timestamp(workflow: str, timestamp_s: float) -> None: - set_gauge( - "slack_retention_last_failure_timestamp_seconds", - timestamp_s, - workflow=workflow, - ) - - -def set_slack_retention_watermark_lag_seconds(mode: str, lag_s: float) -> None: - set_gauge( - "slack_retention_watermark_lag_seconds", - max(lag_s, 0.0), - mode=mode, - ) - - -def set_etl_active_scopes(source: str, count: int) -> None: - set_gauge( - "etl_active_scopes", - max(count, 0), - source=source, - ) - - -def set_etl_failed_scopes(source: str, count: int) -> None: - set_gauge( - "etl_failed_scopes", - max(count, 0), - source=source, - ) - - -def set_etl_scope_sync_freshness_seconds( - source: str, - freshness_s: int | float, -) -> None: - set_gauge( - "etl_scope_sync_freshness_seconds", - max(float(freshness_s), 0.0), - source=source, - ) - - -def set_etl_backfill_jobs( - source: str, - job_type: str, - status: str, - count: int, -) -> None: - set_gauge( - "etl_backfill_jobs", - max(count, 0), - source=source, - job_type=job_type, - status=status, - ) - - -def set_etl_backfill_job_age_seconds( - source: str, - job_type: str, - status: str, - age_s: int | float, -) -> None: - set_gauge( - "etl_backfill_job_age_seconds", - max(float(age_s), 0.0), - source=source, - job_type=job_type, - status=status, - ) - - -def record_company_context_documents_changed( - source: str, - source_type: str, - action: str, - count: int = 1, -) -> None: - increment_metric( - "company_context_documents_changed_total", - count, - source=source, - source_type=source_type, - action=action, - ) - - -def observe_company_context_document_size( - source: str, source_type: str, chars: int -) -> None: - observe_histogram( - "company_context_document_size_chars", - max(chars, 0), - _COMPANY_CONTEXT_DOCUMENT_SIZE_BUCKETS, - source=source, - source_type=source_type, - ) - - -def set_company_context_projection_lag(source: str, projection_lag_s: float) -> None: - set_gauge( - "company_context_projection_lag_seconds", - max(projection_lag_s, 0.0), - source=source, - ) - - -def increment_metric(metric: str, count: int, **labels: str) -> None: - if count < 0: - return - labels = metric_runtime_labels(labels) - key = metric_key(metric, labels) - _METRIC_COUNTERS[key] = _METRIC_COUNTERS.get(key, 0.0) + float(count) - emit_metric_event("counter", metric, count, labels) - push_metric_lines([format_metric_line(metric, labels, _METRIC_COUNTERS[key])]) - - -def set_gauge(metric: str, value: float, **labels: str) -> None: - labels = metric_runtime_labels(labels) - key = metric_key(metric, labels) - _METRIC_GAUGES[key] = float(value) - emit_metric_event("gauge", metric, float(value), labels) - push_metric_lines([format_metric_line(metric, labels, _METRIC_GAUGES[key])]) - - -def observe_histogram( - metric: str, - value: int | float, - buckets: list[int], - **labels: str, -) -> None: - labels = metric_runtime_labels(labels) - key = metric_key(metric, labels) - histogram = _METRIC_HISTOGRAMS.setdefault( - key, - { - "buckets": {bucket: 0 for bucket in buckets}, - "count": 0, - "sum": 0.0, - }, - ) - numeric = float(value) - histogram["count"] += 1 - histogram["sum"] += numeric - emit_metric_event("histogram", metric, numeric, labels) - for bucket in buckets: - if numeric <= bucket: - histogram["buckets"][bucket] += 1 - - lines = [] - for bucket in buckets: - lines.append( - format_metric_line( - f"{metric}_bucket", - {**labels, "le": str(float(bucket))}, - histogram["buckets"][bucket], - ) - ) - lines.append( - format_metric_line( - f"{metric}_bucket", - {**labels, "le": "+Inf"}, - histogram["count"], - ) - ) - lines.append(format_metric_line(f"{metric}_count", labels, histogram["count"])) - lines.append(format_metric_line(f"{metric}_sum", labels, histogram["sum"])) - push_metric_lines(lines) - - -def metric_key( - metric: str, labels: dict[str, str] -) -> tuple[str, tuple[tuple[str, str], ...]]: - return (metric, tuple(sorted((key, str(value)) for key, value in labels.items()))) - - -def emit_metric_event( - kind: str, metric: str, value: int | float, labels: dict[str, str] -) -> None: - if _METRIC_RPC is None: - return - _METRIC_RPC.notify( - { - "type": "ctx.metric", - "kind": kind, - "name": metric, - "value": value, - "labels": labels, - } - ) - - -def metric_runtime_labels(labels: dict[str, str]) -> dict[str, str]: - runtime_labels = dict(labels) - for key, value in default_metric_runtime_labels().items(): - runtime_labels.setdefault(key, value) - return runtime_labels - - -def default_metric_runtime_labels() -> dict[str, str]: - labels: dict[str, str] = {} - namespace = runtime_namespace() - if namespace: - labels["namespace"] = namespace - environment = runtime_environment() - if environment: - labels["environment"] = environment - return labels - - -def runtime_namespace() -> str | None: - for name in ( - "METRICS_NAMESPACE", - "KUBERNETES_NAMESPACE", - "POD_NAMESPACE", - "SESSION_SANDBOX_K8S_NAMESPACE", - ): - value = clean_metric_label_value(os.environ.get(name)) - if value: - return value - - try: - namespace = Path("/var/run/secrets/kubernetes.io/serviceaccount/namespace") - if namespace.exists(): - return clean_metric_label_value(namespace.read_text(encoding="utf-8")) - except OSError: - return None - return None - - -def runtime_environment() -> str | None: - for name in ("METRICS_ENVIRONMENT", "ENVIRONMENT", "DEPLOYMENT_ENVIRONMENT"): - value = clean_metric_label_value(os.environ.get(name)) - if value: - return value - - for attr in os.environ.get("OTEL_RESOURCE_ATTRIBUTES", "").split(","): - key, separator, value = attr.partition("=") - if separator and key.strip() in { - "deployment.environment", - "deployment.environment.name", - }: - cleaned = clean_metric_label_value(value) - if cleaned: - return cleaned - - namespace = runtime_namespace() - if namespace == "centaur-system": - return "production" - if namespace and namespace.startswith("stg-"): - return "staging" - return None - - -def clean_metric_label_value(value: str | None) -> str | None: - if value is None: - return None - value = value.strip() - return value or None - - -def format_metric_line(metric: str, labels: dict[str, str], value: float) -> str: - if labels: - label_text = ",".join( - f'{key}="{escape_label_value(str(label_value))}"' - for key, label_value in sorted(labels.items()) - ) - return f"{metric}{{{label_text}}} {value}" - return f"{metric} {value}" - - -def escape_label_value(value: str) -> str: - return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") - - -def push_metric_lines(lines: list[str]) -> None: - if not victoria_metrics_push_enabled(): - return - payload = ("\n".join(lines) + "\n").encode("utf-8") - request = urllib.request.Request( - f"{victoria_metrics_url().rstrip('/')}/api/v1/import/prometheus", - data=payload, - headers={"Content-Type": "text/plain"}, - method="POST", - ) - try: - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - opener.open(request, timeout=2).close() - except Exception as exc: - print(f"workflow_metric_push_error error={exc}", file=sys.stderr) - - -def victoria_metrics_url() -> str: - return os.environ.get("VICTORIAMETRICS_URL", "http://victoriametrics:8428") - - -def victoria_metrics_push_enabled() -> bool: - return os.environ.get("VICTORIAMETRICS_PUSH_ENABLED", "1").strip().lower() not in { - "0", - "false", - "no", - "off", - } - - def workflow_dirs() -> list[Path]: dirs = [] raw = os.getenv("WORKFLOW_DIRS", "") @@ -1149,7 +168,6 @@ def has_workflow_name_assignment(path: Path) -> bool: def discover_workflows() -> dict[str, RegisteredWorkflow]: dirs = workflow_dirs() configure_workflow_import_paths(dirs) - install_api_compat_module() discovered: dict[str, RegisteredWorkflow] = {} for directory in dirs: for path in sorted(directory.rglob("*.py")): @@ -1278,7 +296,6 @@ def normalize_schedule(workflow: RegisteredWorkflow) -> dict[str, Any] | None: async def run_workflow(message: dict[str, Any], rpc: RpcClient) -> dict[str, Any]: - global _METRIC_RPC workflows = discover_workflows() workflow_name = str(message.get("workflow_name") or "") registered = workflows.get(workflow_name) @@ -1293,8 +310,8 @@ async def run_workflow(message: dict[str, Any], rpc: RpcClient) -> dict[str, Any workflow_name=workflow_name, pool=pool, ) - previous_metric_rpc = _METRIC_RPC - _METRIC_RPC = rpc + previous_metric_rpc = metrics.get_metric_rpc() + metrics.set_metric_rpc(rpc) try: inp = coerce_input(message.get("input") or {}, registered.input_cls) result = registered.handler(inp, ctx) @@ -1311,7 +328,7 @@ async def run_workflow(message: dict[str, Any], rpc: RpcClient) -> dict[str, Any } finally: await rpc.drain_notifications() - _METRIC_RPC = previous_metric_rpc + metrics.set_metric_rpc(previous_metric_rpc) if pool is not None: await pool.close() diff --git a/workflows/company_context_documents.py b/workflows/company_context_documents.py index ff882db61..2bd20a9f4 100644 --- a/workflows/company_context_documents.py +++ b/workflows/company_context_documents.py @@ -10,10 +10,12 @@ from typing import Any from api.runtime_control import canonical_json, decode_jsonb -from api.vm_metrics import ( +from workflows.company_context_metrics import ( observe_company_context_document_size, record_company_context_documents_changed, set_company_context_projection_lag, +) +from workflows.etl_metrics import ( set_etl_active_scopes, set_etl_failed_scopes, set_etl_scope_sync_freshness_seconds, diff --git a/workflows/company_context_metrics.py b/workflows/company_context_metrics.py new file mode 100644 index 000000000..3a0174ccd --- /dev/null +++ b/workflows/company_context_metrics.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from api.metrics import increment_metric, observe_histogram, set_gauge + + +_COMPANY_CONTEXT_DOCUMENT_SIZE_BUCKETS = [ + 100, + 500, + 1_000, + 5_000, + 10_000, + 25_000, + 50_000, + 100_000, + 250_000, + 500_000, +] + + +def record_company_context_documents_changed( + source: str, + source_type: str, + action: str, + count: int = 1, +) -> None: + increment_metric( + "company_context_documents_changed_total", + count, + source=source, + source_type=source_type, + action=action, + ) + + +def observe_company_context_document_size(source: str, source_type: str, chars: int) -> None: + observe_histogram( + "company_context_document_size_chars", + max(chars, 0), + _COMPANY_CONTEXT_DOCUMENT_SIZE_BUCKETS, + source=source, + source_type=source_type, + ) + + +def set_company_context_projection_lag(source: str, projection_lag_s: float) -> None: + set_gauge( + "company_context_projection_lag_seconds", + max(projection_lag_s, 0.0), + source=source, + ) diff --git a/workflows/etl_metrics.py b/workflows/etl_metrics.py new file mode 100644 index 000000000..983ae334d --- /dev/null +++ b/workflows/etl_metrics.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from api.metrics import increment_metric, set_gauge + + +def record_etl_items_seen(source: str, source_type: str, item_type: str, count: int) -> None: + increment_metric( + "etl_items_seen_total", + count, + source=source, + source_type=source_type, + item_type=item_type, + ) + + +def record_etl_items_enqueued(source: str, source_type: str, item_type: str, count: int) -> None: + increment_metric( + "etl_items_enqueued_total", + count, + source=source, + source_type=source_type, + item_type=item_type, + ) + + +def record_etl_items_upserted(source: str, source_type: str, item_type: str, count: int) -> None: + increment_metric( + "etl_items_upserted_total", + count, + source=source, + source_type=source_type, + item_type=item_type, + ) + + +def record_etl_items_deleted(source: str, source_type: str, item_type: str, count: int) -> None: + increment_metric( + "etl_items_deleted_total", + count, + source=source, + source_type=source_type, + item_type=item_type, + ) + + +def record_etl_items_failed( + source: str, + source_type: str, + item_type: str, + reason: str, + count: int = 1, +) -> None: + increment_metric( + "etl_items_failed_total", + count, + source=source, + source_type=source_type, + item_type=item_type, + reason=reason, + ) + + +def set_etl_active_scopes(source: str, count: int) -> None: + set_gauge("etl_active_scopes", max(count, 0), source=source) + + +def set_etl_failed_scopes(source: str, count: int) -> None: + set_gauge("etl_failed_scopes", max(count, 0), source=source) + + +def set_etl_scope_sync_freshness_seconds(source: str, freshness_s: int | float) -> None: + set_gauge("etl_scope_sync_freshness_seconds", max(float(freshness_s), 0.0), source=source) + + +def set_etl_backfill_jobs(source: str, job_type: str, status: str, count: int) -> None: + set_gauge( + "etl_backfill_jobs", + max(count, 0), + source=source, + job_type=job_type, + status=status, + ) + +def set_etl_backfill_job_age_seconds( + source: str, + job_type: str, + status: str, + age_s: int | float, +) -> None: + set_gauge( + "etl_backfill_job_age_seconds", + max(float(age_s), 0.0), + source=source, + job_type=job_type, + status=status, + ) diff --git a/workflows/gsuite/calendar_sync.py b/workflows/gsuite/calendar_sync.py index c51a4cd62..474adf7a2 100644 --- a/workflows/gsuite/calendar_sync.py +++ b/workflows/gsuite/calendar_sync.py @@ -9,7 +9,7 @@ from typing import Any, Protocol from api.runtime_control import canonical_json -from api.vm_metrics import ( +from workflows.etl_metrics import ( record_etl_items_failed, record_etl_items_seen, record_etl_items_upserted, diff --git a/workflows/gsuite/drive_sync.py b/workflows/gsuite/drive_sync.py index aa041dbd5..edba6537f 100644 --- a/workflows/gsuite/drive_sync.py +++ b/workflows/gsuite/drive_sync.py @@ -10,7 +10,7 @@ from workflows.gsuite.drive import GOOGLE_DOC_MIME_TYPE from api.runtime_control import canonical_json -from api.vm_metrics import ( +from workflows.etl_metrics import ( record_etl_items_failed, record_etl_items_seen, record_etl_items_upserted, diff --git a/workflows/linear/sync.py b/workflows/linear/sync.py index d47cfeddf..56ce8b310 100644 --- a/workflows/linear/sync.py +++ b/workflows/linear/sync.py @@ -9,7 +9,7 @@ from typing import Any, Protocol from api.runtime_control import canonical_json -from api.vm_metrics import ( +from workflows.etl_metrics import ( record_etl_items_failed, record_etl_items_seen, record_etl_items_upserted, diff --git a/workflows/slack/archive_import.py b/workflows/slack/archive_import.py index 4d6480566..23c2221a8 100644 --- a/workflows/slack/archive_import.py +++ b/workflows/slack/archive_import.py @@ -17,7 +17,7 @@ from typing import Any, Iterable from api.runtime_control import canonical_json -from api.vm_metrics import ( +from workflows.slack.metrics import ( observe_slack_archive_import_batch_duration, observe_slack_archive_import_duration, record_slack_archive_import_attachments, diff --git a/workflows/slack/backfill.py b/workflows/slack/backfill.py index 047dad0c7..95ad56d1e 100644 --- a/workflows/slack/backfill.py +++ b/workflows/slack/backfill.py @@ -9,12 +9,16 @@ from dataclasses import dataclass, field from typing import Any -from api.vm_metrics import ( +from workflows.etl_metrics import ( record_etl_items_deleted, record_etl_items_enqueued, record_etl_items_failed, record_etl_items_seen, record_etl_items_upserted, + set_etl_backfill_job_age_seconds, + set_etl_backfill_jobs, +) +from workflows.slack.metrics import ( observe_slack_retention_run_duration, record_slack_retention_api_rate_limited, record_slack_retention_api_request, @@ -26,8 +30,6 @@ record_slack_retention_run, set_slack_retention_last_failure_timestamp, set_slack_retention_watermark_lag_seconds, - set_etl_backfill_job_age_seconds, - set_etl_backfill_jobs, ) from api.workflow_engine import WorkflowContext from workflows.slack.shared import ( diff --git a/workflows/slack/metrics.py b/workflows/slack/metrics.py new file mode 100644 index 000000000..460ba092c --- /dev/null +++ b/workflows/slack/metrics.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +from api.metrics import increment_metric, observe_histogram, set_gauge + + +_SLACK_ARCHIVE_IMPORT_BATCH_SIZE_BUCKETS = [1, 10, 100, 500, 1_000, 5_000, 10_000] +_SLACK_ARCHIVE_IMPORT_DURATION_BUCKETS = [1, 5, 10, 30, 60, 120, 300, 600, 1_200, 3_600] +_SLACK_RETENTION_DURATION_BUCKETS = [1, 5, 10, 30, 60, 120, 300, 600, 1_200] + + +def record_slack_etl_rate_limit( + workflow: str, + method: str, + outcome: str, + retry_after_seconds: int | float, +) -> None: + retry_after = max(float(retry_after_seconds), 0.0) + labels = {"workflow": workflow, "method": method, "outcome": outcome} + increment_metric("slack_etl_rate_limits_total", 1, **labels) + increment_metric("slack_etl_rate_limit_retry_after_seconds_total", retry_after, **labels) + + +def record_slack_archive_import_run( + status: str, + reason: str = "none", + count: int = 1, +) -> None: + increment_metric("slack_archive_import_runs_total", count, status=status, reason=reason) + + +def observe_slack_archive_import_duration(status: str, duration_s: float) -> None: + observe_histogram( + "slack_archive_import_duration_seconds", + max(duration_s, 0.0), + _SLACK_ARCHIVE_IMPORT_DURATION_BUCKETS, + status=status, + ) + + +def record_slack_archive_import_bytes(count: int) -> None: + increment_metric("slack_archive_import_bytes_total", count) + + +def record_slack_archive_import_channels(result: str, count: int) -> None: + increment_metric("slack_archive_import_channels_total", count, result=result) + + +def record_slack_archive_import_users(result: str, count: int) -> None: + increment_metric("slack_archive_import_users_total", count, result=result) + + +def record_slack_archive_import_messages(result: str, count: int) -> None: + increment_metric("slack_archive_import_messages_total", count, result=result) + + +def record_slack_archive_import_message_files(result: str, count: int) -> None: + increment_metric("slack_archive_import_message_files_total", count, result=result) + + +def record_slack_archive_import_attachments(result: str, count: int) -> None: + increment_metric("slack_archive_import_attachments_total", count, result=result) + + +def observe_slack_archive_import_batch_duration(entity: str, duration_s: float) -> None: + observe_histogram( + "slack_archive_import_batch_duration_seconds", + max(duration_s, 0.0), + _SLACK_ARCHIVE_IMPORT_DURATION_BUCKETS, + entity=entity, + ) + + +def record_slack_archive_import_batch_size(entity: str, count: int) -> None: + observe_histogram( + "slack_archive_import_batch_size", + max(count, 0), + _SLACK_ARCHIVE_IMPORT_BATCH_SIZE_BUCKETS, + entity=entity, + ) + + +def record_slack_archive_import_failure(stage: str, reason: str, count: int = 1) -> None: + increment_metric("slack_archive_import_failures_total", count, stage=stage, reason=reason) + + +def record_slack_archive_import_skipped_items( + item_type: str, + reason: str, + count: int = 1, +) -> None: + increment_metric( + "slack_archive_import_skipped_items_total", + count, + item_type=item_type, + reason=reason, + ) + + +def record_slack_archive_import_batch_failure( + entity: str, + reason: str, + count: int = 1, +) -> None: + increment_metric( + "slack_archive_import_batch_failures_total", + count, + entity=entity, + reason=reason, + ) + + +def set_slack_archive_import_last_failure_timestamp(timestamp_s: float) -> None: + set_gauge("slack_archive_import_last_failure_timestamp_seconds", timestamp_s) + + +def record_slack_retention_run( + workflow: str, + status: str, + mode: str, + reason: str = "none", + count: int = 1, +) -> None: + increment_metric( + "slack_retention_runs_total", + count, + workflow=workflow, + status=status, + mode=mode, + reason=reason, + ) + + +def observe_slack_retention_run_duration( + workflow: str, + mode: str, + status: str, + duration_s: float, +) -> None: + observe_histogram( + "slack_retention_run_duration_seconds", + max(duration_s, 0.0), + _SLACK_RETENTION_DURATION_BUCKETS, + workflow=workflow, + mode=mode, + status=status, + ) + + +def record_slack_retention_messages_processed( + workflow: str, + mode: str, + result: str, + count: int, +) -> None: + increment_metric( + "slack_retention_messages_processed_total", + count, + workflow=workflow, + mode=mode, + result=result, + ) + + +def record_slack_retention_backfill_job( + job_type: str, + result: str, + reason: str = "none", + count: int = 1, +) -> None: + increment_metric( + "slack_retention_backfill_jobs_total", + count, + job_type=job_type, + result=result, + reason=reason, + ) + + +def record_slack_retention_failure( + workflow: str, + operation: str, + reason: str, + count: int = 1, +) -> None: + increment_metric( + "slack_retention_failures_total", + count, + workflow=workflow, + operation=operation, + reason=reason, + ) + + +def record_slack_retention_api_request( + operation: str, + result: str, + reason: str = "none", + count: int = 1, +) -> None: + increment_metric( + "slack_retention_api_requests_total", + count, + operation=operation, + result=result, + reason=reason, + ) + + +def record_slack_retention_api_rate_limited(operation: str, count: int = 1) -> None: + increment_metric("slack_retention_api_rate_limited_total", count, operation=operation) + + +def record_slack_retention_backfill_job_failure( + job_type: str, + reason: str, + count: int = 1, +) -> None: + increment_metric( + "slack_retention_backfill_job_failures_total", + count, + job_type=job_type, + reason=reason, + ) + + +def record_slack_retention_backfill_terminal_skip( + job_type: str, + reason: str, + count: int = 1, +) -> None: + increment_metric( + "slack_retention_backfill_terminal_skips_total", + count, + job_type=job_type, + reason=reason, + ) + + +def record_slack_retention_channel_failure( + workflow: str, + reason: str, + count: int = 1, +) -> None: + increment_metric( + "slack_retention_channel_failures_total", + count, + workflow=workflow, + reason=reason, + ) + + +def set_slack_retention_last_failure_timestamp(workflow: str, timestamp_s: float) -> None: + set_gauge("slack_retention_last_failure_timestamp_seconds", timestamp_s, workflow=workflow) + + +def set_slack_retention_watermark_lag_seconds(mode: str, lag_s: float) -> None: + set_gauge("slack_retention_watermark_lag_seconds", max(lag_s, 0.0), mode=mode) diff --git a/workflows/slack/retention.py b/workflows/slack/retention.py index 325c10361..0e655e4ff 100644 --- a/workflows/slack/retention.py +++ b/workflows/slack/retention.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from typing import Any -from api.vm_metrics import record_etl_items_deleted +from workflows.etl_metrics import record_etl_items_deleted from api.workflow_engine import WorkflowContext from workflows.slack.shared import env_flag_enabled, positive_int diff --git a/workflows/slack/shared.py b/workflows/slack/shared.py index ff6d15010..d08cbf2e2 100644 --- a/workflows/slack/shared.py +++ b/workflows/slack/shared.py @@ -16,12 +16,12 @@ from centaur_sdk import secret from api.runtime_control import canonical_json -from api.vm_metrics import ( - record_slack_etl_rate_limit, +from workflows.etl_metrics import ( set_etl_active_scopes, set_etl_failed_scopes, set_etl_scope_sync_freshness_seconds, ) +from workflows.slack.metrics import record_slack_etl_rate_limit FALSE_ENV_VALUES = {"0", "false", "no", "off"} DEFAULT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 diff --git a/workflows/slack/sync.py b/workflows/slack/sync.py index 48e1daa43..55508fe1a 100644 --- a/workflows/slack/sync.py +++ b/workflows/slack/sync.py @@ -10,11 +10,13 @@ from typing import Any from api.runtime_control import canonical_json -from api.vm_metrics import ( +from workflows.etl_metrics import ( record_etl_items_enqueued, record_etl_items_failed, record_etl_items_seen, record_etl_items_upserted, +) +from workflows.slack.metrics import ( observe_slack_retention_run_duration, record_slack_retention_api_rate_limited, record_slack_retention_api_request, diff --git a/workflows/slack/tests/test_archive_import.py b/workflows/slack/tests/test_archive_import.py index 351fd2af2..d3957d766 100644 --- a/workflows/slack/tests/test_archive_import.py +++ b/workflows/slack/tests/test_archive_import.py @@ -28,7 +28,7 @@ def _load_archive_import(): api_module.workflow_engine = workflow_engine sys.modules["api.workflow_engine"] = workflow_engine - vm_metrics = types.ModuleType("api.vm_metrics") + slack_metrics = types.ModuleType("workflows.slack.metrics") for name in ( "observe_slack_archive_import_batch_duration", "observe_slack_archive_import_duration", @@ -45,13 +45,18 @@ def _load_archive_import(): "record_slack_archive_import_users", "record_slack_etl_rate_limit", "set_slack_archive_import_last_failure_timestamp", + ): + setattr(slack_metrics, name, lambda *_args, **_kwargs: None) + sys.modules["workflows.slack.metrics"] = slack_metrics + + etl_metrics = types.ModuleType("workflows.etl_metrics") + for name in ( "set_etl_active_scopes", "set_etl_failed_scopes", "set_etl_scope_sync_freshness_seconds", ): - setattr(vm_metrics, name, lambda *_args, **_kwargs: None) - api_module.vm_metrics = vm_metrics - sys.modules["api.vm_metrics"] = vm_metrics + setattr(etl_metrics, name, lambda *_args, **_kwargs: None) + sys.modules["workflows.etl_metrics"] = etl_metrics centaur_sdk = sys.modules.setdefault("centaur_sdk", types.ModuleType("centaur_sdk")) centaur_sdk.secret = lambda _name, default=None: default diff --git a/workflows/slack/tests/test_retention.py b/workflows/slack/tests/test_retention.py index 4fd8c6c64..a36e3781d 100644 --- a/workflows/slack/tests/test_retention.py +++ b/workflows/slack/tests/test_retention.py @@ -20,22 +20,24 @@ def _load_retention(): sys.modules["api"] = api_module sys.modules["api.runtime_control"] = runtime_control - vm_metrics = types.ModuleType("api.vm_metrics") - vm_metrics.metric_calls = [] + etl_metrics = types.ModuleType("workflows.etl_metrics") + etl_metrics.metric_calls = [] def record_etl_items_deleted(*args): - vm_metrics.metric_calls.append(args) + etl_metrics.metric_calls.append(args) for name in ( - "record_slack_etl_rate_limit", "set_etl_active_scopes", "set_etl_failed_scopes", "set_etl_scope_sync_freshness_seconds", ): - setattr(vm_metrics, name, lambda *_args, **_kwargs: None) - vm_metrics.record_etl_items_deleted = record_etl_items_deleted - api_module.vm_metrics = vm_metrics - sys.modules["api.vm_metrics"] = vm_metrics + setattr(etl_metrics, name, lambda *_args, **_kwargs: None) + etl_metrics.record_etl_items_deleted = record_etl_items_deleted + sys.modules["workflows.etl_metrics"] = etl_metrics + + slack_metrics = types.ModuleType("workflows.slack.metrics") + slack_metrics.record_slack_etl_rate_limit = lambda *_args, **_kwargs: None + sys.modules["workflows.slack.metrics"] = slack_metrics workflow_engine = types.ModuleType("api.workflow_engine") @@ -142,7 +144,7 @@ def test_handler_records_metrics_for_non_dry_run(): assert result["slack_etl"]["company_context_documents"] == 1 assert result["slack_dm"]["conversations"] == 3 - metric_calls = sys.modules["api.vm_metrics"].metric_calls + metric_calls = sys.modules["workflows.etl_metrics"].metric_calls assert ("slack", "retention", "company_context_documents", 1) in metric_calls assert ("slack", "retention", "backfill_jobs", 2) in metric_calls assert ("slack_dm", "retention", "conversations", 3) in metric_calls diff --git a/workflows/slack/tests/test_shared_attachments.py b/workflows/slack/tests/test_shared_attachments.py index 1daaa7a70..e1c06c373 100644 --- a/workflows/slack/tests/test_shared_attachments.py +++ b/workflows/slack/tests/test_shared_attachments.py @@ -21,16 +21,18 @@ def _load_shared(): sys.modules.setdefault("api", api_module) sys.modules.setdefault("api.runtime_control", runtime_control) - vm_metrics = types.ModuleType("api.vm_metrics") + slack_metrics = types.ModuleType("workflows.slack.metrics") + slack_metrics.record_slack_etl_rate_limit = lambda *_args, **_kwargs: None + sys.modules["workflows.slack.metrics"] = slack_metrics + + etl_metrics = types.ModuleType("workflows.etl_metrics") for name in ( - "record_slack_etl_rate_limit", "set_etl_active_scopes", "set_etl_failed_scopes", "set_etl_scope_sync_freshness_seconds", ): - setattr(vm_metrics, name, lambda *_args, **_kwargs: None) - api_module.vm_metrics = vm_metrics - sys.modules["api.vm_metrics"] = vm_metrics + setattr(etl_metrics, name, lambda *_args, **_kwargs: None) + sys.modules["workflows.etl_metrics"] = etl_metrics centaur_sdk = types.ModuleType("centaur_sdk") centaur_sdk.secret = lambda _name, default=None: default @@ -42,14 +44,25 @@ def _load_shared(): def _load_backfill(): api_module = sys.modules.setdefault("api", types.ModuleType("api")) - vm_metrics = types.ModuleType("api.vm_metrics") + etl_metrics = types.ModuleType("workflows.etl_metrics") for name in ( - "observe_slack_retention_run_duration", "record_etl_items_deleted", "record_etl_items_enqueued", "record_etl_items_failed", "record_etl_items_seen", "record_etl_items_upserted", + "set_etl_active_scopes", + "set_etl_backfill_job_age_seconds", + "set_etl_backfill_jobs", + "set_etl_failed_scopes", + "set_etl_scope_sync_freshness_seconds", + ): + setattr(etl_metrics, name, lambda *_args, **_kwargs: None) + sys.modules["workflows.etl_metrics"] = etl_metrics + + slack_metrics = types.ModuleType("workflows.slack.metrics") + for name in ( + "observe_slack_retention_run_duration", "record_slack_etl_rate_limit", "record_slack_retention_api_rate_limited", "record_slack_retention_api_request", @@ -61,15 +74,9 @@ def _load_backfill(): "record_slack_retention_run", "set_slack_retention_last_failure_timestamp", "set_slack_retention_watermark_lag_seconds", - "set_etl_active_scopes", - "set_etl_backfill_job_age_seconds", - "set_etl_backfill_jobs", - "set_etl_failed_scopes", - "set_etl_scope_sync_freshness_seconds", ): - setattr(vm_metrics, name, lambda *_args, **_kwargs: None) - api_module.vm_metrics = vm_metrics - sys.modules["api.vm_metrics"] = vm_metrics + setattr(slack_metrics, name, lambda *_args, **_kwargs: None) + sys.modules["workflows.slack.metrics"] = slack_metrics workflow_engine = types.ModuleType("api.workflow_engine") diff --git a/workflows/slack/tests/test_sync_cold_start.py b/workflows/slack/tests/test_sync_cold_start.py index ea2f7e923..2b367a18c 100644 --- a/workflows/slack/tests/test_sync_cold_start.py +++ b/workflows/slack/tests/test_sync_cold_start.py @@ -20,13 +20,22 @@ def _load_sync(): api_module.runtime_control = runtime_control sys.modules["api.runtime_control"] = runtime_control - vm_metrics = types.ModuleType("api.vm_metrics") + etl_metrics = types.ModuleType("workflows.etl_metrics") for name in ( - "observe_slack_retention_run_duration", "record_etl_items_enqueued", "record_etl_items_failed", "record_etl_items_seen", "record_etl_items_upserted", + "set_etl_active_scopes", + "set_etl_failed_scopes", + "set_etl_scope_sync_freshness_seconds", + ): + setattr(etl_metrics, name, lambda *_args, **_kwargs: None) + sys.modules["workflows.etl_metrics"] = etl_metrics + + slack_metrics = types.ModuleType("workflows.slack.metrics") + for name in ( + "observe_slack_retention_run_duration", "record_slack_etl_rate_limit", "record_slack_retention_api_rate_limited", "record_slack_retention_api_request", @@ -36,13 +45,9 @@ def _load_sync(): "record_slack_retention_run", "set_slack_retention_last_failure_timestamp", "set_slack_retention_watermark_lag_seconds", - "set_etl_active_scopes", - "set_etl_failed_scopes", - "set_etl_scope_sync_freshness_seconds", ): - setattr(vm_metrics, name, lambda *_args, **_kwargs: None) - api_module.vm_metrics = vm_metrics - sys.modules["api.vm_metrics"] = vm_metrics + setattr(slack_metrics, name, lambda *_args, **_kwargs: None) + sys.modules["workflows.slack.metrics"] = slack_metrics workflow_engine = types.ModuleType("api.workflow_engine") workflow_engine.WorkflowContext = object diff --git a/workflows/tests/test_company_context_documents_attachments.py b/workflows/tests/test_company_context_documents_attachments.py index bc42ec404..b4e1a638d 100644 --- a/workflows/tests/test_company_context_documents_attachments.py +++ b/workflows/tests/test_company_context_documents_attachments.py @@ -23,26 +23,31 @@ def _load_projection_module(): value if value is not None else default ) - vm_metrics = types.ModuleType("api.vm_metrics") + company_context_metrics = types.ModuleType("workflows.company_context_metrics") for name in ( "observe_company_context_document_size", "record_company_context_documents_changed", "set_company_context_projection_lag", + ): + setattr(company_context_metrics, name, lambda *_args, **_kwargs: None) + + etl_metrics = types.ModuleType("workflows.etl_metrics") + for name in ( "set_etl_active_scopes", "set_etl_failed_scopes", "set_etl_scope_sync_freshness_seconds", ): - setattr(vm_metrics, name, lambda *_args, **_kwargs: None) + setattr(etl_metrics, name, lambda *_args, **_kwargs: None) workflow_engine = types.ModuleType("api.workflow_engine") workflow_engine.WorkflowContext = object api_module.runtime_control = runtime_control - api_module.vm_metrics = vm_metrics api_module.workflow_engine = workflow_engine sys.modules.setdefault("api", api_module) sys.modules.setdefault("api.runtime_control", runtime_control) - sys.modules["api.vm_metrics"] = vm_metrics + sys.modules["workflows.company_context_metrics"] = company_context_metrics + sys.modules["workflows.etl_metrics"] = etl_metrics sys.modules.setdefault("api.workflow_engine", workflow_engine) return importlib.import_module("workflows.company_context_documents") From afa72d2ea493fae8fb42a72b278623bb4a92bc0c Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:02:10 +0300 Subject: [PATCH 006/198] Materialize Slack image attachments (#820) --- crates/harness-server/src/server.rs | 28 +++++++++++++++++++ services/slackbotv2/src/session-api.ts | 12 -------- .../slackbotv2/test/chat-sdk-emulate.test.ts | 24 +++++++++++++--- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/crates/harness-server/src/server.rs b/crates/harness-server/src/server.rs index d615625fd..9caf654e1 100644 --- a/crates/harness-server/src/server.rs +++ b/crates/harness-server/src/server.rs @@ -1546,4 +1546,32 @@ mod tests { assert!(text.starts_with("[Attached file saved to ")); assert!(text.ends_with("notes.txt]")); } + + #[test] + fn inline_image_attachment_block_becomes_local_image_input() { + let _upload_dir = temp_upload_dir(); + let mut state = BlocksState::default(); + let user = r#"{"type":"user","message":{"role":"user","content":[{"type":"attachment","attachment_type":"image","dataBase64":"aGVsbG8=","name":"image.png","mimeType":"image/png","size":5}]}}"#; + let BlocksCommand::User { input, .. } = + parse_blocks_line_with_state(user, &mut state).expect("user parses") + else { + panic!("expected user command"); + }; + + assert_eq!(input.len(), 2); + let UserInput::Text { text, .. } = &input[0] else { + panic!("expected image attachment notice"); + }; + assert!(text.starts_with("[Attached image saved to ")); + assert!(text.ends_with("image.png]")); + + let UserInput::LocalImage { path, .. } = &input[1] else { + panic!("expected inline image attachment to become a local image"); + }; + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("image.png") + ); + assert_eq!(std::fs::read(path).expect("read image bytes"), b"hello"); + } } diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index b8305e7bb..da4c0222e 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -1623,18 +1623,6 @@ function codexAttachmentInput( size: attachment.size } } - const dataUrl = - attachment.dataBase64 && attachment.mimeType - ? `data:${attachment.mimeType};base64,${attachment.dataBase64}` - : undefined - if (attachment.type === 'image' && (dataUrl || attachment.url)) { - return { - type: 'image', - url: dataUrl ?? attachment.url, - detail: 'auto', - name: attachment.name - } - } if (attachment.dataBase64) { return { type: 'attachment', diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index d92c97b9b..7344fd7f4 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -268,7 +268,22 @@ describe('slackbotv2', () => { thread_key: threadKey(parent.ts) }) ) - expect(JSON.stringify(firstInputLine)).toContain('data:image/png;base64') + expect(firstInputLine).toEqual( + expect.objectContaining({ + message: expect.objectContaining({ + content: expect.arrayContaining([ + expect.objectContaining({ + attachment_type: 'image', + dataBase64: Buffer.from('captured-image').toString('base64'), + mimeType: 'image/png', + name: 'captured.png', + type: 'attachment' + }) + ]) + }) + }) + ) + expect(JSON.stringify(firstInputLine)).not.toContain('data:image/png;base64') const followUpAppend = codexApi.appends[1]! expect(followUpAppend.threadKey).toBe(threadKey(parent.ts)) @@ -430,9 +445,10 @@ describe('slackbotv2', () => { const executeInput = JSON.stringify(JSON.parse(codexApi.executes[0]!.body.input_lines.at(-1)!)) expect(executeInput).toContain('Screenshot is attached here.') expect(executeInput).toContain('Earlier Slack thread attachment') - expect(executeInput).toContain( - `data:image/png;base64,${Buffer.from('captured-image').toString('base64')}` - ) + expect(executeInput).toContain('"attachment_type":"image"') + expect(executeInput).toContain('"type":"attachment"') + expect(executeInput).toContain(`"dataBase64":"${Buffer.from('captured-image').toString('base64')}"`) + expect(executeInput).not.toContain('data:image/png;base64') }) it('injects Slack requester identity and verified GitHub handle into Codex input', async () => { From 2effa9fde4e5a61401b5a4631995aac77c5352c8 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Mon, 29 Jun 2026 15:48:01 -0700 Subject: [PATCH 007/198] fix: expose ETL toggles under apiRs values (#821) * fix: expose etl toggles under apiRs values * chore: bump chart version to 0.1.80 --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/apirs.yaml | 52 +++++++++++++++++ contrib/chart/values.schema.json | 68 +++++++++++++++++++++++ contrib/chart/values.yaml | 35 ++++++++++++ docs/pages/operate/slack-etl.mdx | 17 ++++-- docs/pages/reference/configuration.mdx | 29 ++++++---- docs/public/md/operate/slack-etl.md | 17 ++++-- docs/public/md/reference/configuration.md | 29 ++++++---- 8 files changed, 218 insertions(+), 31 deletions(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 9e2da353e..11c8131d4 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.79 +version: 0.1.80 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 3557e28d7..7861f153d 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -52,6 +52,45 @@ {{- $apiRsMetricsAnnotations = dict "prometheus.io/scrape" "true" "prometheus.io/path" .Values.apiRs.metrics.path "prometheus.io/port" (printf "%v" .Values.apiRs.port) -}} {{- $apiRsMetricsAnnotations = mergeOverwrite $apiRsMetricsAnnotations (.Values.apiRs.metrics.annotations | default dict) -}} {{- end -}} +{{- $apiRsEtl := .Values.apiRs.etl | default dict -}} +{{- $apiRsEtlEnv := list + (dict "name" "SLACK_ETL_ENABLED" "value" (dig "slack" "enabled" false $apiRsEtl)) + (dict "name" "SLACK_SYNC_INTERVAL_SECONDS" "value" (dig "slack" "syncIntervalSeconds" 3600 $apiRsEtl)) + (dict "name" "SLACK_SYNC_BACKFILL_LOOKBACK_DAYS" "value" (dig "slack" "syncBackfillLookbackDays" 30 $apiRsEtl)) + (dict "name" "SLACK_SYNC_THREAD_LOOKBACK_DAYS" "value" (dig "slack" "syncThreadLookbackDays" 3 $apiRsEtl)) + (dict "name" "SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS" "value" (dig "slack" "excludedChannelPatterns" "" $apiRsEtl)) + (dict "name" "SLACK_ETL_ATTACHMENTS_ENABLED" "value" (dig "slack" "attachments" "enabled" true $apiRsEtl)) + (dict "name" "SLACK_ETL_ATTACHMENT_MAX_BYTES" "value" (dig "slack" "attachments" "maxBytes" 10485760 $apiRsEtl)) + (dict "name" "SLACK_BACKFILL_ENABLED" "value" (dig "slack" "backfill" "enabled" true $apiRsEtl)) + (dict "name" "SLACK_BACKFILL_INTERVAL_SECONDS" "value" (dig "slack" "backfill" "intervalSeconds" 600 $apiRsEtl)) + (dict "name" "SLACK_BACKFILL_CHANNEL_BATCH_LIMIT" "value" (dig "slack" "backfill" "channelBatchLimit" 50 $apiRsEtl)) + (dict "name" "SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB" "value" (dig "slack" "backfill" "channelPagesPerJob" 5 $apiRsEtl)) + (dict "name" "SLACK_RETENTION_ENABLED" "value" (dig "slack" "retention" "enabled" true $apiRsEtl)) + (dict "name" "SLACK_RETENTION_INTERVAL_MINUTES" "value" (dig "slack" "retention" "intervalMinutes" 60 $apiRsEtl)) + (dict "name" "SLACK_ETL_RETENTION_DAYS" "value" (dig "slack" "retention" "etlDays" 0 $apiRsEtl)) + (dict "name" "SLACK_DM_RETENTION_DAYS" "value" (dig "slack" "retention" "dmDays" 0 $apiRsEtl)) + (dict "name" "LINEAR_ETL_ENABLED" "value" (dig "linear" "enabled" false $apiRsEtl)) + (dict "name" "LINEAR_SYNC_INTERVAL_SECONDS" "value" (dig "linear" "syncIntervalSeconds" 14400 $apiRsEtl)) + (dict "name" "GOOGLE_DRIVE_ETL_ENABLED" "value" (dig "googleDrive" "enabled" false $apiRsEtl)) + (dict "name" "GOOGLE_DRIVE_SYNC_INTERVAL_SECONDS" "value" (dig "googleDrive" "syncIntervalSeconds" 14400 $apiRsEtl)) + (dict "name" "GOOGLE_CALENDAR_ETL_ENABLED" "value" (dig "googleCalendar" "enabled" false $apiRsEtl)) + (dict "name" "GOOGLE_CALENDAR_SYNC_INTERVAL_SECONDS" "value" (dig "googleCalendar" "syncIntervalSeconds" 14400 $apiRsEtl)) + (dict "name" "COMPANY_CONTEXT_DOCUMENTS_ENABLED" "value" (dig "companyContextDocuments" "enabled" true $apiRsEtl)) + (dict "name" "COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS" "value" (dig "companyContextDocuments" "intervalSeconds" 14400 $apiRsEtl)) +-}} +{{- $apiRsEtlPassthroughNames := list -}} +{{- range $env := $apiRsEtlEnv -}} +{{- $apiRsEtlPassthroughNames = append $apiRsEtlPassthroughNames $env.name -}} +{{- end -}} +{{- with (get .Values.apiRs.extraEnv "SESSION_SANDBOX_PASSTHROUGH_ENV") -}} +{{- range $name := splitList "," (toString .) -}} +{{- $trimmedName := trim $name -}} +{{- if $trimmedName -}} +{{- $apiRsEtlPassthroughNames = append $apiRsEtlPassthroughNames $trimmedName -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- $apiRsEtlPassthroughNames = uniq $apiRsEtlPassthroughNames -}} apiVersion: v1 kind: ServiceAccount metadata: @@ -174,6 +213,17 @@ spec: - name: WORKFLOW_ALLOWED_NAMES value: {{ .Values.apiRs.workflowAllowedNames | quote }} {{- end }} +{{- range $env := $apiRsEtlEnv }} +{{- if not (hasKey $.Values.apiRs.extraEnv $env.name) }} + - name: {{ $env.name }} + value: {{ $env.value | toString | quote }} +{{- end }} +{{- end }} + # Forward the chart-rendered ETL workflow config into workflow-host + # sandboxes. Operators should set apiRs.etl.* values, not maintain + # SESSION_SANDBOX_PASSTHROUGH_ENV by hand. + - name: SESSION_SANDBOX_PASSTHROUGH_ENV + value: {{ join "," $apiRsEtlPassthroughNames | quote }} {{- if .Values.overlay.systemPrompt }} - name: CENTAUR_OVERLAY_DIR value: {{ .Values.overlay.mountPath | quote }} @@ -345,8 +395,10 @@ spec: {{- end }} {{- end }} {{- range $name, $value := .Values.apiRs.extraEnv }} +{{- if ne $name "SESSION_SANDBOX_PASSTHROUGH_ENV" }} - name: {{ $name }} value: {{ $value | quote }} +{{- end }} {{- end }} envFrom: - secretRef: diff --git a/contrib/chart/values.schema.json b/contrib/chart/values.schema.json index 26a9d1f03..c6d1d8b2b 100644 --- a/contrib/chart/values.schema.json +++ b/contrib/chart/values.schema.json @@ -242,6 +242,74 @@ "type": "object", "properties": { "syncInfraSecrets": { "type": "boolean" }, + "etl": { + "type": "object", + "properties": { + "slack": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "syncIntervalSeconds": { "type": "integer" }, + "syncBackfillLookbackDays": { "type": "integer" }, + "syncThreadLookbackDays": { "type": "integer" }, + "excludedChannelPatterns": { "type": "string" }, + "attachments": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "maxBytes": { "type": "integer" } + } + }, + "backfill": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "intervalSeconds": { "type": "integer" }, + "channelBatchLimit": { "type": "integer" }, + "channelPagesPerJob": { "type": "integer" } + } + }, + "retention": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "intervalMinutes": { "type": "integer" }, + "etlDays": { "type": "integer" }, + "dmDays": { "type": "integer" } + } + } + } + }, + "linear": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "syncIntervalSeconds": { "type": "integer" } + } + }, + "googleDrive": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "syncIntervalSeconds": { "type": "integer" } + } + }, + "googleCalendar": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "syncIntervalSeconds": { "type": "integer" } + } + }, + "companyContextDocuments": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "intervalSeconds": { "type": "integer" } + } + } + } + }, "metrics": { "type": "object", "properties": { diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index 78cd6b207..c8d486ced 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -308,6 +308,41 @@ apiRs: # in workflowAllowedNames as a comma/whitespace-separated string. workflowEnableMode: all workflowAllowedNames: "" + # Scheduled ETL workflow configuration. The chart renders these into api-rs + # env so workflow discovery sees the right schedules, then derives the + # workflow-host passthrough list from the same non-secret config. + etl: + slack: + enabled: false + syncIntervalSeconds: 3600 + syncBackfillLookbackDays: 30 + syncThreadLookbackDays: 3 + excludedChannelPatterns: "" + attachments: + enabled: true + maxBytes: 10485760 + backfill: + enabled: true + intervalSeconds: 600 + channelBatchLimit: 50 + channelPagesPerJob: 5 + retention: + enabled: true + intervalMinutes: 60 + etlDays: 0 + dmDays: 0 + linear: + enabled: false + syncIntervalSeconds: 14400 + googleDrive: + enabled: false + syncIntervalSeconds: 14400 + googleCalendar: + enabled: false + syncIntervalSeconds: 14400 + companyContextDocuments: + enabled: true + intervalSeconds: 14400 # Reaper: stop sandboxes idle-paused longer than the idle TTL or older than # the max lifetime. 0 disables that sweep. Interval must be >= 1. sandboxIdleStopTtlSecs: 10800 # 3 hours diff --git a/docs/pages/operate/slack-etl.mdx b/docs/pages/operate/slack-etl.mdx index 1a9747de8..d1bb69531 100644 --- a/docs/pages/operate/slack-etl.mdx +++ b/docs/pages/operate/slack-etl.mdx @@ -6,7 +6,7 @@ description: Sync Slack channel history into Postgres, drain historical backfill # Slack ETL :::warning[Off by default in production] -Slack ETL is disabled unless the API service has `SLACK_ETL_ENABLED=true`. +Slack ETL is disabled unless Helm values set `apiRs.etl.slack.enabled=true`. Production deployments should enable it deliberately after choosing the Slack token, channel scope, exclusion patterns, and data boundary they want agents to use. @@ -58,8 +58,17 @@ events. ## Enable the schedules -Set `SLACK_ETL_ENABLED=true` on the API service. The other schedules default on -once Slack ETL is enabled, but can be tuned independently. +Set `apiRs.etl.slack.enabled=true` in Helm values. The chart renders the +corresponding API and workflow-host env automatically; do not set +`SESSION_SANDBOX_PASSTHROUGH_ENV` by hand for these ETLs. The other schedules +default on once Slack ETL is enabled, but can be tuned independently. + +```yaml +apiRs: + etl: + slack: + enabled: true +``` | Environment variable | Default | Effect | |----------------------|---------|--------| @@ -244,7 +253,7 @@ setting alerts. | Symptom | What to check | |---------|---------------| | Schedules are missing | Confirm `WORKFLOW_DIRS` includes `/app/workflows` and the API restarted after the workflow files were deployed. | -| Schedules exist but are disabled | Confirm `SLACK_ETL_ENABLED=true` is present in the API environment. | +| Schedules exist but are disabled | Confirm Helm values set `apiRs.etl.slack.enabled=true` and the API pod was restarted. | | `slack_sync` skips with `no_public_channels` | Confirm the ETL user token can see the expected public channels. | | Channels are all skipped | Check `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` for broad globs. | | Checkpoints show `missing_scope` or `not_allowed_token_type` | Add the missing Slack OAuth scope or use the expected user-token class. | diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index 0cf09a21e..d343992ba 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -209,22 +209,29 @@ Slack ETL workflows: | Env var | Set from | Controls | | --- | --- | --- | -| `SLACK_ETL_ENABLED` | `api.slackEtlEnabled`. | Master switch for Slack sync/backfill/context schedules. | -| `SLACK_SYNC_INTERVAL_SECONDS`, `SLACK_BACKFILL_INTERVAL_SECONDS`, `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `api.*IntervalSeconds`. | Slack ETL schedule intervals. | -| `SLACK_SYNC_BACKFILL_LOOKBACK_DAYS`, `SLACK_SYNC_THREAD_LOOKBACK_DAYS` | `api.slackSync*LookbackDays`. | Slack history/thread lookback windows. | -| `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` | `api.slackEtlExcludedChannelPatterns`. | Comma-separated channel-name globs to skip. | -| `SLACK_BACKFILL_ENABLED`, `SLACK_BACKFILL_CHANNEL_BATCH_LIMIT`, `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `api.extraEnv` or chart batch limit. | Backfill enablement and batch sizing. | -| `SLACK_RETENTION_INTERVAL_MINUTES`, `SLACK_ETL_RETENTION_DAYS`, `SLACK_DM_RETENTION_DAYS` | `api.extraEnv`. | Slack retention cadence and separate public ETL/DM TTLs. | -| `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `api.extraEnv`. | Enables company-context projection when Slack ETL is on. | +| `SLACK_ETL_ENABLED` | `apiRs.etl.slack.enabled`. | Master switch for Slack sync/backfill/context schedules. | +| `SLACK_SYNC_INTERVAL_SECONDS`, `SLACK_BACKFILL_INTERVAL_SECONDS`, `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `apiRs.etl.slack.syncIntervalSeconds`, `apiRs.etl.slack.backfill.intervalSeconds`, `apiRs.etl.companyContextDocuments.intervalSeconds`. | Slack ETL schedule intervals. | +| `SLACK_SYNC_BACKFILL_LOOKBACK_DAYS`, `SLACK_SYNC_THREAD_LOOKBACK_DAYS` | `apiRs.etl.slack.syncBackfillLookbackDays`, `apiRs.etl.slack.syncThreadLookbackDays`. | Slack history/thread lookback windows. | +| `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` | `apiRs.etl.slack.excludedChannelPatterns`. | Comma-separated channel-name globs to skip. | +| `SLACK_BACKFILL_ENABLED`, `SLACK_BACKFILL_CHANNEL_BATCH_LIMIT`, `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `apiRs.etl.slack.backfill.*`. | Backfill enablement and batch sizing. | +| `SLACK_RETENTION_ENABLED`, `SLACK_RETENTION_INTERVAL_MINUTES`, `SLACK_ETL_RETENTION_DAYS`, `SLACK_DM_RETENTION_DAYS` | `apiRs.etl.slack.retention.*`. | Slack retention enablement, cadence, and separate public ETL/DM TTLs. | +| `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `apiRs.etl.companyContextDocuments.enabled`. | Enables company-context projection when any ETL is on. | Google Workspace ETL workflows: | Env var | Set from | Controls | | --- | --- | --- | -| `GOOGLE_DRIVE_ETL_ENABLED` | `api.googleDriveEtlEnabled`. | Enables Google Drive Docs sync. | -| `GOOGLE_DRIVE_SYNC_INTERVAL_SECONDS` | `api.googleDriveSyncIntervalSeconds`. | Google Drive Docs sync schedule interval. | -| `GOOGLE_CALENDAR_ETL_ENABLED` | `api.googleCalendarEtlEnabled`. | Enables Google Calendar sync. | -| `GOOGLE_CALENDAR_SYNC_INTERVAL_SECONDS` | `api.googleCalendarSyncIntervalSeconds`. | Google Calendar sync schedule interval. | +| `GOOGLE_DRIVE_ETL_ENABLED` | `apiRs.etl.googleDrive.enabled`. | Enables Google Drive Docs sync. | +| `GOOGLE_DRIVE_SYNC_INTERVAL_SECONDS` | `apiRs.etl.googleDrive.syncIntervalSeconds`. | Google Drive Docs sync schedule interval. | +| `GOOGLE_CALENDAR_ETL_ENABLED` | `apiRs.etl.googleCalendar.enabled`. | Enables Google Calendar sync. | +| `GOOGLE_CALENDAR_SYNC_INTERVAL_SECONDS` | `apiRs.etl.googleCalendar.syncIntervalSeconds`. | Google Calendar sync schedule interval. | + +Linear ETL workflows: + +| Env var | Set from | Controls | +| --- | --- | --- | +| `LINEAR_ETL_ENABLED` | `apiRs.etl.linear.enabled`. | Enables Linear project/issue/comment sync. | +| `LINEAR_SYNC_INTERVAL_SECONDS` | `apiRs.etl.linear.syncIntervalSeconds`. | Linear sync schedule interval. | ## Observability and Retention diff --git a/docs/public/md/operate/slack-etl.md b/docs/public/md/operate/slack-etl.md index 0db93cb60..e6e3bde3c 100644 --- a/docs/public/md/operate/slack-etl.md +++ b/docs/public/md/operate/slack-etl.md @@ -6,7 +6,7 @@ description: Sync Slack channel history into Postgres, drain historical backfill # Slack ETL :::warning[Off by default in production] -Slack ETL is disabled unless the API service has `SLACK_ETL_ENABLED=true`. +Slack ETL is disabled unless Helm values set `apiRs.etl.slack.enabled=true`. Production deployments should enable it deliberately after choosing the Slack token, channel scope, exclusion patterns, and data boundary they want agents to use. @@ -58,8 +58,17 @@ events. ## Enable the schedules -Set `SLACK_ETL_ENABLED=true` on the API service. The other schedules default on -once Slack ETL is enabled, but can be tuned independently. +Set `apiRs.etl.slack.enabled=true` in Helm values. The chart renders the +corresponding API and workflow-host env automatically; do not set +`SESSION_SANDBOX_PASSTHROUGH_ENV` by hand for these ETLs. The other schedules +default on once Slack ETL is enabled, but can be tuned independently. + +```yaml +apiRs: + etl: + slack: + enabled: true +``` | Environment variable | Default | Effect | |----------------------|---------|--------| @@ -244,7 +253,7 @@ setting alerts. | Symptom | What to check | |---------|---------------| | Schedules are missing | Confirm `WORKFLOW_DIRS` includes `/app/workflows` and the API restarted after the workflow files were deployed. | -| Schedules exist but are disabled | Confirm `SLACK_ETL_ENABLED=true` is present in the API environment. | +| Schedules exist but are disabled | Confirm Helm values set `apiRs.etl.slack.enabled=true` and the API pod was restarted. | | `slack_sync` skips with `no_public_channels` | Confirm the ETL user token can see the expected public channels. | | Channels are all skipped | Check `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` for broad globs. | | Checkpoints show `missing_scope` or `not_allowed_token_type` | Add the missing Slack OAuth scope or use the expected user-token class. | diff --git a/docs/public/md/reference/configuration.md b/docs/public/md/reference/configuration.md index 98811f2d1..0fc428f02 100644 --- a/docs/public/md/reference/configuration.md +++ b/docs/public/md/reference/configuration.md @@ -211,22 +211,29 @@ Slack ETL workflows: | Env var | Set from | Controls | | --- | --- | --- | -| `SLACK_ETL_ENABLED` | `api.slackEtlEnabled`. | Master switch for Slack sync/backfill/context schedules. | -| `SLACK_SYNC_INTERVAL_SECONDS`, `SLACK_BACKFILL_INTERVAL_SECONDS`, `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `api.*IntervalSeconds`. | Slack ETL schedule intervals. | -| `SLACK_SYNC_BACKFILL_LOOKBACK_DAYS`, `SLACK_SYNC_THREAD_LOOKBACK_DAYS` | `api.slackSync*LookbackDays`. | Slack history/thread lookback windows. | -| `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` | `api.slackEtlExcludedChannelPatterns`. | Comma-separated channel-name globs to skip. | -| `SLACK_BACKFILL_ENABLED`, `SLACK_BACKFILL_CHANNEL_BATCH_LIMIT`, `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `api.extraEnv` or chart batch limit. | Backfill enablement and batch sizing. | -| `SLACK_RETENTION_INTERVAL_MINUTES`, `SLACK_ETL_RETENTION_DAYS`, `SLACK_DM_RETENTION_DAYS` | `api.extraEnv`. | Slack retention cadence and separate public ETL/DM TTLs. | -| `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `api.extraEnv`. | Enables company-context projection when Slack ETL is on. | +| `SLACK_ETL_ENABLED` | `apiRs.etl.slack.enabled`. | Master switch for Slack sync/backfill/context schedules. | +| `SLACK_SYNC_INTERVAL_SECONDS`, `SLACK_BACKFILL_INTERVAL_SECONDS`, `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `apiRs.etl.slack.syncIntervalSeconds`, `apiRs.etl.slack.backfill.intervalSeconds`, `apiRs.etl.companyContextDocuments.intervalSeconds`. | Slack ETL schedule intervals. | +| `SLACK_SYNC_BACKFILL_LOOKBACK_DAYS`, `SLACK_SYNC_THREAD_LOOKBACK_DAYS` | `apiRs.etl.slack.syncBackfillLookbackDays`, `apiRs.etl.slack.syncThreadLookbackDays`. | Slack history/thread lookback windows. | +| `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` | `apiRs.etl.slack.excludedChannelPatterns`. | Comma-separated channel-name globs to skip. | +| `SLACK_BACKFILL_ENABLED`, `SLACK_BACKFILL_CHANNEL_BATCH_LIMIT`, `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `apiRs.etl.slack.backfill.*`. | Backfill enablement and batch sizing. | +| `SLACK_RETENTION_ENABLED`, `SLACK_RETENTION_INTERVAL_MINUTES`, `SLACK_ETL_RETENTION_DAYS`, `SLACK_DM_RETENTION_DAYS` | `apiRs.etl.slack.retention.*`. | Slack retention enablement, cadence, and separate public ETL/DM TTLs. | +| `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `apiRs.etl.companyContextDocuments.enabled`. | Enables company-context projection when any ETL is on. | Google Workspace ETL workflows: | Env var | Set from | Controls | | --- | --- | --- | -| `GOOGLE_DRIVE_ETL_ENABLED` | `api.googleDriveEtlEnabled`. | Enables Google Drive Docs sync. | -| `GOOGLE_DRIVE_SYNC_INTERVAL_SECONDS` | `api.googleDriveSyncIntervalSeconds`. | Google Drive Docs sync schedule interval. | -| `GOOGLE_CALENDAR_ETL_ENABLED` | `api.googleCalendarEtlEnabled`. | Enables Google Calendar sync. | -| `GOOGLE_CALENDAR_SYNC_INTERVAL_SECONDS` | `api.googleCalendarSyncIntervalSeconds`. | Google Calendar sync schedule interval. | +| `GOOGLE_DRIVE_ETL_ENABLED` | `apiRs.etl.googleDrive.enabled`. | Enables Google Drive Docs sync. | +| `GOOGLE_DRIVE_SYNC_INTERVAL_SECONDS` | `apiRs.etl.googleDrive.syncIntervalSeconds`. | Google Drive Docs sync schedule interval. | +| `GOOGLE_CALENDAR_ETL_ENABLED` | `apiRs.etl.googleCalendar.enabled`. | Enables Google Calendar sync. | +| `GOOGLE_CALENDAR_SYNC_INTERVAL_SECONDS` | `apiRs.etl.googleCalendar.syncIntervalSeconds`. | Google Calendar sync schedule interval. | + +Linear ETL workflows: + +| Env var | Set from | Controls | +| --- | --- | --- | +| `LINEAR_ETL_ENABLED` | `apiRs.etl.linear.enabled`. | Enables Linear project/issue/comment sync. | +| `LINEAR_SYNC_INTERVAL_SECONDS` | `apiRs.etl.linear.syncIntervalSeconds`. | Linear sync schedule interval. | ## Observability and Retention From 49d3db05083ac973a915a8c38c94b2d880db3b74 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:59:48 +0300 Subject: [PATCH 008/198] Fix Slack event file attachments (#822) fix slack event file attachments --- services/slackbotv2/src/session-api.ts | 70 +++++++++++++++++++ .../slackbotv2/test/chat-sdk-emulate.test.ts | 54 ++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index da4c0222e..2d3905005 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -213,6 +213,11 @@ export async function serializeMessage( for (const attachment of message.attachments) { attachments.push(await serializeAttachment(attachment, options)) } + if (attachments.length === 0) { + for (const attachment of slackRawFileAttachments(message.raw, options)) { + attachments.push(await serializeAttachment(attachment, options)) + } + } const displayText = renderSlackDisplayText({ raw: message.raw, text: message.text }) return { @@ -239,6 +244,71 @@ export async function serializeMessage( } } +function slackRawFileAttachments(raw: unknown, options?: SlackbotV2Options): Attachment[] { + const files = slackRawFiles(raw) + if (files.length === 0) return [] + return files.map(file => slackRawFileAttachment(file, raw, options)) +} + +function slackRawFiles(raw: unknown): JsonObject[] { + const records = slackRawRecords(raw) + const files: JsonObject[] = [] + for (const record of records) { + const rawFiles = record.files + if (!Array.isArray(rawFiles)) continue + for (const file of rawFiles) { + if (isJsonObject(file)) files.push(file) + } + } + return files +} + +function slackRawFileAttachment( + file: JsonObject, + raw: unknown, + options?: SlackbotV2Options +): Attachment { + const url = stringValue(file.url_private_download) ?? stringValue(file.url_private) + const mimeType = stringValue(file.mimetype) + const fetchMetadata: Record = {} + const teamId = slackTeamId(raw) + if (url) fetchMetadata.url = url + if (teamId) fetchMetadata.teamId = teamId + return { + fetchData: url && options ? () => fetchSlackRawFile(options, url) : undefined, + fetchMetadata: Object.keys(fetchMetadata).length > 0 ? fetchMetadata : undefined, + height: numberValue(file.original_h), + mimeType, + name: stringValue(file.name) ?? stringValue(file.title) ?? stringValue(file.id), + size: numberValue(file.size), + type: slackRawFileAttachmentType(mimeType), + url, + width: numberValue(file.original_w) + } +} + +async function fetchSlackRawFile(options: SlackbotV2Options, url: string): Promise { + const fetchFn = options.fetch ?? fetch + const response = await fetchFn(url, { + headers: { authorization: `Bearer ${options.botToken}` } + }) + if (!response.ok) { + throw new Error(`failed to fetch Slack file: ${response.status} ${response.statusText}`) + } + return Buffer.from(await response.arrayBuffer()) +} + +function slackRawFileAttachmentType(mimeType: string | undefined): Attachment['type'] { + if (mimeType?.startsWith('image/')) return 'image' + if (mimeType?.startsWith('video/')) return 'video' + if (mimeType?.startsWith('audio/')) return 'audio' + return 'file' +} + +function numberValue(value: JsonValue | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + const SLACK_MESSAGE_URL_PATTERN = /^https:\/\/[^/\s]+\.slack\.com\/archives\/[A-Z0-9]+\/p\d+/i export function serializeMessageLinks( diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 7344fd7f4..182f76506 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -392,6 +392,60 @@ describe('slackbotv2', () => { expect(executeInput).toContain('summarize the thread so far') }) + it('materializes Slack event files on root mentions without fetching thread replies', async () => { + const mention = await postUserMessage(`<@${BOT_USER_ID}> inspect this root screenshot`) + const fileUrl = `${slackApi.url}/files/captured.png` + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-root-file-mention', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + text: `<@${BOT_USER_ID}> inspect this root screenshot`, + files: [ + { + id: 'F-root-captured', + mimetype: 'image/png', + name: 'captured.png', + original_h: 600, + original_w: 800, + size: 16, + url_private: fileUrl + } + ] + } + }), + {}, + waitUntilContext(waits) + ) + + expect(response.status).toBe(200) + await Promise.all(waits) + + const appendedAttachment = codexApi.appends[0]!.body.messages + .flatMap(message => message.parts) + .find(part => isRecord(part) && part.type === 'attachment') + expect(appendedAttachment).toEqual( + expect.objectContaining({ + attachment_type: 'image', + dataBase64: Buffer.from('captured-image').toString('base64'), + mimeType: 'image/png', + name: 'captured.png', + type: 'attachment', + url: fileUrl + }) + ) + + const executeInput = JSON.stringify(JSON.parse(codexApi.executes[0]!.body.input_lines[0]!)) + expect(executeInput).toContain(`"dataBase64":"${Buffer.from('captured-image').toString('base64')}"`) + expect(executeInput).toContain('"attachment_type":"image"') + }) + it('fetches attachments from preceding Slack thread messages for a mid-thread mention', async () => { const parent = await postUserMessage('Root context before an attachment.') const priorReply = await postUserMessage('Screenshot is attached here.', parent.ts) From 21ec5bbbbe3086816e2990d44c5b974a2114e7f7 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Tue, 30 Jun 2026 09:47:43 -0700 Subject: [PATCH 009/198] fix: remove rich table helpers from sdk (#828) --- centaur_sdk/README.md | 14 ------------- centaur_sdk/__init__.py | 5 ----- centaur_sdk/cli_tables.py | 30 ---------------------------- centaur_sdk/pyproject.toml | 4 +--- tools/business/ashby/cli.py | 2 +- tools/business/attio/cli.py | 2 +- tools/business/pylon/cli.py | 2 +- tools/comms/discord/cli.py | 3 +-- tools/comms/synoptic/cli.py | 3 +-- tools/comms/telegram/cli.py | 3 +-- tools/comms/twitter/cli.py | 3 +-- tools/crypto/allium/cli.py | 3 +-- tools/crypto/arkham/cli.py | 3 +-- tools/crypto/coindesk/cli.py | 3 +-- tools/crypto/coingecko/cli.py | 3 +-- tools/crypto/coinmetrics/cli.py | 3 +-- tools/crypto/defillama/cli.py | 3 +-- tools/crypto/dune/cli.py | 3 +-- tools/crypto/kalshi/cli.py | 3 +-- tools/crypto/messari/cli.py | 3 +-- tools/crypto/nansen/cli.py | 3 +-- tools/crypto/polymarket/cli.py | 3 +-- tools/crypto/standard-metrics/cli.py | 2 +- tools/infra/amplitude/cli.py | 3 +-- tools/infra/grafana/cli.py | 3 +-- tools/infra/posthog/cli.py | 3 +-- tools/infra/profslice/cli.py | 3 +-- tools/infra/reth-log-analyzer/cli.py | 3 +-- tools/infra/reth/cli.py | 3 +-- tools/infra/vlogs/cli.py | 3 +-- tools/productivity/figma/cli.py | 3 +-- tools/productivity/granola/cli.py | 2 +- tools/productivity/gsuite/cli.py | 2 +- tools/productivity/linear/cli.py | 3 +-- tools/productivity/notion/cli.py | 3 +-- tools/productivity/slack/cli.py | 2 +- tools/research/congress/cli.py | 2 +- tools/research/fedreg/cli.py | 2 +- tools/research/googlenews/cli.py | 3 +-- tools/research/harmonic/cli.py | 3 +-- tools/research/legistorm/cli.py | 2 +- tools/research/listennotes/cli.py | 3 +-- tools/research/newsapi/cli.py | 3 +-- tools/research/openfec/cli.py | 2 +- tools/research/plural/cli.py | 2 +- tools/research/sensortower/cli.py | 3 +-- tools/research/similarweb/cli.py | 3 +-- 47 files changed, 44 insertions(+), 126 deletions(-) delete mode 100644 centaur_sdk/cli_tables.py diff --git a/centaur_sdk/README.md b/centaur_sdk/README.md index ba3ccb664..d50da1f74 100644 --- a/centaur_sdk/README.md +++ b/centaur_sdk/README.md @@ -30,17 +30,3 @@ from centaur_sdk.backends import configure, DotEnvBackend configure(DotEnvBackend(".env")) ``` - -### CLI tables - -```python -from centaur_sdk import Table, render_text_table - -# Rich table (interactive) -table = Table(title="Results") -table.add_column("Name") -table.add_row("example") - -# Plain-text table (for piping) -print(render_text_table(["Name", "Value"], [["a", "1"], ["b", "2"]])) -``` diff --git a/centaur_sdk/__init__.py b/centaur_sdk/__init__.py index 29bbce785..e27b6421c 100644 --- a/centaur_sdk/__init__.py +++ b/centaur_sdk/__init__.py @@ -2,13 +2,10 @@ Public API: secret(key) — resolve a secret via the pluggable backend - Table — Rich table (re-export for CLI tools) - render_text_table — plain-text table renderer """ from __future__ import annotations -from centaur_sdk.cli_tables import Table, render_text_table from centaur_sdk.tool_sdk import ( ToolContext, current_session_context, @@ -23,13 +20,11 @@ ) __all__ = [ - "Table", "ToolContext", "current_session_context", "current_slack_thread", "current_thread_key", "get_tool_context", - "render_text_table", "reset_tool_context", "save_attachment", "save_attachment_from_path", diff --git a/centaur_sdk/cli_tables.py b/centaur_sdk/cli_tables.py deleted file mode 100644 index 4db2a04c7..000000000 --- a/centaur_sdk/cli_tables.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Table rendering helpers for tool CLIs.""" - -from __future__ import annotations - -from rich.table import Table as RichTable - -Table = RichTable - - -def render_text_table(headers: list[str], rows: list[list[str]]) -> str: - """Render a plain-text table with padded columns. - - Useful for CLIs that should avoid hardcoding one-off spacing logic. - """ - if not headers: - return "" - if not rows: - return "No rows." - - widths = [len(header) for header in headers] - for row in rows: - for idx, cell in enumerate(row): - widths[idx] = max(widths[idx], len(cell)) - - def _format(row: list[str]) -> str: - return " ".join(cell.ljust(widths[idx]) for idx, cell in enumerate(row)) - - lines = [_format(headers), " ".join("-" * width for width in widths)] - lines.extend(_format(row) for row in rows) - return "\n".join(lines) diff --git a/centaur_sdk/pyproject.toml b/centaur_sdk/pyproject.toml index 33342adcc..7f2753b3f 100644 --- a/centaur_sdk/pyproject.toml +++ b/centaur_sdk/pyproject.toml @@ -5,9 +5,7 @@ description = "Lightweight SDK for building Centaur-compatible tools" requires-python = ">=3.11" readme = "README.md" license = "Apache-2.0 OR MIT" -dependencies = [ - "rich>=13.0", -] +dependencies = [] [project.optional-dependencies] http = ["httpx>=0.28.0"] diff --git a/tools/business/ashby/cli.py b/tools/business/ashby/cli.py index 02e4e990d..faba9cb09 100644 --- a/tools/business/ashby/cli.py +++ b/tools/business/ashby/cli.py @@ -10,8 +10,8 @@ from datetime import datetime, timezone import typer -from centaur_sdk import Table from rich.console import Console +from rich.table import Table app = typer.Typer(name="ashby", help="Ashby ATS CLI for AI agents") diff --git a/tools/business/attio/cli.py b/tools/business/attio/cli.py index cb2729240..15590e90a 100644 --- a/tools/business/attio/cli.py +++ b/tools/business/attio/cli.py @@ -6,7 +6,7 @@ import typer from dotenv import load_dotenv from rich.console import Console -from centaur_sdk import Table +from rich.table import Table from .client import AttioClient diff --git a/tools/business/pylon/cli.py b/tools/business/pylon/cli.py index 1d3a80ba1..12515ac66 100644 --- a/tools/business/pylon/cli.py +++ b/tools/business/pylon/cli.py @@ -9,7 +9,7 @@ import typer from rich.console import Console -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="pylon", help="Pylon CLI for AI agents") diff --git a/tools/comms/discord/cli.py b/tools/comms/discord/cli.py index 5799daeb0..ad2f47c88 100644 --- a/tools/comms/discord/cli.py +++ b/tools/comms/discord/cli.py @@ -8,8 +8,7 @@ import typer # noqa: E402 from rich.console import Console # noqa: E402 - -from centaur_sdk import Table # noqa: E402 +from rich.table import Table # noqa: E402 app = typer.Typer(name="discord", help="Discord self-token CLI for AI agents") diff --git a/tools/comms/synoptic/cli.py b/tools/comms/synoptic/cli.py index 44153db65..9440a3c36 100644 --- a/tools/comms/synoptic/cli.py +++ b/tools/comms/synoptic/cli.py @@ -9,8 +9,7 @@ import typer # noqa: E402 from rich.console import Console # noqa: E402 - -from centaur_sdk import Table # noqa: E402 +from rich.table import Table # noqa: E402 from .client import _client # noqa: E402 diff --git a/tools/comms/telegram/cli.py b/tools/comms/telegram/cli.py index 4f61ae683..9956b78b0 100644 --- a/tools/comms/telegram/cli.py +++ b/tools/comms/telegram/cli.py @@ -9,8 +9,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="telegram", help="Telegram CLI for AI agents") diff --git a/tools/comms/twitter/cli.py b/tools/comms/twitter/cli.py index 09f44199d..b193794e5 100644 --- a/tools/comms/twitter/cli.py +++ b/tools/comms/twitter/cli.py @@ -9,8 +9,7 @@ import typer # noqa: E402 from rich.console import Console # noqa: E402 - -from centaur_sdk import Table # noqa: E402 +from rich.table import Table # noqa: E402 from .client import _client # noqa: E402 diff --git a/tools/crypto/allium/cli.py b/tools/crypto/allium/cli.py index b00db26e0..9de2ac3e3 100644 --- a/tools/crypto/allium/cli.py +++ b/tools/crypto/allium/cli.py @@ -9,8 +9,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table from .client import AlliumClient, get_example_queries diff --git a/tools/crypto/arkham/cli.py b/tools/crypto/arkham/cli.py index e51518afe..6a427b360 100644 --- a/tools/crypto/arkham/cli.py +++ b/tools/crypto/arkham/cli.py @@ -9,8 +9,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="arkham", help="Arkham Intelligence CLI for blockchain analytics") console = Console() diff --git a/tools/crypto/coindesk/cli.py b/tools/crypto/coindesk/cli.py index 545f1e371..e45f788d0 100644 --- a/tools/crypto/coindesk/cli.py +++ b/tools/crypto/coindesk/cli.py @@ -8,8 +8,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table from .client import CoinDeskClient diff --git a/tools/crypto/coingecko/cli.py b/tools/crypto/coingecko/cli.py index a712806ab..86152b0a1 100644 --- a/tools/crypto/coingecko/cli.py +++ b/tools/crypto/coingecko/cli.py @@ -8,8 +8,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="coingecko", help="CoinGecko CLI for cryptocurrency market data") diff --git a/tools/crypto/coinmetrics/cli.py b/tools/crypto/coinmetrics/cli.py index bca2c75da..1bf601c96 100644 --- a/tools/crypto/coinmetrics/cli.py +++ b/tools/crypto/coinmetrics/cli.py @@ -8,8 +8,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="coinmetrics", help="Coin Metrics CLI for crypto market and on-chain data") diff --git a/tools/crypto/defillama/cli.py b/tools/crypto/defillama/cli.py index d2ca0f6e9..d65cd7b27 100644 --- a/tools/crypto/defillama/cli.py +++ b/tools/crypto/defillama/cli.py @@ -5,8 +5,7 @@ import typer from dotenv import load_dotenv from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table from .client import DefiLlamaClient diff --git a/tools/crypto/dune/cli.py b/tools/crypto/dune/cli.py index f29748867..86098705b 100644 --- a/tools/crypto/dune/cli.py +++ b/tools/crypto/dune/cli.py @@ -7,8 +7,7 @@ from dotenv import load_dotenv from rich.console import Console from rich.status import Status - -from centaur_sdk import Table +from rich.table import Table from .client import DuneClient diff --git a/tools/crypto/kalshi/cli.py b/tools/crypto/kalshi/cli.py index cc8dae089..5e85c0e2c 100644 --- a/tools/crypto/kalshi/cli.py +++ b/tools/crypto/kalshi/cli.py @@ -5,8 +5,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="kalshi", help="Kalshi prediction market CLI for market data and analytics") diff --git a/tools/crypto/messari/cli.py b/tools/crypto/messari/cli.py index 482edf355..79c6fea4b 100644 --- a/tools/crypto/messari/cli.py +++ b/tools/crypto/messari/cli.py @@ -5,8 +5,7 @@ import typer from dotenv import load_dotenv from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table from .client import MessariClient diff --git a/tools/crypto/nansen/cli.py b/tools/crypto/nansen/cli.py index fac648629..a75a74305 100644 --- a/tools/crypto/nansen/cli.py +++ b/tools/crypto/nansen/cli.py @@ -4,8 +4,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="nansen", help="Nansen CLI for blockchain analytics and wallet labels") diff --git a/tools/crypto/polymarket/cli.py b/tools/crypto/polymarket/cli.py index b49ebc4f2..81989214e 100644 --- a/tools/crypto/polymarket/cli.py +++ b/tools/crypto/polymarket/cli.py @@ -4,8 +4,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="polymarket", help="Polymarket CLI for prediction market data") diff --git a/tools/crypto/standard-metrics/cli.py b/tools/crypto/standard-metrics/cli.py index 47f49e579..8e8f3c260 100644 --- a/tools/crypto/standard-metrics/cli.py +++ b/tools/crypto/standard-metrics/cli.py @@ -8,7 +8,7 @@ import typer from rich.console import Console -from centaur_sdk.cli_tables import Table +from rich.table import Table app = typer.Typer(name="standard-metrics", help="Standard Metrics CLI for portfolio company data") diff --git a/tools/infra/amplitude/cli.py b/tools/infra/amplitude/cli.py index 7742ed19b..d9809dc43 100644 --- a/tools/infra/amplitude/cli.py +++ b/tools/infra/amplitude/cli.py @@ -4,8 +4,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="amplitude", help="Amplitude product analytics (Dashboard REST + Taxonomy)") diff --git a/tools/infra/grafana/cli.py b/tools/infra/grafana/cli.py index f105e8a4e..f24b3a837 100644 --- a/tools/infra/grafana/cli.py +++ b/tools/infra/grafana/cli.py @@ -5,8 +5,7 @@ import typer from dotenv import load_dotenv from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table load_dotenv() diff --git a/tools/infra/posthog/cli.py b/tools/infra/posthog/cli.py index 24a420b2a..0fcfc84c9 100644 --- a/tools/infra/posthog/cli.py +++ b/tools/infra/posthog/cli.py @@ -4,8 +4,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="posthog", help="PostHog CLI for product analytics and HogQL queries") diff --git a/tools/infra/profslice/cli.py b/tools/infra/profslice/cli.py index 162af9f2b..8c0b986e0 100644 --- a/tools/infra/profslice/cli.py +++ b/tools/infra/profslice/cli.py @@ -9,8 +9,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer( name="profslice", diff --git a/tools/infra/reth-log-analyzer/cli.py b/tools/infra/reth-log-analyzer/cli.py index 41d51db39..816a175c2 100644 --- a/tools/infra/reth-log-analyzer/cli.py +++ b/tools/infra/reth-log-analyzer/cli.py @@ -10,8 +10,7 @@ import json import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table from .client import _client diff --git a/tools/infra/reth/cli.py b/tools/infra/reth/cli.py index 998b26edc..77d96873f 100644 --- a/tools/infra/reth/cli.py +++ b/tools/infra/reth/cli.py @@ -9,8 +9,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="reth", help="Reth CLI for execution timings and performance metrics") diff --git a/tools/infra/vlogs/cli.py b/tools/infra/vlogs/cli.py index a0cc436d0..8e1a0de99 100644 --- a/tools/infra/vlogs/cli.py +++ b/tools/infra/vlogs/cli.py @@ -5,8 +5,7 @@ import typer from dotenv import load_dotenv from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table load_dotenv() diff --git a/tools/productivity/figma/cli.py b/tools/productivity/figma/cli.py index dd9169f4a..a4f170dda 100644 --- a/tools/productivity/figma/cli.py +++ b/tools/productivity/figma/cli.py @@ -8,8 +8,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="figma", help="Figma design system extraction") diff --git a/tools/productivity/granola/cli.py b/tools/productivity/granola/cli.py index 79a6fe515..d0a49d007 100644 --- a/tools/productivity/granola/cli.py +++ b/tools/productivity/granola/cli.py @@ -11,7 +11,7 @@ from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="granola", help="Query Granola meeting notes and transcripts") diff --git a/tools/productivity/gsuite/cli.py b/tools/productivity/gsuite/cli.py index 94ac00880..aefc5a99c 100644 --- a/tools/productivity/gsuite/cli.py +++ b/tools/productivity/gsuite/cli.py @@ -4,8 +4,8 @@ from pathlib import Path import typer -from centaur_sdk import Table from rich.console import Console +from rich.table import Table app = typer.Typer(name="gsuite", help="GSuite CLI for AI agents - Gmail, Calendar, Drive") diff --git a/tools/productivity/linear/cli.py b/tools/productivity/linear/cli.py index 9215b5724..28fe619e2 100644 --- a/tools/productivity/linear/cli.py +++ b/tools/productivity/linear/cli.py @@ -6,8 +6,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="linear", help="Linear CLI for AI agents") diff --git a/tools/productivity/notion/cli.py b/tools/productivity/notion/cli.py index 230af5bd8..767e3d2ec 100644 --- a/tools/productivity/notion/cli.py +++ b/tools/productivity/notion/cli.py @@ -6,8 +6,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="notion", help="Notion CLI for AI agents") diff --git a/tools/productivity/slack/cli.py b/tools/productivity/slack/cli.py index a0a314a3e..aa76b4f93 100644 --- a/tools/productivity/slack/cli.py +++ b/tools/productivity/slack/cli.py @@ -6,7 +6,7 @@ import typer from dotenv import load_dotenv from rich.console import Console -from centaur_sdk import Table +from rich.table import Table load_dotenv() diff --git a/tools/research/congress/cli.py b/tools/research/congress/cli.py index 59e6b74bb..96c6e8891 100644 --- a/tools/research/congress/cli.py +++ b/tools/research/congress/cli.py @@ -8,7 +8,7 @@ import typer from rich.console import Console -from centaur_sdk.cli_tables import Table +from rich.table import Table from .client import CongressClient diff --git a/tools/research/fedreg/cli.py b/tools/research/fedreg/cli.py index d3da86b67..ac55d9707 100644 --- a/tools/research/fedreg/cli.py +++ b/tools/research/fedreg/cli.py @@ -8,7 +8,7 @@ import typer from rich.console import Console -from centaur_sdk.cli_tables import Table +from rich.table import Table app = typer.Typer(name="fedreg", help="Federal Register CLI for regulatory data") diff --git a/tools/research/googlenews/cli.py b/tools/research/googlenews/cli.py index 42370ebc4..0cd416c3a 100644 --- a/tools/research/googlenews/cli.py +++ b/tools/research/googlenews/cli.py @@ -8,8 +8,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="googlenews", help="Google News CLI for news search and headlines") diff --git a/tools/research/harmonic/cli.py b/tools/research/harmonic/cli.py index fec30562f..333da03ef 100644 --- a/tools/research/harmonic/cli.py +++ b/tools/research/harmonic/cli.py @@ -9,8 +9,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="harmonic", help="Harmonic.AI API CLI for startup discovery and enrichment") diff --git a/tools/research/legistorm/cli.py b/tools/research/legistorm/cli.py index 0fa540349..774c01a24 100644 --- a/tools/research/legistorm/cli.py +++ b/tools/research/legistorm/cli.py @@ -4,8 +4,8 @@ from datetime import datetime, timedelta import typer -from centaur_sdk.cli_tables import Table from rich.console import Console +from rich.table import Table app = typer.Typer(name="legistorm", help="LegiStorm CLI for congressional data") diff --git a/tools/research/listennotes/cli.py b/tools/research/listennotes/cli.py index ae780463f..4722796b9 100644 --- a/tools/research/listennotes/cli.py +++ b/tools/research/listennotes/cli.py @@ -8,8 +8,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="listennotes", help="Listen Notes CLI for podcast data") diff --git a/tools/research/newsapi/cli.py b/tools/research/newsapi/cli.py index cbfb4a7ed..f22d7e911 100644 --- a/tools/research/newsapi/cli.py +++ b/tools/research/newsapi/cli.py @@ -8,8 +8,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="newsapi", help="NewsAPI CLI for news search and headlines") diff --git a/tools/research/openfec/cli.py b/tools/research/openfec/cli.py index 5961d0559..e471666f9 100644 --- a/tools/research/openfec/cli.py +++ b/tools/research/openfec/cli.py @@ -8,7 +8,7 @@ import typer from rich.console import Console -from centaur_sdk.cli_tables import Table +from rich.table import Table app = typer.Typer(name="openfec", help="OpenFEC CLI for federal election data") diff --git a/tools/research/plural/cli.py b/tools/research/plural/cli.py index c09bc0e9e..c896d7336 100644 --- a/tools/research/plural/cli.py +++ b/tools/research/plural/cli.py @@ -8,7 +8,7 @@ import typer from rich.console import Console -from centaur_sdk.cli_tables import Table +from rich.table import Table from .client import PluralClient diff --git a/tools/research/sensortower/cli.py b/tools/research/sensortower/cli.py index 73a640e38..939d92867 100644 --- a/tools/research/sensortower/cli.py +++ b/tools/research/sensortower/cli.py @@ -9,8 +9,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="sensortower", help="SensorTower CLI for mobile app analytics") diff --git a/tools/research/similarweb/cli.py b/tools/research/similarweb/cli.py index e9142bc4e..c3169aac3 100644 --- a/tools/research/similarweb/cli.py +++ b/tools/research/similarweb/cli.py @@ -5,8 +5,7 @@ import typer from rich.console import Console - -from centaur_sdk import Table +from rich.table import Table app = typer.Typer(name="similarweb", help="SimilarWeb CLI for web traffic and market intelligence") From d2c76d46e7fb6ed0c97c9b14a9654fe7c0761d7a Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Tue, 30 Jun 2026 10:31:55 -0700 Subject: [PATCH 010/198] fix: repair google docs drive fields selector (#817) --- services/console/Gemfile.lock | 2 +- .../console/app/services/google_docs/sync_credential.rb | 2 +- .../test/services/google_docs/sync_credential_test.rb | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/services/console/Gemfile.lock b/services/console/Gemfile.lock index ec00bda6e..d18751d60 100644 --- a/services/console/Gemfile.lock +++ b/services/console/Gemfile.lock @@ -101,7 +101,7 @@ GEM xpath (~> 3.2) concurrent-ruby (1.3.7) connection_pool (3.0.2) - crass (1.0.6) + crass (1.0.7) date (3.5.1) debug (1.11.1) irb (~> 1.10) diff --git a/services/console/app/services/google_docs/sync_credential.rb b/services/console/app/services/google_docs/sync_credential.rb index adff08df0..5f2ffc600 100644 --- a/services/console/app/services/google_docs/sync_credential.rb +++ b/services/console/app/services/google_docs/sync_credential.rb @@ -154,7 +154,7 @@ def files_list_params(modified_after:, page_token:) "q" => query.join(" and "), "pageSize" => self.class.page_size, "fields" => [ - "nextPageToken", + "nextPageToken,", "files(id,name,mimeType,webViewLink,driveId,owners,lastModifyingUser,", "capabilities,labelInfo,trashed,explicitlyTrashed,createdTime,modifiedTime,version)" ].join, diff --git a/services/console/test/services/google_docs/sync_credential_test.rb b/services/console/test/services/google_docs/sync_credential_test.rb index 8bfbf92b0..bba837129 100644 --- a/services/console/test/services/google_docs/sync_credential_test.rb +++ b/services/console/test/services/google_docs/sync_credential_test.rb @@ -99,6 +99,12 @@ def credential case endpoint when GoogleDocs::SyncCredential::FILES_LIST_ENDPOINT assert_includes params["q"], "modifiedTime > '2026-06-01T00:00:00Z'" + assert_equal( + "nextPageToken,files(id,name,mimeType,webViewLink,driveId,owners," \ + "lastModifyingUser,capabilities,labelInfo,trashed,explicitlyTrashed," \ + "createdTime,modifiedTime,version)", + params["fields"] + ) { "files" => [ { From e6d449f3cc7b42053147428ba634c358615eb039 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Tue, 30 Jun 2026 11:14:07 -0700 Subject: [PATCH 011/198] fix: repair delayed Slack file shares (#830) --- services/slackbotv2/src/index.ts | 344 ++++++++++++++++++ .../slackbotv2/test/chat-sdk-emulate.test.ts | 259 +++++++++++++ 2 files changed, 603 insertions(+) diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 32422d9cd..29de4b041 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto' import { Hono, type Context } from 'hono' import { Chat, + Message as ChatSdkMessage, + parseMarkdown, type Adapter, type Attachment, type Logger, @@ -115,6 +117,21 @@ const SLACK_TASK_DETAILS_MAX_CHARS = 500 const SLACK_FALLBACK_TEXT_MAX_CHARS = 35_000 const POSTGRES_CONNECT_INITIAL_DELAY_MS = 250 const POSTGRES_CONNECT_MAX_DELAY_MS = 10_000 +const LATE_SLACK_FILE_MATCH_WINDOW_MS = 15_000 +const LATE_SLACK_FILE_PENDING_TTL_MS = 60_000 +const LATE_SLACK_FILE_CONSUMED_TTL_MS = 5 * 60_000 +const LATE_SLACK_FILE_IDLE_WAIT_MS = 90_000 +const LATE_SLACK_FILE_IDLE_POLL_MS = 500 +const LATE_SLACK_FILE_MESSAGE_TEXT = 'Late Slack file attachment for the previous message.' + +type PendingLateSlackFileMention = { + channel: string + message: ChatMessage + mentionTs: string + teamId: string + thread: Thread + user: string +} export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { const userName = options.userName ?? 'centaur' @@ -135,9 +152,11 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { onLockConflict: 'force', logger }) + const lateSlackFiles = createLateSlackFileRepair(options, state) chat.onNewMention(async (thread, message) => { if (!isAllowedSlackMessage(message, options, logger)) return + lateSlackFiles.rememberFilelessMention(thread, message) await handleSlackMessageHandoff(thread, message, { assistantStatusRequested: true, mode: 'execute', @@ -150,6 +169,7 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { chat.onSubscribedMessage(async (thread, message) => { if (!isAllowedSlackMessage(message, options, logger)) return + lateSlackFiles.rememberFilelessMention(thread, message) await handleSlackMessageHandoff(thread, message, { assistantStatusRequested: message.isMention === true, mode: message.isMention === true ? 'execute' : 'append', @@ -234,6 +254,8 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { return new globalThis.Response('temporary upstream unavailable', { status: 503 }) } } + const lateFileTask = lateSlackFiles.repairFromWebhook(rawBody) + if (lateFileTask) waitUntil(c, lateFileTask) outcome = response.ok ? 'success' : 'error' return new globalThis.Response(await response.text(), { headers: response.headers, @@ -2034,6 +2056,328 @@ function backgroundWaitUntil(promise: Promise): void { void promise.catch(() => undefined) } +function createLateSlackFileRepair(options: SlackbotV2Options, state: StateAdapter) { + const pending = new Map() + const consumed = new Map() + + const cleanup = () => { + const cutoff = Date.now() - LATE_SLACK_FILE_PENDING_TTL_MS + for (const [key, entries] of pending) { + const fresh = entries.filter(entry => slackTsToMs(entry.mentionTs) >= cutoff) + if (fresh.length > 0) pending.set(key, fresh) + else pending.delete(key) + } + const consumedCutoff = Date.now() - LATE_SLACK_FILE_CONSUMED_TTL_MS + for (const [key, timestamp] of consumed) { + if (timestamp < consumedCutoff) consumed.delete(key) + } + } + + return { + rememberFilelessMention(thread: Thread, message: ChatMessage): void { + if (message.isMention !== true) return + const raw = slackRawRecord(message) + if (slackFiles(raw).length > 0 || message.attachments.length > 0) return + const teamId = stringField(raw.team) || stringField(raw.team_id) + const channel = stringField(raw.channel) + const user = stringField(raw.user) + const mentionTs = stringField(raw.ts) || message.id + if (!teamId || !channel || !user || !mentionTs) return + + cleanup() + const key = lateSlackFilePendingKey(teamId, channel, user) + const entry: PendingLateSlackFileMention = { + channel, + message, + mentionTs, + teamId, + thread, + user + } + const entries = [entry, ...(pending.get(key) ?? [])] + .filter(item => slackTsToMs(item.mentionTs) >= Date.now() - LATE_SLACK_FILE_PENDING_TTL_MS) + .slice(0, 20) + pending.set(key, entries) + traceLog(options, 'slackbotv2_late_file_pending_mention_recorded', undefined, { + slack_channel: channel, + slack_message_ts: mentionTs, + slack_team_id: teamId, + slack_user_id: user, + thread_id: thread.id + }) + }, + + repairFromWebhook(rawBody: string): Promise | null { + const payload = slackWebhookPayload(rawBody) + if (!payload) return null + const event = slackWebhookEvent(payload) + if (!event || !isLateSlackFileEvent(event, options)) return null + cleanup() + + const dedupeKey = lateSlackFileDedupeKey(payload, event) + if (consumed.has(dedupeKey)) { + traceLog(options, 'slackbotv2_late_file_duplicate_skipped', undefined, { + dedupe_key: dedupeKey + }) + return null + } + + const match = matchLateSlackFileMention(pending, payload, event) + if (!match) { + traceLog(options, 'slackbotv2_late_file_no_match', undefined, { + slack_channel: stringField(event.channel), + slack_event_id: stringField(payload.event_id), + slack_message_ts: stringField(event.ts), + slack_team_id: slackEventTeamId(payload, event), + slack_thread_ts: stringField(event.thread_ts), + slack_user_id: stringField(event.user) + }) + return null + } + + consumed.set(dedupeKey, Date.now()) + return repairLateSlackFileMessage(options, state, match, event).catch(error => { + traceWarn(options, 'slackbotv2_late_file_repair_failed', undefined, { + dedupe_key: dedupeKey, + error: errorMessage(error), + slack_channel: stringField(event.channel), + slack_message_ts: stringField(event.ts), + thread_id: match.thread.id + }) + }) + } + } +} + +async function repairLateSlackFileMessage( + options: SlackbotV2Options, + state: StateAdapter, + pending: PendingLateSlackFileMention, + event: Record +): Promise { + const startedAtMs = nowMs() + const eventTs = stringField(event.ts) + const hydratedEvent = await hydrateLateSlackFileEvent(options, event) + const ready = await waitForThreadIdle(pending.thread, options, { + includeContext: true, + messageId: eventTs, + mode: 'execute', + openStream: true, + startedAtMs: nowMs(), + threadId: pending.thread.id + }) + if (!ready) { + traceWarn(options, 'slackbotv2_late_file_repair_idle_timeout', undefined, { + slack_channel: pending.channel, + slack_message_ts: eventTs, + thread_id: pending.thread.id + }) + return + } + + const message = lateSlackFileSyntheticMessage(pending, hydratedEvent) + await handleSlackMessageHandoff(pending.thread, message, { + assistantStatusRequested: true, + mode: 'execute', + options, + state, + trigger: 'late_file_message' + }) + traceLog(options, 'slackbotv2_late_file_repair_complete', undefined, { + phase_ms: elapsedMs(startedAtMs), + slack_channel: pending.channel, + slack_message_ts: eventTs, + thread_id: pending.thread.id + }) +} + +async function waitForThreadIdle( + thread: Thread, + options: SlackbotV2Options, + trace: SlackbotV2Trace +): Promise { + const startedAtMs = nowMs() + while (elapsedMs(startedAtMs) < LATE_SLACK_FILE_IDLE_WAIT_MS) { + const latest = (await thread.state) ?? {} + if (latest.activeExecution !== true) return true + traceLog(options, 'slackbotv2_late_file_repair_waiting_for_idle', trace, { + waited_ms: elapsedMs(startedAtMs) + }) + await sleep(LATE_SLACK_FILE_IDLE_POLL_MS) + } + return false +} + +function lateSlackFileSyntheticMessage( + pending: PendingLateSlackFileMention, + event: Record +): ChatMessage { + const eventTs = stringField(event.ts) || randomUUID() + const raw: Record = { + ...event, + channel: pending.channel, + team: stringField(event.team) || pending.teamId, + team_id: stringField(event.team_id) || pending.teamId, + text: stringField(event.text) || LATE_SLACK_FILE_MESSAGE_TEXT, + thread_ts: stringField(event.thread_ts) || pending.mentionTs, + ts: eventTs + } + return new ChatSdkMessage({ + attachments: [], + author: { + ...pending.message.author, + userId: stringField(event.user) || pending.user, + userName: stringField(event.user) || pending.user, + fullName: stringField(event.user) || pending.user, + isBot: Boolean(event.bot_id), + isMe: false + }, + formatted: parseMarkdown(LATE_SLACK_FILE_MESSAGE_TEXT), + id: eventTs, + isMention: true, + links: [], + metadata: { + dateSent: new Date(slackTsToMs(eventTs)), + edited: false + }, + raw, + text: LATE_SLACK_FILE_MESSAGE_TEXT, + threadId: pending.thread.id + }) +} + +async function hydrateLateSlackFileEvent( + options: SlackbotV2Options, + event: Record +): Promise> { + const files = slackFiles(event) + if (!files.some(file => stringField(file.file_access) === 'check_file_info')) return event + const hydratedFiles: Record[] = [] + for (const file of files) { + if (stringField(file.file_access) !== 'check_file_info') { + hydratedFiles.push(file) + continue + } + const id = stringField(file.id) + if (!id) { + hydratedFiles.push(file) + continue + } + const hydrated = await slackFilesInfo(options, id) + hydratedFiles.push(hydrated ?? file) + } + return { ...event, files: hydratedFiles } +} + +async function slackFilesInfo( + options: SlackbotV2Options, + fileId: string +): Promise | null> { + const fetchFn = options.fetch ?? fetch + const url = new URL('files.info', options.slackApiUrl ?? 'https://slack.com/api/') + url.searchParams.set('file', fileId) + const response = await withSlackApiTimeout(options, 'Slack files.info', () => + fetchFn(url, { + headers: { authorization: `Bearer ${options.botToken}` } + }) + ) + if (!response.ok) { + throw new Error(`Slack files.info failed: ${response.status} ${response.statusText}`) + } + const payload = (await response.json()) as unknown + if (!isJsonObject(payload) || payload.ok !== true || !isJsonObject(payload.file)) return null + traceLog(options, 'slackbotv2_late_file_hydrated_file_info', undefined, { + slack_file_id: fileId + }) + return payload.file as Record +} + +function matchLateSlackFileMention( + pending: Map, + payload: Record, + event: Record +): PendingLateSlackFileMention | null { + const teamId = slackEventTeamId(payload, event) + const channel = stringField(event.channel) + const user = stringField(event.user) + const eventTs = stringField(event.ts) + if (!teamId || !channel || !user || !eventTs) return null + + const entries = pending.get(lateSlackFilePendingKey(teamId, channel, user)) ?? [] + const threadTs = stringField(event.thread_ts) + const eventMs = slackTsToMs(eventTs) + return ( + entries.find(entry => { + if (threadTs && threadTs !== slackThreadTsForPendingMention(entry)) return false + const mentionMs = slackTsToMs(entry.mentionTs) + return eventMs > mentionMs && eventMs - mentionMs <= LATE_SLACK_FILE_MATCH_WINDOW_MS + }) ?? null + ) +} + +function isLateSlackFileEvent( + event: Record, + options: SlackbotV2Options +): boolean { + if (stringField(event.type) !== 'message') return false + if (stringField(event.subtype) && stringField(event.subtype) !== 'file_share') return false + if (slackFiles(event).length === 0) return false + if (stringField(event.user) === options.botUserId) return false + const text = stringField(event.text) + if (options.botUserId && text.includes(`<@${options.botUserId}>`)) return false + return true +} + +function slackWebhookPayload(rawBody: string): Record | null { + try { + const payload = JSON.parse(rawBody) + return isJsonObject(payload) ? (payload as Record) : null + } catch { + return null + } +} + +function slackWebhookEvent(payload: Record): Record | null { + return isJsonObject(payload.event) ? (payload.event as Record) : null +} + +function lateSlackFilePendingKey(teamId: string, channel: string, user: string): string { + return `${teamId}:${channel}:${user}` +} + +function lateSlackFileDedupeKey( + payload: Record, + event: Record +): string { + const eventId = stringField(payload.event_id) + if (eventId) return `event:${eventId}` + const fileIds = slackFiles(event).map(file => stringField(file.id)).filter(Boolean).join(',') + return [ + 'file', + slackEventTeamId(payload, event), + stringField(event.channel), + stringField(event.ts), + fileIds + ].join(':') +} + +function slackEventTeamId( + payload: Record, + event: Record +): string { + return stringField(event.team) || stringField(event.team_id) || stringField(payload.team_id) +} + +function slackThreadTsForPendingMention(entry: PendingLateSlackFileMention): string { + const raw = slackRawRecord(entry.message) + return stringField(raw.thread_ts) || entry.mentionTs +} + +function slackTsToMs(ts: string): number { + const seconds = Number(ts) + return Number.isFinite(seconds) ? seconds * 1000 : 0 +} + function shouldAwaitSlackHandoff(rawBody: string): boolean { try { const payload = JSON.parse(rawBody) as { event?: { type?: unknown }; type?: unknown } diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 182f76506..1467f6f62 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -446,6 +446,231 @@ describe('slackbotv2', () => { expect(executeInput).toContain('"attachment_type":"image"') }) + it('repairs delayed Slack Connect file-only messages as a follow-up turn', async () => { + const mention = await postUserMessage(`<@${BOT_USER_ID}> what's in this image`) + const mentionWaits: Promise[] = [] + const mentionResponse = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-late-file-mention', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + text: `<@${BOT_USER_ID}> what's in this image` + } + }), + {}, + waitUntilContext(mentionWaits) + ) + expect(mentionResponse.status).toBe(200) + await Promise.all(mentionWaits) + + const fileUrl = `${slackApi.url}/files/captured.png` + const fileTs = incrementSlackTs(mention.ts, 2) + const fileWaits: Promise[] = [] + const fileResponse = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-late-file-message', + event: { + type: 'message', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: fileTs, + text: '', + files: [ + { + id: 'F-late-captured', + mimetype: 'image/png', + name: 'late-captured.png', + original_h: 600, + original_w: 800, + size: 16, + url_private: fileUrl + } + ] + } + }), + {}, + waitUntilContext(fileWaits) + ) + + expect(fileResponse.status).toBe(200) + await Promise.all(fileWaits) + + expect(codexApi.executes).toHaveLength(2) + expect(codexApi.executes.map(execute => execute.threadKey)).toEqual([ + threadKey(mention.ts), + threadKey(mention.ts) + ]) + expect(codexApi.executes[1]!.body.idempotency_key).toBe(fileTs) + const appendedAttachment = codexApi.appends[1]!.body.messages + .flatMap(message => message.parts) + .find(part => isRecord(part) && part.type === 'attachment') + expect(appendedAttachment).toEqual( + expect.objectContaining({ + attachment_type: 'image', + dataBase64: Buffer.from('captured-image').toString('base64'), + mimeType: 'image/png', + name: 'late-captured.png', + type: 'attachment', + url: fileUrl + }) + ) + const secondExecuteInput = JSON.stringify( + JSON.parse(codexApi.executes[1]!.body.input_lines.at(-1)!) + ) + expect(secondExecuteInput).toContain('Late Slack file attachment') + expect(secondExecuteInput).toContain('"attachment_type":"image"') + }) + + it('hydrates Slack Connect check_file_info placeholders before late-file repair', async () => { + const mention = await postUserMessage(`<@${BOT_USER_ID}> inspect the delayed file`) + const mentionWaits: Promise[] = [] + await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-check-file-info-mention', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + text: `<@${BOT_USER_ID}> inspect the delayed file` + } + }), + {}, + waitUntilContext(mentionWaits) + ) + await Promise.all(mentionWaits) + + const fileUrl = `${slackApi.url}/files/captured.png` + slackApi.setFileInfo('F-check-file-info', { + id: 'F-check-file-info', + mimetype: 'image/png', + name: 'hydrated.png', + original_h: 600, + original_w: 800, + size: 16, + url_private: fileUrl + }) + + const fileWaits: Promise[] = [] + const fileTs = incrementSlackTs(mention.ts, 2) + const fileResponse = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-check-file-info-message', + event: { + type: 'message', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: fileTs, + text: '', + files: [{ id: 'F-check-file-info', file_access: 'check_file_info' }] + } + }), + {}, + waitUntilContext(fileWaits) + ) + + expect(fileResponse.status).toBe(200) + await Promise.all(fileWaits) + + expect(slackApi.fileInfoRequestCount('F-check-file-info')).toBe(1) + expect(codexApi.executes).toHaveLength(2) + const appendedAttachment = codexApi.appends[1]!.body.messages + .flatMap(message => message.parts) + .find(part => isRecord(part) && part.type === 'attachment') + expect(appendedAttachment).toEqual( + expect.objectContaining({ + dataBase64: Buffer.from('captured-image').toString('base64'), + mimeType: 'image/png', + name: 'hydrated.png', + type: 'attachment', + url: fileUrl + }) + ) + }) + + it('ignores unmatched and duplicate delayed file-only messages', async () => { + const mention = await postUserMessage(`<@${BOT_USER_ID}> maybe an image follows`) + const mentionWaits: Promise[] = [] + await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-dedupe-late-file-mention', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + text: `<@${BOT_USER_ID}> maybe an image follows` + } + }), + {}, + waitUntilContext(mentionWaits) + ) + await Promise.all(mentionWaits) + + const unrelatedWaits: Promise[] = [] + await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-unmatched-late-file', + event: { + type: 'message', + user: USER_B_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: incrementSlackTs(mention.ts, 2), + text: '', + files: [{ id: 'F-unmatched', mimetype: 'image/png', name: 'unmatched.png' }] + } + }), + {}, + waitUntilContext(unrelatedWaits) + ) + await Promise.all(unrelatedWaits) + expect(codexApi.executes).toHaveLength(1) + + const fileEvent = signedSlackEvent({ + event_id: 'Ev-slackbotv2-duplicate-late-file', + event: { + type: 'message', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: incrementSlackTs(mention.ts, 3), + text: '', + files: [ + { + id: 'F-dedupe-late', + mimetype: 'image/png', + name: 'dedupe.png', + url_private: `${slackApi.url}/files/captured.png` + } + ] + } + }) + const firstWaits: Promise[] = [] + await bot.app.request('/api/webhooks/slack', fileEvent, {}, waitUntilContext(firstWaits)) + await Promise.all(firstWaits) + const duplicateWaits: Promise[] = [] + await bot.app.request('/api/webhooks/slack', fileEvent, {}, waitUntilContext(duplicateWaits)) + await Promise.all(duplicateWaits) + + expect(codexApi.executes).toHaveLength(2) + expect(codexApi.executes[1]!.body.idempotency_key).toBe(incrementSlackTs(mention.ts, 3)) + }) + it('fetches attachments from preceding Slack thread messages for a mid-thread mention', async () => { const parent = await postUserMessage('Root context before an attachment.') const priorReply = await postUserMessage('Screenshot is attached here.', parent.ts) @@ -4207,8 +4432,10 @@ type PatchedSlackApi = { failRepliesWithThreadNotFound(channel: string, ts: string): void failStreamAppendsAfter(count: number, error: string): void failStreamStopsLongerThan(maxChars: number): void + fileInfoRequestCount(fileId: string): number holdAssistantStatus(): () => void reset(): void + setFileInfo(fileId: string, file: Record): void setUserProfile(userId: string, profile: Record): void userProfileMethodRequestCount(userId: string, method: string): number userProfileRequestCount(userId: string): number @@ -4245,6 +4472,8 @@ type SlackStreamTranscript = { async function startPatchedSlackApi(emulatorUrl: string): Promise { const upstreamUrl = loopbackUrl(emulatorUrl) const calls: StreamCall[] = [] + const fileInfo = new Map>() + const fileInfoRequests = new Map() const threadMessageFiles = new Map[]>() const userProfiles = new Map>() const userProfileRequests = new Map() @@ -4266,6 +4495,8 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise assistantStatusGate, calls, + fileInfo, + fileInfoRequests, maxStreamStopChars, port, streams, @@ -4297,6 +4528,9 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise { @@ -4312,10 +4546,15 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise) { + fileInfo.set(fileId, file) + }, setUserProfile(userId: string, profile: Record) { userProfiles.set(userId, profile) }, @@ -4336,6 +4575,8 @@ async function handlePatchedSlackRequest( appendFailure: { error: string; remaining: number } assistantStatusGate: () => Promise | null calls: StreamCall[] + fileInfo: Map> + fileInfoRequests: Map maxStreamStopChars: number | null port: number streams: Map @@ -4415,6 +4656,19 @@ async function handlePatchedSlackRequest( await sendWebResponse(res, Response.json({ ok: true, profile })) return } + if (path === '/api/files.info') { + const body = await requestBody(request.clone()) + const fileId = url.searchParams.get('file') ?? stringField(body.file) + input.fileInfoRequests.set(fileId, (input.fileInfoRequests.get(fileId) ?? 0) + 1) + const file = input.fileInfo.get(fileId) + await sendWebResponse( + res, + file + ? Response.json({ ok: true, file }) + : Response.json({ ok: false, error: 'file_not_found' }) + ) + return + } if (path === '/api/chat.startStream') { await sendWebResponse( res, @@ -4896,6 +5150,11 @@ function slackReplyKey(channel: string, ts: string): string { return `${channel}:${ts}` } +function incrementSlackTs(ts: string, seconds: number): string { + const [whole = '0', fractional = '000000'] = ts.split('.') + return `${Number.parseInt(whole, 10) + seconds}.${fractional.padEnd(6, '0').slice(0, 6)}` +} + function stringField(value: unknown): string { return typeof value === 'string' ? value : '' } From 82622a908973b01e84905adb68f2a5627d5e142b Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:32:53 +0300 Subject: [PATCH 012/198] Fix Linear issue search GraphQL argument (#832) --- tools/productivity/linear/readonly.py | 6 ++-- tools/productivity/linear/test_readonly.py | 42 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 tools/productivity/linear/test_readonly.py diff --git a/tools/productivity/linear/readonly.py b/tools/productivity/linear/readonly.py index 58e63595d..7b5529984 100644 --- a/tools/productivity/linear/readonly.py +++ b/tools/productivity/linear/readonly.py @@ -377,8 +377,8 @@ def users(self, limit: int = 100) -> list[dict[str, Any]]: def search_issues(self, query_str: str, limit: int = 25) -> list[dict[str, Any]]: """Search issues by text.""" query = """ - query SearchIssues($query: String!, $first: Int!, $after: String) { - searchIssues(query: $query, first: $first, after: $after) { + query SearchIssues($term: String!, $first: Int!, $after: String) { + searchIssues(term: $term, first: $first, after: $after) { nodes { id identifier @@ -396,7 +396,7 @@ def search_issues(self, query_str: str, limit: int = 25) -> list[dict[str, Any]] return self._connection_nodes( query, connection_path=("searchIssues",), - variables={"query": query_str}, + variables={"term": query_str}, limit=limit, ) diff --git a/tools/productivity/linear/test_readonly.py b/tools/productivity/linear/test_readonly.py new file mode 100644 index 000000000..54bccaa97 --- /dev/null +++ b/tools/productivity/linear/test_readonly.py @@ -0,0 +1,42 @@ +"""Tests for Linear read-only GraphQL helpers. + +Run from this directory: uv run --no-project --with pytest --with httpx pytest test_readonly.py +""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +if "centaur_sdk" not in sys.modules: + sdk_mod = types.ModuleType("centaur_sdk") + sdk_mod.secret = lambda name, default="": default + sys.modules["centaur_sdk"] = sdk_mod + +from centaur_tool_linear.readonly import LinearReadonlyClient + + +class RecordingReadonlyClient(LinearReadonlyClient): + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def _query(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + self.calls.append({"query": query, "variables": variables}) + return { + "searchIssues": { + "nodes": [{"identifier": "ENG-1", "title": "Search result"}], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + + +def test_search_issues_uses_linear_term_argument(): + client = RecordingReadonlyClient() + + result = client.search_issues("auth", limit=1) + + assert result == [{"identifier": "ENG-1", "title": "Search result"}] + assert "searchIssues(term: $term" in client.calls[0]["query"] + assert "query:" not in client.calls[0]["query"] + assert client.calls[0]["variables"] == {"term": "auth", "first": 1, "after": None} From b43c8cd9270f175d7ca7da1e625d68ecfc12e9ac Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:24:02 -0700 Subject: [PATCH 013/198] fix: persist Slack model overrides per thread (#831) --- services/slackbotv2/src/index.ts | 55 +++++++++++- services/slackbotv2/src/overrides.ts | 20 ++--- services/slackbotv2/src/session-api.ts | 6 +- services/slackbotv2/src/types.ts | 12 ++- .../slackbotv2/test/chat-sdk-emulate.test.ts | 86 +++++++++++++++++++ 5 files changed, 159 insertions(+), 20 deletions(-) diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 29de4b041..5bb5cabaf 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -133,6 +133,49 @@ type PendingLateSlackFileMention = { user: string } +type StickyThreadOverrides = Pick + +function stickyThreadOverrideUpdate( + overrides: StickyThreadOverrides +): StickyThreadOverrides | undefined { + const update: StickyThreadOverrides = {} + if (overrides.harnessType) { + update.harnessType = overrides.harnessType + if (!overrides.model) update.model = null + if (!overrides.provider) update.provider = null + } + if (overrides.model) update.model = overrides.model + if (overrides.provider) { + update.provider = overrides.provider + if (!overrides.model) update.model = null + } + return Object.keys(update).length > 0 ? update : undefined +} + +function resolveStickyThreadOverrides( + state: SlackbotV2ThreadState, + update: StickyThreadOverrides | undefined +): { + harnessType?: string + model?: string + provider?: string +} { + return { + harnessType: stickyOverrideValue(state, update, 'harnessType'), + model: stickyOverrideValue(state, update, 'model'), + provider: stickyOverrideValue(state, update, 'provider') + } +} + +function stickyOverrideValue( + state: SlackbotV2ThreadState, + update: StickyThreadOverrides | undefined, + key: keyof StickyThreadOverrides +): string | undefined { + if (update && Object.prototype.hasOwnProperty.call(update, key)) return stringValue(update[key]) + return stringValue(state[key]) +} + export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { const userName = options.userName ?? 'centaur' const logger = options.logger ?? noopLogger @@ -591,6 +634,8 @@ async function syncThreadMessageToSession( const serializedMessage = await serializeMessage(message, input.options) const overrides = extractMessageOverrides(serializedMessage.text) setMessageText(serializedMessage, overrides.cleanedText) + const stickyOverridesUpdate = stickyThreadOverrideUpdate(overrides) + const effectiveOverrides = resolveStickyThreadOverrides(state, stickyOverridesUpdate) if (overrides.harnessType || overrides.model || overrides.provider || overrides.reasoning) { traceLog(input.options, 'slackbotv2_forward_overrides_parsed', trace, { harness_type: overrides.harnessType, @@ -654,12 +699,12 @@ async function syncThreadMessageToSession( executeContextMessages: shouldStartExecution && shouldIncludeContext ? candidateMessages : undefined, executeMessage: shouldStartExecution ? serializedMessage : undefined, - // A harness override only applies when this message starts an execution; + // Sticky harness changes only apply when a message starts an execution; // restarting the thread out from under an active execution would kill it. - harnessType: shouldStartExecution ? overrides.harnessType : undefined, + harnessType: shouldStartExecution ? effectiveOverrides.harnessType : undefined, messages: messagesToAppend, - model: overrides.model, - provider: overrides.provider, + model: shouldStartExecution ? effectiveOverrides.model : undefined, + provider: shouldStartExecution ? effectiveOverrides.provider : undefined, reasoning: overrides.reasoning, onEventId: eventId => { lastEventId = Math.max(lastEventId, eventId) @@ -704,6 +749,7 @@ async function syncThreadMessageToSession( const latestMessageIds = new Set(latest.forwardedMessageIds ?? []) for (const item of messagesToAppend) latestMessageIds.add(item.id) await thread.setState({ + ...(stickyOverridesUpdate ?? {}), forwardedMessageIds: Array.from(latestMessageIds).slice(-1000), historyForwarded: latest.historyForwarded || (shouldIncludeContext && !contextDegraded), lastEventId @@ -732,6 +778,7 @@ async function syncThreadMessageToSession( }) } await thread.setState({ + ...(stickyOverridesUpdate ?? {}), activeExecution: true, executedMessageIds: Array.from(latestExecutedMessageIds).slice(-1000), lastEventId, diff --git a/services/slackbotv2/src/overrides.ts b/services/slackbotv2/src/overrides.ts index 1eb178dd6..4a1c3f542 100644 --- a/services/slackbotv2/src/overrides.ts +++ b/services/slackbotv2/src/overrides.ts @@ -8,16 +8,16 @@ * * Flags are stripped from the text before it reaches the agent. The harness * applies at session creation — an explicit harness flag on a thread pinned to - * another harness restarts the thread on the requested one. The model and - * reasoning effort apply per turn via the blocks-protocol `model` / `reasoning` - * fields; `--model` accepts either a full model id (claude-sonnet-4-6, gpt-5.2, - * ...), an amp mode (deep/fast), or a Claude alias (fable/opus/sonnet/haiku) - * which expands to the full id. Reasoning effort only affects the codex harness - * (it maps to codex's `turn/start` `effort`); other harnesses ignore it. The - * provider rides the blocks-protocol `provider` field and is fixed when the - * codex thread starts; `--bedrock` selects codex's built-in `amazon-bedrock` - * provider (and implies the codex harness). Pair it with `--model ` - * to choose the Bedrock model. + * another harness restarts the thread on the requested one. Harness/model/provider + * choices are sticky at the Slack thread level: the last flag wins for later + * turns in the same thread. `--model` accepts either a full model id + * (claude-sonnet-4-6, gpt-5.2, ...), an amp mode (deep/fast), or a Claude alias + * (fable/opus/sonnet/haiku) which expands to the full id. Reasoning effort only + * affects the codex harness (it maps to codex's `turn/start` `effort`) and stays + * per-turn; other harnesses ignore it. The provider rides the blocks-protocol + * `provider` field and is fixed when the codex thread starts; `--bedrock` + * selects codex's built-in `amazon-bedrock` provider (and implies the codex + * harness). Pair it with `--model ` to choose the Bedrock model. */ export type MessageOverrides = { diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index 2d3905005..aea56dd7c 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -157,7 +157,7 @@ type ForwardSessionApiCallbacks = { onMessagesAppended?(): Promise /** * Fires when session creation restarted the thread onto a new harness - * (explicit --claude/--amp/--codex on a thread pinned to another harness). + * (sticky --claude/--amp/--codex state on a thread pinned to another harness). * Runs before append/execute, so the callback may set * `input.contextPreamble` to re-feed thread history to the fresh harness. */ @@ -703,8 +703,8 @@ async function createSession( message?: SlackbotV2ApiMessage ): Promise { const requested = harnessType ?? options.defaultHarnessType ?? DEFAULT_HARNESS_TYPE - // An explicit --claude/--amp/--codex restarts a thread pinned to another - // harness; the implicit default never forces a switch. + // A sticky --claude/--amp/--codex selection restarts a thread pinned to + // another harness; the implicit default never forces a switch. const response = await postCreateSession( options, threadId, diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 93c548a40..50006e3b4 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -138,8 +138,14 @@ export type SlackbotV2ThreadState = { activeExecution?: boolean executedMessageIds?: string[] forwardedMessageIds?: string[] + /** Last thread-level harness selected by Slack flags. Null clears persisted state. */ + harnessType?: string | null historyForwarded?: boolean lastEventId?: number + /** Last thread-level model selected by Slack flags. Null clears persisted state. */ + model?: string | null + /** Last thread-level model provider selected by Slack flags. Null clears persisted state. */ + provider?: string | null renderObligation?: SlackbotV2RenderObligation | null } @@ -174,12 +180,12 @@ export type ForwardSessionInput = { contextPreamble?: string executionId?: string executeMessage?: SlackbotV2ApiMessage - /** Harness override parsed from message flags (--claude/--amp/--codex). */ + /** Effective harness selected by sticky thread flags (--claude/--amp/--codex). */ harnessType?: string messages: SlackbotV2ApiMessage[] - /** Per-turn model override parsed from message flags (--model/--opus/...). */ + /** Effective model selected by sticky thread flags (--model/--opus/...). */ model?: string - /** Model provider override parsed from message flags (--bedrock); codex only. */ + /** Effective model provider selected by sticky thread flags (--bedrock); codex only. */ provider?: string /** Per-turn reasoning effort parsed from the `-rsn` flag (codex only). */ reasoning?: string diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 1467f6f62..db5a5f58b 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -343,6 +343,92 @@ describe('slackbotv2', () => { expectSlackRenderedReply(renderedReplies[1]!, 'Executed request 2.') }) + it('keeps harness and model flags sticky within a Slack thread', async () => { + const sharedState = createMemoryState() + await sharedState.connect() + bot = createTestBot({ state: sharedState }) + + const parent = await postUserMessage('Thread default context.') + const firstMention = await postUserMessage( + `<@${BOT_USER_ID}> --claude --model claude-opus-4-8 first pass`, + parent.ts + ) + const firstWaits: Promise[] = [] + const firstResponse = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-sticky-overrides-first', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: firstMention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> --claude --model claude-opus-4-8 first pass` + } + }), + {}, + waitUntilContext(firstWaits) + ) + expect(firstResponse.status).toBe(200) + await Promise.all(firstWaits) + + const secondMention = await postUserMessage( + `<@${BOT_USER_ID}> continue without flags`, + parent.ts + ) + const secondWaits: Promise[] = [] + const secondResponse = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-sticky-overrides-second', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: secondMention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> continue without flags` + } + }), + {}, + waitUntilContext(secondWaits) + ) + expect(secondResponse.status).toBe(200) + await Promise.all(secondWaits) + + expect(codexApi.creates.map(create => create.body.harness_type)).toEqual([ + 'claudecode', + 'claudecode' + ]) + expect(codexApi.executes).toHaveLength(2) + const firstInput = JSON.parse(codexApi.executes[0]!.body.input_lines.at(-1)!) as Record< + string, + unknown + > + const secondInput = JSON.parse(codexApi.executes[1]!.body.input_lines.at(-1)!) as Record< + string, + unknown + > + expect(firstInput.model).toBe('claude-opus-4-8') + expect(secondInput.model).toBe('claude-opus-4-8') + expect(JSON.stringify(firstInput)).not.toContain('--claude') + expect(JSON.stringify(firstInput)).not.toContain('--model') + expect(JSON.stringify(secondInput)).toContain('continue without flags') + + const state = await sharedState.get>( + `thread-state:${threadKey(parent.ts)}` + ) + expect(state).toEqual( + expect.objectContaining({ + harnessType: 'claudecode', + model: 'claude-opus-4-8' + }) + ) + }) + it('includes all preceding Slack thread messages for a first mid-thread mention', async () => { const parent = await postUserMessage('Root context for the thread.') const firstReply = await postUserMessage('First preceding reply.', parent.ts) From 128e8289b1236ab6005632608d3e958f8f44ebb8 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 1 Jul 2026 08:26:02 -0700 Subject: [PATCH 014/198] docs: add vmetrics deployment prompt guidance (#839) --- services/sandbox/SYSTEM_PROMPT.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/services/sandbox/SYSTEM_PROMPT.md b/services/sandbox/SYSTEM_PROMPT.md index 1b689bc75..583d4d5c2 100644 --- a/services/sandbox/SYSTEM_PROMPT.md +++ b/services/sandbox/SYSTEM_PROMPT.md @@ -103,6 +103,7 @@ |slack search "query" → Slack search |linear search "query" → Linear issue search |vlogs query "level:error" → recent service errors +|centaur-tools call vmetrics query '{"expr":"centaur_deployment_info"}' → live Centaur deployment image/version/SHA metadata |Tool commands are normal CLIs backed by mounted repo packages. Use direct tool CLIs for tools. |For tool smoke tests, use ` health` as the canonical check. Do not invent ad hoc "test this tool" probes or raw upstream calls unless `health` fails and you are triaging the failure. |For broad tool smoke tests, use the `tool-health-smoke` skill or run its health runner when it is available. @@ -130,6 +131,15 @@ | vlogs tool_analytics --start 7d → tool usage stats | vlogs query 'level:error AND event:tool_call_completed' --limit 20 → raw LogsQL | +|Metrics (VictoriaMetrics via `vmetrics`): +|When a user asks what Centaur version, image, Git SHA, overlay revision, deploy time, or latest deployment is live, query VictoriaMetrics before answering. +|Use the live metrics emitted by the `centaur-deployment-metrics` exporter; repo files and manifests only describe what should exist, not what is currently deployed. +| centaur-tools call vmetrics query '{"expr":"centaur_deployment_info"}' → deployed component metadata; labels include `component`, `version`, `git_sha`, and `image` +| centaur-tools call vmetrics query '{"expr":"centaur_last_deploy_timestamp_seconds"}' → last successful deploy timestamp per component +| centaur-tools call vmetrics query '{"expr":"centaur_overlay_revision_info"}' → deployed overlay repo revisions +| centaur-tools call vmetrics query '{"expr":"centaur_overlay_revision_scrape_success"}' → whether overlay revision scraping is healthy +|If these queries fail or return no data, say you cannot verify the live deployment from metrics right now; do not substitute git history, image tags in values files, or memory as the latest deployed state. +| [Ethereum Mainnet RPC] |When you need an Ethereum mainnet RPC endpoint and the user has not specified another provider, use the Reth-hosted mainnet endpoints: | HTTP: https://ethereum.reth.rs/rpc From db1ba35429d0290b5de1e20d0fb779bb33c56c7e Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:52:10 -0700 Subject: [PATCH 015/198] feat: persist session titles (#837) Generate a short GPT-backed title when the first user message is appended. Store titles on sessions and expose them in session context responses. --- services/api-rs/Cargo.lock | 1 + .../crates/centaur-api-server/src/main.rs | 3 +- .../crates/centaur-api-server/src/routes.rs | 14 +- .../crates/centaur-api-server/src/types.rs | 2 + .../crates/centaur-session-core/src/lib.rs | 2 + .../crates/centaur-session-runtime/Cargo.toml | 1 + .../crates/centaur-session-runtime/src/lib.rs | 277 ++++++++++- .../src/title_generator.rs | 438 ++++++++++++++++++ .../migrations/0032_session_title.sql | 2 + .../crates/centaur-session-sqlx/src/lib.rs | 90 +++- 10 files changed, 818 insertions(+), 12 deletions(-) create mode 100644 services/api-rs/crates/centaur-session-runtime/src/title_generator.rs create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0032_session_title.sql diff --git a/services/api-rs/Cargo.lock b/services/api-rs/Cargo.lock index a5034c645..d3440de4b 100644 --- a/services/api-rs/Cargo.lock +++ b/services/api-rs/Cargo.lock @@ -981,6 +981,7 @@ dependencies = [ "centaur-telemetry", "dashmap", "futures-util", + "reqwest", "serde", "serde_json", "sha2 0.10.9", diff --git a/services/api-rs/crates/centaur-api-server/src/main.rs b/services/api-rs/crates/centaur-api-server/src/main.rs index be8c1e61c..8ee2c0879 100644 --- a/services/api-rs/crates/centaur-api-server/src/main.rs +++ b/services/api-rs/crates/centaur-api-server/src/main.rs @@ -60,7 +60,8 @@ async fn initialize_runtime(args: Args, app_state: AppState) -> Result<(), Serve } let pool = store.pool().clone(); let sandbox_runtime = args.sandbox_runtime().await?; - let mut runtime = SessionRuntime::new(store.clone(), sandbox_runtime); + let mut runtime = SessionRuntime::new(store.clone(), sandbox_runtime) + .with_openai_session_title_generator_from_env(); let mut warm_pool_bootstrap_principal = None; let mut workflow_host_principal = None; if let Some(iron_control) = args.iron_control_runtime().await? { diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index d2d946f28..9cc8cec9f 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -404,10 +404,22 @@ async fn get_session_context( State(state): State, Path(raw_thread_key): Path, ) -> Result, ApiError> { - let _runtime = state.runtime()?; + let runtime = state.runtime()?; let thread_key = ThreadKey::try_from(raw_thread_key)?; + let title = match runtime.session_title(&thread_key).await { + Ok(title) => title, + Err(error) => { + tracing::warn!( + thread_key = %thread_key, + %error, + "failed to load optional session title" + ); + None + } + }; Ok(Json(SessionContextResponse { slack: slack_thread_context(&thread_key), + title, thread_key, })) } diff --git a/services/api-rs/crates/centaur-api-server/src/types.rs b/services/api-rs/crates/centaur-api-server/src/types.rs index 0dd584a69..b8030b5aa 100644 --- a/services/api-rs/crates/centaur-api-server/src/types.rs +++ b/services/api-rs/crates/centaur-api-server/src/types.rs @@ -37,6 +37,8 @@ pub struct CreateSessionResponse { pub struct SessionContextResponse { pub thread_key: ThreadKey, #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub slack: Option, } diff --git a/services/api-rs/crates/centaur-session-core/src/lib.rs b/services/api-rs/crates/centaur-session-core/src/lib.rs index a24a67d47..cb11387a1 100644 --- a/services/api-rs/crates/centaur-session-core/src/lib.rs +++ b/services/api-rs/crates/centaur-session-core/src/lib.rs @@ -165,6 +165,8 @@ impl Default for SandboxCapabilities { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct Session { pub thread_key: ThreadKey, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, pub sandbox_id: Option, /// Capabilities applied to the currently assigned sandbox. `None` means the /// sandbox predates capability tracking; callers may treat it as compatible diff --git a/services/api-rs/crates/centaur-session-runtime/Cargo.toml b/services/api-rs/crates/centaur-session-runtime/Cargo.toml index 1b0ce2449..44625f201 100644 --- a/services/api-rs/crates/centaur-session-runtime/Cargo.toml +++ b/services/api-rs/crates/centaur-session-runtime/Cargo.toml @@ -14,6 +14,7 @@ centaur-session-sqlx.workspace = true centaur-telemetry.workspace = true dashmap.workspace = true futures-util.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index f4e5617bf..203c03035 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -1,7 +1,9 @@ mod cleanup; +mod title_generator; use std::{ collections::{BTreeMap, HashMap, HashSet, VecDeque}, + future::Future, sync::Arc, time::{Duration, SystemTime}, }; @@ -27,8 +29,8 @@ use centaur_telemetry::{ record_session_execution_finished, record_session_execution_started, record_session_failure, record_session_first_token_latency, set_span_parent_trace, }; -use dashmap::DashMap; -use futures_util::{SinkExt, Stream, StreamExt, stream}; +use dashmap::{DashMap, DashSet}; +use futures_util::{FutureExt, SinkExt, Stream, StreamExt, future::BoxFuture, stream}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -42,6 +44,10 @@ use tokio_util::codec::{FramedRead, FramedWrite, LinesCodec, LinesCodecError}; use tracing::{Instrument, Span, error, info, info_span, warn}; pub use cleanup::SessionSandboxCleanupConfig; +pub use title_generator::SessionTitleGenerationError; +use title_generator::{ + OpenAiSessionTitleGenerator, sanitize_session_title, session_title_source_from_parts, +}; pub const SESSION_OUTPUT_LINE_EVENT: &str = "session.output.line"; pub const SESSION_FIRST_TOKEN_EVENT: &str = "session.first_token"; @@ -63,6 +69,10 @@ type SessionInputSink = FramedWrite; type ExecutionSpanRegistry = Arc>>; type SessionPipeMap = Arc>; type SessionPipeOpenLocks = Arc>>>; +type SessionTitleThreadSet = Arc>; +type SessionTitleGenerator = Arc< + dyn Fn(String) -> BoxFuture<'static, Result> + Send + Sync, +>; #[derive(Clone)] pub struct SessionRuntime { @@ -74,6 +84,9 @@ pub struct SessionRuntime { iron_control: Option, warm_pool: Option>, personas: Option>, + session_title_generator: Option, + session_title_in_flight: SessionTitleThreadSet, + session_title_rerun_requested: SessionTitleThreadSet, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -319,9 +332,32 @@ impl SessionRuntime { iron_control: None, warm_pool: None, personas: None, + session_title_generator: None, + session_title_in_flight: Arc::new(DashSet::new()), + session_title_rerun_requested: Arc::new(DashSet::new()), } } + pub fn with_session_title_generator(mut self, generator: F) -> Self + where + F: Fn(String) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.session_title_generator = Some(Arc::new(move |source| generator(source).boxed())); + self + } + + pub fn with_openai_session_title_generator_from_env(mut self) -> Self { + let Some(generator) = OpenAiSessionTitleGenerator::from_env() else { + return self; + }; + self.session_title_generator = Some(Arc::new(move |source| { + let generator = generator.clone(); + async move { generator.generate(source).await }.boxed() + })); + self + } + pub fn with_personas(mut self, personas: PersonaRegistry) -> Self { self.personas = Some(Arc::new(personas)); self @@ -334,6 +370,13 @@ impl SessionRuntime { .unwrap_or_default() } + pub async fn session_title( + &self, + thread_key: &ThreadKey, + ) -> Result, SessionRuntimeError> { + Ok(self.store.get_session_title(thread_key).await?) + } + fn resolve_persona_for_create( &self, requested_persona_id: Option<&str>, @@ -712,9 +755,45 @@ impl SessionRuntime { }; self.forward_messages_to_active_execution(thread_key, messages, &message_ids) .await; + self.spawn_session_title_generation(thread_key); Ok(message_ids) } + fn spawn_session_title_generation(&self, thread_key: &ThreadKey) { + let Some(generator) = self.session_title_generator.clone() else { + return; + }; + if !self.session_title_in_flight.insert(thread_key.clone()) { + self.session_title_rerun_requested + .insert(thread_key.clone()); + return; + } + let store = self.store.clone(); + let in_flight = self.session_title_in_flight.clone(); + let rerun_requested = self.session_title_rerun_requested.clone(); + let thread_key = thread_key.clone(); + tokio::spawn(async move { + // Appends skipped while generation is in flight request one more pass, + // which lets low-signal wakeups defer to a later substantive message. + loop { + rerun_requested.remove(&thread_key); + maybe_generate_session_title(store.clone(), generator.clone(), thread_key.clone()) + .await; + if rerun_requested.remove(&thread_key).is_some() { + continue; + } + + in_flight.remove(&thread_key); + if rerun_requested.remove(&thread_key).is_some() + && in_flight.insert(thread_key.clone()) + { + continue; + } + break; + } + }); + } + /// Stop every non-terminal sandbox the backend currently owns. /// /// Intended for a clean control-plane shutdown (e.g. before a deploy): @@ -2096,6 +2175,73 @@ impl SessionRuntime { } } +async fn maybe_generate_session_title( + store: PgSessionStore, + generator: SessionTitleGenerator, + thread_key: ThreadKey, +) { + let parts = match store.title_generation_candidate(&thread_key).await { + Ok(Some(parts)) => parts, + Ok(None) => return, + Err(error) => { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_title_candidate_failed", + thread_key = %thread_key, + %error, + "failed to load session title candidate" + ); + return; + } + }; + let Some(source) = session_title_source_from_parts(&parts) else { + return; + }; + let raw_title = match generator(source).await { + Ok(title) => title, + Err(error) => { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_title_generation_failed", + thread_key = %thread_key, + %error, + "failed to generate session title" + ); + return; + } + }; + let Some(title) = sanitize_session_title(&raw_title) else { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_title_generation_empty", + thread_key = %thread_key, + "session title generation returned an empty title" + ); + return; + }; + match store.set_session_title_if_empty(&thread_key, &title).await { + Ok(true) => { + info!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_title_set", + thread_key = %thread_key, + title, + "session title set" + ); + } + Ok(false) => {} + Err(error) => { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_title_set_failed", + thread_key = %thread_key, + %error, + "failed to set session title" + ); + } + } +} + impl SandboxRuntime { pub async fn create_running_io( &self, @@ -5774,6 +5920,7 @@ mod tests { let now = OffsetDateTime::now_utc(); Session { thread_key, + title: None, sandbox_id: Some(sandbox_id.to_owned()), sandbox_capabilities: None, harness_type: HarnessType::Codex, @@ -6087,6 +6234,22 @@ mod adoption_tests { } } + async fn wait_for_session_title( + store: &PgSessionStore, + thread_key: &ThreadKey, + expected: &str, + ) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let session = store.get_session(thread_key).await.expect("get session"); + if session.title.as_deref() == Some(expected) { + return; + } + assert!(Instant::now() < deadline, "timed out waiting for title"); + sleep(Duration::from_millis(25)).await; + } + } + async fn events(store: &PgSessionStore, thread_key: &ThreadKey) -> Vec { store .list_events_after(thread_key, 0, None, 1000) @@ -6101,6 +6264,116 @@ mod adoption_tests { ) } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn append_messages_generates_missing_session_title_once() { + let Some(store) = test_store().await else { + return; + }; + let _serial = TEST_LOCK.lock().await; + let thread_key = ThreadKey::parse(format!("test:title-{}", uuid::Uuid::new_v4())).unwrap(); + store + .create_or_get_session(&thread_key, &HarnessType::Codex, None, json!({})) + .await + .expect("create session"); + + let calls = Arc::new(AtomicUsize::new(0)); + let sources = Arc::new(Mutex::new(Vec::::new())); + let generator_started = Arc::new(tokio::sync::Notify::new()); + let generator_release = Arc::new(tokio::sync::Notify::new()); + let calls_for_generator = calls.clone(); + let sources_for_generator = sources.clone(); + let started_for_generator = generator_started.clone(); + let release_for_generator = generator_release.clone(); + let runtime = runtime_with( + &store, + Arc::new(MockBackend::new(SandboxStatus::Running, Vec::new())), + ) + .with_session_title_generator(move |source| { + let calls = calls_for_generator.clone(); + let sources = sources_for_generator.clone(); + let started = started_for_generator.clone(); + let release = release_for_generator.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + sources.lock().await.push(source); + started.notify_one(); + release.notified().await; + Ok("Fix worker memory leak".to_owned()) + } + }); + + tokio::time::timeout( + Duration::from_secs(1), + runtime.append_messages( + &thread_key, + &[SessionMessageInput { + client_message_id: Some("first".to_owned()), + role: MessageRole::User, + parts: vec![ + json!({ + "type": "text", + "text": "# Requester Context\n\nThe Slack user who prompted this turn is Alice." + }), + json!({ + "type": "text", + "text": "<@U123> please fix the memory leak in the worker" + }), + ], + metadata: json!({}), + }], + ), + ) + .await + .expect("append first message should not wait for title generation") + .expect("append first message"); + + generator_started.notified().await; + + let session = store.get_session(&thread_key).await.unwrap(); + assert_eq!(session.title, None); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + sources.lock().await.clone(), + vec!["please fix the memory leak in the worker".to_owned()] + ); + + runtime + .append_messages( + &thread_key, + &[SessionMessageInput { + client_message_id: Some("burst".to_owned()), + role: MessageRole::User, + parts: vec![json!({"type": "text", "text": "add more logging"})], + metadata: json!({}), + }], + ) + .await + .expect("append burst message"); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + + generator_release.notify_one(); + wait_for_session_title(&store, &thread_key, "Fix worker memory leak").await; + assert_eq!(calls.load(Ordering::SeqCst), 1); + + runtime + .append_messages( + &thread_key, + &[SessionMessageInput { + client_message_id: Some("second".to_owned()), + role: MessageRole::User, + parts: vec![json!({"type": "text", "text": "add more logging"})], + metadata: json!({}), + }], + ) + .await + .expect("append second message"); + + let session = store.get_session(&thread_key).await.unwrap(); + assert_eq!(session.title.as_deref(), Some("Fix worker memory leak")); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + fn env_value<'a>(spec: &'a SandboxSpec, name: &str) -> Option<&'a str> { spec.env .iter() diff --git a/services/api-rs/crates/centaur-session-runtime/src/title_generator.rs b/services/api-rs/crates/centaur-session-runtime/src/title_generator.rs new file mode 100644 index 000000000..f8c80c16e --- /dev/null +++ b/services/api-rs/crates/centaur-session-runtime/src/title_generator.rs @@ -0,0 +1,438 @@ +use std::{env, sync::Arc, time::Duration}; + +use serde_json::{Value, json}; +use thiserror::Error; + +const SESSION_TITLE_MODEL: &str = "gpt-5.4-nano"; +const SESSION_TITLE_MAX_SOURCE_CHARS: usize = 4_000; +const SESSION_TITLE_MAX_CHARS: usize = 80; +const SESSION_TITLE_REQUEST_TIMEOUT: Duration = Duration::from_secs(4); + +#[derive(Clone)] +pub(crate) struct OpenAiSessionTitleGenerator { + api_key: Arc, + client: reqwest::Client, +} + +impl OpenAiSessionTitleGenerator { + pub(crate) fn from_env() -> Option { + let api_key = env::var("OPENAI_API_KEY").ok()?; + let api_key = api_key.trim(); + if api_key.is_empty() || api_key == "OPENAI_API_KEY" { + return None; + } + let client = reqwest::Client::builder() + .timeout(SESSION_TITLE_REQUEST_TIMEOUT) + .build() + .ok()?; + Some(Self { + api_key: Arc::from(api_key.to_owned()), + client, + }) + } + + pub(crate) async fn generate( + &self, + source: String, + ) -> Result { + let body = json!({ + "model": SESSION_TITLE_MODEL, + "instructions": "Generate a short session title for the user's request. Return only the title. Use commit-message style with an imperative verb first, such as Fix, Investigate, Add, Update, Debug, Review, Explain, or Analyze. Keep it to 5 words max; 6-7 words are okay only when needed for a product name. Do not include punctuation, quotes, emoji, markdown, or a trailing period.", + "input": format!("User request:\n{}", source), + "max_output_tokens": 24, + }); + let response = self + .client + .post("https://api.openai.com/v1/responses") + .bearer_auth(self.api_key.as_ref()) + .json(&body) + .send() + .await?; + let status = response.status(); + let text = response.text().await?; + if !status.is_success() { + return Err(SessionTitleGenerationError::HttpStatus { status, body: text }); + } + openai_response_output_text(&text).ok_or(SessionTitleGenerationError::MissingOutput) + } +} + +pub(crate) fn session_title_source_from_parts(parts: &[Value]) -> Option { + let mut text_blocks = Vec::new(); + let mut slack_thread_source = None; + let mut attachment_names = Vec::new(); + for part in parts { + match part { + Value::String(text) => { + collect_title_source_text(text, &mut text_blocks, &mut slack_thread_source); + } + Value::Object(object) => { + if let Some(text) = object.get("text").and_then(Value::as_str) { + collect_title_source_text(text, &mut text_blocks, &mut slack_thread_source); + } + for key in ["name", "title", "filename"] { + if let Some(name) = object.get(key).and_then(Value::as_str) + && let Some(name) = clean_nonempty(name) + { + attachment_names.push(name.to_owned()); + break; + } + } + } + _ => {} + } + } + let source = slack_thread_source + .or_else(|| text_blocks.first().cloned()) + .or_else(|| { + attachment_names + .first() + .map(|name| format!("Analyze attachment {name}")) + })?; + Some(truncate_chars(&source, SESSION_TITLE_MAX_SOURCE_CHARS)) +} + +fn collect_title_source_text( + raw_text: &str, + text_blocks: &mut Vec, + slack_thread_source: &mut Option, +) { + if slack_thread_source.is_none() + && let Some(text) = slack_thread_context_title_source(raw_text) + && title_source_has_signal(&text) + { + *slack_thread_source = Some(text); + } + if is_session_context_text(raw_text) { + return; + } + if let Some(text) = clean_title_source_text(raw_text) + && title_source_has_signal(&text) + { + text_blocks.push(text); + } +} + +fn slack_thread_context_title_source(text: &str) -> Option { + if !text.trim_start().starts_with("# Slack Thread Context") { + return None; + } + + let mut in_first_message = false; + let mut lines = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed == "# Current Request" { + break; + } + if !in_first_message { + if trimmed.starts_with("1. ") && trimmed.ends_with(':') { + in_first_message = true; + } + continue; + } + if trimmed.starts_with("2. ") && trimmed.ends_with(':') { + break; + } + if trimmed.is_empty() { + if lines.is_empty() { + continue; + } + break; + } + lines.push(trimmed); + } + + let text = lines.join(" "); + clean_title_source_text(&text) +} + +fn title_source_has_signal(text: &str) -> bool { + let normalized = normalize_low_signal_text(text); + if normalized.is_empty() { + return false; + } + if is_low_signal_phrase(&normalized) { + return false; + } + + let words = normalized.split_whitespace().collect::>(); + if words.iter().all(|word| is_low_signal_word(word)) { + return false; + } + + true +} + +fn normalize_low_signal_text(text: &str) -> String { + let mut output = String::with_capacity(text.len()); + let lowercase = text.to_lowercase(); + let mut chars = lowercase.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == ':' { + let mut emoji_name = String::new(); + while let Some(next) = chars.peek().copied() { + chars.next(); + if next == ':' { + break; + } + if next.is_ascii_alphanumeric() || matches!(next, '_' | '-' | '+') { + emoji_name.push(next); + continue; + } + output.push(' '); + output.push_str(&emoji_name); + output.push(next); + emoji_name.clear(); + break; + } + continue; + } + if ch.is_alphanumeric() { + output.push(ch); + } else { + output.push(' '); + } + } + output.split_whitespace().collect::>().join(" ") +} + +fn is_low_signal_phrase(text: &str) -> bool { + matches!( + text, + "hey" + | "hi" + | "hello" + | "yo" + | "hey bot" + | "hi bot" + | "hello bot" + | "hey ai" + | "hi ai" + | "hello ai" + | "thread" + | "help" + | "can you help" + | "can you help me" + | "please help" + | "pls help" + ) +} + +fn is_low_signal_word(word: &str) -> bool { + matches!( + word, + "hey" | "hi" | "hello" | "yo" | "bot" | "ai" | "thread" | "please" | "pls" + ) +} + +fn clean_title_source_text(text: &str) -> Option { + let text = strip_slack_user_mentions(text) + .replace('\r', "\n") + .split_whitespace() + .collect::>() + .join(" "); + let mut text = clean_nonempty(&text)?.to_owned(); + if text.starts_with('@') { + text = text + .char_indices() + .find(|(_, ch)| ch.is_whitespace()) + .map(|(index, _)| text[index..].trim_start().to_owned()) + .unwrap_or_default(); + } + clean_nonempty(&text).map(str::to_owned) +} + +fn strip_slack_user_mentions(text: &str) -> String { + let mut output = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find("<@") { + output.push_str(&rest[..start]); + let mention = &rest[start + 2..]; + let Some(end) = mention.find('>') else { + output.push_str(&rest[start..]); + return output; + }; + rest = &mention[end + 1..]; + } + output.push_str(rest); + output +} + +fn is_session_context_text(text: &str) -> bool { + let text = text.trim_start(); + [ + "# Requester Context", + "# Slack Session Context", + "# Slack Thread Context", + "Earlier Slack thread attachment", + ] + .iter() + .any(|prefix| text.starts_with(prefix)) +} + +pub(crate) fn sanitize_session_title(title: &str) -> Option { + let title = title + .trim() + .trim_matches(|ch: char| { + matches!( + ch, + '"' | '\'' | '`' | '*' | '_' | '-' | ':' | ';' | ',' | '.' + ) + }) + .split_whitespace() + .collect::>() + .join(" "); + let title = clean_nonempty(&title)?; + let words = title + .split_whitespace() + .take(7) + .map(|word| { + word.trim_matches(|ch: char| matches!(ch, '"' | '\'' | '`' | ',' | '.' | ':' | ';')) + }) + .filter(|word| !word.is_empty()) + .collect::>(); + if words.is_empty() { + return None; + } + Some(truncate_chars(&words.join(" "), SESSION_TITLE_MAX_CHARS)) +} + +fn openai_response_output_text(body: &str) -> Option { + let value: Value = serde_json::from_str(body).ok()?; + if let Some(text) = value.get("output_text").and_then(Value::as_str) + && clean_nonempty(text).is_some() + { + return Some(text.to_owned()); + } + for output in value.get("output").and_then(Value::as_array)? { + let Some(content) = output.get("content").and_then(Value::as_array) else { + continue; + }; + for item in content { + if let Some(text) = item.get("text").and_then(Value::as_str) + && clean_nonempty(text).is_some() + { + return Some(text.to_owned()); + } + } + } + None +} + +fn clean_nonempty(value: &str) -> Option<&str> { + let value = value.trim(); + if value.is_empty() { None } else { Some(value) } +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + let mut truncated = value.chars().take(max_chars).collect::(); + if truncated.ends_with(char::is_whitespace) { + truncated = truncated.trim_end().to_owned(); + } + truncated +} + +#[derive(Debug, Error)] +pub enum SessionTitleGenerationError { + #[error("OpenAI title response did not include output text")] + MissingOutput, + #[error("OpenAI title request failed with status {status}: {body}")] + HttpStatus { + status: reqwest::StatusCode, + body: String, + }, + #[error(transparent)] + Http(#[from] reqwest::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_title_source_prefers_user_ask_over_slack_context() { + let parts = vec![ + json!({ + "type": "text", + "text": "# Requester Context\n\nThe Slack user who prompted this turn is Alice." + }), + json!({ + "type": "text", + "text": "<@U123> please fix the memory leak in the worker" + }), + ]; + + assert_eq!( + session_title_source_from_parts(&parts), + Some("please fix the memory leak in the worker".to_owned()) + ); + } + + #[test] + fn session_title_source_uses_first_slack_thread_message() { + let parts = vec![ + json!({ + "type": "text", + "text": "# Slack Thread Context\n\nEarlier messages in this Slack thread, in chronological order:\n\n1. Alice:\n Planning to replace the billing export job with a streaming worker because the nightly batch keeps timing out\n\n# Current Request\n\nThe user message follows in the next content block.\n---" + }), + json!({ + "type": "text", + "text": "<@U123> investigate this" + }), + ]; + + assert_eq!( + session_title_source_from_parts(&parts), + Some( + "Planning to replace the billing export job with a streaming worker because the nightly batch keeps timing out" + .to_owned() + ) + ); + } + + #[test] + fn session_title_source_skips_low_signal_wakeups() { + assert_eq!( + session_title_source_from_parts(&[ + json!({"type": "text", "text": "<@U123> Hey"}), + json!({"type": "text", "text": ":thread:"}), + ]), + None + ); + + assert_eq!( + session_title_source_from_parts(&[ + json!({"type": "text", "text": "<@U123> Hey"}), + json!({"type": "text", "text": "Can you investigate queue stalls?"}), + ]), + Some("Can you investigate queue stalls?".to_owned()) + ); + } + + #[test] + fn sanitize_session_title_keeps_model_wording() { + assert_eq!( + sanitize_session_title("Memory leak in worker queue needs investigation immediately"), + Some("Memory leak in worker queue needs investigation".to_owned()) + ); + assert_eq!( + sanitize_session_title("\"Fix worker memory leak.\""), + Some("Fix worker memory leak".to_owned()) + ); + } + + #[test] + fn openai_response_output_text_reads_responses_api_shapes() { + assert_eq!( + openai_response_output_text(r#"{"output_text":"Fix worker memory leak"}"#), + Some("Fix worker memory leak".to_owned()) + ); + assert_eq!( + openai_response_output_text( + r#"{"output":[{"content":[{"type":"output_text","text":"Add Tempo Explorer filter"}]}]}"# + ), + Some("Add Tempo Explorer filter".to_owned()) + ); + } +} diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0032_session_title.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0032_session_title.sql new file mode 100644 index 000000000..7e20b465e --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0032_session_title.sql @@ -0,0 +1,2 @@ +alter table sessions + add column if not exists title text; diff --git a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs index ba78484b4..75626f3a7 100644 --- a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs +++ b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs @@ -3,8 +3,8 @@ use std::str::FromStr; use centaur_session_core::{ - ExecutionStatus, HarnessType, SandboxCapabilities, Session, SessionEvent, SessionExecution, - SessionMessage, SessionMessageInput, SessionStatus, ThreadKey, empty_object, + ExecutionStatus, HarnessType, MessageRole, SandboxCapabilities, Session, SessionEvent, + SessionExecution, SessionMessage, SessionMessageInput, SessionStatus, ThreadKey, empty_object, }; use serde::Deserialize; use serde_json::Value; @@ -126,7 +126,7 @@ impl PgSessionStore { pub async fn get_session(&self, thread_key: &ThreadKey) -> Result { let row = sqlx::query_as::<_, SessionRow>( r#" - select thread_key, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + select thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at from sessions where thread_key = $1 "#, @@ -141,6 +141,25 @@ impl PgSessionStore { row.try_into() } + pub async fn get_session_title( + &self, + thread_key: &ThreadKey, + ) -> Result, SessionStoreError> { + let title = sqlx::query_scalar::<_, Option>( + r#" + select title + from sessions + where thread_key = $1 + "#, + ) + .bind(thread_key.as_str()) + .fetch_optional(&self.pool) + .await? + .flatten(); + + Ok(title) + } + pub async fn append_messages( &self, thread_key: &ThreadKey, @@ -178,6 +197,59 @@ impl PgSessionStore { Ok(message_ids) } + pub async fn title_generation_candidate( + &self, + thread_key: &ThreadKey, + ) -> Result>, SessionStoreError> { + let rows = sqlx::query_scalar::<_, Value>( + r#" + select m.parts + from sessions s + join session_messages m on m.thread_key = s.thread_key + where s.thread_key = $1 and s.title is null + and m.role = $2 + order by m.created_at, m.message_id + "#, + ) + .bind(thread_key.as_str()) + .bind(MessageRole::User.as_ref()) + .fetch_all(&self.pool) + .await?; + + if rows.is_empty() { + return Ok(None); + } + + let parts = rows + .into_iter() + .flat_map(|parts| match parts { + Value::Array(parts) => parts, + other => vec![other], + }) + .collect(); + Ok(Some(parts)) + } + + pub async fn set_session_title_if_empty( + &self, + thread_key: &ThreadKey, + title: &str, + ) -> Result { + let result = sqlx::query( + r#" + update sessions + set title = $2, updated_at = now() + where thread_key = $1 and title is null + "#, + ) + .bind(thread_key.as_str()) + .bind(title) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + pub async fn list_messages( &self, thread_key: &ThreadKey, @@ -618,7 +690,7 @@ impl PgSessionStore { sandbox_observability_enabled = null, updated_at = now() where thread_key = $1 - returning thread_key, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -644,7 +716,7 @@ impl PgSessionStore { sandbox_observability_enabled = $4, updated_at = now() where thread_key = $1 - returning thread_key, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -700,7 +772,7 @@ impl PgSessionStore { status = $3, updated_at = now() where thread_key = $1 - returning thread_key, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -725,7 +797,7 @@ impl PgSessionStore { update sessions set iron_control_principal = $2, updated_at = now() where thread_key = $1 - returning thread_key, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -833,7 +905,7 @@ impl PgSessionStore { update sessions set harness_thread_id = $2, updated_at = now() where thread_key = $1 - returning thread_key, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -931,6 +1003,7 @@ pub enum SessionStoreError { #[derive(Debug, FromRow)] struct SessionRow { thread_key: String, + title: Option, sandbox_id: Option, sandbox_repo_cache_enabled: Option, sandbox_observability_enabled: Option, @@ -949,6 +1022,7 @@ impl TryFrom for Session { fn try_from(row: SessionRow) -> Result { Ok(Self { thread_key: parse_persisted(row.thread_key)?, + title: row.title, sandbox_id: row.sandbox_id, sandbox_capabilities: match ( row.sandbox_repo_cache_enabled, From 6ff5f81b12cf95202123aeb70c5713596c1d3186 Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:52:34 -0700 Subject: [PATCH 016/198] Remove legacy agent API leftovers (#836) Closes paradigmxyz/centaur#640 Co-authored-by: Amp --- docs/pages/architecture.mdx | 2 +- docs/public/md/architecture.md | 32 +- docs/public/md/deploying-in-production.md | 106 ++---- docs/public/md/extend/acme-example.md | 6 +- docs/public/md/extend/tools.md | 54 ++- docs/public/md/extend/workflows-v2.md | 10 +- docs/public/md/extend/workflows.md | 6 +- packages/api-client/package.json | 4 +- packages/api-client/src/client.ts | 352 ++---------------- packages/api-client/src/index.ts | 4 - packages/api-client/test/client.test.ts | 250 +++---------- pnpm-lock.yaml | 12 - .../crates/centaur-workflows/src/lib.rs | 12 +- tools/productivity/slack/feedback.py | 74 ++-- .../productivity/slack/tests/test_feedback.py | 58 ++- 15 files changed, 279 insertions(+), 703 deletions(-) diff --git a/docs/pages/architecture.mdx b/docs/pages/architecture.mdx index 53a44e0ea..db6d5e5ef 100644 --- a/docs/pages/architecture.mdx +++ b/docs/pages/architecture.mdx @@ -52,7 +52,7 @@ https://api.acme.com/api/webhooks/slack The webhook does not use a Centaur API key. Slack signs every request with `X-Slack-Signature` and `X-Slack-Request-Timestamp`; the Slackbot validates that HMAC signature with `SLACK_SIGNING_SECRET` before it routes the event to the API. -After validation, the Slackbot calls Centaur's agent API with +After validation, the Slackbot calls Centaur's api-rs session API with `SLACKBOT_API_KEY`. During a Slack delivery, the API owns the execution state while Slackbot owns diff --git a/docs/public/md/architecture.md b/docs/public/md/architecture.md index dafa20f4d..db6d5e5ef 100644 --- a/docs/public/md/architecture.md +++ b/docs/public/md/architecture.md @@ -18,7 +18,7 @@ an event trail clients can replay. | Plane | Responsibility | Main components | |-------|----------------|-----------------| | Ingress | Accept user and client input. | Slack Events API, Slackbot webhook, external API clients. | -| Control | Persist requests and coordinate runtime state. | FastAPI, Postgres, execution worker. | +| Control | Persist requests and coordinate runtime state. | api-rs, Postgres, session runtime, workflow runtime. | | Execution | Run one assigned agent session per thread. | Kubernetes sandbox pods. | | Capabilities | Give agents approved actions. | Tool plugins, workflow engine, overlays. | | Secrets and egress | Let agents call third-party APIs without receiving raw keys. | Kubernetes Secret, [iron-proxy](https://docs.iron.sh), per-sandbox proxy token mapping. | @@ -30,11 +30,10 @@ the API and follow the event stream. | Step | Endpoint | What it saves | |------|----------|----------------| -| Start or reuse a sandbox | `POST /agent/spawn` | The thread's current sandbox assignment. | -| Persist input | `POST /agent/message` | Writes the user turn and extracts large multimodal attachments. | -| Run the agent | `POST /agent/execute` | A run row with status and final result. | -| Follow output | `GET /agent/threads/{thread}/events` | Tool calls, model output, status changes, and final text. | -| Clean up | `POST /agent/threads/{thread}/release` | Releases the sandbox and can cancel running work. | +| Start or reuse a session | `POST /api/session/{thread}` | The thread's current sandbox assignment. | +| Persist input | `POST /api/session/{thread}/messages` | Writes one or more durable transcript messages. | +| Run the agent | `POST /api/session/{thread}/execute` | An execution row with status and final result events. | +| Follow output | `GET /api/session/{thread}/events` | Model output, status changes, and final text. | Because each step is stored, a Slack reconnect, browser refresh, API restart, pod replacement, or worker failover does not erase the run. The event stream is @@ -53,7 +52,7 @@ https://api.acme.com/api/webhooks/slack The webhook does not use a Centaur API key. Slack signs every request with `X-Slack-Signature` and `X-Slack-Request-Timestamp`; the Slackbot validates that HMAC signature with `SLACK_SIGNING_SECRET` before it routes the event to the API. -After validation, the Slackbot calls Centaur's agent API with +After validation, the Slackbot calls Centaur's api-rs session API with `SLACKBOT_API_KEY`. During a Slack delivery, the API owns the execution state while Slackbot owns @@ -80,16 +79,21 @@ third-party API keys. ## Tool and workflow layer -Tools are Python plugin directories. Each public client method becomes a REST -method at `/tools/{name}/{method}`. Agents discover tools when they start. +Tools are Python plugin directories. api-rs discovers their metadata for +secret grants, while sandbox startup scans `TOOL_DIRS` for +`pyproject.toml [project.scripts]` and installs each script as a local CLI +shim. Agents discover tools with `centaur-tools list`, inspect one with +` --help`, and run the direct CLI. Use tools for search, Slack, GitHub, market data, calendars, internal systems, and deployment-specific APIs. Tool code should read credentials with `secret("NAME")` so the same code works locally and in production. -Workflows are Python handlers that save step results. When a worker restarts, -the handler runs again, but `ctx.step(...)` returns cached results for completed -work. +Workflows are Python handlers run by `services/workflow-python` under the +api-rs Absurd workflow runtime. When a worker restarts, the handler runs again, +but `ctx.step(...)` returns cached results for completed work. Workflow +`ctx.call_tool(...)` remains available through the generated `centaur-tools` +compatibility bridge. Use workflows for scheduled digests, monitoring loops, approval gates, jobs that sleep for minutes or days, and parent/child workflow trees. @@ -112,7 +116,7 @@ and does not protect against. |---------|-------------------| | Client disconnects | Reconnect to the event stream with `after_event_id`. | | API restarts | Reload assignments, executions, and terminal state from Postgres. | -| Sandbox pod dies | The execution becomes terminal, the event trail remains in Postgres, and operators inspect `GET /agent/executions/{execution_id}` plus API/sandbox logs before retrying the turn. | +| Sandbox pod dies | The execution becomes terminal, the event trail remains in Postgres, and operators inspect `GET /api/session/{thread}` plus api-rs/sandbox logs before retrying the turn. | | Workflow worker restarts | Re-run the handler and skip completed checkpoints. | | Proxy restarts | Rebuild the key-injection map from the secret-manager cache. | -| Tool changes | Discovery reloads plugin metadata; agents see the updated methods. | +| Tool changes | api-rs reloads plugin metadata; new or refreshed sandboxes install updated CLI shims. | diff --git a/docs/public/md/deploying-in-production.md b/docs/public/md/deploying-in-production.md index ab5d2ea1b..7338e97a8 100644 --- a/docs/public/md/deploying-in-production.md +++ b/docs/public/md/deploying-in-production.md @@ -81,7 +81,6 @@ Store one secret per enabled harness credential: |---------|-----------|----------------|---------------------|----------| | Codex default | `codex` | none or `--codex` | `OPENAI_API_KEY` | `api.openai.com` | | Codex with OpenRouter provider | `codex` | none or `--codex` | `OPENROUTER_API_KEY` | `openrouter.ai` | -| Codex with Bedrock provider | `codex` | `--bedrock` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | `bedrock-mantle..api.aws` | | Amp | `amp` | `--amp` | `AMP_API_KEY` | `ampcode.com` | | Claude Code | `claude-code` | `--claude` | `ANTHROPIC_API_KEY` | `api.anthropic.com` | | pi-mono | `pi-mono` | `--pi` | `ANTHROPIC_API_KEY` | `api.anthropic.com` | @@ -105,48 +104,6 @@ so any thread can use any configured credential. Per-user and per-channel scoping is on the roadmap; until then, scope tool and harness access accordingly. See [Security](/security) for the full threat model. -### Codex with Amazon Bedrock - -Codex can run against [Amazon Bedrock](https://aws.amazon.com/bedrock/) through -its built-in `amazon-bedrock` provider, which talks to the Bedrock -OpenAI-compatible Responses endpoint (`bedrock-mantle..api.aws`). It is -opt-in and is never the default provider. - -Authentication uses AWS SigV4, not a bearer token, but the sandbox never sees -real AWS credentials. Codex signs each request with *placeholder* credentials -and [iron-proxy](https://docs.iron.sh) re-signs it with the real read-only IAM -keys — the same placeholder-swap model as every other harness credential, just -for SigV4 (this is exactly how the `cloudwatch` tool works). The re-signing is -scoped to the `bedrock` service and the configured region only. - -To enable it: - -1. Store `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in the vault. Scope the - IAM principal to Bedrock inference only (e.g. `bedrock:InvokeModel`, - `bedrock:InvokeModelWithResponseStream`) — least privilege, like the - read-only CloudWatch user. If the keys are temporary (STS) and carry a - session token, also store `AWS_SESSION_TOKEN` and set - `CODEX_BEDROCK_SESSION_TOKEN=1` (via `sandbox.extraEnv`). -2. Set `CODEX_BEDROCK_REGION` (via `sandbox.extraEnv`) to your Bedrock region. - This single setting opts the provider in and is the one source of truth for - the region: it registers the SigV4 re-signing credential (scoped to that - region), injects the placeholder AWS env into sandboxes, and pins codex's - `amazon-bedrock` provider to the same region at sandbox boot — so the - in-sandbox client and the proxy can never disagree. Defaults to `us-east-1` - when unset. (You can still layer further codex provider config via - `CODEX_CONFIG_OVERLAY`, which is applied on top.) -3. If you have locked egress down (it is open by default), allowlist - `bedrock-mantle..api.aws`. - -Select it per thread with the `--bedrock` Slack flag (it implies the codex -harness), and pick the Bedrock model with `--model ` (for -example `--model anthropic.claude-sonnet-4-...` or `--model openai.gpt-oss-120b`) -or by setting a default `CODEX_MODEL`. The provider is fixed when the codex -thread starts. `--bedrock` on a thread pinned to another harness restarts it onto -codex+Bedrock; to move an existing codex thread between providers, start a new -thread (a mid-thread provider switch is logged and ignored rather than applied -silently). - ### Codex Auth Modes :::warning[Dedicate the account to Centaur] @@ -310,59 +267,37 @@ helm upgrade --install centaur contrib/chart \ ## 6. Verify the deployment -Check health from inside the API deployment first. Localhost is accepted for -operator-only routes, so this avoids needing an external admin key for the first -smoke check: - -```bash -kubectl exec -n centaur-system deploy/centaur-centaur-api -- \ - curl -fsS http://localhost:8000/health - -kubectl exec -n centaur-system deploy/centaur-centaur-api -- \ - curl -fsS http://localhost:8000/health/ready | jq - -kubectl exec -n centaur-system deploy/centaur-centaur-api -- \ - curl -fsS http://localhost:8000/health/tools | jq -``` - -If you need to call operator routes from outside the cluster, create an admin -API key from inside the API deployment and save the returned plaintext key: +Check health from inside the api-rs deployment first: ```bash -kubectl exec -n centaur-system deploy/centaur-centaur-api -- \ - curl -fsS -X POST http://localhost:8000/admin/api-keys \ - -H "Content-Type: application/json" \ - -d '{"name":"operator","scopes":["admin"],"created_by":"ops"}' | jq -``` +kubectl exec -n centaur-system deploy/centaur-centaur-api-rs -- \ + curl -fsS http://localhost:8080/healthz -External operator calls then use: - -```bash -curl -s "$CENTAUR_API_URL/health/tools" \ - -H "X-Api-Key: $ADMIN_KEY" | jq +kubectl exec -n centaur-system deploy/centaur-centaur-api-rs -- \ + curl -fsS http://localhost:8080/readyz | jq ``` -Run one agent turn from inside the API deployment: +Run one agent turn from inside the api-rs deployment: ```bash -THREAD_KEY=production-smoke-codex +THREAD_KEY=cli:production-smoke-codex +THREAD_PATH=$(jq -rn --arg v "$THREAD_KEY" '$v|@uri') -SPAWN=$(kubectl exec -n centaur-system deploy/centaur-centaur-api -- curl -s -X POST http://localhost:8000/agent/spawn \ +SESSION=$(kubectl exec -n centaur-system deploy/centaur-centaur-api-rs -- curl -s -X POST "http://localhost:8080/api/session/${THREAD_PATH}" \ -H "Content-Type: application/json" \ - -d "{\"thread_key\":\"${THREAD_KEY}\"}") -ASSIGNMENT_GENERATION=$(printf '%s' "$SPAWN" | jq -r '.assignment_generation') + -d '{"harness_type":"codex","on_harness_conflict":"restart"}') -kubectl exec -n centaur-system deploy/centaur-centaur-api -- curl -s -X POST http://localhost:8000/agent/message \ +kubectl exec -n centaur-system deploy/centaur-centaur-api-rs -- curl -s -X POST "http://localhost:8080/api/session/${THREAD_PATH}/messages" \ -H "Content-Type: application/json" \ - -d "{\"thread_key\":\"${THREAD_KEY}\",\"assignment_generation\":${ASSIGNMENT_GENERATION},\"role\":\"user\",\"parts\":[{\"type\":\"text\",\"text\":\"Reply with exactly PONG.\"}]}" + -d '{"messages":[{"role":"user","parts":[{"type":"text","text":"Reply with exactly PONG."}]}]}' -EXECUTE=$(kubectl exec -n centaur-system deploy/centaur-centaur-api -- curl -s -X POST http://localhost:8000/agent/execute \ +EXECUTE=$(kubectl exec -n centaur-system deploy/centaur-centaur-api-rs -- curl -s -X POST "http://localhost:8080/api/session/${THREAD_PATH}/execute" \ -H "Content-Type: application/json" \ - -d "{\"thread_key\":\"${THREAD_KEY}\",\"assignment_generation\":${ASSIGNMENT_GENERATION},\"delivery\":{\"platform\":\"dev\"}}") + -d '{"input_lines":["{\"type\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly PONG.\"}]}}"]}') EXECUTION_ID=$(printf '%s' "$EXECUTE" | jq -r '.execution_id') -kubectl exec -n centaur-system deploy/centaur-centaur-api -- curl -s \ - "http://localhost:8000/agent/executions/${EXECUTION_ID}" | jq +kubectl exec -n centaur-system deploy/centaur-centaur-api-rs -- curl -s -N \ + "http://localhost:8080/api/session/${THREAD_PATH}/events?execution_id=${EXECUTION_ID}&after_event_id=0" ``` Then run the same prompt through Slack: @@ -378,16 +313,17 @@ Inspect sandbox pods with the labels Centaur actually sets: ```bash kubectl get pods -n centaur-system -l centaur.ai/managed=true +kubectl exec -n centaur-system -- centaur-tools list ``` If a run fails because the sandbox pod exits or is deleted, inspect the durable -execution before retrying: +session and api-rs logs before retrying: ```bash -kubectl exec -n centaur-system deploy/centaur-centaur-api -- curl -s \ - "http://localhost:8000/agent/executions/${EXECUTION_ID}" | jq +kubectl exec -n centaur-system deploy/centaur-centaur-api-rs -- curl -s \ + "http://localhost:8080/api/session/${THREAD_PATH}" | jq -kubectl logs -n centaur-system deploy/centaur-centaur-api --tail=200 +kubectl logs -n centaur-system deploy/centaur-centaur-api-rs --tail=200 kubectl get pods -n centaur-system -l centaur.ai/managed=true ``` diff --git a/docs/public/md/extend/acme-example.md b/docs/public/md/extend/acme-example.md index 5a5263153..7309446be 100644 --- a/docs/public/md/extend/acme-example.md +++ b/docs/public/md/extend/acme-example.md @@ -220,11 +220,11 @@ Expected paths include: /home/agent/github/your-org/centaur-acme/.agents/skills ``` -You can also inspect the runtime payload for a thread: +You can also inspect the api-rs session context for a thread: ```bash -curl -s "$CENTAUR_API_URL/agent/runtime?key=$THREAD_KEY" \ - -H "X-Api-Key: $RUNTIME_API_KEY" | jq '.overlay' +THREAD_PATH=$(jq -rn --arg v "$THREAD_KEY" '$v|@uri') +curl -s "$CENTAUR_API_URL/api/session/${THREAD_PATH}" | jq ``` ## What to change first diff --git a/docs/public/md/extend/tools.md b/docs/public/md/extend/tools.md index 342a87aa6..2be4df45d 100644 --- a/docs/public/md/extend/tools.md +++ b/docs/public/md/extend/tools.md @@ -5,11 +5,14 @@ description: Add Centaur tool plugins with client.py, pyproject metadata, and ty # Creating Tools -Tools are Python plugins that Centaur discovers at API startup and exposes as -REST endpoints at `/tools/{name}/{method}`. Put organization-specific tools in -an overlay repo under `tools/` so the base Centaur repo stays generic. See -[Using an overlay](/extend/overlay) for packaging, mount paths, and chart -configuration. +Tools are Python plugins that Centaur discovers from ordered tool directories. +api-rs reads their metadata for secret grants, while agent sandboxes install +their `[project.scripts]` entries as local CLI shims. Agents use +`centaur-tools list`, ` --help`, and the direct tool CLI; api-rs does not +serve legacy HTTP tool-method routes as the current sandbox registry. Put +organization-specific tools in an overlay repo under `tools/` so the base +Centaur repo stays generic. See [Using an overlay](/extend/overlay) for +packaging, mount paths, and chart configuration. Tools are loaded from `TOOL_DIRS`. In an overlay deployment, the tool must exist under the source's `toolsSubdir` — by default `tools/` — in its repo-cache @@ -34,6 +37,9 @@ version = "0.1.0" requires-python = ">=3.11" dependencies = ["httpx>=0.27.0"] +[project.scripts] +warehouse = "warehouse.cli:app" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -119,16 +125,40 @@ Do not call `load_dotenv()` in `client.py`. Server-side tools should use `secret("KEY")`; standalone CLIs may load local `.env` files in their CLI wrapper. +## Write the CLI + +The sandbox shim installer only exposes tools with `[project.scripts]`. Keep +the CLI thin: parse command-line arguments, call the client, and print JSON or +plain text that an agent can read. + +```python +import json +import typer + +from .client import _client + +app = typer.Typer() + + +@app.command() +def query(sql: str) -> None: + print(json.dumps(_client().query(sql))) +``` + + ## Verify -After deploy: +After deploy, verify from a fresh sandbox: ```bash -kubectl exec -n centaur-system deploy/centaur-centaur-api -- \ - curl -fsS http://localhost:8000/health/tools | jq +kubectl exec -n centaur-system -- centaur-tools list +kubectl exec -n centaur-system -- warehouse --help +kubectl exec -n centaur-system -- warehouse query "select 1" ``` -Check that the tool appears and that missing-secret warnings match what you -expect. If a tool is missing, inspect the configured repo/ref in repo-cache, -`TOOL_DIRS`, the tool directory name, and -`[tool.centaur] module = "client.py"`. +Check that the tool appears, the CLI help is useful, and a real invocation +works through iron-proxy when credentials are needed. If a tool is missing, +inspect the configured repo/ref in repo-cache, `TOOL_DIRS`, the tool directory +name, `[tool.centaur] module = "client.py"`, and the `[project.scripts]` entry. +For workflow-only use, also run a small workflow that exercises +`ctx.call_tool(...)`, which uses the generated `centaur-tools call` bridge. diff --git a/docs/public/md/extend/workflows-v2.md b/docs/public/md/extend/workflows-v2.md index c21658575..8e56d5343 100644 --- a/docs/public/md/extend/workflows-v2.md +++ b/docs/public/md/extend/workflows-v2.md @@ -59,7 +59,7 @@ Supported v2 primitives: | `handler(inp, ctx)` | Supported | | `ctx.step(name, fn)` | Supported | | `ctx.agent_turn(...)` / `ctx.run_agent(...)` | Supported | -| `ctx.call_tool(...)` | Supported through the configured tool API proxy | +| `ctx.call_tool(...)` | Supported through the generated `centaur-tools call` bridge in the workflow-host sandbox | | `ctx.post_to_slack(...)` | Supported | | `ctx._pool` | Supported when the workflow-host sandbox receives `DATABASE_URL` | | `WEBHOOKS` | Supported | @@ -197,9 +197,10 @@ Workflows that import unrelated API-service internals should move that behavior into the workflow-host API surface or a small local helper owned by the workflow domain before they are v2-ready. -The tool runtime is also still proxied. `ctx.call_tool(...)` works through the -configured tool API, but a fully native `api-rs` tool runtime is a separate -migration step. +`ctx.call_tool(...)` is a compatibility surface in the Python workflow host. It +uses the generated `centaur-tools call` bridge against the installed tool +package; agent sandboxes should use direct tool CLIs instead of deprecated +`/tools/...` HTTP routes. ## Verify a migration @@ -217,7 +218,6 @@ Then create a real run: ```bash curl -s "$CENTAUR_API_URL/api/workflows/runs" \ -H "Content-Type: application/json" \ - -H "X-Api-Key: $WORKFLOW_API_KEY" \ -d '{ "workflow_name": "nightly_report", "input": {"topic": "open incidents"} diff --git a/docs/public/md/extend/workflows.md b/docs/public/md/extend/workflows.md index 82eeefeb9..d95da510a 100644 --- a/docs/public/md/extend/workflows.md +++ b/docs/public/md/extend/workflows.md @@ -97,9 +97,8 @@ These primitives compose into larger automations: Create a run through the API: ```bash -curl -s "$CENTAUR_API_URL/workflows/runs" \ +curl -s "$CENTAUR_API_URL/api/workflows/runs" \ -H "Content-Type: application/json" \ - -H "X-Api-Key: $WORKFLOW_API_KEY" \ -d '{ "workflow_name": "nightly_report", "input": {"channel": "ops", "topic": "open incidents"}, @@ -110,8 +109,7 @@ curl -s "$CENTAUR_API_URL/workflows/runs" \ Inspect it: ```bash -curl -s "$CENTAUR_API_URL/workflows/runs/$RUN_ID" \ - -H "X-Api-Key: $WORKFLOW_API_KEY" | jq +curl -s "$CENTAUR_API_URL/api/workflows/runs/$RUN_ID" | jq ``` ## Schedule a workflow diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 9fe1ff397..57cea2d3e 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -9,9 +9,7 @@ "test:watch": "vitest" }, "dependencies": { - "@centaur/harness-events": "workspace:*", - "axios": "^1.13.6", - "eventsource-parser": "^3.0.6" + "axios": "^1.13.6" }, "devDependencies": { "typescript": "5.9.3", diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index bbee8389e..7becd032c 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,73 +1,5 @@ -import { EventSourceParserStream, type EventSourceMessage } from "eventsource-parser/stream"; import axios, { type AxiosInstance } from "axios"; -export type InputContentBlock = - | { type: "text"; text: string } - | { - type: "image"; - source_path?: string; - source: { type: "base64"; media_type: string; data: string }; - } - | { - type: "document"; - source_path?: string; - source: { type: "base64"; media_type: string; data: string }; - }; - -export interface SpawnOptions { - threadKey: string; - spawnId?: string; - harness?: string; - engine?: string; - personaId?: string; - agentsMdOverride?: string; -} - -export interface SpawnResult { - ok: boolean; - runtime_id: string; - thread_key: string; - trace_id?: string; - assignment_state: string; - assignment_generation: number; - persona_id?: string | null; - prompt_ref?: string | null; - effective_agents_md_sha256?: string | null; -} - -export interface MessageOptions { - threadKey: string; - assignmentGeneration: number; - messageId?: string; - role?: string; - event?: Record; - parts?: InputContentBlock[]; - userId?: string; - metadata?: Record; -} - -export interface ExecuteOptions { - threadKey: string; - assignmentGeneration: number; - executeId?: string; - harness?: string; - platform?: string; - userId?: string; - metadata?: Record; - delivery?: Record; -} - -export interface ExecutionAccepted { - ok: boolean; - execution_id: string; - execute_id: string; - assignment_generation: number; - status: string; - final_key: string; - delivery_token: string; - idempotent?: boolean; -} - export interface WorkflowRunOptions { workflowName: string; triggerKey?: string; @@ -99,32 +31,14 @@ export interface WorkflowRunAccepted { idempotent?: boolean; } -export interface ThreadMessageRecord { - id: string; - role: string; - parts: Array>; - user_id?: string | null; - metadata?: Record | null; - created_at?: string | null; -} - -export interface StreamEvent { - eventId: number; - eventKind: string; - data: Record; -} - export class CentaurClient { readonly http: AxiosInstance; - private log?: { info: Function; warn: Function; error: Function }; constructor(opts: { apiUrl: string; apiKey: string; timeoutMs?: number; - logger?: { info: Function; warn: Function; error: Function }; }) { - this.log = opts.logger; this.http = axios.create({ baseURL: opts.apiUrl, headers: { Authorization: `Bearer ${opts.apiKey}` }, @@ -132,71 +46,24 @@ export class CentaurClient { }); } - private get authHeader(): string { - return (this.http.defaults.headers["Authorization"] ?? - this.http.defaults.headers.common?.["Authorization"]) as string; - } - - async spawn(opts: SpawnOptions): Promise { - const { data } = await this.http.post("/agent/spawn", { - thread_key: opts.threadKey, - spawn_id: opts.spawnId, - harness: opts.harness, - engine: opts.engine, - persona_id: opts.personaId, - agents_md_override: opts.agentsMdOverride, - }); - return data as SpawnResult; - } - - async message(opts: MessageOptions): Promise<{ ok: boolean; message_id: string; attachment_ids: string[] }> { - const body: Record = { - thread_key: opts.threadKey, - assignment_generation: opts.assignmentGeneration, - message_id: opts.messageId, - metadata: opts.metadata, - user_id: opts.userId, - }; - - if (opts.event) { - body.event = opts.event; - } else { - body.role = opts.role ?? "user"; - body.parts = opts.parts ?? []; - } - - const { data } = await this.http.post("/agent/message", body); - return data as { ok: boolean; message_id: string; attachment_ids: string[] }; - } - - async execute(opts: ExecuteOptions): Promise { - const { data } = await this.http.post("/agent/execute", { - thread_key: opts.threadKey, - assignment_generation: opts.assignmentGeneration, - execute_id: opts.executeId, - harness: opts.harness, - platform: opts.platform, - user_id: opts.userId, - metadata: opts.metadata, - delivery: opts.delivery, - }); - return data as ExecutionAccepted; - } - async startWorkflowRun(opts: WorkflowRunOptions): Promise { - const { data } = await this.http.post("/workflows/runs", { - workflow_name: opts.workflowName, - trigger_key: opts.triggerKey, - input: opts.input ?? {}, - eager_start: opts.eagerStart ?? false, - }, { - timeout: opts.timeoutMs, - }); + const { data } = await this.http.post( + "/api/workflows/runs", + { + workflow_name: opts.workflowName, + trigger_key: opts.triggerKey, + input: opts.input ?? {}, + eager_start: opts.eagerStart ?? false, + }, + { + timeout: opts.timeoutMs, + }, + ); return data as WorkflowRunAccepted; } async getWorkflowRun(runId: string): Promise { - const { data } = await this.http.get(`/workflows/runs/${encodeURIComponent(runId)}`); + const { data } = await this.http.get(`/api/workflows/runs/${encodeURIComponent(runId)}`); return data as WorkflowRunAccepted; } @@ -207,7 +74,7 @@ export class CentaurClient { parentRunId?: string; limit?: number; }): Promise<{ ok: boolean; items: WorkflowRunAccepted[] }> { - const { data } = await this.http.get("/workflows/runs", { + const { data } = await this.http.get("/api/workflows/runs", { params: { workflow_name: opts?.workflowName, thread_key: opts?.threadKey, @@ -219,15 +86,21 @@ export class CentaurClient { return data as { ok: boolean; items: WorkflowRunAccepted[] }; } - async getWorkflowChildren(runId: string, limit = 200): Promise<{ ok: boolean; items: WorkflowRunAccepted[] }> { - const { data } = await this.http.get(`/workflows/runs/${encodeURIComponent(runId)}/children`, { - params: { limit }, - }); + async getWorkflowChildren( + runId: string, + limit = 200, + ): Promise<{ ok: boolean; items: WorkflowRunAccepted[] }> { + const { data } = await this.http.get( + `/api/workflows/runs/${encodeURIComponent(runId)}/children`, + { + params: { limit }, + }, + ); return data as { ok: boolean; items: WorkflowRunAccepted[] }; } async cancelWorkflowRun(runId: string): Promise { - const { data } = await this.http.post(`/workflows/runs/${encodeURIComponent(runId)}/cancel`); + const { data } = await this.http.post(`/api/workflows/runs/${encodeURIComponent(runId)}/cancel`); return data as WorkflowRunAccepted; } @@ -236,184 +109,11 @@ export class CentaurClient { correlationId: string; payload?: Record; }): Promise> { - const { data } = await this.http.post("/workflows/events", { + const { data } = await this.http.post("/api/workflows/events", { event_type: opts.eventType, correlation_id: opts.correlationId, payload: opts.payload ?? {}, }); return data as Record; } - - async *streamEvents(opts: { - threadKey: string; - afterEventId?: number; - executionId?: string; - pollMs?: number; - signal?: AbortSignal; - }): AsyncGenerator { - const params = new URLSearchParams(); - if (opts.afterEventId !== undefined) params.set("after_event_id", String(opts.afterEventId)); - if (opts.executionId) params.set("execution_id", opts.executionId); - if (opts.pollMs !== undefined) params.set("poll_ms", String(opts.pollMs)); - - const url = `${this.http.defaults.baseURL}/agent/threads/${encodeURIComponent(opts.threadKey)}/events?${params.toString()}`; - const res = await fetch(url, { - method: "GET", - headers: { - Authorization: this.authHeader, - "X-Centaur-Thread-Key": opts.threadKey, - }, - signal: opts.signal, - }); - - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`/agent/threads/{thread}/events failed (${res.status}): ${text.slice(0, 300)}`); - } - if (!res.body) return; - - const stream = (res.body as ReadableStream) - .pipeThrough(new TextDecoderStream() as unknown as TransformStream) - .pipeThrough(new EventSourceParserStream()); - - for await (const event of stream as unknown as AsyncIterable) { - if (!event.data || event.data === "[DONE]") continue; - let parsed: Record = { type: "unknown", raw: event.data }; - try { - parsed = JSON.parse(event.data) as Record; - } catch { - // keep raw fallback - } - yield { - eventId: Number(event.id || 0), - eventKind: event.event || "message", - data: parsed, - }; - } - } - - async getExecution(executionId: string) { - const { data } = await this.http.get(`/agent/executions/${encodeURIComponent(executionId)}`); - return data as Record; - } - - async getMessages(threadKey: string, opts?: { cursor?: string; limit?: number }) { - const { data } = await this.http.get("/agent/messages", { - params: { - thread_key: threadKey, - cursor: opts?.cursor, - limit: opts?.limit ?? 50, - }, - }); - return data as { - messages: ThreadMessageRecord[]; - cursor: string | null; - has_more: boolean; - }; - } - - async listExecutions(threadKey: string, limit = 20) { - const { data } = await this.http.get( - `/agent/threads/${encodeURIComponent(threadKey)}/executions`, - { params: { limit } }, - ); - return data as { thread_key: string; executions: Array> }; - } - - async cancelExecution(executionId: string) { - const { data } = await this.http.post(`/agent/executions/${encodeURIComponent(executionId)}/cancel`); - return data as Record; - } - - async steerExecution( - executionId: string, - opts?: { - contentBlocks?: Array>; - messageId?: string; - userId?: string; - metadata?: Record; - suppressCancellationDelivery?: boolean; - }, - ) { - const { data } = await this.http.post(`/agent/executions/${encodeURIComponent(executionId)}/steer`, { - content_blocks: opts?.contentBlocks, - message_id: opts?.messageId, - user_id: opts?.userId, - metadata: { - ...(opts?.metadata || {}), - ...(opts?.suppressCancellationDelivery === undefined - ? {} - : { steer_replacement: opts.suppressCancellationDelivery }), - }, - }); - return data as Record; - } - - async releaseThread(threadKey: string, opts?: { releaseId?: string; cancelInflight?: boolean }) { - const { data } = await this.http.post( - `/agent/threads/${encodeURIComponent(threadKey)}/release`, - { - release_id: opts?.releaseId, - cancel_inflight: opts?.cancelInflight ?? false, - }, - ); - return data as Record; - } - - async claimFinalDeliveries(opts: { consumerId: string; limit?: number; leaseSeconds?: number; platform?: string }) { - const { data } = await this.http.post("/agent/final-deliveries/claim", { - consumer_id: opts.consumerId, - limit: opts.limit ?? 1, - lease_seconds: opts.leaseSeconds ?? 60, - platform: opts.platform, - }); - return data as { deliveries: Array> }; - } - - async renewFinalDeliveryLease(executionId: string, opts: { consumerId: string; leaseSeconds?: number }) { - const { data } = await this.http.post( - `/agent/final-deliveries/${encodeURIComponent(executionId)}/heartbeat`, - { - consumer_id: opts.consumerId, - lease_seconds: opts.leaseSeconds ?? 60, - }, - ); - return data as Record; - } - - async markFinalDelivered(executionId: string, consumerId?: string) { - const { data } = await this.http.post( - `/agent/final-deliveries/${encodeURIComponent(executionId)}/delivered`, - { consumer_id: consumerId }, - ); - return data as Record; - } - - async markFinalFailed( - executionId: string, - error: string, - opts?: { - consumerId?: string; - retryAfterSeconds?: number; - nonRetryable?: boolean; - errorClass?: string; - }, - ) { - const { data } = await this.http.post( - `/agent/final-deliveries/${encodeURIComponent(executionId)}/failed`, - { - consumer_id: opts?.consumerId, - error, - retry_after_seconds: opts?.retryAfterSeconds ?? 15, - non_retryable: opts?.nonRetryable ?? false, - error_class: opts?.errorClass, - }, - ); - return data as Record; - } - - async getStatus(threadKey: string) { - const { data } = await this.http.get("/agent/status", { params: { key: threadKey } }); - return data as Record; - } } diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 9a341652c..67b3b8db7 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,10 +1,6 @@ export { ApiError } from "./types"; export { CentaurClient } from "./client"; export type { - ExecuteOptions, - MessageOptions, - InputContentBlock, - ThreadMessageRecord, WorkflowRunOptions, WorkflowRunAccepted, } from "./client"; diff --git a/packages/api-client/test/client.test.ts b/packages/api-client/test/client.test.ts index 76dea8cd4..78b618cbd 100644 --- a/packages/api-client/test/client.test.ts +++ b/packages/api-client/test/client.test.ts @@ -1,233 +1,99 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { CentaurClient, type StreamEvent } from "../src/client"; - -async function collectEvents(events: AsyncIterable): Promise { - const collected: StreamEvent[] = []; - for await (const event of events) { - collected.push(event); - } - return collected; -} - -function sseResponse(body: string, init?: ResponseInit): Response { - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(body)); - controller.close(); - }, - }), - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - ...init, - }, - ); -} +import { CentaurClient } from "../src/client"; describe("CentaurClient", () => { afterEach(() => { vi.restoreAllMocks(); - vi.unstubAllGlobals(); }); - it("parses SSE ids, events, JSON data, [DONE], and invalid JSON payloads", async () => { - const fetchMock = vi.fn(async () => sseResponse([ - "id: 11", - "event: amp_raw_event", - 'data: {"type":"assistant","message":{"content":"hello"}}', - "", - "id: 12", - "event: done", - "data: [DONE]", - "", - "id: 13", - "data: not-json", - "", - "", - ].join("\n"))); - vi.stubGlobal("fetch", fetchMock); - + it("starts workflow runs through the workflow API", async () => { const client = new CentaurClient({ apiUrl: "http://api.local", apiKey: "test-key", }); - - await expect(collectEvents(client.streamEvents({ threadKey: "thread-1" }))).resolves.toEqual([ - { - eventId: 11, - eventKind: "amp_raw_event", - data: { type: "assistant", message: { content: "hello" } }, - }, - { - eventId: 13, - eventKind: "message", - data: { type: "unknown", raw: "not-json" }, - }, - ]); - }); - - it("URL encodes Slack thread keys in event stream URLs", async () => { - const fetchMock = vi.fn(async () => sseResponse("")); - vi.stubGlobal("fetch", fetchMock); - const client = new CentaurClient({ - apiUrl: "http://api.local", - apiKey: "test-key", + const postMock = vi.spyOn(client.http, "post").mockResolvedValue({ + data: { ok: true, run_id: "run-123", workflow_name: "nightly", status: "queued" }, }); - await collectEvents(client.streamEvents({ - threadKey: "slack:C123:1700000000.000100", - executionId: "exe-1", - afterEventId: 42, - pollMs: 250, - })); - - expect(fetchMock).toHaveBeenCalledWith( - "http://api.local/agent/threads/slack%3AC123%3A1700000000.000100/events?after_event_id=42&execution_id=exe-1&poll_ms=250", - expect.objectContaining({ - method: "GET", - headers: { - Authorization: "Bearer test-key", - "X-Centaur-Thread-Key": "slack:C123:1700000000.000100", - }, + await expect( + client.startWorkflowRun({ + workflowName: "nightly", + triggerKey: "trigger-1", + input: { topic: "incidents" }, + eagerStart: true, + timeoutMs: 5000, }), - ); - }); + ).resolves.toMatchObject({ run_id: "run-123" }); - it("URL encodes Slack thread keys in path-based API calls", async () => { - const client = new CentaurClient({ - apiUrl: "http://api.local", - apiKey: "test-key", - }); - const getMock = vi.spyOn(client.http, "get").mockResolvedValue({ - data: { thread_key: "slack:C123:1700000000.000100", executions: [] }, - }); - const postMock = vi.spyOn(client.http, "post").mockResolvedValue({ data: { ok: true } }); - - await client.listExecutions("slack:C123:1700000000.000100", 2); - await client.releaseThread("slack:C123:1700000000.000100", { - releaseId: "release:1", - cancelInflight: true, - }); - - expect(getMock).toHaveBeenCalledWith( - "/agent/threads/slack%3AC123%3A1700000000.000100/executions", - { params: { limit: 2 } }, - ); expect(postMock).toHaveBeenCalledWith( - "/agent/threads/slack%3AC123%3A1700000000.000100/release", + "/api/workflows/runs", { - release_id: "release:1", - cancel_inflight: true, + workflow_name: "nightly", + trigger_key: "trigger-1", + input: { topic: "incidents" }, + eager_start: true, }, + { timeout: 5000 }, ); }); - it("throws useful errors for non-OK event stream responses", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response( - "upstream unavailable", - { status: 503, statusText: "Service Unavailable" }, - ))); + it("reads and mutates workflow runs through workflow endpoints", async () => { const client = new CentaurClient({ apiUrl: "http://api.local", apiKey: "test-key", }); - - await expect( - collectEvents(client.streamEvents({ threadKey: "slack:C123:1700000000.000100" })), - ).rejects.toThrow( - "/agent/threads/{thread}/events failed (503): upstream unavailable", - ); - }); - - it("posts the expected steerExecution payload", async () => { - const client = new CentaurClient({ - apiUrl: "http://api.local", - apiKey: "test-key", + const getMock = vi.spyOn(client.http, "get").mockResolvedValue({ + data: { ok: true, run_id: "run:123", workflow_name: "nightly", status: "completed" }, }); - const postMock = vi.spyOn(client.http, "post").mockResolvedValue({ data: { ok: true } }); - - await client.steerExecution("exe:123", { - contentBlocks: [{ type: "text", text: "replacement" }], - messageId: "slack:1700000000.000200", - userId: "U123", - metadata: { platform: "slack" }, - suppressCancellationDelivery: true, + const postMock = vi.spyOn(client.http, "post").mockResolvedValue({ + data: { ok: true, run_id: "run:123", workflow_name: "nightly", status: "cancelled" }, }); - expect(postMock).toHaveBeenCalledWith( - "/agent/executions/exe%3A123/steer", - { - content_blocks: [{ type: "text", text: "replacement" }], - message_id: "slack:1700000000.000200", - user_id: "U123", - metadata: { - platform: "slack", - steer_replacement: true, - }, + await client.getWorkflowRun("run:123"); + await client.listWorkflowRuns({ + workflowName: "nightly", + threadKey: "slack:C:1", + status: "running", + parentRunId: "root", + limit: 5, + }); + await client.getWorkflowChildren("run:123", 10); + await client.cancelWorkflowRun("run:123"); + + expect(getMock).toHaveBeenNthCalledWith(1, "/api/workflows/runs/run%3A123"); + expect(getMock).toHaveBeenNthCalledWith(2, "/api/workflows/runs", { + params: { + workflow_name: "nightly", + thread_key: "slack:C:1", + status: "running", + parent_run_id: "root", + limit: 5, }, - ); + }); + expect(getMock).toHaveBeenNthCalledWith(3, "/api/workflows/runs/run%3A123/children", { + params: { limit: 10 }, + }); + expect(postMock).toHaveBeenCalledWith("/api/workflows/runs/run%3A123/cancel"); }); - it("posts the expected final-delivery payloads", async () => { + it("sends workflow events", async () => { const client = new CentaurClient({ apiUrl: "http://api.local", apiKey: "test-key", }); - const postMock = vi.spyOn(client.http, "post").mockResolvedValue({ data: { ok: true, deliveries: [] } }); + const postMock = vi.spyOn(client.http, "post").mockResolvedValue({ data: { ok: true } }); - await client.claimFinalDeliveries({ - consumerId: "slackbot-1", - limit: 3, - leaseSeconds: 120, - platform: "slack", - }); - await client.renewFinalDeliveryLease("exe:123", { - consumerId: "slackbot-1", - leaseSeconds: 90, - }); - await client.markFinalDelivered("exe:123", "slackbot-1"); - await client.markFinalFailed("exe:123", "rate limited", { - consumerId: "slackbot-1", - retryAfterSeconds: 45, - nonRetryable: true, - errorClass: "slack_rate_limit", + await client.sendWorkflowEvent({ + eventType: "approval.received", + correlationId: "corr-1", + payload: { approved: true }, }); - expect(postMock).toHaveBeenNthCalledWith( - 1, - "/agent/final-deliveries/claim", - { - consumer_id: "slackbot-1", - limit: 3, - lease_seconds: 120, - platform: "slack", - }, - ); - expect(postMock).toHaveBeenNthCalledWith( - 2, - "/agent/final-deliveries/exe%3A123/heartbeat", - { - consumer_id: "slackbot-1", - lease_seconds: 90, - }, - ); - expect(postMock).toHaveBeenNthCalledWith( - 3, - "/agent/final-deliveries/exe%3A123/delivered", - { consumer_id: "slackbot-1" }, - ); - expect(postMock).toHaveBeenNthCalledWith( - 4, - "/agent/final-deliveries/exe%3A123/failed", - { - consumer_id: "slackbot-1", - error: "rate limited", - retry_after_seconds: 45, - non_retryable: true, - error_class: "slack_rate_limit", - }, - ); + expect(postMock).toHaveBeenCalledWith("/api/workflows/events", { + event_type: "approval.received", + correlation_id: "corr-1", + payload: { approved: true }, + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3811c3aaf..52802f311 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,15 +24,9 @@ importers: packages/api-client: dependencies: - '@centaur/harness-events': - specifier: workspace:* - version: link:../harness-events axios: specifier: ^1.13.6 version: 1.18.0 - eventsource-parser: - specifier: ^3.0.6 - version: 3.1.0 devDependencies: typescript: specifier: 5.9.3 @@ -880,10 +874,6 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} - engines: {node: '>=18.0.0'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -2417,8 +2407,6 @@ snapshots: eventemitter3@5.0.4: {} - eventsource-parser@3.1.0: {} - expect-type@1.3.0: {} express@5.2.1: diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index d60c21fbd..7ada7e8be 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -3313,13 +3313,11 @@ async fn call_python_workflow_tool(message: &Value) -> Result dict[str, Any]: + def _request_json( + self, method: str, path: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: headers = { "Authorization": f"Bearer {self.api_key}", "Accept": "application/json", @@ -159,57 +162,62 @@ def start_improvement_run( persona_id: str = "eng", thread_key: str | None = None, ) -> dict[str, Any]: - """Spawn, message, and execute a background improvement agent run.""" - thread_key = thread_key or f"feedback-improvement:{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}:{uuid.uuid4().hex[:8]}" - spawn = self._request_json( + """Create a session, persist the prompt, and execute it.""" + thread_key = thread_key or ( + f"feedback-improvement:{datetime.now(datetime.UTC).strftime('%Y%m%dT%H%M%SZ')}:{uuid.uuid4().hex[:8]}" + ) + thread_path = urllib.parse.quote(thread_key, safe="") + + self._request_json( "POST", - "/agent/spawn", + f"/api/session/{thread_path}", { - "thread_key": thread_key, - "harness": harness, + "harness_type": harness, "persona_id": persona_id, + "metadata": {"source": "slack-feedback-loop"}, + "on_harness_conflict": "restart", }, ) - assignment_generation = spawn["assignment_generation"] + parts = [{"type": "text", "text": prompt}] self._request_json( "POST", - "/agent/message", + f"/api/session/{thread_path}/messages", { - "thread_key": thread_key, - "assignment_generation": assignment_generation, - "role": "user", - "parts": [{"type": "text", "text": prompt}], - "metadata": {"source": "slack-feedback-loop"}, + "messages": [ + { + "role": "user", + "parts": parts, + "metadata": {"source": "slack-feedback-loop"}, + } + ], }, ) execute = self._request_json( "POST", - "/agent/execute", + f"/api/session/{thread_path}/execute", { - "thread_key": thread_key, - "assignment_generation": assignment_generation, - "execute_id": f"feedback-improvement-{uuid.uuid4().hex[:12]}", - "harness": harness, - "delivery": {"platform": "dev"}, - "metadata": {"source": "slack-feedback-loop"}, + "idempotency_key": f"feedback-improvement-{uuid.uuid4().hex[:12]}", + "metadata": {"source": "slack-feedback-loop", "delivery": {"platform": "dev"}}, + "input_lines": [ + json.dumps( + {"type": "user", "message": {"content": parts}}, + separators=(",", ":"), + ) + ], }, ) return { "thread_key": thread_key, - "assignment_generation": assignment_generation, "execution_id": execute["execution_id"], "status": execute.get("status"), } def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None: - columns = { - row["name"] - for row in conn.execute(f"PRAGMA table_info({table})").fetchall() - } + columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} if column not in columns: conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}") @@ -244,7 +252,7 @@ def _severity_filter_clause(min_severity: str | None) -> tuple[str, list[str]]: def _load_centaur_api_key() -> str | None: - return os.environ.get("CENTAUR_AGENT_API_KEY") + return os.getenv("SLACK_FEEDBACK_API_KEY") def _bot_message_looks_like_error(text: str) -> bool: @@ -430,8 +438,10 @@ def classify_feedback(signals: FeedbackSignals, messages: list[dict]) -> tuple[s if signals.has_bot_error: category = "cli_bug" elif ( - signals.has_positive_reaction or signals.positive_keywords_found - ) and not signals.has_negative_reaction and not signals.negative_keywords_found: + (signals.has_positive_reaction or signals.positive_keywords_found) + and not signals.has_negative_reaction + and not signals.negative_keywords_found + ): category = "success" elif signals.repeated_requests: category = "routing_error" diff --git a/tools/productivity/slack/tests/test_feedback.py b/tools/productivity/slack/tests/test_feedback.py index 455e79cf3..4da8eb857 100644 --- a/tools/productivity/slack/tests/test_feedback.py +++ b/tools/productivity/slack/tests/test_feedback.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime, timezone from slack import feedback @@ -52,8 +53,12 @@ def test_run_improvement_cycle_dispatches_only_actionable_items(tmp_path, monkey monkeypatch.setattr(feedback, "FEEDBACK_DB_PATH", tmp_path / "feedback.db") conn = feedback.init_db() - actionable_id = feedback.save_feedback_item(conn, _sample_item(category="cli_bug", severity="high")).item_id - success_id = feedback.save_feedback_item(conn, _sample_item(category="success", severity="low")).item_id + actionable_id = feedback.save_feedback_item( + conn, _sample_item(category="cli_bug", severity="high") + ).item_id + success_id = feedback.save_feedback_item( + conn, _sample_item(category="success", severity="low") + ).item_id conn.close() monkeypatch.setattr( @@ -74,7 +79,6 @@ def start_improvement_run(self, prompt: str, **kwargs): assert "git-branch paradigmxyz/centaur" in prompt return { "thread_key": "feedback-improvement:test", - "assignment_generation": 7, "execution_id": "exec-test-123", "status": "queued", } @@ -105,6 +109,54 @@ def start_improvement_run(self, prompt: str, **kwargs): assert row_by_id[success_id]["agent_execution_id"] is None +def test_agent_client_uses_session_api_for_improvement_runs(): + class RecordingAgentClient(feedback.CentaurAgentClient): + def __init__(self): + super().__init__(base_url="http://api.local", api_key="test-key") + self.calls = [] + + def _request_json(self, method: str, path: str, payload: dict | None = None): + self.calls.append((method, path, payload)) + if path.endswith("/execute"): + return {"execution_id": "exec-test-123", "status": "queued"} + return {"ok": True} + + client = RecordingAgentClient() + + result = client.start_improvement_run( + "Improve the Slack tool", + harness="codex", + persona_id="eng", + thread_key="feedback-improvement:test:1", + ) + + assert result == { + "thread_key": "feedback-improvement:test:1", + "execution_id": "exec-test-123", + "status": "queued", + } + assert [path for _, path, _ in client.calls] == [ + "/api/session/feedback-improvement%3Atest%3A1", + "/api/session/feedback-improvement%3Atest%3A1/messages", + "/api/session/feedback-improvement%3Atest%3A1/execute", + ] + assert client.calls[0][2] == { + "harness_type": "codex", + "persona_id": "eng", + "metadata": {"source": "slack-feedback-loop"}, + "on_harness_conflict": "restart", + } + execute_payload = client.calls[2][2] + assert execute_payload["metadata"] == { + "source": "slack-feedback-loop", + "delivery": {"platform": "dev"}, + } + assert json.loads(execute_payload["input_lines"][0]) == { + "type": "user", + "message": {"content": [{"type": "text", "text": "Improve the Slack tool"}]}, + } + + def test_analyze_thread_signals_does_not_treat_exceptional_as_error(): messages = [ {"user": "arjun", "text": "@centaur_ai --invest are L2s still investable or cooked"}, From 27cb6518cffac9055664f6c530b1c23331737e50 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 1 Jul 2026 10:21:03 -0700 Subject: [PATCH 017/198] feat: auto-reload repo-cache tools in sandboxes (#840) --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/apirs.yaml | 2 + contrib/chart/templates/repo-cache.yaml | 130 +------ contrib/chart/values.schema.json | 1 + contrib/chart/values.yaml | 1 + docs/public/md/extend/acme-example.md | 8 +- docs/public/md/reference/configuration.md | 2 + .../crates/centaur-api-server/src/args.rs | 34 ++ .../centaur-sandbox-agent-k8s/src/tools.rs | 22 ++ services/sandbox/Dockerfile | 2 + services/sandbox/entrypoint.sh | 15 + services/sandbox/install_tool_shims.py | 252 ++++++++++---- services/sandbox/repo_cache_sync.py | 328 ++++++++++++++++++ services/sandbox/repo_cache_watch.py | 150 ++++++++ services/sandbox/test_install_tool_shims.py | 48 +++ services/sandbox/test_repo_cache_sync.py | 80 +++++ services/sandbox/test_repo_cache_watch.py | 148 ++++++++ 17 files changed, 1025 insertions(+), 200 deletions(-) create mode 100644 services/sandbox/repo_cache_sync.py create mode 100644 services/sandbox/repo_cache_watch.py create mode 100644 services/sandbox/test_repo_cache_sync.py create mode 100644 services/sandbox/test_repo_cache_watch.py diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 11c8131d4..8a1b0dda3 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.80 +version: 0.1.81 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 7861f153d..550abd736 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -378,6 +378,8 @@ spec: {{- if $toolsUseRepoCache }} - name: KUBERNETES_TOOLS_REPO_CACHE_PATH value: {{ .Values.repoCache.hostPath | quote }} + - name: KUBERNETES_TOOLS_AUTO_RELOAD + value: {{ .Values.repoCache.autoReload | quote }} {{- if $repoCacheUsePvc }} - name: KUBERNETES_TOOLS_REPO_CACHE_PVC value: {{ $repoCachePvcName | quote }} diff --git a/contrib/chart/templates/repo-cache.yaml b/contrib/chart/templates/repo-cache.yaml index 8d0f919eb..d4a6724cf 100644 --- a/contrib/chart/templates/repo-cache.yaml +++ b/contrib/chart/templates/repo-cache.yaml @@ -91,123 +91,7 @@ spec: image: {{ printf "%s:%s" $repoCacheImageRepository $repoCacheImageTag | quote }} imagePullPolicy: {{ $repoCacheImagePullPolicy }} command: - - /bin/bash - - -ec - - | - set -o pipefail - token_file=/github-token/token - if [ -s "$token_file" ]; then - cat > /tmp/git-askpass <<'EOF' - #!/bin/sh - case "$1" in - *Username*) printf '%s\n' x-access-token ;; - *Password*) cat /github-token/token ;; - *) printf '\n' ;; - esac - EOF - chmod 0700 /tmp/git-askpass - export GIT_ASKPASS=/tmp/git-askpass - fi - git config --global --add safe.directory '*' - git config --global init.defaultBranch main - umask 022 - ready_file=/cache/.repo-cache-ready - ready_tmp="${ready_file}.tmp" - - repository_fingerprint() { - printf 'repositories=%s\nrepository_refs=%s\n' "$REPOSITORIES" "$REPOSITORY_REFS" - } - - repo_ref() { - local repo="$1" - for entry in $REPOSITORY_REFS; do - case "$entry" in - "$repo="*) printf '%s\n' "${entry#*=}"; return 0 ;; - esac - done - } - - checkout_repo() { - local repo="$1" - local target="$2" - local requested_ref - local default_branch - requested_ref="$(repo_ref "$repo")" - if [ -n "$requested_ref" ]; then - if git -C "$target" rev-parse --verify --quiet "origin/${requested_ref}^{commit}" >/dev/null; then - git -C "$target" checkout -q --detach "origin/${requested_ref}" - elif git -C "$target" rev-parse --verify --quiet "${requested_ref}^{commit}" >/dev/null; then - git -C "$target" checkout -q --detach "$requested_ref" - else - git -C "$target" -c gc.auto=0 fetch --prune --tags origin "$requested_ref" - git -C "$target" checkout -q --detach FETCH_HEAD - fi - return - fi - - default_branch="$(git -C "$target" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##' || true)" - if [ -z "$default_branch" ] || [ "$default_branch" = "(unknown)" ]; then - default_branch=main - fi - git -C "$target" checkout -q -B "$default_branch" "origin/$default_branch" - } - - sync_repo() { - local repo="$1" - local repo_url="https://github.com/${repo}.git" - local target="/cache/${repo}" - local tmp - # Deterministic temp name. This script runs as a Kubernetes - # container command, and Kubernetes collapses a doubled dollar - # sign to a single one during its own $(VAR) expansion before - # bash sees it, so a PID-based suffix is neither unique nor - # valid. Sync is sequential per pod, so a fixed name plus the - # rm -rf below is enough. - tmp="${target}.tmp" - - mkdir -p "$(dirname "$target")" - if git -C "$target" rev-parse --git-dir >/dev/null 2>&1; then - echo "Updating $repo" - git -C "$target" config gc.auto 0 || true - git -C "$target" remote set-url origin "$repo_url" || git -C "$target" remote add origin "$repo_url" - git -C "$target" -c gc.auto=0 fetch --prune --tags origin - git -C "$target" remote set-head origin -a || true - checkout_repo "$repo" "$target" - git -C "$target" clean -fd - else - echo "Cloning $repo" - # Also sweep any stale "${target}.tmp.*" dirs left by the - # previous PID-suffix scheme so they don't accumulate on disk. - rm -rf "${target}".tmp* "$target" - git clone --quiet "$repo_url" "$tmp" - git -C "$tmp" config gc.auto 0 - git -C "$tmp" -c gc.auto=0 fetch --prune --tags origin - git -C "$tmp" remote set-head origin -a || true - checkout_repo "$repo" "$tmp" - git -C "$tmp" clean -fd - mv "$tmp" "$target" - fi - } - - while true; do - sync_ok=1 - for repo in $REPOSITORIES; do - if ! sync_repo "$repo"; then - echo "Failed to sync $repo" >&2 - sync_ok=0 - fi - done - if [ "$sync_ok" = "1" ]; then - { - repository_fingerprint - printf 'synced_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - } > "$ready_tmp" - mv "$ready_tmp" "$ready_file" - else - rm -f "$ready_tmp" "$ready_file" - fi - sleep "$SYNC_INTERVAL_SECONDS" - done + - /usr/local/bin/repo-cache-sync env: - name: REPOSITORIES value: {{ join " " $repoCacheRepositories | quote }} @@ -220,16 +104,8 @@ spec: readinessProbe: exec: command: - - /bin/bash - - -ec - - | - ready_file=/cache/.repo-cache-ready - expected="$(printf 'repositories=%s\nrepository_refs=%s\n' "$REPOSITORIES" "$REPOSITORY_REFS")" - actual="$(sed -n '1,2p' "$ready_file" 2>/dev/null || true)" - [ "$actual" = "$expected" ] || exit 1 - for repo in $REPOSITORIES; do - [ -d "/cache/$repo/.git" ] || exit 1 - done + - /usr/local/bin/repo-cache-sync + - --check-ready initialDelaySeconds: {{ .Values.repoCache.readinessProbe.initialDelaySeconds }} periodSeconds: {{ .Values.repoCache.readinessProbe.periodSeconds }} timeoutSeconds: {{ .Values.repoCache.readinessProbe.timeoutSeconds }} diff --git a/contrib/chart/values.schema.json b/contrib/chart/values.schema.json index c6d1d8b2b..0507439d1 100644 --- a/contrib/chart/values.schema.json +++ b/contrib/chart/values.schema.json @@ -166,6 +166,7 @@ "type": "object", "properties": { "enabled": { "type": "boolean" }, + "autoReload": { "type": "boolean" }, "hostPath": { "type": "string" }, "repositories": { "type": "array", diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index c8d486ced..9931900ee 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -226,6 +226,7 @@ sandbox: repoCache: enabled: true + autoReload: true hostPath: /var/lib/centaur/repos storage: # hostPath preserves the existing DaemonSet/node-local behavior. Set to diff --git a/docs/public/md/extend/acme-example.md b/docs/public/md/extend/acme-example.md index 7309446be..7d448c8c1 100644 --- a/docs/public/md/extend/acme-example.md +++ b/docs/public/md/extend/acme-example.md @@ -87,8 +87,12 @@ git -C centaur-acme rev-parse --short HEAD The Centaur chart's repo-cache DaemonSet checks out the overlay repo on each node, so changing tools, workflows, or skills is a Git push — no API, sandbox, or overlay image rebuild is required for overlay-only changes. New sandboxes see -the latest cached checkout; existing sandboxes can run `centaur-tools refresh` -when they need to refresh tool shims from the current repo-cache checkout. +the latest cached checkout. Repo-cache-enabled running sandboxes auto-refresh +their local tool shims and copied skills from the latest cached checkout; use +`centaur-tools refresh` only when you need a manual refresh. This only updates +the runtime catalog and local source copy. Secret grants and proxy credentials +are reconciled separately, so a newly visible tool may still fail normally until +its credential path is available. Configure the ordered overlay sources in Helm values: diff --git a/docs/public/md/reference/configuration.md b/docs/public/md/reference/configuration.md index 0fc428f02..a15083fa9 100644 --- a/docs/public/md/reference/configuration.md +++ b/docs/public/md/reference/configuration.md @@ -183,6 +183,8 @@ Sandbox entrypoint and wrappers: | --- | --- | --- | | `CENTAUR_HARNESS_CONFIG_DIR`, `CENTAUR_HARNESS_ADAPTER` | Sandbox image or `sandbox.extraEnv`. | Harness config directory and optional adapter executable. | | `CENTAUR_SKILL_DIRS` | Chart-rendered from `overlays.sources[*].skillsSubdir` (default `.agents/skills`) through `SESSION_SANDBOX_EXTRA_ENV`. | Ordered skill directories copied into the agent workspace. | +| `CENTAUR_TOOLS_AUTO_RELOAD` | `repoCache.autoReload` via api-rs tools config; defaults to `true`. | Enables repo-cache-backed auto-refresh of local tool shims and copied skills in running sandboxes. Runtime catalog only; secret grants/proxy credentials reconcile separately. | +| `CENTAUR_TOOLS_RELOAD_INTERVAL_SECONDS` | `sandbox.extraEnv`. | Poll interval for the repo-cache checkout watchdog. | | `AGENT_REPO`, `AGENT_PERSONA` | Runtime assignment metadata. | Workspace repo clone and persona prompt. | | `GOOGLE_APPLICATION_CREDENTIALS` | Sandbox entrypoint or `sandbox.extraEnv`. | Google ADC path; entrypoint creates a local stub when unset. | | `CODEX_API_KEY`, `CODEX_HOME`, `CODEX_CONTINUE_THREAD_ID` | `sandbox.extraEnv` or runtime resume. | Codex auth/config/resume behavior. | diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index 2ffd7ed33..c70e39b3a 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -1425,6 +1425,14 @@ struct ToolsArgs { env = "KUBERNETES_TOOLS_REPO_CACHE_PVC" )] repo_cache_pvc: Option, + #[arg( + id = "tools_auto_reload", + long = "kubernetes-tools-auto-reload", + env = "KUBERNETES_TOOLS_AUTO_RELOAD", + default_value_t = true, + action = clap::ArgAction::Set + )] + auto_reload: bool, #[arg( id = "tools_extra_sources", long = "kubernetes-tools-extra-sources", @@ -1478,6 +1486,7 @@ impl ToolsArgs { } config.repo_cache_path = clean_optional_value(self.repo_cache_path.as_deref()); config.repo_cache_pvc = clean_optional_value(self.repo_cache_pvc.as_deref()); + config.auto_reload = self.auto_reload; config.extra_sources = self.extra_sources(); Some(config) } @@ -2115,11 +2124,36 @@ mod tests { tools.repo_cache_path.as_deref(), Some("/var/lib/centaur/repos") ); + assert!(tools.auto_reload); let token = tools.github_token.expect("token should be Some"); assert_eq!(token.secret_name, "centaur-repo-cache-github-token"); assert_eq!(token.secret_key, "token"); } + #[test] + fn tools_config_reads_auto_reload_flag() { + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + "--session-sandbox-backend", + "agent-k8s", + "--kubernetes-sandbox-iron-proxy-mode", + "disabled", + "--kubernetes-tools-repo", + "paradigmxyz/centaur", + "--kubernetes-tools-runner-image", + "centaur-agent:test", + "--kubernetes-tools-auto-reload", + "false", + ]) + .unwrap(); + + let config = AgentSandboxConfig::try_from(&args.sandbox).unwrap(); + let tools = config.tools.expect("tools should be Some"); + assert!(!tools.auto_reload); + } + #[test] fn agent_k8s_workflow_dirs_fan_out_across_extra_sources() { let args = Args::try_parse_from([ diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs index 1e77a69f5..482acad94 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs @@ -71,6 +71,9 @@ pub struct ToolsConfig { /// Optional PVC backing for `repo_cache_path`. This lets Autopilot clusters use /// repoCache without hostPath volumes. pub repo_cache_pvc: Option, + /// Whether running sandboxes should watch repo-cache checkouts and refresh + /// local tool shims when commits change. + pub auto_reload: bool, /// Additional tool sources copied after the base tree. Duplicate tool names /// are skipped by the copy helper. pub extra_sources: Vec, @@ -102,6 +105,7 @@ impl ToolsConfig { github_token: None, repo_cache_path: None, repo_cache_pvc: None, + auto_reload: true, extra_sources: Vec::new(), } } @@ -149,6 +153,12 @@ pub(crate) fn baked_base_tool_dirs() -> String { /// Agent env added for tools wiring. pub(crate) fn agent_env(tools: Option<&ToolsConfig>) -> Vec<(String, String)> { let mut env = vec![("TOOL_DIRS".to_owned(), agent_tool_dirs())]; + if let Some(tools) = tools { + env.push(( + "CENTAUR_TOOLS_AUTO_RELOAD".to_owned(), + tools.auto_reload.to_string(), + )); + } if tools .and_then(|tools| tools.github_token.as_ref()) .is_some() @@ -442,6 +452,17 @@ mod tests { assert_eq!(env, vec![("TOOL_DIRS".to_owned(), "/app/tools".to_owned())]); } + #[test] + fn agent_env_sets_auto_reload_from_tools_config() { + let mut tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); + tools.auto_reload = false; + + let env = agent_env(Some(&tools)); + + assert!(env.contains(&("TOOL_DIRS".to_owned(), "/app/tools".to_owned()))); + assert!(env.contains(&("CENTAUR_TOOLS_AUTO_RELOAD".to_owned(), "false".to_owned()))); + } + #[test] fn baked_base_agent_env_sets_baked_tool_dirs() { assert_eq!( @@ -666,6 +687,7 @@ mod tests { }); let env = agent_env(Some(&tools)); + assert!(env.contains(&("CENTAUR_TOOLS_AUTO_RELOAD".to_owned(), "true".to_owned()))); assert!(env.contains(&( "CENTAUR_TOOLS_GITHUB_TOKEN_FILE".to_owned(), "/tools-github-token/token".to_owned() diff --git a/services/sandbox/Dockerfile b/services/sandbox/Dockerfile index 7d2048335..9c555635a 100644 --- a/services/sandbox/Dockerfile +++ b/services/sandbox/Dockerfile @@ -225,6 +225,8 @@ COPY --link --chmod=755 services/workflow-python/workflow_host.py /usr/local/bin COPY --link services/workflow-python/api/ /usr/local/bin/api/ COPY --link --chmod=755 services/sandbox/git-branch.sh /usr/local/bin/git-branch COPY --link --chmod=755 services/sandbox/install_tool_shims.py /usr/local/bin/install-tool-shims +COPY --link --chmod=755 services/sandbox/repo_cache_sync.py /usr/local/bin/repo-cache-sync +COPY --link --chmod=755 services/sandbox/repo_cache_watch.py /usr/local/bin/repo-cache-watch COPY --link --chmod=755 services/sandbox/entrypoint.sh /entrypoint.sh USER agent diff --git a/services/sandbox/entrypoint.sh b/services/sandbox/entrypoint.sh index 02b2e91f5..46996c337 100644 --- a/services/sandbox/entrypoint.sh +++ b/services/sandbox/entrypoint.sh @@ -390,6 +390,21 @@ mkdir -p "$HOME_DIR/uploads" WORKSPACE_DIR="$WORKSPACE_DIR" install-tool-shims --refresh-skills \ || echo "warning: failed to reload Centaur skills" >&2 +# ── Background: refresh repo-cache-backed tools/skills in running sandboxes ─── +case "${CENTAUR_TOOLS_AUTO_RELOAD:-true}" in + 0|false|False|FALSE|no|No|NO|off|Off|OFF) _centaur_tools_auto_reload=0 ;; + *) _centaur_tools_auto_reload=1 ;; +esac +if [ "$_centaur_tools_auto_reload" = "1" ] \ + && [ "${CENTAUR_SANDBOX_REPO_CACHE_ENABLED:-true}" != "false" ] \ + && [ -n "${TOOL_DIRS:-}" ]; then + ( + WORKSPACE_DIR="$WORKSPACE_DIR" repo-cache-watch \ + || echo "warning: Centaur tool auto-reload watcher stopped" >&2 + ) & +fi +unset _centaur_tools_auto_reload + # ── Assemble system prompt from bind mounts ────────────────────────────────── # Base prompt: mounted as AGENTS_BASE.md when present, fallback to baked-in AGENTS.md. # Org/persona overlays are mounted alongside the base prompt when present. diff --git a/services/sandbox/install_tool_shims.py b/services/sandbox/install_tool_shims.py index 50796bbf9..94c943165 100644 --- a/services/sandbox/install_tool_shims.py +++ b/services/sandbox/install_tool_shims.py @@ -13,8 +13,13 @@ import sys import tempfile import tomllib +from contextlib import contextmanager + +import fcntl TOOLS_METADATA_NAME = ".centaur-tools-source.json" +TOOLS_LOCK_NAME = ".centaur-tools.lock" +GENERATED_SHIM_MARKER = "# generated by install-tool-shims" def _split_paths(value: str) -> list[Path]: @@ -91,6 +96,40 @@ def _remove_path(path: Path) -> None: path.unlink() +def _write_text_atomic(path: Path, content: str, mode: int | None = None) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w") as f: + f.write(content) + if mode is None and path.exists(): + mode = stat.S_IMODE(path.stat().st_mode) + if mode is not None: + tmp_path.chmod(mode) + os.replace(tmp_path, path) + except Exception: + try: + tmp_path.unlink() + except FileNotFoundError: + pass + raise + + +@contextmanager +def _tool_lock(bin_dir: Path, *, exclusive: bool): + bin_dir.mkdir(parents=True, exist_ok=True) + lock_path = bin_dir / TOOLS_LOCK_NAME + with lock_path.open("a") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) + try: + yield + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + + def _clear_published_tools(tool_dir: Path) -> None: tool_dir.mkdir(parents=True, exist_ok=True) for child in _sorted_children(tool_dir): @@ -400,13 +439,13 @@ def _discover_scripts(tool_dirs: list[Path]) -> dict[str, dict[str, str]]: def _write_executable(path: Path, content: str) -> None: - path.write_text(content) - path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + _write_text_atomic(path, content, 0o755) def _write_tool_shim(path: Path, script: dict[str, str], _pythonpath: str) -> None: catalog = path.parent / "centaur-tools" content = f"""#!/bin/sh +{GENERATED_SHIM_MARKER} set -e exec {shlex.quote(str(catalog))} run {shlex.quote(script["name"])} "$@" """ @@ -424,8 +463,11 @@ def _write_catalog(path: Path, index_path: Path, pythonpath: str) -> None: import sys from datetime import datetime, timezone import time +from contextlib import contextmanager +import fcntl INDEX = {str(index_path)!r} +LOCK = {str(index_path.parent / TOOLS_LOCK_NAME)!r} PYTHONPATH_VALUE = {pythonpath!r} MAX_ANALYTICS_ARGS = 32 MAX_ANALYTICS_ARGS_LENGTH = 512 @@ -442,6 +484,18 @@ def usage(): return 2 +@contextmanager +def catalog_lock(exclusive=False): + lock_path = Path(LOCK) + lock_path.parent.mkdir(parents=True, exist_ok=True) + with open(lock_path, "a") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) + try: + yield + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + + CALL_RUNNER = r''' import asyncio import importlib @@ -659,49 +713,50 @@ def main(argv): command = argv[1] if len(argv) > 1 else "list" if command == "refresh": return subprocess.call(["install-tool-shims", "--refresh"]) - tools = load() - by_name = {{tool["name"]: tool for tool in tools}} - if command == "list": - for tool in tools: - print(f'{{tool["name"]}}\\t{{tool["project_dir"]}}') - return 0 - if command == "json": - print(json.dumps(tools, indent=2, sort_keys=True)) - return 0 - if command == "which" and len(argv) == 3: - tool = by_name.get(argv[2]) - if not tool: - print(f"unknown tool: {{argv[2]}}", file=sys.stderr) - return 1 - print(tool["project_dir"]) - return 0 - if command == "run" and len(argv) >= 3: - name = argv[2] - if name not in by_name: - print(f"unknown tool: {{name}}", file=sys.stderr) - return 1 - return run_tool(by_name[name], argv[3:]) - if command == "call" and len(argv) >= 4: - # Internal compatibility for Python workflow ctx.call_tool(...). Agents - # should use direct tool CLIs (` --help`, ` ...`) instead. - name = argv[2] - method = argv[3] - if name not in by_name: - print(f"unknown tool: {{name}}", file=sys.stderr) - return 1 - try: - payload = json.loads(argv[4]) if len(argv) >= 5 else {{}} - result = call_tool(by_name[name], method, payload) - if result.stdout: - print(result.stdout, end="") - if result.returncode != 0: - if result.stderr: - print(result.stderr, file=sys.stderr, end="") - return result.returncode + with catalog_lock(exclusive=False): + tools = load() + by_name = {{tool["name"]: tool for tool in tools}} + if command == "list": + for tool in tools: + print(f'{{tool["name"]}}\\t{{tool["project_dir"]}}') + return 0 + if command == "json": + print(json.dumps(tools, indent=2, sort_keys=True)) return 0 - except Exception as exc: - print(str(exc), file=sys.stderr) - return 1 + if command == "which" and len(argv) == 3: + tool = by_name.get(argv[2]) + if not tool: + print(f"unknown tool: {{argv[2]}}", file=sys.stderr) + return 1 + print(tool["project_dir"]) + return 0 + if command == "run" and len(argv) >= 3: + name = argv[2] + if name not in by_name: + print(f"unknown tool: {{name}}", file=sys.stderr) + return 1 + return run_tool(by_name[name], argv[3:]) + if command == "call" and len(argv) >= 4: + # Internal compatibility for Python workflow ctx.call_tool(...). Agents + # should use direct tool CLIs (` --help`, ` ...`) instead. + name = argv[2] + method = argv[3] + if name not in by_name: + print(f"unknown tool: {{name}}", file=sys.stderr) + return 1 + try: + payload = json.loads(argv[4]) if len(argv) >= 5 else {{}} + result = call_tool(by_name[name], method, payload) + if result.stdout: + print(result.stdout, end="") + if result.returncode != 0: + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + return result.returncode + return 0 + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 return usage() @@ -720,6 +775,85 @@ def _option_values(argv: list[str], option: str, count: int) -> list[str] | None return argv[index + 1 : index + 1 + count] +def _read_index_tool_names(index_path: Path) -> set[str]: + if not index_path.is_file(): + return set() + try: + data = json.loads(index_path.read_text()) + except (OSError, json.JSONDecodeError): + return set() + if not isinstance(data, list): + return set() + names = set() + for entry in data: + if isinstance(entry, dict) and isinstance(entry.get("name"), str): + names.add(entry["name"]) + return names + + +def _is_generated_tool_shim(path: Path) -> bool: + try: + content = path.read_text(errors="replace") + except OSError: + return False + return GENERATED_SHIM_MARKER in content or ( + "centaur-tools" in content and " run " in content + ) + + +def _remove_stale_tool_shims( + bin_dir: Path, old_names: set[str], new_names: set[str] +) -> None: + for name in sorted(old_names - new_names): + path = bin_dir / name + if path.exists() and _is_generated_tool_shim(path): + path.unlink() + + +def _install_tool_shims(tool_dirs: list[Path], bin_dir: Path, *, refresh: bool) -> int: + with _tool_lock(bin_dir, exclusive=True): + index_path = bin_dir / ".centaur-tools.json" + old_names = _read_index_tool_names(index_path) + + if refresh: + refreshed = _refresh_tool_dirs(tool_dirs) + print(f"refreshed {refreshed} Centaur tool source dirs", file=sys.stderr) + copied = _refresh_skill_dirs(_workspace_dir()) + print(f"reloaded {copied} Centaur skill entries", file=sys.stderr) + + scripts = _discover_scripts(tool_dirs) + pythonpath_parts = [ + part + for part in os.environ.get("CENTAUR_TOOL_PYTHONPATH", "").split(os.pathsep) + if part + ] + sdk_parent = Path("/opt/centaur") + if (sdk_parent / "centaur_sdk").is_dir() and str( + sdk_parent + ) not in pythonpath_parts: + pythonpath_parts.append(str(sdk_parent)) + pythonpath = os.pathsep.join(pythonpath_parts) + + for name, script in scripts.items(): + _write_tool_shim(bin_dir / name, script, pythonpath) + + new_names = set(scripts) + _remove_stale_tool_shims(bin_dir, old_names, new_names) + _write_text_atomic( + index_path, + json.dumps(list(scripts.values()), indent=2, sort_keys=True) + "\n", + ) + _write_catalog(bin_dir / "centaur-tools", index_path, pythonpath) + + # stdout is reserved for harness JSONL output (the session stdout pump streams + # it to clients); write bootstrap notices to stderr so they never pollute it. + print( + f"installed {len(scripts)} Centaur tool CLI shims into {bin_dir}", + file=sys.stderr, + ) + return 0 + + def main(argv: list[str]) -> int: refresh = "--refresh" in argv[1:] refresh_skills_only = "--refresh-skills" in argv[1:] @@ -736,34 +870,12 @@ def main(argv: list[str]) -> int: print(f"reloaded {copied} Centaur skill entries", file=sys.stderr) return 0 - bin_dir = Path(os.environ.get("CENTAUR_TOOL_BIN_DIR", str(Path.home() / ".local/bin"))) + bin_dir = Path( + os.environ.get("CENTAUR_TOOL_BIN_DIR", str(Path.home() / ".local/bin")) + ) bin_dir.mkdir(parents=True, exist_ok=True) - if refresh: - refreshed = _refresh_tool_dirs(tool_dirs) - print(f"refreshed {refreshed} Centaur tool source dirs", file=sys.stderr) - copied = _refresh_skill_dirs(_workspace_dir()) - print(f"reloaded {copied} Centaur skill entries", file=sys.stderr) - - scripts = _discover_scripts(tool_dirs) - pythonpath_parts = [ - part for part in os.environ.get("CENTAUR_TOOL_PYTHONPATH", "").split(os.pathsep) if part - ] - sdk_parent = Path("/opt/centaur") - if (sdk_parent / "centaur_sdk").is_dir() and str(sdk_parent) not in pythonpath_parts: - pythonpath_parts.append(str(sdk_parent)) - pythonpath = os.pathsep.join(pythonpath_parts) - - for name, script in scripts.items(): - _write_tool_shim(bin_dir / name, script, pythonpath) - - index_path = bin_dir / ".centaur-tools.json" - index_path.write_text(json.dumps(list(scripts.values()), indent=2, sort_keys=True) + "\n") - _write_catalog(bin_dir / "centaur-tools", index_path, pythonpath) - # stdout is reserved for harness JSONL output (the session stdout pump streams - # it to clients); write bootstrap notices to stderr so they never pollute it. - print(f"installed {len(scripts)} Centaur tool CLI shims into {bin_dir}", file=sys.stderr) - return 0 + return _install_tool_shims(tool_dirs, bin_dir, refresh=refresh) if __name__ == "__main__": diff --git a/services/sandbox/repo_cache_sync.py b/services/sandbox/repo_cache_sync.py new file mode 100644 index 000000000..04a5397e7 --- /dev/null +++ b/services/sandbox/repo_cache_sync.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Keep configured GitHub repositories synced into the Centaur repo cache.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import glob +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import time + + +def _split_words(value: str) -> list[str]: + return [part for part in value.split() if part] + + +def _repository_refs(value: str) -> dict[str, str]: + refs = {} + for entry in _split_words(value): + if "=" not in entry: + continue + repo, ref = entry.split("=", 1) + if repo and ref: + refs[repo] = ref + return refs + + +def _atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f"{path.name}.tmp") + tmp.write_text(content) + tmp.replace(path) + + +def _remove_path(path: Path) -> None: + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + elif path.exists() or path.is_symlink(): + path.unlink() + + +class RepoCacheSync: + def __init__( + self, + *, + cache_dir: Path, + repositories: list[str], + repository_refs: dict[str, str], + sync_interval_seconds: float, + github_token_file: Path, + ) -> None: + self.cache_dir = cache_dir + self.repositories = repositories + self.repository_refs = repository_refs + self.sync_interval_seconds = sync_interval_seconds + self.github_token_file = github_token_file + self.git_env: dict[str, str] | None = None + self.ready_file = self.cache_dir / ".repo-cache-ready" + + @classmethod + def from_env(cls) -> RepoCacheSync: + interval = os.environ.get("SYNC_INTERVAL_SECONDS", "").strip() + try: + sync_interval_seconds = float(interval) if interval else 30.0 + except ValueError: + sync_interval_seconds = 30.0 + if sync_interval_seconds <= 0: + sync_interval_seconds = 30.0 + + return cls( + cache_dir=Path(os.environ.get("REPO_CACHE_DIR", "/cache")), + repositories=_split_words(os.environ.get("REPOSITORIES", "")), + repository_refs=_repository_refs(os.environ.get("REPOSITORY_REFS", "")), + sync_interval_seconds=sync_interval_seconds, + github_token_file=Path( + os.environ.get("GITHUB_TOKEN_FILE", "/github-token/token") + ), + ) + + def _git_env(self) -> dict[str, str]: + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + if ( + self.github_token_file.is_file() + and self.github_token_file.stat().st_size > 0 + ): + askpass = Path("/tmp/git-askpass") + askpass.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + " *Username*) printf '%s\\n' x-access-token ;;\n" + f" *Password*) cat {shlex.quote(str(self.github_token_file))} ;;\n" + " *) printf '\\n' ;;\n" + "esac\n" + ) + askpass.chmod(0o700) + env["GIT_ASKPASS"] = str(askpass) + return env + + def configure_git(self) -> None: + self._run_git( + ["config", "--global", "--add", "safe.directory", "*"], "git safe.directory" + ) + self._run_git( + ["config", "--global", "init.defaultBranch", "main"], + "git init.defaultBranch", + ) + + def _run_git(self, args: list[str], label: str) -> subprocess.CompletedProcess[str]: + if self.git_env is None: + self.git_env = self._git_env() + try: + return subprocess.run( + ["git", *args], + check=True, + text=True, + env=self.git_env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.strip() + detail = f": {stderr}" if stderr else "" + raise RuntimeError(f"{label} failed{detail}") from exc + + def _git_output(self, repo_path: Path, *args: str) -> str | None: + try: + result = self._run_git(["-C", str(repo_path), *args], "git") + except RuntimeError: + return None + return result.stdout.strip() or None + + def _git_ok(self, repo_path: Path, *args: str) -> bool: + try: + self._run_git(["-C", str(repo_path), *args], "git") + except RuntimeError: + return False + return True + + def checkout_repo(self, repo: str, target: Path) -> None: + requested_ref = self.repository_refs.get(repo) + if requested_ref: + if self._git_ok( + target, + "rev-parse", + "--verify", + "--quiet", + f"origin/{requested_ref}^{{commit}}", + ): + self._run_git( + [ + "-C", + str(target), + "checkout", + "-q", + "--detach", + f"origin/{requested_ref}", + ], + f"checkout {repo}@origin/{requested_ref}", + ) + elif self._git_ok( + target, + "rev-parse", + "--verify", + "--quiet", + f"{requested_ref}^{{commit}}", + ): + self._run_git( + ["-C", str(target), "checkout", "-q", "--detach", requested_ref], + f"checkout {repo}@{requested_ref}", + ) + else: + self._run_git( + [ + "-C", + str(target), + "-c", + "gc.auto=0", + "fetch", + "--prune", + "--tags", + "origin", + requested_ref, + ], + f"fetch {repo}@{requested_ref}", + ) + self._run_git( + ["-C", str(target), "checkout", "-q", "--detach", "FETCH_HEAD"], + f"checkout {repo}@FETCH_HEAD", + ) + return + + default_branch = self._git_output( + target, + "symbolic-ref", + "--short", + "refs/remotes/origin/HEAD", + ) + if default_branch and default_branch.startswith("origin/"): + default_branch = default_branch.removeprefix("origin/") + if not default_branch or default_branch == "(unknown)": + default_branch = "main" + self._run_git( + [ + "-C", + str(target), + "checkout", + "-q", + "-B", + default_branch, + f"origin/{default_branch}", + ], + f"checkout {repo}@{default_branch}", + ) + + def sync_repo(self, repo: str) -> None: + repo_url = f"https://github.com/{repo}.git" + target = self.cache_dir / repo + tmp = target.with_name(f"{target.name}.tmp") + target.parent.mkdir(parents=True, exist_ok=True) + + if self._git_ok(target, "rev-parse", "--git-dir"): + print(f"Updating {repo}", flush=True) + self._git_ok(target, "config", "gc.auto", "0") + if not self._git_ok(target, "remote", "set-url", "origin", repo_url): + self._run_git( + ["-C", str(target), "remote", "add", "origin", repo_url], + f"set origin for {repo}", + ) + self._run_git( + [ + "-C", + str(target), + "-c", + "gc.auto=0", + "fetch", + "--prune", + "--tags", + "origin", + ], + f"fetch {repo}", + ) + self._git_ok(target, "remote", "set-head", "origin", "-a") + self.checkout_repo(repo, target) + self._run_git(["-C", str(target), "clean", "-fd"], f"clean {repo}") + return + + print(f"Cloning {repo}", flush=True) + for stale_tmp in glob.glob(f"{target}.tmp*"): + _remove_path(Path(stale_tmp)) + _remove_path(target) + self._run_git(["clone", "--quiet", repo_url, str(tmp)], f"clone {repo}") + self._git_ok(tmp, "config", "gc.auto", "0") + self._run_git( + ["-C", str(tmp), "-c", "gc.auto=0", "fetch", "--prune", "--tags", "origin"], + f"fetch {repo}", + ) + self._git_ok(tmp, "remote", "set-head", "origin", "-a") + self.checkout_repo(repo, tmp) + self._run_git(["-C", str(tmp), "clean", "-fd"], f"clean {repo}") + tmp.replace(target) + + def repository_fingerprint(self) -> str: + return ( + f"repositories={' '.join(self.repositories)}\n" + f"repository_refs={' '.join(f'{repo}={ref}' for repo, ref in self.repository_refs.items())}\n" + ) + + def write_ready(self) -> None: + synced_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + _atomic_write( + self.ready_file, f"{self.repository_fingerprint()}synced_at={synced_at}\n" + ) + + def check_ready(self) -> int: + try: + ready_lines = self.ready_file.read_text().splitlines() + except OSError: + return 1 + + expected_lines = self.repository_fingerprint().splitlines() + if ready_lines[: len(expected_lines)] != expected_lines: + return 1 + for repo in self.repositories: + if not (self.cache_dir / repo / ".git").is_dir(): + return 1 + return 0 + + def sync_once(self) -> bool: + sync_ok = True + for repo in self.repositories: + try: + self.sync_repo(repo) + except Exception as exc: + print(f"Failed to sync {repo}: {exc}", file=sys.stderr, flush=True) + sync_ok = False + if sync_ok: + self.write_ready() + else: + _remove_path(self.ready_file) + return sync_ok + + def run_forever(self) -> int: + os.umask(0o022) + self.configure_git() + if not self.repositories: + print( + "No repositories configured for repo-cache", file=sys.stderr, flush=True + ) + return 0 + while True: + self.sync_once() + time.sleep(self.sync_interval_seconds) + + +def main() -> int: + sync = RepoCacheSync.from_env() + if "--check-ready" in sys.argv[1:]: + return sync.check_ready() + return sync.run_forever() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/sandbox/repo_cache_watch.py b/services/sandbox/repo_cache_watch.py new file mode 100644 index 000000000..6e4275fcf --- /dev/null +++ b/services/sandbox/repo_cache_watch.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Refresh sandbox tools when mounted repo-cache checkouts change.""" + +from __future__ import annotations + +from collections.abc import Callable +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +TOOLS_METADATA_NAME = ".centaur-tools-source.json" + + +def _split_paths(value: str) -> list[Path]: + return [Path(part) for part in value.split(":") if part] + + +def _env_float(name: str, default: float) -> float: + value = os.environ.get(name, "").strip() + if not value: + return default + try: + parsed = float(value) + except ValueError: + return default + return parsed if parsed > 0 else default + + +def _metadata_sources(metadata: dict[str, object]) -> list[dict[str, object]]: + sources = metadata.get("sources") + if isinstance(sources, list) and sources: + return [source for source in sources if isinstance(source, dict)] + return [metadata] + + +def _repo_cache_watches(tool_dirs: list[Path]) -> list[dict[str, str]]: + watches: list[dict[str, str]] = [] + seen = set() + for tool_dir in tool_dirs: + metadata_path = tool_dir / TOOLS_METADATA_NAME + if not metadata_path.is_file(): + continue + try: + metadata = json.loads(metadata_path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + print(f"warning: failed to read {metadata_path}: {exc}", file=sys.stderr) + continue + if not isinstance(metadata, dict): + continue + for source in _metadata_sources(metadata): + if source.get("source") != "repo_cache": + continue + repo_cache_repo_path = source.get("repo_cache_repo_path") + if not repo_cache_repo_path: + continue + repo = str(source.get("repo") or repo_cache_repo_path) + repo_path = str(repo_cache_repo_path) + key = (repo, repo_path) + if key in seen: + continue + seen.add(key) + watches.append({"repo": repo, "repo_cache_repo_path": repo_path}) + return sorted(watches, key=lambda watch: (watch["repo"], watch["repo_cache_repo_path"])) + + +def _git_output(repo_path: str, *args: str) -> str | None: + try: + result = subprocess.run( + ["git", "-C", repo_path, *args], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.CalledProcessError): + return None + return result.stdout.strip() or None + + +def _repo_cache_fingerprint(tool_dirs: list[Path]) -> str | None: + watches = _repo_cache_watches(tool_dirs) + if not watches: + return None + + entries = [] + for watch in watches: + repo_path = watch["repo_cache_repo_path"] + commit = _git_output(repo_path, "rev-parse", "HEAD") + if commit is None: + return None + entries.append( + { + "repo": watch["repo"], + "repo_cache_repo_path": repo_path, + "commit": commit, + } + ) + return json.dumps(entries, sort_keys=True, separators=(",", ":")) + + +def _refresh_tools() -> int: + try: + return subprocess.call(["centaur-tools", "refresh"]) + except OSError as exc: + print(f"warning: failed to run centaur-tools refresh: {exc}", file=sys.stderr) + return 1 + + +def _refresh_if_changed( + tool_dirs: list[Path], + applied_fingerprint: str | None, + refresh: Callable[[], int] = _refresh_tools, +) -> tuple[str | None, bool]: + fingerprint = _repo_cache_fingerprint(tool_dirs) + if fingerprint is None or fingerprint == applied_fingerprint: + return applied_fingerprint, False + + print("repo-cache changed; running centaur-tools refresh", file=sys.stderr) + if refresh() != 0: + print("warning: centaur-tools refresh failed", file=sys.stderr) + return applied_fingerprint, False + return fingerprint, True + + +def watch_repo_cache(tool_dirs: list[Path]) -> int: + if not _repo_cache_watches(tool_dirs): + print( + "repo-cache tool auto-reload disabled: no repo-cache tool sources", + file=sys.stderr, + ) + return 0 + + interval = _env_float("CENTAUR_TOOLS_RELOAD_INTERVAL_SECONDS", 10.0) + applied_fingerprint = _repo_cache_fingerprint(tool_dirs) + print("repo-cache tool auto-reload watcher started", file=sys.stderr) + + while True: + time.sleep(interval) + applied_fingerprint, _ = _refresh_if_changed(tool_dirs, applied_fingerprint) + + +def main() -> int: + return watch_repo_cache(_split_paths(os.environ.get("TOOL_DIRS", ""))) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/sandbox/test_install_tool_shims.py b/services/sandbox/test_install_tool_shims.py index 5da15bbba..672d0027d 100644 --- a/services/sandbox/test_install_tool_shims.py +++ b/services/sandbox/test_install_tool_shims.py @@ -312,5 +312,53 @@ def test_centaur_tools_run_uses_catalog_entry_directly(self) -> None: self.assertIn("usage: centaur-tools", result.stderr) +class RefreshInstallTest(unittest.TestCase): + def test_install_removes_stale_generated_shims(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + tool_dir = root / "tools" + bin_dir = root / "bin" + package_dir = tool_dir / "research" / "websearch" + package_dir.mkdir(parents=True) + bin_dir.mkdir() + (package_dir / "pyproject.toml").write_text( + '[project]\nname = "websearch"\n\n[project.scripts]\nwebsearch = "client:main"\n' + ) + (bin_dir / ".centaur-tools.json").write_text( + json.dumps( + [ + { + "name": "websearch", + "project_dir": "/old/websearch", + "package": "websearch", + "entrypoint": "client:main", + "client_module": "client.py", + }, + { + "name": "gone", + "project_dir": "/old/gone", + "package": "gone", + "entrypoint": "client:main", + "client_module": "client.py", + }, + ] + ) + + "\n" + ) + (bin_dir / "gone").write_text( + "#!/bin/sh\n# generated by install-tool-shims\n" + ) + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + install_tool_shims._install_tool_shims( + [tool_dir], bin_dir, refresh=False + ) + + self.assertTrue((bin_dir / "websearch").exists()) + self.assertFalse((bin_dir / "gone").exists()) + index = json.loads((bin_dir / ".centaur-tools.json").read_text()) + self.assertEqual([tool["name"] for tool in index], ["websearch"]) + if __name__ == "__main__": unittest.main() diff --git a/services/sandbox/test_repo_cache_sync.py b/services/sandbox/test_repo_cache_sync.py new file mode 100644 index 000000000..68983f38e --- /dev/null +++ b/services/sandbox/test_repo_cache_sync.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +import repo_cache_sync + + +class RepoCacheSyncTest(unittest.TestCase): + def test_repository_refs_parse_nonempty_entries(self) -> None: + self.assertEqual( + repo_cache_sync._repository_refs("acme/one=main bad acme/two=abc123"), + {"acme/one": "main", "acme/two": "abc123"}, + ) + + def test_write_ready_preserves_readiness_format(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + + sync = repo_cache_sync.RepoCacheSync( + cache_dir=root / "cache", + repositories=["acme/centaur"], + repository_refs={"acme/centaur": "main"}, + sync_interval_seconds=30, + github_token_file=root / "missing-token", + ) + + sync.write_ready() + + lines = (root / "cache" / ".repo-cache-ready").read_text().splitlines() + self.assertEqual(lines[0], "repositories=acme/centaur") + self.assertEqual(lines[1], "repository_refs=acme/centaur=main") + self.assertRegex(lines[2], r"^synced_at=\d{4}-\d{2}-\d{2}T") + + def test_check_ready_validates_fingerprint_and_repos(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + repo_path = root / "cache" / "acme" / "centaur" / ".git" + repo_path.mkdir(parents=True) + sync = repo_cache_sync.RepoCacheSync( + cache_dir=root / "cache", + repositories=["acme/centaur"], + repository_refs={"acme/centaur": "main"}, + sync_interval_seconds=30, + github_token_file=root / "missing-token", + ) + sync.write_ready() + + self.assertEqual(sync.check_ready(), 0) + (root / "cache" / ".repo-cache-ready").write_text( + "repositories=wrong\nrepository_refs=acme/centaur=main\n" + ) + self.assertEqual(sync.check_ready(), 1) + + def test_run_forever_restores_repo_cache_umask(self) -> None: + class StopAfterUmask(repo_cache_sync.RepoCacheSync): + def configure_git(self) -> None: + raise RuntimeError("stop") + + old_umask = os.umask(0o077) + try: + sync = StopAfterUmask( + cache_dir=Path("/tmp"), + repositories=["acme/centaur"], + repository_refs={}, + sync_interval_seconds=30, + github_token_file=Path("/tmp/missing-token"), + ) + with self.assertRaises(RuntimeError): + sync.run_forever() + current_umask = os.umask(old_umask) + self.assertEqual(current_umask, 0o022) + finally: + os.umask(old_umask) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/sandbox/test_repo_cache_watch.py b/services/sandbox/test_repo_cache_watch.py new file mode 100644 index 000000000..13d7d832b --- /dev/null +++ b/services/sandbox/test_repo_cache_watch.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import contextlib +import io +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +import repo_cache_watch + + +def _write_metadata(tool_dir: Path, repo_path: Path) -> None: + tool_dir.mkdir(parents=True, exist_ok=True) + (tool_dir / repo_cache_watch.TOOLS_METADATA_NAME).write_text( + json.dumps( + { + "sources": [ + { + "repo": "acme/centaur", + "source": "repo_cache", + "source_subdir": "tools", + "repo_cache_repo_path": str(repo_path), + } + ] + } + ) + ) + + +def _init_repo(repo_path: Path) -> None: + repo_path.mkdir(parents=True) + subprocess.run(["git", "init", "-q", "-b", "test-branch", str(repo_path)], check=True) + subprocess.run( + ["git", "-C", str(repo_path), "config", "user.email", "test@example.com"], + check=True, + ) + subprocess.run( + ["git", "-C", str(repo_path), "config", "user.name", "Test"], + check=True, + ) + + +def _commit(repo_path: Path, content: str) -> str: + (repo_path / "tools").mkdir(exist_ok=True) + (repo_path / "tools" / "example.txt").write_text(content) + subprocess.run(["git", "-C", str(repo_path), "add", "tools"], check=True) + subprocess.run( + ["git", "-C", str(repo_path), "commit", "-q", "-m", "update"], + check=True, + ) + return subprocess.check_output( + ["git", "-C", str(repo_path), "rev-parse", "HEAD"], + text=True, + ).strip() + + +class RepoCacheWatchTest(unittest.TestCase): + def test_fingerprint_uses_repo_cache_commit(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + tool_dir = root / "tools" + repo_path = root / "cache" / "acme" / "centaur" + _init_repo(repo_path) + commit = _commit(repo_path, "hello\n") + _write_metadata(tool_dir, repo_path) + + entries = json.loads(repo_cache_watch._repo_cache_fingerprint([tool_dir])) + self.assertEqual( + entries, + [ + { + "commit": commit, + "repo": "acme/centaur", + "repo_cache_repo_path": str(repo_path), + } + ], + ) + + fingerprint = repo_cache_watch._repo_cache_fingerprint([tool_dir]) + _commit(repo_path, "goodbye\n") + self.assertNotEqual( + repo_cache_watch._repo_cache_fingerprint([tool_dir]), + fingerprint, + ) + + def test_refresh_if_changed_calls_refresh_and_advances_on_success(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + tool_dir = root / "tools" + repo_path = root / "cache" / "acme" / "centaur" + _init_repo(repo_path) + _commit(repo_path, "hello\n") + _write_metadata(tool_dir, repo_path) + calls = 0 + + def refresh() -> int: + nonlocal calls + calls += 1 + return 0 + + with contextlib.redirect_stderr(io.StringIO()): + applied, refreshed = repo_cache_watch._refresh_if_changed( + [tool_dir], None, refresh + ) + self.assertTrue(refreshed) + self.assertEqual(calls, 1) + + with contextlib.redirect_stderr(io.StringIO()): + applied, refreshed = repo_cache_watch._refresh_if_changed( + [tool_dir], applied, refresh + ) + self.assertFalse(refreshed) + self.assertEqual(calls, 1) + + _commit(repo_path, "goodbye\n") + with contextlib.redirect_stderr(io.StringIO()): + applied, refreshed = repo_cache_watch._refresh_if_changed( + [tool_dir], applied, refresh + ) + self.assertTrue(refreshed) + self.assertEqual(calls, 2) + self.assertEqual( + applied, + repo_cache_watch._repo_cache_fingerprint([tool_dir]), + ) + + def test_refresh_if_changed_retries_after_failure(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + tool_dir = root / "tools" + repo_path = root / "cache" / "acme" / "centaur" + _init_repo(repo_path) + _commit(repo_path, "hello\n") + _write_metadata(tool_dir, repo_path) + + with contextlib.redirect_stderr(io.StringIO()): + applied, refreshed = repo_cache_watch._refresh_if_changed( + [tool_dir], None, lambda: 1 + ) + + self.assertFalse(refreshed) + self.assertIsNone(applied) + + +if __name__ == "__main__": + unittest.main() From 363e5d657f5a2dba8c1cbe60b5426b0a6219f353 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:50:07 +0300 Subject: [PATCH 018/198] [codex] default sandbox lifecycle cleanup (#739) * fix: default sandbox timeout policy * fix: rely on sandbox max lifetime reaping * docs: clarify sandbox lifecycle timers * fix: prune stale warm sandboxes before replenishing * fix: prune stale warm sandboxes across workloads * fix: preserve idle pause deadlines after restart * refactor: simplify idle cleanup candidate query --- contrib/chart/templates/apirs.yaml | 2 - contrib/chart/values.yaml | 8 +- docs/pages/deploying-in-production.mdx | 15 ++ docs/pages/reference/configuration.mdx | 14 +- docs/public/md/deploying-in-production.md | 15 ++ docs/public/md/reference/configuration.md | 14 +- .../crates/centaur-api-server/src/args.rs | 24 +- .../centaur-sandbox-agent-k8s/src/lib.rs | 6 +- .../centaur-sandbox-manager/src/reaper.rs | 115 +++------ .../centaur-sandbox-manager/src/warm_pool.rs | 231 ++++++++++++++++++ .../centaur-session-runtime/src/cleanup.rs | 2 +- .../crates/centaur-session-runtime/src/lib.rs | 4 +- .../crates/centaur-session-sqlx/src/lib.rs | 212 ++++++++++++++-- services/slackbotv2/src/session-api.ts | 12 +- services/slackbotv2/src/types.ts | 1 + services/slackbotv2/test/session-api.test.ts | 34 ++- 16 files changed, 578 insertions(+), 131 deletions(-) diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 550abd736..bd1f63bb2 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -242,8 +242,6 @@ spec: value: {{ .Values.apiRs.sandboxWarmPoolSize | quote }} - name: SESSION_SANDBOX_WARM_POOL_REPLENISH_INTERVAL_SECS value: {{ .Values.apiRs.sandboxWarmPoolReplenishIntervalSecs | quote }} - - name: SESSION_SANDBOX_IDLE_STOP_TTL_SECS - value: {{ .Values.apiRs.sandboxIdleStopTtlSecs | quote }} - name: SESSION_SANDBOX_MAX_LIFETIME_SECS value: {{ .Values.apiRs.sandboxMaxLifetimeSecs | quote }} - name: SESSION_SANDBOX_REAP_INTERVAL_SECS diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index 9931900ee..640accfd1 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -344,14 +344,14 @@ apiRs: companyContextDocuments: enabled: true intervalSeconds: 14400 - # Reaper: stop sandboxes idle-paused longer than the idle TTL or older than - # the max lifetime. 0 disables that sweep. Interval must be >= 1. - sandboxIdleStopTtlSecs: 10800 # 3 hours + # Reaper: stop sandboxes older than the max lifetime, regardless of whether + # they are running or suspended. 0 disables the sweep. Interval must be >= 1. sandboxMaxLifetimeSecs: 259200 # 3 days sandboxReapIntervalSecs: 300 # Cleanup worker: stop unreferenced session/warm-pool sandboxes after two # consecutive sweeps and restore idle-pauses lost across api-rs restarts. - # 0 disables the corresponding arm. + # The idle backstop is only used when older execution rows have no persisted + # idle_timeout_ms. 0 disables the corresponding arm. sandboxCleanupIntervalSecs: 300 sandboxIdleCleanupBackstopSecs: 21600 # 6 hours ironProxy: diff --git a/docs/pages/deploying-in-production.mdx b/docs/pages/deploying-in-production.mdx index 7338e97a8..b9a76eeb8 100644 --- a/docs/pages/deploying-in-production.mdx +++ b/docs/pages/deploying-in-production.mdx @@ -238,6 +238,10 @@ ironProxy: secretSource: onepassword-connect secretTtl: 10m +apiRs: + # Delete any sandbox older than this, running or suspended. + sandboxMaxLifetimeSecs: 259200 + onepasswordConnect: connect: create: true @@ -255,6 +259,17 @@ sandbox: The Kubernetes sandbox backend is the active runtime backend; there is no chart switch named `api.sandboxBackend`. +Sandbox lifecycle has two separate timers: + +- Slackbot v2 sends `idle_timeout_ms` on execute requests, defaulting to up to + 3 hours, so api-rs can pause an idle sandbox after a turn finishes. +- api-rs deletes old sandboxes through `apiRs.sandboxMaxLifetimeSecs`, default + 72 hours, regardless of whether the sandbox is still running or already + suspended. + +There is no suspended-only delete setting. If you want sandboxes gone after N +hours, set `apiRs.sandboxMaxLifetimeSecs` to N hours in seconds. + Install or upgrade: ```bash diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index d343992ba..22a246d26 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -85,6 +85,18 @@ Optional required-by-mode variables: | `apiRs.metrics.path` | Helm value, default `/metrics`. | Metrics scrape path for annotation-based discovery. | | `apiRs.metrics.annotations` | Helm value. | Additional scrape annotations for Prometheus-compatible collectors. | +Sandbox lifecycle: + +| Env var or value | Set from | Controls | +| --- | --- | --- | +| `SESSION_IDLE_TIMEOUT_MS` | `slackbotv2.extraEnv`; default is up to 3 hours. | Slackbot v2 execute idle timeout. After an execution reaches a terminal state, api-rs pauses the sandbox if no newer execution has used that sandbox. If `SESSION_MAX_DURATION_MS` is lower than 3 hours and this value is unset, Slackbot v2 caps the default idle timeout to the max duration. | +| `SESSION_MAX_DURATION_MS` | `slackbotv2.extraEnv`. | Optional per-execution max duration forwarded to api-rs. api-rs rejects requests where `idle_timeout_ms` is greater than `max_duration_ms`. | +| `apiRs.sandboxMaxLifetimeSecs` / `SESSION_SANDBOX_MAX_LIFETIME_SECS` | Helm value, default `259200` (72 hours). | Restart-surviving sandbox deletion backstop. The reaper stops any non-terminal sandbox older than this, regardless of whether it is running or suspended. Set `0` to disable max-lifetime reaping. | +| `apiRs.sandboxReapIntervalSecs` / `SESSION_SANDBOX_REAP_INTERVAL_SECS` | Helm value, default `300`. | How often api-rs sweeps observed sandboxes for max-lifetime expiry. | + +There is no separate suspended-only delete timer. Pausing is controlled by the +per-execution idle timeout; deletion is controlled by sandbox max lifetime. + Execution tuning: | Env var | Set from | Controls | @@ -163,7 +175,7 @@ Kubernetes backend: | `KUBERNETES_SANDBOX_RUNTIME_CLASS_NAME`, `KUBERNETES_SANDBOX_SERVICE_ACCOUNT_NAME` | `sandbox.runtimeClassName`, `api.extraEnv`. | Pod runtime class and service account. | | `KUBERNETES_SANDBOX_CPU_LIMIT`, `KUBERNETES_SANDBOX_MEMORY_LIMIT`, `KUBERNETES_SANDBOX_CPU_REQUEST`, `KUBERNETES_SANDBOX_MEMORY_REQUEST` | `sandbox.resources.*`. | Sandbox pod resources. | | `KUBERNETES_SANDBOX_READY_TIMEOUT_S`, `KUBERNETES_ATTACH_LOG_TAIL_LINES` | `api.extraEnv`. | Sandbox readiness and attach diagnostics. | -| `SESSION_SANDBOX_CLEANUP_INTERVAL_SECS`, `SESSION_SANDBOX_IDLE_CLEANUP_BACKSTOP_SECS` | `apiRs.sandboxCleanupIntervalSecs`, `apiRs.sandboxIdleCleanupBackstopSecs`. | DB-aware cleanup of unreferenced sandboxes and idle-pause backstop after API restarts. | +| `SESSION_SANDBOX_CLEANUP_INTERVAL_SECS`, `SESSION_SANDBOX_IDLE_CLEANUP_BACKSTOP_SECS` | `apiRs.sandboxCleanupIntervalSecs`, `apiRs.sandboxIdleCleanupBackstopSecs`. | DB-aware cleanup of unreferenced sandboxes and restart recovery for idle pauses. Persisted `idle_timeout_ms` is honored after restart; the backstop is the fallback for older execution rows without that metadata. | | `KUBERNETES_SANDBOX_EXTRA_ENV` | `sandbox.extraEnv`. | JSON list copied into each sandbox. | | `KUBERNETES_WORKFLOW_DIRS` | Chart-rendered from `overlays.sources[*].workflowsSubdir` (default `workflows`) using the sandbox repo-cache mount prefix. | Workflow-host sandbox discovery paths. | | `KUBERNETES_FIREWALL_CA_SECRET_NAME`, `KUBERNETES_FIREWALL_CA_KEY_SECRET_NAME` | `firewall.existingCa*` or generated CA Secrets. | CA material for sandbox/proxy TLS interception. | diff --git a/docs/public/md/deploying-in-production.md b/docs/public/md/deploying-in-production.md index 7338e97a8..b9a76eeb8 100644 --- a/docs/public/md/deploying-in-production.md +++ b/docs/public/md/deploying-in-production.md @@ -238,6 +238,10 @@ ironProxy: secretSource: onepassword-connect secretTtl: 10m +apiRs: + # Delete any sandbox older than this, running or suspended. + sandboxMaxLifetimeSecs: 259200 + onepasswordConnect: connect: create: true @@ -255,6 +259,17 @@ sandbox: The Kubernetes sandbox backend is the active runtime backend; there is no chart switch named `api.sandboxBackend`. +Sandbox lifecycle has two separate timers: + +- Slackbot v2 sends `idle_timeout_ms` on execute requests, defaulting to up to + 3 hours, so api-rs can pause an idle sandbox after a turn finishes. +- api-rs deletes old sandboxes through `apiRs.sandboxMaxLifetimeSecs`, default + 72 hours, regardless of whether the sandbox is still running or already + suspended. + +There is no suspended-only delete setting. If you want sandboxes gone after N +hours, set `apiRs.sandboxMaxLifetimeSecs` to N hours in seconds. + Install or upgrade: ```bash diff --git a/docs/public/md/reference/configuration.md b/docs/public/md/reference/configuration.md index a15083fa9..e12dcc5f5 100644 --- a/docs/public/md/reference/configuration.md +++ b/docs/public/md/reference/configuration.md @@ -85,6 +85,18 @@ Optional required-by-mode variables: | `apiRs.metrics.path` | Helm value, default `/metrics`. | Metrics scrape path for annotation-based discovery. | | `apiRs.metrics.annotations` | Helm value. | Additional scrape annotations for Prometheus-compatible collectors. | +Sandbox lifecycle: + +| Env var or value | Set from | Controls | +| --- | --- | --- | +| `SESSION_IDLE_TIMEOUT_MS` | `slackbotv2.extraEnv`; default is up to 3 hours. | Slackbot v2 execute idle timeout. After an execution reaches a terminal state, api-rs pauses the sandbox if no newer execution has used that sandbox. If `SESSION_MAX_DURATION_MS` is lower than 3 hours and this value is unset, Slackbot v2 caps the default idle timeout to the max duration. | +| `SESSION_MAX_DURATION_MS` | `slackbotv2.extraEnv`. | Optional per-execution max duration forwarded to api-rs. api-rs rejects requests where `idle_timeout_ms` is greater than `max_duration_ms`. | +| `apiRs.sandboxMaxLifetimeSecs` / `SESSION_SANDBOX_MAX_LIFETIME_SECS` | Helm value, default `259200` (72 hours). | Restart-surviving sandbox deletion backstop. The reaper stops any non-terminal sandbox older than this, regardless of whether it is running or suspended. Set `0` to disable max-lifetime reaping. | +| `apiRs.sandboxReapIntervalSecs` / `SESSION_SANDBOX_REAP_INTERVAL_SECS` | Helm value, default `300`. | How often api-rs sweeps observed sandboxes for max-lifetime expiry. | + +There is no separate suspended-only delete timer. Pausing is controlled by the +per-execution idle timeout; deletion is controlled by sandbox max lifetime. + Execution tuning: | Env var | Set from | Controls | @@ -163,7 +175,7 @@ Kubernetes backend: | `KUBERNETES_SANDBOX_RUNTIME_CLASS_NAME`, `KUBERNETES_SANDBOX_SERVICE_ACCOUNT_NAME` | `sandbox.runtimeClassName`, `api.extraEnv`. | Pod runtime class and service account. | | `KUBERNETES_SANDBOX_CPU_LIMIT`, `KUBERNETES_SANDBOX_MEMORY_LIMIT`, `KUBERNETES_SANDBOX_CPU_REQUEST`, `KUBERNETES_SANDBOX_MEMORY_REQUEST` | `sandbox.resources.*`. | Sandbox pod resources. | | `KUBERNETES_SANDBOX_READY_TIMEOUT_S`, `KUBERNETES_ATTACH_LOG_TAIL_LINES` | `api.extraEnv`. | Sandbox readiness and attach diagnostics. | -| `SESSION_SANDBOX_CLEANUP_INTERVAL_SECS`, `SESSION_SANDBOX_IDLE_CLEANUP_BACKSTOP_SECS` | `apiRs.sandboxCleanupIntervalSecs`, `apiRs.sandboxIdleCleanupBackstopSecs`. | DB-aware cleanup of unreferenced sandboxes and idle-pause backstop after API restarts. | +| `SESSION_SANDBOX_CLEANUP_INTERVAL_SECS`, `SESSION_SANDBOX_IDLE_CLEANUP_BACKSTOP_SECS` | `apiRs.sandboxCleanupIntervalSecs`, `apiRs.sandboxIdleCleanupBackstopSecs`. | DB-aware cleanup of unreferenced sandboxes and restart recovery for idle pauses. Persisted `idle_timeout_ms` is honored after restart; the backstop is the fallback for older execution rows without that metadata. | | `KUBERNETES_SANDBOX_EXTRA_ENV` | `sandbox.extraEnv`. | JSON list copied into each sandbox. | | `KUBERNETES_WORKFLOW_DIRS` | Chart-rendered from `overlays.sources[*].workflowsSubdir` (default `workflows`) using the sandbox repo-cache mount prefix. | Workflow-host sandbox discovery paths. | | `KUBERNETES_FIREWALL_CA_SECRET_NAME`, `KUBERNETES_FIREWALL_CA_KEY_SECRET_NAME` | `firewall.existingCa*` or generated CA Secrets. | CA material for sandbox/proxy TLS interception. | diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index c70e39b3a..f6828d705 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -529,21 +529,13 @@ struct SandboxArgs { value_parser = clap::value_parser!(u64).range(1..) )] warm_pool_replenish_interval_secs: u64, - /// Stop sandboxes that have been idle-paused longer than this. 0 disables - /// the idle sweep. - #[arg( - long = "session-sandbox-idle-stop-ttl-secs", - env = "SESSION_SANDBOX_IDLE_STOP_TTL_SECS", - default_value_t = 3600 - )] - sandbox_idle_stop_ttl_secs: u64, /// Stop any sandbox older than this regardless of status; sessions replace /// reaped sandboxes on their next message. 0 disables the max-lifetime /// sweep. #[arg( long = "session-sandbox-max-lifetime-secs", env = "SESSION_SANDBOX_MAX_LIFETIME_SECS", - default_value_t = 86_400 + default_value_t = 259_200 )] sandbox_max_lifetime_secs: u64, #[arg( @@ -1225,7 +1217,6 @@ impl SandboxArgs { let ttl = |secs: u64| (secs > 0).then(|| Duration::from_secs(secs)); SandboxReaperConfig { interval: Duration::from_secs(self.sandbox_reap_interval_secs), - idle_ttl: ttl(self.sandbox_idle_stop_ttl_secs), max_lifetime: ttl(self.sandbox_max_lifetime_secs), } } @@ -2041,6 +2032,19 @@ mod tests { assert_eq!(args.sandbox.k8s_context.as_deref(), Some("kind-test")); } + #[test] + fn sandbox_reaper_defaults_delete_after_max_lifetime() { + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + ]) + .unwrap(); + + let config = args.sandbox_reaper_config(); + assert_eq!(config.max_lifetime, Some(Duration::from_secs(259_200))); + } + #[test] fn accepts_kubernetes_aliases_for_sandbox_flags() { let args = Args::try_parse_from([ diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index e3c3674f5..d58a7fb64 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -42,10 +42,8 @@ const MANAGED_BY_VALUE: &str = "api-rs"; // so resume (which has only the sandbox id) can rebind without the spec or any // in-memory state. Survives pause and api-rs restarts. const IRON_CONTROL_PRINCIPAL_ANNOTATION: &str = "centaur.ai/iron-control-principal"; -// RFC 3339 instant stamped when the sandbox is paused for idleness and -// cleared on resume. The reaper uses it to stop sandboxes whose pause -// outlived the idle TTL, surviving api-rs restarts (the pause timer is -// otherwise in-memory only). +// RFC 3339 instant stamped when the sandbox is paused for idleness and cleared +// on resume. This keeps suspended status observable across api-rs restarts. const PAUSED_AT_ANNOTATION: &str = "centaur.ai/paused-at"; static NEXT_ID: AtomicU64 = AtomicU64::new(1); diff --git a/services/api-rs/crates/centaur-sandbox-manager/src/reaper.rs b/services/api-rs/crates/centaur-sandbox-manager/src/reaper.rs index b058467af..80462b8bf 100644 --- a/services/api-rs/crates/centaur-sandbox-manager/src/reaper.rs +++ b/services/api-rs/crates/centaur-sandbox-manager/src/reaper.rs @@ -1,18 +1,18 @@ //! Background garbage collection for leaked sandboxes. //! -//! Sessions pause idle sandboxes (replicas to zero) but nothing stops them: -//! the pause timer lives in process memory and dies with the deploy, and -//! sandboxes whose sessions never go idle are retained forever. The reaper is -//! the restart-surviving backstop: it sweeps the backend's observed sandboxes -//! and stops any that outlived their welcome, releasing the sandbox, its -//! proxy resources, and its node pod slots. +//! Sessions pause idle sandboxes (replicas to zero), but paused sandboxes and +//! sandboxes whose sessions never go idle still need a restart-surviving +//! backstop. The reaper sweeps the backend's observed sandboxes and stops any +//! that exceed the configured max lifetime, releasing the sandbox, its proxy +//! resources, and its node pod slots. use std::{ sync::Arc, time::{Duration, SystemTime}, }; -use centaur_sandbox_core::{ObservedSandbox, SandboxResult, SandboxStatus}; +use centaur_sandbox_core::ObservedSandbox; +use centaur_sandbox_core::SandboxResult; use tokio::time::{MissedTickBehavior, interval}; use tracing::{info, warn}; @@ -22,9 +22,6 @@ use crate::SandboxManager; pub struct SandboxReaperConfig { /// How often to sweep. pub interval: Duration, - /// Stop sandboxes that have been suspended longer than this. `None` - /// disables the idle sweep. - pub idle_ttl: Option, /// Stop any sandbox older than this regardless of status. `None` disables /// the max-lifetime sweep. pub max_lifetime: Option, @@ -32,7 +29,7 @@ pub struct SandboxReaperConfig { impl SandboxReaperConfig { pub fn is_enabled(&self) -> bool { - self.idle_ttl.is_some() || self.max_lifetime.is_some() + self.max_lifetime.is_some() } } @@ -99,14 +96,6 @@ fn reap_reason( if observed.status.is_terminal() { return None; } - if let (Some(idle_ttl), Some(suspended_since)) = (config.idle_ttl, observed.suspended_since) - && matches!(observed.status, SandboxStatus::Suspended) - && now - .duration_since(suspended_since) - .is_ok_and(|age| age >= idle_ttl) - { - return Some("idle_ttl"); - } if let (Some(max_lifetime), Some(created_at)) = (config.max_lifetime, observed.created_at) && now .duration_since(created_at) @@ -121,73 +110,36 @@ fn reap_reason( mod tests { use super::*; - fn config(idle_ttl: Option, max_lifetime: Option) -> SandboxReaperConfig { + fn config(max_lifetime: Option) -> SandboxReaperConfig { SandboxReaperConfig { interval: Duration::from_secs(60), - idle_ttl, max_lifetime, } } - fn observed(status: SandboxStatus) -> ObservedSandbox { + fn observed(status: centaur_sandbox_core::SandboxStatus) -> ObservedSandbox { ObservedSandbox::new("sandbox-1", "fake", status) } #[test] - fn reaps_suspended_sandbox_past_idle_ttl() { + fn reaps_running_sandbox_past_max_lifetime() { let now = SystemTime::now(); - let sandbox = observed(SandboxStatus::Suspended) - .with_suspended_since(Some(now - Duration::from_secs(7200))); + let sandbox = observed(centaur_sandbox_core::SandboxStatus::Running) + .with_created_at(Some(now - Duration::from_secs(100_000))); - let reason = reap_reason( - &sandbox, - now, - &config(Some(Duration::from_secs(3600)), None), - ); + let reason = reap_reason(&sandbox, now, &config(Some(Duration::from_secs(86_400)))); - assert_eq!(reason, Some("idle_ttl")); + assert_eq!(reason, Some("max_lifetime")); } #[test] - fn keeps_suspended_sandbox_within_idle_ttl() { + fn reaps_suspended_sandbox_past_max_lifetime() { let now = SystemTime::now(); - let sandbox = observed(SandboxStatus::Suspended) + let sandbox = observed(centaur_sandbox_core::SandboxStatus::Suspended) + .with_created_at(Some(now - Duration::from_secs(100_000))) .with_suspended_since(Some(now - Duration::from_secs(60))); - let reason = reap_reason( - &sandbox, - now, - &config(Some(Duration::from_secs(3600)), None), - ); - - assert_eq!(reason, None); - } - - #[test] - fn keeps_suspended_sandbox_without_pause_timestamp() { - let now = SystemTime::now(); - let sandbox = observed(SandboxStatus::Suspended); - - let reason = reap_reason( - &sandbox, - now, - &config(Some(Duration::from_secs(3600)), None), - ); - - assert_eq!(reason, None); - } - - #[test] - fn reaps_running_sandbox_past_max_lifetime() { - let now = SystemTime::now(); - let sandbox = observed(SandboxStatus::Running) - .with_created_at(Some(now - Duration::from_secs(100_000))); - - let reason = reap_reason( - &sandbox, - now, - &config(None, Some(Duration::from_secs(86_400))), - ); + let reason = reap_reason(&sandbox, now, &config(Some(Duration::from_secs(86_400)))); assert_eq!(reason, Some("max_lifetime")); } @@ -195,14 +147,10 @@ mod tests { #[test] fn keeps_running_sandbox_within_max_lifetime() { let now = SystemTime::now(); - let sandbox = - observed(SandboxStatus::Running).with_created_at(Some(now - Duration::from_secs(60))); + let sandbox = observed(centaur_sandbox_core::SandboxStatus::Running) + .with_created_at(Some(now - Duration::from_secs(60))); - let reason = reap_reason( - &sandbox, - now, - &config(None, Some(Duration::from_secs(86_400))), - ); + let reason = reap_reason(&sandbox, now, &config(Some(Duration::from_secs(86_400)))); assert_eq!(reason, None); } @@ -210,17 +158,10 @@ mod tests { #[test] fn ignores_terminal_sandboxes() { let now = SystemTime::now(); - let sandbox = - observed(SandboxStatus::Gone).with_created_at(Some(now - Duration::from_secs(100_000))); - - let reason = reap_reason( - &sandbox, - now, - &config( - Some(Duration::from_secs(3600)), - Some(Duration::from_secs(86_400)), - ), - ); + let sandbox = observed(centaur_sandbox_core::SandboxStatus::Gone) + .with_created_at(Some(now - Duration::from_secs(100_000))); + + let reason = reap_reason(&sandbox, now, &config(Some(Duration::from_secs(86_400)))); assert_eq!(reason, None); } @@ -228,10 +169,10 @@ mod tests { #[test] fn disabled_config_reaps_nothing() { let now = SystemTime::now(); - let sandbox = observed(SandboxStatus::Suspended) + let sandbox = observed(centaur_sandbox_core::SandboxStatus::Suspended) .with_created_at(Some(now - Duration::from_secs(100_000))) .with_suspended_since(Some(now - Duration::from_secs(100_000))); - let config = config(None, None); + let config = config(None); assert!(!config.is_enabled()); assert_eq!(reap_reason(&sandbox, now, &config), None); diff --git a/services/api-rs/crates/centaur-sandbox-manager/src/warm_pool.rs b/services/api-rs/crates/centaur-sandbox-manager/src/warm_pool.rs index 8d1cf611a..ee90d39b4 100644 --- a/services/api-rs/crates/centaur-sandbox-manager/src/warm_pool.rs +++ b/services/api-rs/crates/centaur-sandbox-manager/src/warm_pool.rs @@ -115,6 +115,8 @@ impl WarmPoolManager { } async fn replenish_once(&self) -> Result<(), WarmPoolError> { + self.prune_stale_ready_sandboxes().await?; + let needed = self.config.target_size.saturating_sub( self.store .count_ready_warm_sandboxes(self.workload_key.as_str()) @@ -140,6 +142,27 @@ impl WarmPoolManager { Ok(()) } + + async fn prune_stale_ready_sandboxes(&self) -> Result<(), WarmPoolError> { + for sandbox_id in self.store.list_ready_warm_sandbox_ids().await? { + let id = SandboxId::new(sandbox_id.as_str()); + let failure = match self.manager.status(&id).await { + Ok(SandboxStatus::Running) => continue, + Ok(status) => format!("ready warm sandbox was not running: {status:?}"), + Err(SandboxError::NotFound(_)) => "ready warm sandbox was not found".to_owned(), + Err(error) => { + let error_message = error.to_string(); + warn!(%sandbox_id, error = %error_message); + return Err(WarmPoolError::Sandbox(error)); + } + }; + warn!(%sandbox_id, error = %failure, "marking stale ready warm sandbox failed"); + self.store + .mark_warm_sandbox_failed(&sandbox_id, &failure) + .await?; + } + Ok(()) + } } #[derive(Debug, Error)] @@ -149,3 +172,211 @@ pub enum WarmPoolError { #[error(transparent)] Sandbox(#[from] SandboxError), } + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + use async_trait::async_trait; + use centaur_sandbox_core::{ + ObservedSandbox, SandboxBackend, SandboxError, SandboxHandle, SandboxId, SandboxIo, + SandboxResult, SandboxSpec, SandboxStatus, + }; + + use super::*; + + #[tokio::test] + async fn replenisher_prunes_missing_ready_rows_before_counting() { + let Some(store) = test_store().await else { + return; + }; + let suffix = unique_suffix(); + let workload_key = format!("test-prune-{suffix}"); + let old_workload_key = format!("test-prune-old-{suffix}"); + let stale_sandbox = format!("stale-{suffix}"); + let old_stale_sandbox = format!("old-stale-{suffix}"); + let fresh_sandbox = format!("fresh-{suffix}"); + + store + .insert_ready_warm_sandbox(&stale_sandbox, &workload_key) + .await + .expect("insert stale warm sandbox row"); + store + .insert_ready_warm_sandbox(&old_stale_sandbox, &old_workload_key) + .await + .expect("insert stale warm sandbox row for old workload"); + assert_eq!( + store + .count_ready_warm_sandboxes(&workload_key) + .await + .expect("count ready warm sandboxes"), + 1 + ); + assert_eq!( + store + .count_ready_warm_sandboxes(&old_workload_key) + .await + .expect("count ready warm sandboxes for old workload"), + 1 + ); + + let backend = Arc::new(TestBackend::new(fresh_sandbox.clone())); + let pool = WarmPoolManager::new( + Arc::new(SandboxManager::new(backend.clone())), + store.clone(), + Arc::new(|| SandboxSpec::new("image")), + workload_key.clone(), + WarmPoolConfig { + target_size: 1, + replenish_interval: Duration::from_secs(60), + bootstrap_iron_control_principal: None, + }, + ); + + pool.replenish_once().await.expect("replenish warm pool"); + + assert_eq!(backend.created(), vec![fresh_sandbox.clone()]); + assert_eq!( + store + .count_ready_warm_sandboxes(&workload_key) + .await + .expect("count ready warm sandboxes"), + 1 + ); + assert_eq!( + store + .claim_ready_warm_sandbox(&workload_key, "test-thread") + .await + .expect("claim ready warm sandbox"), + Some(fresh_sandbox) + ); + assert_eq!( + store + .count_ready_warm_sandboxes(&old_workload_key) + .await + .expect("count ready warm sandboxes for old workload"), + 0 + ); + } + + async fn test_store() -> Option { + let Ok(url) = std::env::var("SESSION_RUNTIME_TEST_DATABASE_URL") else { + eprintln!("skipping: SESSION_RUNTIME_TEST_DATABASE_URL not set"); + return None; + }; + let store = PgSessionStore::connect(&url) + .await + .expect("connect test db"); + store.run_migrations().await.expect("run migrations"); + Some(store) + } + + fn unique_suffix() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos() + .to_string() + } + + struct TestBackend { + create_id: String, + statuses: Mutex>, + created: Mutex>, + } + + impl TestBackend { + fn new(create_id: String) -> Self { + Self { + create_id, + statuses: Mutex::new(BTreeMap::new()), + created: Mutex::new(Vec::new()), + } + } + + fn created(&self) -> Vec { + self.created.lock().unwrap().clone() + } + } + + #[async_trait] + impl SandboxBackend for TestBackend { + fn name(&self) -> &'static str { + "test" + } + + async fn create(&self, _spec: SandboxSpec) -> SandboxResult { + self.statuses + .lock() + .unwrap() + .insert(self.create_id.clone(), SandboxStatus::Running); + self.created.lock().unwrap().push(self.create_id.clone()); + Ok(SandboxHandle::new( + SandboxId::new(self.create_id.clone()), + self.name(), + )) + } + + async fn open_io(&self, _id: &SandboxId) -> SandboxResult { + Err(SandboxError::Unsupported { + backend: self.name(), + operation: "open_io", + }) + } + + async fn status(&self, id: &SandboxId) -> SandboxResult { + self.statuses + .lock() + .unwrap() + .get(id.as_str()) + .cloned() + .ok_or_else(|| SandboxError::NotFound(id.as_str().to_owned())) + } + + async fn observe(&self, id: &SandboxId) -> SandboxResult { + Ok(ObservedSandbox::new( + id.clone(), + self.name(), + self.status(id).await?, + )) + } + + async fn list_observed(&self) -> SandboxResult> { + Ok(self + .statuses + .lock() + .unwrap() + .iter() + .map(|(id, status)| ObservedSandbox::new(id.clone(), self.name(), status.clone())) + .collect()) + } + + async fn stop(&self, id: &SandboxId) -> SandboxResult<()> { + self.statuses + .lock() + .unwrap() + .insert(id.as_str().to_owned(), SandboxStatus::Stopped); + Ok(()) + } + + async fn pause(&self, id: &SandboxId) -> SandboxResult<()> { + self.statuses + .lock() + .unwrap() + .insert(id.as_str().to_owned(), SandboxStatus::Suspended); + Ok(()) + } + + async fn resume(&self, id: &SandboxId) -> SandboxResult<()> { + self.statuses + .lock() + .unwrap() + .insert(id.as_str().to_owned(), SandboxStatus::Running); + Ok(()) + } + } +} diff --git a/services/api-rs/crates/centaur-session-runtime/src/cleanup.rs b/services/api-rs/crates/centaur-session-runtime/src/cleanup.rs index 0dd724b1f..49eaeb60a 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/cleanup.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/cleanup.rs @@ -130,7 +130,7 @@ impl SessionSandboxCleanupWorker { &candidate.thread_key, &candidate.execution_id, &candidate.sandbox_id, - idle_backstop, + candidate.idle_timeout, ) .await { diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 203c03035..a7d09f2ea 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -469,8 +469,8 @@ impl SessionRuntime { self } - /// Spawn the background reaper that stops sandboxes whose idle pause or - /// total lifetime expired. No-op when both TTLs are disabled. + /// Spawn the background reaper that stops sandboxes whose total lifetime + /// expired. No-op when max-lifetime reaping is disabled. pub fn with_sandbox_reaper(self, config: SandboxReaperConfig) -> Self { if !config.is_enabled() { return self; diff --git a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs index 75626f3a7..4314157d8 100644 --- a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs +++ b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs @@ -1,6 +1,6 @@ //! SQLx-backed session repository. -use std::str::FromStr; +use std::{str::FromStr, time::Duration}; use centaur_session_core::{ ExecutionStatus, HarnessType, MessageRole, SandboxCapabilities, Session, SessionEvent, @@ -42,6 +42,7 @@ pub struct IdleSandboxCandidate { pub thread_key: ThreadKey, pub sandbox_id: String, pub execution_id: String, + pub idle_timeout: Duration, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -616,7 +617,7 @@ impl PgSessionStore { pub async fn list_idle_sandbox_candidates( &self, - idle_backstop: std::time::Duration, + idle_backstop: Duration, ) -> Result, SessionStoreError> { let rows = sqlx::query_as::<_, IdleSandboxCandidateRow>( r#" @@ -625,20 +626,22 @@ impl PgSessionStore { execution_id, thread_key, status, - completed_at + completed_at, + metadata from session_executions order by thread_key, created_at desc, execution_id desc ) select s.thread_key, s.sandbox_id as sandbox_id, - latest.execution_id + latest.execution_id, + latest.completed_at, + latest.metadata from sessions s join latest on latest.thread_key = s.thread_key where s.sandbox_id is not null and latest.status in ('completed', 'failed', 'cancelled') and latest.completed_at is not null - and latest.completed_at <= now() - ($1::float8 * interval '1 second') and not exists ( select 1 from session_executions active @@ -648,11 +651,13 @@ impl PgSessionStore { order by latest.completed_at, s.thread_key "#, ) - .bind(idle_backstop.as_secs_f64()) .fetch_all(&self.pool) .await?; - rows.into_iter().map(TryInto::try_into).collect() + let now = OffsetDateTime::now_utc(); + rows.into_iter() + .filter_map(|row| idle_candidate_from_row(row, idle_backstop, now).transpose()) + .collect() } pub async fn list_workflow_owned_sandboxes( @@ -843,6 +848,20 @@ impl PgSessionStore { Ok(count) } + pub async fn list_ready_warm_sandbox_ids(&self) -> Result, SessionStoreError> { + let sandbox_ids = sqlx::query_scalar::<_, String>( + r#" + select sandbox_id + from session_warm_sandboxes + where status = 'ready' + order by created_at, sandbox_id + "#, + ) + .fetch_all(&self.pool) + .await?; + Ok(sandbox_ids) + } + pub async fn claim_ready_warm_sandbox( &self, workload_key: &str, @@ -1097,18 +1116,46 @@ struct IdleSandboxCandidateRow { thread_key: String, sandbox_id: String, execution_id: String, + completed_at: OffsetDateTime, + metadata: Value, } -impl TryFrom for IdleSandboxCandidate { - type Error = SessionStoreError; +fn idle_candidate_from_row( + row: IdleSandboxCandidateRow, + idle_backstop: Duration, + now: OffsetDateTime, +) -> Result, SessionStoreError> { + let idle_timeout = effective_idle_timeout(&row.metadata, idle_backstop); + if !idle_deadline_elapsed(row.completed_at, idle_timeout, now) { + return Ok(None); + } + Ok(Some(IdleSandboxCandidate { + thread_key: parse_persisted(row.thread_key)?, + sandbox_id: row.sandbox_id, + execution_id: row.execution_id, + idle_timeout, + })) +} - fn try_from(row: IdleSandboxCandidateRow) -> Result { - Ok(Self { - thread_key: parse_persisted(row.thread_key)?, - sandbox_id: row.sandbox_id, - execution_id: row.execution_id, - }) +fn effective_idle_timeout(metadata: &Value, idle_backstop: Duration) -> Duration { + metadata + .get("idle_timeout_ms") + .and_then(Value::as_u64) + .filter(|value| *value > 0) + .map(Duration::from_millis) + .unwrap_or_else(|| std::cmp::max(idle_backstop, Duration::from_millis(1))) +} + +fn idle_deadline_elapsed( + completed_at: OffsetDateTime, + idle_timeout: Duration, + now: OffsetDateTime, +) -> bool { + let elapsed = now - completed_at; + if elapsed.is_negative() { + return false; } + elapsed.whole_nanoseconds() >= idle_timeout.as_nanos() as i128 } #[derive(Debug, FromRow)] @@ -1230,7 +1277,26 @@ pub fn default_metadata(metadata: Option) -> Value { #[cfg(test)] mod tests { - use super::SessionEventNotification; + use std::time::Duration; + + use centaur_session_core::{HarnessType, ThreadKey}; + use serde_json::json; + use time::{Duration as TimeDuration, OffsetDateTime}; + use uuid::Uuid; + + use super::{IdleSandboxCandidateRow, PgSessionStore, SessionEventNotification}; + + async fn test_store() -> Option { + let Ok(url) = std::env::var("SESSION_RUNTIME_TEST_DATABASE_URL") else { + eprintln!("skipping: SESSION_RUNTIME_TEST_DATABASE_URL not set"); + return None; + }; + let store = PgSessionStore::connect(&url) + .await + .expect("connect test db"); + store.run_migrations().await.expect("run migrations"); + Some(store) + } #[test] fn parses_session_event_notification_payload() { @@ -1245,4 +1311,118 @@ mod tests { } ); } + + fn idle_row( + metadata: serde_json::Value, + completed_at: OffsetDateTime, + ) -> IdleSandboxCandidateRow { + IdleSandboxCandidateRow { + thread_key: "test:idle-row".to_owned(), + sandbox_id: "sbx-idle-row".to_owned(), + execution_id: "exe-idle-row".to_owned(), + completed_at, + metadata, + } + } + + #[test] + fn idle_candidate_uses_persisted_timeout_deadline() { + let now = OffsetDateTime::now_utc(); + let candidate = super::idle_candidate_from_row( + idle_row( + json!({"idle_timeout_ms": 1000}), + now - TimeDuration::seconds(2), + ), + Duration::from_secs(3600), + now, + ) + .unwrap() + .expect("candidate should use persisted timeout"); + + assert_eq!(candidate.idle_timeout, Duration::from_secs(1)); + } + + #[test] + fn idle_candidate_waits_for_persisted_timeout_even_when_backstop_elapsed() { + let now = OffsetDateTime::now_utc(); + let candidate = super::idle_candidate_from_row( + idle_row( + json!({"idle_timeout_ms": 10_000}), + now - TimeDuration::seconds(2), + ), + Duration::from_secs(1), + now, + ) + .unwrap(); + + assert!(candidate.is_none()); + } + + #[test] + fn idle_candidate_falls_back_to_backstop_for_missing_or_invalid_timeout() { + let now = OffsetDateTime::now_utc(); + let candidate = super::idle_candidate_from_row( + idle_row( + json!({"idle_timeout_ms": "not-a-number"}), + now - TimeDuration::seconds(2), + ), + Duration::from_secs(1), + now, + ) + .unwrap() + .expect("candidate should use backstop"); + + assert_eq!(candidate.idle_timeout, Duration::from_secs(1)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn idle_candidates_use_persisted_execution_idle_timeout() { + let Some(store) = test_store().await else { + return; + }; + let thread_key = ThreadKey::parse(format!("test:idle-cleanup-{}", Uuid::new_v4())).unwrap(); + let sandbox_id = format!("sbx-idle-{}", Uuid::new_v4()); + store + .create_or_get_session(&thread_key, &HarnessType::Codex, None, json!({})) + .await + .expect("create session"); + store + .update_sandbox_id(&thread_key, Some(&sandbox_id)) + .await + .expect("set sandbox id"); + let execution_id = store + .create_execution(&thread_key, None, json!({"idle_timeout_ms": 1000})) + .await + .expect("create execution") + .execution + .execution_id; + store + .complete_execution(&execution_id) + .await + .expect("complete execution"); + sqlx::query( + r#" + update session_executions + set completed_at = now() - interval '2 seconds', updated_at = now() + where execution_id = $1 + "#, + ) + .bind(&execution_id) + .execute(store.pool()) + .await + .expect("age execution"); + + let candidates = store + .list_idle_sandbox_candidates(Duration::from_secs(3600)) + .await + .expect("list idle sandbox candidates"); + let candidate = candidates + .iter() + .find(|candidate| candidate.thread_key == thread_key) + .expect("candidate should use execution idle timeout, not backstop"); + + assert_eq!(candidate.sandbox_id, sandbox_id); + assert_eq!(candidate.execution_id, execution_id); + assert_eq!(candidate.idle_timeout, Duration::from_secs(1)); + } } diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index aea56dd7c..fa607d618 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -62,9 +62,18 @@ export function isRetryableSessionApiError(error: unknown): boolean { return error.name === 'AbortError' || error.name === 'TypeError' } +export const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 3 * 60 * 60 * 1000 const DEFAULT_SESSION_API_TIMEOUT_MS = 30_000 const DEFAULT_SLACK_API_TIMEOUT_MS = 5_000 +function sessionIdleTimeoutMs(options: SlackbotV2Options): number { + if (options.idleTimeoutMs !== undefined) return options.idleTimeoutMs + if (options.maxDurationMs !== undefined) { + return Math.min(DEFAULT_SESSION_IDLE_TIMEOUT_MS, options.maxDurationMs) + } + return DEFAULT_SESSION_IDLE_TIMEOUT_MS +} + class FetchTimeoutError extends Error { constructor(action: string, timeoutMs: number) { super(`${action} timed out after ${timeoutMs}ms`) @@ -1166,6 +1175,7 @@ async function executeSession( ): Promise { const fetchFn = options.fetch ?? fetch const requesterIdentity = await resolveRequesterIdentity(options, message) + const idleTimeoutMs = sessionIdleTimeoutMs(options) const body: SlackbotV2ExecuteSessionRequest = { idempotency_key: message.id, metadata: sessionMetadata(message, { action: 'execute' }, requesterIdentity), @@ -1179,7 +1189,7 @@ async function executeSession( reasoning, provider ), - ...(options.idleTimeoutMs === undefined ? {} : { idle_timeout_ms: options.idleTimeoutMs }), + idle_timeout_ms: idleTimeoutMs, ...(options.maxDurationMs === undefined ? {} : { max_duration_ms: options.maxDurationMs }) } const response = await fetchWithTimeout( diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 50006e3b4..77271128c 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -106,6 +106,7 @@ export type SlackbotV2Options = { */ defaultHarnessType?: string fetch?: SlackbotV2Fetch + /** Milliseconds before an idle execution pauses its sandbox. Defaults to up to 3h. */ idleTimeoutMs?: number logger?: Logger maxDurationMs?: number diff --git a/services/slackbotv2/test/session-api.test.ts b/services/slackbotv2/test/session-api.test.ts index ae8d11de1..d601f0046 100644 --- a/services/slackbotv2/test/session-api.test.ts +++ b/services/slackbotv2/test/session-api.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { clearConversationNameCacheForTests, clearRequesterIdentityCacheForTests, + DEFAULT_SESSION_IDLE_TIMEOUT_MS, forwardToSessionApi, harnessRestartPreamble, serializeAttachment, @@ -99,9 +100,13 @@ function options(fetchFn: SlackbotV2Options['fetch']): SlackbotV2Options { } } -function executeLine(requests: RecordedRequest[]): JsonObject { +function executeBody(requests: RecordedRequest[]): Record { const execute = requests.find(request => request.url.endsWith('/execute')) - const inputLines = (execute?.body as { input_lines: string[] }).input_lines + return (execute?.body ?? {}) as Record +} + +function executeLine(requests: RecordedRequest[]): JsonObject { + const inputLines = (executeBody(requests) as { input_lines: string[] }).input_lines return JSON.parse(inputLines[0]!) as JsonObject } @@ -452,6 +457,31 @@ describe('forwardToSessionApi overrides', () => { expect('reasoning' in line).toBe(false) }) + test('includes default idle timeout on execute requests', async () => { + const { fetchFn, requests } = fakeApi() + await forwardToSessionApi(options(fetchFn), forwardInput(apiMessage('hi'))) + expect(executeBody(requests).idle_timeout_ms).toBe(DEFAULT_SESSION_IDLE_TIMEOUT_MS) + }) + + test('caps default idle timeout to max duration on execute requests', async () => { + const { fetchFn, requests } = fakeApi() + await forwardToSessionApi( + { ...options(fetchFn), maxDurationMs: 60_000 }, + forwardInput(apiMessage('hi')) + ) + expect(executeBody(requests).idle_timeout_ms).toBe(60_000) + expect(executeBody(requests).max_duration_ms).toBe(60_000) + }) + + test('allows idle timeout override on execute requests', async () => { + const { fetchFn, requests } = fakeApi() + await forwardToSessionApi( + { ...options(fetchFn), idleTimeoutMs: 12_345 }, + forwardInput(apiMessage('hi')) + ) + expect(executeBody(requests).idle_timeout_ms).toBe(12_345) + }) + test('retries session creation with existing harness on 409 conflict', async () => { const { fetchFn, requests } = fakeApi({ createSession: [ From 4c17d8b827d9eed5e0d32b1c9f24ea8a2e46a433 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:37:40 +0300 Subject: [PATCH 019/198] feat: add live activity summaries --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/apirs.yaml | 16 + contrib/chart/values.yaml | 8 + docs/pages/reference/configuration.mdx | 3 + docs/public/md/reference/configuration.md | 3 + packages/rendering/src/chat-sdk.test.ts | 11 + packages/rendering/src/chat-sdk.ts | 2 +- .../rendering/src/codex-app-server.test.ts | 144 +-- packages/rendering/src/codex-app-server.ts | 184 +--- .../src/activity_summary.rs | 818 ++++++++++++++++++ .../crates/centaur-api-server/src/args.rs | 132 +++ .../crates/centaur-api-server/src/main.rs | 7 + .../crates/centaur-iron-proxy/src/fragment.rs | 16 +- .../crates/centaur-iron-proxy/src/tests.rs | 14 +- services/linearbot/src/comment-bot.ts | 15 +- services/linearbot/test/comment-bot.test.ts | 19 + services/slackbotv2/src/index.ts | 70 +- services/slackbotv2/src/session-api.ts | 9 + services/slackbotv2/src/types.ts | 2 +- .../slackbotv2/test/chat-sdk-emulate.test.ts | 137 ++- services/slackbotv2/test/session-api.test.ts | 51 ++ 21 files changed, 1331 insertions(+), 332 deletions(-) create mode 100644 services/api-rs/crates/centaur-api-server/src/activity_summary.rs diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 8a1b0dda3..1ae8cbb01 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.81 +version: 0.1.82 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index bd1f63bb2..5bd82afdd 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -250,6 +250,22 @@ spec: value: {{ .Values.apiRs.sandboxCleanupIntervalSecs | quote }} - name: SESSION_SANDBOX_IDLE_CLEANUP_BACKSTOP_SECS value: {{ .Values.apiRs.sandboxIdleCleanupBackstopSecs | quote }} + - name: SESSION_ACTIVITY_SUMMARY_ENABLED + value: {{ .Values.apiRs.activitySummary.enabled | quote }} +{{- if .Values.apiRs.activitySummary.enabled }} + - name: SESSION_ACTIVITY_SUMMARY_MODEL + value: {{ .Values.apiRs.activitySummary.model | quote }} + - name: SESSION_ACTIVITY_SUMMARY_OPENAI_BASE_URL + value: {{ .Values.apiRs.activitySummary.openaiBaseUrl | quote }} + - name: SESSION_ACTIVITY_SUMMARY_MIN_INTERVAL_SECS + value: {{ .Values.apiRs.activitySummary.minIntervalSecs | quote }} + - name: SESSION_ACTIVITY_SUMMARY_TIMEOUT_SECS + value: {{ .Values.apiRs.activitySummary.timeoutSecs | quote }} + - name: SESSION_ACTIVITY_SUMMARY_MAX_FACTS + value: {{ .Values.apiRs.activitySummary.maxFacts | quote }} + - name: SESSION_ACTIVITY_SUMMARY_MAX_OUTPUT_TOKENS + value: {{ .Values.apiRs.activitySummary.maxOutputTokens | quote }} +{{- end }} - name: SESSION_SANDBOX_K8S_NAMESPACE value: {{ .Release.Namespace | quote }} - name: SESSION_SANDBOX_IMAGE diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index 640accfd1..f9fb9711d 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -354,6 +354,14 @@ apiRs: # idle_timeout_ms. 0 disables the corresponding arm. sandboxCleanupIntervalSecs: 300 sandboxIdleCleanupBackstopSecs: 21600 # 6 hours + activitySummary: + enabled: false + model: gpt-5.4-nano + openaiBaseUrl: https://api.openai.com/v1 + minIntervalSecs: 8 + timeoutSecs: 5 + maxFacts: 12 + maxOutputTokens: 128 ironProxy: mode: enabled metrics: diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index 22a246d26..798b7d28b 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -84,6 +84,9 @@ Optional required-by-mode variables: | `apiRs.metrics.scrapeAnnotations` | Helm value, default `true`. | Adds Prometheus scrape annotations to the API-RS Pod template and Service. | | `apiRs.metrics.path` | Helm value, default `/metrics`. | Metrics scrape path for annotation-based discovery. | | `apiRs.metrics.annotations` | Helm value. | Additional scrape annotations for Prometheus-compatible collectors. | +| `apiRs.activitySummary.*` | Helm values, default disabled. | Enables API-RS to summarize live session activity into durable `session.activity_summary` events. | +| `OPENAI_API_KEY` | Secret mounted into api-rs, or `apiRs.extraEnv` for local/dev overrides. | OpenAI credential for activity summaries; the feature stays disabled when no key is present. | +| `SESSION_ACTIVITY_SUMMARY_MODEL` | `apiRs.activitySummary.model`, default `gpt-5.4-nano`. | Model used for the short live activity sentence. | Sandbox lifecycle: diff --git a/docs/public/md/reference/configuration.md b/docs/public/md/reference/configuration.md index e12dcc5f5..2f6a739fe 100644 --- a/docs/public/md/reference/configuration.md +++ b/docs/public/md/reference/configuration.md @@ -84,6 +84,9 @@ Optional required-by-mode variables: | `apiRs.metrics.scrapeAnnotations` | Helm value, default `true`. | Adds Prometheus scrape annotations to the API-RS Pod template and Service. | | `apiRs.metrics.path` | Helm value, default `/metrics`. | Metrics scrape path for annotation-based discovery. | | `apiRs.metrics.annotations` | Helm value. | Additional scrape annotations for Prometheus-compatible collectors. | +| `apiRs.activitySummary.*` | Helm values, default disabled. | Enables API-RS to summarize live session activity into durable `session.activity_summary` events. | +| `OPENAI_API_KEY` | Secret mounted into api-rs, or `apiRs.extraEnv` for local/dev overrides. | OpenAI credential for activity summaries; the feature stays disabled when no key is present. | +| `SESSION_ACTIVITY_SUMMARY_MODEL` | `apiRs.activitySummary.model`, default `gpt-5.4-nano`. | Model used for the short live activity sentence. | Sandbox lifecycle: diff --git a/packages/rendering/src/chat-sdk.test.ts b/packages/rendering/src/chat-sdk.test.ts index e0bd2e53a..4d66c2008 100644 --- a/packages/rendering/src/chat-sdk.test.ts +++ b/packages/rendering/src/chat-sdk.test.ts @@ -61,6 +61,17 @@ describe('ChatSDKRenderer', () => { ]) }) + it('treats status updates as renderer side effects only', () => { + const renderer = new ChatSDKRenderer() + + expect( + renderer.render('session-1', { + type: 'renderer.status', + status: 'The agent is inspecting events.' + }) + ).toEqual([]) + }) + it('bounds large task details while preserving full task output', () => { const renderer = new ChatSDKRenderer() const largeDetails = 'd'.repeat(10000) diff --git a/packages/rendering/src/chat-sdk.ts b/packages/rendering/src/chat-sdk.ts index 0be7f10a0..a6eb57c95 100644 --- a/packages/rendering/src/chat-sdk.ts +++ b/packages/rendering/src/chat-sdk.ts @@ -61,7 +61,7 @@ export class ChatSDKRenderer implements RendererInterface { return [] } if (event.type === 'renderer.status') { - return [{ type: 'chat.message.upsert', message: { text: event.status } }] + return [] } if (event.type === 'renderer.message.delta') { return [ diff --git a/packages/rendering/src/codex-app-server.test.ts b/packages/rendering/src/codex-app-server.test.ts index 2df4cf634..3c837005a 100644 --- a/packages/rendering/src/codex-app-server.test.ts +++ b/packages/rendering/src/codex-app-server.test.ts @@ -4,7 +4,6 @@ import { codexAppServerToChatSdkStream, codexAppServerToRendererEvents } from './codex-app-server' -import type { RendererTaskBlock } from './types' describe('CodexAppServerRendererEventMapper', () => { it('maps final answer deltas to generic renderer message deltas after activity exists', () => { @@ -53,18 +52,18 @@ describe('CodexAppServerRendererEventMapper', () => { }) }) - it('maps commentary to Thinking task updates instead of message deltas', () => { + it('suppresses commentary thinking blocks', () => { const mapper = new CodexAppServerRendererEventMapper() - mapper.process({ + expect(mapper.process({ type: 'item.started', item: { id: 'thinking-1', type: 'agentMessage', phase: 'commentary' } - }) - mapper.process({ + })).toEqual([]) + expect(mapper.process({ type: 'item.agentMessage.delta', itemId: 'thinking-1', delta: 'Checking the runtime.' - }) + })).toEqual([]) const events = mapper.process({ type: 'item.completed', @@ -77,31 +76,18 @@ describe('CodexAppServerRendererEventMapper', () => { }) expect(events.some(event => event.type === 'renderer.message.delta')).toBe(false) - const task = events.find(event => event.type === 'renderer.task.update') - // Sealed commentary stays in_progress until the next activity starts so - // "Thinking completed" never headlines the Slack plan card mid-turn. - expect(task).toMatchObject({ - type: 'renderer.task.update', - task: { - id: 'thinking-thinking-1', - title: 'Thinking', - status: 'in_progress' - } - }) - expect(plain(task?.type === 'renderer.task.update' ? task.task.details : undefined)).toContain( - 'Checking the runtime.' - ) + expect(events.some(event => event.type === 'renderer.task.update')).toBe(false) const next = mapper.process({ type: 'item.started', item: { id: 'cmd-1', type: 'commandExecution', command: 'pnpm test' } }) expect(next.find(event => event.type === 'renderer.task.update')).toMatchObject({ - task: { id: 'thinking-thinking-1', title: 'Thinking', status: 'complete' } + task: { id: 'cmd-1', title: '1. Command execution', status: 'in_progress' } }) }) - it('keeps one Thinking task in_progress across reasoning deltas until the item seals', () => { + it('suppresses reasoning thinking blocks', () => { const mapper = new CodexAppServerRendererEventMapper() const first = mapper.process({ @@ -109,47 +95,28 @@ describe('CodexAppServerRendererEventMapper', () => { itemId: 'reasoning-1', delta: 'Inspecting the ' }) - expect(first).toContainEqual({ - type: 'renderer.task.update', - task: { - id: 'reasoning-1', - title: 'Thinking', - status: 'in_progress', - details: [{ type: 'text', text: 'Inspecting the' }], - output: undefined - }, - flush: true - }) + expect(first).toEqual([]) - // A command starting mid-thought must not flip the Thinking task to complete. - mapper.process({ + const command = mapper.process({ type: 'item.started', item: { id: 'cmd-1', type: 'commandExecution', command: 'pnpm test' } }) + expect(command.find(event => event.type === 'renderer.task.update')).toMatchObject({ + task: { id: 'cmd-1', title: '1. Command execution', status: 'in_progress' } + }) const second = mapper.process({ type: 'item.reasoning.textDelta', itemId: 'reasoning-1', delta: 'event stream' }) - const secondUpdate = second.find(event => event.type === 'renderer.task.update') - expect(secondUpdate).toMatchObject({ - task: { id: 'reasoning-1', title: 'Thinking', status: 'in_progress' } - }) - expect( - plain(secondUpdate?.type === 'renderer.task.update' ? secondUpdate.task.details : undefined) - ).toContain('Inspecting the event stream') + expect(second.some(event => event.type === 'renderer.task.update')).toBe(false) - // Sealing completes the Thinking task; the still-running command keeps - // the plan in an in-progress state so the Slack header tracks it. const sealed = mapper.process({ type: 'item.completed', item: { id: 'reasoning-1', type: 'reasoning', content: ['Inspecting the event stream'] } }) - const sealedUpdate = sealed.find(event => event.type === 'renderer.task.update') - expect(sealedUpdate).toMatchObject({ - task: { id: 'reasoning-1', title: 'Thinking', status: 'complete' } - }) + expect(sealed.some(event => event.type === 'renderer.task.update')).toBe(false) }) it('holds the last finished task in_progress so the Slack header never claims completion mid-turn', () => { @@ -161,8 +128,8 @@ describe('CodexAppServerRendererEventMapper', () => { }) // The command finishes, leaving nothing else running. Slack would show - // "Thinking completed" for an all-complete plan, so the completion is - // held back and the task stays presented as in_progress. + // a completed-task header, so the completion is held back and the task + // stays presented as in_progress. const completed = mapper.process({ type: 'item.completed', item: { @@ -205,30 +172,22 @@ describe('CodexAppServerRendererEventMapper', () => { ) }) - it('separates Codex reasoning summary sections within one Thinking task', () => { + it('suppresses Codex reasoning summary sections', () => { const mapper = new CodexAppServerRendererEventMapper() - mapper.process({ + expect(mapper.process({ type: 'item.reasoning.summaryTextDelta', itemId: 'reasoning-1', summaryIndex: 0, delta: 'First section.' - }) + })).toEqual([]) const events = mapper.process({ type: 'item.reasoning.summaryTextDelta', itemId: 'reasoning-1', summaryIndex: 1, delta: 'Second section.' }) - const update = events.find(event => event.type === 'renderer.task.update') - expect(update).toMatchObject({ - task: { - id: 'reasoning-1', - title: 'Thinking', - status: 'in_progress', - details: [{ type: 'text', text: 'First section.\n\nSecond section.' }] - } - }) + expect(events).toEqual([]) }) it('parses Rust session output lines before mapping app-server notifications', () => { @@ -290,6 +249,24 @@ describe('CodexAppServerRendererEventMapper', () => { }) }) + it('maps Rust activity summary events to renderer status updates', () => { + const mapper = new CodexAppServerRendererEventMapper() + const events = mapper.process({ + eventKind: 'session.activity_summary', + data: { + execution_id: 'exe-1', + summary: 'The agent is inspecting App Server events.' + } + }) + + expect(events).toEqual([ + { + type: 'renderer.status', + status: 'The agent is inspecting App Server events.' + } + ]) + }) + it('maps app-server agent message deltas keyed by turnId', () => { const mapper = new CodexAppServerRendererEventMapper() const events = mapper.process({ @@ -408,23 +385,13 @@ describe('CodexAppServerRendererEventMapper', () => { title: 'Inspect App Server events', status: 'complete' }) - expect(chunks).toContainEqual({ - type: 'task_update', - id: 'reasoning-1', - title: 'Thinking', - status: 'in_progress', - details: 'Inspecting the event stream' - }) - expect(chunks).toContainEqual({ - type: 'task_update', - id: 'reasoning-1', - title: 'Thinking', - status: 'complete' - }) + expect(chunks.some(chunk => chunk.type === 'task_update' && chunk.title === 'Thinking')).toBe( + false + ) expect(chunks).toContainEqual({ type: 'markdown_text', text: 'Done.' }) }) - it('coalesces repeated reasoning deltas into one Thinking task', async () => { + it('suppresses repeated reasoning deltas from Chat SDK output', async () => { const chunks = await collect( codexAppServerToChatSdkStream( toAsyncIterable([ @@ -452,24 +419,9 @@ describe('CodexAppServerRendererEventMapper', () => { ) ) - const thinkingChunks = chunks.filter( - (chunk): chunk is Extract<(typeof chunks)[number], { type: 'task_update' }> => - chunk.type === 'task_update' && chunk.title === 'Thinking' + expect(chunks.some(chunk => chunk.type === 'task_update' && chunk.title === 'Thinking')).toBe( + false ) - expect(new Set(thinkingChunks.map(chunk => chunk.id))).toEqual(new Set(['reasoning-1'])) - expect(thinkingChunks).toContainEqual({ - type: 'task_update', - id: 'reasoning-1', - title: 'Thinking', - status: 'in_progress', - details: 'Inspecting the event stream' - }) - expect(thinkingChunks).toContainEqual({ - type: 'task_update', - id: 'reasoning-1', - title: 'Thinking', - status: 'complete' - }) }) it('streams command details once and command output incrementally', async () => { @@ -785,12 +737,6 @@ describe('CodexAppServerRendererEventMapper', () => { }) }) -function plain(elements: RendererTaskBlock[] | undefined): string { - return (elements ?? []) - .map(element => element.text) - .join('') -} - async function collect(source: AsyncIterable): Promise { const out: T[] = [] for await (const item of source) out.push(item) diff --git a/packages/rendering/src/codex-app-server.ts b/packages/rendering/src/codex-app-server.ts index 39912db89..5cd323eae 100644 --- a/packages/rendering/src/codex-app-server.ts +++ b/packages/rendering/src/codex-app-server.ts @@ -59,8 +59,6 @@ type CodexMapperState = { agentMessagePhase: AgentMessagePhase | null agentMessagePhaseByItemId: Map planText: string - reasoningTextByItemId: Map - reasoningSummaryIndexByItemId: Map taskByUseId: Map commandOutputById: Map emittedActivityRunByTaskId: Map @@ -103,6 +101,8 @@ export class CodexAppServerRendererEventMapper const rustMapped = rustSessionEventToServerNotification(source) if (rustMapped?.kind === 'failed') return this.fail(rustMapped.error) if (rustMapped?.kind === 'completed') return this.complete(rustMapped.resultText) + if (rustMapped?.kind === 'status') + return [{ type: 'renderer.status', status: rustMapped.status }] if (rustMapped?.kind === 'notification') return this.processNotification(rustMapped.notification) if (!isRecord(source)) return [] @@ -114,7 +114,6 @@ export class CodexAppServerRendererEventMapper if (this.state.done) return [] this.state.done = true const out: RendererEvent[] = [] - completeThinkingTasks(this.state) completeOpenTasks(this.state) this.emitActivitySummary(out, { final: true }) this.ensureFinalAnswerText() @@ -171,9 +170,6 @@ export class CodexAppServerRendererEventMapper trackAgentMessageLifecycle(event, this.state) ensureCommentarySegmentBreak(event, this.state) - if (startThinkingTask(this.state, event)) { - this.emitActivitySummary(out) - } const structuredPlan = structuredPlanUpdate(event) if (structuredPlan) { @@ -295,71 +291,6 @@ export class CodexAppServerRendererEventMapper if (update.correction) { this.logCanonicalCorrection(event, update.correction) } - if (buffer === 'commentary' && event?.type === 'item.completed') { - upsertThinkingTask(this.state, event) - this.emitActivitySummary(out) - } - } - - const reasoningMessage = reasoningText(event) - if (reasoningMessage.trim()) { - const itemId = reasoningEventItemId(event) - if (isReasoningDeltaEvent(event) && itemId) { - // Accumulate deltas into one task per reasoning item and keep it - // in_progress until the item seals (item.completed) or the - // execution finishes (flush). Completing earlier makes the Slack - // plan card flip between "Thinking", "Thinking completed", and the - // running command. - const previous = this.state.reasoningTextByItemId.get(itemId) ?? '' - const summaryIndex = reasoningSummaryIndex(event) - const needsBreak = - summaryIndex !== undefined && - this.state.reasoningSummaryIndexByItemId.get(itemId) !== undefined && - this.state.reasoningSummaryIndexByItemId.get(itemId) !== summaryIndex && - previous.trim() !== '' - if (summaryIndex !== undefined) { - this.state.reasoningSummaryIndexByItemId.set(itemId, summaryIndex) - } - const accumulated = previous + (needsBreak ? '\n\n' : '') + reasoningMessage - this.state.reasoningTextByItemId.set(itemId, accumulated) - this.state.taskByUseId.set(itemId, { - id: itemId, - title: 'Thinking', - status: 'in_progress', - details: [section([text(accumulated.trim())])], - output: [] - }) - } else { - const id = itemId || `reasoning-${++this.state.stepCounter}` - this.state.taskByUseId.set(id, { - id, - title: 'Thinking', - status: 'complete', - details: [section([text(reasoningMessage.trim())])], - output: [] - }) - } - this.emitActivitySummary(out) - } - - const sealedReasoning = completedReasoningItem(event) - if (sealedReasoning) { - const id = String(sealedReasoning.id ?? '') - const accumulated = id ? this.state.reasoningTextByItemId.get(id) ?? '' : '' - const finalText = (reasoningItemText(sealedReasoning) || accumulated).trim() - const existing = id ? this.state.taskByUseId.get(id) : undefined - if (id && (existing || finalText)) { - this.state.taskByUseId.set(id, { - id, - title: 'Thinking', - status: 'complete', - details: finalText ? [section([text(finalText)])] : existing?.details ?? [], - output: [] - }) - this.state.reasoningTextByItemId.delete(id) - this.state.reasoningSummaryIndexByItemId.delete(id) - this.emitActivitySummary(out) - } } if (isTerminalCodexAppServerEvent(event)) { @@ -694,6 +625,7 @@ export type RustSessionMappingResult = | { kind: 'notification'; notification: ServerNotification } | { kind: 'failed'; error: string } | { kind: 'completed'; resultText?: string } + | { kind: 'status'; status: string } | null export function rustSessionEventToServerNotification(source: unknown): RustSessionMappingResult { @@ -721,6 +653,12 @@ export function rustSessionEventToServerNotification(source: unknown): RustSessi } } + if (eventKind === 'session.activity_summary') { + const data = isRecord(source.data) ? source.data : source + const status = String(data.summary ?? data.status ?? '').trim() + return status ? { kind: 'status', status } : null + } + if ( eventKind === 'session.execution_failed' || eventKind === 'session.stream_error' || @@ -772,8 +710,6 @@ function newState(): CodexMapperState { agentMessagePhase: null, agentMessagePhaseByItemId: new Map(), planText: '', - reasoningTextByItemId: new Map(), - reasoningSummaryIndexByItemId: new Map(), taskByUseId: new Map(), commandOutputById: new Map(), emittedActivityRunByTaskId: new Map(), @@ -888,49 +824,6 @@ function lastInsertedKey(map: Map): K | undefined { return last } -function commentaryItemId(event: any): string { - return String(event?.itemId ?? event?.item_id ?? event?.item?.id ?? '') -} - -function startThinkingTask(state: CodexMapperState, event: any): boolean { - if (event?.type !== 'item.started') return false - if (agentMessageItemPhase(event?.item) !== 'commentary') return false - const id = commentaryItemId(event) - if (!id || state.taskByUseId.has(`thinking-${id}`)) return false - state.taskByUseId.set(`thinking-${id}`, { - id: `thinking-${id}`, - title: 'Thinking', - status: 'in_progress', - details: [], - output: [] - }) - return true -} - -function upsertThinkingTask(state: CodexMapperState, event: any): void { - const id = commentaryItemId(event) - if (!id) return - const body = String(event?.item?.text ?? state.commentaryByItemId.get(id) ?? '').trim() - if (!body) return - if (state.commentaryByItemId.get(id) !== body) { - state.commentaryByItemId.set(id, body) - recomposeBuffers(state) - } - state.taskByUseId.set(`thinking-${id}`, { - id: `thinking-${id}`, - title: 'Thinking', - status: 'complete', - details: [section([text(body)])], - output: [] - }) -} - -function completeThinkingTasks(state: CodexMapperState): void { - for (const [id, body] of state.commentaryByItemId) { - upsertThinkingTask(state, { item: { id, text: body } }) - } -} - function eventCarriesAgentMessageText(event: any): boolean { if (event?.type === 'item.agentMessage.delta') return Boolean(extractDeltaText(event)) if (event?.type === 'assistant') return Boolean(assistantTextFromAssistantEvent(event)) @@ -987,52 +880,6 @@ function textHash(value: string): string { return (hash >>> 0).toString(16).padStart(8, '0') } -function reasoningText(event: any): string { - if ( - event?.type === 'item.reasoning.summaryTextDelta' || - event?.type === 'item.reasoning.textDelta' - ) { - return String(event.delta ?? '') - } - if (event?.type !== 'reasoning') return '' - return String(event.text ?? event.thinking ?? '') -} - -function isReasoningDeltaEvent(event: any): boolean { - return ( - event?.type === 'item.reasoning.summaryTextDelta' || - event?.type === 'item.reasoning.textDelta' - ) -} - -function reasoningEventItemId(event: any): string { - return String(event?.itemId ?? event?.item_id ?? '') -} - -function reasoningSummaryIndex(event: any): number | undefined { - const value = event?.summaryIndex ?? event?.summary_index - return typeof value === 'number' ? value : undefined -} - -function completedReasoningItem(event: any): Record | null { - if (event?.type !== 'item.completed') return null - const item = event.item - if (!item || item.type !== 'reasoning') return null - return item -} - -function reasoningItemText(item: any): string { - const parts = [ - ...(Array.isArray(item?.content) ? item.content : []), - ...(Array.isArray(item?.summary) ? item.summary : []) - ] - const texts = parts - .map(part => (typeof part === 'string' ? part : String(part?.text ?? ''))) - .filter(part => part.trim()) - if (texts.length) return texts.join('\n\n') - return String(item?.text ?? '') -} - function terminalResultText(event: any): string { for (const key of ['result', 'result_text', 'text', 'final_text']) { const value = event?.[key] @@ -1186,12 +1033,10 @@ function changedActivityTaskUpdates( output?: RendererTaskBlock[] }> = [] // Slack derives the plan card header from task statuses: it shows the - // current in_progress task, and falls back to "Thinking completed" when - // nothing is in progress — even mid-turn (e.g. while the model thinks - // between commands without emitting reasoning events). Mid-turn, present - // the most recent finished task as still in progress so the header never - // claims completion; its true status is emitted with the next batch or at - // the final flush. + // current in_progress task, and falls back to a completed-task header when + // nothing is in progress. Mid-turn, present the most recent finished task as + // still in progress so the header never claims completion; its true status + // is emitted with the next batch or at the final flush. const report = opts.final ? tasks : holdLastFinishedTask(tasks) for (const task of report) { let details: RendererTaskBlock[] | undefined @@ -1246,9 +1091,6 @@ function holdLastFinishedTask(tasks: HarnessTask[]): HarnessTask[] { } function activityRunBlock(task: HarnessTask): RendererTaskBlock[] { - if (task.title === 'Thinking' && task.details.length) { - return task.details - } const command = firstPreformattedBody(task.details) if (command) { return [pre(command, shellLanguage(firstPreformattedLanguage(task.details)))] diff --git a/services/api-rs/crates/centaur-api-server/src/activity_summary.rs b/services/api-rs/crates/centaur-api-server/src/activity_summary.rs new file mode 100644 index 000000000..7a203d083 --- /dev/null +++ b/services/api-rs/crates/centaur-api-server/src/activity_summary.rs @@ -0,0 +1,818 @@ +use std::{ + collections::{HashMap, VecDeque}, + time::{Duration, Instant}, +}; + +use centaur_session_core::{SessionEvent, ThreadKey, ThreadKeyError}; +use centaur_session_runtime::SESSION_OUTPUT_LINE_EVENT; +use centaur_session_sqlx::{PgSessionStore, SessionEventNotification, SessionStoreError}; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use thiserror::Error; +use tokio::time::sleep; +use tracing::{debug, info, warn}; + +pub(crate) const SESSION_ACTIVITY_SUMMARY_EVENT: &str = "session.activity_summary"; + +const SYSTEM_PROMPT: &str = "\ +You write live status text for a software agent. Use only the supplied event facts. \ +Write one short, conversational first-person present-tense sentence under 45 characters, \ +including spaces, as if you are the agent. Describe the goal you are working toward, \ +not the exact command, file path, ID, flag, or implementation step you are using. \ +Avoid mechanics like running tests, reading output, building images, checking logs, \ +or watching rollouts unless they are the user's explicit goal. If the facts are mostly \ +mechanics, infer the higher-level outcome and omit those mechanics. Prefer short \ +outcomes like \"I'm checking the fix\" or \"I'm getting the preview ready\". \ +Do not mention tests, output, builds, logs, rollouts, commands, paths, IDs, or flags unless the user asked for them. \ +Use user-facing words like fix, preview, update, or summary behavior instead of \ +infrastructure words like server, deployment, or rollout. Do not refer to \"the agent\". \ +Never write more than 45 characters. No markdown, no quotes, no event IDs, and no speculation."; + +#[derive(Clone)] +pub(crate) struct ActivitySummaryConfig { + pub(crate) base_url: String, + pub(crate) api_key: String, + pub(crate) max_facts: usize, + pub(crate) max_output_tokens: u16, + pub(crate) min_interval: Duration, + pub(crate) model: String, + pub(crate) timeout: Duration, +} + +pub(crate) struct ActivitySummaryWorker { + client: ActivitySummaryClient, + config: ActivitySummaryConfig, + states: HashMap, + store: PgSessionStore, +} + +impl ActivitySummaryWorker { + pub(crate) fn new( + store: PgSessionStore, + config: ActivitySummaryConfig, + ) -> Result { + Ok(Self { + client: ActivitySummaryClient::new(&config)?, + config, + states: HashMap::new(), + store, + }) + } + + pub(crate) async fn run(mut self) { + info!( + model = %self.config.model, + min_interval_ms = self.config.min_interval.as_millis(), + "session activity summary worker started" + ); + loop { + let mut listener = match self.store.listen_session_events().await { + Ok(listener) => listener, + Err(error) => { + warn!(%error, "failed to listen for session activity events"); + sleep(Duration::from_secs(5)).await; + continue; + } + }; + + loop { + match listener.recv().await { + Ok(notification) => { + if let Err(error) = self.process_notification(notification).await { + warn!(%error, "failed to process session activity event"); + } + } + Err(error) => { + warn!(%error, "session activity event listener failed; reconnecting"); + sleep(Duration::from_secs(1)).await; + break; + } + } + } + } + } + + async fn process_notification( + &mut self, + notification: SessionEventNotification, + ) -> Result<(), ActivitySummaryError> { + let thread_key = ThreadKey::parse(notification.thread_key)?; + let events = self + .store + .list_events_after( + &thread_key, + notification.event_id.saturating_sub(1), + None, + 8, + ) + .await?; + let Some(event) = events + .into_iter() + .find(|event| event.event_id == notification.event_id) + else { + return Ok(()); + }; + self.process_event(event).await + } + + async fn process_event(&mut self, event: SessionEvent) -> Result<(), ActivitySummaryError> { + if event.event_type == SESSION_ACTIVITY_SUMMARY_EVENT { + return Ok(()); + } + let Some(execution_id) = event.execution_id.as_deref() else { + return Ok(()); + }; + if is_terminal_session_event(&event.event_type) { + self.states.remove(execution_id); + return Ok(()); + } + if event.event_type != SESSION_OUTPUT_LINE_EVENT { + return Ok(()); + } + + let Some(fact) = activity_fact_from_output_event(&event) else { + return Ok(()); + }; + let now = Instant::now(); + let publish = { + let state = self + .states + .entry(execution_id.to_owned()) + .or_insert_with(|| ExecutionActivity { + facts: VecDeque::with_capacity(self.config.max_facts), + last_attempt_at: None, + last_published_signature: None, + last_summary: None, + max_facts: self.config.max_facts, + }); + state.push(fact); + state.prepare_publish(now, self.config.min_interval) + }; + + let Some(prompt) = publish else { + return Ok(()); + }; + + let summary = match self.client.summarize(&prompt).await { + Ok(summary) => summary, + Err(error) => { + warn!(%error, "failed to generate session activity summary"); + return Ok(()); + } + }; + let Some(summary) = sanitize_summary(&summary) else { + debug!("discarded empty session activity summary"); + return Ok(()); + }; + + self.store + .append_event( + &event.thread_key, + Some(execution_id), + SESSION_ACTIVITY_SUMMARY_EVENT, + json!({ + "execution_id": execution_id, + "model": self.config.model.as_str(), + "source_event_id": event.event_id, + "summary": summary, + }), + ) + .await?; + + if let Some(state) = self.states.get_mut(execution_id) { + state.last_published_signature = Some(state.signature()); + state.last_summary = Some(summary); + } + Ok(()) + } +} + +#[derive(Debug)] +struct ExecutionActivity { + facts: VecDeque, + last_attempt_at: Option, + last_published_signature: Option, + last_summary: Option, + max_facts: usize, +} + +impl ExecutionActivity { + fn push(&mut self, fact: ActivityFact) { + if self + .facts + .back() + .is_some_and(|existing| existing.kind == fact.kind && existing.text == fact.text) + { + return; + } + self.facts.push_back(fact); + while self.facts.len() > self.max_facts { + self.facts.pop_front(); + } + } + + fn prepare_publish(&mut self, now: Instant, min_interval: Duration) -> Option { + if self.facts.is_empty() { + return None; + } + if self + .last_attempt_at + .is_some_and(|last| now.saturating_duration_since(last) < min_interval) + { + return None; + } + let signature = self.signature(); + if self + .last_published_signature + .as_ref() + .is_some_and(|last| last == &signature) + { + return None; + } + self.last_attempt_at = Some(now); + Some(self.prompt()) + } + + fn prompt(&self) -> String { + let mut lines = Vec::new(); + if let Some(summary) = self.last_summary.as_deref() { + lines.push(format!("Previous status sentence: {summary}")); + } + lines.push("Recent activity facts, oldest to newest:".to_owned()); + for fact in &self.facts { + lines.push(format!("- {}: {}", fact.kind, fact.text)); + } + lines.join("\n") + } + + fn signature(&self) -> String { + self.facts + .iter() + .map(|fact| format!("{}={}", fact.kind, fact.text)) + .collect::>() + .join("\n") + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ActivityFact { + kind: &'static str, + text: String, +} + +fn activity_fact_from_output_event(event: &SessionEvent) -> Option { + let line = event.payload.as_str()?; + let value = serde_json::from_str::(line).ok()?; + activity_fact_from_value(&value) +} + +fn activity_fact_from_value(value: &Value) -> Option { + let event_type = event_type(value)?; + let normalized = event_type.replace('/', "."); + match normalized.as_str() { + "turn.plan.updated" => plan_fact(value), + "item.plan.delta" => string_field(value, &["delta", "text"]).map(|text| ActivityFact { + kind: "plan", + text: format!("planning {}", one_line(&text, 180)), + }), + "item.reasoning.summaryTextDelta" | "item.reasoning.textDelta" => { + string_field(value, &["delta", "text"]).map(|text| ActivityFact { + kind: "thinking", + text: one_line(&text, 220), + }) + } + "item.commandExecution.outputDelta" => Some(ActivityFact { + kind: "command", + text: "reading command output".to_owned(), + }), + "item.mcpToolCall.progress" => Some(ActivityFact { + kind: "tool", + text: progress_fact_text(value), + }), + "item.started" | "item.updated" | "item.completed" => item_fact(value, &normalized), + "assistant" => assistant_tool_fact(value), + "tool" | "user" => tool_result_fact(value), + _ => None, + } +} + +fn event_type(value: &Value) -> Option { + string_at(value, &["method"]).or_else(|| string_at(value, &["type"])) +} + +fn plan_fact(value: &Value) -> Option { + let plan = value + .get("plan") + .or_else(|| value.get("params").and_then(|params| params.get("plan")))?; + let items = plan.as_array()?; + let current = items + .iter() + .find(|item| { + let status = string_at(item, &["status"]) + .unwrap_or_default() + .to_ascii_lowercase(); + matches!( + status.as_str(), + "inprogress" | "in_progress" | "running" | "pending" | "" + ) + }) + .or_else(|| items.last())?; + let step = string_at(current, &["step"]) + .or_else(|| string_at(current, &["title"])) + .or_else(|| string_at(current, &["text"]))?; + Some(ActivityFact { + kind: "plan", + text: format!("working on {}", one_line(&strip_plan_marker(&step), 180)), + }) +} + +fn item_fact(value: &Value, normalized_event_type: &str) -> Option { + let item = protocol_item(value)?; + let item_type = string_at(item, &["type"]).unwrap_or_default(); + let completed = normalized_event_type == "item.completed"; + match item_type.as_str() { + "commandExecution" | "command_execution" => { + let command = string_at(item, &["command"]).unwrap_or_else(|| "command".to_owned()); + let action = if completed { "finished" } else { "running" }; + Some(ActivityFact { + kind: "command", + text: format!( + "{action} {}", + one_line(&unwrap_shell_command(&command), 220) + ), + }) + } + "fileChange" | "file_change" => Some(ActivityFact { + kind: "files", + text: file_change_text(item, completed), + }), + "reasoning" => reasoning_item_fact(item, completed), + "mcpToolCall" | "mcp_tool_call" | "dynamicToolCall" | "dynamic_tool_call" => { + let name = tool_name(item); + let action = if completed { "finished using" } else { "using" }; + Some(ActivityFact { + kind: "tool", + text: format!("{action} {name}"), + }) + } + // Assistant messages are the user-visible answer/commentary stream, not + // a useful live activity signal. Tool, plan, and command events carry + // the actual work in progress. + "agentMessage" | "agent_message" => None, + "plan" => string_at(item, &["text"]).map(|text| ActivityFact { + kind: "plan", + text: format!("updated plan {}", one_line(&text, 180)), + }), + _ => None, + } +} + +fn protocol_item(value: &Value) -> Option<&Value> { + value + .get("item") + .or_else(|| value.get("params").and_then(|params| params.get("item"))) +} + +fn reasoning_item_fact(item: &Value, completed: bool) -> Option { + let text = string_at(item, &["text"]) + .or_else(|| array_text(item.get("summary"))) + .or_else(|| array_text(item.get("content")))?; + Some(ActivityFact { + kind: "thinking", + text: if completed { + format!("finished thinking about {}", one_line(&text, 180)) + } else { + one_line(&text, 220) + }, + }) +} + +fn file_change_text(item: &Value, completed: bool) -> String { + let action = if completed { + "finished editing" + } else { + "editing" + }; + let paths = item + .get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| string_at(change, &["path"])) + .collect::>() + }) + .unwrap_or_default(); + if paths.is_empty() { + return format!("{action} files"); + } + let unique = paths + .into_iter() + .fold(Vec::::new(), |mut out, path| { + if !out.contains(&path) { + out.push(path); + } + out + }); + format!("{action} {}", one_line(&unique.join(", "), 180)) +} + +fn progress_fact_text(value: &Value) -> String { + let name = string_at(value, &["name"]) + .or_else(|| string_at(value, &["toolName"])) + .or_else(|| string_at(value, &["params", "name"])) + .or_else(|| string_at(value, &["params", "toolName"])) + .unwrap_or_else(|| "tool".to_owned()); + format!("waiting on {name}") +} + +fn assistant_tool_fact(value: &Value) -> Option { + let content = value.get("content").and_then(Value::as_array)?; + let tool = content + .iter() + .find(|item| string_at(item, &["type"]).as_deref() == Some("tool_use"))?; + Some(ActivityFact { + kind: "tool", + text: format!("using {}", tool_name(tool)), + }) +} + +fn tool_result_fact(value: &Value) -> Option { + let content = value.get("content").and_then(Value::as_array)?; + if content.iter().any(|item| { + string_at(item, &["type"]).as_deref() == Some("tool_result") + || string_at(item, &["tool_use_id"]).is_some() + }) { + return Some(ActivityFact { + kind: "tool", + text: "reading tool results".to_owned(), + }); + } + None +} + +fn tool_name(item: &Value) -> String { + string_at(item, &["name"]) + .or_else(|| string_at(item, &["toolName"])) + .or_else(|| string_at(item, &["tool_name"])) + .or_else(|| string_at(item, &["serverLabel"])) + .or_else(|| string_at(item, &["server_label"])) + .unwrap_or_else(|| "tool".to_owned()) +} + +fn array_text(value: Option<&Value>) -> Option { + let texts = value? + .as_array()? + .iter() + .filter_map(|item| { + if let Some(text) = item.as_str() { + return Some(text.to_owned()); + } + string_at(item, &["text"]) + }) + .filter(|text| !text.trim().is_empty()) + .collect::>(); + (!texts.is_empty()).then(|| texts.join(" ")) +} + +fn string_field(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| string_at(value, &[*key])) +} + +fn string_at(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + current + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn strip_plan_marker(value: &str) -> String { + let mut text = value.trim(); + if let Some(rest) = text.strip_prefix("- ") { + text = rest; + } else if let Some(rest) = text.strip_prefix("* ") { + text = rest; + } + for marker in ["[ ] ", "[x] ", "[X] "] { + if let Some(rest) = text.strip_prefix(marker) { + text = rest; + } + } + text.trim().to_owned() +} + +fn unwrap_shell_command(command: &str) -> String { + let trimmed = command.trim(); + let Some(rest) = trimmed.strip_prefix("/bin/bash -lc ") else { + return trimmed.to_owned(); + }; + rest.trim() + .trim_matches(|ch| ch == '"' || ch == '\'') + .trim() + .to_owned() +} + +fn one_line(value: &str, max_chars: usize) -> String { + let normalized = value.split_whitespace().collect::>().join(" "); + if normalized.chars().count() <= max_chars { + return normalized; + } + let mut out = normalized + .chars() + .take(max_chars.saturating_sub(3)) + .collect::(); + out.push_str("..."); + out +} + +fn sanitize_summary(summary: &str) -> Option { + let summary = one_line(summary.trim().trim_matches('"').trim_matches('\''), 180); + (!summary.is_empty()).then_some(summary) +} + +fn is_terminal_session_event(event_type: &str) -> bool { + matches!( + event_type, + "session.execution_completed" + | "session.execution_failed" + | "session.execution_cancelled" + | "session.stream_error" + | "session.stdout_pump_failed" + ) +} + +#[derive(Clone)] +struct ActivitySummaryClient { + api_key: String, + client: reqwest::Client, + max_output_tokens: u16, + model: String, + responses_url: String, +} + +impl ActivitySummaryClient { + fn new(config: &ActivitySummaryConfig) -> Result { + let client = reqwest::Client::builder() + .timeout(config.timeout) + .build() + .map_err(ActivitySummaryError::Http)?; + let responses_url = format!("{}/responses", config.base_url.trim_end_matches('/')); + Ok(Self { + api_key: config.api_key.clone(), + client, + max_output_tokens: config.max_output_tokens, + model: config.model.clone(), + responses_url, + }) + } + + async fn summarize(&self, prompt: &str) -> Result { + let response = self + .client + .post(&self.responses_url) + .bearer_auth(&self.api_key) + .json(&json!({ + "model": self.model.as_str(), + "instructions": SYSTEM_PROMPT, + "input": prompt, + "max_output_tokens": self.max_output_tokens, + "store": false, + })) + .send() + .await?; + let status = response.status(); + let body = response.text().await?; + if !status.is_success() { + return Err(ActivitySummaryError::OpenAiStatus { + body: redact_openai_error_body(&body), + status, + }); + } + let value = serde_json::from_str::(&body)?; + if let Some(reason) = string_at(&value, &["incomplete_details", "reason"]) { + return Err(ActivitySummaryError::Incomplete { reason }); + } + extract_response_text(&value).ok_or(ActivitySummaryError::MissingOutputText) + } +} + +fn extract_response_text(value: &Value) -> Option { + if let Some(text) = string_at(value, &["output_text"]) { + return Some(text); + } + let output = value.get("output")?.as_array()?; + let mut parts = Vec::new(); + for item in output { + let Some(content) = item.get("content").and_then(Value::as_array) else { + continue; + }; + for content_item in content { + if let Some(text) = string_at(content_item, &["text"]) { + parts.push(text); + } + } + } + (!parts.is_empty()).then(|| parts.join(" ")) +} + +fn redact_openai_error_body(body: &str) -> String { + let body = one_line(body, 300); + let marker = "Incorrect API key provided:"; + let Some(marker_index) = body.find(marker) else { + return body; + }; + let value_start = marker_index + marker.len(); + let value_end = body[value_start..] + .find('.') + .map(|offset| value_start + offset) + .unwrap_or(body.len()); + format!( + "{} [redacted]{}", + body[..value_start].trim_end(), + &body[value_end..] + ) +} + +#[derive(Debug, Error)] +pub(crate) enum ActivitySummaryError { + #[error("activity summary HTTP error: {0}")] + Http(#[from] reqwest::Error), + #[error("activity summary OpenAI request failed with {status}: {body}")] + OpenAiStatus { status: StatusCode, body: String }, + #[error("activity summary OpenAI response incomplete: {reason}")] + Incomplete { reason: String }, + #[error("activity summary OpenAI response did not include output text")] + MissingOutputText, + #[error("activity summary JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("activity summary session store error: {0}")] + Store(#[from] SessionStoreError), + #[error("activity summary thread key error: {0}")] + ThreadKey(#[from] ThreadKeyError), +} + +#[cfg(test)] +mod tests { + use centaur_session_core::ThreadKey; + use time::OffsetDateTime; + + use super::*; + + fn event(line: Value) -> SessionEvent { + SessionEvent { + event_id: 7, + thread_key: ThreadKey::parse("test:thread").unwrap(), + execution_id: Some("exec-1".to_owned()), + event_type: SESSION_OUTPUT_LINE_EVENT.to_owned(), + payload: Value::String(line.to_string()), + created_at: OffsetDateTime::now_utc(), + } + } + + #[test] + fn projects_plan_update_into_activity_fact() { + let fact = activity_fact_from_output_event(&event(json!({ + "type": "turn.plan.updated", + "plan": [ + {"step": "Inspect App Server events", "status": "completed"}, + {"step": "Add activity summary worker", "status": "in_progress"} + ] + }))) + .unwrap(); + + assert_eq!( + fact, + ActivityFact { + kind: "plan", + text: "working on Add activity summary worker".to_owned(), + } + ); + } + + #[test] + fn projects_command_event_without_output() { + let fact = activity_fact_from_output_event(&event(json!({ + "method": "item/started", + "params": { + "item": { + "id": "cmd-1", + "type": "commandExecution", + "command": "/bin/bash -lc 'rg session.activity'" + } + } + }))) + .unwrap(); + + assert_eq!( + fact, + ActivityFact { + kind: "command", + text: "running rg session.activity".to_owned(), + } + ); + } + + #[test] + fn ignores_agent_commentary_messages_as_activity() { + let fact = activity_fact_from_output_event(&event(json!({ + "method": "item/started", + "params": { + "item": { + "id": "msg-1", + "phase": "commentary", + "text": "", + "type": "agentMessage" + } + } + }))); + + assert_eq!(fact, None); + } + + #[test] + fn system_prompt_requires_conversational_goal_status() { + assert!(SYSTEM_PROMPT.contains("first-person")); + assert!(SYSTEM_PROMPT.contains("under 45 characters")); + assert!(SYSTEM_PROMPT.contains("Describe the goal")); + assert!(SYSTEM_PROMPT.contains("not the exact")); + assert!(SYSTEM_PROMPT.contains("Avoid mechanics")); + assert!(SYSTEM_PROMPT.contains("infer the")); + assert!(SYSTEM_PROMPT.contains("Do not mention tests")); + assert!(SYSTEM_PROMPT.contains("Use user-facing words")); + assert!(SYSTEM_PROMPT.contains("\"I'm checking the fix\"")); + assert!(SYSTEM_PROMPT.contains("Never write more than 45 characters")); + assert!(SYSTEM_PROMPT.contains("Do not refer to \"the agent\"")); + } + + #[test] + fn extracts_output_text_from_responses_body() { + let text = extract_response_text(&json!({ + "output": [ + { + "type": "message", + "content": [ + {"type": "output_text", "text": "I'm inspecting events."} + ] + } + ] + })) + .unwrap(); + + assert_eq!(text, "I'm inspecting events."); + } + + #[test] + fn detects_incomplete_responses_body() { + let reason = string_at( + &json!({ + "status": "incomplete", + "incomplete_details": {"reason": "max_output_tokens"}, + "output": [ + {"type": "reasoning", "content": [], "summary": []} + ] + }), + &["incomplete_details", "reason"], + ) + .unwrap(); + + assert_eq!(reason, "max_output_tokens"); + } + + #[test] + fn redacts_openai_invalid_key_errors() { + let redacted = redact_openai_error_body( + r#"{"error":{"message":"Incorrect API key provided: sk-svc-secret. You can find your API key at https://platform.openai.com/account/api-keys."}}"#, + ); + + assert!(redacted.contains("Incorrect API key provided: [redacted]")); + assert!(!redacted.contains("sk-svc-secret")); + } + + #[test] + fn throttles_unchanged_activity() { + let mut state = ExecutionActivity { + facts: VecDeque::new(), + last_attempt_at: None, + last_published_signature: None, + last_summary: None, + max_facts: 4, + }; + let now = Instant::now(); + state.push(ActivityFact { + kind: "tool", + text: "using github".to_owned(), + }); + assert!(state.prepare_publish(now, Duration::from_secs(8)).is_some()); + state.last_published_signature = Some(state.signature()); + assert!( + state + .prepare_publish(now + Duration::from_secs(9), Duration::from_secs(8)) + .is_none() + ); + } +} diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index f6828d705..c253c9fb4 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -34,6 +34,7 @@ use tracing::{error, info, warn}; use crate::{ ServerError, + activity_summary::ActivitySummaryConfig, tool_discovery::{ DiscoveredToolProxyFragment, ToolDiscoveryConfig, discover_persona_registry, discover_tool_proxy_fragment, @@ -60,6 +61,8 @@ pub(crate) struct Args { pub(crate) server: ServerArgs, #[command(flatten)] sandbox: SandboxArgs, + #[command(flatten)] + activity_summary: ActivitySummaryArgs, } impl Args { @@ -103,6 +106,10 @@ impl Args { .workflow_host_sandbox_runtime(bootstrap_iron_control_principal) .await } + + pub(crate) fn activity_summary_config(&self) -> Option { + self.activity_summary.config() + } } pub(crate) struct IronControlRuntime { @@ -121,6 +128,82 @@ pub(crate) struct IronControlToolReconciler { interval: Duration, } +#[derive(Debug, ClapArgs)] +struct ActivitySummaryArgs { + /// Enable API-side model summaries of durable Codex App Server activity. + #[arg( + long = "session-activity-summary-enabled", + env = "SESSION_ACTIVITY_SUMMARY_ENABLED", + default_value_t = false, + action = clap::ArgAction::Set + )] + enabled: bool, + #[arg( + long = "session-activity-summary-model", + env = "SESSION_ACTIVITY_SUMMARY_MODEL", + default_value = "gpt-5.4-nano" + )] + model: String, + #[arg( + long = "session-activity-summary-openai-base-url", + env = "SESSION_ACTIVITY_SUMMARY_OPENAI_BASE_URL", + default_value = "https://api.openai.com/v1" + )] + openai_base_url: String, + #[arg( + long = "session-activity-summary-min-interval-secs", + env = "SESSION_ACTIVITY_SUMMARY_MIN_INTERVAL_SECS", + default_value_t = 8, + value_parser = clap::value_parser!(u64).range(1..) + )] + min_interval_secs: u64, + #[arg( + long = "session-activity-summary-timeout-secs", + env = "SESSION_ACTIVITY_SUMMARY_TIMEOUT_SECS", + default_value_t = 5, + value_parser = clap::value_parser!(u64).range(1..) + )] + timeout_secs: u64, + #[arg( + long = "session-activity-summary-max-facts", + env = "SESSION_ACTIVITY_SUMMARY_MAX_FACTS", + default_value_t = 12, + value_parser = clap::value_parser!(u64).range(1..) + )] + max_facts: u64, + #[arg( + long = "session-activity-summary-max-output-tokens", + env = "SESSION_ACTIVITY_SUMMARY_MAX_OUTPUT_TOKENS", + default_value_t = 128, + value_parser = clap::value_parser!(u64).range(1..) + )] + max_output_tokens: u64, +} + +impl ActivitySummaryArgs { + fn config(&self) -> Option { + if !self.enabled { + return None; + } + let Some(api_key) = clean_optional_value(env::var("OPENAI_API_KEY").ok().as_deref()) else { + warn!( + "session activity summaries are enabled but no OpenAI credential is configured; \ + set OPENAI_API_KEY in the api-rs environment" + ); + return None; + }; + Some(ActivitySummaryConfig { + base_url: self.openai_base_url.clone(), + api_key, + max_facts: usize::try_from(self.max_facts).unwrap_or(usize::MAX), + max_output_tokens: u16::try_from(self.max_output_tokens).unwrap_or(u16::MAX), + min_interval: Duration::from_secs(self.min_interval_secs), + model: self.model.clone(), + timeout: Duration::from_secs(self.timeout_secs), + }) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct ToolGitSource { repo: String, @@ -2002,6 +2085,55 @@ mod tests { )); } + #[test] + fn activity_summary_uses_direct_openai_key_by_default() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("OPENAI_API_KEY", "sk-test"), + ("FIREWALL_MANAGER_SECRET_SOURCE", "env"), + ("KUBERNETES_OP_CONNECT_HOST", ""), + ("OP_CONNECT_TOKEN", ""), + ("OP_VAULT", ""), + ]); + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + "--session-activity-summary-enabled", + "true", + ]) + .unwrap(); + + let config = args.activity_summary_config().unwrap(); + assert_eq!(config.api_key, "sk-test"); + } + + #[test] + fn activity_summary_uses_mounted_openai_key_even_with_onepassword_connect_source() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("OPENAI_API_KEY", "sk-mounted"), + ("FIREWALL_MANAGER_SECRET_SOURCE", "onepassword-connect"), + ( + "KUBERNETES_OP_CONNECT_HOST", + "http://onepassword-connect:8080", + ), + ("OP_CONNECT_TOKEN", "op-token"), + ("OP_VAULT", "centaur-agent"), + ]); + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + "--session-activity-summary-enabled", + "true", + ]) + .unwrap(); + + let config = args.activity_summary_config().unwrap(); + assert_eq!(config.api_key, "sk-mounted"); + } + #[test] fn parses_session_sandbox_flags() { let args = Args::try_parse_from([ diff --git a/services/api-rs/crates/centaur-api-server/src/main.rs b/services/api-rs/crates/centaur-api-server/src/main.rs index 8ee2c0879..0c2d04a41 100644 --- a/services/api-rs/crates/centaur-api-server/src/main.rs +++ b/services/api-rs/crates/centaur-api-server/src/main.rs @@ -1,3 +1,4 @@ +mod activity_summary; mod args; mod tool_discovery; @@ -58,6 +59,10 @@ async fn initialize_runtime(args: Args, app_state: AppState) -> Result<(), Serve if args.server.run_migrations { store.run_migrations().await?; } + if let Some(config) = args.activity_summary_config() { + let worker = activity_summary::ActivitySummaryWorker::new(store.clone(), config)?; + tokio::spawn(worker.run()); + } let pool = store.pool().clone(); let sandbox_runtime = args.sandbox_runtime().await?; let mut runtime = SessionRuntime::new(store.clone(), sandbox_runtime) @@ -140,6 +145,8 @@ pub(crate) enum ServerError { Telemetry(#[from] centaur_telemetry::TelemetryError), #[error(transparent)] ToolDiscovery(#[from] tool_discovery::ToolDiscoveryError), + #[error(transparent)] + ActivitySummary(#[from] activity_summary::ActivitySummaryError), #[error("tool source error: {0}")] ToolSource(String), #[error("iron-proxy requires both firewall CA cert and key Secret names")] diff --git a/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs b/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs index f0a58b7b0..650e3c93a 100644 --- a/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs +++ b/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs @@ -162,11 +162,9 @@ transforms: config: secrets: - id: OPENAI_API_KEY_AUTHORIZATION - source: - placeholder: OPENAI_API_KEY - inject: - header: Authorization - formatter: "Bearer {{.Value}}" + replace: + proxy_value: OPENAI_API_KEY + match_headers: ["Authorization"] rules: [{ host: api.openai.com }] "#; @@ -176,11 +174,9 @@ transforms: config: secrets: - id: OPENROUTER_API_KEY_AUTHORIZATION - source: - placeholder: OPENROUTER_API_KEY - inject: - header: Authorization - formatter: "Bearer {{.Value}}" + replace: + proxy_value: OPENROUTER_API_KEY + match_headers: ["Authorization"] rules: [{ host: openrouter.ai }] "#; diff --git a/services/api-rs/crates/centaur-iron-proxy/src/tests.rs b/services/api-rs/crates/centaur-iron-proxy/src/tests.rs index 05a317cb0..b634db59b 100644 --- a/services/api-rs/crates/centaur-iron-proxy/src/tests.rs +++ b/services/api-rs/crates/centaur-iron-proxy/src/tests.rs @@ -3,7 +3,11 @@ use super::*; #[test] fn harness_auth_fragments_are_baked_in() { let codex = harness_auth_fragment("codex", "api_key").unwrap().unwrap(); - assert!(placeholder_env(&[codex]).is_empty()); + let codex_placeholders = placeholder_env(&[codex]); + assert_eq!( + codex_placeholders.get("OPENAI_API_KEY").map(String::as_str), + Some("OPENAI_API_KEY") + ); // access_token carries the token-broker credential, not a replace // placeholder, so it contributes no sandbox placeholder env. @@ -15,7 +19,13 @@ fn harness_auth_fragments_are_baked_in() { let openrouter = harness_auth_fragment("openrouter", "api_key") .unwrap() .unwrap(); - assert!(placeholder_env(&[openrouter]).is_empty()); + let openrouter_placeholders = placeholder_env(&[openrouter]); + assert_eq!( + openrouter_placeholders + .get("OPENROUTER_API_KEY") + .map(String::as_str), + Some("OPENROUTER_API_KEY") + ); assert!(harness_auth_fragment("codex", "bogus").unwrap().is_none()); diff --git a/services/linearbot/src/comment-bot.ts b/services/linearbot/src/comment-bot.ts index 6cb516cca..0d7e74fbd 100644 --- a/services/linearbot/src/comment-bot.ts +++ b/services/linearbot/src/comment-bot.ts @@ -55,7 +55,7 @@ export class CommentReplyCollector { private cotChars = 0; // The renderer re-emits terminal task updates at stream close; one line per // task id is enough (mirrors the narrator's settledTaskIds). - private readonly settledTaskIds = new Set(); + private readonly recordedTaskIds = new Set(); // The command/reasoning text arrives on the in-progress update; the terminal // update often omits `details` (carrying only output). Cache per task id so // the settled line keeps its parameter — mirrors the narrator's taskDetails. @@ -83,15 +83,18 @@ export class CommentReplyCollector { .filter(Boolean) .join("\n"); } - // Only persist settled tasks; in-progress repeats are noise in a static - // transcript. - if (chunk.status !== "complete" && chunk.status !== "error") return; - if (this.settledTaskIds.has(chunk.id)) return; - this.settledTaskIds.add(chunk.id); const line = flattenCotLine(this.formatTaskLine(chunk)); if (chunk.title === "Thinking" && line) { this.latestThoughtText = line.slice(0, THOUGHT_MAX_CHARS); } + if (chunk.status !== "complete" && chunk.status !== "error") { + if (!line || this.recordedTaskIds.has(chunk.id)) return; + this.recordedTaskIds.add(chunk.id); + this.pushCot(line); + return; + } + if (this.recordedTaskIds.has(chunk.id)) return; + this.recordedTaskIds.add(chunk.id); this.pushCot(line); } diff --git a/services/linearbot/test/comment-bot.test.ts b/services/linearbot/test/comment-bot.test.ts index c964f17b9..20a01e4e6 100644 --- a/services/linearbot/test/comment-bot.test.ts +++ b/services/linearbot/test/comment-bot.test.ts @@ -47,6 +47,25 @@ describe("CommentReplyCollector chain-of-thought flattening", () => { ]); }); + it("records in-progress task details for live replies without duplicating the terminal update", () => { + const collector = new CommentReplyCollector(); + collector.update({ + type: "task_update", + id: "cmd-1", + title: "1. Command execution", + status: "in_progress", + details: "```sh\npnpm test\n```", + }); + collector.update({ + type: "task_update", + id: "cmd-1", + title: "1. Command execution", + status: "complete", + }); + + expect(collector.cotLines).toEqual(["1. Command execution: `pnpm test`"]); + }); + it("tracks the latest reasoning as the current thought", () => { const collector = new CommentReplyCollector(); collector.update(thinking("t-1", "First, read the options.")); diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 5bb5cabaf..efb262fdc 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -113,6 +113,7 @@ const RENDER_RECOVERY_THREAD_TIMEOUT_MS = 2 * 60 * 1000 const RENDER_RECOVERY_MAX_THREAD_FAILURES = 5 const RENDER_RETRY_INITIAL_DELAY_MS = 250 const RENDER_RETRY_MAX_DELAY_MS = 5_000 +const ASSISTANT_STATUS_MAX_CHARS = 50 const SLACK_TASK_DETAILS_MAX_CHARS = 500 const SLACK_FALLBACK_TEXT_MAX_CHARS = 35_000 const POSTGRES_CONNECT_INITIAL_DELAY_MS = 250 @@ -365,6 +366,7 @@ async function handleSlackMessageHandoff( trigger: input.trigger }) await syncThreadMessageToSession(thread, message, { + initialAssistantStatusRequested: input.assistantStatusRequested, initialAssistantStatusVisible, mode: input.mode, options: input.options, @@ -578,6 +580,7 @@ async function syncThreadMessageToSession( thread: Thread, message: ChatMessage, input: { + initialAssistantStatusRequested?: boolean initialAssistantStatusVisible?: boolean mode: SlackbotV2MessageMode options: SlackbotV2Options @@ -617,7 +620,8 @@ async function syncThreadMessageToSession( history_forwarded: state.historyForwarded === true }) const assistantStatusVisible = shouldStartExecution - ? input.initialAssistantStatusVisible === true + ? input.initialAssistantStatusVisible === true || + input.initialAssistantStatusRequested === true : false if (shouldStartExecution && input.initialAssistantStatusVisible === undefined) { backgroundWaitUntil( @@ -1824,12 +1828,16 @@ async function renderExecutionStream( }) const capture = { diverged: false } try { + const taskDisplayMode = slackStreamTaskDisplayMode(options) const visibleStream = await streamAfterFirstChunk( conflateChatSdkStream( slackSafeChatSdkStream( - codexAppServerToChatSdkStream( - stream, - rendererOptions(thread, options, capture) + slackVisibleChatSdkStream( + codexAppServerToChatSdkStream( + stream, + rendererOptions(thread, options, capture, trace) + ), + taskDisplayMode ) ) ) @@ -1843,7 +1851,7 @@ async function renderExecutionStream( const sent = await thread.adapter.stream!(thread.id, visibleStream, { recipientTeamId: message.teamId, recipientUserId: message.author.userId, - taskDisplayMode: options.streamTaskDisplayMode ?? 'plan' + ...(taskDisplayMode === 'none' ? {} : { taskDisplayMode }) }) return { diverged: capture.diverged, messageId: sent?.id } } finally { @@ -1871,12 +1879,16 @@ async function renderRecoveredExecutionStream( }) const capture = { diverged: false } try { + const taskDisplayMode = slackStreamTaskDisplayMode(options) const visibleStream = await streamAfterFirstChunk( conflateChatSdkStream( slackSafeChatSdkStream( - codexAppServerToChatSdkStream( - stream, - rendererOptions(thread, options, capture) + slackVisibleChatSdkStream( + codexAppServerToChatSdkStream( + stream, + rendererOptions(thread, options, capture, trace) + ), + taskDisplayMode ) ) ) @@ -1888,7 +1900,7 @@ async function renderRecoveredExecutionStream( { recipientTeamId: message.teamId, recipientUserId: message.author.userId, - taskDisplayMode: options.streamTaskDisplayMode ?? 'plan' + ...(taskDisplayMode === 'none' ? {} : { taskDisplayMode }) } ) return { diverged: capture.diverged, messageId: sent?.id } @@ -1925,7 +1937,7 @@ async function renderPlainTextExecutionStream( slackSafeChatSdkStream( codexAppServerToChatSdkStream( fallback.collectSource(stream), - rendererOptions(thread, options) + rendererOptions(thread, options, undefined, trace) ) ) ) @@ -2000,6 +2012,22 @@ async function* slackSafeChatSdkStream( } } +type SlackStreamTaskDisplayMode = NonNullable + +function slackStreamTaskDisplayMode(options: SlackbotV2Options): SlackStreamTaskDisplayMode { + return options.streamTaskDisplayMode ?? 'none' +} + +async function* slackVisibleChatSdkStream( + stream: AsyncIterable, + taskDisplayMode: SlackStreamTaskDisplayMode +): AsyncIterable { + for await (const chunk of stream) { + if (taskDisplayMode === 'none' && chunk.type !== 'markdown_text') continue + yield chunk + } +} + function slackSafeChatSdkChunk(chunk: ChatSDKStreamChunk): ChatSDKStreamChunk { if (chunk.type !== 'task_update') return chunk const { output: _output, details, ...safeChunk } = chunk @@ -2728,7 +2756,8 @@ function rendererLogInfo( function rendererOptions( thread: Thread, options: SlackbotV2Options, - capture?: { diverged: boolean } + capture?: { diverged: boolean }, + trace?: SlackbotV2Trace ): CodexAppServerToChatStreamOptions { const mapper = options.mapper return { @@ -2739,6 +2768,9 @@ function rendererOptions( if (event.type === 'renderer.title.update') { await setAssistantTitle(thread, event.title, options) } + if (event.type === 'renderer.status') { + await setAssistantStatus(thread, event.status, options, trace) + } } } } @@ -2797,13 +2829,14 @@ async function setAssistantStatus( trace?: SlackbotV2Trace ): Promise { const startedAtMs = nowMs() + const normalizedStatus = normalizeAssistantStatus(status) const target = slackAssistantTarget(thread) const adapter = thread.adapter as SlackAssistantAdapter const fields = { has_adapter: Boolean(adapter.setAssistantStatus), has_target: Boolean(target), - operation: status ? 'set' : 'clear', - status_empty: !status + operation: normalizedStatus ? 'set' : 'clear', + status_empty: !normalizedStatus } if (options) traceLog(options, 'slackbotv2_assistant_status_started', trace, fields) if (!target || !adapter.setAssistantStatus) { @@ -2831,8 +2864,8 @@ async function setAssistantStatus( adapter.setAssistantStatus!( target.channel, target.threadTs, - status, - status ? [status] : undefined + normalizedStatus, + normalizedStatus ? [normalizedStatus] : undefined ) ) ) @@ -2858,6 +2891,13 @@ async function setAssistantStatus( } } +function normalizeAssistantStatus(status: string): string { + const oneLine = status.replace(/\s+/g, ' ').trim() + const chars = Array.from(oneLine) + if (chars.length <= ASSISTANT_STATUS_MAX_CHARS) return oneLine + return `${chars.slice(0, ASSISTANT_STATUS_MAX_CHARS - 3).join('').trimEnd()}...` +} + async function setAssistantTitle( thread: Thread, title: string | undefined, diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index fa607d618..1e830de11 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -1753,6 +1753,15 @@ async function* parseSessionEventStream( if (isTerminalCodexOutputLine(event.data)) return continue } + if (event.event === 'session.activity_summary') { + yield { + data: sessionEventData(event), + event: event.event, + eventId: event.id, + eventKind: event.event + } satisfies RustSessionStreamEvent + continue + } if (event.event === 'session.execution_failed' || event.event === 'session.stream_error') { yield { data: { error: sessionErrorMessage(event) }, diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 77271128c..2fed9a8ae 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -124,7 +124,7 @@ export type SlackbotV2Options = { slackApiTimeoutMs?: number state?: StateAdapter stateKeyPrefix?: string - streamTaskDisplayMode?: 'plan' | 'timeline' + streamTaskDisplayMode?: 'none' | 'plan' | 'timeline' triggerBotAllowlist?: readonly string[] userName?: string mapper?: CodexAppServerToChatStreamOptions diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index db5a5f58b..cf4bd7fa8 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -327,8 +327,9 @@ describe('slackbotv2', () => { const text = await threadText(parent.ts) expect(text).toContain('Implementation plan') expect(text).toContain('Inspect App Server events') - expect(text).toContain('Checking the command output') - expect(text).toContain('Inspecting the event stream') + expect(text).not.toContain('Checking the command output') + expect(text).not.toContain('Inspecting the event stream') + expect(text).not.toContain('Thinking') expect(text).toContain('Command execution') expect(text).toContain('pnpm test') expect(text).not.toContain('tests passed') @@ -3135,6 +3136,91 @@ describe('slackbotv2', () => { await Promise.all(waits) }) + it('uses session activity summaries as assistant status instead of visible text', async () => { + bot = createProductionDefaultTestBot() + codexApi.autoRespond = false + + const parent = await postUserMessage('Context before status update.') + const mention = await postUserMessage(`<@${BOT_USER_ID}> summarize activity`, parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-activity-summary-status', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> summarize activity` + } + }), + {}, + waitUntilContext(waits) + ) + + expect(response.status).toBe(200) + await waitFor(() => codexApi.executes.length === 1) + await waitFor(() => codexApi.eventRequests.length === 1) + await waitFor(() => codexApi.streamCount === 1) + + const key = threadKey(parent.ts) + const summary = + "I'm checking the benchmark page and related logs so I can explain the chart shape." + const clippedSummary = `${summary.slice(0, 47).trimEnd()}...` + codexApi.emitSessionEvent(key, 'session.activity_summary', { + execution_id: 'exe-activity-summary-status', + summary + }) + codexApi.emitOutputLine( + key, + JSON.stringify({ + type: 'item.started', + item: { + id: 'cmd-1', + type: 'commandExecution', + command: 'rg activity summary', + status: 'inProgress' + } + }) + ) + codexApi.emitOutputLine( + key, + JSON.stringify({ + type: 'turn.done', + result: 'Done with status.' + }) + ) + + await Promise.all(waits) + const statusCalls = slackApi.calls.filter(call => call.method === 'assistant.threads.setStatus') + expect(statusCalls.map(call => stringField(call.body.status))).toEqual([ + 'Thinking...', + clippedSummary, + '' + ]) + expect(Array.from(clippedSummary)).toHaveLength(50) + expect(statusCalls[1]?.body).toEqual( + expect.objectContaining({ + channel_id: CHANNEL_ID, + thread_ts: parent.ts, + loading_messages: [clippedSummary], + status: clippedSummary + }) + ) + const transcripts = slackStreamTranscripts(slackApi.calls) + expect(transcripts).toHaveLength(1) + expect(transcripts[0]!.start.body.task_display_mode).toBeUndefined() + expect(transcripts[0]!.chunks.every(chunk => chunk.type === 'markdown_text')).toBe(true) + const text = await threadText(parent.ts) + expect(text).toContain('Done with status.') + expect(text).not.toContain(summary) + expect(text).not.toContain('Command execution') + expect(text).not.toContain('Thinking') + }) + it('recovers unfinished render obligations from Chat SDK state on startup', async () => { const sharedState = createMemoryState() await sharedState.connect() @@ -3799,6 +3885,17 @@ describe('slackbotv2', () => { function createTestBot( overrides: Partial[0]> = {} +): SlackbotV2 { + return createProductionDefaultTestBot({ + // Most tests in this file exercise the legacy structured-card renderer. + // Production omits this option and uses assistant status for live activity. + streamTaskDisplayMode: 'plan', + ...overrides + }) +} + +function createProductionDefaultTestBot( + overrides: Partial[0]> = {} ): SlackbotV2 { return createSlackbotV2({ apiKey: 'slackbotv2-api-key', @@ -5095,6 +5192,7 @@ function expectSlackPlanStreamShape( expect(markdownText).not.toContain('Implementation plan') expect(markdownText).not.toContain('Checking the command output') expect(markdownText).not.toContain('Inspecting the event stream') + expect(markdownText).not.toContain('Thinking') expect(markdownText).not.toContain('Command execution') expect(markdownText).not.toContain('pnpm test') expect(markdownText).not.toContain('tests passed') @@ -5109,25 +5207,11 @@ function expectSlackPlanStreamShape( expect(progressChunks).toContainEqual( expect.objectContaining({ type: 'plan_update', title: 'Implementation plan' }) ) - // Conflation may merge intermediate states into the final card update - // when the consumer is behind, so only assert the terminal status per - // card here; content presence is asserted on the aggregate text below. - expect(progressChunks).toContainEqual( - expect.objectContaining({ - type: 'task_update', - id: 'thinking-commentary-1', - title: 'Thinking', - status: 'complete' - }) - ) - expect(progressChunks).toContainEqual( - expect.objectContaining({ - type: 'task_update', - id: 'reasoning-1', - title: 'Thinking', - status: 'complete' - }) - ) + expect( + progressChunks.some(chunk => chunk.type === 'task_update' && chunk.title === 'Thinking') + ).toBe(false) + expect(progressText).not.toContain('Checking the command output') + expect(progressText).not.toContain('Inspecting the event stream') expect(progressChunks).toContainEqual( expect.objectContaining({ type: 'task_update', @@ -5149,8 +5233,9 @@ function expectSlackPlanStreamShape( expect(renderedText).toContain('Implementation plan') expect(renderedText).toContain('Inspect App Server events') expect(renderedText).toContain('Stream Chat SDK chunks') - expect(renderedText).toContain('Checking the command output') - expect(renderedText).toContain('Inspecting the event stream') + expect(renderedText).not.toContain('Checking the command output') + expect(renderedText).not.toContain('Inspecting the event stream') + expect(renderedText).not.toContain('Thinking') expect(renderedText).toContain('Command execution') expect(renderedText).toContain('pnpm test') expect(renderedText).not.toContain('tests passed') @@ -5162,9 +5247,9 @@ function expectSlackRenderedReply(text: string, answer: string): void { expect(text).toContain('Implementation plan') expect(text).toContain('Inspect App Server events') expect(text).toContain('Stream Chat SDK chunks') - expect(text).toContain('Thinking') - expect(text).toContain('Checking the command output') - expect(text).toContain('Inspecting the event stream') + expect(text).not.toContain('Thinking') + expect(text).not.toContain('Checking the command output') + expect(text).not.toContain('Inspecting the event stream') expect(text).toContain('Command execution') expect(text).toContain('pnpm test') expect(text).not.toContain('tests passed') diff --git a/services/slackbotv2/test/session-api.test.ts b/services/slackbotv2/test/session-api.test.ts index d601f0046..336add92e 100644 --- a/services/slackbotv2/test/session-api.test.ts +++ b/services/slackbotv2/test/session-api.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_SESSION_IDLE_TIMEOUT_MS, forwardToSessionApi, harnessRestartPreamble, + openSessionEventStream, serializeAttachment, serializeMessage } from '../src/session-api' @@ -136,6 +137,56 @@ function isJsonRecord(value: JsonValue | undefined): value is JsonObject { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) } +describe('session event streaming', () => { + test('passes activity summary events through to the renderer source stream', async () => { + const encoded = new TextEncoder().encode( + [ + 'id: 1', + 'event: session.activity_summary', + 'data: {"summary":"The agent is reading App Server events."}', + '', + 'id: 2', + 'event: session.execution_completed', + 'data: {"result_text":"done"}', + '', + ].join('\n') + ) + const fetchFn: SlackbotV2Options['fetch'] = async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoded) + controller.close() + } + }), + { headers: { 'content-type': 'text/event-stream' } } + ) + const seenEventIds: number[] = [] + + const stream = await openSessionEventStream(options(fetchFn), { + afterEventId: 0, + executionId: 'exec-1', + onEventId: eventId => seenEventIds.push(eventId), + threadId: 'slack:C1:1700000000.000100' + }) + const events = [] + for await (const event of stream) events.push(event) + + expect(events[0]).toEqual({ + data: { summary: 'The agent is reading App Server events.' }, + event: 'session.activity_summary', + eventId: 1, + eventKind: 'session.activity_summary' + }) + expect(events[1]).toMatchObject({ + event: 'session.execution_completed', + eventId: 2, + eventKind: 'session.execution_completed' + }) + expect(seenEventIds).toEqual([1, 2]) + }) +}) + describe('Slack display text fallback', () => { test('serializeMessage extracts raw Slack blocks when adapter text is empty', async () => { const raw = { From c439443a079ec4a04ae1deb89e5458097a7036b7 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:38:16 +0300 Subject: [PATCH 020/198] feat: add sandbox capacity manager (#812) --- contrib/chart/templates/apirs.yaml | 4 + contrib/chart/values.schema.json | 2 + contrib/chart/values.yaml | 5 + docs/pages/reference/configuration.mdx | 1 + services/api-rs/Cargo.lock | 2 + .../crates/centaur-api-server/src/args.rs | 33 +- .../crates/centaur-api-server/src/main.rs | 4 + .../crates/centaur-sandbox-manager/Cargo.toml | 1 + .../centaur-sandbox-manager/src/warm_pool.rs | 129 ++++ .../crates/centaur-session-core/src/lib.rs | 4 + .../crates/centaur-session-runtime/Cargo.toml | 1 + .../crates/centaur-session-runtime/src/lib.rs | 633 +++++++++++++++++- .../0033_session_sandbox_activity.sql | 21 + .../crates/centaur-session-sqlx/src/lib.rs | 267 +++++++- 14 files changed, 1093 insertions(+), 14 deletions(-) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0033_session_sandbox_activity.sql diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 5bd82afdd..6ac5d3c27 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -242,6 +242,10 @@ spec: value: {{ .Values.apiRs.sandboxWarmPoolSize | quote }} - name: SESSION_SANDBOX_WARM_POOL_REPLENISH_INTERVAL_SECS value: {{ .Values.apiRs.sandboxWarmPoolReplenishIntervalSecs | quote }} + - name: SESSION_SANDBOX_RUNNING_LIMIT + value: {{ .Values.apiRs.sandboxRunningLimit | quote }} + - name: SESSION_SANDBOX_HOT_IDLE_GRACE_SECS + value: {{ .Values.apiRs.sandboxHotIdleGraceSecs | quote }} - name: SESSION_SANDBOX_MAX_LIFETIME_SECS value: {{ .Values.apiRs.sandboxMaxLifetimeSecs | quote }} - name: SESSION_SANDBOX_REAP_INTERVAL_SECS diff --git a/contrib/chart/values.schema.json b/contrib/chart/values.schema.json index 0507439d1..8c5360447 100644 --- a/contrib/chart/values.schema.json +++ b/contrib/chart/values.schema.json @@ -243,6 +243,8 @@ "type": "object", "properties": { "syncInfraSecrets": { "type": "boolean" }, + "sandboxRunningLimit": { "type": "integer", "minimum": 0 }, + "sandboxHotIdleGraceSecs": { "type": "integer", "minimum": 0 }, "etl": { "type": "object", "properties": { diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index f9fb9711d..fc6a7a241 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -299,6 +299,11 @@ apiRs: sandboxReadyTimeoutSecs: 90 sandboxWarmPoolSize: 3 sandboxWarmPoolReplenishIntervalSecs: 5 + # Capacity manager: when nonzero, api-rs keeps observed running-like + # sandboxes at or below this limit by discarding ready warm sandboxes first, + # then pausing least-recently-active idle session sandboxes. 0 disables. + sandboxRunningLimit: 0 + sandboxHotIdleGraceSecs: 300 # When true, api-rs upserts the shared iron-control infra role and its # backing secrets at startup and on tool-secret reconciliation intervals. # Set false when multiple Centaur instances share one console/1Password diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index 798b7d28b..b546e4461 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -178,6 +178,7 @@ Kubernetes backend: | `KUBERNETES_SANDBOX_RUNTIME_CLASS_NAME`, `KUBERNETES_SANDBOX_SERVICE_ACCOUNT_NAME` | `sandbox.runtimeClassName`, `api.extraEnv`. | Pod runtime class and service account. | | `KUBERNETES_SANDBOX_CPU_LIMIT`, `KUBERNETES_SANDBOX_MEMORY_LIMIT`, `KUBERNETES_SANDBOX_CPU_REQUEST`, `KUBERNETES_SANDBOX_MEMORY_REQUEST` | `sandbox.resources.*`. | Sandbox pod resources. | | `KUBERNETES_SANDBOX_READY_TIMEOUT_S`, `KUBERNETES_ATTACH_LOG_TAIL_LINES` | `api.extraEnv`. | Sandbox readiness and attach diagnostics. | +| `SESSION_SANDBOX_RUNNING_LIMIT`, `SESSION_SANDBOX_HOT_IDLE_GRACE_SECS` | `apiRs.sandboxRunningLimit`, `apiRs.sandboxHotIdleGraceSecs`. | Capacity admission for running-like sandboxes; discards ready warm sandboxes first, then pauses least-recently-active idle sessions outside the grace window. | | `SESSION_SANDBOX_CLEANUP_INTERVAL_SECS`, `SESSION_SANDBOX_IDLE_CLEANUP_BACKSTOP_SECS` | `apiRs.sandboxCleanupIntervalSecs`, `apiRs.sandboxIdleCleanupBackstopSecs`. | DB-aware cleanup of unreferenced sandboxes and restart recovery for idle pauses. Persisted `idle_timeout_ms` is honored after restart; the backstop is the fallback for older execution rows without that metadata. | | `KUBERNETES_SANDBOX_EXTRA_ENV` | `sandbox.extraEnv`. | JSON list copied into each sandbox. | | `KUBERNETES_WORKFLOW_DIRS` | Chart-rendered from `overlays.sources[*].workflowsSubdir` (default `workflows`) using the sandbox repo-cache mount prefix. | Workflow-host sandbox discovery paths. | diff --git a/services/api-rs/Cargo.lock b/services/api-rs/Cargo.lock index d3440de4b..8f80ffd79 100644 --- a/services/api-rs/Cargo.lock +++ b/services/api-rs/Cargo.lock @@ -936,6 +936,7 @@ dependencies = [ "centaur-sandbox-core", "centaur-session-sqlx", "centaur-telemetry", + "sqlx", "thiserror", "tokio", "tracing", @@ -985,6 +986,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sqlx", "thiserror", "time", "tokio", diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index c253c9fb4..502951802 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -27,7 +27,9 @@ use centaur_sandbox_core::{Mount, MountKind, SandboxSpec}; use centaur_sandbox_local::LocalSandboxBackend; use centaur_sandbox_manager::{SandboxReaperConfig, WarmPoolConfig}; use centaur_session_core::HarnessType; -use centaur_session_runtime::{PersonaRegistry, SandboxWorkloadMode, SessionSandboxCleanupConfig}; +use centaur_session_runtime::{ + PersonaRegistry, SandboxCapacityConfig, SandboxWorkloadMode, SessionSandboxCleanupConfig, +}; use centaur_workflows::WorkflowHostSandboxRuntime; use clap::{Args as ClapArgs, Parser, ValueEnum}; use tracing::{error, info, warn}; @@ -90,6 +92,10 @@ impl Args { self.sandbox.warm_pool_config() } + pub(crate) fn sandbox_capacity_config(&self) -> Option { + self.sandbox.sandbox_capacity_config() + } + pub(crate) fn sandbox_reaper_config(&self) -> SandboxReaperConfig { self.sandbox.sandbox_reaper_config() } @@ -612,6 +618,22 @@ struct SandboxArgs { value_parser = clap::value_parser!(u64).range(1..) )] warm_pool_replenish_interval_secs: u64, + /// Hard cap on observed running-like sandboxes. 0 disables capacity + /// admission. + #[arg( + long = "session-sandbox-running-limit", + env = "SESSION_SANDBOX_RUNNING_LIMIT", + default_value_t = 0 + )] + sandbox_running_limit: usize, + /// Do not evict assigned idle sandboxes that were active within this + /// window. Warm sandboxes can still be discarded first. + #[arg( + long = "session-sandbox-hot-idle-grace-secs", + env = "SESSION_SANDBOX_HOT_IDLE_GRACE_SECS", + default_value_t = 300 + )] + sandbox_hot_idle_grace_secs: u64, /// Stop any sandbox older than this regardless of status; sessions replace /// reaped sandboxes on their next message. 0 disables the max-lifetime /// sweep. @@ -1293,6 +1315,15 @@ impl SandboxArgs { target_size: self.warm_pool_size, replenish_interval: Duration::from_secs(self.warm_pool_replenish_interval_secs), bootstrap_iron_control_principal: None, + max_running_sandboxes: (self.sandbox_running_limit > 0) + .then_some(self.sandbox_running_limit), + }) + } + + fn sandbox_capacity_config(&self) -> Option { + (self.sandbox_running_limit > 0).then(|| SandboxCapacityConfig { + max_running: self.sandbox_running_limit, + hot_idle_grace: Duration::from_secs(self.sandbox_hot_idle_grace_secs), }) } diff --git a/services/api-rs/crates/centaur-api-server/src/main.rs b/services/api-rs/crates/centaur-api-server/src/main.rs index 0c2d04a41..14ced7b59 100644 --- a/services/api-rs/crates/centaur-api-server/src/main.rs +++ b/services/api-rs/crates/centaur-api-server/src/main.rs @@ -80,6 +80,10 @@ async fn initialize_runtime(args: Args, app_state: AppState) -> Result<(), Serve tokio::spawn(reconciler.run()); } runtime = runtime.with_personas(args.persona_registry()?); + let sandbox_capacity_config = args.sandbox_capacity_config(); + if let Some(config) = sandbox_capacity_config { + runtime = runtime.with_sandbox_capacity(config); + } if let Some(mut config) = args.warm_pool_config() { config.bootstrap_iron_control_principal = warm_pool_bootstrap_principal.clone(); runtime = runtime.with_warm_pool(config); diff --git a/services/api-rs/crates/centaur-sandbox-manager/Cargo.toml b/services/api-rs/crates/centaur-sandbox-manager/Cargo.toml index 9849842b9..1e33c727b 100644 --- a/services/api-rs/crates/centaur-sandbox-manager/Cargo.toml +++ b/services/api-rs/crates/centaur-sandbox-manager/Cargo.toml @@ -15,6 +15,7 @@ tracing.workspace = true [dev-dependencies] async-trait.workspace = true +sqlx.workspace = true tokio = { version = "1", features = ["macros", "rt", "sync"] } [lints] diff --git a/services/api-rs/crates/centaur-sandbox-manager/src/warm_pool.rs b/services/api-rs/crates/centaur-sandbox-manager/src/warm_pool.rs index ee90d39b4..f058b846a 100644 --- a/services/api-rs/crates/centaur-sandbox-manager/src/warm_pool.rs +++ b/services/api-rs/crates/centaur-sandbox-manager/src/warm_pool.rs @@ -9,11 +9,13 @@ use tracing::warn; use crate::SandboxManager; pub type WarmSandboxSpecFactory = Arc SandboxSpec + Send + Sync>; +const STALE_EVICTING_WARM_SANDBOX_AGE: Duration = Duration::from_secs(300); pub struct WarmPoolConfig { pub target_size: usize, pub replenish_interval: Duration, pub bootstrap_iron_control_principal: Option, + pub max_running_sandboxes: Option, } pub struct WarmPoolManager { @@ -116,6 +118,7 @@ impl WarmPoolManager { async fn replenish_once(&self) -> Result<(), WarmPoolError> { self.prune_stale_ready_sandboxes().await?; + self.prune_stale_evicting_sandboxes().await?; let needed = self.config.target_size.saturating_sub( self.store @@ -123,6 +126,7 @@ impl WarmPoolManager { .await? .max(0) as usize, ); + let needed = needed.min(self.available_running_slots().await?); for _ in 0..needed { let mut spec = (self.spec_factory)(); @@ -163,6 +167,65 @@ impl WarmPoolManager { } Ok(()) } + + async fn prune_stale_evicting_sandboxes(&self) -> Result<(), WarmPoolError> { + for sandbox_id in self + .store + .list_stale_evicting_warm_sandbox_ids(STALE_EVICTING_WARM_SANDBOX_AGE) + .await? + { + let id = SandboxId::new(sandbox_id.as_str()); + let failure = match self.manager.status(&id).await { + Ok(status) if status_consumes_running_slot(&status) => { + match self.manager.stop(&id).await { + Ok(()) | Err(SandboxError::NotFound(_)) => { + "stale evicting warm sandbox stopped".to_owned() + } + Err(error) => { + let error_message = error.to_string(); + warn!(%sandbox_id, error = %error_message); + return Err(WarmPoolError::Sandbox(error)); + } + } + } + Ok(status) => format!("stale evicting warm sandbox was not running: {status:?}"), + Err(SandboxError::NotFound(_)) => { + "stale evicting warm sandbox was not found".to_owned() + } + Err(error) => { + let error_message = error.to_string(); + warn!(%sandbox_id, error = %error_message); + return Err(WarmPoolError::Sandbox(error)); + } + }; + warn!(%sandbox_id, reason = %failure, "marking stale evicting warm sandbox failed"); + self.store + .mark_warm_sandbox_failed(&sandbox_id, &failure) + .await?; + } + Ok(()) + } + + async fn available_running_slots(&self) -> Result { + let Some(max_running) = self.config.max_running_sandboxes else { + return Ok(usize::MAX); + }; + let running = self + .manager + .list_observed() + .await? + .into_iter() + .filter(|observed| status_consumes_running_slot(&observed.status)) + .count(); + Ok(max_running.saturating_sub(running)) + } +} + +fn status_consumes_running_slot(status: &SandboxStatus) -> bool { + matches!( + status, + SandboxStatus::Created | SandboxStatus::Running | SandboxStatus::Unknown(_) + ) } #[derive(Debug, Error)] @@ -234,6 +297,7 @@ mod tests { target_size: 1, replenish_interval: Duration::from_secs(60), bootstrap_iron_control_principal: None, + max_running_sandboxes: None, }, ); @@ -263,6 +327,64 @@ mod tests { ); } + #[tokio::test] + async fn replenisher_prunes_stale_evicting_rows() { + let Some(store) = test_store().await else { + return; + }; + let suffix = unique_suffix(); + let workload_key = format!("test-evicting-{suffix}"); + let stale_sandbox = format!("stale-evicting-{suffix}"); + + store + .insert_ready_warm_sandbox(&stale_sandbox, &workload_key) + .await + .expect("insert stale evicting warm sandbox row"); + sqlx::query( + r#" + update session_warm_sandboxes + set status = 'evicting', updated_at = now() - interval '10 minutes' + where sandbox_id = $1 + "#, + ) + .bind(&stale_sandbox) + .execute(store.pool()) + .await + .expect("make warm sandbox eviction stale"); + + let backend = Arc::new(TestBackend::new(format!("fresh-{suffix}"))); + backend.set_status(&stale_sandbox, SandboxStatus::Running); + let pool = WarmPoolManager::new( + Arc::new(SandboxManager::new(backend.clone())), + store.clone(), + Arc::new(|| SandboxSpec::new("image")), + workload_key.clone(), + WarmPoolConfig { + target_size: 0, + replenish_interval: Duration::from_secs(60), + bootstrap_iron_control_principal: None, + max_running_sandboxes: None, + }, + ); + + pool.replenish_once().await.expect("replenish warm pool"); + + assert_eq!( + backend + .status(&SandboxId::new(&stale_sandbox)) + .await + .unwrap(), + SandboxStatus::Stopped + ); + assert!( + !store + .list_referenced_sandbox_ids() + .await + .expect("list referenced sandboxes") + .contains(&stale_sandbox) + ); + } + async fn test_store() -> Option { let Ok(url) = std::env::var("SESSION_RUNTIME_TEST_DATABASE_URL") else { eprintln!("skipping: SESSION_RUNTIME_TEST_DATABASE_URL not set"); @@ -301,6 +423,13 @@ mod tests { fn created(&self) -> Vec { self.created.lock().unwrap().clone() } + + fn set_status(&self, sandbox_id: &str, status: SandboxStatus) { + self.statuses + .lock() + .unwrap() + .insert(sandbox_id.to_owned(), status); + } } #[async_trait] diff --git a/services/api-rs/crates/centaur-session-core/src/lib.rs b/services/api-rs/crates/centaur-session-core/src/lib.rs index cb11387a1..ee8b1b90a 100644 --- a/services/api-rs/crates/centaur-session-core/src/lib.rs +++ b/services/api-rs/crates/centaur-session-core/src/lib.rs @@ -180,6 +180,10 @@ pub struct Session { /// iron-control principal OID this session's egress proxy binds to, /// captured at registration so a resumed session can recreate its sandbox. pub iron_control_principal: Option, + /// Last meaningful activity for the currently assigned sandbox. This is + /// the eviction signal for capacity pressure and intentionally separate + /// from `updated_at`, which also changes for metadata/status writes. + pub sandbox_last_active_at: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } diff --git a/services/api-rs/crates/centaur-session-runtime/Cargo.toml b/services/api-rs/crates/centaur-session-runtime/Cargo.toml index 44625f201..b12efc59a 100644 --- a/services/api-rs/crates/centaur-session-runtime/Cargo.toml +++ b/services/api-rs/crates/centaur-session-runtime/Cargo.toml @@ -26,6 +26,7 @@ uuid.workspace = true [dev-dependencies] async-trait.workspace = true +sqlx.workspace = true time.workspace = true tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread", "sync", "time"] } uuid.workspace = true diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index a7d09f2ea..7757bc488 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -22,7 +22,8 @@ use centaur_session_core::{ Session, SessionEvent, SessionExecution, SessionMessageInput, ThreadKey, }; use centaur_session_sqlx::{ - PgSessionStore, SessionEventListener, SessionStoreError, default_metadata, + PgSessionStore, SandboxCapacityCandidate, SessionEventListener, SessionStoreError, + default_metadata, }; use centaur_telemetry::{ export_thread_trace_root_span, record_sandbox_warm_pool_claim, @@ -87,6 +88,19 @@ pub struct SessionRuntime { session_title_generator: Option, session_title_in_flight: SessionTitleThreadSet, session_title_rerun_requested: SessionTitleThreadSet, + capacity: Option>, +} + +#[derive(Clone, Copy, Debug)] +pub struct SandboxCapacityConfig { + pub max_running: usize, + pub hot_idle_grace: Duration, +} + +impl SandboxCapacityConfig { + pub fn is_enabled(&self) -> bool { + self.max_running > 0 + } } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -281,6 +295,328 @@ struct RuntimeContext { execution_spans: ExecutionSpanRegistry, } +struct SandboxCapacityController { + store: PgSessionStore, + manager: Arc, + sandbox_pipes: SessionPipeMap, + lock: Mutex<()>, + config: SandboxCapacityConfig, +} + +impl SandboxCapacityController { + fn new( + store: PgSessionStore, + manager: Arc, + sandbox_pipes: SessionPipeMap, + config: SandboxCapacityConfig, + ) -> Self { + Self { + store, + manager, + sandbox_pipes, + lock: Mutex::new(()), + config, + } + } + + async fn run_with_capacity( + &self, + protected_thread_key: &ThreadKey, + trigger_execution_id: &str, + operation: &'static str, + action: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let _guard = self.lock.lock().await; + self.ensure_running_slot(protected_thread_key, trigger_execution_id, operation) + .await?; + action().await + } + + async fn ensure_running_slot( + &self, + protected_thread_key: &ThreadKey, + trigger_execution_id: &str, + operation: &'static str, + ) -> Result<(), SessionRuntimeError> { + let running = self.running_slot_count().await?; + if running < self.config.max_running { + return Ok(()); + } + + let mut slots_needed = running.saturating_sub(self.config.max_running) + 1; + let mut stopped_warm = 0usize; + let mut paused_idle = 0usize; + let mut stale_candidates_reconciled = 0usize; + + for sandbox_id in self + .store + .reserve_ready_warm_sandboxes_for_eviction(candidate_fetch_limit(slots_needed)) + .await? + { + if slots_needed == 0 { + break; + } + let id = SandboxId::new(sandbox_id.as_str()); + match self.manager.status(&id).await { + Ok(status) if status_consumes_running_slot(&status) => {} + Ok(_) | Err(SandboxError::NotFound(_)) => { + let _ = self + .store + .mark_warm_sandbox_failed( + sandbox_id.as_str(), + "not running during sandbox capacity admission", + ) + .await; + continue; + } + Err(error) => { + let failure = + format!("status failed during sandbox capacity admission: {error}"); + let _ = self + .store + .mark_warm_sandbox_failed(sandbox_id.as_str(), &failure) + .await; + return Err(SessionRuntimeError::Sandbox(error)); + } + } + + match self.manager.stop(&id).await { + Ok(()) | Err(SandboxError::NotFound(_)) => { + stopped_warm += 1; + slots_needed -= 1; + let _ = self + .store + .mark_warm_sandbox_failed( + sandbox_id.as_str(), + "stopped for sandbox capacity pressure", + ) + .await; + info!( + component = COMPONENT_SESSION_RUNTIME, + event = "sandbox_capacity_warm_stopped", + sandbox_id, + trigger_thread_key = %protected_thread_key, + trigger_execution_id, + operation, + max_running = self.config.max_running, + "stopped warm sandbox for capacity" + ); + } + Err(error) => { + let failure = format!("stop failed during sandbox capacity admission: {error}"); + let _ = self + .store + .mark_warm_sandbox_failed(sandbox_id.as_str(), &failure) + .await; + return Err(SessionRuntimeError::Sandbox(error)); + } + } + } + + if slots_needed > 0 { + loop { + let candidates = self + .store + .list_sandbox_capacity_candidates( + Some(protected_thread_key), + self.config.hot_idle_grace, + candidate_fetch_limit(slots_needed), + ) + .await?; + if candidates.is_empty() { + break; + } + + let mut made_progress = false; + for candidate in candidates { + if slots_needed == 0 { + break; + } + match self + .pause_capacity_candidate( + &candidate, + protected_thread_key, + trigger_execution_id, + operation, + ) + .await? + { + CapacityCandidateAction::Paused => { + paused_idle += 1; + slots_needed -= 1; + made_progress = true; + } + CapacityCandidateAction::ReconciledStale => { + stale_candidates_reconciled += 1; + made_progress = true; + } + CapacityCandidateAction::Skipped => {} + } + } + + if slots_needed == 0 || !made_progress { + break; + } + } + } + + if slots_needed == 0 { + info!( + component = COMPONENT_SESSION_RUNTIME, + event = "sandbox_capacity_admitted", + trigger_thread_key = %protected_thread_key, + trigger_execution_id, + operation, + running_before = running, + max_running = self.config.max_running, + stopped_warm, + paused_idle, + stale_candidates_reconciled, + "admitted sandbox operation under capacity pressure" + ); + return Ok(()); + } + + Err(SessionRuntimeError::CapacityExceeded { + max_running: self.config.max_running, + running, + operation, + }) + } + + async fn pause_capacity_candidate( + &self, + candidate: &SandboxCapacityCandidate, + protected_thread_key: &ThreadKey, + trigger_execution_id: &str, + operation: &'static str, + ) -> Result { + let id = SandboxId::new(candidate.sandbox_id.as_str()); + match self.manager.status(&id).await { + Ok(SandboxStatus::Running | SandboxStatus::Created | SandboxStatus::Unknown(_)) => {} + Ok(SandboxStatus::Suspended) => { + return Ok(CapacityCandidateAction::Skipped); + } + Ok(SandboxStatus::Stopped | SandboxStatus::Gone) => { + return self.reconcile_stale_capacity_candidate(candidate).await; + } + Err(SandboxError::NotFound(_)) => { + return self.reconcile_stale_capacity_candidate(candidate).await; + } + Err(error) => return Err(SessionRuntimeError::Sandbox(error)), + } + + self.sandbox_pipes.remove(candidate.sandbox_id.as_str()); + match self.manager.pause(&id).await { + Ok(()) => { + self.store + .append_event( + &candidate.thread_key, + candidate.latest_execution_id.as_deref(), + "session.sandbox_paused", + json!({ + "thread_key": candidate.thread_key.as_str(), + "sandbox_id": candidate.sandbox_id.as_str(), + "reason": "capacity_pressure", + "trigger_thread_key": protected_thread_key.as_str(), + "trigger_execution_id": trigger_execution_id, + "operation": operation, + "last_active_at": candidate.last_active_at, + "hot_idle_grace_ms": duration_millis_u64(self.config.hot_idle_grace), + "max_running": self.config.max_running, + }), + ) + .await?; + info!( + component = COMPONENT_SESSION_RUNTIME, + event = "sandbox_capacity_idle_paused", + thread_key = %candidate.thread_key, + sandbox_id = %candidate.sandbox_id, + trigger_thread_key = %protected_thread_key, + trigger_execution_id, + operation, + last_active_at = %candidate.last_active_at, + max_running = self.config.max_running, + "paused idle sandbox for capacity" + ); + Ok(CapacityCandidateAction::Paused) + } + Err(error) => { + self.store + .append_event( + &candidate.thread_key, + candidate.latest_execution_id.as_deref(), + "session.sandbox_pause_failed", + json!({ + "thread_key": candidate.thread_key.as_str(), + "sandbox_id": candidate.sandbox_id.as_str(), + "reason": "capacity_pressure", + "trigger_thread_key": protected_thread_key.as_str(), + "trigger_execution_id": trigger_execution_id, + "operation": operation, + "error": error.to_string(), + }), + ) + .await?; + Err(SessionRuntimeError::Sandbox(error)) + } + } + } + + async fn reconcile_stale_capacity_candidate( + &self, + candidate: &SandboxCapacityCandidate, + ) -> Result { + let cleared = self + .store + .clear_sandbox_id_if_matches(&candidate.thread_key, candidate.sandbox_id.as_str()) + .await?; + if cleared { + info!( + component = COMPONENT_SESSION_RUNTIME, + event = "sandbox_capacity_stale_reconciled", + thread_key = %candidate.thread_key, + sandbox_id = %candidate.sandbox_id, + "cleared stale sandbox assignment during capacity admission" + ); + Ok(CapacityCandidateAction::ReconciledStale) + } else { + Ok(CapacityCandidateAction::Skipped) + } + } + + async fn running_slot_count(&self) -> Result { + Ok(self + .manager + .list_observed() + .await? + .into_iter() + .filter(|observed| status_consumes_running_slot(&observed.status)) + .count()) + } +} + +enum CapacityCandidateAction { + Paused, + ReconciledStale, + Skipped, +} + +fn candidate_fetch_limit(slots_needed: usize) -> i64 { + slots_needed.saturating_mul(4).clamp(16, 1000) as i64 +} + +fn status_consumes_running_slot(status: &SandboxStatus) -> bool { + matches!( + status, + SandboxStatus::Created | SandboxStatus::Running | SandboxStatus::Unknown(_) + ) +} + struct EventStreamState { store: PgSessionStore, thread_key: ThreadKey, @@ -335,6 +671,7 @@ impl SessionRuntime { session_title_generator: None, session_title_in_flight: Arc::new(DashSet::new()), session_title_rerun_requested: Arc::new(DashSet::new()), + capacity: None, } } @@ -469,6 +806,39 @@ impl SessionRuntime { self } + pub fn with_sandbox_capacity(mut self, config: SandboxCapacityConfig) -> Self { + if !config.is_enabled() { + return self; + } + self.capacity = Some(Arc::new(SandboxCapacityController::new( + self.store.clone(), + self.sandbox_runtime.manager.clone(), + self.sandbox_pipes.clone(), + config, + ))); + self + } + + async fn run_with_running_capacity( + &self, + thread_key: &ThreadKey, + execution_id: &str, + operation: &'static str, + action: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + if let Some(capacity) = self.capacity.as_ref() { + capacity + .run_with_capacity(thread_key, execution_id, operation, action) + .await + } else { + action().await + } + } + /// Spawn the background reaper that stops sandboxes whose total lifetime /// expired. No-op when max-lifetime reaping is disabled. pub fn with_sandbox_reaper(self, config: SandboxReaperConfig) -> Self { @@ -726,6 +1096,15 @@ impl SessionRuntime { "appending session messages" ); let message_ids = self.store.append_messages(thread_key, messages).await?; + if let Err(error) = self.store.touch_session_sandbox_activity(thread_key).await { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_sandbox_activity_touch_failed", + thread_key = %thread_key, + %error, + "failed to touch sandbox activity after message append" + ); + } info!( component = COMPONENT_SESSION_RUNTIME, event = "session_messages_append_completed", @@ -1508,7 +1887,22 @@ impl SessionRuntime { } ExistingSandboxAction::ResumeOrReplace => { self.sandbox_pipes.remove(sandbox_id); - match self.sandbox_runtime.manager.resume(&id).await { + let resume_id = id.clone(); + match self + .run_with_running_capacity( + thread_key, + execution_id, + "resume", + || async { + self.sandbox_runtime + .manager + .resume(&resume_id) + .await + .map_err(SessionRuntimeError::Sandbox) + }, + ) + .await + { Ok(()) => { span.record("centaur.sandbox_id", sandbox_id); span.record("sandbox_id", sandbox_id); @@ -1548,7 +1942,7 @@ impl SessionRuntime { ); return Ok(sandbox_id.to_owned()); } - Err(error) => { + Err(SessionRuntimeError::Sandbox(error)) => { warn!( component = COMPONENT_SESSION_RUNTIME, event = "sandbox_ensure_resume_failed", @@ -1572,6 +1966,7 @@ impl SessionRuntime { ) .await?; } + Err(error) => return Err(error), } } ExistingSandboxAction::Replace => { @@ -1699,7 +2094,15 @@ impl SessionRuntime { } apply_sandbox_capabilities(&mut spec, desired_capabilities); let create_started = Instant::now(); - let handle = self.sandbox_runtime.manager.create_running(spec).await?; + let handle = self + .run_with_running_capacity(thread_key, execution_id, "cold_create", || async { + self.sandbox_runtime + .manager + .create_running(spec) + .await + .map_err(SessionRuntimeError::Sandbox) + }) + .await?; let startup_duration = create_started.elapsed(); let ready_duration = ensure_started.elapsed(); span.record("centaur.sandbox_id", handle.id.as_str()); @@ -1779,6 +2182,22 @@ impl SessionRuntime { let startup_duration_ms = startup_duration.map(duration_millis_u64).unwrap_or(0); let sandbox_started_for_request = startup_duration.is_some(); + if let Err(error) = self + .store + .touch_sandbox_activity(thread_key, sandbox_id) + .await + { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_sandbox_activity_touch_failed", + thread_key = %thread_key, + execution_id, + sandbox_id, + %error, + "failed to touch sandbox activity after sandbox ready" + ); + } + if let Err(error) = self .store .append_event( @@ -3801,6 +4220,21 @@ async fn record_terminal_output( } }; ctx.execution_spans.lock().await.remove(execution_id); + if let Err(error) = ctx + .store + .touch_sandbox_activity(thread_key, sandbox_id) + .await + { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_sandbox_activity_touch_failed", + thread_key = %thread_key, + execution_id, + sandbox_id, + %error, + "failed to touch sandbox activity after terminal output" + ); + } record_finished_execution_metric( &ctx.store, thread_key, @@ -3861,6 +4295,16 @@ async fn record_max_duration_failure( return Ok(()); }; ctx.execution_spans.lock().await.remove(execution_id); + if let Err(error) = ctx.store.touch_session_sandbox_activity(thread_key).await { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_sandbox_activity_touch_failed", + thread_key = %thread_key, + execution_id, + %error, + "failed to touch sandbox activity after max duration" + ); + } ctx.store .append_event( thread_key, @@ -4188,6 +4632,7 @@ fn runtime_error_failure_class(error: &SessionRuntimeError) -> &'static str { SessionRuntimeError::Sandbox(SandboxError::InvalidSpec(_)) => "sandbox_invalid_spec", SessionRuntimeError::IronControl(_) => "iron_control", SessionRuntimeError::WarmPool(_) => "warm_pool", + SessionRuntimeError::CapacityExceeded { .. } => "capacity", } } @@ -4998,6 +5443,14 @@ pub enum SessionRuntimeError { IronControl(#[from] centaur_iron_control::IronControlError), #[error(transparent)] WarmPool(#[from] WarmPoolError), + #[error( + "sandbox running capacity exceeded during {operation}: running={running}, max_running={max_running}" + )] + CapacityExceeded { + max_running: usize, + running: usize, + operation: &'static str, + }, } #[cfg(test)] @@ -5928,6 +6381,7 @@ mod tests { persona_id: None, status: SessionStatus::Idle, iron_control_principal: None, + sandbox_last_active_at: Some(now), created_at: now, updated_at: now, } @@ -5980,7 +6434,7 @@ mod tests { #[cfg(test)] mod adoption_tests { use std::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, sync::atomic::{AtomicBool, AtomicUsize, Ordering}, }; @@ -5999,6 +6453,7 @@ mod adoption_tests { recorded_output: std::sync::Mutex>, open_count: AtomicUsize, status: std::sync::Mutex, + observed_statuses: std::sync::Mutex>, create_id: String, created_specs: std::sync::Mutex>, resume_fails: AtomicBool, @@ -6014,6 +6469,7 @@ mod adoption_tests { recorded_output: std::sync::Mutex::new(recorded_output), open_count: AtomicUsize::new(0), status: std::sync::Mutex::new(status), + observed_statuses: std::sync::Mutex::new(BTreeMap::new()), create_id: "mock-sbx".to_owned(), created_specs: std::sync::Mutex::new(Vec::new()), resume_fails: AtomicBool::new(false), @@ -6039,6 +6495,21 @@ mod adoption_tests { *self.status.lock().unwrap() = status; } + fn set_observed_status(&self, sandbox_id: &str, status: SandboxStatus) { + self.observed_statuses + .lock() + .unwrap() + .insert(sandbox_id.to_owned(), status); + } + + fn status_of(&self, sandbox_id: &str) -> Option { + self.observed_statuses + .lock() + .unwrap() + .get(sandbox_id) + .cloned() + } + fn fail_resume(&self) { self.resume_fails.store(true, Ordering::SeqCst); } @@ -6071,6 +6542,7 @@ mod adoption_tests { async fn create(&self, spec: SandboxSpec) -> SandboxResult { self.created_specs.lock().unwrap().push(spec); + self.set_observed_status(&self.create_id, SandboxStatus::Running); Ok(SandboxHandle::new( SandboxId::new(self.create_id.clone()), "mock", @@ -6095,6 +6567,9 @@ mod adoption_tests { } async fn status(&self, _id: &SandboxId) -> SandboxResult { + if let Some(status) = self.status_of(_id.as_str()) { + return Ok(status); + } Ok(self.status.lock().unwrap().clone()) } @@ -6104,7 +6579,13 @@ mod adoption_tests { } async fn list_observed(&self) -> SandboxResult> { - Ok(Vec::new()) + Ok(self + .observed_statuses + .lock() + .unwrap() + .iter() + .map(|(id, status)| ObservedSandbox::new(id.as_str(), "mock", status.clone())) + .collect()) } async fn stop(&self, id: &SandboxId) -> SandboxResult<()> { @@ -6112,6 +6593,7 @@ mod adoption_tests { return Err(SandboxError::NotFound(id.as_str().to_owned())); } self.stopped.lock().unwrap().push(id.as_str().to_owned()); + self.set_observed_status(id.as_str(), SandboxStatus::Stopped); Ok(()) } @@ -6128,6 +6610,7 @@ mod adoption_tests { } async fn pause(&self, _id: &SandboxId) -> SandboxResult<()> { + self.set_observed_status(_id.as_str(), SandboxStatus::Suspended); Ok(()) } @@ -6135,6 +6618,7 @@ mod adoption_tests { if self.resume_fails.load(Ordering::SeqCst) { return Err(SandboxError::NotFound(_id.as_str().to_owned())); } + self.set_observed_status(_id.as_str(), SandboxStatus::Running); Ok(()) } } @@ -6426,6 +6910,7 @@ mod adoption_tests { target_size: 1, replenish_interval: Duration::from_secs(60), bootstrap_iron_control_principal: None, + max_running_sandboxes: None, }, )); runtime.warm_pool = Some(warm_pool); @@ -6611,6 +7096,142 @@ mod adoption_tests { ); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn capacity_pressure_pauses_oldest_idle_assigned_sandbox() { + let Some(store) = test_store().await else { + return; + }; + let _serial = TEST_LOCK.lock().await; + + let backend = Arc::new(MockBackend::new(SandboxStatus::Running, Vec::new())); + backend.set_observed_status( + "sbx-old", + SandboxStatus::Unknown("status temporarily unavailable".to_owned()), + ); + backend.set_observed_status("sbx-hot", SandboxStatus::Running); + backend.set_observed_status("sbx-stale", SandboxStatus::Gone); + backend.set_observed_status("sbx-paused", SandboxStatus::Suspended); + + let stale_thread = + ThreadKey::parse(format!("test:capacity-stale-{}", uuid::Uuid::new_v4())).unwrap(); + let paused_thread = + ThreadKey::parse(format!("test:capacity-paused-{}", uuid::Uuid::new_v4())).unwrap(); + let old_thread = + ThreadKey::parse(format!("test:capacity-old-{}", uuid::Uuid::new_v4())).unwrap(); + let hot_thread = + ThreadKey::parse(format!("test:capacity-hot-{}", uuid::Uuid::new_v4())).unwrap(); + let trigger_thread = + ThreadKey::parse(format!("test:capacity-trigger-{}", uuid::Uuid::new_v4())).unwrap(); + + store + .create_or_get_session(&stale_thread, &HarnessType::Codex, None, json!({})) + .await + .expect("create stale session"); + store + .update_sandbox_id(&stale_thread, Some("sbx-stale")) + .await + .expect("assign stale sandbox"); + store + .create_or_get_session(&paused_thread, &HarnessType::Codex, None, json!({})) + .await + .expect("create paused session"); + store + .update_sandbox_id(&paused_thread, Some("sbx-paused")) + .await + .expect("assign paused sandbox"); + store + .append_event( + &paused_thread, + None, + "session.sandbox_paused", + json!({ + "thread_key": paused_thread.as_str(), + "sandbox_id": "sbx-paused", + "reason": "capacity_pressure", + }), + ) + .await + .expect("append paused event"); + store + .create_or_get_session(&old_thread, &HarnessType::Codex, None, json!({})) + .await + .expect("create old session"); + store + .update_sandbox_id(&old_thread, Some("sbx-old")) + .await + .expect("assign old sandbox"); + store + .create_or_get_session(&hot_thread, &HarnessType::Codex, None, json!({})) + .await + .expect("create hot session"); + store + .update_sandbox_id(&hot_thread, Some("sbx-hot")) + .await + .expect("assign hot sandbox"); + sqlx::query( + r#" + update sessions + set sandbox_last_active_at = case + when thread_key = $1 then now() - interval '3 hours' + when thread_key = $2 then now() - interval '2 hours' + when thread_key = $3 then now() - interval '1 hour' + end + where thread_key in ($1, $2, $3) + "#, + ) + .bind(stale_thread.as_str()) + .bind(paused_thread.as_str()) + .bind(old_thread.as_str()) + .execute(store.pool()) + .await + .expect("age capacity candidates"); + + let controller = SandboxCapacityController::new( + store.clone(), + Arc::new(SandboxManager::new(backend.clone())), + Arc::new(DashMap::new()), + SandboxCapacityConfig { + max_running: 2, + hot_idle_grace: Duration::from_secs(300), + }, + ); + + controller + .run_with_capacity(&trigger_thread, "exe-trigger", "cold_create", || async { + Ok(()) + }) + .await + .expect("admit under capacity"); + + assert_eq!(backend.status_of("sbx-old"), Some(SandboxStatus::Suspended)); + assert_eq!(backend.status_of("sbx-hot"), Some(SandboxStatus::Running)); + assert_eq!( + store + .get_session(&stale_thread) + .await + .expect("get stale session") + .sandbox_id, + None + ); + assert_eq!( + store + .get_session(&paused_thread) + .await + .expect("get paused session") + .sandbox_id + .as_deref(), + Some("sbx-paused") + ); + let old_events = store + .list_events_after(&old_thread, 0, None, 100) + .await + .expect("list old events"); + assert!(old_events.iter().any(|event| { + event.event_type == "session.sandbox_paused" + && event.payload.get("reason").and_then(Value::as_str) == Some("capacity_pressure") + })); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn workflow_cleanup_stops_and_clears_owned_sandbox() { let Some(store) = test_store().await else { diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0033_session_sandbox_activity.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0033_session_sandbox_activity.sql new file mode 100644 index 000000000..eac1434ca --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0033_session_sandbox_activity.sql @@ -0,0 +1,21 @@ +alter table sessions + add column if not exists sandbox_last_active_at timestamptz; + +update sessions +set sandbox_last_active_at = coalesce(sandbox_last_active_at, updated_at, created_at) +where sandbox_id is not null; + +create index if not exists sessions_sandbox_activity_idx + on sessions (sandbox_last_active_at, thread_key) + where sandbox_id is not null; + +alter table session_warm_sandboxes + drop constraint if exists session_warm_sandboxes_status_supported; + +alter table session_warm_sandboxes + add constraint session_warm_sandboxes_status_supported + check (status in ('ready', 'claimed', 'evicting', 'failed')); + +create index if not exists session_warm_sandboxes_evicting_idx + on session_warm_sandboxes (updated_at, sandbox_id) + where status = 'evicting'; diff --git a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs index 4314157d8..df233f216 100644 --- a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs +++ b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs @@ -45,6 +45,14 @@ pub struct IdleSandboxCandidate { pub idle_timeout: Duration, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SandboxCapacityCandidate { + pub thread_key: ThreadKey, + pub sandbox_id: String, + pub latest_execution_id: Option, + pub last_active_at: OffsetDateTime, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct WorkflowOwnedSandbox { pub thread_key: ThreadKey, @@ -127,7 +135,7 @@ impl PgSessionStore { pub async fn get_session(&self, thread_key: &ThreadKey) -> Result { let row = sqlx::query_as::<_, SessionRow>( r#" - select thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + select thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at from sessions where thread_key = $1 "#, @@ -606,7 +614,7 @@ impl PgSessionStore { select sandbox_id from session_warm_sandboxes - where status in ('ready', 'claimed') + where status in ('ready', 'claimed', 'evicting') "#, ) .fetch_all(&self.pool) @@ -660,6 +668,78 @@ impl PgSessionStore { .collect() } + pub async fn list_sandbox_capacity_candidates( + &self, + excluded_thread_key: Option<&ThreadKey>, + hot_idle_grace: std::time::Duration, + limit: i64, + ) -> Result, SessionStoreError> { + let rows = sqlx::query_as::<_, SandboxCapacityCandidateRow>( + r#" + with latest as ( + select distinct on (thread_key) + execution_id, + thread_key, + completed_at + from session_executions + order by thread_key, created_at desc, execution_id desc + ) + select + s.thread_key, + s.sandbox_id as sandbox_id, + latest.execution_id as latest_execution_id, + coalesce( + s.sandbox_last_active_at, + latest.completed_at, + s.updated_at, + s.created_at + ) as last_active_at + from sessions s + left join latest on latest.thread_key = s.thread_key + where s.sandbox_id is not null + and ($1::text is null or s.thread_key != $1) + and not exists ( + select 1 + from lateral ( + select e.event_type + from session_events e + where e.thread_key = s.thread_key + and e.payload->>'sandbox_id' = s.sandbox_id + and e.event_type in ( + 'session.sandbox_paused', + 'session.sandbox_ready', + 'session.sandbox_resumed' + ) + order by e.created_at desc, e.event_id desc + limit 1 + ) latest_sandbox_event + where latest_sandbox_event.event_type = 'session.sandbox_paused' + ) + and coalesce( + s.sandbox_last_active_at, + latest.completed_at, + s.updated_at, + s.created_at + ) <= now() - ($2::float8 * interval '1 second') + and not exists ( + select 1 + from session_executions active + where active.thread_key = s.thread_key + and active.status in ('queued', 'running') + ) + order by last_active_at, s.thread_key + limit $3 + "#, + ) + .bind(excluded_thread_key.map(ThreadKey::as_str)) + .bind(hot_idle_grace.as_secs_f64()) + .bind(limit) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(TryInto::try_into).collect() + } + pub async fn list_workflow_owned_sandboxes( &self, workflow_run_id: &str, @@ -693,9 +773,13 @@ impl PgSessionStore { sandbox_id = $2, sandbox_repo_cache_enabled = null, sandbox_observability_enabled = null, + sandbox_last_active_at = case + when $2::text is null then null + else now() + end, updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -719,9 +803,10 @@ impl PgSessionStore { sandbox_id = $2, sandbox_repo_cache_enabled = $3, sandbox_observability_enabled = $4, + sandbox_last_active_at = now(), updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -746,6 +831,7 @@ impl PgSessionStore { sandbox_id = null, sandbox_repo_cache_enabled = null, sandbox_observability_enabled = null, + sandbox_last_active_at = null, updated_at = now() where thread_key = $1 and sandbox_id = $2 "#, @@ -774,10 +860,11 @@ impl PgSessionStore { sandbox_id = null, sandbox_repo_cache_enabled = null, sandbox_observability_enabled = null, + sandbox_last_active_at = null, status = $3, updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -802,7 +889,7 @@ impl PgSessionStore { update sessions set iron_control_principal = $2, updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -895,6 +982,54 @@ impl PgSessionStore { Ok(sandbox_id) } + pub async fn reserve_ready_warm_sandboxes_for_eviction( + &self, + limit: i64, + ) -> Result, SessionStoreError> { + let rows = sqlx::query_scalar::<_, String>( + r#" + with candidates as ( + select sandbox_id + from session_warm_sandboxes + where status = 'ready' + order by created_at, sandbox_id + for update skip locked + limit $1 + ) + update session_warm_sandboxes warm + set + status = 'evicting', + updated_at = now() + from candidates + where warm.sandbox_id = candidates.sandbox_id + returning warm.sandbox_id + "#, + ) + .bind(limit) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + pub async fn list_stale_evicting_warm_sandbox_ids( + &self, + min_age: Duration, + ) -> Result, SessionStoreError> { + let rows = sqlx::query_scalar::<_, String>( + r#" + select sandbox_id + from session_warm_sandboxes + where status = 'evicting' + and updated_at <= now() - ($1::float8 * interval '1 second') + order by updated_at, sandbox_id + "#, + ) + .bind(min_age.as_secs_f64()) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + pub async fn mark_warm_sandbox_failed( &self, sandbox_id: &str, @@ -924,7 +1059,7 @@ impl PgSessionStore { update sessions set harness_thread_id = $2, updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -935,6 +1070,44 @@ impl PgSessionStore { row.try_into() } + pub async fn touch_session_sandbox_activity( + &self, + thread_key: &ThreadKey, + ) -> Result { + let result = sqlx::query( + r#" + update sessions + set sandbox_last_active_at = now() + where thread_key = $1 and sandbox_id is not null + "#, + ) + .bind(thread_key.as_str()) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + + pub async fn touch_sandbox_activity( + &self, + thread_key: &ThreadKey, + sandbox_id: &str, + ) -> Result { + let result = sqlx::query( + r#" + update sessions + set sandbox_last_active_at = now() + where thread_key = $1 and sandbox_id = $2 + "#, + ) + .bind(thread_key.as_str()) + .bind(sandbox_id) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + async fn set_session_status( &self, thread_key: &str, @@ -1031,6 +1204,7 @@ struct SessionRow { persona_id: Option, status: String, iron_control_principal: Option, + sandbox_last_active_at: Option, created_at: OffsetDateTime, updated_at: OffsetDateTime, } @@ -1060,6 +1234,7 @@ impl TryFrom for Session { persona_id: row.persona_id, status: parse_persisted(row.status)?, iron_control_principal: row.iron_control_principal, + sandbox_last_active_at: row.sandbox_last_active_at, created_at: row.created_at, updated_at: row.updated_at, }) @@ -1158,6 +1333,27 @@ fn idle_deadline_elapsed( elapsed.whole_nanoseconds() >= idle_timeout.as_nanos() as i128 } +#[derive(Debug, FromRow)] +struct SandboxCapacityCandidateRow { + thread_key: String, + sandbox_id: String, + latest_execution_id: Option, + last_active_at: OffsetDateTime, +} + +impl TryFrom for SandboxCapacityCandidate { + type Error = SessionStoreError; + + fn try_from(row: SandboxCapacityCandidateRow) -> Result { + Ok(Self { + thread_key: parse_persisted(row.thread_key)?, + sandbox_id: row.sandbox_id, + latest_execution_id: row.latest_execution_id, + last_active_at: row.last_active_at, + }) + } +} + #[derive(Debug, FromRow)] struct WorkflowOwnedSandboxRow { thread_key: String, @@ -1425,4 +1621,61 @@ mod tests { assert_eq!(candidate.execution_id, execution_id); assert_eq!(candidate.idle_timeout, Duration::from_secs(1)); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn warm_eviction_reservation_blocks_later_claims() { + let Some(store) = test_store().await else { + return; + }; + let sandbox_id = format!("sbx-warm-evict-{}", Uuid::new_v4()); + let workload_key = format!("workload-warm-evict-{}", Uuid::new_v4()); + store + .insert_ready_warm_sandbox(&sandbox_id, &workload_key) + .await + .expect("insert warm sandbox"); + sqlx::query( + r#" + update session_warm_sandboxes + set created_at = now() - interval '100 years' + where sandbox_id = $1 + "#, + ) + .bind(&sandbox_id) + .execute(store.pool()) + .await + .expect("age warm sandbox"); + + let reserved = store + .reserve_ready_warm_sandboxes_for_eviction(1) + .await + .expect("reserve warm sandbox"); + + assert_eq!(reserved, vec![sandbox_id.clone()]); + assert_eq!( + store + .claim_ready_warm_sandbox(&workload_key, "test-thread") + .await + .expect("claim after reservation"), + None + ); + assert!( + store + .list_referenced_sandbox_ids() + .await + .expect("list referenced sandboxes") + .contains(&sandbox_id) + ); + + store + .mark_warm_sandbox_failed(&sandbox_id, "test cleanup") + .await + .expect("mark reserved warm sandbox failed"); + assert!( + !store + .list_referenced_sandbox_ids() + .await + .expect("list referenced sandboxes") + .contains(&sandbox_id) + ); + } } From 5d41ff0d013e0578310e22604bfe8f828ca69c8a Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 1 Jul 2026 12:48:48 -0700 Subject: [PATCH 021/198] fix: stop syncing tool secrets to infra role (#845) --- .../crates/centaur-api-server/src/args.rs | 212 +----------------- .../crates/centaur-api-server/src/main.rs | 4 - .../centaur-iron-control/src/registry.rs | 5 +- 3 files changed, 9 insertions(+), 212 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index 502951802..f49e91d6c 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -32,7 +32,7 @@ use centaur_session_runtime::{ }; use centaur_workflows::WorkflowHostSandboxRuntime; use clap::{Args as ClapArgs, Parser, ValueEnum}; -use tracing::{error, info, warn}; +use tracing::{info, warn}; use crate::{ ServerError, @@ -78,12 +78,6 @@ impl Args { self.sandbox.iron_control_runtime().await } - pub(crate) fn iron_control_tool_reconciler( - &self, - ) -> Result, ServerError> { - self.sandbox.iron_control_tool_reconciler() - } - pub(crate) fn persona_registry(&self) -> Result { self.sandbox.persona_registry() } @@ -124,16 +118,6 @@ pub(crate) struct IronControlRuntime { pub(crate) workflow_host_principal: String, } -pub(crate) struct IronControlToolReconciler { - client: IronControlClient, - namespace: String, - source_policy: SourcePolicy, - base_infra_fragment: ProxyFragment, - tool_dirs: Vec, - tool_git_sources: Vec, - interval: Duration, -} - #[derive(Debug, ClapArgs)] struct ActivitySummaryArgs { /// Enable API-side model summaries of durable Codex App Server activity. @@ -219,79 +203,6 @@ struct ToolGitSource { repo_cache_path: Option, } -impl IronControlToolReconciler { - pub(crate) async fn run(self) { - let mut interval = tokio::time::interval(self.interval); - // The startup path already registered once; wait a full period so this - // task only handles post-start git/volume updates. - interval.tick().await; - loop { - interval.tick().await; - if let Err(error) = self.reconcile_once().await { - error!(%error, "failed to reconcile iron-control tool secrets"); - } - } - } - - async fn reconcile_once(&self) -> Result<(), ServerError> { - let tool_dirs = self.tool_dirs()?; - let tool_fragment = self.discover_tool_proxy_fragment()?; - let mut infra = self.base_infra_fragment.clone(); - if let Some(tool_fragment) = &tool_fragment { - merge_fragment(&mut infra, tool_fragment.fragment.clone()); - } - let role_id = register_role( - &self.client, - &self.namespace, - &RoleSpec::infra(), - &infra, - &self.source_policy, - ) - .await?; - info!( - role_id, - tool_dirs = ?tool_dirs, - tool_count = tool_fragment - .as_ref() - .map_or(0, |fragment| fragment.tool_count), - secret_count = tool_fragment - .as_ref() - .map_or(0, |fragment| fragment.secret_count), - "reconciled iron-control tool secrets" - ); - Ok(()) - } - - fn discover_tool_proxy_fragment( - &self, - ) -> Result, ServerError> { - let tool_dirs = self.tool_dirs()?; - let discovered = discover_tool_proxy_fragment(&tool_dirs)?; - if discovered.secret_count == 0 { - return Ok(None); - } - Ok(Some(discovered)) - } - - fn tool_dirs(&self) -> Result, ServerError> { - if !self.tool_git_sources.is_empty() { - let mut dirs = Vec::with_capacity(self.tool_git_sources.len()); - for source in &self.tool_git_sources { - source.sync()?; - let tools_dir = source.tools_dir(); - // Skip sources without a tools tree (chart-defaulted subdirs - // make this a normal case for non-tool overlay repos). - if !tools_dir.is_dir() { - continue; - } - dirs.push(tools_dir); - } - return Ok(dirs); - } - Ok(self.tool_dirs.clone()) - } -} - impl ToolGitSource { fn from_config(tools: &ToolsConfig) -> Vec { let mut sources = vec![Self::from_source( @@ -719,12 +630,6 @@ struct SandboxArgs { kubernetes_workflow_dirs: Option, #[command(flatten)] tools_source: ToolsArgs, - #[arg( - long = "tool-proxy-reconcile-interval-secs", - env = "TOOL_PROXY_RECONCILE_INTERVAL_SECS", - default_value_t = 60 - )] - tool_proxy_reconcile_interval_secs: u64, } impl SandboxArgs { @@ -737,8 +642,7 @@ impl SandboxArgs { let namespace = self.iron_control.namespace.clone(); let role_ids = if self.iron_control_sync_infra_secrets { let policy = self.iron_proxy.source_policy(); - let tool_fragment = self.discover_tool_proxy_fragment()?; - let roles = self.iron_proxy.roles_to_register(tool_fragment.as_ref())?; + let roles = self.iron_proxy.roles_to_register()?; let mut role_ids = Vec::with_capacity(roles.len()); for (spec, fragment) in &roles { role_ids.push( @@ -792,39 +696,6 @@ impl SandboxArgs { })) } - /// Background registration for git/volume-backed tool updates. Startup - /// registration keeps the stable infra role current; re-upserting that role - /// here adds newly discovered tool secrets to principals that hold the role - /// without restarting api-rs or sandboxes. Session registration only seeds - /// this role onto brand-new principals, so operator revocations stay sticky. - fn iron_control_tool_reconciler( - &self, - ) -> Result, ServerError> { - if !self.iron_control_sync_infra_secrets { - return Ok(None); - } - let Some(client) = self.iron_control.client() else { - return Ok(None); - }; - if self.tool_proxy_reconcile_interval_secs == 0 { - return Ok(None); - } - Ok(Some(IronControlToolReconciler { - client, - namespace: self.iron_control.namespace.clone(), - source_policy: self.iron_proxy.source_policy(), - base_infra_fragment: self.iron_proxy.infra_fragment()?, - tool_dirs: self.tools.resolve_tool_dirs()?, - tool_git_sources: self - .tools_source - .to_config() - .as_ref() - .map(ToolGitSource::from_config) - .unwrap_or_default(), - interval: Duration::from_secs(self.tool_proxy_reconcile_interval_secs), - })) - } - fn persona_registry(&self) -> Result { let default_persona_id = clean_optional_value(self.default_persona.as_deref()); Ok(discover_persona_registry( @@ -1697,22 +1568,15 @@ impl IronProxyArgs { } /// The role to register in iron-control. The shared `infra` role contains - /// infra, harness, and discovered tool secrets, and every session principal - /// is granted that single role (see [`SessionRegistrar`]). - fn roles_to_register( - &self, - tool_fragment: Option<&DiscoveredToolProxyFragment>, - ) -> Result, ServerError> { - let mut infra = self.infra_fragment()?; - if let Some(tool_fragment) = tool_fragment { - merge_fragment(&mut infra, tool_fragment.fragment.clone()); - } + /// infra and harness secrets, and every session principal is granted that + /// role (see [`SessionRegistrar`]). + fn roles_to_register(&self) -> Result, ServerError> { + let infra = self.infra_fragment()?; Ok(vec![(RoleSpec::infra(), infra)]) } /// The full infra fragment: the shared infra secrets plus every available - /// harness auth fragment (also infra), selected by auth mode. Discovered - /// tool secrets are folded into the same infra role at registration time. + /// harness auth fragment (also infra), selected by auth mode. fn infra_fragment(&self) -> Result { let mut infra = infra_fragment()?; for fragment in self.harness.fragments()? { @@ -2760,62 +2624,6 @@ mod tests { ); } - #[test] - fn iron_control_registers_discovered_tool_secrets_on_infra_role() { - use centaur_iron_proxy::{Secret, SecretReplace, Transform, TransformConfig}; - - let args = Args::try_parse_from([ - "centaur-api-server", - "--database-url", - "postgres://postgres:postgres@localhost/centaur", - "--kubernetes-iron-proxy-harness-auth-mode", - "api_key", - ]) - .unwrap(); - let tool_fragment = DiscoveredToolProxyFragment { - fragment: ProxyFragment { - transforms: vec![Transform { - name: "secrets".to_owned(), - config: TransformConfig { - secrets: vec![Secret { - id: Some("TOOL_API_KEY".to_owned()), - replace: Some(SecretReplace { - proxy_value: Some("TOOL_API_KEY".to_owned()), - ..Default::default() - }), - rules: vec![serde_yaml::from_str("{host: api.tool.test}").unwrap()], - ..Default::default() - }], - ..Default::default() - }, - ..Default::default() - }], - ..Default::default() - }, - tool_count: 1, - secret_count: 1, - }; - - let roles = args - .sandbox - .iron_proxy - .roles_to_register(Some(&tool_fragment)) - .unwrap(); - - assert_eq!(roles.len(), 1); - assert_eq!(roles[0].0.foreign_id, "infra"); - assert!(roles[0].1.transforms.iter().any(|transform| { - transform.config.secrets.iter().any(|secret| { - secret.id.as_deref() == Some("TOOL_API_KEY") - && secret - .replace - .as_ref() - .and_then(|replace| replace.proxy_value.as_deref()) - == Some("TOOL_API_KEY") - }) - })); - } - #[test] fn iron_control_infra_secret_sync_can_be_disabled() { let args = Args::try_parse_from([ @@ -2832,12 +2640,6 @@ mod tests { .unwrap(); assert!(!args.sandbox.iron_control_sync_infra_secrets); - assert!( - args.sandbox - .iron_control_tool_reconciler() - .unwrap() - .is_none() - ); } #[test] diff --git a/services/api-rs/crates/centaur-api-server/src/main.rs b/services/api-rs/crates/centaur-api-server/src/main.rs index 14ced7b59..635f472df 100644 --- a/services/api-rs/crates/centaur-api-server/src/main.rs +++ b/services/api-rs/crates/centaur-api-server/src/main.rs @@ -75,10 +75,6 @@ async fn initialize_runtime(args: Args, app_state: AppState) -> Result<(), Serve workflow_host_principal = Some(iron_control.workflow_host_principal); runtime = runtime.with_iron_control(iron_control.registrar); } - if let Some(reconciler) = args.iron_control_tool_reconciler()? { - info!("iron-control tool secret reconciliation enabled"); - tokio::spawn(reconciler.run()); - } runtime = runtime.with_personas(args.persona_registry()?); let sandbox_capacity_config = args.sandbox_capacity_config(); if let Some(config) = sandbox_capacity_config { diff --git a/services/api-rs/crates/centaur-iron-control/src/registry.rs b/services/api-rs/crates/centaur-iron-control/src/registry.rs index 4a2f47d22..5127d6f9e 100644 --- a/services/api-rs/crates/centaur-iron-control/src/registry.rs +++ b/services/api-rs/crates/centaur-iron-control/src/registry.rs @@ -4,9 +4,8 @@ //! Today the proxy config is rendered from fragments and baked into a //! per-sandbox ConfigMap. Under iron-control the same fragments become durable //! control-plane state: each fragment's secrets are upserted as typed secret -//! resources and granted to a role. api-rs currently folds infra, harness, and -//! discovered tool fragments into the single shared infra role so each sandbox -//! principal only needs one assignment. +//! resources and granted to a role. api-rs registers infra and harness +//! fragments against the shared infra role. //! //! [`secret_inputs_from_fragment`] is the pure translation (fragment → secret //! inputs) and is unit-tested without a server; [`register_role`] drives the From a41ad46afc4c7914d77db69a5ca10b2b7d2f735b Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:20:03 +0300 Subject: [PATCH 022/198] fix: gate activity summary status rendering (#846) --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/slackbotv2.yaml | 2 + services/slackbotv2/src/index.ts | 4 +- services/slackbotv2/src/server.ts | 10 +++ services/slackbotv2/src/types.ts | 5 ++ .../slackbotv2/test/chat-sdk-emulate.test.ts | 74 ++++++++++++++++++- 6 files changed, 91 insertions(+), 6 deletions(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 1ae8cbb01..9721eea0b 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.82 +version: 0.1.83 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/slackbotv2.yaml b/contrib/chart/templates/slackbotv2.yaml index 30c6ac6cb..6fd5d0a78 100644 --- a/contrib/chart/templates/slackbotv2.yaml +++ b/contrib/chart/templates/slackbotv2.yaml @@ -67,6 +67,8 @@ spec: key: {{ printf "%sDATABASE_URL" .Values.secretManager.envPrefix }} - name: SLACKBOTV2_USER_NAME value: {{ .Values.slackbotv2.userName | quote }} + - name: SLACKBOTV2_ACTIVITY_SUMMARY_STATUS_ENABLED + value: {{ .Values.apiRs.activitySummary.enabled | quote }} {{- if .Values.slackbotv2.assistantStatus }} - name: SLACKBOTV2_ASSISTANT_STATUS value: {{ .Values.slackbotv2.assistantStatus | quote }} diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index efb262fdc..c8198a00c 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -2015,7 +2015,7 @@ async function* slackSafeChatSdkStream( type SlackStreamTaskDisplayMode = NonNullable function slackStreamTaskDisplayMode(options: SlackbotV2Options): SlackStreamTaskDisplayMode { - return options.streamTaskDisplayMode ?? 'none' + return options.streamTaskDisplayMode ?? (options.activitySummaryStatusEnabled ? 'none' : 'plan') } async function* slackVisibleChatSdkStream( @@ -2768,7 +2768,7 @@ function rendererOptions( if (event.type === 'renderer.title.update') { await setAssistantTitle(thread, event.title, options) } - if (event.type === 'renderer.status') { + if (event.type === 'renderer.status' && options.activitySummaryStatusEnabled) { await setAssistantStatus(thread, event.status, options, trace) } } diff --git a/services/slackbotv2/src/server.ts b/services/slackbotv2/src/server.ts index 71c106864..f7a96251c 100644 --- a/services/slackbotv2/src/server.ts +++ b/services/slackbotv2/src/server.ts @@ -28,6 +28,7 @@ const options: SlackbotV2Options = { apiUrl, apiKey: optionalEnv('SLACKBOT_API_KEY'), assistantStatus: optionalEnv('SLACKBOTV2_ASSISTANT_STATUS'), + activitySummaryStatusEnabled: booleanEnv('SLACKBOTV2_ACTIVITY_SUMMARY_STATUS_ENABLED', false), botToken, botUserId: optionalEnv('SLACK_BOT_USER_ID'), defaultHarnessType: optionalEnv('SLACKBOTV2_DEFAULT_HARNESS'), @@ -61,6 +62,7 @@ console.log( level: 'info', event: 'slackbotv2_started', service: 'slackbotv2', + activity_summary_status_enabled: options.activitySummaryStatusEnabled, port: server.port, api_url: apiUrl }) @@ -87,6 +89,14 @@ function numberEnv(name: string, fallback: number): number { return optionalNumberEnv(name) ?? fallback } +function booleanEnv(name: string, fallback: boolean): boolean { + const value = optionalEnv(name) + if (!value) return fallback + if (['1', 'true', 'yes', 'on'].includes(value.toLowerCase())) return true + if (['0', 'false', 'no', 'off'].includes(value.toLowerCase())) return false + throw new Error(`${name} must be a boolean`) +} + function optionalNumberEnv(name: string): number | undefined { const value = optionalEnv(name) if (!value) return undefined diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 2fed9a8ae..19d3fa093 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -98,6 +98,11 @@ export type SlackbotV2Options = { apiKey?: string apiUrl: string assistantStatus?: string + /** + * When enabled, session.activity_summary events update Slack's assistant + * status and structured task output is hidden from the Slack stream. + */ + activitySummaryStatusEnabled?: boolean botToken: string botUserId?: string /** diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index cf4bd7fa8..e89a36f23 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -3136,10 +3136,79 @@ describe('slackbotv2', () => { await Promise.all(waits) }) - it('uses session activity summaries as assistant status instead of visible text', async () => { + it('shows visible task progress by default when activity summary status is disabled', async () => { bot = createProductionDefaultTestBot() codexApi.autoRespond = false + const parent = await postUserMessage('Context before default progress.') + const mention = await postUserMessage(`<@${BOT_USER_ID}> summarize progress`, parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-default-visible-progress', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> summarize progress` + } + }), + {}, + waitUntilContext(waits) + ) + + expect(response.status).toBe(200) + await waitFor(() => codexApi.executes.length === 1) + await waitFor(() => codexApi.eventRequests.length === 1) + await waitFor(() => codexApi.streamCount === 1) + + const key = threadKey(parent.ts) + const summary = "I'm checking the event stream so I can explain the current state." + codexApi.emitSessionEvent(key, 'session.activity_summary', { + execution_id: 'exe-default-visible-progress', + summary + }) + codexApi.emitOutputLine( + key, + JSON.stringify({ + type: 'item.started', + item: { + id: 'cmd-default-progress', + type: 'commandExecution', + command: 'rg activity summary', + status: 'inProgress' + } + }) + ) + codexApi.emitOutputLine( + key, + JSON.stringify({ + type: 'turn.done', + result: 'Default progress done.' + }) + ) + + await Promise.all(waits) + const statusCalls = slackApi.calls.filter(call => call.method === 'assistant.threads.setStatus') + expect(statusCalls.map(call => stringField(call.body.status))).toEqual(['Thinking...', '']) + const transcripts = slackStreamTranscripts(slackApi.calls) + expect(transcripts).toHaveLength(1) + expect(transcripts[0]!.start.body.task_display_mode).toBe('plan') + expect(transcripts[0]!.chunks.some(chunk => chunk.type === 'task_update')).toBe(true) + const text = await threadText(parent.ts) + expect(text).toContain('Command execution') + expect(text).toContain('Default progress done.') + expect(text).not.toContain(summary) + }) + + it('uses session activity summaries as assistant status instead of visible text', async () => { + bot = createProductionDefaultTestBot({ activitySummaryStatusEnabled: true }) + codexApi.autoRespond = false + const parent = await postUserMessage('Context before status update.') const mention = await postUserMessage(`<@${BOT_USER_ID}> summarize activity`, parent.ts) const waits: Promise[] = [] @@ -3887,8 +3956,7 @@ function createTestBot( overrides: Partial[0]> = {} ): SlackbotV2 { return createProductionDefaultTestBot({ - // Most tests in this file exercise the legacy structured-card renderer. - // Production omits this option and uses assistant status for live activity. + // Most tests in this file pin the structured progress renderer explicitly. streamTaskDisplayMode: 'plan', ...overrides }) From c749ad3c17c3d4c18d45f4360aead3e891aad5e7 Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Wed, 1 Jul 2026 14:07:33 -0700 Subject: [PATCH 023/198] Guide crypto tool usage away from bad raw endpoints (#847) Co-authored-by: Centaur AI --- tools/crypto/coingecko/cli.py | 38 +++++- tools/crypto/defillama/cli.py | 200 ++++++++++++++++++++++++++++++- tools/crypto/defillama/client.py | 24 ++++ 3 files changed, 256 insertions(+), 6 deletions(-) diff --git a/tools/crypto/coingecko/cli.py b/tools/crypto/coingecko/cli.py index 86152b0a1..2278f6578 100644 --- a/tools/crypto/coingecko/cli.py +++ b/tools/crypto/coingecko/cli.py @@ -10,7 +10,14 @@ from rich.console import Console from rich.table import Table -app = typer.Typer(name="coingecko", help="CoinGecko CLI for cryptocurrency market data") +app = typer.Typer( + name="coingecko", + help=( + "CoinGecko CLI for cryptocurrency market data. Prefer typed commands " + "for common work: price, markets, coin, history, categories, exchanges. " + "Raw exchange volume_chart endpoints are not supported by this wrapper." + ), +) @app.command("health") @@ -480,7 +487,15 @@ def raw( endpoint: str = typer.Argument(..., help="API endpoint (e.g., /ping, /coins/list)"), params: str = typer.Option(None, "--params", "-p", help="Query params as key=value,key=value"), ): - """Make a raw API call.""" + """Make a raw API call. + + Prefer typed commands when available: + - coingecko history bitcoin --days 365 --json for token history + - coingecko exchanges --json for exchange volume/rank snapshots + + This wrapper does not support exchange volume_chart raw endpoints such as + /exchanges/binance/volume_chart or /exchanges/binance/volume_chart/range. + """ client = get_client() query_params = None @@ -491,6 +506,25 @@ def raw( k, v = pair.split("=", 1) query_params[k.strip()] = v.strip() + endpoint_lower = endpoint.lower() + if endpoint_lower.startswith("/exchanges/") and "/volume_chart" in endpoint_lower: + console.print( + "[red]CoinGecko exchange volume_chart raw endpoints are not supported by this CLI. " + "For token history use: coingecko history --days --json. " + "For exchange snapshots use: coingecko exchanges --json.[/]" + ) + raise typer.Exit(2) + if endpoint_lower.startswith("/derivatives/exchanges/") and ( + endpoint_lower.endswith("/open_interest_chart") + or endpoint_lower.endswith("/volume_chart") + ): + console.print( + "[red]CoinGecko derivatives exchange chart raw endpoints are not supported by this CLI. " + "Use a market-data source with first-class derivatives/OI support, or DefiLlama " + "derivatives-volume for venue volume context.[/]" + ) + raise typer.Exit(2) + try: data = client._request(endpoint, params=query_params) print(json.dumps(data, indent=2)) diff --git a/tools/crypto/defillama/cli.py b/tools/crypto/defillama/cli.py index d65cd7b27..985eb0b4d 100644 --- a/tools/crypto/defillama/cli.py +++ b/tools/crypto/defillama/cli.py @@ -7,11 +7,19 @@ from rich.console import Console from rich.table import Table -from .client import DefiLlamaClient +from .client import DefiLlamaClient, _looks_like_perps load_dotenv() -app = typer.Typer(name="defillama", help="DefiLlama CLI for stablecoin and DeFi analytics") +app = typer.Typer( + name="defillama", + help=( + "DefiLlama CLI for stablecoin and DeFi analytics. Prefer typed commands " + "over raw endpoints; use derivatives-volume/derivatives-summary/open-interest " + "for " + "perps venues such as Hyperliquid, Lighter, GMX, and dYdX." + ), +) @app.command("health") @@ -251,10 +259,29 @@ def protocols( @app.command() def protocol( - slug: str = typer.Argument(..., help="Protocol slug (e.g., aave, uniswap)"), + slug: str = typer.Argument( + ..., + help=( + "TVL protocol slug (e.g., aave, uniswap). For perps venues use " + "derivatives-summary instead, e.g. defillama derivatives-summary hyperliquid." + ), + ), json_output: bool = typer.Option(False, "--json", help="Output as JSON"), ): """Get protocol details including historical TVL.""" + if _looks_like_perps(slug): + console.print( + "[red]This looks like a perpetuals venue. Use: " + f"defillama derivatives-summary {slug} --json[/]" + ) + raise typer.Exit(2) + if slug in {"trade-xyz", "trade.xyz"}: + console.print( + "[red]No DefiLlama TVL protocol is known for this slug. Check the canonical " + "slug with `defillama protocols --json` before calling protocol details.[/]" + ) + raise typer.Exit(2) + client = get_client() data = client.get_protocol(slug) @@ -423,6 +450,140 @@ def dex_volume( console.print(f"\n[bold]Total 24h Volume: {format_number(total_24h)}[/]") +@app.command("derivatives-volume") +def derivatives_volume( + chain: str = typer.Option(None, "--chain", "-c", help="Filter by chain"), + limit: int = typer.Option(20, "--limit", "-n", help="Max results"), + json_output: bool = typer.Option(False, "--json", help="Output as JSON"), + markdown: bool = typer.Option(False, "--markdown", "-m", help="Output as markdown table"), +): + """Perpetual futures venue volumes. + + Use this for perps venues such as Hyperliquid, Lighter, GMX, dYdX, Drift, and Vertex. + These venues are under DefiLlama derivatives endpoints, not DEX volume endpoints. + """ + client = get_client() + data = client.get_derivatives_volumes(chain) + + if json_output: + print(json.dumps(data, indent=2)) + return + + protocols = data.get("protocols", []) + protocols = sorted(protocols, key=lambda x: x.get("total24h") or 0, reverse=True)[:limit] + + if markdown: + rows = [] + for p in protocols: + vol_24h = p.get("total24h", 0) or 0 + vol_7d = p.get("total7d", 0) or 0 + change = p.get("change_1d", 0) or 0 + rows.append( + [ + p.get("name", ""), + format_number(vol_24h), + format_number(vol_7d), + f"{change:+.1f}%", + ] + ) + print_markdown_table(["Venue", "24h Volume", "7d Volume", "Change"], rows) + total_24h = data.get("total24h", 0) + if total_24h: + print(f"\nTotal 24h Derivatives Volume: {format_number(total_24h)}") + return + + table = Table(title=f"Derivatives Volumes{f' ({chain})' if chain else ''}") + table.add_column("Venue", style="cyan", max_width=25) + table.add_column("24h Volume", style="yellow", justify="right") + table.add_column("7d Volume", style="green", justify="right") + table.add_column("Change", style="dim", justify="right") + + for p in protocols: + change = p.get("change_1d", 0) or 0 + change_color = "green" if change >= 0 else "red" + table.add_row( + p.get("name", ""), + format_number(p.get("total24h", 0) or 0), + format_number(p.get("total7d", 0) or 0), + f"[{change_color}]{change:+.1f}%[/]", + ) + + console.print(table) + + total_24h = data.get("total24h", 0) + if total_24h: + console.print(f"\n[bold]Total 24h Derivatives Volume: {format_number(total_24h)}[/]") + + +@app.command("derivatives-summary") +def derivatives_summary( + protocol: str = typer.Argument( + ..., help="Derivatives protocol slug, e.g. hyperliquid, lighter, gmx-v2, dydx-v4" + ), + json_output: bool = typer.Option(False, "--json", help="Output as JSON"), +): + """Volume details for a specific perpetual futures venue.""" + client = get_client() + data = client.get_derivatives_summary(protocol) + + if json_output: + print(json.dumps(data, indent=2)) + return + + console.print(f"\n[bold cyan]{data.get('name', protocol)}[/] Derivatives\n") + console.print(f"24h Volume: [yellow]{format_number(data.get('total24h', 0) or 0)}[/]") + console.print(f"7d Volume: [green]{format_number(data.get('total7d', 0) or 0)}[/]") + + +@app.command("open-interest") +def open_interest( + chain: str = typer.Option(None, "--chain", "-c", help="Filter by chain"), + json_output: bool = typer.Option(False, "--json", help="Output as JSON"), +): + """Perpetual futures open interest overview. + + Uses DefiLlama's kebab-case open-interest endpoint. Do not use camelCase + /overview/openInterest. + """ + client = get_client() + data = client.get_open_interest_overview(chain) + + if json_output: + print(json.dumps(data, indent=2)) + return + + console.print(f"\n[bold]Open Interest{f' ({chain})' if chain else ''}[/]\n") + total_24h = data.get("total24h") + total_30d = data.get("total30d") + if total_24h: + console.print(f"24h: [yellow]{format_number(total_24h)}[/]") + if total_30d: + console.print(f"30d: [green]{format_number(total_30d)}[/]") + chains = data.get("allChains", []) + if chains: + console.print(f"Chains: {', '.join(chains[:10])}") + + +@app.command("open-interest-summary") +def open_interest_summary( + protocol: str = typer.Argument( + ..., help="Open-interest protocol slug, e.g. hyperliquid, lighter, dydx-v4" + ), + json_output: bool = typer.Option(False, "--json", help="Output as JSON"), +): + """Open interest details for a specific perpetual futures venue.""" + client = get_client() + data = client.get_open_interest_summary(protocol) + + if json_output: + print(json.dumps(data, indent=2)) + return + + console.print(f"\n[bold cyan]{data.get('name', protocol)}[/] Open Interest\n") + console.print(f"Category: [green]{data.get('category', 'Unknown')}[/]") + console.print(f"Chains: {', '.join(data.get('chains', [])[:10])}") + + @app.command() def bridges( chain: str = typer.Option(None, "--chain", "-c", help="Filter by chain"), @@ -548,7 +709,13 @@ def raw( None, "--base", "-b", help="Base URL (main, stablecoins, bridges, coins). Default: main" ), ): - """Make a raw API call. Params as key=value,key=value + """Make a raw API call. Params as key=value,key=value. + + Prefer typed commands when available: + - defillama derivatives-volume, not raw /overview/derivatives + - defillama derivatives-summary hyperliquid, not protocol hyperliquid + - defillama raw /overview/open-interest, not /overview/openInterest + - defillama open-interest-summary hyperliquid, not raw /summary/openInterest/hyperliquid Base URLs: main: https://api.llama.fi (default) @@ -580,6 +747,31 @@ def raw( console.print(f"[red]Unknown base: {base}. Use: main, stablecoins, bridges, coins[/]") raise typer.Exit(1) + endpoint_lower = endpoint.lower() + if endpoint == "/overview/openInterest": + console.print( + "[red]Use /overview/open-interest, not /overview/openInterest. " + "Prefer: defillama open-interest --json[/]" + ) + raise typer.Exit(2) + if endpoint_lower.startswith("/overview/derivatives") and pro: + console.print( + "[red]/overview/derivatives is a public main API endpoint; do not pass --pro. " + "Prefer: defillama derivatives-volume --json[/]" + ) + raise typer.Exit(2) + if endpoint_lower.startswith("/summary/open-interest"): + console.print( + "[red]Prefer the typed command: defillama open-interest-summary --json[/]" + ) + raise typer.Exit(2) + if endpoint.startswith("/summary/openInterest"): + console.print( + "[red]Use /summary/open-interest/, not /summary/openInterest/. " + "Prefer: defillama open-interest-summary --json[/]" + ) + raise typer.Exit(2) + try: data = client._request(endpoint, params=query_params, pro=pro, base=base_url) print(json.dumps(data, indent=2)) diff --git a/tools/crypto/defillama/client.py b/tools/crypto/defillama/client.py index 140ab7c8d..7ab6ff984 100644 --- a/tools/crypto/defillama/client.py +++ b/tools/crypto/defillama/client.py @@ -317,6 +317,30 @@ def get_derivatives_summary(self, protocol: str) -> dict: """ return self._request(f"/summary/derivatives/{protocol}") + def get_open_interest_overview(self, chain: str | None = None) -> dict: + """Get perpetual-futures open interest overview. + + Args: + chain: Optional chain name to filter + + Returns: + Open interest overview data + """ + if chain: + return self._request(f"/overview/open-interest/{chain}") + return self._request("/overview/open-interest") + + def get_open_interest_summary(self, protocol: str) -> dict: + """Get open interest details for a specific perpetuals venue. + + Args: + protocol: Protocol slug (e.g., "hyperliquid", "lighter", "dydx-v4") + + Returns: + Protocol open interest details + """ + return self._request(f"/summary/open-interest/{protocol}") + # === Bridges === def list_bridges(self) -> list[dict]: From 7830e9464243c25c74e1094b0701711d666ed0c6 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Wed, 1 Jul 2026 16:09:32 -0700 Subject: [PATCH 024/198] fix: handle attachment refs in sandbox prompt --- crates/harness-server/Cargo.lock | 88 +++++++++++++ crates/harness-server/Cargo.toml | 1 + crates/harness-server/src/server.rs | 197 +++++++++++++++++++++++++++- services/sandbox/SYSTEM_PROMPT.md | 4 +- 4 files changed, 285 insertions(+), 5 deletions(-) diff --git a/crates/harness-server/Cargo.lock b/crates/harness-server/Cargo.lock index 5469c3317..1366264ad 100644 --- a/crates/harness-server/Cargo.lock +++ b/crates/harness-server/Cargo.lock @@ -1461,8 +1461,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1546,6 +1548,7 @@ dependencies = [ "codex-utils-absolute-path", "opentelemetry-proto", "prost", + "reqwest", "serde", "serde_json", "thiserror 2.0.18", @@ -1753,6 +1756,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2333,6 +2337,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lsp-types" version = "0.94.1" @@ -2924,6 +2934,61 @@ dependencies = [ "serde", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -3421,7 +3486,9 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -3436,6 +3503,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3443,6 +3512,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -3450,6 +3520,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -3507,6 +3578,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -3563,6 +3640,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -4925,6 +5003,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/crates/harness-server/Cargo.toml b/crates/harness-server/Cargo.toml index 1fa56f00f..67862ad9e 100644 --- a/crates/harness-server/Cargo.toml +++ b/crates/harness-server/Cargo.toml @@ -21,6 +21,7 @@ codex-protocol = { git = "https://github.com/openai/codex", rev = "e93dc98a48d59 codex-utils-absolute-path = { git = "https://github.com/openai/codex", rev = "e93dc98a48d597df322436ffe8d03bfd7ec63b3b", package = "codex-utils-absolute-path" } opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["trace", "gen-tonic-messages"] } prost = "0.14" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" diff --git a/crates/harness-server/src/server.rs b/crates/harness-server/src/server.rs index 9caf654e1..1c448c5c4 100644 --- a/crates/harness-server/src/server.rs +++ b/crates/harness-server/src/server.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::{ }; use serde::Deserialize; use serde_json::{Value, json}; +use url::Url; use uuid::Uuid; use crate::amp::AmpHarness; @@ -314,9 +315,16 @@ enum BlocksInput { struct AttachmentBlock { #[serde(rename = "type")] kind: String, + #[serde( + rename = "attachment_id", + alias = "attachmentId", + alias = "id", + default + )] + attachment_id: Option, #[serde(default)] name: Option, - #[serde(rename = "mimeType", default)] + #[serde(rename = "mimeType", alias = "mime_type", default)] mime_type: Option, #[serde(rename = "attachment_type", default)] attachment_type: Option, @@ -357,7 +365,7 @@ pub(crate) fn parse_blocks_line_with_state( .and_then(|message| message.content.as_ref()) .or(parsed.content.as_ref()); let mut input = match content { - Some(content) => blocks_content_to_user_input(content, state)?, + Some(content) => blocks_content_to_user_input(content, state, &trace_context)?, None => parsed .text .map(|text| { @@ -408,11 +416,12 @@ pub(crate) fn parse_blocks_line_with_state( fn blocks_content_to_user_input( content: &BlocksContent, state: &mut BlocksState, + trace_context: &TraceContext, ) -> Result> { match content { BlocksContent::Inputs(input) => input .iter() - .map(|item| blocks_input_to_user_input(item, state)) + .map(|item| blocks_input_to_user_input(item, state, trace_context)) .collect::>>() .map(|items| items.into_iter().flatten().collect()), BlocksContent::Text(text) => Ok(vec![UserInput::Text { @@ -425,12 +434,16 @@ fn blocks_content_to_user_input( fn blocks_input_to_user_input( input: &BlocksInput, state: &mut BlocksState, + trace_context: &TraceContext, ) -> Result> { match input { BlocksInput::UserInput(input) => Ok(vec![input.clone()]), BlocksInput::Attachment(block) if block.kind == "attachment" => { attachment_block_to_user_input(block, state) } + BlocksInput::Attachment(block) if block.kind == "attachment_ref" => { + Ok(attachment_ref_block_to_user_input(block, trace_context)) + } BlocksInput::Attachment(block) => Ok(vec![UserInput::Text { text: format!("[Unsupported attachment block type: {}]", block.kind), text_elements: Vec::new(), @@ -438,6 +451,108 @@ fn blocks_input_to_user_input( } } +fn attachment_ref_block_to_user_input( + block: &AttachmentBlock, + trace_context: &TraceContext, +) -> Vec { + let attachment_id = non_empty(block.attachment_id.as_deref()); + let mime_type = non_empty(block.mime_type.as_deref()); + let attachment_type = non_empty(block.attachment_type.as_deref()); + let name = non_empty(block.name.as_deref()).unwrap_or("attachment"); + + if let (Some(attachment_id), Some(thread_key)) = ( + attachment_id, + non_empty(trace_context.thread_key.as_deref()), + ) { + match download_attachment_ref(attachment_id, thread_key, name, mime_type) { + Ok(path) => { + return local_file_inputs( + &path, + mime_type, + is_image_attachment(attachment_type, mime_type), + ); + } + Err(error) => { + return vec![UserInput::Text { + text: format!( + "[Attachment reference could not be downloaded: id={attachment_id} name={name} error={error}. The file is not preloaded in /home/agent/uploads; recover it locally before inspecting it.]" + ), + text_elements: Vec::new(), + }]; + } + } + } + + let mut fields = Vec::new(); + if let Some(attachment_id) = attachment_id { + fields.push(format!("id={attachment_id}")); + } + fields.push(format!("name={name}")); + if let Some(mime_type) = mime_type { + fields.push(format!("mime={mime_type}")); + } + + let summary = if fields.is_empty() { + "attachment_ref".to_string() + } else { + format!("attachment_ref {}", fields.join(" ")) + }; + vec![UserInput::Text { + text: format!( + "[Attachment reference: {summary}. The file is not preloaded in /home/agent/uploads; recover it locally before inspecting it.]" + ), + text_elements: Vec::new(), + }] +} + +fn download_attachment_ref( + attachment_id: &str, + thread_key: &str, + name: &str, + mime_type: Option<&str>, +) -> std::result::Result { + let api_base = attachment_api_base().ok_or_else(|| "CENTAUR_API_URL is not set".to_string())?; + let url = attachment_download_url(&api_base, attachment_id, thread_key)?; + let response = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| error.to_string())? + .get(url) + .send() + .map_err(|error| error.to_string())?; + let status = response.status(); + if !status.is_success() { + return Err(format!("download returned HTTP {status}")); + } + let bytes = response.bytes().map_err(|error| error.to_string())?; + let path = unique_upload_path(name, mime_type).map_err(|error| error.to_string())?; + std::fs::write(&path, &bytes).map_err(|error| error.to_string())?; + Ok(path) +} + +fn attachment_api_base() -> Option { + ["CENTAUR_API_URL", "SESSION_SANDBOX_CENTAUR_API_URL"] + .iter() + .find_map(|name| non_empty(env::var(name).ok().as_deref()).map(str::to_owned)) +} + +fn attachment_download_url( + api_base: &str, + attachment_id: &str, + thread_key: &str, +) -> std::result::Result { + let mut url = Url::parse(api_base.trim_end_matches('/')).map_err(|error| error.to_string())?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| "attachment API base URL cannot be a base".to_string())?; + segments.pop_if_empty(); + segments.extend(["agent", "attachments", attachment_id, "download"]); + } + url.query_pairs_mut().append_pair("thread_key", thread_key); + Ok(url) +} + fn attachment_block_to_user_input( block: &AttachmentBlock, state: &mut BlocksState, @@ -1347,6 +1462,7 @@ pub(crate) fn write_blocks_error( #[cfg(test)] mod tests { use super::*; + use std::io::Read as _; fn temp_upload_dir() -> PathBuf { let path = env::temp_dir().join(format!("harness-server-test-{}", Uuid::new_v4().simple())); @@ -1403,6 +1519,81 @@ mod tests { assert_eq!(model, None); } + #[test] + fn parses_attachment_ref_as_recoverable_reference() { + let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"inspect this"},{"type":"attachment_ref","attachment_id":"att_123","name":"report.pdf","mime_type":"application/pdf"}]}}"#; + let BlocksCommand::User { input, .. } = parse_blocks_line(line).expect("parses") else { + panic!("expected user command"); + }; + + assert_eq!(input.len(), 2); + let UserInput::Text { text, .. } = &input[1] else { + panic!("expected attachment_ref to become text guidance"); + }; + assert!(text.contains("Attachment reference")); + assert!(text.contains("id=att_123")); + assert!(text.contains("name=report.pdf")); + assert!(text.contains("mime=application/pdf")); + assert!(text.contains("not preloaded in /home/agent/uploads")); + assert!(!text.contains("Unsupported attachment block type")); + } + + #[test] + fn attachment_ref_downloads_to_uploads_dir_when_api_is_available() { + let upload_dir = temp_upload_dir(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let api_base = format!("http://{}", listener.local_addr().expect("local addr")); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = String::new(); + let mut buffer = [0; 1024]; + loop { + let read = stream.read(&mut buffer).expect("read request"); + if read == 0 { + break; + } + request.push_str(&String::from_utf8_lossy(&buffer[..read])); + if request.contains("\r\n\r\n") { + break; + } + } + assert!( + request.starts_with("GET /agent/attachments/att_123/download?thread_key=web%3At1 ") + ); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 9\r\nConnection: close\r\n\r\nhello-ref", + ) + .expect("write response"); + }); + + unsafe { + env::set_var("CENTAUR_API_URL", api_base); + } + let line = r#"{"type":"user","thread_key":"web:t1","message":{"role":"user","content":[{"type":"text","text":"inspect this"},{"type":"attachment_ref","attachment_id":"att_123","name":"report.txt","mime_type":"text/plain"}]}}"#; + let BlocksCommand::User { input, .. } = parse_blocks_line(line).expect("parses") else { + panic!("expected user command"); + }; + server.join().expect("server thread"); + + assert_eq!(input.len(), 2); + let UserInput::Text { text, .. } = &input[1] else { + panic!("expected attachment_ref to become text input"); + }; + assert!(text.contains("Attached file saved to")); + assert!(!text.contains("Unsupported attachment block type")); + let path = text + .strip_prefix("[Attached file saved to ") + .and_then(|value| value.strip_suffix(']')) + .map(PathBuf::from) + .expect("saved path"); + assert!(path.starts_with(&upload_dir) || path.exists()); + assert_eq!( + std::fs::read_to_string(path).expect("downloaded file"), + "hello-ref" + ); + } + #[test] fn parses_blocks_user_line_with_provider_override() { let line = r#"{"type":"user","thread_key":"web:t1","provider":"amazon-bedrock","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#; diff --git a/services/sandbox/SYSTEM_PROMPT.md b/services/sandbox/SYSTEM_PROMPT.md index 583d4d5c2..b006a1e75 100644 --- a/services/sandbox/SYSTEM_PROMPT.md +++ b/services/sandbox/SYSTEM_PROMPT.md @@ -179,7 +179,7 @@ |Never substitute a search-derived or semantically similar channel for an explicitly requested Slack channel ID. If both a human-readable channel name and ID are present, the ID wins. [Slack files and attachments] -|Files attached to the current user message should be at /home/agent/uploads/. +|Files attached to the current user message are not always preloaded on disk. Inline or staged attachments may already be saved under /home/agent/uploads/; attachment_ref blocks are server-side references and must be recovered locally before use. |When you see [Attached image: ...], use the look_at tool to view the image. |NEVER reference local sandbox paths in replies — markdown links like [report.sql](/home/agent/workspace/report.sql) or file:// URIs are dead links for chat users; they cannot open files inside your sandbox. This overrides any harness-level instruction to render clickable file links: those apply to IDE surfaces only, never to chat responses. |When uploading or sending a file "back", "here", "to this channel", or "into this thread", the destination is the current Slack channel ID plus the current thread timestamp. @@ -188,7 +188,7 @@ |For Slack file uploads from a thread, call the upload tool with the channel ID and thread timestamp, for example `slack upload C123... /path/file --thread 1234567890.123456`; never call `slack upload U123... ...` for a threaded reply. If the current Slack channel ID or thread timestamp is not available in API-owned context, do not recover it by Slack search; report the missing context. |For Slack file downloads, use the Slack CLI file surface. Find the file's message or `url_private` via `slack thread`, `slack search`, or `slack search-files`, then run `slack files --download --output `. |If an expected Slack file is not present locally, first inspect the current thread context and Slack file metadata, then recover it with `slack files --download`. -|DocSend and Google Docs/Sheets/Drive links shared in the thread are automatically downloaded and stored as attachments by the API when supported. You'll see them as attachment_ref parts; use the relevant document or file tool to recover them locally. +|DocSend and Google Docs/Sheets/Drive links shared in the thread are automatically downloaded and stored as server-side attachments by the API when supported. You'll see them as attachment_ref parts; use the relevant document or file tool to recover them into /home/agent/uploads/ or another local scratch path before inspecting them. |Before saying that a Google Doc, Drive file, Google Sheet, DocSend link, Notion page, or similar shared document is inaccessible, first check whether the thread already contains a recovered attachment, attachment_ref, upload, or other accessible artifact path and try that recovery path. |Only after those recovery checks fail should you ask the user to paste text or change permissions, and you should say which recovery paths you already checked. |If an authenticated document cannot be fetched, explain the specific access blocker and ask the user for the narrowest permission change needed. Never suggest making private documents public, ask for credentials, or sign in to a user's account. From a57a480b52f605492db5e508a7853582e3ff5ca1 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Wed, 1 Jul 2026 16:35:51 -0700 Subject: [PATCH 025/198] fix: lease session stdout ownership (#851) --- .../crates/centaur-session-runtime/src/lib.rs | 185 ++++++++-- .../0034_session_execution_stdout_owner.sql | 7 + .../crates/centaur-session-sqlx/src/lib.rs | 344 +++++++++++++++++- 3 files changed, 508 insertions(+), 28 deletions(-) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0034_session_execution_stdout_owner.sql diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 7757bc488..0488dfc52 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -58,6 +58,8 @@ const STEERING_STARTUP_RETRY_INTERVAL: Duration = Duration::from_millis(250); const STEERING_STARTUP_RETRY_TIMEOUT: Duration = Duration::from_secs(15); const SESSION_PIPE_MAX_REATTACH_ATTEMPTS: u32 = 3; const SESSION_PIPE_REATTACH_DELAY: Duration = Duration::from_millis(500); +const STDOUT_OWNER_LEASE: Duration = Duration::from_secs(45); +const STDOUT_OWNER_RENEW_INTERVAL: Duration = Duration::from_secs(10); const COMPONENT_SESSION_RUNTIME: &str = "session_runtime"; const SANDBOX_REPOS_MOUNT_PATH: &str = "/home/agent/github"; const OBSERVABILITY_TOOL_BLOCKLIST: &str = @@ -89,6 +91,7 @@ pub struct SessionRuntime { session_title_in_flight: SessionTitleThreadSet, session_title_rerun_requested: SessionTitleThreadSet, capacity: Option>, + stdout_owner_id: String, } #[derive(Clone, Copy, Debug)] @@ -293,6 +296,7 @@ struct RuntimeContext { manager: Arc, sandbox_pipes: SessionPipeMap, execution_spans: ExecutionSpanRegistry, + stdout_owner_id: String, } struct SandboxCapacityController { @@ -672,6 +676,7 @@ impl SessionRuntime { session_title_in_flight: Arc::new(DashSet::new()), session_title_rerun_requested: Arc::new(DashSet::new()), capacity: None, + stdout_owner_id: format!("api-rs-{}", uuid::Uuid::new_v4().simple()), } } @@ -768,9 +773,38 @@ impl SessionRuntime { manager: self.sandbox_runtime.manager.clone(), sandbox_pipes: self.sandbox_pipes.clone(), execution_spans: self.execution_spans.clone(), + stdout_owner_id: self.stdout_owner_id.clone(), } } + async fn claim_stdout_owner(&self, execution_id: &str) -> Result<(), SessionRuntimeError> { + let claimed = self + .store + .claim_stdout_owner(execution_id, &self.stdout_owner_id, STDOUT_OWNER_LEASE) + .await?; + if !claimed { + return Err(SessionRuntimeError::BadRequest(format!( + "execution {execution_id} stdout is owned by another control plane process" + ))); + } + spawn_stdout_owner_renewer(self.context(), execution_id.to_owned()); + Ok(()) + } + + async fn claim_expired_stdout_owner( + &self, + execution_id: &str, + ) -> Result { + let claimed = self + .store + .claim_expired_stdout_owner(execution_id, &self.stdout_owner_id, STDOUT_OWNER_LEASE) + .await?; + if claimed { + spawn_stdout_owner_renewer(self.context(), execution_id.to_owned()); + } + Ok(claimed) + } + /// Attach an iron-control registrar so each new session upserts its /// principal and assigns the configured roles. pub fn with_iron_control(mut self, registrar: SessionRegistrar) -> Self { @@ -1421,6 +1455,11 @@ impl SessionRuntime { ); return Ok(execution); } + if let Err(error) = self.claim_stdout_owner(&execution.execution_id).await { + self.record_execution_failure(thread_key, &execution.execution_id, &error) + .await; + return Err(error); + } let execution_trace_span = info_span!( "centaur.api_rs.session.execution", component = COMPONENT_SESSION_RUNTIME, @@ -1562,6 +1601,19 @@ impl SessionRuntime { ) { self.execution_spans.lock().await.remove(execution_id); let error_message = error.to_string(); + let execution = match self + .store + .fail_execution_if_active_and_stdout_owner( + execution_id, + &self.stdout_owner_id, + &error_message, + ) + .await + { + Ok(Some(execution)) => execution, + Ok(None) => return, + Err(_) => return, + }; let _ = self .store .append_event( @@ -1575,20 +1627,14 @@ impl SessionRuntime { }), ) .await; - if let Ok(execution) = self - .store - .fail_execution(execution_id, &error_message) - .await - { - record_finished_execution_metric( - &self.store, - thread_key, - &execution, - "failed", - Some(runtime_error_failure_class(error)), - ) - .await; - } + record_finished_execution_metric( + &self.store, + thread_key, + &execution, + "failed", + Some(runtime_error_failure_class(error)), + ) + .await; } async fn forward_messages_to_active_execution( @@ -2472,6 +2518,26 @@ impl SessionRuntime { .await; return Ok(()); } + if !self.claim_expired_stdout_owner(execution_id).await? { + info!( + component = COMPONENT_SESSION_RUNTIME, + event = "execution_adoption_deferred", + thread_key = %thread_key, + execution_id, + sandbox_id, + "active stdout owner lease still exists; deferring adoption" + ); + let _ = self + .store + .append_event( + thread_key, + Some(execution_id), + "session.execution_adoption_deferred", + json!({ "sandbox_id": sandbox_id, "reason": "stdout_owner_lease_active" }), + ) + .await; + return Ok(()); + } // The turn may have finished while no control plane was attached. An // attach stream cannot replay that output, but the backend's recorded @@ -2531,7 +2597,13 @@ impl SessionRuntime { // No terminal in the recorded output: treat the turn as still in // flight. Re-attach the stdout pump and re-arm the remaining // max-duration budget so an adopted-but-silent turn stays bounded. - self.ensure_session_pipe(thread_key, sandbox_id).await?; + if let Err(error) = self.ensure_session_pipe(thread_key, sandbox_id).await { + let _ = self + .store + .release_stdout_owner(execution_id, &self.stdout_owner_id) + .await; + return Err(error); + } info!( component = COMPONENT_SESSION_RUNTIME, event = "execution_adopted", @@ -2572,6 +2644,10 @@ impl SessionRuntime { sandbox_id: &str, detail: &str, ) { + let _ = self + .store + .claim_stdout_owner(execution_id, &self.stdout_owner_id, STDOUT_OWNER_LEASE) + .await; let error = format!("execution orphaned by control plane restart; {detail}"); if let Err(record_error) = record_terminal_output( &self.context(), @@ -3349,6 +3425,7 @@ async fn run_stdout_pump( "session stdout pump started" ); let mut output_state = StdoutPumpState::default(); + let mut lost_stdout_ownership = HashSet::new(); let mut line_count = 0_u64; while let Some(line) = stdout.next().await { let line = match line { @@ -3377,6 +3454,9 @@ async fn run_stdout_pump( else { continue; }; + if lost_stdout_ownership.contains(&output_execution_id) { + continue; + } let first_token_execution = active_execution .as_ref() .filter(|execution| { @@ -3399,10 +3479,24 @@ async fn run_stdout_pump( sandbox_id, &output_execution_id, ); - let output_event = - append_output_line(&ctx.store, &thread_key, Some(&output_execution_id), &line) - .instrument(output_span.clone()) - .await?; + let Some(output_event) = + append_output_line(&ctx, &thread_key, &output_execution_id, &line) + .instrument(output_span.clone()) + .await? + else { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_stdout_owner_lost", + thread_key = %thread_key, + execution_id = %output_execution_id, + sandbox_id, + stdout_owner_id = %ctx.stdout_owner_id, + "stdout pump no longer owns execution output; suppressing further rows" + ); + lost_stdout_ownership.insert(output_execution_id.clone()); + output_state.forget(&output_execution_id); + continue; + }; if let Some(execution) = first_token_execution { record_first_token_observation( &ctx, @@ -4171,7 +4265,10 @@ async fn record_terminal_output( reason, result_text, } => { - let Some(execution) = ctx.store.complete_execution_if_active(execution_id).await? + let Some(execution) = ctx + .store + .complete_execution_if_active_and_stdout_owner(execution_id, &ctx.stdout_owner_id) + .await? else { return Ok(()); }; @@ -4199,7 +4296,11 @@ async fn record_terminal_output( failure_class = Some(terminal_failure_class(&error)); let Some(execution) = ctx .store - .fail_execution_if_active(execution_id, &error) + .fail_execution_if_active_and_stdout_owner( + execution_id, + &ctx.stdout_owner_id, + &error, + ) .await? else { return Ok(()); @@ -4278,6 +4379,33 @@ fn spawn_max_duration_failure( }); } +fn spawn_stdout_owner_renewer(ctx: RuntimeContext, execution_id: String) { + tokio::spawn(async move { + loop { + sleep(STDOUT_OWNER_RENEW_INTERVAL).await; + match ctx + .store + .renew_stdout_owner(&execution_id, &ctx.stdout_owner_id, STDOUT_OWNER_LEASE) + .await + { + Ok(true) => {} + Ok(false) => break, + Err(error) => { + warn!( + component = COMPONENT_SESSION_RUNTIME, + event = "session_stdout_owner_renew_failed", + execution_id, + stdout_owner_id = %ctx.stdout_owner_id, + %error, + "failed to renew stdout owner lease" + ); + break; + } + } + } + }); +} + async fn record_max_duration_failure( ctx: &RuntimeContext, thread_key: &ThreadKey, @@ -4289,7 +4417,7 @@ async fn record_max_duration_failure( let error = format!("execution exceeded max_duration_ms={max_duration_ms}"); let Some(execution) = ctx .store - .fail_execution_if_active(execution_id, &error) + .fail_execution_if_active_and_stdout_owner(execution_id, &ctx.stdout_owner_id, &error) .await? else { return Ok(()); @@ -5142,16 +5270,19 @@ fn steering_input_line( } async fn append_output_line( - store: &PgSessionStore, + ctx: &RuntimeContext, thread_key: &ThreadKey, - execution_id: Option<&str>, + execution_id: &str, line: &str, -) -> Result { +) -> Result, SessionRuntimeError> { let safe_line = redact_sensitive_text(line); - let event = store - .append_event( + let event = ctx + .store + .append_event_if_stdout_owner( thread_key, execution_id, + &ctx.stdout_owner_id, + STDOUT_OWNER_LEASE, SESSION_OUTPUT_LINE_EVENT, Value::String(safe_line), ) diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0034_session_execution_stdout_owner.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0034_session_execution_stdout_owner.sql new file mode 100644 index 000000000..9dd0c44b1 --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0034_session_execution_stdout_owner.sql @@ -0,0 +1,7 @@ +alter table session_executions + add column if not exists stdout_owner_id text, + add column if not exists stdout_owner_lease_expires_at timestamptz; + +create index if not exists session_executions_stdout_owner_lease_idx + on session_executions (stdout_owner_lease_expires_at) + where status in ('queued', 'running') and stdout_owner_id is not null; diff --git a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs index df233f216..223de2f76 100644 --- a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs +++ b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs @@ -13,7 +13,7 @@ use sqlx::{ postgres::{PgListener, PgPoolOptions}, }; use thiserror::Error; -use time::OffsetDateTime; +use time::{Duration as TimeDuration, OffsetDateTime}; use uuid::Uuid; // The API binary embeds these migrations at compile time. @@ -425,6 +425,121 @@ impl PgSessionStore { }) } + pub async fn claim_stdout_owner( + &self, + execution_id: &str, + owner_id: &str, + lease: Duration, + ) -> Result { + let lease_expires_at = stdout_lease_expires_at(lease); + let result = sqlx::query( + r#" + update session_executions + set stdout_owner_id = $2, + stdout_owner_lease_expires_at = $3, + updated_at = now() + where execution_id = $1 + and status in ($4, $5) + and ( + stdout_owner_id is null + or stdout_owner_id = $2 + or stdout_owner_lease_expires_at < now() + ) + "#, + ) + .bind(execution_id) + .bind(owner_id) + .bind(lease_expires_at) + .bind(ExecutionStatus::Queued.as_ref()) + .bind(ExecutionStatus::Running.as_ref()) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + + pub async fn claim_expired_stdout_owner( + &self, + execution_id: &str, + owner_id: &str, + lease: Duration, + ) -> Result { + let lease_expires_at = stdout_lease_expires_at(lease); + let result = sqlx::query( + r#" + update session_executions + set stdout_owner_id = $2, + stdout_owner_lease_expires_at = $3, + updated_at = now() + where execution_id = $1 + and status in ($4, $5) + and ( + stdout_owner_id is null + or stdout_owner_lease_expires_at < now() + ) + "#, + ) + .bind(execution_id) + .bind(owner_id) + .bind(lease_expires_at) + .bind(ExecutionStatus::Queued.as_ref()) + .bind(ExecutionStatus::Running.as_ref()) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + + pub async fn renew_stdout_owner( + &self, + execution_id: &str, + owner_id: &str, + lease: Duration, + ) -> Result { + let lease_expires_at = stdout_lease_expires_at(lease); + let result = sqlx::query( + r#" + update session_executions + set stdout_owner_lease_expires_at = $3, + updated_at = now() + where execution_id = $1 + and stdout_owner_id = $2 + and status in ($4, $5) + "#, + ) + .bind(execution_id) + .bind(owner_id) + .bind(lease_expires_at) + .bind(ExecutionStatus::Queued.as_ref()) + .bind(ExecutionStatus::Running.as_ref()) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + + pub async fn release_stdout_owner( + &self, + execution_id: &str, + owner_id: &str, + ) -> Result { + let result = sqlx::query( + r#" + update session_executions + set stdout_owner_id = null, + stdout_owner_lease_expires_at = null, + updated_at = now() + where execution_id = $1 and stdout_owner_id = $2 + "#, + ) + .bind(execution_id) + .bind(owner_id) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + pub async fn complete_execution( &self, execution_id: &str, @@ -474,6 +589,41 @@ impl PgSessionStore { row.try_into().map(Some) } + pub async fn complete_execution_if_active_and_stdout_owner( + &self, + execution_id: &str, + owner_id: &str, + ) -> Result, SessionStoreError> { + let row = sqlx::query_as::<_, SessionExecutionRow>( + r#" + update session_executions + set status = $2, + completed_at = coalesce(completed_at, now()), + stdout_owner_id = null, + stdout_owner_lease_expires_at = null, + updated_at = now() + where execution_id = $1 + and status in ($3, $4) + and stdout_owner_id = $5 + returning execution_id, idempotency_key, thread_key, status, metadata, error, created_at, updated_at, started_at, completed_at + "#, + ) + .bind(execution_id) + .bind(ExecutionStatus::Completed.as_ref()) + .bind(ExecutionStatus::Queued.as_ref()) + .bind(ExecutionStatus::Running.as_ref()) + .bind(owner_id) + .fetch_optional(&self.pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + self.set_session_status(&row.thread_key, SessionStatus::Idle) + .await?; + row.try_into().map(Some) + } + pub async fn fail_execution( &self, execution_id: &str, @@ -527,6 +677,44 @@ impl PgSessionStore { row.try_into().map(Some) } + pub async fn fail_execution_if_active_and_stdout_owner( + &self, + execution_id: &str, + owner_id: &str, + error: &str, + ) -> Result, SessionStoreError> { + let row = sqlx::query_as::<_, SessionExecutionRow>( + r#" + update session_executions + set status = $2, + error = $3, + completed_at = coalesce(completed_at, now()), + stdout_owner_id = null, + stdout_owner_lease_expires_at = null, + updated_at = now() + where execution_id = $1 + and status in ($4, $5) + and stdout_owner_id = $6 + returning execution_id, idempotency_key, thread_key, status, metadata, error, created_at, updated_at, started_at, completed_at + "#, + ) + .bind(execution_id) + .bind(ExecutionStatus::Failed.as_ref()) + .bind(error) + .bind(ExecutionStatus::Queued.as_ref()) + .bind(ExecutionStatus::Running.as_ref()) + .bind(owner_id) + .fetch_optional(&self.pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + self.set_session_status(&row.thread_key, SessionStatus::Failed) + .await?; + row.try_into().map(Some) + } + pub async fn append_event( &self, thread_key: &ThreadKey, @@ -551,6 +739,60 @@ impl PgSessionStore { row.try_into() } + pub async fn append_event_if_stdout_owner( + &self, + thread_key: &ThreadKey, + execution_id: &str, + owner_id: &str, + lease: Duration, + event_type: &str, + payload: Value, + ) -> Result, SessionStoreError> { + let lease_expires_at = stdout_lease_expires_at(lease); + let mut tx = self.pool.begin().await?; + let result = sqlx::query( + r#" + update session_executions + set stdout_owner_lease_expires_at = $3, + updated_at = now() + where execution_id = $1 + and stdout_owner_id = $2 + and status in ($4, $5) + and thread_key = $6 + "#, + ) + .bind(execution_id) + .bind(owner_id) + .bind(lease_expires_at) + .bind(ExecutionStatus::Queued.as_ref()) + .bind(ExecutionStatus::Running.as_ref()) + .bind(thread_key.as_str()) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + tx.commit().await?; + return Ok(None); + } + + let row = sqlx::query_as::<_, SessionEventRow>( + r#" + insert into session_events (thread_key, execution_id, event_type, payload) + values ($1, $2, $3, $4) + returning event_id, thread_key, execution_id, event_type, payload, created_at + "#, + ) + .bind(thread_key.as_str()) + .bind(execution_id) + .bind(event_type) + .bind(payload) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + row.try_into().map(Some) + } + pub async fn list_events_after( &self, thread_key: &ThreadKey, @@ -1471,6 +1713,11 @@ pub fn default_metadata(metadata: Option) -> Value { metadata.unwrap_or_else(empty_object) } +fn stdout_lease_expires_at(lease: Duration) -> OffsetDateTime { + let seconds = i64::try_from(lease.as_secs()).unwrap_or(i64::MAX); + OffsetDateTime::now_utc() + TimeDuration::new(seconds, lease.subsec_nanos() as i32) +} + #[cfg(test)] mod tests { use std::time::Duration; @@ -1622,6 +1869,101 @@ mod tests { assert_eq!(candidate.idle_timeout, Duration::from_secs(1)); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn stdout_owner_fences_output_and_terminal_updates() { + let Some(store) = test_store().await else { + return; + }; + let thread_key = ThreadKey::parse(format!("test:stdout-owner-{}", Uuid::new_v4())).unwrap(); + store + .create_or_get_session(&thread_key, &HarnessType::Codex, None, json!({})) + .await + .expect("create session"); + let execution_id = store + .create_execution(&thread_key, None, json!({})) + .await + .expect("create execution") + .execution + .execution_id; + store + .mark_execution_running(&execution_id) + .await + .expect("mark running"); + + assert!( + store + .claim_stdout_owner(&execution_id, "owner-a", Duration::from_millis(25)) + .await + .expect("owner-a claims stdout") + ); + assert!( + store + .append_event_if_stdout_owner( + &thread_key, + &execution_id, + "owner-a", + Duration::from_millis(25), + "session.output.line", + json!("line-from-owner-a"), + ) + .await + .expect("owner-a appends") + .is_some() + ); + assert!( + store + .append_event_if_stdout_owner( + &thread_key, + &execution_id, + "owner-b", + Duration::from_millis(25), + "session.output.line", + json!("line-from-stale-owner-b"), + ) + .await + .expect("owner-b append is fenced") + .is_none() + ); + assert!( + store + .complete_execution_if_active_and_stdout_owner(&execution_id, "owner-b") + .await + .expect("owner-b terminal update is fenced") + .is_none() + ); + + tokio::time::sleep(Duration::from_millis(40)).await; + assert!( + store + .claim_expired_stdout_owner(&execution_id, "owner-b", Duration::from_secs(5)) + .await + .expect("owner-b claims after lease expiry") + ); + assert!( + store + .append_event_if_stdout_owner( + &thread_key, + &execution_id, + "owner-a", + Duration::from_secs(5), + "session.output.line", + json!("line-from-expired-owner-a"), + ) + .await + .expect("expired owner-a append is fenced") + .is_none() + ); + let completed = store + .complete_execution_if_active_and_stdout_owner(&execution_id, "owner-b") + .await + .expect("owner-b completes") + .expect("completion should be recorded"); + assert_eq!( + completed.status, + centaur_session_core::ExecutionStatus::Completed + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn warm_eviction_reservation_blocks_later_claims() { let Some(store) = test_store().await else { From 10f1db104a172b93ed0a88977b7b082d4083a885 Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:52:21 -0700 Subject: [PATCH 026/198] feat(chart): add console.slackOauth flag for Slack console sign-in (#862) Co-authored-by: Claude Fable 5 --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/console.yaml | 18 ++++++++++++++++++ contrib/chart/values.yaml | 11 +++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 9721eea0b..b5149c029 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.83 +version: 0.1.84 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/console.yaml b/contrib/chart/templates/console.yaml index 4268c6d56..87c6d24f9 100644 --- a/contrib/chart/templates/console.yaml +++ b/contrib/chart/templates/console.yaml @@ -160,6 +160,24 @@ spec: secretKeyRef: name: {{ $secretEnv }} key: {{ printf "%sIRON_CONTROL_GOOGLE_CLIENT_SECRET" $prefix }} +{{- end }} +{{- if $console.slackOauth.enabled }} + # Slack OIDC app credentials (console sign-in only — distinct from + # the DB-managed Slack OAuth app the bot/DM sync uses). Gated on + # console.slackOauth.enabled; the keys must exist in the shared + # infra Secret when enabled. Uses the modern CENTAUR_CONSOLE_* + # names (unlike the legacy IRON_CONTROL_* keys above) since this + # block postdates the rename. + - name: CENTAUR_CONSOLE_SLACK_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sCENTAUR_CONSOLE_SLACK_CLIENT_ID" $prefix }} + - name: CENTAUR_CONSOLE_SLACK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sCENTAUR_CONSOLE_SLACK_CLIENT_SECRET" $prefix }} {{- end }} - name: RAILS_ENV value: {{ $console.railsEnv | quote }} diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index fc6a7a241..e14479702 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -106,6 +106,17 @@ console: # _SECRET), which must exist or the console pods CreateContainerConfigError. googleOauth: enabled: false + # Slack as an OIDC identity provider for console sign-in. Off by default — + # not every deployment uses Slack to log in. When enabled, the + # CENTAUR_CONSOLE_SLACK_CLIENT_ID and CENTAUR_CONSOLE_SLACK_CLIENT_SECRET + # env vars are sourced from the shared infra Secret (keys + # CENTAUR_CONSOLE_SLACK_CLIENT_ID / _SECRET), which + # must exist or the console pods CreateContainerConfigError. This is the + # Sign-in-with-Slack OIDC app (scopes openid/email/profile, redirect URL + # /auth/slack/callback) — not the DB-managed Slack OAuth app the + # bot and DM sync use. + slackOauth: + enabled: false # OAuth app slug whose user-level Slack credentials are used by the DM sync # worker. Keep this aligned with the Console OAuth app users authorize. slackDmSync: From 2a562e651b6b080762b94e4dff528e4ab6ce12f2 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 1 Jul 2026 21:00:51 -0700 Subject: [PATCH 027/198] fix: restrict non-observability sandbox egress (#858) * fix: restrict non-observability sandbox egress * fix: whitelist observable cluster egress explicitly * fix: allow in-cluster database egress * fix: tighten restricted sandbox egress review gaps * fix: use configured control plane network peer * fix: keep restricted blocklist observability scoped * fix: allow direct victoria observability egress * fix: configure sandbox observability egress explicitly * fix: derive observability egress from endpoints * fix: avoid hardcoded observability endpoints * fix: allow sandbox egress to api pods --- .../crates/centaur-api-server/src/args.rs | 117 ++++- .../src/iron_proxy.rs | 455 ++++++++++++++++-- .../centaur-sandbox-agent-k8s/src/lib.rs | 5 + 3 files changed, 541 insertions(+), 36 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index f49e91d6c..7c6e7b847 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -1085,6 +1085,43 @@ impl SandboxArgs { } } + fn sandbox_observability_egress_targets(&self) -> Result, ServerError> { + if !matches!(self.workload, SandboxWorkloadKind::CodexAppServer) { + return Ok(Vec::new()); + } + let envs = self.codex_app_server_env_template()?; + Ok(["VICTORIAMETRICS_URL", "VICTORIALOGS_URL"] + .into_iter() + .filter_map(|key| { + let endpoint = envs + .iter() + .find(|(name, value)| name == key && !value.trim().is_empty()) + .map(|(_, value)| value.trim().to_owned())?; + match parse_otlp_egress_target(&endpoint) { + Some(target) => { + info!( + env = key, + namespace = %target.namespace, + port = target.port, + endpoint = %endpoint, + "sandbox observability egress enabled" + ); + Some(target) + } + None => { + warn!( + env = key, + endpoint = %endpoint, + "sandbox observability endpoint is not an in-cluster service DNS name; \ + no sandbox egress NetworkPolicy rule will be created for it" + ); + None + } + } + }) + .collect()) + } + fn workflow_host_env_template(&self) -> Result, ServerError> { let mut envs = vec![("CENTAUR_API_URL".to_owned(), self.centaur_api_url())]; @@ -1316,10 +1353,9 @@ impl TryFrom<&SandboxArgs> for AgentSandboxConfig { } config.iron_control = args.iron_control.settings(); config.tools = args.tools_source.to_config(); - // Direct harness OTLP export (codex usage/cost spans) needs a hole in - // the per-sandbox egress NetworkPolicy; derived from the sandbox's own - // OTLP endpoint env so there is a single source of truth. + // Direct harness OTLP export needs a per-sandbox egress rule. config.otlp_egress = args.sandbox_otlp_egress_target()?; + config.observability_egress = args.sandbox_observability_egress_targets()?; // iron-control is the only proxy mode: a per-sandbox proxy syncs its // secrets from the control plane, so configuring iron-proxy without // iron-control would produce a non-functional proxy. Fail fast. @@ -1855,9 +1891,9 @@ fn parse_host_port(value: &str) -> Option { value.rsplit_once(':')?.1.parse().ok() } -/// Map an OTLP endpoint URL onto a NetworkPolicy egress target. Only -/// in-cluster service DNS hosts (`..svc[.]`) -/// are mapped; the namespace label is the policy's `kubernetes.io/metadata.name` +/// Map an OTLP/observability endpoint URL onto a NetworkPolicy egress target. +/// Only in-cluster service DNS hosts (`..svc[...]`) are +/// mapped; the namespace label is the policy's `kubernetes.io/metadata.name` /// selector. Ports default by scheme when absent. fn parse_otlp_egress_target(endpoint: &str) -> Option { let trimmed = endpoint.trim(); @@ -2499,6 +2535,75 @@ mod tests { assert_eq!(mock.sandbox.sandbox_otlp_egress_target().unwrap(), None); } + #[test] + fn sandbox_observability_egress_absent_without_endpoint_env() { + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + "--session-sandbox-workload", + "codex-app-server", + ]) + .unwrap(); + + assert!( + args.sandbox + .sandbox_observability_egress_targets() + .unwrap() + .is_empty() + ); + } + + #[test] + fn sandbox_observability_egress_uses_endpoint_env() { + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + "--session-sandbox-workload", + "codex-app-server", + "--session-sandbox-extra-env", + r#"[ + {"name":"VICTORIAMETRICS_URL","value":"http://victoriametrics.telemetry.svc:18428"}, + {"name":"VICTORIALOGS_URL","value":"https://victorialogs.logs.svc.cluster.local"} + ]"#, + ]) + .unwrap(); + + assert_eq!( + args.sandbox.sandbox_observability_egress_targets().unwrap(), + vec![ + OtlpEgressTarget { + namespace: "telemetry".to_owned(), + port: 18428, + }, + OtlpEgressTarget { + namespace: "logs".to_owned(), + port: 443, + }, + ] + ); + } + + #[test] + fn sandbox_observability_egress_absent_for_mock_workload() { + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + "--session-sandbox-extra-env", + r#"[{"name":"VICTORIAMETRICS_URL","value":"http://victoriametrics.telemetry.svc:18428"}]"#, + ]) + .unwrap(); + + assert!( + args.sandbox + .sandbox_observability_egress_targets() + .unwrap() + .is_empty() + ); + } + /// The only test that mutates the process-level OTLP env keys: keeps all /// assertions that depend on their presence or absence sequential so /// parallel tests never race on them. diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs index 0be0c87dd..e1f7d8e5b 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs @@ -9,7 +9,7 @@ use k8s_openapi::api::core::v1::{ SecurityContext, Service, ServicePort, ServiceSpec, Volume, VolumeMount, }; use k8s_openapi::api::networking::v1::{ - NetworkPolicy, NetworkPolicyEgressRule, NetworkPolicyIngressRule, NetworkPolicyPeer, + IPBlock, NetworkPolicy, NetworkPolicyEgressRule, NetworkPolicyIngressRule, NetworkPolicyPeer, NetworkPolicyPort, NetworkPolicySpec, }; use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta}; @@ -83,6 +83,7 @@ pub struct IronProxyConfig { pub op_connect_app_name: String, pub op_connect_port: u16, pub api_pod_labels: BTreeMap, + pub control_plane_pod_labels: BTreeMap, } impl IronProxyConfig { @@ -106,6 +107,10 @@ impl IronProxyConfig { "app.kubernetes.io/component".to_owned(), "api".to_owned(), )]), + control_plane_pod_labels: BTreeMap::from([( + "app.kubernetes.io/component".to_owned(), + "console".to_owned(), + )]), } } } @@ -131,6 +136,7 @@ pub(crate) struct ResolvedIronProxy { // random per proxy pod. The claim barrier reads it back off the live pod // env, so it survives api-rs restarts and respects env overrides. management_api_key: String, + observability_enabled: bool, } /// The single Postgres listener the proxy multiplexes every upstream through. @@ -158,6 +164,11 @@ struct ProxySyncEnv { token: String, } +struct ControlPlaneEgressTarget { + peer: NetworkPolicyPeer, + port: u16, +} + impl AgentSandboxBackend { pub(crate) async fn resolve_iron_proxy( &self, @@ -189,6 +200,7 @@ impl AgentSandboxBackend { principal_id, pg, replace_placeholders, + spec.capabilities.observability_enabled, ))) } @@ -261,11 +273,21 @@ impl AgentSandboxBackend { }; let pg = self.resolved_pg(); let replace_placeholders = self.effective_replace_placeholders(&principal_id).await?; + let observability_enabled = sandbox_observability_enabled(&sandbox, &self.config.container_name) + .unwrap_or_else(|| { + tracing::warn!( + sandbox_id = id.as_str(), + container_name = self.config.container_name.as_str(), + "sandbox observability capability env is missing or invalid; defaulting to enabled network policy" + ); + true + }); Ok(Some(self.resolved_iron_proxy_for_principal( id, principal_id, pg, replace_placeholders, + observability_enabled, ))) } @@ -275,6 +297,7 @@ impl AgentSandboxBackend { principal_id: String, pg: Option, replace_placeholders: BTreeMap, + observability_enabled: bool, ) -> ResolvedIronProxy { ResolvedIronProxy { proxy_host: iron_proxy_service_name(id), @@ -284,6 +307,7 @@ impl AgentSandboxBackend { pg, replace_placeholders, management_api_key: new_proxy_management_api_key(), + observability_enabled, } } @@ -304,13 +328,19 @@ impl AgentSandboxBackend { ) .await .map_err(|err| map_kube_error("create iron-proxy service", err))?; - let control_port = url_port(&sync.control_url).unwrap_or(443); + let control_target = control_plane_egress_target( + &sync.control_url, + &self.config.namespace, + iron_proxy.control_plane_pod_labels.clone(), + ); for policy in build_iron_proxy_network_policies( id, resolved, iron_proxy, - control_port, + &control_target, self.config.otlp_egress.as_ref(), + &self.config.observability_egress, + resolved.observability_enabled, ) { self.network_policies() .create(&PostParams::default(), &policy) @@ -544,8 +574,24 @@ impl AgentSandboxBackend { let pg = self.resolved_pg_for_repair(sandbox.as_ref()); let principal_id = principal_id.to_owned(); let replace_placeholders = self.effective_replace_placeholders(&principal_id).await?; - let resolved = - self.resolved_iron_proxy_for_principal(id, principal_id, pg, replace_placeholders); + let observability_enabled = sandbox + .as_ref() + .and_then(|sandbox| sandbox_observability_enabled(sandbox, &self.config.container_name)) + .unwrap_or_else(|| { + tracing::warn!( + sandbox_id = id.as_str(), + container_name = self.config.container_name.as_str(), + "sandbox observability capability env is missing or invalid during proxy repair; defaulting to enabled network policy" + ); + true + }); + let resolved = self.resolved_iron_proxy_for_principal( + id, + principal_id, + pg, + replace_placeholders, + observability_enabled, + ); self.create_iron_proxy_resources(id, Some(&resolved)) .await?; if let Some(sandbox) = sandbox @@ -1257,8 +1303,10 @@ fn build_iron_proxy_network_policies( id: &SandboxId, resolved: &ResolvedIronProxy, iron_proxy: &IronProxyConfig, - control_port: u16, + control_target: &ControlPlaneEgressTarget, otlp_egress: Option<&OtlpEgressTarget>, + observability_egress: &[OtlpEgressTarget], + observability_enabled: bool, ) -> Vec { let sandbox_to_proxy_ports = sandbox_to_proxy_ports(resolved); let mut sandbox_egress = vec![ @@ -1266,13 +1314,21 @@ fn build_iron_proxy_network_policies( vec![pod_peer(iron_proxy_labels(id))], sandbox_to_proxy_ports.clone(), ), + dns_egress_rule(), egress_to( vec![pod_peer(iron_proxy.api_pod_labels.clone())], vec![network_port(8000), network_port(8080)], ), - dns_egress_rule(), ]; - if let Some(target) = otlp_egress { + if observability_enabled { + sandbox_egress.extend(observability_egress.iter().map(|target| { + egress_to( + vec![namespace_peer(&target.namespace)], + vec![network_port(target.port)], + ) + })); + } + if observability_enabled && let Some(target) = otlp_egress { // Direct harness OTLP export (codex usage/cost spans). The collector // lives outside this namespace, so the sandbox bypasses iron-proxy for // this one destination (the endpoint host also rides NO_PROXY). @@ -1311,7 +1367,12 @@ fn build_iron_proxy_network_policies( ports: Some(vec![network_port(PROXY_MANAGEMENT_PORT)]), }, ]), - egress: Some(proxy_egress_rules(iron_proxy, control_port)), + egress: Some(proxy_egress_rules( + iron_proxy, + control_target, + otlp_egress, + observability_enabled, + )), }), }, ] @@ -1325,25 +1386,37 @@ fn sandbox_to_proxy_ports(resolved: &ResolvedIronProxy) -> Vec, + observability_enabled: bool, ) -> Vec { // Upstream egress: 443/5432 for normal traffic, plus the iron-control port - // (deduped) so a sync-mode proxy can reach the control plane. - let mut upstream_ports = vec![network_port(443), network_port(5432)]; - if control_port != 443 && control_port != 5432 { - upstream_ports.push(network_port(control_port)); - } - let mut rules = vec![ - dns_egress_rule(), - egress_to( + // (deduped) so a sync-mode proxy can reach the control plane. Public + // upstreams are always constrained away from private/cluster CIDRs; any + // intra-cluster destination must be added as an explicit rule below. + let upstream_ports = vec![network_port(443), network_port(5432)]; + let mut rules = vec![dns_egress_rule()]; + rules.push(egress_to( + vec![control_target.peer.clone()], + vec![network_port(control_target.port)], + )); + rules.push(egress_to( + vec![all_namespaces_peer()], + vec![network_port(PG_LISTENER_PORT)], + )); + rules.push(egress_to(vec![public_ipv4_peer()], upstream_ports)); + if observability_enabled { + rules.push(egress_to( vec![pod_peer(iron_proxy.api_pod_labels.clone())], vec![network_port(8000), network_port(8080)], - ), - NetworkPolicyEgressRule { - ports: Some(upstream_ports), - ..Default::default() - }, - ]; + )); + if let Some(target) = otlp_egress { + rules.push(egress_to( + vec![namespace_peer(&target.namespace)], + vec![network_port(target.port)], + )); + } + } if matches!( iron_proxy.source_policy.kind, SourceKind::OnePasswordConnect @@ -1376,6 +1449,60 @@ fn namespace_peer(namespace: &str) -> NetworkPolicyPeer { } } +fn namespace_pod_peer(namespace: &str, labels: BTreeMap) -> NetworkPolicyPeer { + NetworkPolicyPeer { + namespace_selector: Some(label_selector(BTreeMap::from([( + "kubernetes.io/metadata.name".to_owned(), + namespace.to_owned(), + )]))), + pod_selector: Some(label_selector(labels)), + ..Default::default() + } +} + +fn all_namespaces_peer() -> NetworkPolicyPeer { + NetworkPolicyPeer { + namespace_selector: Some(LabelSelector::default()), + ..Default::default() + } +} + +fn public_ipv4_peer() -> NetworkPolicyPeer { + NetworkPolicyPeer { + ip_block: Some(IPBlock { + cidr: "0.0.0.0/0".to_owned(), + except: Some(vec![ + "0.0.0.0/8".to_owned(), + "10.0.0.0/8".to_owned(), + "100.64.0.0/10".to_owned(), + "127.0.0.0/8".to_owned(), + "169.254.0.0/16".to_owned(), + "172.16.0.0/12".to_owned(), + "192.0.0.0/24".to_owned(), + "192.0.2.0/24".to_owned(), + "192.168.0.0/16".to_owned(), + "198.18.0.0/15".to_owned(), + "198.51.100.0/24".to_owned(), + "203.0.113.0/24".to_owned(), + "224.0.0.0/4".to_owned(), + "240.0.0.0/4".to_owned(), + ]), + }), + ..Default::default() + } +} + +fn control_plane_egress_target( + control_url: &str, + default_namespace: &str, + control_plane_pod_labels: BTreeMap, +) -> ControlPlaneEgressTarget { + ControlPlaneEgressTarget { + peer: namespace_pod_peer(default_namespace, control_plane_pod_labels), + port: url_port(control_url).unwrap_or(443), + } +} + fn proxy_env( proxy_host: &str, proxy_port: u16, @@ -1483,6 +1610,38 @@ fn pg_from_sandbox_env( pg_from_sandbox_dsn(dsn, listen, port) } +fn sandbox_observability_enabled( + sandbox: &crate::crd::Sandbox, + container_name: &str, +) -> Option { + sandbox_env_value( + sandbox, + "CENTAUR_SANDBOX_OBSERVABILITY_ENABLED", + container_name, + ) + .and_then(|value| value.parse().ok()) +} + +fn sandbox_env_value( + sandbox: &crate::crd::Sandbox, + name: &str, + fallback_container_name: &str, +) -> Option { + sandbox + .spec + .pod_template + .spec + .containers + .iter() + .find(|container| container.name == fallback_container_name) + .or_else(|| sandbox.spec.pod_template.spec.containers.first())? + .env + .as_ref()? + .iter() + .find(|env| env.name == name) + .and_then(|env| env.value.clone()) +} + fn pg_from_sandbox_dsn(dsn: &str, listen: &str, port: u16) -> Option { let rest = dsn .strip_prefix("postgresql://") @@ -1758,9 +1917,45 @@ mod tests { pg: None, replace_placeholders: BTreeMap::new(), management_api_key: "test-management-key".to_owned(), + observability_enabled: true, + } + } + + fn control_target() -> ControlPlaneEgressTarget { + ControlPlaneEgressTarget { + peer: namespace_pod_peer( + "centaur", + BTreeMap::from([( + "app.kubernetes.io/component".to_owned(), + "console".to_owned(), + )]), + ), + port: 3000, } } + fn peer_namespace(peer: &NetworkPolicyPeer) -> Option<&str> { + peer.namespace_selector + .as_ref()? + .match_labels + .as_ref()? + .get("kubernetes.io/metadata.name") + .map(String::as_str) + } + + fn peer_component(peer: &NetworkPolicyPeer) -> Option<&str> { + peer.pod_selector + .as_ref()? + .match_labels + .as_ref()? + .get("app.kubernetes.io/component") + .map(String::as_str) + } + + fn control_peer(target: &ControlPlaneEgressTarget) -> &NetworkPolicyPeer { + &target.peer + } + fn rule_allows_namespace_port( rule: &NetworkPolicyEgressRule, namespace: &str, @@ -1784,17 +1979,78 @@ mod tests { }) } + fn rule_allows_all_namespaces_port(rule: &NetworkPolicyEgressRule, port: u16) -> bool { + rule.to.as_ref().is_some_and(|peers| { + peers.iter().any(|peer| { + peer.namespace_selector + .as_ref() + .is_some_and(|selector| selector.match_labels.is_none()) + }) + }) && rule.ports.as_ref().is_some_and(|ports| { + ports + .iter() + .any(|policy_port| policy_port.port == Some(IntOrString::Int(i32::from(port)))) + }) + } + + fn rule_allows_public_port(rule: &NetworkPolicyEgressRule, port: u16) -> bool { + rule.to.as_ref().is_some_and(|peers| { + peers.iter().any(|peer| { + peer.ip_block + .as_ref() + .is_some_and(|block| block.cidr == "0.0.0.0/0") + }) + }) && rule.ports.as_ref().is_some_and(|ports| { + ports + .iter() + .any(|policy_port| policy_port.port == Some(IntOrString::Int(i32::from(port)))) + }) + } + + #[test] + fn control_plane_egress_target_uses_configured_namespace_and_labels() { + let target = control_plane_egress_target( + "http://prod-centaur-console:3000", + "centaur", + BTreeMap::from([( + "app.kubernetes.io/component".to_owned(), + "console".to_owned(), + )]), + ); + assert_eq!(target.port, 3000); + assert_eq!(peer_namespace(control_peer(&target)), Some("centaur")); + assert_eq!(peer_component(control_peer(&target)), Some("console")); + } + #[test] fn sandbox_egress_policy_allows_otlp_collector_when_configured() { let id = SandboxId::new("asbx-test"); let iron_proxy = IronProxyConfig::new("proxy:test", "ca-cert", "ca-key"); + let control_target = control_target(); let target = OtlpEgressTarget { namespace: "laminar".to_owned(), port: 8000, }; - - let policies = - build_iron_proxy_network_policies(&id, &resolved(), &iron_proxy, 3000, Some(&target)); + let observability_targets = vec![ + OtlpEgressTarget { + namespace: "observability".to_owned(), + port: 8428, + }, + OtlpEgressTarget { + namespace: "observability".to_owned(), + port: 9428, + }, + ]; + + let policies = build_iron_proxy_network_policies( + &id, + &resolved(), + &iron_proxy, + &control_target, + Some(&target), + &observability_targets, + true, + ); let sandbox_egress = policies[0] .spec .as_ref() @@ -1808,8 +2064,33 @@ mod tests { .iter() .any(|rule| rule_allows_namespace_port(rule, "laminar", 8000)) ); - - let policies = build_iron_proxy_network_policies(&id, &resolved(), &iron_proxy, 3000, None); + assert!(sandbox_egress.iter().any(|rule| rule_allows_namespace_port( + rule, + "observability", + 8428 + ))); + assert!(sandbox_egress.iter().any(|rule| rule_allows_namespace_port( + rule, + "observability", + 9428 + ))); + let proxy_egress = policies[1].spec.as_ref().unwrap().egress.as_ref().unwrap(); + assert!( + proxy_egress + .iter() + .any(|rule| rule_allows_namespace_port(rule, "laminar", 8000)) + ); + assert!(!proxy_egress.iter().any(|rule| rule.to.is_none())); + + let policies = build_iron_proxy_network_policies( + &id, + &resolved(), + &iron_proxy, + &control_target, + None, + &observability_targets, + true, + ); let sandbox_egress = policies[0] .spec .as_ref() @@ -1825,6 +2106,111 @@ mod tests { ); } + #[test] + fn restricted_sandbox_and_proxy_policies_block_internal_cluster_egress() { + let id = SandboxId::new("asbx-test"); + let iron_proxy = IronProxyConfig::new("proxy:test", "ca-cert", "ca-key"); + let control_target = control_target(); + let target = OtlpEgressTarget { + namespace: "laminar".to_owned(), + port: 8000, + }; + let observability_targets = vec![ + OtlpEgressTarget { + namespace: "observability".to_owned(), + port: 8428, + }, + OtlpEgressTarget { + namespace: "observability".to_owned(), + port: 9428, + }, + ]; + + let policies = build_iron_proxy_network_policies( + &id, + &resolved(), + &iron_proxy, + &control_target, + Some(&target), + &observability_targets, + false, + ); + let sandbox_egress = policies[0].spec.as_ref().unwrap().egress.as_ref().unwrap(); + assert!( + !sandbox_egress + .iter() + .any(|rule| rule_allows_namespace_port(rule, "laminar", 8000)) + ); + assert!( + !sandbox_egress.iter().any(|rule| rule_allows_namespace_port( + rule, + "observability", + 8428 + )) + ); + assert!( + !sandbox_egress.iter().any(|rule| rule_allows_namespace_port( + rule, + "observability", + 9428 + )) + ); + assert!(sandbox_egress.iter().any(|rule| { + rule.to.as_ref().is_some_and(|peers| { + peers.iter().any(|peer| { + peer.pod_selector.as_ref().is_some_and(|selector| { + selector.match_labels.as_ref() == Some(&iron_proxy.api_pod_labels) + }) + }) + }) + })); + + let proxy_egress = policies[1].spec.as_ref().unwrap().egress.as_ref().unwrap(); + assert!( + !proxy_egress + .iter() + .any(|rule| rule_allows_namespace_port(rule, "laminar", 8000)) + ); + assert!(!proxy_egress.iter().any(|rule| { + rule.to.as_ref().is_some_and(|peers| { + peers.iter().any(|peer| { + peer.pod_selector.as_ref().is_some_and(|selector| { + selector.match_labels.as_ref() == Some(&iron_proxy.api_pod_labels) + }) + }) + }) + })); + assert!(proxy_egress.iter().any(|rule| { + rule.to.as_ref().is_some_and(|peers| { + peers.iter().any(|peer| { + peer.ip_block.as_ref().is_some_and(|block| { + block.cidr == "0.0.0.0/0" + && block.except.as_ref().is_some_and(|except| { + except.iter().any(|cidr| cidr == "10.0.0.0/8") + && except.iter().any(|cidr| cidr == "172.16.0.0/12") + && except.iter().any(|cidr| cidr == "192.168.0.0/16") + }) + }) + }) + }) + })); + assert!( + proxy_egress + .iter() + .any(|rule| rule_allows_namespace_port(rule, "centaur", 3000)) + ); + assert!( + proxy_egress + .iter() + .any(|rule| rule_allows_all_namespaces_port(rule, 5432)) + ); + assert!( + !proxy_egress + .iter() + .any(|rule| rule_allows_public_port(rule, 3000)) + ); + } + #[test] fn managed_proxy_env_sets_response_header_timeout() { let iron_proxy = IronProxyConfig::new("proxy:test", "ca-cert", "ca-key"); @@ -1869,7 +2255,16 @@ mod tests { let id = SandboxId::new("asbx-test"); let iron_proxy = IronProxyConfig::new("proxy:test", "ca-cert", "ca-key"); - let policies = build_iron_proxy_network_policies(&id, &resolved(), &iron_proxy, 3000, None); + let control_target = control_target(); + let policies = build_iron_proxy_network_policies( + &id, + &resolved(), + &iron_proxy, + &control_target, + None, + &[], + true, + ); let ingress = policies[1] .spec .as_ref() diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index d58a7fb64..22c51849d 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -69,6 +69,10 @@ pub struct AgentSandboxConfig { /// destinations except the proxy/control plane, so without this rule the /// harness's usage/cost spans never leave the pod. pub otlp_egress: Option, + /// Direct in-cluster observability services reached through NO_PROXY by + /// tools such as vlogs/vmetrics. Enabled only for observability-capable + /// sandboxes. + pub observability_egress: Vec, pub ready_timeout: Duration, } @@ -110,6 +114,7 @@ impl AgentSandboxConfig { iron_control: None, tools: None, otlp_egress: None, + observability_egress: Vec::new(), ready_timeout: Duration::from_secs(60), } } From 4bae192e50debe4c077083fbc55b425a2b6c4bf7 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 1 Jul 2026 21:46:29 -0700 Subject: [PATCH 028/198] fix: label observable sandboxes for egress (#864) --- .../crates/centaur-api-server/src/args.rs | 129 ++---------------- .../src/iron_proxy.rs | 73 +--------- .../centaur-sandbox-agent-k8s/src/lib.rs | 76 +++++++++-- 3 files changed, 81 insertions(+), 197 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index 7c6e7b847..ee754d7f6 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -1041,11 +1041,10 @@ impl SandboxArgs { .collect() } - /// Per-sandbox OTLP egress NetworkPolicy target, derived from the OTLP - /// endpoint the codex sandbox env will carry. Only in-cluster service DNS - /// endpoints (`..svc[...]`) map to a namespace - /// selector; anything else gets no rule and a warning, because a silently - /// missing rule means harness usage/cost spans never reach the collector. + /// Per-sandbox proxy OTLP egress NetworkPolicy target, derived from the + /// OTLP endpoint the codex sandbox env will carry. Only in-cluster service + /// DNS endpoints (`..svc[...]`) map to a namespace + /// selector. fn sandbox_otlp_egress_target(&self) -> Result, ServerError> { if !matches!(self.workload, SandboxWorkloadKind::CodexAppServer) { return Ok(None); @@ -1070,7 +1069,7 @@ impl SandboxArgs { namespace = %target.namespace, port = target.port, endpoint = %endpoint, - "sandbox OTLP egress enabled" + "sandbox proxy OTLP egress enabled" ); Ok(Some(target)) } @@ -1078,50 +1077,13 @@ impl SandboxArgs { warn!( endpoint = %endpoint, "sandbox OTLP endpoint is not an in-cluster service DNS name; \ - no sandbox egress NetworkPolicy rule will be created for it" + no proxy egress NetworkPolicy rule will be created for it" ); Ok(None) } } } - fn sandbox_observability_egress_targets(&self) -> Result, ServerError> { - if !matches!(self.workload, SandboxWorkloadKind::CodexAppServer) { - return Ok(Vec::new()); - } - let envs = self.codex_app_server_env_template()?; - Ok(["VICTORIAMETRICS_URL", "VICTORIALOGS_URL"] - .into_iter() - .filter_map(|key| { - let endpoint = envs - .iter() - .find(|(name, value)| name == key && !value.trim().is_empty()) - .map(|(_, value)| value.trim().to_owned())?; - match parse_otlp_egress_target(&endpoint) { - Some(target) => { - info!( - env = key, - namespace = %target.namespace, - port = target.port, - endpoint = %endpoint, - "sandbox observability egress enabled" - ); - Some(target) - } - None => { - warn!( - env = key, - endpoint = %endpoint, - "sandbox observability endpoint is not an in-cluster service DNS name; \ - no sandbox egress NetworkPolicy rule will be created for it" - ); - None - } - } - }) - .collect()) - } - fn workflow_host_env_template(&self) -> Result, ServerError> { let mut envs = vec![("CENTAUR_API_URL".to_owned(), self.centaur_api_url())]; @@ -1353,9 +1315,9 @@ impl TryFrom<&SandboxArgs> for AgentSandboxConfig { } config.iron_control = args.iron_control.settings(); config.tools = args.tools_source.to_config(); - // Direct harness OTLP export needs a per-sandbox egress rule. + // The chart label policy handles sandbox OTLP egress; keep the + // per-sandbox proxy's own in-cluster OTLP egress explicit. config.otlp_egress = args.sandbox_otlp_egress_target()?; - config.observability_egress = args.sandbox_observability_egress_targets()?; // iron-control is the only proxy mode: a per-sandbox proxy syncs its // secrets from the control plane, so configuring iron-proxy without // iron-control would produce a non-functional proxy. Fail fast. @@ -1891,9 +1853,9 @@ fn parse_host_port(value: &str) -> Option { value.rsplit_once(':')?.1.parse().ok() } -/// Map an OTLP/observability endpoint URL onto a NetworkPolicy egress target. -/// Only in-cluster service DNS hosts (`..svc[...]`) are -/// mapped; the namespace label is the policy's `kubernetes.io/metadata.name` +/// Map an OTLP endpoint URL onto a NetworkPolicy egress target. Only +/// in-cluster service DNS hosts (`..svc[...]`) are mapped; +/// the namespace label is the policy's `kubernetes.io/metadata.name` /// selector. Ports default by scheme when absent. fn parse_otlp_egress_target(endpoint: &str) -> Option { let trimmed = endpoint.trim(); @@ -2535,75 +2497,6 @@ mod tests { assert_eq!(mock.sandbox.sandbox_otlp_egress_target().unwrap(), None); } - #[test] - fn sandbox_observability_egress_absent_without_endpoint_env() { - let args = Args::try_parse_from([ - "centaur-api-server", - "--database-url", - "postgres://postgres:postgres@localhost/centaur", - "--session-sandbox-workload", - "codex-app-server", - ]) - .unwrap(); - - assert!( - args.sandbox - .sandbox_observability_egress_targets() - .unwrap() - .is_empty() - ); - } - - #[test] - fn sandbox_observability_egress_uses_endpoint_env() { - let args = Args::try_parse_from([ - "centaur-api-server", - "--database-url", - "postgres://postgres:postgres@localhost/centaur", - "--session-sandbox-workload", - "codex-app-server", - "--session-sandbox-extra-env", - r#"[ - {"name":"VICTORIAMETRICS_URL","value":"http://victoriametrics.telemetry.svc:18428"}, - {"name":"VICTORIALOGS_URL","value":"https://victorialogs.logs.svc.cluster.local"} - ]"#, - ]) - .unwrap(); - - assert_eq!( - args.sandbox.sandbox_observability_egress_targets().unwrap(), - vec![ - OtlpEgressTarget { - namespace: "telemetry".to_owned(), - port: 18428, - }, - OtlpEgressTarget { - namespace: "logs".to_owned(), - port: 443, - }, - ] - ); - } - - #[test] - fn sandbox_observability_egress_absent_for_mock_workload() { - let args = Args::try_parse_from([ - "centaur-api-server", - "--database-url", - "postgres://postgres:postgres@localhost/centaur", - "--session-sandbox-extra-env", - r#"[{"name":"VICTORIAMETRICS_URL","value":"http://victoriametrics.telemetry.svc:18428"}]"#, - ]) - .unwrap(); - - assert!( - args.sandbox - .sandbox_observability_egress_targets() - .unwrap() - .is_empty() - ); - } - /// The only test that mutates the process-level OTLP env keys: keeps all /// assertions that depend on their presence or absence sequential so /// parallel tests never race on them. diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs index e1f7d8e5b..1a9e2a6de 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs @@ -339,7 +339,6 @@ impl AgentSandboxBackend { iron_proxy, &control_target, self.config.otlp_egress.as_ref(), - &self.config.observability_egress, resolved.observability_enabled, ) { self.network_policies() @@ -1305,11 +1304,10 @@ fn build_iron_proxy_network_policies( iron_proxy: &IronProxyConfig, control_target: &ControlPlaneEgressTarget, otlp_egress: Option<&OtlpEgressTarget>, - observability_egress: &[OtlpEgressTarget], observability_enabled: bool, ) -> Vec { let sandbox_to_proxy_ports = sandbox_to_proxy_ports(resolved); - let mut sandbox_egress = vec![ + let sandbox_egress = vec![ egress_to( vec![pod_peer(iron_proxy_labels(id))], sandbox_to_proxy_ports.clone(), @@ -1320,23 +1318,6 @@ fn build_iron_proxy_network_policies( vec![network_port(8000), network_port(8080)], ), ]; - if observability_enabled { - sandbox_egress.extend(observability_egress.iter().map(|target| { - egress_to( - vec![namespace_peer(&target.namespace)], - vec![network_port(target.port)], - ) - })); - } - if observability_enabled && let Some(target) = otlp_egress { - // Direct harness OTLP export (codex usage/cost spans). The collector - // lives outside this namespace, so the sandbox bypasses iron-proxy for - // this one destination (the endpoint host also rides NO_PROXY). - sandbox_egress.push(egress_to( - vec![namespace_peer(&target.namespace)], - vec![network_port(target.port)], - )); - } vec![ NetworkPolicy { metadata: object_meta( @@ -2023,7 +2004,7 @@ mod tests { } #[test] - fn sandbox_egress_policy_allows_otlp_collector_when_configured() { + fn sandbox_egress_policy_does_not_inline_otlp_collector_rule() { let id = SandboxId::new("asbx-test"); let iron_proxy = IronProxyConfig::new("proxy:test", "ca-cert", "ca-key"); let control_target = control_target(); @@ -2031,16 +2012,6 @@ mod tests { namespace: "laminar".to_owned(), port: 8000, }; - let observability_targets = vec![ - OtlpEgressTarget { - namespace: "observability".to_owned(), - port: 8428, - }, - OtlpEgressTarget { - namespace: "observability".to_owned(), - port: 9428, - }, - ]; let policies = build_iron_proxy_network_policies( &id, @@ -2048,7 +2019,6 @@ mod tests { &iron_proxy, &control_target, Some(&target), - &observability_targets, true, ); let sandbox_egress = policies[0] @@ -2060,20 +2030,10 @@ mod tests { .unwrap() .clone(); assert!( - sandbox_egress + !sandbox_egress .iter() .any(|rule| rule_allows_namespace_port(rule, "laminar", 8000)) ); - assert!(sandbox_egress.iter().any(|rule| rule_allows_namespace_port( - rule, - "observability", - 8428 - ))); - assert!(sandbox_egress.iter().any(|rule| rule_allows_namespace_port( - rule, - "observability", - 9428 - ))); let proxy_egress = policies[1].spec.as_ref().unwrap().egress.as_ref().unwrap(); assert!( proxy_egress @@ -2088,7 +2048,6 @@ mod tests { &iron_proxy, &control_target, None, - &observability_targets, true, ); let sandbox_egress = policies[0] @@ -2115,16 +2074,6 @@ mod tests { namespace: "laminar".to_owned(), port: 8000, }; - let observability_targets = vec![ - OtlpEgressTarget { - namespace: "observability".to_owned(), - port: 8428, - }, - OtlpEgressTarget { - namespace: "observability".to_owned(), - port: 9428, - }, - ]; let policies = build_iron_proxy_network_policies( &id, @@ -2132,7 +2081,6 @@ mod tests { &iron_proxy, &control_target, Some(&target), - &observability_targets, false, ); let sandbox_egress = policies[0].spec.as_ref().unwrap().egress.as_ref().unwrap(); @@ -2141,20 +2089,6 @@ mod tests { .iter() .any(|rule| rule_allows_namespace_port(rule, "laminar", 8000)) ); - assert!( - !sandbox_egress.iter().any(|rule| rule_allows_namespace_port( - rule, - "observability", - 8428 - )) - ); - assert!( - !sandbox_egress.iter().any(|rule| rule_allows_namespace_port( - rule, - "observability", - 9428 - )) - ); assert!(sandbox_egress.iter().any(|rule| { rule.to.as_ref().is_some_and(|peers| { peers.iter().any(|peer| { @@ -2262,7 +2196,6 @@ mod tests { &iron_proxy, &control_target, None, - &[], true, ); let ingress = policies[1] diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index 22c51849d..21d961b32 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -37,6 +37,7 @@ const BACKEND_NAME: &str = "agent-sandbox-k8s"; const DEFAULT_CONTAINER_NAME: &str = "agent"; const MANAGED_BY_LABEL: &str = "centaur.ai/managed-by"; const SANDBOX_ID_LABEL: &str = "centaur.ai/sandbox-id"; +const OBSERVABILITY_ENABLED_LABEL: &str = "centaur.ai/observability-enabled"; const MANAGED_BY_VALUE: &str = "api-rs"; // iron-control principal OID the sandbox's proxy binds to, stamped at create // so resume (which has only the sandbox id) can rebind without the spec or any @@ -64,15 +65,10 @@ pub struct AgentSandboxConfig { /// git-clones the tools repo into the agent's `/app/tools`, and `TOOL_DIRS` /// is set so the agent's shim installer finds them. pub tools: Option, - /// In-cluster OTLP collector (e.g. Laminar) the sandbox exports harness - /// traces to directly. The per-sandbox egress NetworkPolicy denies all - /// destinations except the proxy/control plane, so without this rule the - /// harness's usage/cost spans never leave the pod. + /// In-cluster OTLP collector (e.g. Laminar) used for observability-capable + /// sandboxes. Sandbox pod egress is granted by chart-level label policy; + /// the per-sandbox proxy uses this target for its own explicit egress. pub otlp_egress: Option, - /// Direct in-cluster observability services reached through NO_PROXY by - /// tools such as vlogs/vmetrics. Enabled only for observability-capable - /// sandboxes. - pub observability_egress: Vec, pub ready_timeout: Duration, } @@ -114,7 +110,6 @@ impl AgentSandboxConfig { iron_control: None, tools: None, otlp_egress: None, - observability_egress: Vec::new(), ready_timeout: Duration::from_secs(60), } } @@ -576,6 +571,9 @@ fn build_agent_sandbox( labels.extend(spec.labels.clone()); labels.insert(MANAGED_BY_LABEL.to_owned(), MANAGED_BY_VALUE.to_owned()); labels.insert(SANDBOX_ID_LABEL.to_owned(), id.as_str().to_owned()); + if spec.capabilities.observability_enabled { + labels.insert(OBSERVABILITY_ENABLED_LABEL.to_owned(), "true".to_owned()); + } let mut pod_labels = labels.clone(); pod_labels.insert( @@ -908,6 +906,66 @@ mod tests { assert!(container.resources.as_ref().unwrap().limits.is_some()); } + #[test] + fn labels_observability_enabled_sandboxes_for_chart_policy() { + let spec = SandboxSpec::new("centaur-agent:latest").capabilities(SandboxCapabilities { + repo_cache_enabled: true, + observability_enabled: true, + }); + let config = AgentSandboxConfig::new("centaur"); + + let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); + + assert_eq!( + sandbox + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(OBSERVABILITY_ENABLED_LABEL)) + .map(String::as_str), + Some("true") + ); + assert_eq!( + sandbox + .spec + .pod_template + .metadata + .as_ref() + .and_then(|metadata| metadata.labels.as_ref()) + .and_then(|labels| labels.get(OBSERVABILITY_ENABLED_LABEL)) + .map(String::as_str), + Some("true") + ); + } + + #[test] + fn omits_observability_enabled_label_for_restricted_sandboxes() { + let spec = SandboxSpec::new("centaur-agent:latest").capabilities(SandboxCapabilities { + repo_cache_enabled: true, + observability_enabled: false, + }); + let config = AgentSandboxConfig::new("centaur"); + + let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); + + assert!( + sandbox + .metadata + .labels + .as_ref() + .is_none_or(|labels| !labels.contains_key(OBSERVABILITY_ENABLED_LABEL)) + ); + assert!( + sandbox + .spec + .pod_template + .metadata + .as_ref() + .and_then(|metadata| metadata.labels.as_ref()) + .is_none_or(|labels| !labels.contains_key(OBSERVABILITY_ENABLED_LABEL)) + ); + } + #[test] fn tools_clone_rides_iron_proxy_when_enabled() { // apply_proxy_env runs before build_agent_sandbox in create(), so the From 2701e67db7177e80bfa0d03cd49a3f968e4a120e Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:51:29 +0300 Subject: [PATCH 029/198] fix: improve activity summary quality (#850) --- contrib/chart/values.yaml | 2 +- .../src/activity_summary.rs | 583 ++++++++++++++---- .../crates/centaur-api-server/src/args.rs | 2 +- 3 files changed, 470 insertions(+), 117 deletions(-) diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index e14479702..cc3732a96 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -374,7 +374,7 @@ apiRs: enabled: false model: gpt-5.4-nano openaiBaseUrl: https://api.openai.com/v1 - minIntervalSecs: 8 + minIntervalSecs: 20 timeoutSecs: 5 maxFacts: 12 maxOutputTokens: 128 diff --git a/services/api-rs/crates/centaur-api-server/src/activity_summary.rs b/services/api-rs/crates/centaur-api-server/src/activity_summary.rs index 7a203d083..4405a1034 100644 --- a/services/api-rs/crates/centaur-api-server/src/activity_summary.rs +++ b/services/api-rs/crates/centaur-api-server/src/activity_summary.rs @@ -3,7 +3,7 @@ use std::{ time::{Duration, Instant}, }; -use centaur_session_core::{SessionEvent, ThreadKey, ThreadKeyError}; +use centaur_session_core::{MessageRole, SessionEvent, ThreadKey, ThreadKeyError}; use centaur_session_runtime::SESSION_OUTPUT_LINE_EVENT; use centaur_session_sqlx::{PgSessionStore, SessionEventNotification, SessionStoreError}; use reqwest::StatusCode; @@ -16,17 +16,17 @@ pub(crate) const SESSION_ACTIVITY_SUMMARY_EVENT: &str = "session.activity_summar const SYSTEM_PROMPT: &str = "\ You write live status text for a software agent. Use only the supplied event facts. \ -Write one short, conversational first-person present-tense sentence under 45 characters, \ -including spaces, as if you are the agent. Describe the goal you are working toward, \ -not the exact command, file path, ID, flag, or implementation step you are using. \ -Avoid mechanics like running tests, reading output, building images, checking logs, \ -or watching rollouts unless they are the user's explicit goal. If the facts are mostly \ -mechanics, infer the higher-level outcome and omit those mechanics. Prefer short \ -outcomes like \"I'm checking the fix\" or \"I'm getting the preview ready\". \ -Do not mention tests, output, builds, logs, rollouts, commands, paths, IDs, or flags unless the user asked for them. \ -Use user-facing words like fix, preview, update, or summary behavior instead of \ -infrastructure words like server, deployment, or rollout. Do not refer to \"the agent\". \ -Never write more than 45 characters. No markdown, no quotes, no event IDs, and no speculation."; +Write one conversational first-person present-tense sentence under 45 characters, \ +including spaces, as if you are the agent. Describe the current user-facing goal or \ +question you are resolving, not the exact command, file path, ID, flag, or \ +implementation step. Prefer specific noun phrases from the session goal over \ +generic phrases like details, info, items, update, or summary. Use the session goal \ +and facts labeled commentary, plan, or tool before any lower-level facts. If the facts only show \ +setup, help output, dependency installs, builds, command output, logs, tests, or \ +other mechanics, output exactly SKIP. If you cannot say a meaningful new status \ +that differs from the previous one, output exactly SKIP. Do not mention tests, \ +output, builds, logs, rollouts, commands, paths, IDs, or flags unless the user asked \ +for them. Do not refer to \"the agent\". No markdown, no quotes, no event IDs, and no speculation."; #[derive(Clone)] pub(crate) struct ActivitySummaryConfig { @@ -133,18 +133,17 @@ impl ActivitySummaryWorker { let Some(fact) = activity_fact_from_output_event(&event) else { return Ok(()); }; + let goal = if self.states.contains_key(execution_id) { + None + } else { + self.activity_goal_context(&event.thread_key).await? + }; let now = Instant::now(); let publish = { let state = self .states .entry(execution_id.to_owned()) - .or_insert_with(|| ExecutionActivity { - facts: VecDeque::with_capacity(self.config.max_facts), - last_attempt_at: None, - last_published_signature: None, - last_summary: None, - max_facts: self.config.max_facts, - }); + .or_insert_with(|| ExecutionActivity::new(self.config.max_facts, goal)); state.push(fact); state.prepare_publish(now, self.config.min_interval) }; @@ -164,6 +163,15 @@ impl ActivitySummaryWorker { debug!("discarded empty session activity summary"); return Ok(()); }; + if self + .states + .get(execution_id) + .and_then(|state| state.last_summary.as_deref()) + .is_some_and(|last| summaries_are_similar(last, &summary)) + { + debug!(summary, "discarded redundant session activity summary"); + return Ok(()); + } self.store .append_event( @@ -185,11 +193,30 @@ impl ActivitySummaryWorker { } Ok(()) } + + async fn activity_goal_context( + &self, + thread_key: &ThreadKey, + ) -> Result, ActivitySummaryError> { + if let Some(title) = self.store.get_session_title(thread_key).await? + && let Some(title) = clean_goal_text(&title) + { + return Ok(Some(title)); + } + + let messages = self.store.list_messages(thread_key).await?; + let goal = messages + .iter() + .find(|message| message.role == MessageRole::User) + .and_then(|message| message_parts_text(&message.parts)); + Ok(goal.and_then(|goal| clean_goal_text(&goal))) + } } #[derive(Debug)] struct ExecutionActivity { facts: VecDeque, + goal: Option, last_attempt_at: Option, last_published_signature: Option, last_summary: Option, @@ -197,11 +224,25 @@ struct ExecutionActivity { } impl ExecutionActivity { + fn new(max_facts: usize, goal: Option) -> Self { + Self { + facts: VecDeque::with_capacity(max_facts), + goal, + last_attempt_at: None, + last_published_signature: None, + last_summary: None, + max_facts, + } + } + fn push(&mut self, fact: ActivityFact) { + if !fact.is_publishable() { + return; + } if self .facts - .back() - .is_some_and(|existing| existing.kind == fact.kind && existing.text == fact.text) + .iter() + .any(|existing| existing.kind == fact.kind && existing.text == fact.text) { return; } @@ -215,6 +256,9 @@ impl ExecutionActivity { if self.facts.is_empty() { return None; } + if !self.facts.iter().any(ActivityFact::is_publishable) { + return None; + } if self .last_attempt_at .is_some_and(|last| now.saturating_duration_since(last) < min_interval) @@ -238,28 +282,97 @@ impl ExecutionActivity { if let Some(summary) = self.last_summary.as_deref() { lines.push(format!("Previous status sentence: {summary}")); } + if let Some(goal) = self.goal.as_deref() { + lines.push(format!("Session goal: {goal}")); + } lines.push("Recent activity facts, oldest to newest:".to_owned()); - for fact in &self.facts { + for fact in self.facts.iter().filter(|fact| fact.is_publishable()) { lines.push(format!("- {}: {}", fact.kind, fact.text)); } lines.join("\n") } fn signature(&self) -> String { - self.facts - .iter() - .map(|fact| format!("{}={}", fact.kind, fact.text)) + let goal = self.goal.as_deref().unwrap_or_default(); + std::iter::once(format!("goal={goal}")) + .chain( + self.facts + .iter() + .filter(|fact| fact.is_publishable()) + .map(|fact| format!("{}={}", fact.kind, fact.text)), + ) .collect::>() .join("\n") } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ActivitySignal { + High, + Low, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct ActivityFact { kind: &'static str, + signal: ActivitySignal, text: String, } +impl ActivityFact { + fn high(kind: &'static str, text: impl Into) -> Self { + Self { + kind, + signal: ActivitySignal::High, + text: text.into(), + } + } + + fn low(kind: &'static str, text: impl Into) -> Self { + Self { + kind, + signal: ActivitySignal::Low, + text: text.into(), + } + } + + fn is_publishable(&self) -> bool { + self.signal == ActivitySignal::High + } +} + +fn message_parts_text(parts: &[Value]) -> Option { + let text = parts + .iter() + .filter_map(message_part_text) + .collect::>() + .join(" "); + (!text.trim().is_empty()).then_some(text) +} + +fn message_part_text(part: &Value) -> Option { + if let Some(text) = part.as_str() { + return Some(text.trim().to_owned()).filter(|text| !text.is_empty()); + } + string_at(part, &["text"]) + .or_else(|| string_at(part, &["content"])) + .or_else(|| string_at(part, &["title"])) +} + +fn clean_goal_text(value: &str) -> Option { + let text = one_line(value, 160); + let lower = text.to_ascii_lowercase(); + if lower.is_empty() + || matches!( + lower.as_str(), + "continue" | "go on" | "ok" | "okay" | "yes" | "yep" | "sure" + ) + { + return None; + } + Some(text) +} + fn activity_fact_from_output_event(event: &SessionEvent) -> Option { let line = event.payload.as_str()?; let value = serde_json::from_str::(line).ok()?; @@ -271,24 +384,14 @@ fn activity_fact_from_value(value: &Value) -> Option { let normalized = event_type.replace('/', "."); match normalized.as_str() { "turn.plan.updated" => plan_fact(value), - "item.plan.delta" => string_field(value, &["delta", "text"]).map(|text| ActivityFact { - kind: "plan", - text: format!("planning {}", one_line(&text, 180)), - }), + "item.plan.delta" => string_field(value, &["delta", "text"]) + .map(|text| ActivityFact::high("plan", format!("planning {}", one_line(&text, 180)))), "item.reasoning.summaryTextDelta" | "item.reasoning.textDelta" => { - string_field(value, &["delta", "text"]).map(|text| ActivityFact { - kind: "thinking", - text: one_line(&text, 220), - }) + string_field(value, &["delta", "text"]) + .map(|text| ActivityFact::high("thinking", one_line(&text, 220))) } - "item.commandExecution.outputDelta" => Some(ActivityFact { - kind: "command", - text: "reading command output".to_owned(), - }), - "item.mcpToolCall.progress" => Some(ActivityFact { - kind: "tool", - text: progress_fact_text(value), - }), + "item.commandExecution.outputDelta" => None, + "item.mcpToolCall.progress" => Some(ActivityFact::high("tool", progress_fact_text(value))), "item.started" | "item.updated" | "item.completed" => item_fact(value, &normalized), "assistant" => assistant_tool_fact(value), "tool" | "user" => tool_result_fact(value), @@ -320,10 +423,10 @@ fn plan_fact(value: &Value) -> Option { let step = string_at(current, &["step"]) .or_else(|| string_at(current, &["title"])) .or_else(|| string_at(current, &["text"]))?; - Some(ActivityFact { - kind: "plan", - text: format!("working on {}", one_line(&strip_plan_marker(&step), 180)), - }) + Some(ActivityFact::high( + "plan", + format!("working on {}", one_line(&strip_plan_marker(&step), 180)), + )) } fn item_fact(value: &Value, normalized_event_type: &str) -> Option { @@ -333,40 +436,54 @@ fn item_fact(value: &Value, normalized_event_type: &str) -> Option match item_type.as_str() { "commandExecution" | "command_execution" => { let command = string_at(item, &["command"]).unwrap_or_else(|| "command".to_owned()); - let action = if completed { "finished" } else { "running" }; - Some(ActivityFact { - kind: "command", - text: format!( - "{action} {}", - one_line(&unwrap_shell_command(&command), 220) - ), - }) + command_fact(&command, completed) } - "fileChange" | "file_change" => Some(ActivityFact { - kind: "files", - text: file_change_text(item, completed), - }), + "fileChange" | "file_change" => Some(ActivityFact::high( + "files", + file_change_text(item, completed), + )), "reasoning" => reasoning_item_fact(item, completed), "mcpToolCall" | "mcp_tool_call" | "dynamicToolCall" | "dynamic_tool_call" => { let name = tool_name(item); let action = if completed { "finished using" } else { "using" }; - Some(ActivityFact { - kind: "tool", - text: format!("{action} {name}"), - }) + Some(ActivityFact::high("tool", format!("{action} {name}"))) } - // Assistant messages are the user-visible answer/commentary stream, not - // a useful live activity signal. Tool, plan, and command events carry - // the actual work in progress. - "agentMessage" | "agent_message" => None, - "plan" => string_at(item, &["text"]).map(|text| ActivityFact { - kind: "plan", - text: format!("updated plan {}", one_line(&text, 180)), + "agentMessage" | "agent_message" => agent_message_fact(item, completed), + "plan" => string_at(item, &["text"]).map(|text| { + ActivityFact::high("plan", format!("updated plan {}", one_line(&text, 180))) }), _ => None, } } +fn command_fact(command: &str, completed: bool) -> Option { + let command = unwrap_shell_command(command); + if is_low_signal_command(&command) { + return Some(ActivityFact::low( + "command", + low_signal_command_label(&command), + )); + } + let tool = command_tool_name(&command)?; + let action = if completed { "finished using" } else { "using" }; + Some(ActivityFact::high("tool", format!("{action} {tool}"))) +} + +fn agent_message_fact(item: &Value, completed: bool) -> Option { + if !completed { + return None; + } + let phase = string_at(item, &["phase"]).unwrap_or_default(); + if phase != "commentary" { + return None; + } + let text = string_at(item, &["text"])?; + if is_low_signal_commentary(&text) { + return None; + } + Some(ActivityFact::high("commentary", one_line(&text, 220))) +} + fn protocol_item(value: &Value) -> Option<&Value> { value .get("item") @@ -377,14 +494,14 @@ fn reasoning_item_fact(item: &Value, completed: bool) -> Option { let text = string_at(item, &["text"]) .or_else(|| array_text(item.get("summary"))) .or_else(|| array_text(item.get("content")))?; - Some(ActivityFact { - kind: "thinking", - text: if completed { + Some(ActivityFact::high( + "thinking", + if completed { format!("finished thinking about {}", one_line(&text, 180)) } else { one_line(&text, 220) }, - }) + )) } fn file_change_text(item: &Value, completed: bool) -> String { @@ -431,10 +548,10 @@ fn assistant_tool_fact(value: &Value) -> Option { let tool = content .iter() .find(|item| string_at(item, &["type"]).as_deref() == Some("tool_use"))?; - Some(ActivityFact { - kind: "tool", - text: format!("using {}", tool_name(tool)), - }) + Some(ActivityFact::high( + "tool", + format!("using {}", tool_name(tool)), + )) } fn tool_result_fact(value: &Value) -> Option { @@ -443,10 +560,7 @@ fn tool_result_fact(value: &Value) -> Option { string_at(item, &["type"]).as_deref() == Some("tool_result") || string_at(item, &["tool_use_id"]).is_some() }) { - return Some(ActivityFact { - kind: "tool", - text: "reading tool results".to_owned(), - }); + return Some(ActivityFact::low("tool", "reading tool results")); } None } @@ -460,6 +574,112 @@ fn tool_name(item: &Value) -> String { .unwrap_or_else(|| "tool".to_owned()) } +fn command_tool_name(command: &str) -> Option { + let first = command + .split_whitespace() + .next()? + .trim_matches(|ch| ch == '"' || ch == '\''); + let name = first.rsplit('/').next().unwrap_or(first); + if name.is_empty() || is_shell_or_package_command(name) { + return None; + } + Some(name.to_owned()) +} + +fn is_low_signal_command(command: &str) -> bool { + let lower = command.to_ascii_lowercase(); + let first = lower.split_whitespace().next().unwrap_or_default(); + lower.is_empty() + || lower == "command" + || lower.contains(" --help") + || lower.ends_with(" --help") + || lower.contains(" -h") + || lower.contains("centaur-tools list") + || lower.contains("centaur-tools refresh") + || lower.contains("uv sync") + || lower.contains("uv pip install") + || lower.contains("pip install") + || lower.contains("pnpm install") + || lower.contains("npm install") + || lower.contains("cargo build") + || lower.contains("cargo check") + || lower.contains("cargo test") + || lower.contains("cargo fmt") + || lower.contains("ruff ") + || lower.contains("pytest") + || lower.contains("helm template") + || lower.contains("helm lint") + || matches!( + first, + "rg" | "grep" + | "sed" + | "awk" + | "cat" + | "ls" + | "find" + | "git" + | "kubectl" + | "jq" + | "curl" + | "python" + | "python3" + | "node" + | "sh" + | "bash" + ) +} + +fn low_signal_command_label(command: &str) -> String { + let lower = command.to_ascii_lowercase(); + if lower.contains(" --help") || lower.ends_with(" --help") || lower.contains(" -h") { + "checking tool help".to_owned() + } else if lower.contains("install") || lower.contains("build") { + "setup work".to_owned() + } else { + "mechanical command".to_owned() + } +} + +fn is_shell_or_package_command(name: &str) -> bool { + matches!( + name, + "bash" + | "sh" + | "zsh" + | "python" + | "python3" + | "node" + | "bun" + | "uv" + | "pip" + | "pnpm" + | "npm" + | "cargo" + | "git" + | "kubectl" + | "rg" + | "grep" + | "sed" + | "awk" + | "cat" + | "ls" + | "find" + | "jq" + | "curl" + ) +} + +fn is_low_signal_commentary(text: &str) -> bool { + let lower = text.trim().to_ascii_lowercase(); + lower.is_empty() + || lower == "i'll take a look." + || lower == "i\u{2019}ll take a look." + || lower == "i'll check." + || lower == "i\u{2019}ll check." + || lower == "i'm working on it." + || lower == "i\u{2019}m working on it." +} + fn array_text(value: Option<&Value>) -> Option { let texts = value? .as_array()? @@ -531,10 +751,90 @@ fn one_line(value: &str, max_chars: usize) -> String { } fn sanitize_summary(summary: &str) -> Option { - let summary = one_line(summary.trim().trim_matches('"').trim_matches('\''), 180); + let summary = summary + .trim() + .trim_matches('"') + .trim_matches('\'') + .trim() + .trim_end_matches('.') + .to_owned(); + if summary.eq_ignore_ascii_case("skip") || summary.chars().count() > 45 { + return None; + } + if is_generic_summary(&summary) { + return None; + } (!summary.is_empty()).then_some(summary) } +fn is_generic_summary(summary: &str) -> bool { + let normalized = normalize_summary(summary); + normalized.is_empty() + || normalized.contains("gathering details") + || normalized.contains("gathering info") + || (normalized.contains("gathering") && normalized.contains("info")) + || normalized.contains("listing available") + || normalized.contains("available items") + || normalized.contains("preparing your update") + || normalized.contains("preparing your summary") + || (normalized.contains("preparing your") && normalized.contains("summary")) + || normalized.contains("checking the request") + || normalized.contains("working on it") + || normalized.contains("making progress") + || normalized.contains("handling the task") +} + +fn summaries_are_similar(previous: &str, candidate: &str) -> bool { + let previous = summary_keywords(previous); + let candidate = summary_keywords(candidate); + if previous.is_empty() || candidate.is_empty() { + return false; + } + let shared = candidate + .iter() + .filter(|word| previous.contains(*word)) + .count(); + let smaller = previous.len().min(candidate.len()); + shared * 4 >= smaller * 3 +} + +fn summary_keywords(summary: &str) -> Vec { + normalize_summary(summary) + .split_whitespace() + .filter(|word| { + !matches!( + *word, + "i" | "m" + | "im" + | "i'm" + | "am" + | "the" + | "a" + | "an" + | "for" + | "to" + | "on" + | "your" + | "my" + | "this" + | "that" + ) + }) + .map(ToOwned::to_owned) + .collect() +} + +fn normalize_summary(summary: &str) -> String { + summary + .to_ascii_lowercase() + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + fn is_terminal_session_event(event_type: &str) -> bool { matches!( event_type, @@ -687,65 +987,74 @@ mod tests { assert_eq!( fact, - ActivityFact { - kind: "plan", - text: "working on Add activity summary worker".to_owned(), - } + ActivityFact::high("plan", "working on Add activity summary worker") ); } #[test] - fn projects_command_event_without_output() { + fn drops_low_signal_command_events() { let fact = activity_fact_from_output_event(&event(json!({ "method": "item/started", "params": { "item": { "id": "cmd-1", "type": "commandExecution", - "command": "/bin/bash -lc 'rg session.activity'" + "command": "/bin/bash -lc 'centaur-tools list'" } } }))) .unwrap(); - assert_eq!( - fact, - ActivityFact { - kind: "command", - text: "running rg session.activity".to_owned(), - } - ); + assert_eq!(fact, ActivityFact::low("command", "mechanical command")); } #[test] - fn ignores_agent_commentary_messages_as_activity() { + fn projects_tool_command_by_tool_name() { let fact = activity_fact_from_output_event(&event(json!({ "method": "item/started", + "params": { + "item": { + "id": "cmd-1", + "type": "commandExecution", + "command": "/bin/bash -lc 'websearch search --query usdG yield'" + } + } + }))) + .unwrap(); + + assert_eq!(fact, ActivityFact::high("tool", "using websearch")); + } + + #[test] + fn captures_completed_agent_commentary_as_activity() { + let fact = activity_fact_from_output_event(&event(json!({ + "method": "item/completed", "params": { "item": { "id": "msg-1", "phase": "commentary", - "text": "", + "text": "I'll trace the USDG vault yield source.", "type": "agentMessage" } } - }))); + }))) + .unwrap(); - assert_eq!(fact, None); + assert_eq!( + fact, + ActivityFact::high("commentary", "I'll trace the USDG vault yield source.") + ); } #[test] fn system_prompt_requires_conversational_goal_status() { assert!(SYSTEM_PROMPT.contains("first-person")); assert!(SYSTEM_PROMPT.contains("under 45 characters")); - assert!(SYSTEM_PROMPT.contains("Describe the goal")); + assert!(SYSTEM_PROMPT.contains("user-facing goal")); assert!(SYSTEM_PROMPT.contains("not the exact")); - assert!(SYSTEM_PROMPT.contains("Avoid mechanics")); - assert!(SYSTEM_PROMPT.contains("infer the")); + assert!(SYSTEM_PROMPT.contains("specific noun phrases")); + assert!(SYSTEM_PROMPT.contains("output exactly SKIP")); assert!(SYSTEM_PROMPT.contains("Do not mention tests")); - assert!(SYSTEM_PROMPT.contains("Use user-facing words")); - assert!(SYSTEM_PROMPT.contains("\"I'm checking the fix\"")); - assert!(SYSTEM_PROMPT.contains("Never write more than 45 characters")); assert!(SYSTEM_PROMPT.contains("Do not refer to \"the agent\"")); } @@ -795,18 +1104,9 @@ mod tests { #[test] fn throttles_unchanged_activity() { - let mut state = ExecutionActivity { - facts: VecDeque::new(), - last_attempt_at: None, - last_published_signature: None, - last_summary: None, - max_facts: 4, - }; + let mut state = ExecutionActivity::new(4, Some("Investigate USDG vault yield".to_owned())); let now = Instant::now(); - state.push(ActivityFact { - kind: "tool", - text: "using github".to_owned(), - }); + state.push(ActivityFact::high("tool", "using websearch")); assert!(state.prepare_publish(now, Duration::from_secs(8)).is_some()); state.last_published_signature = Some(state.signature()); assert!( @@ -815,4 +1115,57 @@ mod tests { .is_none() ); } + + #[test] + fn skips_low_signal_only_activity() { + let mut state = ExecutionActivity::new(4, Some("Investigate USDG vault yield".to_owned())); + let now = Instant::now(); + state.push(ActivityFact::low("command", "checking tool help")); + + assert!(state.prepare_publish(now, Duration::from_secs(8)).is_none()); + } + + #[test] + fn prompt_includes_session_goal() { + let mut state = ExecutionActivity::new(4, Some("Investigate USDG vault yield".to_owned())); + state.push(ActivityFact::high("tool", "using websearch")); + + let prompt = state.prompt(); + + assert!(prompt.contains("Session goal: Investigate USDG vault yield")); + assert!(prompt.contains("- tool: using websearch")); + } + + #[test] + fn sanitizes_useless_summaries() { + assert_eq!(sanitize_summary("SKIP"), None); + assert_eq!( + sanitize_summary("I'm gathering details for the USDG info."), + None + ); + assert_eq!( + sanitize_summary("I'm preparing your USDG vault update summary"), + None + ); + assert_eq!( + sanitize_summary("I'm checking USDG yield sources."), + Some("I'm checking USDG yield sources".to_owned()) + ); + assert_eq!( + sanitize_summary("I'm checking a summary that is far too long for Slack status text"), + None + ); + } + + #[test] + fn detects_redundant_summary_phrasing() { + assert!(summaries_are_similar( + "I'm checking USDG yield sources", + "I'm checking USDG yield source" + )); + assert!(!summaries_are_similar( + "I'm checking USDG yield sources", + "I'm comparing vault contract events" + )); + } } diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index ee754d7f6..a8bdd66e4 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -143,7 +143,7 @@ struct ActivitySummaryArgs { #[arg( long = "session-activity-summary-min-interval-secs", env = "SESSION_ACTIVITY_SUMMARY_MIN_INTERVAL_SECS", - default_value_t = 8, + default_value_t = 20, value_parser = clap::value_parser!(u64).range(1..) )] min_interval_secs: u64, From a83a1bdf736b8230338b17f746db7212ad87a597 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:33:47 +0300 Subject: [PATCH 030/198] fix: steer activity summaries to current step, not session goal (#870) Replayed the summary pipeline over five real threads (stg + prd) and found the deployed prompt produces one vague goal-restating status per thread: it steers the model toward the session goal, and the specific summaries it does write usually land at 46-52 characters, where sanitize_summary silently discards them (19 of 35 attempts). Two prompt changes, validated by replaying the same threads: - Describe the current step or latest finding instead of the overall session goal, with concrete example phrasings. - Target 40 characters and state the 45-character hard limit so the model cuts words instead of overflowing. Published summaries went from 14/35 attempts to 27/35, too-long discards from 19 to 3, and the output narrates actual progress ("I found forge-std is vendored, not submodule", "I'm blocked on tempo-obs metrics 502") instead of repeating "I'm checking X". Co-authored-by: Claude Fable 5 --- .../src/activity_summary.rs | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/activity_summary.rs b/services/api-rs/crates/centaur-api-server/src/activity_summary.rs index 4405a1034..6f63dff11 100644 --- a/services/api-rs/crates/centaur-api-server/src/activity_summary.rs +++ b/services/api-rs/crates/centaur-api-server/src/activity_summary.rs @@ -16,17 +16,21 @@ pub(crate) const SESSION_ACTIVITY_SUMMARY_EVENT: &str = "session.activity_summar const SYSTEM_PROMPT: &str = "\ You write live status text for a software agent. Use only the supplied event facts. \ -Write one conversational first-person present-tense sentence under 45 characters, \ -including spaces, as if you are the agent. Describe the current user-facing goal or \ -question you are resolving, not the exact command, file path, ID, flag, or \ -implementation step. Prefer specific noun phrases from the session goal over \ -generic phrases like details, info, items, update, or summary. Use the session goal \ -and facts labeled commentary, plan, or tool before any lower-level facts. If the facts only show \ -setup, help output, dependency installs, builds, command output, logs, tests, or \ -other mechanics, output exactly SKIP. If you cannot say a meaningful new status \ -that differs from the previous one, output exactly SKIP. Do not mention tests, \ -output, builds, logs, rollouts, commands, paths, IDs, or flags unless the user asked \ -for them. Do not refer to \"the agent\". No markdown, no quotes, no event IDs, and no speculation."; +Write one first-person present-tense sentence of at most 40 characters, including \ +spaces, as if you are the agent. The hard limit is 45 characters: anything longer is \ +thrown away, so when in doubt cut words and use the shortest name for things. \ +Describe the current step or latest finding, not the overall session goal: say what \ +you are doing or learned right now, like \"I'm computing TPS from blocks\", \ +\"I found the chain config\", or \"I'm blocked on metrics access\". Take the newest \ +facts labeled commentary, plan, or tool as the current step; earlier facts are only \ +context. Name one specific thing from the facts (a chain, PR, partner, tool, or \ +topic); avoid generic words like details, info, items, update, or summary, and avoid \ +repeating the session goal word for word. Each status must say something new \ +compared to the previous status sentence; if you cannot, output exactly SKIP. If the \ +facts only show setup, help output, dependency installs, builds, command output, \ +logs, tests, or other mechanics, output exactly SKIP. Do not mention commands, \ +paths, IDs, or flags. Do not refer to \"the agent\". No markdown, no quotes, no \ +event IDs, and no speculation."; #[derive(Clone)] pub(crate) struct ActivitySummaryConfig { @@ -1047,14 +1051,15 @@ mod tests { } #[test] - fn system_prompt_requires_conversational_goal_status() { + fn system_prompt_requires_conversational_step_status() { assert!(SYSTEM_PROMPT.contains("first-person")); - assert!(SYSTEM_PROMPT.contains("under 45 characters")); - assert!(SYSTEM_PROMPT.contains("user-facing goal")); - assert!(SYSTEM_PROMPT.contains("not the exact")); - assert!(SYSTEM_PROMPT.contains("specific noun phrases")); + assert!(SYSTEM_PROMPT.contains("at most 40 characters")); + assert!(SYSTEM_PROMPT.contains("hard limit is 45 characters")); + assert!(SYSTEM_PROMPT.contains("current step or latest finding")); + assert!(SYSTEM_PROMPT.contains("not the overall session goal")); + assert!(SYSTEM_PROMPT.contains("Name one specific thing")); assert!(SYSTEM_PROMPT.contains("output exactly SKIP")); - assert!(SYSTEM_PROMPT.contains("Do not mention tests")); + assert!(SYSTEM_PROMPT.contains("Do not mention commands")); assert!(SYSTEM_PROMPT.contains("Do not refer to \"the agent\"")); } From 6a8713615cab4056e1060dea7c461dfa18e9ef2d Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:52:05 +0300 Subject: [PATCH 031/198] [codex] Add MCP tool host sandboxes (#841) * feat: add MCP tool host sandboxes * chore: bump chart version * fix: address MCP tool-host review findings - Always validate the requested method against the parsed client method list; previously tools with zero public methods (methods == [help]) skipped validation and shipped unknown methods to the sandbox - Keep the timed_out result when the sandbox-id lookup fails instead of masking the timeout with a store error - Serialize register_mcp_tool_host_principal under the same per-principal lock as run_tool_host_call so concurrent registrations cannot interleave with session setup, and evict idle lock entries after each call - Cache the discovered tool catalog for 10s so bursts of MCP requests do not redo directory scans and metadata parsing; drop the redundant client.py re-read in the help path - Extract tool_host_session_metadata and reuse tool_host_thread_key instead of duplicating both constructions - Derive SandboxBootMode inside ensure_session_sandbox from the thread key and iron-control principal instead of threading it as a parameter Co-Authored-By: Claude Fable 5 * feat: improve MCP tool errors and method discoverability - tools/list and method=help now expose full method signatures parsed from the tool's client.py (e.g. search_tweets(query, limit=10)) so agents pass correct keyword arguments instead of guessing - CALL_RUNNER binds keyword arguments against the target signature before invoking and returns a short 'invalid arguments for method(signature): ...' error instead of a TypeError traceback - Failed tool calls reduce Python tracebacks to the final exception message and append a hint to call method=help for usage Co-Authored-By: Claude Fable 5 * [codex] Add console MCP OAuth JWT auth for tool hosts (#842) * feat: add console MCP OAuth JWT auth * fix: revoke MCP OAuth tokens for inactive users * fix: require approval for MCP OAuth clients * fix: harden MCP OAuth issuer and loopback checks * fix: address MCP OAuth review findings - Fail closed in resolve_requested_resource: reject authorize requests when no canonical MCP resource URL is configured instead of minting tokens bound to caller-supplied audiences - Validate JWT iat is not unreasonably in the future per RFC 0004 - Add missing apiRs.mcpPublicUrl / slackbotv2.mcpPublicUrl entries to values.schema.json - Share header_value between routes.rs and mcp.rs instead of duplicating - Extract HashedTokenLookup concern for the OAuth code/refresh-token models' SHA-256 hash-and-lookup pattern - Cache static env configuration (signing secret, public URLs) in OnceLock instead of re-reading per request Co-Authored-By: Claude Fable 5 * chore: bump chart version Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/apirs.yaml | 14 + contrib/chart/templates/console-worker.yaml | 14 + contrib/chart/templates/console.yaml | 14 + contrib/chart/values.dev.yaml | 5 + contrib/chart/values.schema.json | 3 + contrib/chart/values.yaml | 9 + contrib/scripts/bootstrap-k8s-secrets.sh | 23 +- .../crates/centaur-api-server/src/args.rs | 14 +- .../crates/centaur-api-server/src/error.rs | 3 + .../crates/centaur-api-server/src/lib.rs | 37 + .../crates/centaur-api-server/src/main.rs | 3 +- .../crates/centaur-api-server/src/mcp.rs | 1219 +++++++++++++++++ .../crates/centaur-api-server/src/routes.rs | 14 +- .../centaur-api-server/src/tool_discovery.rs | 113 +- .../crates/centaur-session-runtime/src/lib.rs | 433 +++++- .../api-rs/rfcs/0004-console-mcp-jwt-auth.md | 745 ++++++++++ .../app/controllers/application_controller.rb | 10 +- .../app/controllers/mcp/oauth_controller.rb | 499 +++++++ .../models/concerns/hashed_token_lookup.rb | 27 + .../models/mcp_oauth_authorization_code.rb | 36 + .../console/app/models/mcp_oauth_client.rb | 93 ++ .../app/models/mcp_oauth_refresh_token.rb | 36 + services/console/app/models/user.rb | 15 + .../app/views/mcp/oauth/authorize.html.erb | 49 + services/console/config/routes.rb | 9 + ...20260630090000_create_mcp_oauth_clients.rb | 15 + ...01_create_mcp_oauth_authorization_codes.rb | 21 + ...0090002_create_mcp_oauth_refresh_tokens.rb | 20 + services/console/db/schema.rb | 59 +- services/console/lib/mcp/jwt.rb | 23 + .../console/users_controller_test.rb | 24 + .../controllers/mcp/oauth_controller_test.rb | 269 ++++ .../test/models/mcp_oauth_client_test.rb | 28 + services/sandbox/Dockerfile | 1 + services/sandbox/centaur_tool_host.py | 106 ++ services/sandbox/install_tool_shims.py | 17 + 37 files changed, 3980 insertions(+), 42 deletions(-) create mode 100644 services/api-rs/crates/centaur-api-server/src/mcp.rs create mode 100644 services/api-rs/rfcs/0004-console-mcp-jwt-auth.md create mode 100644 services/console/app/controllers/mcp/oauth_controller.rb create mode 100644 services/console/app/models/concerns/hashed_token_lookup.rb create mode 100644 services/console/app/models/mcp_oauth_authorization_code.rb create mode 100644 services/console/app/models/mcp_oauth_client.rb create mode 100644 services/console/app/models/mcp_oauth_refresh_token.rb create mode 100644 services/console/app/views/mcp/oauth/authorize.html.erb create mode 100644 services/console/db/migrate/20260630090000_create_mcp_oauth_clients.rb create mode 100644 services/console/db/migrate/20260630090001_create_mcp_oauth_authorization_codes.rb create mode 100644 services/console/db/migrate/20260630090002_create_mcp_oauth_refresh_tokens.rb create mode 100644 services/console/lib/mcp/jwt.rb create mode 100644 services/console/test/controllers/mcp/oauth_controller_test.rb create mode 100644 services/console/test/models/mcp_oauth_client_test.rb create mode 100644 services/sandbox/centaur_tool_host.py diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index b5149c029..488f5cc63 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.84 +version: 0.1.85 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 6ac5d3c27..b429280a6 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -1,5 +1,6 @@ {{- if .Values.apiRs.enabled }} {{- $console := include "centaur.consoleValues" . | fromYaml -}} +{{- $mcpPublicUrl := default .Values.slackbotv2.mcpPublicUrl .Values.apiRs.mcpPublicUrl -}} {{- $apiRsName := include "centaur.componentName" (dict "root" . "component" "api-rs") -}} {{- $repoCacheStorageType := include "centaur.repoCacheStorageType" . -}} {{- $repoCacheUsePvc := eq $repoCacheStorageType "persistentVolumeClaim" -}} @@ -189,8 +190,21 @@ spec: secretKeyRef: name: {{ include "centaur.secretEnvName" . }} key: {{ printf "%sDATABASE_URL" .Values.secretManager.envPrefix }} + - name: CENTAUR_JWT_SIGNING_SECRET + valueFrom: + secretKeyRef: + name: {{ include "centaur.secretEnvName" . }} + key: {{ printf "%sCENTAUR_JWT_SIGNING_SECRET" .Values.secretManager.envPrefix }} - name: BIND_ADDR value: {{ printf "0.0.0.0:%v" .Values.apiRs.port | quote }} +{{- if $mcpPublicUrl }} + - name: CENTAUR_MCP_PUBLIC_URL + value: {{ $mcpPublicUrl | quote }} +{{- end }} +{{- if $console.publicUrl }} + - name: CENTAUR_CONSOLE_PUBLIC_URL + value: {{ $console.publicUrl | quote }} +{{- end }} - name: RUN_MIGRATIONS value: {{ .Values.apiRs.runMigrations | quote }} - name: IRON_CONTROL_SYNC_INFRA_SECRETS diff --git a/contrib/chart/templates/console-worker.yaml b/contrib/chart/templates/console-worker.yaml index 5164ce0a8..a448d342a 100644 --- a/contrib/chart/templates/console-worker.yaml +++ b/contrib/chart/templates/console-worker.yaml @@ -3,6 +3,7 @@ {{- $secretEnv := include "centaur.secretEnvName" . }} {{- $prefix := .Values.secretManager.envPrefix }} {{- $apiRsName := include "centaur.componentName" (dict "root" . "component" "api-rs") }} +{{- $mcpPublicUrl := default .Values.slackbotv2.mcpPublicUrl .Values.apiRs.mcpPublicUrl }} # console background job worker — runs Solid Queue (`bin/jobs`) so # console's enqueued jobs actually execute. The most important of these is # the broker-credential OAuth refresh loop, which mints and refreshes the access @@ -120,6 +121,19 @@ spec: secretKeyRef: name: {{ $secretEnv }} key: {{ printf "%sIRON_CONTROL_SECRET_KEY_BASE" $prefix }} + - name: CENTAUR_JWT_SIGNING_SECRET + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sCENTAUR_JWT_SIGNING_SECRET" $prefix }} +{{- if $console.publicUrl }} + - name: CENTAUR_CONSOLE_PUBLIC_URL + value: {{ $console.publicUrl | quote }} +{{- end }} +{{- if $mcpPublicUrl }} + - name: CENTAUR_MCP_PUBLIC_URL + value: {{ $mcpPublicUrl | quote }} +{{- end }} {{- if $console.googleOauth.enabled }} # Google OAuth app credentials, needed by the broker-credential # OAuth refresh loop. Gated on console.googleOauth.enabled. diff --git a/contrib/chart/templates/console.yaml b/contrib/chart/templates/console.yaml index 87c6d24f9..d462dd362 100644 --- a/contrib/chart/templates/console.yaml +++ b/contrib/chart/templates/console.yaml @@ -4,6 +4,7 @@ {{- $prefix := .Values.secretManager.envPrefix }} {{- $dbName := $console.database.name }} {{- $apiRsName := include "centaur.componentName" (dict "root" . "component" "api-rs") }} +{{- $mcpPublicUrl := default .Values.slackbotv2.mcpPublicUrl .Values.apiRs.mcpPublicUrl }} # console — Rails control plane for authenticated API access and encrypted # secret storage. The chart owns the Deployment shape (image, port, env, # security context) so operators tune it via `helm upgrade`. It runs against a @@ -146,6 +147,19 @@ spec: secretKeyRef: name: {{ $secretEnv }} key: {{ printf "%sIRON_CONTROL_SECRET_KEY_BASE" $prefix }} + - name: CENTAUR_JWT_SIGNING_SECRET + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sCENTAUR_JWT_SIGNING_SECRET" $prefix }} +{{- if $console.publicUrl }} + - name: CENTAUR_CONSOLE_PUBLIC_URL + value: {{ $console.publicUrl | quote }} +{{- end }} +{{- if $mcpPublicUrl }} + - name: CENTAUR_MCP_PUBLIC_URL + value: {{ $mcpPublicUrl | quote }} +{{- end }} {{- if $console.googleOauth.enabled }} # Google OAuth app credentials (sign-in + brokered token refresh). # Gated on console.googleOauth.enabled; the keys must exist in the diff --git a/contrib/chart/values.dev.yaml b/contrib/chart/values.dev.yaml index e5502b1c8..f6a06fe6a 100644 --- a/contrib/chart/values.dev.yaml +++ b/contrib/chart/values.dev.yaml @@ -32,6 +32,11 @@ apiRs: image: pullPolicy: IfNotPresent +console: + enabled: true + image: + pullPolicy: IfNotPresent + ironProxy: image: pullPolicy: IfNotPresent diff --git a/contrib/chart/values.schema.json b/contrib/chart/values.schema.json index 8c5360447..2799b633c 100644 --- a/contrib/chart/values.schema.json +++ b/contrib/chart/values.schema.json @@ -7,6 +7,7 @@ "properties": { "enabled": { "type": "boolean" }, "replicaCount": { "type": "integer" }, + "publicUrl": { "type": "string" }, "railsEnv": { "type": "string" }, "image": { "type": "object", @@ -243,6 +244,7 @@ "type": "object", "properties": { "syncInfraSecrets": { "type": "boolean" }, + "mcpPublicUrl": { "type": "string" }, "sandboxRunningLimit": { "type": "integer", "minimum": 0 }, "sandboxHotIdleGraceSecs": { "type": "integer", "minimum": 0 }, "etl": { @@ -333,6 +335,7 @@ "slackbotv2": { "type": "object", "properties": { + "mcpPublicUrl": { "type": "string" }, "metrics": { "type": "object", "properties": { diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index cc3732a96..c2626f6b7 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -85,6 +85,9 @@ toolServer: console: enabled: false replicaCount: 1 + # Public URL users reach in a browser. Set this when console is exposed behind + # Tailscale/Ingress so MCP OAuth issuer metadata and JWT validation agree. + publicUrl: "" image: repository: centaur-console tag: latest @@ -304,6 +307,9 @@ apiRs: tag: latest pullPolicy: Always port: 8080 + # Public/local MCP endpoint advertised through OAuth protected-resource + # metadata. Empty falls back to slackbotv2.mcpPublicUrl. + mcpPublicUrl: "" runMigrations: true sandboxBackend: agent-k8s sandboxWorkload: codex-app-server @@ -418,6 +424,9 @@ slackbotv2: pullPolicy: Always userName: centaur assistantStatus: "" + # Public/local MCP endpoint shown in MCP SSO setup messages. The default + # assumes users connect through `kubectl port-forward ... 3000:8080`. + mcpPublicUrl: "http://localhost:3000" externalOrgAllowlist: "" triggerBotAllowlist: "" metrics: diff --git a/contrib/scripts/bootstrap-k8s-secrets.sh b/contrib/scripts/bootstrap-k8s-secrets.sh index 76dcdf8ea..86a6459d5 100755 --- a/contrib/scripts/bootstrap-k8s-secrets.sh +++ b/contrib/scripts/bootstrap-k8s-secrets.sh @@ -9,8 +9,10 @@ usage() { Usage: scripts/bootstrap-k8s-secrets.sh [--namespace NAMESPACE] [--force] Creates the required local-dev Kubernetes infra Secrets consumed by the Helm chart. -Requires OP_SERVICE_ACCOUNT_TOKEN, OP_VAULT, SLACK_BOT_TOKEN, -SLACK_SIGNING_SECRET, and SLACKBOT_API_KEY in the shell environment. +When creating centaur-infra-env from scratch or with --force, requires +OP_SERVICE_ACCOUNT_TOKEN, OP_VAULT, SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, +and SLACKBOT_API_KEY in the shell environment. Existing Secrets are only topped +up with newly generated optional keys when absent. Optional 1Password Connect bootstrap (when ironProxy.manager.secretSource is set to onepassword-connect in the Helm values): @@ -127,11 +129,6 @@ rand_hex() { require_cmd kubectl require_cmd openssl -require_env OP_SERVICE_ACCOUNT_TOKEN -require_env OP_VAULT -require_env SLACK_BOT_TOKEN -require_env SLACK_SIGNING_SECRET -require_env SLACKBOT_API_KEY # Linear config is optional but must be complete: a token without the webhook # secret (or vice versa) deploys a linearbot that boots and then rejects every @@ -163,6 +160,14 @@ delete_if_forced centaur-firewall-ca delete_if_forced centaur-firewall-ca-key delete_if_forced centaur-onepassword-connect-credentials +if ! secret_exists centaur-infra-env; then + require_env OP_SERVICE_ACCOUNT_TOKEN + require_env OP_VAULT + require_env SLACK_BOT_TOKEN + require_env SLACK_SIGNING_SECRET + require_env SLACKBOT_API_KEY +fi + secret_key_present() { local key="$1" local value @@ -249,6 +254,9 @@ if secret_exists centaur-infra-env; then if ! secret_key_present IRON_CONTROL_SECRET_KEY_BASE; then patch_data+=("\"IRON_CONTROL_SECRET_KEY_BASE\":\"$(printf '%s%s' "$(rand_hex)" "$(rand_hex)" | base64 | tr -d '\n')\"") fi + if ! secret_key_present CENTAUR_JWT_SIGNING_SECRET; then + patch_data+=("\"CENTAUR_JWT_SIGNING_SECRET\":\"$(printf '%s%s' "$(rand_hex)" "$(rand_hex)" | base64 | tr -d '\n')\"") + fi # Linear bot credentials. Set whenever present so the OAuth token can be # rotated; the api-rs bearer is generated once and kept stable. if [[ -n "${LINEAR_ACCESS_TOKEN:-}" ]]; then @@ -295,6 +303,7 @@ else --from-literal=IRON_CONTROL_AR_ENCRYPTION_DETERMINISTIC_KEY="$(rand_hex)" --from-literal=IRON_CONTROL_AR_ENCRYPTION_KEY_DERIVATION_SALT="$(rand_hex)" --from-literal=IRON_CONTROL_SECRET_KEY_BASE="$(rand_hex)$(rand_hex)" + --from-literal=CENTAUR_JWT_SIGNING_SECRET="$(rand_hex)$(rand_hex)" ) if [[ -n "${DISCORD_BOT_TOKEN:-}" ]]; then secret_args+=( diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index a8bdd66e4..3dcce01ab 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -11,7 +11,10 @@ use std::{ #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -use centaur_api_server::SandboxRuntime; +use centaur_api_server::{ + DiscoveredToolProxyFragment, SandboxRuntime, ToolDiscoveryConfig, discover_persona_registry, + discover_tool_proxy_fragment, +}; use centaur_iron_control::{ IdentityInput, IronControlClient, IronControlError, RegisterError, RoleSpec, SessionRegistrar, register_role, @@ -34,14 +37,7 @@ use centaur_workflows::WorkflowHostSandboxRuntime; use clap::{Args as ClapArgs, Parser, ValueEnum}; use tracing::{info, warn}; -use crate::{ - ServerError, - activity_summary::ActivitySummaryConfig, - tool_discovery::{ - DiscoveredToolProxyFragment, ToolDiscoveryConfig, discover_persona_registry, - discover_tool_proxy_fragment, - }, -}; +use crate::{ServerError, activity_summary::ActivitySummaryConfig}; const SANDBOX_REPOS_MOUNT_PATH: &str = "/home/agent/github"; diff --git a/services/api-rs/crates/centaur-api-server/src/error.rs b/services/api-rs/crates/centaur-api-server/src/error.rs index a144442a3..48700e2c6 100644 --- a/services/api-rs/crates/centaur-api-server/src/error.rs +++ b/services/api-rs/crates/centaur-api-server/src/error.rs @@ -17,6 +17,8 @@ pub enum ApiError { #[error("{0}")] Unauthorized(String), #[error("{0}")] + Forbidden(String), + #[error("{0}")] NotFound(String), #[error("{0}")] MethodNotAllowed(String), @@ -49,6 +51,7 @@ impl IntoResponse for ApiError { let status = match &self { Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::Unauthorized(_) => StatusCode::UNAUTHORIZED, + Self::Forbidden(_) => StatusCode::FORBIDDEN, Self::NotFound(_) => StatusCode::NOT_FOUND, Self::MethodNotAllowed(_) => StatusCode::METHOD_NOT_ALLOWED, Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE, diff --git a/services/api-rs/crates/centaur-api-server/src/lib.rs b/services/api-rs/crates/centaur-api-server/src/lib.rs index 0b0b3819b..a57358de4 100644 --- a/services/api-rs/crates/centaur-api-server/src/lib.rs +++ b/services/api-rs/crates/centaur-api-server/src/lib.rs @@ -1,6 +1,8 @@ pub mod client; mod error; +mod mcp; mod routes; +mod tool_discovery; pub mod types; pub use centaur_session_runtime::{SandboxRuntime, SessionRuntime}; @@ -9,6 +11,10 @@ pub use routes::{ AppState, build_router_with_app_state, build_router_with_runtime, build_router_with_session_and_workflow_runtime, build_router_with_session_runtime, }; +pub use tool_discovery::{ + DiscoveredToolProxyFragment, ToolDiscoveryConfig, ToolDiscoveryError, + discover_persona_registry, discover_tool_proxy_fragment, +}; #[cfg(test)] mod tests { @@ -225,6 +231,37 @@ mod tests { } } + #[tokio::test] + async fn mcp_requires_bearer_before_runtime_is_ready() { + let app = build_router_with_app_state(AppState::unready()); + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/mcp") + .header(header::HOST, "centaur.local") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let challenge = response + .headers() + .get(header::WWW_AUTHENTICATE) + .and_then(|value| value.to_str().ok()) + .unwrap(); + assert!(challenge.contains("Bearer")); + assert!(challenge.contains( + "resource_metadata=\"http://centaur.local/.well-known/oauth-protected-resource/mcp\"" + )); + } + #[tokio::test] async fn append_messages_does_not_apply_a_session_body_limit() { let pool = diff --git a/services/api-rs/crates/centaur-api-server/src/main.rs b/services/api-rs/crates/centaur-api-server/src/main.rs index 635f472df..75151880a 100644 --- a/services/api-rs/crates/centaur-api-server/src/main.rs +++ b/services/api-rs/crates/centaur-api-server/src/main.rs @@ -1,6 +1,5 @@ mod activity_summary; mod args; -mod tool_discovery; use centaur_api_server::{AppState, build_router_with_app_state}; use centaur_session_runtime::SessionRuntime; @@ -144,7 +143,7 @@ pub(crate) enum ServerError { #[error(transparent)] Telemetry(#[from] centaur_telemetry::TelemetryError), #[error(transparent)] - ToolDiscovery(#[from] tool_discovery::ToolDiscoveryError), + ToolDiscovery(#[from] centaur_api_server::ToolDiscoveryError), #[error(transparent)] ActivitySummary(#[from] activity_summary::ActivitySummaryError), #[error("tool source error: {0}")] diff --git a/services/api-rs/crates/centaur-api-server/src/mcp.rs b/services/api-rs/crates/centaur-api-server/src/mcp.rs new file mode 100644 index 000000000..2e4365ef3 --- /dev/null +++ b/services/api-rs/crates/centaur-api-server/src/mcp.rs @@ -0,0 +1,1219 @@ +use std::{ + collections::BTreeMap, + env, fs, + path::PathBuf, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use axum::{ + Json, + extract::State, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, +}; +use base64::{Engine as _, engine::general_purpose}; +use centaur_session_runtime::{SessionRuntime, ToolHostCallInput}; +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; + +use crate::{ + ApiError, + routes::{AppState, header_value}, + tool_discovery::{DiscoveredTool, ToolDiscoveryConfig, discover_tool_catalog}, +}; + +pub(crate) async fn mcp_get() -> Response { + ( + StatusCode::METHOD_NOT_ALLOWED, + Json(json!({ + "ok": false, + "error": "MCP Streamable HTTP requests must use POST for this endpoint", + })), + ) + .into_response() +} + +pub(crate) async fn mcp_protected_resource_metadata(headers: HeaderMap) -> Json { + let authorization_servers = mcp_authorization_server_url() + .into_iter() + .collect::>(); + Json(json!({ + "resource": mcp_resource_url(&headers), + "authorization_servers": authorization_servers, + "bearer_methods_supported": ["header"], + "scopes_supported": ["mcp:tools"], + })) +} + +#[derive(Debug, Deserialize)] +pub(crate) struct McpJsonRpcRequest { + jsonrpc: Option, + #[serde(default)] + id: Option, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Debug, Deserialize)] +struct McpToolCallParams { + name: String, + #[serde(default)] + arguments: Value, +} + +#[derive(Debug, Deserialize)] +struct CentaurToolMcpArguments { + method: String, + #[serde(default)] + arguments: Value, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct McpPrincipal { + token_id: String, + principal_id: String, + name: String, + scopes: Vec, + expires_at: Option, +} + +pub(crate) async fn mcp_post( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let Some(principal) = authenticate_mcp_bearer(&headers)? else { + return Ok(mcp_unauthorized(&headers)); + }; + if request.jsonrpc.as_deref().unwrap_or("2.0") != "2.0" { + return Ok(mcp_json_error( + request.id.unwrap_or(Value::Null), + -32600, + "invalid JSON-RPC version", + )); + } + let Some(id) = request.id.clone() else { + return Ok(StatusCode::NO_CONTENT.into_response()); + }; + + let result = match request.method.as_str() { + "initialize" => json!({ + "protocolVersion": requested_mcp_protocol_version(&request.params), + "capabilities": { + "tools": { + "listChanged": false, + }, + }, + "serverInfo": { + "name": "centaur", + "version": env!("CARGO_PKG_VERSION"), + }, + }), + "ping" => json!({}), + "tools/list" => { + ensure_mcp_scope(&principal.scopes, "mcp:tools")?; + let mut tools = vec![mcp_whoami_tool()]; + tools.extend(mcp_centaur_tool_entries()?); + json!({ + "tools": tools, + }) + } + "tools/call" => { + ensure_mcp_scope(&principal.scopes, "mcp:tools")?; + let params = serde_json::from_value::(request.params.clone()) + .map_err(|error| ApiError::BadRequest(error.to_string()))?; + if params.name == "centaur_whoami" { + mcp_whoami_result(&principal, params.arguments)? + } else { + let Some(tool) = mcp_find_centaur_tool(¶ms.name)? else { + return Ok(mcp_json_error(id, -32602, "unknown tool")); + }; + mcp_centaur_tool_result(&state, &principal, tool, params.arguments).await? + } + } + _ => return Ok(mcp_json_error(id, -32601, "method not found")), + }; + + Ok(Json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + })) + .into_response()) +} + +fn mcp_whoami_tool() -> Value { + json!({ + "name": "centaur_whoami", + "description": "Show the authenticated Centaur MCP principal and token metadata.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false, + }, + }) +} + +fn mcp_centaur_tool_entries() -> Result, ApiError> { + let mut entries = Vec::new(); + for tool in mcp_centaur_tool_catalog()? { + let methods = mcp_tool_methods(&tool); + let signatures = methods + .iter() + .map(|method| method.signature.as_str()) + .collect::>(); + let names = methods + .iter() + .map(|method| method.name.as_str()) + .collect::>(); + let mut description = tool + .description + .clone() + .unwrap_or_else(|| format!("Centaur tool package {}", tool.package)); + if !methods.is_empty() { + description.push_str(" Available methods: "); + description.push_str(&signatures.join(", ")); + description.push_str(". Pass keyword arguments matching the method signature; call method=help for this list."); + } + let mut method_schema = json!({ + "type": "string", + "description": "Public method on the tool client to call. Use help to list available methods.", + }); + if !methods.is_empty() { + method_schema["enum"] = json!(names); + } + entries.push(json!({ + "name": tool.name, + "description": description, + "inputSchema": { + "type": "object", + "required": ["method"], + "properties": { + "method": method_schema, + "arguments": { + "type": "object", + "description": "Keyword arguments passed to the selected method.", + "additionalProperties": true, + }, + }, + "additionalProperties": false, + }, + })); + } + Ok(entries) +} + +struct McpToolMethod { + name: String, + signature: String, +} + +fn mcp_tool_methods(tool: &DiscoveredTool) -> Vec { + let mut methods = BTreeMap::from([("help".to_owned(), "help()".to_owned())]); + let path = tool.project_dir.join(&tool.client_module); + if let Ok(contents) = fs::read_to_string(&path) { + for line in contents.lines() { + let indent = line.chars().take_while(|ch| *ch == ' ').count(); + if indent != 0 && indent != 4 { + continue; + } + let trimmed = line.trim_start(); + let definition = trimmed + .strip_prefix("def ") + .or_else(|| trimmed.strip_prefix("async def ")); + let Some(definition) = definition else { + continue; + }; + let Some((name, params)) = definition.split_once('(') else { + continue; + }; + let name = name.trim(); + if name.is_empty() || name.starts_with('_') { + continue; + } + methods.insert(name.to_owned(), mcp_method_signature(name, params)); + } + } + methods + .into_iter() + .map(|(name, signature)| McpToolMethod { name, signature }) + .collect() +} + +/// Render `name(params)` from the text after the opening paren of a `def` +/// line, dropping a leading `self`. Multi-line parameter lists fall back to +/// `name(...)`. +fn mcp_method_signature(name: &str, params: &str) -> String { + let mut depth = 1usize; + let Some(end) = params.find(|ch| { + match ch { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + _ => {} + } + depth == 0 + }) else { + return format!("{name}(...)"); + }; + let mut params = params[..end].trim(); + if let Some(rest) = params.strip_prefix("self") { + params = rest.trim_start().trim_start_matches(',').trim_start(); + } + format!("{name}({params})") +} + +fn mcp_tool_help_result( + tool: &DiscoveredTool, + methods: &[McpToolMethod], +) -> Result { + Ok(mcp_text_result( + serde_json::to_string_pretty(&json!({ + "tool": tool.name, + "description": tool.description, + "methods": methods + .iter() + .map(|method| method.signature.as_str()) + .collect::>(), + "usage": "Call this tool with {\"method\": \"\", \"arguments\": {}}.", + }))?, + false, + )) +} + +fn mcp_centaur_tool_catalog() -> Result, ApiError> { + // Discovery scans the tool dirs and parses package metadata on every + // call; reuse a recent result so each MCP request does not redo that + // I/O while still picking up newly synced tools quickly. Tests point + // the discovery env vars at per-case temp dirs, so they read live. + const CATALOG_TTL: Duration = Duration::from_secs(10); + static CATALOG_CACHE: Mutex)>> = Mutex::new(None); + if !cfg!(test) + && let Some((discovered_at, tools)) = CATALOG_CACHE.lock().unwrap().as_ref() + && discovered_at.elapsed() < CATALOG_TTL + { + return Ok(tools.clone()); + } + + let dirs = ToolDiscoveryConfig { + tool_dirs: env::var("TOOL_DIRS").ok(), + tools_path: env::var("TOOLS_PATH").ok().map(PathBuf::from), + tools_overlay_path: env::var("TOOLS_OVERLAY_PATH").ok().map(PathBuf::from), + plugins_dir: env::var("PLUGINS_DIR").ok().map(PathBuf::from), + tools_config: env::var("TOOLS_CONFIG").ok().map(PathBuf::from), + } + .resolve_tool_dirs() + .map_err(|error| ApiError::Internal(error.to_string()))?; + let tools = discover_tool_catalog(&dirs) + .map_err(|error| ApiError::Internal(error.to_string()))? + .tools; + if !cfg!(test) { + *CATALOG_CACHE.lock().unwrap() = Some((Instant::now(), tools.clone())); + } + Ok(tools) +} + +fn mcp_find_centaur_tool(name: &str) -> Result, ApiError> { + Ok(mcp_centaur_tool_catalog()? + .into_iter() + .find(|tool| tool.name == name)) +} + +fn mcp_whoami_result(principal: &McpPrincipal, arguments: Value) -> Result { + if !arguments.is_null() && !arguments.as_object().is_some_and(serde_json::Map::is_empty) { + return Err(ApiError::BadRequest( + "centaur_whoami does not accept arguments".to_owned(), + )); + } + Ok(mcp_text_result( + serde_json::to_string_pretty(&json!({ + "principal_id": principal.principal_id, + "token_id": principal.token_id, + "token_name": principal.name, + "scopes": principal.scopes, + "expires_at": principal + .expires_at + .map(|value| value.format(&time::format_description::well_known::Rfc3339)) + .transpose() + .map_err(|error| ApiError::Internal(error.to_string()))?, + }))?, + false, + )) +} + +async fn mcp_centaur_tool_result( + state: &AppState, + principal: &McpPrincipal, + tool: DiscoveredTool, + arguments: Value, +) -> Result { + let params = serde_json::from_value::(arguments) + .map_err(|error| ApiError::BadRequest(error.to_string()))?; + if params.method.trim().is_empty() { + return Err(ApiError::BadRequest("method is required".to_owned())); + } + let method = params.method.trim().to_owned(); + let methods = mcp_tool_methods(&tool); + if method == "help" { + return mcp_tool_help_result(&tool, &methods); + } + if !methods.iter().any(|candidate| candidate.name == method) { + return Ok(mcp_text_result( + format!( + "centaur tool {} has no method {method}. Available methods: {}", + tool.name, + methods + .iter() + .map(|method| method.signature.as_str()) + .collect::>() + .join(", ") + ), + true, + )); + } + run_tool_host_centaur_tool( + state.runtime()?, + principal, + &tool, + &method, + params.arguments, + ) + .await +} + +async fn run_tool_host_centaur_tool( + runtime: SessionRuntime, + principal: &McpPrincipal, + tool: &DiscoveredTool, + method: &str, + arguments: Value, +) -> Result { + let output = runtime + .run_tool_host_call(ToolHostCallInput { + principal_id: principal.principal_id.clone(), + token_id: Some(principal.token_id.clone()), + tool_name: tool.name.clone(), + method: method.to_owned(), + arguments, + timeout: Duration::from_secs(120), + }) + .await?; + if output.timed_out { + return Ok(mcp_text_result( + format!( + "centaur tool {}.{method} timed out in sandbox {}: {}", + tool.name, output.sandbox_id, output.stderr + ), + true, + )); + } + if output.exit_status != Some(0) { + let raw = if output.stderr.is_empty() { + &output.stdout + } else { + &output.stderr + }; + let detail = mcp_tool_failure_detail(raw); + return Ok(mcp_text_result( + format!( + "centaur tool {}.{method} failed in sandbox {} with status {:?}: {detail}\n\nCall the {} tool with method \"help\" to list available methods and their signatures.", + tool.name, output.sandbox_id, output.exit_status, tool.name + ), + true, + )); + } + let stdout = output.stdout.trim(); + if stdout.is_empty() { + return Ok(mcp_text_result("null".to_owned(), false)); + } + match serde_json::from_str::(stdout) { + Ok(value) => Ok(mcp_text_result( + serde_json::to_string_pretty(&value)?, + false, + )), + Err(error) => Ok(mcp_text_result( + format!( + "centaur tool {}.{method} returned non-json output in sandbox {}: {error}: {stdout}", + tool.name, output.sandbox_id + ), + true, + )), + } +} + +/// Reduce a Python traceback to its final exception message: agents act on +/// the error line, not on stack frames or build noise, so keep everything +/// from the last traceback's exception message to the end. +fn mcp_tool_failure_detail(raw: &str) -> String { + let trimmed = raw.trim(); + let Some(index) = trimmed.rfind("Traceback (most recent call last):") else { + return trimmed.to_owned(); + }; + let lines = trimmed[index..].lines().collect::>(); + let message_start = lines + .iter() + .skip(1) + .position(|line| !line.is_empty() && !line.starts_with(char::is_whitespace)); + match message_start { + Some(position) => lines[position + 1..].join("\n"), + None => trimmed.to_owned(), + } +} + +fn mcp_text_result(text: String, is_error: bool) -> Value { + json!({ + "content": [ + { + "type": "text", + "text": text, + }, + ], + "isError": is_error, + }) +} + +fn authenticate_mcp_bearer(headers: &HeaderMap) -> Result, ApiError> { + let Some(token) = bearer_token(headers) else { + return Ok(None); + }; + verify_mcp_jwt(&token, headers) +} + +#[derive(Debug, Deserialize)] +struct McpJwtHeader { + alg: String, +} + +#[derive(Debug, Deserialize)] +struct McpJwtClaims { + aud: Value, + exp: i64, + #[serde(default)] + iat: Option, + iss: String, + #[serde(default)] + jti: Option, + #[serde(default)] + name: Option, + #[serde(default)] + email: Option, + #[serde(default)] + nbf: Option, + principal_id: String, + #[serde(default)] + scope: Option, + #[serde(default)] + scopes: Option>, + #[serde(default)] + sub: Option, +} + +fn verify_mcp_jwt(token: &str, headers: &HeaderMap) -> Result, ApiError> { + let secret = jwt_signing_secret() + .filter(|secret| !secret.trim().is_empty()) + .ok_or_else(|| { + ApiError::ServiceUnavailable("CENTAUR_JWT_SIGNING_SECRET is not configured".to_owned()) + })?; + + let parts = token.split('.').collect::>(); + if parts.len() != 3 { + return Ok(None); + } + let Some(header) = decode_base64url_json::(parts[0]) else { + return Ok(None); + }; + if header.alg != "HS256" { + return Ok(None); + } + + let signing_input = format!("{}.{}", parts[0], parts[1]); + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).map_err(|_| { + ApiError::Internal("CENTAUR_JWT_SIGNING_SECRET is not valid HMAC key material".to_owned()) + })?; + mac.update(signing_input.as_bytes()); + let expected = mac.finalize().into_bytes(); + let Some(presented) = decode_base64url(parts[2]) else { + return Ok(None); + }; + if !constant_time_eq(&presented, expected.as_slice()) { + return Ok(None); + } + + let Some(claims) = decode_base64url_json::(parts[1]) else { + return Ok(None); + }; + let now = OffsetDateTime::now_utc().unix_timestamp(); + if claims.exp <= now { + return Ok(None); + } + if claims.nbf.is_some_and(|nbf| nbf > now + 30) { + return Ok(None); + } + if claims.iat.is_some_and(|iat| iat > now + 30) { + return Ok(None); + } + let Some(issuer) = mcp_authorization_server_url() else { + return Ok(None); + }; + if !same_url(&claims.iss, &issuer) { + return Ok(None); + } + if !audience_contains(&claims.aud, &mcp_resource_url(headers)) { + return Ok(None); + } + if claims.principal_id.trim().is_empty() { + return Ok(None); + } + + let mut scopes = claims.scopes.unwrap_or_default(); + if let Some(scope) = claims.scope { + scopes.extend(scope.split_whitespace().map(ToOwned::to_owned)); + } + scopes = normalize_scope_list(scopes); + if scopes.is_empty() { + return Ok(None); + } + let expires_at = OffsetDateTime::from_unix_timestamp(claims.exp).ok(); + let token_id = claims.jti.unwrap_or_else(|| { + let digest = Sha256::digest(token.as_bytes()); + format!("mcp_jwt_{}", hex::encode(&digest[..12])) + }); + let name = first_non_empty_owned([ + claims.name, + claims.email, + claims.sub, + Some(claims.principal_id.clone()), + ]) + .unwrap_or_else(|| claims.principal_id.clone()); + + Ok(Some(McpPrincipal { + token_id, + principal_id: claims.principal_id, + name, + scopes, + expires_at, + })) +} + +fn decode_base64url_json Deserialize<'de>>(value: &str) -> Option { + let decoded = decode_base64url(value)?; + serde_json::from_slice(&decoded).ok() +} + +fn decode_base64url(value: &str) -> Option> { + general_purpose::URL_SAFE_NO_PAD + .decode(value) + .or_else(|_| general_purpose::URL_SAFE.decode(value)) + .ok() +} + +fn normalize_scope_list(scopes: Vec) -> Vec { + let mut scopes = scopes + .into_iter() + .map(|scope| scope.trim().to_owned()) + .filter(|scope| !scope.is_empty()) + .collect::>(); + scopes.sort(); + scopes.dedup(); + scopes +} + +fn first_non_empty_owned(values: impl IntoIterator>) -> Option { + values + .into_iter() + .flatten() + .map(|value| value.trim().to_owned()) + .find(|value| !value.is_empty()) +} + +fn audience_contains(audience: &Value, resource: &str) -> bool { + match audience { + Value::String(value) => same_url(value, resource), + Value::Array(values) => values + .iter() + .filter_map(Value::as_str) + .any(|value| same_url(value, resource)), + _ => false, + } +} + +fn same_url(left: &str, right: &str) -> bool { + normalize_public_url(left) + .is_some_and(|left| normalize_public_url(right).is_some_and(|right| left == right)) +} + +fn bearer_token(headers: &HeaderMap) -> Option { + let value = header_value(headers, "Authorization")?; + let token = value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer ")) + .unwrap_or(value.as_str()) + .trim(); + (!token.is_empty()).then(|| token.to_owned()) +} + +fn ensure_mcp_scope(scopes: &[String], required: &str) -> Result<(), ApiError> { + if scopes + .iter() + .any(|scope| scope == "*" || scope == required || scope == "mcp:*") + { + Ok(()) + } else { + Err(ApiError::Forbidden(format!( + "missing required scope {required}" + ))) + } +} + +fn requested_mcp_protocol_version(params: &Value) -> &'static str { + const DEFAULT_PROTOCOL_VERSION: &str = "2025-06-18"; + match params + .get("protocolVersion") + .and_then(Value::as_str) + .filter(|version| !version.trim().is_empty()) + { + Some("2025-11-25") => "2025-11-25", + Some("2025-06-18") => "2025-06-18", + Some("2025-03-26") => "2025-03-26", + _ => DEFAULT_PROTOCOL_VERSION, + } +} + +fn mcp_json_error(id: Value, code: i64, message: &str) -> Response { + Json(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message, + }, + })) + .into_response() +} + +fn mcp_unauthorized(headers: &HeaderMap) -> Response { + let metadata = format!( + "{}/.well-known/oauth-protected-resource/mcp", + mcp_public_base_url(headers) + ); + let challenge = format!(r#"Bearer resource_metadata="{metadata}", scope="mcp:tools""#); + let mut response = ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "ok": false, + "error": "missing or invalid MCP bearer token", + })), + ) + .into_response(); + if let Ok(value) = HeaderValue::from_str(&challenge) { + response.headers_mut().insert("WWW-Authenticate", value); + } + response +} + +fn mcp_resource_url(headers: &HeaderMap) -> String { + if let Some(url) = mcp_public_url_env() + .as_deref() + .and_then(normalize_mcp_endpoint_url) + { + return url; + } + format!("{}/mcp", request_base_url(headers)) +} + +fn mcp_authorization_server_url() -> Option { + [console_public_url_env(), iron_control_public_url_env()] + .into_iter() + .find_map(|url| url.as_deref().and_then(normalize_public_url)) +} + +fn mcp_public_base_url(headers: &HeaderMap) -> String { + if let Some(url) = mcp_public_url_env() + .as_deref() + .and_then(normalize_public_url) + { + return url.strip_suffix("/mcp").unwrap_or(&url).to_owned(); + } + request_base_url(headers) +} + +// The variables below are static deployment configuration, so each is resolved +// once per process. Tests mutate them per-case, so cfg!(test) reads live. +fn static_env(cell: &'static OnceLock>, name: &str) -> Option { + if cfg!(test) { + return env::var(name).ok(); + } + cell.get_or_init(|| env::var(name).ok()).clone() +} + +fn jwt_signing_secret() -> Option { + static CELL: OnceLock> = OnceLock::new(); + static_env(&CELL, "CENTAUR_JWT_SIGNING_SECRET") +} + +fn mcp_public_url_env() -> Option { + static CELL: OnceLock> = OnceLock::new(); + static_env(&CELL, "CENTAUR_MCP_PUBLIC_URL") +} + +fn console_public_url_env() -> Option { + static CELL: OnceLock> = OnceLock::new(); + static_env(&CELL, "CENTAUR_CONSOLE_PUBLIC_URL") +} + +fn iron_control_public_url_env() -> Option { + static CELL: OnceLock> = OnceLock::new(); + static_env(&CELL, "IRON_CONTROL_PUBLIC_URL") +} + +fn normalize_mcp_endpoint_url(value: &str) -> Option { + let mut url = normalize_public_url(value)?; + if !url.ends_with("/mcp") { + url.push_str("/mcp"); + } + Some(url) +} + +fn normalize_public_url(value: &str) -> Option { + let trimmed = value.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_owned()) +} + +fn request_base_url(headers: &HeaderMap) -> String { + let proto = header_value(headers, "X-Forwarded-Proto").unwrap_or_else(|| "http".to_owned()); + let host = header_value(headers, "X-Forwarded-Host") + .or_else(|| header_value(headers, "Host")) + .unwrap_or_else(|| "127.0.0.1:8080".to_owned()); + format!("{}://{}", proto.trim(), host.trim()) +} + +/// Compare two byte strings in constant time (modulo length, which is not +/// secret here). +fn constant_time_eq(actual: &[u8], expected: &[u8]) -> bool { + use subtle::ConstantTimeEq; + + actual.ct_eq(expected).into() +} + +#[cfg(test)] +mod mcp_tests { + use std::{ + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, + }; + + use futures_util::FutureExt; + + use super::*; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + saved: Vec<(&'static str, Option)>, + } + + impl EnvGuard { + fn set(vars: &[(&'static str, &'static str)]) -> Self { + let saved = vars + .iter() + .map(|(name, _)| (*name, env::var(name).ok())) + .collect(); + for (name, value) in vars { + // SAFETY: tests that mutate process env hold ENV_LOCK for the + // duration of the guard, so concurrent tests in this module + // cannot observe partial mutations. + unsafe { + env::set_var(name, value); + } + } + Self { saved } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (name, value) in self.saved.drain(..) { + // SAFETY: see EnvGuard::set; the lock outlives the guard. + unsafe { + if let Some(value) = value { + env::set_var(name, value); + } else { + env::remove_var(name); + } + } + } + } + } + + fn temp_dir(prefix: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{}-{suffix}", std::process::id())) + } + + fn test_tool(project_dir: PathBuf) -> DiscoveredTool { + DiscoveredTool { + name: "demo".to_owned(), + package: "demo".to_owned(), + description: Some("Demo tool".to_owned()), + client_module: "client.py".to_owned(), + project_dir, + } + } + + fn test_jwt(secret: &str, claims: Value) -> String { + let header = general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&json!({"alg": "HS256", "typ": "JWT"})).unwrap()); + let payload = general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let signing_input = format!("{header}.{payload}"); + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(signing_input.as_bytes()); + let signature = general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + format!("{signing_input}.{signature}") + } + + fn mcp_auth_headers(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + headers + } + + #[test] + fn mcp_tool_method_names_include_public_client_methods_and_help() { + let temp = temp_dir("centaur-api-rs-mcp-methods"); + fs::create_dir_all(&temp).unwrap(); + fs::write( + temp.join("client.py"), + r#" +def search(query, limit=20): + return [] + +def _hidden(): + return None + +class DemoClient: + def list_channels(self, limit=200): + def nested_helper(): + return None + return [] + + async def search_messages(self, query): + return [] +"#, + ) + .unwrap(); + + let parsed = mcp_tool_methods(&test_tool(temp.clone())); + let methods = parsed + .iter() + .map(|method| method.name.clone()) + .collect::>(); + + assert!(methods.contains(&"help".to_owned())); + assert!(methods.contains(&"search".to_owned())); + assert!(methods.contains(&"list_channels".to_owned())); + assert!(methods.contains(&"search_messages".to_owned())); + assert!(!methods.contains(&"_hidden".to_owned())); + assert!(!methods.contains(&"nested_helper".to_owned())); + + let signatures = parsed + .into_iter() + .map(|method| method.signature) + .collect::>(); + assert!(signatures.contains(&"search(query, limit=20)".to_owned())); + assert!(signatures.contains(&"list_channels(limit=200)".to_owned())); + assert!(signatures.contains(&"search_messages(query)".to_owned())); + assert!(signatures.contains(&"help()".to_owned())); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn mcp_tool_failure_detail_keeps_final_exception_from_chained_traceback() { + let stderr = r#"Building twitter @ file:///tools/comms/twitter +Installed 16 packages in 66ms +Traceback (most recent call last): + File "/tools/comms/twitter/client.py", line 53, in _request + response.raise_for_status() +httpx.HTTPStatusError: Client error '401 Unauthorized' for url 'https://api.x.com/2/tweets/search/recent' + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "", line 45, in + File "/tools/comms/twitter/client.py", line 229, in search_tweets + tweets, meta, includes = self._paged( +RuntimeError: X API error: 401 - { + "title": "Unauthorized", + "status": 401 +}"#; + + let detail = mcp_tool_failure_detail(stderr); + + assert!(detail.starts_with("RuntimeError: X API error: 401")); + assert!(detail.contains("\"title\": \"Unauthorized\"")); + assert!(!detail.contains("Traceback")); + assert!(!detail.contains("Installed 16 packages")); + + let plain = "invalid arguments for search_tweets(query, limit=10): got an unexpected keyword argument 'max_results'"; + assert_eq!(mcp_tool_failure_detail(plain), plain); + } + + #[tokio::test] + async fn mcp_unknown_method_returns_available_methods_without_running_tool() { + let temp = temp_dir("centaur-api-rs-mcp-unknown-method"); + fs::create_dir_all(&temp).unwrap(); + fs::write( + temp.join("client.py"), + r#" +def search(query, limit=20): + return [] +"#, + ) + .unwrap(); + + let result = mcp_centaur_tool_result( + &AppState::unready(), + &McpPrincipal { + principal_id: "mcp:test".to_owned(), + token_id: "mcp_tok_test".to_owned(), + name: "test".to_owned(), + scopes: vec!["mcp:tools".to_owned()], + expires_at: None, + }, + test_tool(temp.clone()), + json!({"method": "missing", "arguments": {}}), + ) + .await + .unwrap(); + + assert_eq!(result["isError"], true); + let text = result["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("has no method missing")); + assert!(text.contains("search")); + + let _ = fs::remove_dir_all(temp); + } + + #[tokio::test] + async fn mcp_unknown_method_is_rejected_when_tool_has_no_public_methods() { + let temp = temp_dir("centaur-api-rs-mcp-no-methods"); + fs::create_dir_all(&temp).unwrap(); + fs::write(temp.join("client.py"), "def _hidden():\n return None\n").unwrap(); + + let result = mcp_centaur_tool_result( + &AppState::unready(), + &McpPrincipal { + principal_id: "mcp:test".to_owned(), + token_id: "mcp_tok_test".to_owned(), + name: "test".to_owned(), + scopes: vec!["mcp:tools".to_owned()], + expires_at: None, + }, + test_tool(temp.clone()), + json!({"method": "missing", "arguments": {}}), + ) + .await + .unwrap(); + + assert_eq!(result["isError"], true); + let text = result["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("has no method missing")); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn mcp_jwt_authenticates_console_principal() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("CENTAUR_JWT_SIGNING_SECRET", "test-secret"), + ("CENTAUR_MCP_PUBLIC_URL", "http://localhost:3000/mcp"), + ("CENTAUR_CONSOLE_PUBLIC_URL", "http://localhost:3001"), + ]); + let token = test_jwt( + "test-secret", + json!({ + "iss": "http://localhost:3001", + "sub": "usr_test", + "aud": "http://localhost:3000/mcp", + "exp": OffsetDateTime::now_utc().unix_timestamp() + 3600, + "iat": OffsetDateTime::now_utc().unix_timestamp(), + "jti": "mcpjwt_test", + "scope": "mcp:tools", + "principal_id": "prn_test", + "email": "test@example.com", + }), + ); + + let principal = authenticate_mcp_bearer(&mcp_auth_headers(&token)) + .unwrap() + .unwrap(); + + assert_eq!(principal.token_id, "mcpjwt_test"); + assert_eq!(principal.principal_id, "prn_test"); + assert_eq!(principal.name, "test@example.com"); + assert_eq!(principal.scopes, vec!["mcp:tools"]); + assert!(principal.expires_at.is_some()); + } + + #[test] + fn mcp_jwt_rejects_wrong_audience() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("CENTAUR_JWT_SIGNING_SECRET", "test-secret"), + ("CENTAUR_MCP_PUBLIC_URL", "http://localhost:3000/mcp"), + ("CENTAUR_CONSOLE_PUBLIC_URL", "http://localhost:3001"), + ]); + let token = test_jwt( + "test-secret", + json!({ + "iss": "http://localhost:3001", + "aud": "http://other.example/mcp", + "exp": OffsetDateTime::now_utc().unix_timestamp() + 3600, + "principal_id": "prn_test", + "scope": "mcp:tools", + }), + ); + + assert!( + authenticate_mcp_bearer(&mcp_auth_headers(&token)) + .unwrap() + .is_none() + ); + } + + #[test] + fn mcp_jwt_rejects_issued_at_in_the_future() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("CENTAUR_JWT_SIGNING_SECRET", "test-secret"), + ("CENTAUR_MCP_PUBLIC_URL", "http://localhost:3000/mcp"), + ("CENTAUR_CONSOLE_PUBLIC_URL", "http://localhost:3001"), + ]); + let token = test_jwt( + "test-secret", + json!({ + "iss": "http://localhost:3001", + "aud": "http://localhost:3000/mcp", + "exp": OffsetDateTime::now_utc().unix_timestamp() + 3600, + "iat": OffsetDateTime::now_utc().unix_timestamp() + 600, + "principal_id": "prn_test", + "scope": "mcp:tools", + }), + ); + + assert!( + authenticate_mcp_bearer(&mcp_auth_headers(&token)) + .unwrap() + .is_none() + ); + } + + #[test] + fn mcp_jwt_rejects_internal_console_control_plane_issuer() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("CENTAUR_JWT_SIGNING_SECRET", "test-secret"), + ("CENTAUR_MCP_PUBLIC_URL", "http://localhost:3000/mcp"), + ("CENTAUR_CONSOLE_PUBLIC_URL", ""), + ("IRON_CONTROL_PUBLIC_URL", ""), + ("CENTAUR_CONSOLE_URL", "http://centaur-console:3000"), + ("IRON_CONTROL_URL", "http://centaur-console:3000"), + ]); + let token = test_jwt( + "test-secret", + json!({ + "iss": "http://centaur-console:3000", + "aud": "http://localhost:3000/mcp", + "exp": OffsetDateTime::now_utc().unix_timestamp() + 3600, + "principal_id": "prn_test", + "scope": "mcp:tools", + }), + ); + + assert!( + authenticate_mcp_bearer(&mcp_auth_headers(&token)) + .unwrap() + .is_none() + ); + } + + #[test] + fn mcp_non_jwt_bearer_values_are_not_accepted() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[("CENTAUR_JWT_SIGNING_SECRET", "test-secret")]); + + assert!( + authenticate_mcp_bearer(&mcp_auth_headers("not-a-jwt-token")) + .unwrap() + .is_none() + ); + } + + #[test] + fn mcp_protected_resource_metadata_uses_configured_urls() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("CENTAUR_MCP_PUBLIC_URL", "http://localhost:3000"), + ("CENTAUR_CONSOLE_PUBLIC_URL", "http://localhost:3001"), + ]); + + let Json(metadata) = mcp_protected_resource_metadata(HeaderMap::new()) + .now_or_never() + .unwrap(); + + assert_eq!(metadata["resource"], "http://localhost:3000/mcp"); + assert_eq!( + metadata["authorization_servers"][0], + "http://localhost:3001" + ); + } + + #[test] + fn mcp_protected_resource_metadata_ignores_internal_console_control_plane_url() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("CENTAUR_CONSOLE_PUBLIC_URL", ""), + ("IRON_CONTROL_PUBLIC_URL", ""), + ("CENTAUR_CONSOLE_URL", "http://centaur-console:3000"), + ("IRON_CONTROL_URL", "http://centaur-console:3000"), + ]); + let Json(metadata) = mcp_protected_resource_metadata(HeaderMap::new()) + .now_or_never() + .unwrap(); + + assert_eq!(metadata["authorization_servers"], json!([])); + } + + #[test] + fn mcp_unauthorized_challenge_uses_public_metadata_url() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[("CENTAUR_MCP_PUBLIC_URL", "http://localhost:3000/mcp")]); + + let response = mcp_unauthorized(&HeaderMap::new()); + let challenge = response + .headers() + .get("WWW-Authenticate") + .unwrap() + .to_str() + .unwrap(); + + assert!(challenge.contains( + r#"resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp""# + )); + assert!(!challenge.contains("/mcp/.well-known")); + } +} diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index 9cc8cec9f..e95cef0df 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -54,6 +54,7 @@ use uuid::Uuid; use crate::{ ApiError, + mcp::{mcp_get, mcp_post, mcp_protected_resource_metadata}, types::{ AppendMessagesRequest, AppendMessagesResponse, CreateSessionRequest, CreateSessionResponse, EmitWorkflowEventRequest, EventsQuery, ExecuteSessionRequest, ExecuteSessionResponse, @@ -125,7 +126,7 @@ impl AppState { self.initialized().is_some() } - fn runtime(&self) -> Result { + pub(crate) fn runtime(&self) -> Result { self.initialized() .map(|initialized| initialized.runtime) .ok_or_else(|| ApiError::ServiceUnavailable("api-rs is still starting".to_owned())) @@ -189,6 +190,15 @@ pub fn build_router_with_app_state(state: AppState) -> Router { .route("/readyz", get(readyz)) .route("/metrics", get(metrics)) .route("/api/personas", get(list_personas)) + .route("/mcp", post(mcp_post).get(mcp_get)) + .route( + "/.well-known/oauth-protected-resource", + get(mcp_protected_resource_metadata), + ) + .route( + "/.well-known/oauth-protected-resource/mcp", + get(mcp_protected_resource_metadata), + ) .route( "/api/session/{thread_key}", post(create_or_get_session).get(get_session_context), @@ -2879,7 +2889,7 @@ fn signature_header_name(auth: &WorkflowWebhookAuth) -> Option<&str> { } } -fn header_value(headers: &HeaderMap, name: &str) -> Option { +pub(crate) fn header_value(headers: &HeaderMap, name: &str) -> Option { headers .get(name) .and_then(|value| value.to_str().ok()) diff --git a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs index d293639ee..a0efab019 100644 --- a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs +++ b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs @@ -38,23 +38,37 @@ const DEFAULT_MATCH_HEADERS: &[&str] = &[ ]; #[derive(Clone, Debug, Default)] -pub(crate) struct ToolDiscoveryConfig { - pub(crate) tool_dirs: Option, - pub(crate) tools_path: Option, - pub(crate) tools_overlay_path: Option, - pub(crate) plugins_dir: Option, - pub(crate) tools_config: Option, +pub struct ToolDiscoveryConfig { + pub tool_dirs: Option, + pub tools_path: Option, + pub tools_overlay_path: Option, + pub plugins_dir: Option, + pub tools_config: Option, } #[derive(Clone, Debug)] -pub(crate) struct DiscoveredToolProxyFragment { - pub(crate) fragment: ProxyFragment, - pub(crate) tool_count: usize, - pub(crate) secret_count: usize, +pub struct DiscoveredToolProxyFragment { + pub fragment: ProxyFragment, + pub tool_count: usize, + pub secret_count: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiscoveredToolCatalog { + pub(crate) tools: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiscoveredTool { + pub(crate) name: String, + pub(crate) package: String, + pub(crate) description: Option, + pub(crate) client_module: String, + pub(crate) project_dir: PathBuf, } #[derive(Debug, Error)] -pub(crate) enum ToolDiscoveryError { +pub enum ToolDiscoveryError { #[error("failed to read {path}: {source}")] Read { path: PathBuf, @@ -72,7 +86,7 @@ pub(crate) enum ToolDiscoveryError { } impl ToolDiscoveryConfig { - pub(crate) fn resolve_tool_dirs(&self) -> Result, ToolDiscoveryError> { + pub fn resolve_tool_dirs(&self) -> Result, ToolDiscoveryError> { if let Some(tool_dirs) = clean_optional_str(self.tool_dirs.as_deref()) { return Ok(split_tool_dirs(&tool_dirs)); } @@ -113,7 +127,7 @@ impl ToolDiscoveryConfig { } } -pub(crate) fn discover_tool_proxy_fragment( +pub fn discover_tool_proxy_fragment( tool_dirs: &[PathBuf], ) -> Result { let tools = collect_plugin_metadata(tool_dirs)?.tools; @@ -136,7 +150,7 @@ pub(crate) fn discover_tool_proxy_fragment( }) } -pub(crate) fn discover_persona_registry( +pub fn discover_persona_registry( tool_dirs: &[PathBuf], default_persona_id: Option, ) -> Result { @@ -145,6 +159,25 @@ pub(crate) fn discover_persona_registry( .map_err(ToolDiscoveryError::Invalid) } +pub(crate) fn discover_tool_catalog( + tool_dirs: &[PathBuf], +) -> Result { + let mut tools = Vec::new(); + for tool in collect_plugin_metadata(tool_dirs)?.tools { + for script_name in tool.script_names { + tools.push(DiscoveredTool { + name: script_name, + package: tool.package.clone(), + description: tool.description.clone(), + client_module: tool.client_module.clone(), + project_dir: tool.dir.clone(), + }); + } + } + tools.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(DiscoveredToolCatalog { tools }) +} + fn split_tool_dirs(value: &str) -> Vec { value .split(':') @@ -281,6 +314,11 @@ fn parse_toml(path: &Path, contents: &str) -> Result, + client_module: String, + script_names: Vec, secrets: Vec, } @@ -436,7 +474,7 @@ fn load_plugin_meta( .and_then(|value| value.get("centaur")) .unwrap_or(&default_tool_conf); if tool_conf.get("type").and_then(TomlValue::as_str) != Some("persona") { - return load_tool_meta(source_root, plugin_dir, tool_conf) + return load_tool_meta(source_root, plugin_dir, &pyproject, tool_conf) .map(|meta| meta.map(LoadedPluginMeta::Tool)); } let id = plugin_dir @@ -473,6 +511,7 @@ fn load_plugin_meta( fn load_tool_meta( source_root: &Path, tool_dir: &Path, + pyproject: &TomlValue, tool_conf: &TomlValue, ) -> Result, ToolDiscoveryError> { let name = tool_dir @@ -482,6 +521,40 @@ fn load_tool_meta( ToolDiscoveryError::Invalid(format!("invalid tool path {}", tool_dir.display())) })? .to_owned(); + let default_project_conf = TomlValue::Table(Default::default()); + let project_conf = pyproject.get("project").unwrap_or(&default_project_conf); + let package = project_conf + .get("name") + .and_then(TomlValue::as_str) + .map(str::to_owned) + .unwrap_or_else(|| name.clone()); + let description = project_conf + .get("description") + .and_then(TomlValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let client_module = tool_conf + .get("module") + .and_then(TomlValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("client.py") + .to_owned(); + let script_names = project_conf + .get("scripts") + .and_then(TomlValue::as_table) + .map(|scripts| { + let mut names = scripts + .keys() + .filter(|script| !script.contains('/') && !script.contains('\0')) + .cloned() + .collect::>(); + names.sort(); + names + }) + .filter(|names| !names.is_empty()) + .unwrap_or_else(|| vec![name.clone()]); let default_hosts = string_array(tool_conf.get("hosts")); let labels = tool_labels(&name, &overlay_name_for_root(source_root)); let secrets = match parse_secret_list(tool_conf.get("secrets"), &default_hosts, &labels) @@ -503,7 +576,15 @@ fn load_tool_meta( return Ok(None); } }; - Ok(Some(LoadedToolMeta { name, secrets })) + Ok(Some(LoadedToolMeta { + name, + dir: tool_dir.to_path_buf(), + package, + description, + client_module, + script_names, + secrets, + })) } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 0488dfc52..76d8deda6 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -39,10 +39,11 @@ use thiserror::Error; use tokio::{ io, sync::Mutex, - time::{Instant, Interval, MissedTickBehavior, interval_at, sleep}, + time::{Instant, Interval, MissedTickBehavior, interval_at, sleep, timeout}, }; use tokio_util::codec::{FramedRead, FramedWrite, LinesCodec, LinesCodecError}; use tracing::{Instrument, Span, error, info, info_span, warn}; +use uuid::Uuid; pub use cleanup::SessionSandboxCleanupConfig; pub use title_generator::SessionTitleGenerationError; @@ -72,6 +73,7 @@ type SessionInputSink = FramedWrite; type ExecutionSpanRegistry = Arc>>; type SessionPipeMap = Arc>; type SessionPipeOpenLocks = Arc>>>; +type ToolHostCallLocks = Arc>>>; type SessionTitleThreadSet = Arc>; type SessionTitleGenerator = Arc< dyn Fn(String) -> BoxFuture<'static, Result> + Send + Sync, @@ -83,6 +85,7 @@ pub struct SessionRuntime { sandbox_runtime: SandboxRuntime, sandbox_pipes: SessionPipeMap, sandbox_pipe_open_locks: SessionPipeOpenLocks, + tool_host_call_locks: ToolHostCallLocks, execution_spans: ExecutionSpanRegistry, iron_control: Option, warm_pool: Option>, @@ -283,11 +286,53 @@ pub struct ExecuteSessionInput { pub max_duration_ms: Option, } +#[derive(Debug)] +pub struct ToolHostCallInput { + pub principal_id: String, + pub token_id: Option, + pub tool_name: String, + pub method: String, + pub arguments: Value, + pub timeout: Duration, +} + +#[derive(Debug)] +pub struct ToolHostCallOutput { + pub sandbox_id: String, + pub stdout: String, + pub stderr: String, + pub exit_status: Option, + pub timed_out: bool, +} + #[derive(Clone)] struct SessionPipe { stdin: Arc>, } +#[derive(Serialize)] +struct ToolHostRequest { + id: String, + tool: String, + method: String, + arguments: Value, + principal_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + token_id: Option, + timeout_seconds: u64, +} + +#[derive(Deserialize)] +struct ToolHostResponse { + status: Option, + #[serde(default)] + stdout: String, + #[serde(default)] + stderr: String, + #[serde(default)] + timed_out: bool, +} + /// Shared handles threaded through background session tasks (stdout pump, /// terminal-output recording, max-duration failure, idle pause). #[derive(Clone)] @@ -655,6 +700,25 @@ struct EnsureSessionSandboxRequest<'a> { execution_id: &'a str, } +#[derive(Clone, Debug, Eq, PartialEq)] +enum SandboxBootMode { + Harness, + ToolHost { principal_id: String }, +} + +impl SandboxBootMode { + fn as_str(&self) -> &'static str { + match self { + Self::Harness => "harness", + Self::ToolHost { .. } => "tool_host", + } + } + + fn uses_warm_pool(&self) -> bool { + matches!(self, Self::Harness) + } +} + struct PersonaResolution { persona_id: Option, context: Option, @@ -668,6 +732,7 @@ impl SessionRuntime { sandbox_runtime, sandbox_pipes: Arc::new(DashMap::new()), sandbox_pipe_open_locks: Arc::new(DashMap::new()), + tool_host_call_locks: Arc::new(DashMap::new()), execution_spans: Arc::new(Mutex::new(HashMap::new())), iron_control: None, warm_pool: None, @@ -777,6 +842,254 @@ impl SessionRuntime { } } + pub async fn run_tool_host_call( + &self, + input: ToolHostCallInput, + ) -> Result { + let principal_id = input.principal_id.trim().to_owned(); + let tool_name = input.tool_name.trim().to_owned(); + let method = input.method.trim().to_owned(); + if principal_id.is_empty() { + return Err(SessionRuntimeError::BadRequest( + "tool host principal_id is required".to_owned(), + )); + } + if tool_name.is_empty() { + return Err(SessionRuntimeError::BadRequest( + "tool host tool_name is required".to_owned(), + )); + } + if method.is_empty() { + return Err(SessionRuntimeError::BadRequest( + "tool host method is required".to_owned(), + )); + } + if input.timeout.is_zero() { + return Err(SessionRuntimeError::BadRequest( + "tool host timeout must be non-zero".to_owned(), + )); + } + + let thread_key = tool_host_thread_key(&principal_id)?; + let input = ToolHostCallInput { + principal_id, + tool_name, + method, + ..input + }; + let call_lock = self.tool_host_call_lock(&thread_key); + let result = { + let _call_guard = call_lock.lock().await; + self.locked_tool_host_call(&thread_key, input).await + }; + // Drop our clone so an idle entry is only referenced by the map, then + // evict it; remove_if holds the shard lock, so no concurrent caller + // can clone the entry between the count check and the removal. + drop(call_lock); + self.tool_host_call_locks + .remove_if(thread_key.as_str(), |_, lock| Arc::strong_count(lock) == 1); + result + } + + fn tool_host_call_lock(&self, thread_key: &ThreadKey) -> Arc> { + self.tool_host_call_locks + .entry(thread_key.as_str().to_owned()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + async fn locked_tool_host_call( + &self, + thread_key: &ThreadKey, + input: ToolHostCallInput, + ) -> Result { + let ToolHostCallInput { + principal_id, + token_id, + tool_name, + method, + arguments, + timeout, + } = input; + self.create_or_get_tool_host_session(thread_key, &principal_id) + .await?; + + let request_id = format!("mcp-call-{}", Uuid::new_v4().simple()); + let request = ToolHostRequest { + id: request_id.clone(), + tool: tool_name.clone(), + method: method.clone(), + arguments, + principal_id, + token_id, + timeout_seconds: timeout.as_secs().max(1), + }; + let input_line = serde_json::to_string(&request).map_err(|error| { + SessionRuntimeError::Sandbox(SandboxError::io_source("encode tool host request", error)) + })?; + let response_timeout = timeout.saturating_add(Duration::from_secs(5)); + let execution = self + .execute_session( + thread_key, + ExecuteSessionInput { + idempotency_key: Some(request_id.clone()), + metadata: Some(json!({ + "mcp_tool_host_call": true, + "request_id": request_id, + "tool": tool_name, + "method": method, + "timeout_ms": duration_millis_u64(timeout), + })), + input_lines: vec![input_line], + idle_timeout_ms: None, + max_duration_ms: Some(duration_millis_u64(response_timeout)), + }, + ) + .await?; + self.wait_for_tool_host_call(thread_key, &execution.execution_id, response_timeout) + .await + } + + async fn create_or_get_tool_host_session( + &self, + thread_key: &ThreadKey, + principal_id: &str, + ) -> Result<(), SessionRuntimeError> { + let harness = self + .sandbox_runtime + .warm_harness + .clone() + .unwrap_or(HarnessType::Codex); + let metadata = tool_host_session_metadata(principal_id); + let session = self + .store + .create_or_get_session(thread_key, &harness, None, metadata) + .await?; + if self.iron_control.is_some() + && session.iron_control_principal.as_deref() != Some(principal_id) + { + self.store + .set_iron_control_principal(thread_key, Some(principal_id)) + .await?; + } + Ok(()) + } + + async fn wait_for_tool_host_call( + &self, + thread_key: &ThreadKey, + execution_id: &str, + response_timeout: Duration, + ) -> Result { + let events = self + .stream_events(thread_key, 0, Some(execution_id)) + .await?; + futures_util::pin_mut!(events); + match timeout(response_timeout, async { + while let Some(event) = events.next().await { + let event = event?; + match event.event_type.as_str() { + "session.execution_completed" => { + return self.tool_host_completed_output(thread_key, &event).await; + } + "session.execution_failed" => { + return self.tool_host_failed_output(thread_key, &event).await; + } + _ => {} + } + } + Err(SessionRuntimeError::Sandbox(SandboxError::io( + "session event stream ended before tool host call completed", + ))) + }) + .await + { + Ok(output) => output, + // Best-effort sandbox id: a store error must not replace the + // timeout result with an internal error. + Err(_) => Ok(ToolHostCallOutput { + sandbox_id: self + .current_sandbox_id(thread_key) + .await + .unwrap_or_default(), + stdout: String::new(), + stderr: format!( + "tool host call timed out after {} ms", + response_timeout.as_millis() + ), + exit_status: None, + timed_out: true, + }), + } + } + + async fn tool_host_completed_output( + &self, + thread_key: &ThreadKey, + event: &SessionEvent, + ) -> Result { + let sandbox_id = self.current_sandbox_id(thread_key).await?; + let Some(result_text) = event.payload.get("result_text").and_then(Value::as_str) else { + return Ok(ToolHostCallOutput { + sandbox_id, + stdout: String::new(), + stderr: String::new(), + exit_status: Some(0), + timed_out: false, + }); + }; + let response = serde_json::from_str::(result_text).map_err(|error| { + SessionRuntimeError::Sandbox(SandboxError::io_source( + "decode tool host response", + error, + )) + })?; + Ok(ToolHostCallOutput { + sandbox_id, + stdout: response.stdout, + stderr: response.stderr, + exit_status: response.status, + timed_out: response.timed_out, + }) + } + + async fn tool_host_failed_output( + &self, + thread_key: &ThreadKey, + event: &SessionEvent, + ) -> Result { + let error = event + .payload + .get("error") + .and_then(Value::as_str) + .unwrap_or("tool host execution failed") + .to_owned(); + let timed_out = event + .payload + .get("reason") + .and_then(Value::as_str) + .is_some_and(|reason| reason == "max_duration_exceeded"); + Ok(ToolHostCallOutput { + sandbox_id: self.current_sandbox_id(thread_key).await?, + stdout: String::new(), + stderr: error, + exit_status: None, + timed_out, + }) + } + + async fn current_sandbox_id( + &self, + thread_key: &ThreadKey, + ) -> Result { + Ok(self + .store + .get_session(thread_key) + .await? + .sandbox_id + .unwrap_or_default()) + } + async fn claim_stdout_owner(&self, execution_id: &str) -> Result<(), SessionRuntimeError> { let claimed = self .store @@ -806,12 +1119,45 @@ impl SessionRuntime { } /// Attach an iron-control registrar so each new session upserts its - /// principal and assigns the configured roles. + /// principal and assigns it the configured roles. pub fn with_iron_control(mut self, registrar: SessionRegistrar) -> Self { self.iron_control = Some(registrar); self } + /// Register the shared unauthenticated MCP tool-host principal when + /// iron-control is enabled, so proxy-backed tool calls can resolve an + /// effective config without minting per-user credentials in this layer. + pub async fn register_mcp_tool_host_principal( + &self, + principal_id: &str, + ) -> Result { + let principal_id = principal_id.trim(); + if principal_id.is_empty() { + return Err(SessionRuntimeError::BadRequest( + "mcp tool host principal_id is required".to_owned(), + )); + } + if principal_id.contains(':') { + return Err(SessionRuntimeError::BadRequest( + "mcp tool host principal_id must not contain ':'".to_owned(), + )); + } + let thread_key = tool_host_thread_key(principal_id)?; + if let Some(registrar) = &self.iron_control { + // Serialize with run_tool_host_call so concurrent registrations + // for the same principal cannot interleave with session setup. + let call_lock = self.tool_host_call_lock(&thread_key); + let _call_guard = call_lock.lock().await; + let metadata = tool_host_session_metadata(principal_id); + let principal = registrar + .register_session(thread_key.as_str(), Some(&metadata)) + .await?; + return Ok(principal.id); + } + Ok(principal_id.to_owned()) + } + pub fn with_warm_pool(mut self, config: WarmPoolConfig) -> Self { if config.target_size == 0 { return self; @@ -1842,6 +2188,7 @@ impl SessionRuntime { desired_capabilities, execution_id, } = request; + let boot_mode = sandbox_boot_mode_for_thread(thread_key, iron_control_principal); let span = info_span!( "centaur.api_rs.sandbox.ensure", component = COMPONENT_SESSION_RUNTIME, @@ -1855,6 +2202,7 @@ impl SessionRuntime { existing_sandbox_id = existing_sandbox_id.unwrap_or(""), iron_control_principal_present = iron_control_principal.is_some(), persona_id = persona_id.unwrap_or(""), + sandbox_boot_mode = boot_mode.as_str(), sandbox_repo_cache_enabled = desired_capabilities.repo_cache_enabled, sandbox_observability_enabled = desired_capabilities.observability_enabled, ); @@ -2063,7 +2411,8 @@ impl SessionRuntime { .warm_pool .as_ref() .filter(|_| { - warm_harness_matches + boot_mode.uses_warm_pool() + && warm_harness_matches && warm_persona_matches && desired_capabilities.is_default_enabled() }) @@ -2138,6 +2487,7 @@ impl SessionRuntime { if let Some(principal) = iron_control_principal { spec.iron_control_principal = Some(principal.to_owned()); } + apply_sandbox_boot_mode(&mut spec, &boot_mode); apply_sandbox_capabilities(&mut spec, desired_capabilities); let create_started = Instant::now(); let handle = self @@ -5507,6 +5857,66 @@ fn nonzero_duration_millis(value: u64) -> Result Ok(Duration::from_millis(value)) } +fn tool_host_thread_key(principal_id: &str) -> Result { + ThreadKey::parse(format!("mcp:{principal_id}")) + .map_err(|error| SessionRuntimeError::BadRequest(error.to_string())) +} + +/// Session/principal metadata recorded for observability; runtime behavior +/// derives from the `mcp:` thread-key prefix, not from these fields. +fn tool_host_session_metadata(principal_id: &str) -> Value { + json!({ + "mcp_tool_host": true, + "mcp_principal_id": principal_id, + }) +} + +fn sandbox_boot_mode_for_thread( + thread_key: &ThreadKey, + iron_control_principal: Option<&str>, +) -> SandboxBootMode { + let Some(thread_principal_id) = thread_key.as_str().strip_prefix("mcp:") else { + return SandboxBootMode::Harness; + }; + let principal_id = iron_control_principal + .unwrap_or(thread_principal_id) + .to_owned(); + SandboxBootMode::ToolHost { principal_id } +} + +fn apply_sandbox_boot_mode(spec: &mut SandboxSpec, boot_mode: &SandboxBootMode) { + let SandboxBootMode::ToolHost { principal_id } = boot_mode else { + return; + }; + spec.labels + .insert("centaur.ai/component".to_owned(), "tool-host".to_owned()); + spec.labels + .insert("centaur.ai/workload".to_owned(), "mcp-tool-host".to_owned()); + if !principal_id.trim().is_empty() { + spec.iron_control_principal = Some(principal_id.to_owned()); + upsert_spec_env(spec, "CENTAUR_MCP_PRINCIPAL_ID", principal_id.to_owned()); + } + configure_tool_host_command(spec); +} + +fn configure_tool_host_command(spec: &mut SandboxSpec) { + if should_preserve_entrypoint_for_tool_host(spec) { + spec.command = Some(vec!["/entrypoint.sh".to_owned()]); + spec.args = vec!["centaur-tool-host".to_owned()]; + } else { + spec.command = Some(vec!["centaur-tool-host".to_owned()]); + spec.args.clear(); + } +} + +fn should_preserve_entrypoint_for_tool_host(spec: &SandboxSpec) -> bool { + spec.command + .as_ref() + .and_then(|command| command.first()) + .is_some_and(|program| program == "/entrypoint.sh") + || spec.args.first().is_some_and(|arg| arg == "harness-server") +} + fn execution_metadata( metadata: Option, idle_timeout_ms: Option, @@ -5621,6 +6031,23 @@ mod tests { assert!(PersonaRegistry::new(Vec::new(), Some("missing".to_owned()), Vec::new()).is_err()); } + #[test] + fn tool_host_command_preserves_sandbox_entrypoint_for_tool_setup() { + let thread_key = ThreadKey::parse("mcp:test").unwrap(); + let workload = SandboxWorkloadMode::codex_app_server( + "centaur-agent:latest", + [("TOOL_DIRS".to_owned(), "/app/tools".to_owned())], + HarnessType::Codex, + ); + let mut spec = workload.spec(&thread_key, &HarnessType::Codex, None); + + configure_tool_host_command(&mut spec); + + assert_eq!(spec.command, Some(vec!["/entrypoint.sh".to_owned()])); + assert_eq!(spec.args, vec!["centaur-tool-host"]); + assert_eq!(env_value(&spec, "TOOL_DIRS"), Some("/app/tools")); + } + #[test] fn turn_completed_without_answer_text_is_terminal() { let event = json!({ diff --git a/services/api-rs/rfcs/0004-console-mcp-jwt-auth.md b/services/api-rs/rfcs/0004-console-mcp-jwt-auth.md new file mode 100644 index 000000000..2760693b6 --- /dev/null +++ b/services/api-rs/rfcs/0004-console-mcp-jwt-auth.md @@ -0,0 +1,745 @@ +# RFC 0004: Console OAuth for MCP Auth + +Status: Draft +Owner: TBD +Target: `services/console`, `services/api-rs` + +## Summary + +Make Centaur's remote MCP endpoint use the MCP HTTP authorization flow, with +console acting as the OAuth authorization server and api-rs acting as the MCP +protected resource server. + +The user should not need to copy a JWT from a console page into Amp. Instead: + +1. A harness connects to `POST /mcp` without a token. +2. api-rs returns `401 Unauthorized` with a `WWW-Authenticate: Bearer ...` + challenge that points at MCP Protected Resource Metadata. +3. The harness fetches the Protected Resource Metadata and learns that console + is the authorization server. +4. The harness discovers console's OAuth metadata. +5. The harness registers as an OAuth client, or uses preconfigured client + metadata. +6. The harness opens a browser to console's authorization endpoint with PKCE. +7. The user signs in with the normal console login/SSO flow. +8. Console ensures the signed-in user has an iron-control principal and returns + an authorization code to the harness. +9. The harness exchanges the code for a bearer access token. +10. The harness calls `POST /mcp` with `Authorization: Bearer `. +11. api-rs verifies the token and uses the encoded principal for MCP tool + execution. + +This matches the MCP authorization model used by HTTP-based MCP clients and +harnesses, while keeping console as the identity and permission UX. + +## Motivation + +The current MCP branch exposes the HTTP MCP transport and persistent tool +runners without a user-facing auth flow. That keeps the transport work small, +but MCP clients still need a standard way to sign in and bind tool execution to +the right console principal. + +Remote MCP clients already know how to follow an OAuth-style authorization +flow. We should use that instead of asking users to paste bearer tokens. + +Console already owns: + +- user login and SSO +- user approval/disable state +- principals, roles, grants, and effective permissions +- the operator UI where users can understand what identity they are using + +MCP auth should use that surface. + +The important product property is that MCP permissions are controlled by the +principal. The access token should identify the principal. It should not copy +the principal's current grants into the token. If an operator changes the +principal's roles or grants, the next per-user tool runner/proxy sync should see +the updated permissions without reissuing the token. + +## Goals + +- Use MCP-standard HTTP authorization so Amp and other harnesses can start auth + themselves. +- Keep the existing MCP endpoint as `POST /mcp`. +- Keep api-rs as the MCP protected resource server. +- Make console the OAuth authorization server for Centaur MCP. +- Use console login/SSO as the user authentication ceremony. +- Return bearer access tokens from a token endpoint, not from a copy-token page. +- Encode the iron-control principal id in the access token. +- Keep MCP authorization based on live principal grants in iron-control. +- Keep the current per-principal persistent MCP tool runner model. +- Support Dynamic Client Registration initially, since generic MCP clients may + not be pre-registered with our console. + +## Non-Goals + +- Implement a local bridge. +- Make Slackbot the long-term issuer of MCP credentials. +- Put live credential grants or secret names inside access tokens. +- Build a full general-purpose OAuth provider for arbitrary third-party apps. +- Support every OAuth client authentication method in the first version. +- Require Tailscale identity for MCP auth. + +## Current State + +The MCP branch has: + +- `POST /mcp` in api-rs. +- No MCP token issuer. +- An unauthenticated transport path used as the base for this authorization + work. +- Persistent tool runners keyed by `principal_id`. + +The persistent runner already wants the iron-control principal id. For a +proxied tool, api-rs creates or reuses a runner whose sandbox spec carries: + +```text +iron_control_principal = +CENTAUR_MCP_PRINCIPAL_ID = +``` + +That means the access token should encode the iron-control principal id, not +only the console user id or email. + +## Protocol Design + +### Roles + +Centaur maps MCP/OAuth roles as follows: + +| Role | Centaur Component | +|------|-------------------| +| MCP protected resource server | api-rs `/mcp` | +| OAuth authorization server | console | +| OAuth client | Amp, Codex, VS Code, or another MCP harness/client | +| Resource owner | signed-in console user | + +### Discovery Flow + +When a harness calls `/mcp` without a valid token, api-rs returns: + +```http +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer realm="mcp", + resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp", + scope="mcp:tools" +``` + +api-rs serves Protected Resource Metadata at both: + +```text +/.well-known/oauth-protected-resource +/.well-known/oauth-protected-resource/mcp +``` + +Example: + +```json +{ + "resource": "https://api.example.com/mcp", + "authorization_servers": ["https://console.example.com"], + "scopes_supported": ["mcp:tools"] +} +``` + +The `resource` value must be the canonical externally visible MCP endpoint. +For local preview dogfooding this can be `http://localhost:3000/mcp`; for +production it should be the public HTTPS MCP URL. + +The harness then fetches authorization server metadata from console. + +Console should serve OAuth Authorization Server Metadata at: + +```text +/.well-known/oauth-authorization-server +``` + +Optionally, console can also serve: + +```text +/.well-known/openid-configuration +``` + +Example metadata: + +```json +{ + "issuer": "https://console.example.com", + "authorization_endpoint": "https://console.example.com/mcp/oauth/authorize", + "token_endpoint": "https://console.example.com/mcp/oauth/token", + "registration_endpoint": "https://console.example.com/mcp/oauth/register", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["mcp:tools"], + "token_endpoint_auth_methods_supported": ["none"], + "resource_indicators_supported": true +} +``` + +### Client Registration + +First version should support Dynamic Client Registration because generic MCP +harnesses may not have a pre-registered Centaur client id. + +Console endpoint: + +```text +POST /mcp/oauth/register +``` + +Allowed registration shape: + +```json +{ + "client_name": "Amp", + "redirect_uris": ["http://127.0.0.1:49152/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" +} +``` + +Console returns: + +```json +{ + "client_id": "mcp_client_...", + "client_name": "Amp", + "redirect_uris": ["http://127.0.0.1:49152/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "client_id_issued_at": 1782749000 +} +``` + +Registration constraints: + +- public clients only in v1 (`token_endpoint_auth_method = "none"`) +- require PKCE S256 at authorize/token time +- allow loopback redirect URIs (`http://127.0.0.1`, `http://localhost`, `[::1]`) +- allow HTTPS redirect URIs +- reject wildcard redirect URIs +- reject non-loopback plain HTTP redirect URIs +- store only client metadata and timestamps, not user authorization + +Future versions can add OAuth Client ID Metadata Documents. Do not advertise +`client_id_metadata_document_supported` until console actually validates those +documents. + +### Authorization Endpoint + +Console endpoint: + +```text +GET /mcp/oauth/authorize +``` + +Required parameters: + +```text +response_type=code +client_id= +redirect_uri= +code_challenge= +code_challenge_method=S256 +resource= +scope=mcp:tools +state= +``` + +Behavior: + +- If signed out, redirect through existing console login/SSO and return to the + authorize request. +- If the console user is pending or disabled, deny authorization. +- Validate `client_id`, `redirect_uri`, `scope`, `resource`, and PKCE params. +- Ensure the signed-in user has an MCP principal. +- Optionally show a compact consent/confirmation page. +- Create a short-lived one-time authorization code. +- Redirect to the client `redirect_uri` with `code` and original `state`. + +The authorization code stores: + +- `client_id` +- `redirect_uri` +- `code_challenge` +- `code_challenge_method` +- `resource` +- `scope` +- `user_id` +- `principal_id` +- expiration timestamp, suggested 5 minutes +- consumed timestamp, initially null + +### Token Endpoint + +Console endpoint: + +```text +POST /mcp/oauth/token +``` + +Authorization code exchange request: + +```text +grant_type=authorization_code +code= +redirect_uri= +client_id= +code_verifier= +resource= +``` + +Console validates: + +- code exists, is unexpired, and is unused +- code belongs to `client_id` +- `redirect_uri` matches the code +- `resource` matches the code +- PKCE verifier matches the stored S256 challenge +- user is still active +- principal still exists + +Console returns: + +```json +{ + "access_token": "", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "cmcpr_...", + "scope": "mcp:tools" +} +``` + +The access token is a JWT signed by console with the shared Centaur signing +secret. Refresh tokens are opaque random strings stored hashed by console. + +Refresh token request: + +```text +grant_type=refresh_token +refresh_token= +client_id= +resource= +scope=mcp:tools +``` + +Refresh behavior: + +- validate refresh token hash, client id, user status, principal, resource, and + scope +- rotate refresh token on each use +- return a fresh access token + +### Access Token Claims + +Example JWT payload: + +```json +{ + "iss": "https://console.example.com", + "aud": "https://api.example.com/mcp", + "sub": "usr_abc123", + "jti": "mcp_at_018f...", + "iat": 1782749000, + "nbf": 1782749000, + "exp": 1782752600, + "scope": "mcp:tools", + "client_id": "mcp_client_abc123", + "principal_id": "prn_abc123", + "principal_foreign_id": "console-user-alice-example-com", + "principal_namespace": "default", + "email": "alice@example.com", + "name": "Alice Example" +} +``` + +Claim semantics: + +| Claim | Purpose | +|-------|---------| +| `iss` | Console issuer URL from authorization server metadata. | +| `aud` | Canonical MCP resource URI from the authorization request. | +| `sub` | Console user oid. Useful for audit/debugging. | +| `jti` | Access token instance id. Used as `token_id` in MCP whoami/logs. | +| `iat`/`nbf`/`exp` | Token lifetime. | +| `scope` | MCP protocol scopes, not credential grants. | +| `client_id` | Registered OAuth client id. | +| `principal_id` | iron-control principal oid used for tool runner/proxy binding. | +| `principal_foreign_id` | Human/debug identifier. Not authoritative for proxy binding. | +| `principal_namespace` | Human/debug namespace. | +| `email`/`name` | Display only. | + +The JWT must not include: + +- secret ids +- role ids +- grant details +- actual credential values +- provider refresh tokens + +### Principal Model + +Console creates or finds one MCP principal per active console user. + +Default mapping: + +```text +namespace: +foreign_id: console-user- +name: Console User +labels: + managed-by: centaur + principal-kind: console-user + console-user-id: + email: +``` + +The access token includes the resulting principal oid, for example `prn_...`. + +The oid is what api-rs uses to bind the per-sandbox iron-proxy. The foreign id +and email are included for diagnostics only. + +This gives us the permissions flexibility we want: + +- grant tools/secrets directly to the user's console principal +- assign roles to the user's console principal +- later change grants/roles without changing MCP tokens +- later support group/team based assignment through console policy without + changing MCP transport + +### Signing Secret + +Add one general Centaur JWT signing secret instead of an MCP-specific secret: + +```text +CENTAUR_JWT_SIGNING_SECRET +``` + +This should live in the shared infra Secret and be mounted into both console and +api-rs. + +Why not reuse Rails `SECRET_KEY_BASE`? + +- It is tied to Rails cookies and framework internals. +- Rotating it has Rails-specific blast radius. +- api-rs should not need to treat Rails session signing material as an API auth + root. +- A general Centaur JWT secret can support other future service-issued JWTs with + issuer/audience separation. + +The first version can use HS256 with this shared secret. + +JWT verification in api-rs must require: + +- known algorithm: `HS256` +- trusted issuer matching console metadata +- audience matching the canonical MCP resource URI +- `exp` in the future +- `nbf` absent or not in the future +- `iat` not unreasonably in the future +- required `principal_id` +- required `scope` containing `mcp:tools` or `mcp:*` + +Future rotation can add: + +```text +CENTAUR_JWT_SIGNING_KID +CENTAUR_JWT_VERIFYING_SECRETS +``` + +where the verifying env var is a JSON map of `kid -> secret`. + +### MCP Endpoint Auth + +api-rs should accept only console-issued OAuth access JWTs. + +```text +Authorization: Bearer + +verify as console OAuth MCP access token +``` + +The verified identity should normalize into the existing MCP principal shape: + +```text +McpAuthenticatedPrincipal { + token_id: + principal_id: + name: + scopes: ["mcp:tools"] + expires_at: +} +``` + +Everything after auth should stay the same: + +- `tools/list` checks `mcp:tools`. +- `tools/call` checks `mcp:tools`. +- proxied tools use persistent runner keyed by `principal_id`. +- the runner sandbox uses that same `principal_id` for iron-proxy. + +## Deployment Config + +Add the shared secret to the infra Secret: + +```text +CENTAUR_JWT_SIGNING_SECRET= +``` + +`just bootstrap-secrets` should generate it if absent and never rotate it in +place. + +Chart wiring: + +- api-rs already imports the shared infra Secret with `envFrom`, so it can read + `CENTAUR_JWT_SIGNING_SECRET`. +- console should explicitly mount `CENTAUR_JWT_SIGNING_SECRET` from the shared + infra Secret, because console intentionally does not use `envFrom`. +- api-rs needs a canonical MCP public URL for Protected Resource Metadata. +- console needs the same canonical MCP public URL for OAuth resource validation. +- console needs its own public issuer URL. + +Suggested env vars: + +```text +CENTAUR_JWT_SIGNING_SECRET +CENTAUR_MCP_PUBLIC_URL +CENTAUR_CONSOLE_PUBLIC_URL +CENTAUR_MCP_ACCESS_TOKEN_TTL_SECONDS +CENTAUR_MCP_REFRESH_TOKEN_TTL_SECONDS +CENTAUR_MCP_PRINCIPAL_NAMESPACE +``` + +`CENTAUR_JWT_SIGNING_SECRET` is intentionally general. The other env vars are +MCP-specific policy/display knobs. + +## Security Considerations + +### Bearer Token Risk + +The OAuth access token is a bearer token. Anyone who obtains it can use the +encoded principal's MCP permissions until it expires. + +Mitigations: + +- short access token TTL, suggested 1 hour +- refresh tokens stored hashed by console +- refresh token rotation +- no access token values in logs +- no access token persistence in console DB +- no token values in Slack messages +- no grants embedded in access tokens +- future `jti` denylist if needed + +### Disabled Users + +Console checks user status when authorizing and refreshing. + +Because access tokens are stateless, disabling a user does not automatically +invalidate already-issued access tokens until they expire. Short access token +TTL limits this window. Refresh tokens must stop working immediately for +disabled users. + +### Permission Changes + +Permission changes should not require new access tokens. + +The token identifies `principal_id`; iron-control remains the live source of +truth for grants. If a role is revoked from the principal, the next proxy sync +should remove that credential from the user's runner. + +### Audience and Resource Binding + +The API must require `aud` to match the canonical MCP resource URI. Console must +issue access tokens only for the `resource` value supplied by the client and +accepted by console policy. + +Do not accept generic audiences like `api` or `centaur`. + +### DCR Abuse + +Unauthenticated Dynamic Client Registration can be abused if unconstrained. + +Initial constraints: + +- only public clients +- loopback or HTTPS redirect URIs only +- no wildcard redirects +- no custom schemes initially +- rate limit registration +- audit client registrations +- optionally prune unused clients + +### Secret Rotation + +Initial implementation can use one shared signing secret. + +Before production reliance, add a `kid` strategy: + +- console signs with active `kid` +- api-rs verifies against active plus previous keys +- old keys stay in verify-only mode until all access tokens signed with them + expire + +## Alternatives Considered + +### Manual Copyable JWT Page + +Pros: + +- simplest to implement +- no OAuth client registration, auth codes, or token endpoint + +Cons: + +- not the MCP-supported HTTP auth flow harnesses are built around +- poor UX for Amp and other clients +- users manually handle long bearer secrets +- harder to refresh tokens cleanly + +This RFC replaces the copy-token page with MCP OAuth. + +### Keep Opaque DB Tokens + +Pros: + +- simple revocation +- simple for a Slack-first prototype + +Cons: + +- api-rs remains an issuer +- Slackbot remains an issuance UX +- every non-Slack surface needs another token flow +- console login/SSO is not the source of user identity +- not the harness-native MCP auth path + +### External OAuth Provider Only + +We could point the MCP Protected Resource Metadata directly at Okta, Google, or +another IdP. + +Pros: + +- mature OAuth implementation +- less auth code in console + +Cons: + +- the access token still needs Centaur principal claims +- we still need a principal mapping layer +- group/role policy becomes split between IdP and iron-control +- local/dev and preview flows are harder + +Console can still federate login to Okta/Google while issuing the Centaur MCP +access token itself. + +### Tailscale MCP Auth + +Pros: + +- strong device/user identity on a tailnet + +Cons: + +- not every user is on the same tailnet +- does not solve Discord or external users +- still need to map tailnet identity to iron-control principals + +### Reuse Rails `SECRET_KEY_BASE` + +Pros: + +- already exists +- console and api-rs can be wired to read it + +Cons: + +- wrong blast radius +- tied to Rails cookies/framework behavior +- not obviously safe to expose as a general API signing root + +Use `CENTAUR_JWT_SIGNING_SECRET` instead. + +## Rollout Plan + +1. Add this RFC. +2. Add `CENTAUR_JWT_SIGNING_SECRET` bootstrap and chart wiring. +3. Add api-rs Protected Resource Metadata with console authorization server URL. +4. Add console OAuth authorization server metadata. +5. Add console Dynamic Client Registration. +6. Add console authorization code + PKCE flow. +7. Add console token endpoint with JWT access tokens and opaque refresh tokens. +8. Add console principal resolution for signed-in users. +9. Add api-rs JWT access token verification for MCP bearer auth. +10. Replace the unauthenticated MCP path with JWT bearer verification. +11. Dogfood with Amp using a local port-forwarded preview API. + +## Test Plan + +api-rs tests: + +- missing bearer returns `401` with `WWW-Authenticate` containing + `resource_metadata` and `scope="mcp:tools"` +- Protected Resource Metadata returns the configured resource and console + authorization server +- expired JWT is rejected +- wrong issuer is rejected +- wrong audience/resource is rejected +- missing `principal_id` is rejected +- missing `mcp:tools` scope is rejected +- valid JWT authenticates and `centaur_whoami` reports principal and `jti` + +Console tests: + +- authorization metadata includes required endpoints and supported capabilities +- DCR accepts loopback redirect URIs +- DCR rejects wildcard and non-loopback HTTP redirect URIs +- signed-out authorize request redirects to login and resumes +- pending user cannot authorize +- disabled user cannot authorize +- active user can authorize +- authorization code is one-time-use +- wrong PKCE verifier is rejected +- token endpoint returns bearer JWT with expected issuer, audience, subject, + principal, scope, and expiration +- refresh token is stored hashed and rotates on use +- disabled users cannot refresh +- raw signing secret is never rendered + +Chart/script tests: + +- `just bootstrap-secrets` creates `CENTAUR_JWT_SIGNING_SECRET` only when absent +- console deployment receives `CENTAUR_JWT_SIGNING_SECRET` +- api-rs receives the same secret +- Helm lint/template pass + +Manual dogfood: + +- port-forward preview api-rs +- configure Amp with the preview MCP URL +- verify Amp opens browser auth automatically +- sign in through console +- complete PKCE code exchange +- call `centaur_whoami` +- call a non-secret tool +- call a proxied tool granted to the console principal +- revoke a grant and verify subsequent proxied calls lose access + +## Open Questions + +- Do we need refresh tokens in v1, or will access-token-only be acceptable for + the harnesses we care about? +- Should we support OAuth Client ID Metadata Documents in v1, or is DCR enough + for Amp/Codex/VS Code? +- Default access token TTL: 1 hour, 8 hours, or environment-specific? +- Default refresh token TTL: 7 days, 30 days, or environment-specific? +- Should the console principal namespace default to `default` or a dedicated + namespace like `mcp`? +- Do we want a first-version access-token `jti` denylist, or is short TTL + enough? diff --git a/services/console/app/controllers/application_controller.rb b/services/console/app/controllers/application_controller.rb index 2093da1eb..c54547a9e 100644 --- a/services/console/app/controllers/application_controller.rb +++ b/services/console/app/controllers/application_controller.rb @@ -82,15 +82,23 @@ def sign_in_console_user(user, disabled: :redirect) return redirect_to login_path, alert: "Your account is disabled." end + return_to = session[:return_to] reset_session session[:user_id] = user.id + session[:return_to] = return_to if return_to.present? if user.active? - redirect_to console_principals_path, notice: "Signed in as #{user.email}." + redirect_to post_login_redirect_path, notice: "Signed in as #{user.email}." else redirect_to pending_path, notice: "Your account is awaiting approval." end end + def post_login_redirect_path + path = session.delete(:return_to).to_s + return console_principals_path unless path.start_with?("/") && !path.start_with?("//") + path + end + def render_not_found(e) render plain: e.message, status: :not_found end diff --git a/services/console/app/controllers/mcp/oauth_controller.rb b/services/console/app/controllers/mcp/oauth_controller.rb new file mode 100644 index 000000000..24679fa19 --- /dev/null +++ b/services/console/app/controllers/mcp/oauth_controller.rb @@ -0,0 +1,499 @@ +require "base64" +require "digest" +require "uri" + +module Mcp + class OauthController < ApplicationController + layout "auth" + + skip_before_action :require_login, only: %i[metadata register authorize token] + skip_before_action :require_active_account, only: %i[metadata register authorize token] + skip_forgery_protection only: %i[register token] + + ACCESS_TOKEN_TTL_SECONDS = 1.hour.to_i + + # GET /.well-known/oauth-authorization-server + def metadata + render json: { + issuer: public_base_url, + authorization_endpoint: URI.join(public_base_url, "/mcp/oauth/authorize").to_s, + token_endpoint: URI.join(public_base_url, "/mcp/oauth/token").to_s, + registration_endpoint: URI.join(public_base_url, "/mcp/oauth/register").to_s, + response_types_supported: [ "code" ], + grant_types_supported: McpOauthClient::DEFAULT_GRANT_TYPES, + code_challenge_methods_supported: [ "S256" ], + token_endpoint_auth_methods_supported: [ "none" ], + scopes_supported: McpOauthClient::DEFAULT_SCOPES, + resource_parameter_supported: true + } + end + + # POST /mcp/oauth/register + def register + requested_redirect_uris = + Array(params[:redirect_uris]).map(&:to_s).map(&:strip).reject(&:blank?) + client = McpOauthClient.create!( + name: params[:client_name].presence || "MCP client", + redirect_uris: requested_redirect_uris, + grant_types: normalize_list_param( + params[:grant_types], + McpOauthClient::DEFAULT_GRANT_TYPES + ), + response_types: normalize_list_param( + params[:response_types], + McpOauthClient::DEFAULT_RESPONSE_TYPES + ), + scopes: normalize_scope_param(params[:scope], McpOauthClient::DEFAULT_SCOPES), + metadata: registration_metadata + ) + + render json: { + client_id: client.public_client_id, + client_name: client.name, + redirect_uris: client.redirect_uris, + grant_types: client.grant_types, + response_types: client.response_types, + scope: client.scopes.join(" "), + token_endpoint_auth_method: "none" + }, status: :created + rescue ActiveRecord::RecordInvalid => e + oauth_error( + :invalid_client_metadata, + e.record.errors.full_messages.to_sentence, + status: :bad_request + ) + end + + # GET /mcp/oauth/authorize + def authorize + return redirect_to_login unless current_user + return redirect_to pending_path if current_user.pending? + return redirect_to login_path, alert: "Your account is disabled." if current_user.disabled? + + authorization = validated_authorization_request + return unless authorization + + assign_authorization_view(authorization) + render :authorize + end + + # POST /mcp/oauth/authorize + def approve + authorization = validated_authorization_request + return unless authorization + + unless params[:decision] == "approve" + return authorization_error( + authorization[:client], + :access_denied, + "The user denied the authorization request." + ) + end + + issue_authorization_code(authorization) + rescue ActiveRecord::RecordInvalid => e + authorization_error(nil, :server_error, e.record.errors.full_messages.to_sentence) + end + + # POST /mcp/oauth/token + def token + case params[:grant_type] + when "authorization_code" + exchange_authorization_code + when "refresh_token" + exchange_refresh_token + else + oauth_error(:unsupported_grant_type, "Unsupported grant_type.", status: :bad_request) + end + end + + private + + def validated_authorization_request + client = resolve_client(params[:client_id]) + return authorization_request_error(nil, :invalid_request, "Unknown client.") unless client + + unless params[:response_type] == "code" + return authorization_request_error( + client, + :unsupported_response_type, + "Only response_type=code is supported." + ) + end + unless client.redirect_uri_allowed?(params[:redirect_uri]) + return authorization_request_error( + client, + :invalid_request, + "redirect_uri is not registered for this client." + ) + end + unless params[:code_challenge_method] == "S256" + return authorization_request_error( + client, + :invalid_request, + "code_challenge_method must be S256." + ) + end + if params[:code_challenge].blank? + return authorization_request_error(client, :invalid_request, "code_challenge is required.") + end + + scopes = normalize_scope_param(params[:scope], McpOauthClient::DEFAULT_SCOPES) + unsupported = scopes - McpOauthClient::DEFAULT_SCOPES + if unsupported.any? + return authorization_request_error( + client, + :invalid_scope, + "Unsupported scope: #{unsupported.join(' ')}." + ) + end + + resource = resolve_requested_resource + return authorization_request_error(client, :invalid_target, "resource is required.") if resource.blank? + + { client: client, scopes: scopes, resource: resource } + end + + def authorization_request_error(client, error, description) + authorization_error(client, error, description) + nil + end + + def assign_authorization_view(authorization) + @client = authorization[:client] + @scopes = authorization[:scopes] + @resource = authorization[:resource] + @redirect_uri = params[:redirect_uri].to_s + @redirect_host = redirect_uri_host(@redirect_uri) + @authorization_params = authorization_form_params(authorization) + end + + def issue_authorization_code(authorization) + client = authorization[:client] + principal = principal_for_current_user + code = McpOauthAuthorizationCode.create!( + mcp_oauth_client: client, + user: current_user, + principal: principal, + redirect_uri: params[:redirect_uri].to_s, + code_challenge: params[:code_challenge].to_s, + resource: authorization[:resource], + scopes: authorization[:scopes] + ) + client.touch(:last_used_at) + + uri = URI.parse(params[:redirect_uri]) + query = Rack::Utils.parse_nested_query(uri.query) + query["code"] = code.plaintext_code + query["state"] = params[:state] if params[:state].present? + uri.query = query.to_query + redirect_to uri.to_s, allow_other_host: true + end + + def authorization_form_params(authorization) + { + response_type: params[:response_type].to_s, + client_id: authorization[:client].public_client_id, + redirect_uri: params[:redirect_uri].to_s, + scope: authorization[:scopes].join(" "), + state: params[:state].to_s, + resource: authorization[:resource], + code_challenge: params[:code_challenge].to_s, + code_challenge_method: params[:code_challenge_method].to_s + } + end + + def redirect_uri_host(value) + URI.parse(value).host + rescue URI::InvalidURIError + value + end + + def exchange_authorization_code + client = resolve_client(params[:client_id]) + return oauth_error(:invalid_client, "Unknown client.", status: :unauthorized) unless client + code = McpOauthAuthorizationCode.find_usable(params[:code]) + unless code + return oauth_error( + :invalid_grant, + "Authorization code is invalid or expired.", + status: :bad_request + ) + end + unless code.mcp_oauth_client == client + return oauth_error( + :invalid_grant, + "Authorization code was not issued to this client.", + status: :bad_request + ) + end + unless code.redirect_uri == params[:redirect_uri].to_s + return oauth_error( + :invalid_grant, + "redirect_uri does not match the authorization request.", + status: :bad_request + ) + end + unless pkce_valid?(code.code_challenge, params[:code_verifier].to_s) + return oauth_error(:invalid_grant, "PKCE verification failed.", status: :bad_request) + end + + refresh = nil + invalid_grant = false + inactive_user = false + McpOauthAuthorizationCode.transaction do + code.lock! + if code.consumed_at.present? || code.expires_at <= Time.current + invalid_grant = true + elsif !code.user.active? + inactive_user = true + code.consume! + code.user.revoke_mcp_oauth_refresh_tokens! + else + code.consume! + refresh = McpOauthRefreshToken.create!( + mcp_oauth_client: client, + user: code.user, + principal: code.principal, + resource: code.resource, + scopes: code.scopes + ) + end + end + if invalid_grant + return oauth_error( + :invalid_grant, + "Authorization code is invalid or expired.", + status: :bad_request + ) + end + if inactive_user + return oauth_error( + :invalid_grant, + "User account is not active.", + status: :bad_request + ) + end + + client.touch(:last_used_at) + render_token_response( + client: client, + user: code.user, + principal: code.principal, + resource: code.resource, + scopes: code.scopes, + refresh_token: refresh.plaintext_token + ) + end + + def exchange_refresh_token + client = resolve_client(params[:client_id]) + return oauth_error(:invalid_client, "Unknown client.", status: :unauthorized) unless client + refresh = McpOauthRefreshToken.find_usable(params[:refresh_token]) + unless refresh + return oauth_error( + :invalid_grant, + "Refresh token is invalid or expired.", + status: :bad_request + ) + end + unless refresh.mcp_oauth_client == client + return oauth_error( + :invalid_grant, + "Refresh token was not issued to this client.", + status: :bad_request + ) + end + + rotated = nil + invalid_grant = false + inactive_user = false + McpOauthRefreshToken.transaction do + refresh.lock! + if refresh.revoked_at.present? || refresh.expires_at <= Time.current + invalid_grant = true + elsif !refresh.user.active? + inactive_user = true + refresh.user.revoke_mcp_oauth_refresh_tokens! + else + refresh.update!(revoked_at: Time.current, last_used_at: Time.current) + rotated = McpOauthRefreshToken.create!( + mcp_oauth_client: client, + user: refresh.user, + principal: refresh.principal, + resource: refresh.resource, + scopes: refresh.scopes + ) + end + end + if invalid_grant + return oauth_error( + :invalid_grant, + "Refresh token is invalid or expired.", + status: :bad_request + ) + end + if inactive_user + return oauth_error( + :invalid_grant, + "User account is not active.", + status: :bad_request + ) + end + + client.touch(:last_used_at) + render_token_response( + client: client, + user: refresh.user, + principal: refresh.principal, + resource: refresh.resource, + scopes: refresh.scopes, + refresh_token: rotated.plaintext_token + ) + end + + def render_token_response(client:, user:, principal:, resource:, scopes:, refresh_token:) + now = Time.current.to_i + ttl = access_token_ttl_seconds + payload = { + iss: public_base_url, + sub: user.oid, + aud: resource, + exp: now + ttl, + nbf: now - 5, + iat: now, + jti: "mcpjwt_#{SecureRandom.hex(16)}", + scope: scopes.join(" "), + client_id: client.public_client_id, + principal_id: principal.oid, + principal_foreign_id: principal.foreign_id, + email: user.email, + name: user.name.presence || user.email + } + render json: { + access_token: Mcp::Jwt.encode(payload), + token_type: "Bearer", + expires_in: ttl, + scope: scopes.join(" "), + refresh_token: refresh_token + } + rescue KeyError => e + oauth_error(:server_error, e.message, status: :service_unavailable) + end + + def redirect_to_login + session[:return_to] = request.fullpath if request.request_method == "GET" + redirect_to login_path + end + + def authorization_error(client, error, description) + if client&.redirect_uri_allowed?(params[:redirect_uri]) + uri = URI.parse(params[:redirect_uri]) + query = Rack::Utils.parse_nested_query(uri.query) + query["error"] = error.to_s + query["error_description"] = description + query["state"] = params[:state] if params[:state].present? + uri.query = query.to_query + redirect_to uri.to_s, allow_other_host: true + else + render plain: description, status: :bad_request + end + end + + def oauth_error(error, description, status:) + render json: { error: error.to_s, error_description: description }, status: status + end + + def resolve_client(client_id) + McpOauthClient.find_by_oid(client_id) + end + + def resolve_requested_resource + # Fail closed: without a configured canonical resource URL we would + # otherwise mint tokens bound to any caller-supplied audience. + configured = normalize_mcp_resource_url(configured_mcp_resource_url) + return nil if configured.blank? + requested = params[:resource].presence + return nil if requested.present? && normalize_mcp_resource_url(requested) != configured + configured + end + + def configured_mcp_resource_url + ENV["CENTAUR_MCP_PUBLIC_URL"].presence || ConsoleEnv["MCP_PUBLIC_URL"].presence + end + + def normalize_mcp_resource_url(value) + uri = URI.parse(value.to_s.strip) + return nil unless %w[http https].include?(uri.scheme) && uri.host.present? + uri.fragment = nil + path = uri.path.to_s.sub(%r{/+\z}, "") + uri.path = path.end_with?("/mcp") ? path : "#{path}/mcp" + uri.to_s.sub(/\?\z/, "") + rescue URI::InvalidURIError + nil + end + + def principal_for_current_user + foreign_id = principal_foreign_id(current_user.email) + Principal + .find_or_initialize_by(namespace: mcp_principal_namespace, foreign_id: foreign_id) + .tap do |principal| + principal.created_by ||= current_user + principal.name = current_user.name.presence || current_user.email + principal.labels = principal.labels.merge( + "managed-by" => "centaur", + "kind" => "console_user", + "console-user-id" => current_user.oid, + "email" => current_user.email + ) + principal.save! + end + end + + def principal_foreign_id(email) + normalized = email.to_s.downcase.strip + safe = normalized.gsub(/[^A-Za-z0-9\-._~]/, "-").gsub(/-+/, "-").first(48) + digest = Digest::SHA256.hexdigest(normalized).first(12) + "console-user-#{safe}-#{digest}" + end + + def mcp_principal_namespace + ENV["CENTAUR_MCP_PRINCIPAL_NAMESPACE"].presence || + ConsoleEnv["MCP_PRINCIPAL_NAMESPACE"].presence || + "default" + end + + def access_token_ttl_seconds + raw = + ENV["CENTAUR_MCP_ACCESS_TOKEN_TTL_SECONDS"].presence || + ConsoleEnv["MCP_ACCESS_TOKEN_TTL_SECONDS"].presence + seconds = raw.to_i + seconds.positive? ? seconds : ACCESS_TOKEN_TTL_SECONDS + end + + def registration_metadata + params + .to_unsafe_h + .slice("client_uri", "logo_uri", "contacts", "software_id", "software_version") + .compact + end + + def normalize_list_param(value, default) + list = value.presence || default + Array(list).map(&:to_s).map(&:strip).reject(&:blank?).presence || default + end + + def normalize_scope_param(value, default) + return default if value.blank? + value.to_s.split(/[,\s]+/).map(&:strip).reject(&:blank?) + end + + def pkce_valid?(challenge, verifier) + return false if verifier.blank? + actual = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + ActiveSupport::SecurityUtils.secure_compare(actual, challenge.to_s) + rescue ArgumentError + false + end + end +end diff --git a/services/console/app/models/concerns/hashed_token_lookup.rb b/services/console/app/models/concerns/hashed_token_lookup.rb new file mode 100644 index 000000000..9bd7ce5b1 --- /dev/null +++ b/services/console/app/models/concerns/hashed_token_lookup.rb @@ -0,0 +1,27 @@ +require "digest" + +# Lookup of single-use secrets stored as SHA-256 digests. Including models +# declare the digest column and must define a `usable` scope. +module HashedTokenLookup + extend ActiveSupport::Concern + + class_methods do + # Acts as both setter (when called with a value) and getter. + # class McpOauthRefreshToken < ApplicationRecord + # token_hash_attribute :token_hash + # end + def token_hash_attribute(value = nil) + @token_hash_attribute = value.to_sym if value + @token_hash_attribute or + raise NotImplementedError, "#{name} must declare `token_hash_attribute :...`" + end + + def hash_token(value) + Digest::SHA256.hexdigest(value.to_s) + end + + def find_usable(value) + usable.find_by(token_hash_attribute => hash_token(value)) + end + end +end diff --git a/services/console/app/models/mcp_oauth_authorization_code.rb b/services/console/app/models/mcp_oauth_authorization_code.rb new file mode 100644 index 000000000..ed3e46856 --- /dev/null +++ b/services/console/app/models/mcp_oauth_authorization_code.rb @@ -0,0 +1,36 @@ +class McpOauthAuthorizationCode < ApplicationRecord + include HashedTokenLookup + + oid_prefix "moa" + token_hash_attribute :code_hash + + CODE_TTL = 10.minutes + TOKEN_PREFIX = "mcpauth_".freeze + + attr_accessor :plaintext_code + + belongs_to :mcp_oauth_client + belongs_to :user + belongs_to :principal + + before_validation :issue_code, on: :create + + validates :code_hash, presence: true, uniqueness: true + validates :redirect_uri, :code_challenge, :resource, :expires_at, presence: true + validates :scopes, presence: true + + scope :usable, -> { where(consumed_at: nil).where("expires_at > ?", Time.current) } + + def consume! + update!(consumed_at: Time.current) + end + + private + + def issue_code + self.expires_at ||= CODE_TTL.from_now + return if code_hash.present? + self.plaintext_code = "#{TOKEN_PREFIX}#{SecureRandom.urlsafe_base64(48)}" + self.code_hash = self.class.hash_token(plaintext_code) + end +end diff --git a/services/console/app/models/mcp_oauth_client.rb b/services/console/app/models/mcp_oauth_client.rb new file mode 100644 index 000000000..38649610f --- /dev/null +++ b/services/console/app/models/mcp_oauth_client.rb @@ -0,0 +1,93 @@ +require "ipaddr" +require "uri" + +class McpOauthClient < ApplicationRecord + oid_prefix "moc" + + DEFAULT_GRANT_TYPES = %w[authorization_code refresh_token].freeze + DEFAULT_RESPONSE_TYPES = %w[code].freeze + DEFAULT_SCOPES = %w[mcp:tools].freeze + + has_many :authorization_codes, class_name: "McpOauthAuthorizationCode", dependent: :destroy + has_many :refresh_tokens, class_name: "McpOauthRefreshToken", dependent: :destroy + + validates :redirect_uris, presence: true + validate :redirect_uris_valid + validate :grant_types_supported + validate :response_types_supported + validate :scopes_supported + + def public_client_id = oid + + def redirect_uri_allowed?(uri) + return false unless self.class.allowed_redirect_uri?(uri) + + requested = URI.parse(uri.to_s) + redirect_uris.any? do |registered| + next false unless self.class.allowed_redirect_uri?(registered) + next true if registered == uri.to_s + + registered_uri = URI.parse(registered.to_s) + loopback_redirect_uri_match?(registered_uri, requested) + rescue URI::InvalidURIError + false + end + rescue URI::InvalidURIError + false + end + + private + + def redirect_uris_valid + return errors.add(:redirect_uris, "must be an array") unless redirect_uris.is_a?(Array) + errors.add(:redirect_uris, "must not be empty") if redirect_uris.empty? + redirect_uris.each do |uri| + errors.add(:redirect_uris, "#{uri.inspect} is not an allowed public-client redirect URI") unless self.class.allowed_redirect_uri?(uri) + end + end + + def grant_types_supported + return errors.add(:grant_types, "must be an array") unless grant_types.is_a?(Array) + unsupported = grant_types - DEFAULT_GRANT_TYPES + errors.add(:grant_types, "contains unsupported values: #{unsupported.join(', ')}") if unsupported.any? + end + + def response_types_supported + return errors.add(:response_types, "must be an array") unless response_types.is_a?(Array) + unsupported = response_types - DEFAULT_RESPONSE_TYPES + errors.add(:response_types, "contains unsupported values: #{unsupported.join(', ')}") if unsupported.any? + end + + def scopes_supported + return errors.add(:scopes, "must be an array") unless scopes.is_a?(Array) + unsupported = scopes - DEFAULT_SCOPES + errors.add(:scopes, "contains unsupported values: #{unsupported.join(', ')}") if unsupported.any? + end + + def self.allowed_redirect_uri?(value) + uri = URI.parse(value.to_s) + uri.scheme == "http" && loopback_host?(uri.host) + rescue URI::InvalidURIError + false + end + + def loopback_redirect_uri_match?(registered_uri, requested_uri) + return false unless registered_uri.scheme == "http" && requested_uri.scheme == "http" + return false unless self.class.loopback_host?(registered_uri.host) + return false unless self.class.loopback_host?(requested_uri.host) + return false if registered_uri.port && registered_uri.port != registered_uri.default_port + return false unless registered_uri.path == requested_uri.path + return false unless registered_uri.query == requested_uri.query + + true + end + + def self.loopback_host?(host) + normalized = host.to_s.downcase + return true if normalized == "localhost" + + IPAddr.new(normalized).loopback? + rescue IPAddr::Error + false + end +end diff --git a/services/console/app/models/mcp_oauth_refresh_token.rb b/services/console/app/models/mcp_oauth_refresh_token.rb new file mode 100644 index 000000000..d6b3809d4 --- /dev/null +++ b/services/console/app/models/mcp_oauth_refresh_token.rb @@ -0,0 +1,36 @@ +class McpOauthRefreshToken < ApplicationRecord + include HashedTokenLookup + + oid_prefix "mor" + token_hash_attribute :token_hash + + DEFAULT_TTL = 90.days + TOKEN_PREFIX = "mcprt_".freeze + + attr_accessor :plaintext_token + + belongs_to :mcp_oauth_client + belongs_to :user + belongs_to :principal + + before_validation :issue_token, on: :create + + validates :token_hash, presence: true, uniqueness: true + validates :resource, :expires_at, presence: true + validates :scopes, presence: true + + scope :usable, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) } + + def revoke! + update!(revoked_at: Time.current) + end + + private + + def issue_token + self.expires_at ||= DEFAULT_TTL.from_now + return if token_hash.present? + self.plaintext_token = "#{TOKEN_PREFIX}#{SecureRandom.urlsafe_base64(48)}" + self.token_hash = self.class.hash_token(plaintext_token) + end +end diff --git a/services/console/app/models/user.rb b/services/console/app/models/user.rb index 83e8c31be..76b8bdadd 100644 --- a/services/console/app/models/user.rb +++ b/services/console/app/models/user.rb @@ -7,9 +7,13 @@ class User < ApplicationRecord has_secure_password validations: false has_many :api_keys, dependent: :destroy + has_many :mcp_oauth_refresh_tokens, dependent: :destroy has_many :user_identities, dependent: :destroy belongs_to :approved_by, class_name: "User", optional: true + after_update :revoke_mcp_oauth_refresh_tokens_when_disabled, + if: -> { saved_change_to_status? && disabled? } + # pending: signed in via SSO but not yet approved -- cannot use the console. # active: approved operator. disabled: access revoked. enum :status, { pending: "pending", active: "active", disabled: "disabled" }, @@ -28,6 +32,11 @@ def approve!(by:) update!(status: :active, approved_at: Time.current, approved_by: by) end + def revoke_mcp_oauth_refresh_tokens! + now = Time.current + mcp_oauth_refresh_tokens.usable.update_all(revoked_at: now, updated_at: now) + end + # Resolves the console user behind a verified SSO identity, creating or linking # as needed, and (re)caches the identity's email/name. A returning login matches # by the stable (provider, subject). A new identity links to an existing user @@ -68,4 +77,10 @@ def self.provisioned_attributes(identity) { email: identity[:email], name: identity[:name], status: admin ? :active : :pending, admin: admin } end private_class_method :provisioned_attributes + + private + + def revoke_mcp_oauth_refresh_tokens_when_disabled + revoke_mcp_oauth_refresh_tokens! + end end diff --git a/services/console/app/views/mcp/oauth/authorize.html.erb b/services/console/app/views/mcp/oauth/authorize.html.erb new file mode 100644 index 000000000..0a1a4cef1 --- /dev/null +++ b/services/console/app/views/mcp/oauth/authorize.html.erb @@ -0,0 +1,49 @@ +<% content_for :title, "Authorize MCP Client · Centaur Console" %> + +
+ <%= image_tag "centaur-lockup-white.svg", alt: "Centaur", class: "mx-auto h-9 w-auto", width: 497, height: 127 %> +

Authorize MCP access.

+
+ +
+
+
+

<%= @client.name %>

+

<%= @redirect_host %>

+
+ +
+
+
Resource
+
<%= @resource %>
+
+
+
Scope
+
<%= @scopes.join(" ") %>
+
+
+
Signed in as
+
<%= current_user.email %>
+
+
+
+ + <%= form_with url: "/mcp/oauth/authorize", method: :post, data: { turbo: false }, class: "mt-6" do %> + <% @authorization_params.each do |key, value| %> + <%= hidden_field_tag key, value %> + <% end %> + +
+ <%= button_tag "Deny", + type: "submit", + name: "decision", + value: "deny", + class: "cursor-pointer rounded border border-ink-600 bg-ink-800/60 px-4 py-2 text-sm text-zinc-300 transition-colors hover:border-zinc-500 hover:text-zinc-100" %> + <%= button_tag "Allow", + type: "submit", + name: "decision", + value: "approve", + class: "cursor-pointer rounded border border-centaur-500/40 bg-centaur-500/10 px-4 py-2 text-sm text-centaur-300 transition-colors hover:bg-centaur-500/20 hover:text-centaur-200" %> +
+ <% end %> +
diff --git a/services/console/config/routes.rb b/services/console/config/routes.rb index d10db590a..d6cccd347 100644 --- a/services/console/config/routes.rb +++ b/services/console/config/routes.rb @@ -22,6 +22,15 @@ get "auth/:provider/start", to: "session_oauth#start", as: :auth_start get "auth/:provider/callback", to: "session_oauth#callback", as: :auth_callback + # MCP OAuth authorization server. MCP clients discover this from api-rs' + # OAuth protected-resource metadata and register public PKCE clients here. + get ".well-known/oauth-authorization-server", to: "mcp/oauth#metadata" + get ".well-known/openid-configuration", to: "mcp/oauth#metadata" + post "mcp/oauth/register", to: "mcp/oauth#register" + get "mcp/oauth/authorize", to: "mcp/oauth#authorize" + post "mcp/oauth/authorize", to: "mcp/oauth#approve" + post "mcp/oauth/token", to: "mcp/oauth#token" + # Operator console (server-rendered HTML UI). root "console#principals" get "console/principals", to: "console#principals", as: :console_principals diff --git a/services/console/db/migrate/20260630090000_create_mcp_oauth_clients.rb b/services/console/db/migrate/20260630090000_create_mcp_oauth_clients.rb new file mode 100644 index 000000000..6a0710b91 --- /dev/null +++ b/services/console/db/migrate/20260630090000_create_mcp_oauth_clients.rb @@ -0,0 +1,15 @@ +class CreateMcpOauthClients < ActiveRecord::Migration[8.1] + def change + create_table :mcp_oauth_clients do |t| + t.string :name + t.jsonb :redirect_uris, null: false, default: [] + t.jsonb :grant_types, null: false, default: [] + t.jsonb :response_types, null: false, default: [] + t.jsonb :scopes, null: false, default: [] + t.jsonb :metadata, null: false, default: {} + t.datetime :last_used_at + + t.timestamps + end + end +end diff --git a/services/console/db/migrate/20260630090001_create_mcp_oauth_authorization_codes.rb b/services/console/db/migrate/20260630090001_create_mcp_oauth_authorization_codes.rb new file mode 100644 index 000000000..0f5108b26 --- /dev/null +++ b/services/console/db/migrate/20260630090001_create_mcp_oauth_authorization_codes.rb @@ -0,0 +1,21 @@ +class CreateMcpOauthAuthorizationCodes < ActiveRecord::Migration[8.1] + def change + create_table :mcp_oauth_authorization_codes do |t| + t.references :mcp_oauth_client, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.references :principal, null: false, foreign_key: true + t.string :code_hash, null: false + t.string :redirect_uri, null: false + t.string :code_challenge, null: false + t.string :resource, null: false + t.jsonb :scopes, null: false, default: [] + t.datetime :expires_at, null: false + t.datetime :consumed_at + + t.timestamps + end + + add_index :mcp_oauth_authorization_codes, :code_hash, unique: true + add_index :mcp_oauth_authorization_codes, :expires_at + end +end diff --git a/services/console/db/migrate/20260630090002_create_mcp_oauth_refresh_tokens.rb b/services/console/db/migrate/20260630090002_create_mcp_oauth_refresh_tokens.rb new file mode 100644 index 000000000..03f1c44ed --- /dev/null +++ b/services/console/db/migrate/20260630090002_create_mcp_oauth_refresh_tokens.rb @@ -0,0 +1,20 @@ +class CreateMcpOauthRefreshTokens < ActiveRecord::Migration[8.1] + def change + create_table :mcp_oauth_refresh_tokens do |t| + t.references :mcp_oauth_client, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.references :principal, null: false, foreign_key: true + t.string :token_hash, null: false + t.string :resource, null: false + t.jsonb :scopes, null: false, default: [] + t.datetime :expires_at, null: false + t.datetime :revoked_at + t.datetime :last_used_at + + t.timestamps + end + + add_index :mcp_oauth_refresh_tokens, :token_hash, unique: true + add_index :mcp_oauth_refresh_tokens, :expires_at + end +end diff --git a/services/console/db/schema.rb b/services/console/db/schema.rb index 5402028d7..eae5df582 100644 --- a/services/console/db/schema.rb +++ b/services/console/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_25_030000) do +ActiveRecord::Schema[8.1].define(version: 2026_06_30_090002) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -176,6 +176,57 @@ t.index ["namespace", "foreign_id"], name: "index_hmac_secrets_on_namespace_and_foreign_id", unique: true end + create_table "mcp_oauth_authorization_codes", force: :cascade do |t| + t.string "code_challenge", null: false + t.string "code_hash", null: false + t.datetime "consumed_at" + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "mcp_oauth_client_id", null: false + t.bigint "principal_id", null: false + t.string "redirect_uri", null: false + t.string "resource", null: false + t.jsonb "scopes", default: [], null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["code_hash"], name: "index_mcp_oauth_authorization_codes_on_code_hash", unique: true + t.index ["expires_at"], name: "index_mcp_oauth_authorization_codes_on_expires_at" + t.index ["mcp_oauth_client_id"], name: "index_mcp_oauth_authorization_codes_on_mcp_oauth_client_id" + t.index ["principal_id"], name: "index_mcp_oauth_authorization_codes_on_principal_id" + t.index ["user_id"], name: "index_mcp_oauth_authorization_codes_on_user_id" + end + + create_table "mcp_oauth_clients", force: :cascade do |t| + t.datetime "created_at", null: false + t.jsonb "grant_types", default: [], null: false + t.datetime "last_used_at" + t.jsonb "metadata", default: {}, null: false + t.string "name" + t.jsonb "redirect_uris", default: [], null: false + t.jsonb "response_types", default: [], null: false + t.jsonb "scopes", default: [], null: false + t.datetime "updated_at", null: false + end + + create_table "mcp_oauth_refresh_tokens", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.datetime "last_used_at" + t.bigint "mcp_oauth_client_id", null: false + t.bigint "principal_id", null: false + t.string "resource", null: false + t.datetime "revoked_at" + t.jsonb "scopes", default: [], null: false + t.string "token_hash", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["expires_at"], name: "index_mcp_oauth_refresh_tokens_on_expires_at" + t.index ["mcp_oauth_client_id"], name: "index_mcp_oauth_refresh_tokens_on_mcp_oauth_client_id" + t.index ["principal_id"], name: "index_mcp_oauth_refresh_tokens_on_principal_id" + t.index ["token_hash"], name: "index_mcp_oauth_refresh_tokens_on_token_hash", unique: true + t.index ["user_id"], name: "index_mcp_oauth_refresh_tokens_on_user_id" + end + create_table "oauth_apps", force: :cascade do |t| t.jsonb "allowed_scopes", default: [], null: false t.string "client_id", null: false @@ -404,6 +455,12 @@ add_foreign_key "grants", "static_secrets" add_foreign_key "grants", "users", column: "created_by_id" add_foreign_key "hmac_secrets", "users", column: "created_by_id" + add_foreign_key "mcp_oauth_authorization_codes", "mcp_oauth_clients" + add_foreign_key "mcp_oauth_authorization_codes", "principals" + add_foreign_key "mcp_oauth_authorization_codes", "users" + add_foreign_key "mcp_oauth_refresh_tokens", "mcp_oauth_clients" + add_foreign_key "mcp_oauth_refresh_tokens", "principals" + add_foreign_key "mcp_oauth_refresh_tokens", "users" add_foreign_key "oauth_apps", "users", column: "created_by_id" add_foreign_key "oauth_token_secrets", "users", column: "created_by_id" add_foreign_key "pg_dsn_secrets", "users", column: "created_by_id" diff --git a/services/console/lib/mcp/jwt.rb b/services/console/lib/mcp/jwt.rb new file mode 100644 index 000000000..6a973d413 --- /dev/null +++ b/services/console/lib/mcp/jwt.rb @@ -0,0 +1,23 @@ +require "base64" +require "json" +require "openssl" + +module Mcp + module Jwt + module_function + + def encode(payload) + signing_secret = ENV["CENTAUR_JWT_SIGNING_SECRET"].to_s + raise KeyError, "CENTAUR_JWT_SIGNING_SECRET is not configured" if signing_secret.blank? + + header = { "alg" => "HS256", "typ" => "JWT" } + signing_input = [ base64url_json(header), base64url_json(payload) ].join(".") + signature = OpenSSL::HMAC.digest("SHA256", signing_secret, signing_input) + "#{signing_input}.#{Base64.urlsafe_encode64(signature, padding: false)}" + end + + def base64url_json(value) + Base64.urlsafe_encode64(JSON.generate(value), padding: false) + end + end +end diff --git a/services/console/test/controllers/console/users_controller_test.rb b/services/console/test/controllers/console/users_controller_test.rb index d4bcfc681..d0944e934 100644 --- a/services/console/test/controllers/console/users_controller_test.rb +++ b/services/console/test/controllers/console/users_controller_test.rb @@ -53,6 +53,30 @@ def sign_in(user) assert target.reload.disabled? end + test "disable revokes outstanding MCP OAuth refresh tokens" do + sign_in users(:acme_admin) + target = users(:member_user) + refresh = McpOauthRefreshToken.create!( + mcp_oauth_client: McpOauthClient.create!( + name: "Amp", + redirect_uris: [ "http://127.0.0.1:49152/callback" ], + grant_types: McpOauthClient::DEFAULT_GRANT_TYPES, + response_types: McpOauthClient::DEFAULT_RESPONSE_TYPES, + scopes: McpOauthClient::DEFAULT_SCOPES + ), + user: target, + principal: principals(:acme_channel), + resource: "http://localhost:3000/mcp", + scopes: [ "mcp:tools" ] + ) + + post disable_console_user_url(target.oid) + + assert_redirected_to console_users_path + assert target.reload.disabled? + assert refresh.reload.revoked_at.present? + end + test "an admin cannot disable their own account" do admin = users(:acme_admin) sign_in admin diff --git a/services/console/test/controllers/mcp/oauth_controller_test.rb b/services/console/test/controllers/mcp/oauth_controller_test.rb new file mode 100644 index 000000000..0bf9b30e2 --- /dev/null +++ b/services/console/test/controllers/mcp/oauth_controller_test.rb @@ -0,0 +1,269 @@ +require "test_helper" +require "base64" +require "digest" +require "uri" + +module Mcp + class OauthControllerTest < ActionDispatch::IntegrationTest + setup do + @operator = users(:acme_admin) + @saved_env = { + "CENTAUR_JWT_SIGNING_SECRET" => ENV["CENTAUR_JWT_SIGNING_SECRET"], + "CENTAUR_MCP_PUBLIC_URL" => ENV["CENTAUR_MCP_PUBLIC_URL"], + "CENTAUR_CONSOLE_PUBLIC_URL" => ENV["CENTAUR_CONSOLE_PUBLIC_URL"] + } + ENV["CENTAUR_JWT_SIGNING_SECRET"] = "test-secret" + ENV["CENTAUR_MCP_PUBLIC_URL"] = "http://localhost:3000/mcp" + ENV["CENTAUR_CONSOLE_PUBLIC_URL"] = "http://www.example.com" + end + + teardown do + @saved_env.each do |key, value| + if value.nil? + ENV.delete(key) + else + ENV[key] = value + end + end + end + + test "metadata advertises MCP OAuth endpoints" do + get "/.well-known/oauth-authorization-server" + + assert_response :ok + body = JSON.parse(response.body) + assert_equal "http://www.example.com", body.fetch("issuer") + assert_equal "http://www.example.com/mcp/oauth/authorize", body.fetch("authorization_endpoint") + assert_equal "http://www.example.com/mcp/oauth/token", body.fetch("token_endpoint") + assert_equal "http://www.example.com/mcp/oauth/register", body.fetch("registration_endpoint") + assert_includes body.fetch("code_challenge_methods_supported"), "S256" + end + + test "dynamic client registration creates a public PKCE client" do + assert_difference -> { McpOauthClient.count }, 1 do + post "/mcp/oauth/register", + params: { + client_name: "Amp", + redirect_uris: [ "http://127.0.0.1:49152/callback" ], + scope: "mcp:tools" + }, + as: :json + end + + assert_response :created + body = JSON.parse(response.body) + assert_match(/\Amoc_/, body.fetch("client_id")) + assert_equal "none", body.fetch("token_endpoint_auth_method") + assert_equal "mcp:tools", body.fetch("scope") + end + + test "dynamic client registration rejects non-loopback redirect URIs" do + assert_no_difference -> { McpOauthClient.count } do + post "/mcp/oauth/register", + params: { + client_name: "Attacker", + redirect_uris: [ "https://evil.example/callback" ], + scope: "mcp:tools" + }, + as: :json + end + + assert_response :bad_request + assert_equal "invalid_client_metadata", JSON.parse(response.body).fetch("error") + end + + test "authorize rejects non-loopback redirect URIs even when already stored" do + client = create_client + client.update_column(:redirect_uris, [ "https://evil.example/callback" ]) + post login_url, params: { email: @operator.email, password: "password123456" } + + assert_no_difference -> { McpOauthAuthorizationCode.count } do + get "/mcp/oauth/authorize", + params: authorize_params(client).merge(redirect_uri: "https://evil.example/callback") + end + + assert_response :bad_request + assert_includes response.body, "redirect_uri is not registered" + end + + test "authorize redirects signed-out users through login and preserves the request" do + client = create_client + get "/mcp/oauth/authorize", params: authorize_params(client) + + assert_redirected_to login_path + + post login_url, params: { email: @operator.email, password: "password123456" } + assert_match %r{\Ahttp://www.example.com/mcp/oauth/authorize\?}, response.location + end + + test "authorize accepts dynamic loopback redirect ports" do + client = create_client(redirect_uris: [ "http://localhost/callback" ]) + approval_params = authorize_params(client).merge( + redirect_uri: "http://localhost:49153/callback" + ) + post login_url, params: { email: @operator.email, password: "password123456" } + + assert_no_difference -> { McpOauthAuthorizationCode.count } do + get "/mcp/oauth/authorize", params: approval_params + end + + assert_response :ok + assert_select "form[action=?]", "/mcp/oauth/authorize" + + post "/mcp/oauth/authorize", params: approval_params.merge(decision: "approve") + assert_response :redirect + redirect = URI.parse(response.location) + assert_equal "localhost", redirect.host + assert_equal 49153, redirect.port + assert Rack::Utils.parse_nested_query(redirect.query).key?("code") + end + + test "authorization approval denial redirects without issuing a code" do + client = create_client + post login_url, params: { email: @operator.email, password: "password123456" } + + assert_no_difference -> { McpOauthAuthorizationCode.count } do + post "/mcp/oauth/authorize", params: authorize_params(client).merge(decision: "deny") + end + + assert_response :redirect + redirect = URI.parse(response.location) + query = Rack::Utils.parse_nested_query(redirect.query) + assert_equal "access_denied", query.fetch("error") + assert_equal "state-test", query.fetch("state") + end + + test "authorization code exchange returns a JWT access token for the console principal" do + client = create_client + code = authorize_code(client) + stored_code = McpOauthAuthorizationCode.find_usable(code) + assert_equal @operator, stored_code.user + assert_equal "http://localhost:3000/mcp", stored_code.resource + assert_match(/\Aprn_/, stored_code.principal.oid) + + exchange_authorization_code(client, code) + + assert_response :ok + body = JSON.parse(response.body) + assert_equal "Bearer", body.fetch("token_type") + assert_equal "mcp:tools", body.fetch("scope") + assert_match(/\Amcprt_/, body.fetch("refresh_token")) + + jwt_payload = decode_jwt_payload(body.fetch("access_token")) + assert_equal "http://www.example.com", jwt_payload.fetch("iss") + assert_equal "http://localhost:3000/mcp", jwt_payload.fetch("aud") + assert_equal stored_code.principal.oid, jwt_payload.fetch("principal_id") + assert_equal @operator.email, jwt_payload.fetch("email") + assert_equal "mcp:tools", jwt_payload.fetch("scope") + end + + test "authorization code exchange rejects users disabled after consent" do + client = create_client + code = authorize_code(client) + stored_code = McpOauthAuthorizationCode.find_usable(code) + @operator.update!(status: :disabled) + + assert_no_difference -> { McpOauthRefreshToken.count } do + exchange_authorization_code(client, code) + end + + assert_response :bad_request + assert_equal "invalid_grant", JSON.parse(response.body).fetch("error") + assert stored_code.reload.consumed_at.present? + end + + test "refresh token exchange rejects inactive users and revokes their tokens" do + client = create_client + code = authorize_code(client) + exchange_authorization_code(client, code) + refresh_token = JSON.parse(response.body).fetch("refresh_token") + issued = McpOauthRefreshToken.find_usable(refresh_token) + extra = McpOauthRefreshToken.create!( + mcp_oauth_client: client, + user: @operator, + principal: issued.principal, + resource: issued.resource, + scopes: issued.scopes + ) + @operator.update_column(:status, "disabled") + + post "/mcp/oauth/token", + params: { + grant_type: "refresh_token", + client_id: client.public_client_id, + refresh_token: refresh_token + } + + assert_response :bad_request + assert_equal "invalid_grant", JSON.parse(response.body).fetch("error") + assert issued.reload.revoked_at.present? + assert extra.reload.revoked_at.present? + assert_equal 0, @operator.mcp_oauth_refresh_tokens.usable.count + end + + private + + def create_client(redirect_uris: [ redirect_uri ]) + McpOauthClient.create!( + name: "Amp", + redirect_uris: redirect_uris, + grant_types: McpOauthClient::DEFAULT_GRANT_TYPES, + response_types: McpOauthClient::DEFAULT_RESPONSE_TYPES, + scopes: McpOauthClient::DEFAULT_SCOPES + ) + end + + def authorize_params(client) + { + response_type: "code", + client_id: client.public_client_id, + redirect_uri: redirect_uri, + scope: "mcp:tools", + state: "state-test", + resource: "http://localhost:3000/mcp", + code_challenge: code_challenge, + code_challenge_method: "S256" + } + end + + def authorize_code(client) + approval_params = authorize_params(client) + post login_url, params: { email: @operator.email, password: "password123456" } + + assert_no_difference -> { McpOauthAuthorizationCode.count } do + get "/mcp/oauth/authorize", params: approval_params + end + assert_response :ok + assert_select "form[action=?]", "/mcp/oauth/authorize" + + post "/mcp/oauth/authorize", params: approval_params.merge(decision: "approve") + assert_response :redirect + redirect = URI.parse(response.location) + Rack::Utils.parse_nested_query(redirect.query).fetch("code") + end + + def exchange_authorization_code(client, code) + post "/mcp/oauth/token", + params: { + grant_type: "authorization_code", + client_id: client.public_client_id, + code: code, + redirect_uri: redirect_uri, + code_verifier: code_verifier + } + end + + def redirect_uri = "http://127.0.0.1:49152/callback" + + def code_verifier = "test-code-verifier" + + def code_challenge + Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + end + + def decode_jwt_payload(token) + _header, payload, _signature = token.split(".") + JSON.parse(Base64.urlsafe_decode64(payload)) + end + end +end diff --git a/services/console/test/models/mcp_oauth_client_test.rb b/services/console/test/models/mcp_oauth_client_test.rb new file mode 100644 index 000000000..86d22956e --- /dev/null +++ b/services/console/test/models/mcp_oauth_client_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class McpOauthClientTest < ActiveSupport::TestCase + test "allowed redirect URI accepts only localhost and loopback IP literals" do + assert McpOauthClient.allowed_redirect_uri?("http://localhost:49152/callback") + assert McpOauthClient.allowed_redirect_uri?("http://127.0.0.1:49152/callback") + assert McpOauthClient.allowed_redirect_uri?("http://127.1.2.3:49152/callback") + assert McpOauthClient.allowed_redirect_uri?("http://[::1]:49152/callback") + + refute McpOauthClient.allowed_redirect_uri?("https://127.0.0.1/callback") + refute McpOauthClient.allowed_redirect_uri?("http://127.evil.com/callback") + refute McpOauthClient.allowed_redirect_uri?("http://127.0.0.1.evil.com/callback") + refute McpOauthClient.allowed_redirect_uri?("http://localhost.evil.com/callback") + end + + test "redirect matching rejects attacker controlled 127-looking hostnames" do + client = McpOauthClient.create!( + name: "Amp", + redirect_uris: [ "http://127.0.0.1/callback" ], + grant_types: McpOauthClient::DEFAULT_GRANT_TYPES, + response_types: McpOauthClient::DEFAULT_RESPONSE_TYPES, + scopes: McpOauthClient::DEFAULT_SCOPES + ) + + refute client.redirect_uri_allowed?("http://127.evil.com/callback") + refute client.redirect_uri_allowed?("http://127.0.0.1.evil.com/callback") + end +end diff --git a/services/sandbox/Dockerfile b/services/sandbox/Dockerfile index 9c555635a..d28b2f9ad 100644 --- a/services/sandbox/Dockerfile +++ b/services/sandbox/Dockerfile @@ -225,6 +225,7 @@ COPY --link --chmod=755 services/workflow-python/workflow_host.py /usr/local/bin COPY --link services/workflow-python/api/ /usr/local/bin/api/ COPY --link --chmod=755 services/sandbox/git-branch.sh /usr/local/bin/git-branch COPY --link --chmod=755 services/sandbox/install_tool_shims.py /usr/local/bin/install-tool-shims +COPY --link --chmod=755 services/sandbox/centaur_tool_host.py /usr/local/bin/centaur-tool-host COPY --link --chmod=755 services/sandbox/repo_cache_sync.py /usr/local/bin/repo-cache-sync COPY --link --chmod=755 services/sandbox/repo_cache_watch.py /usr/local/bin/repo-cache-watch COPY --link --chmod=755 services/sandbox/entrypoint.sh /entrypoint.sh diff --git a/services/sandbox/centaur_tool_host.py b/services/sandbox/centaur_tool_host.py new file mode 100644 index 000000000..e6e35aaa4 --- /dev/null +++ b/services/sandbox/centaur_tool_host.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import subprocess +import sys +import traceback +from typing import Any + + +def _text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode(errors="replace") + return str(value) + + +def _run_tool(request: dict[str, Any]) -> dict[str, Any]: + request_id = request.get("id") + tool = request["tool"] + method = request["method"] + arguments = request.get("arguments", {}) + timeout_seconds = max(1, int(request.get("timeout_seconds") or 120)) + + env = os.environ.copy() + principal_id = request.get("principal_id") + token_id = request.get("token_id") + if principal_id: + env["CENTAUR_MCP_PRINCIPAL_ID"] = str(principal_id) + if token_id: + env["CENTAUR_MCP_TOKEN_ID"] = str(token_id) + + try: + completed = subprocess.run( + [ + "centaur-tools", + "call", + str(tool), + str(method), + json.dumps(arguments, separators=(",", ":")), + ], + check=False, + text=True, + capture_output=True, + timeout=timeout_seconds, + env=env, + ) + return { + "id": request_id, + "status": completed.returncode, + "stdout": completed.stdout, + "stderr": completed.stderr, + "timed_out": False, + } + except subprocess.TimeoutExpired as exc: + return { + "id": request_id, + "status": None, + "stdout": _text(exc.stdout), + "stderr": _text(exc.stderr) + + f"\ncentaur tool call timed out after {timeout_seconds}s", + "timed_out": True, + } + + +def _emit_result(response: dict[str, Any]) -> None: + print( + json.dumps( + { + "type": "result", + "turn_id": response.get("id"), + "result": json.dumps(response, separators=(",", ":")), + }, + separators=(",", ":"), + ), + flush=True, + ) + + +def main() -> int: + print("__CENTAUR_TOOL_HOST_READY", flush=True) + for raw_line in sys.stdin: + raw_line = raw_line.strip() + if not raw_line: + continue + request_id = None + try: + request = json.loads(raw_line) + request_id = request.get("id") + response = _run_tool(request) + except Exception: + response = { + "id": request_id, + "status": 1, + "stdout": "", + "stderr": traceback.format_exc(), + "timed_out": False, + } + _emit_result(response) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/sandbox/install_tool_shims.py b/services/sandbox/install_tool_shims.py index 94c943165..9db44ea39 100644 --- a/services/sandbox/install_tool_shims.py +++ b/services/sandbox/install_tool_shims.py @@ -534,6 +534,23 @@ def catalog_lock(exclusive=False): if target is None: raise RuntimeError(f"tool has no method {{method}}") +# Validate keyword arguments up front so a wrong argument name produces a +# short usage error with the expected signature instead of a traceback. +if isinstance(payload, dict) and callable(target): + try: + signature = inspect.signature(target) + except (TypeError, ValueError): + signature = None + if signature is not None: + try: + signature.bind(**payload) + except TypeError as exc: + print( + f"invalid arguments for {{method}}{{signature}}: {{exc}}", + file=sys.stderr, + ) + raise SystemExit(2) + ctx_token = None thread_key = os.environ.get("CENTAUR_THREAD_KEY", "").strip() if thread_key: From 379b48edaeb61fe41ba4e4de0d82e814d74b9b9d Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Thu, 2 Jul 2026 09:56:37 -0700 Subject: [PATCH 032/198] fix: avoid api attachment downloads from sandbox (#859) --- centaur_sdk/tests/test_tool_sdk.py | 49 ++++++++++ centaur_sdk/tool_sdk.py | 57 ++++++++++- crates/harness-server/Cargo.lock | 88 ----------------- crates/harness-server/Cargo.toml | 1 - crates/harness-server/src/server.rs | 143 +--------------------------- 5 files changed, 109 insertions(+), 229 deletions(-) diff --git a/centaur_sdk/tests/test_tool_sdk.py b/centaur_sdk/tests/test_tool_sdk.py index ec873c008..5064a6f33 100644 --- a/centaur_sdk/tests/test_tool_sdk.py +++ b/centaur_sdk/tests/test_tool_sdk.py @@ -1,6 +1,7 @@ from __future__ import annotations import threading +from pathlib import Path import pytest @@ -9,6 +10,7 @@ current_session_context, current_slack_thread, reset_tool_context, + save_attachment, secret, set_tool_context, ) @@ -139,6 +141,53 @@ def read(self) -> bytes: reset_tool_context(token) +def test_save_attachment_writes_to_sandbox_uploads_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path +): + def fail_urlopen(*_args, **_kwargs): + raise AssertionError("save_attachment should not call the API in sandbox mode") + + monkeypatch.setenv("CENTAUR_UPLOADS_DIR", str(tmp_path)) + monkeypatch.setattr("urllib.request.urlopen", fail_urlopen) + + result = save_attachment( + name="../report.txt", + data=b"hello", + mime_type="text/plain", + source_url="https://example.test/report", + ) + + saved_path = tmp_path / "report.txt" + assert saved_path.read_bytes() == b"hello" + assert result == { + "attachment_id": None, + "filename": "report.txt", + "mime_type": "text/plain", + "download_url": None, + "path": str(saved_path), + "local_path": str(saved_path), + "source_url": "https://example.test/report", + "size_bytes": 5, + } + + +def test_save_attachment_uses_unique_local_name_on_collision( + monkeypatch: pytest.MonkeyPatch, tmp_path +): + monkeypatch.setenv("CENTAUR_UPLOADS_DIR", str(tmp_path)) + + first = save_attachment(name="same.txt", data=b"first") + second = save_attachment(name="same.txt", data=b"second") + + assert first["path"] != second["path"] + assert (tmp_path / "same.txt").read_bytes() == b"first" + second_path = Path(str(second["path"])) + assert second_path.exists() + assert second_path.read_bytes() == b"second" + assert second_path.name.startswith("same-") + assert second_path.suffix == ".txt" + + @pytest.mark.asyncio async def test_stub_backend_returns_key_placeholders(): backend = StubBackend() diff --git a/centaur_sdk/tool_sdk.py b/centaur_sdk/tool_sdk.py index fc5589e13..04a3861f6 100644 --- a/centaur_sdk/tool_sdk.py +++ b/centaur_sdk/tool_sdk.py @@ -7,12 +7,14 @@ import json import logging import mimetypes -from urllib.parse import quote +import os import urllib.request +import uuid from contextvars import ContextVar from dataclasses import dataclass, field from pathlib import Path from typing import Any +from urllib.parse import quote log = logging.getLogger(__name__) @@ -125,6 +127,47 @@ def current_slack_thread() -> dict[str, str]: } +def _sandbox_uploads_dir() -> Path | None: + configured = os.environ.get("CENTAUR_UPLOADS_DIR", "").strip() + if configured: + return Path(configured) + if os.environ.get("CENTAUR_THREAD_KEY", "").strip(): + return Path.home() / "uploads" + return None + + +def _unique_upload_path(uploads_dir: Path, name: str) -> Path: + candidate = uploads_dir / name + if not candidate.exists(): + return candidate + suffix = candidate.suffix + stem = candidate.stem or "attachment" + return uploads_dir / f"{stem}-{uuid.uuid4().hex}{suffix}" + + +def _save_local_attachment( + *, + name: str, + data: bytes, + mime_type: str, + source_url: str | None, + uploads_dir: Path, +) -> dict[str, Any]: + uploads_dir.mkdir(parents=True, exist_ok=True) + path = _unique_upload_path(uploads_dir, name) + path.write_bytes(data) + return { + "attachment_id": None, + "filename": name, + "mime_type": mime_type, + "download_url": None, + "path": str(path), + "local_path": str(path), + "source_url": source_url, + "size_bytes": len(data), + } + + def save_attachment( *, name: str, @@ -133,9 +176,19 @@ def save_attachment( source_url: str | None = None, ) -> dict[str, Any]: """Persist bytes as a Centaur attachment scoped to the current tool thread.""" - thread_key = current_thread_key() safe_name = Path(name).name or "attachment" resolved_mime = mime_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream" + uploads_dir = _sandbox_uploads_dir() + if uploads_dir is not None: + return _save_local_attachment( + name=safe_name, + data=data, + mime_type=resolved_mime, + source_url=source_url, + uploads_dir=uploads_dir, + ) + + thread_key = current_thread_key() base_url = secret("CENTAUR_API_URL", "http://api:8000").rstrip("/") payload = json.dumps( { diff --git a/crates/harness-server/Cargo.lock b/crates/harness-server/Cargo.lock index 1366264ad..5469c3317 100644 --- a/crates/harness-server/Cargo.lock +++ b/crates/harness-server/Cargo.lock @@ -1461,10 +1461,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -1548,7 +1546,6 @@ dependencies = [ "codex-utils-absolute-path", "opentelemetry-proto", "prost", - "reqwest", "serde", "serde_json", "thiserror 2.0.18", @@ -1756,7 +1753,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -2337,12 +2333,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lsp-types" version = "0.94.1" @@ -2934,61 +2924,6 @@ dependencies = [ "serde", ] -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases 0.2.1", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases 0.2.1", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.52.0", -] - [[package]] name = "quote" version = "1.0.45" @@ -3486,9 +3421,7 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", - "futures-channel", "futures-core", - "futures-util", "h2", "http", "http-body", @@ -3503,8 +3436,6 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3512,7 +3443,6 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls", "tower", "tower-http", "tower-service", @@ -3520,7 +3450,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -3578,12 +3507,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - [[package]] name = "rusticata-macros" version = "4.1.0" @@ -3640,7 +3563,6 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] @@ -5003,16 +4925,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/crates/harness-server/Cargo.toml b/crates/harness-server/Cargo.toml index 67862ad9e..1fa56f00f 100644 --- a/crates/harness-server/Cargo.toml +++ b/crates/harness-server/Cargo.toml @@ -21,7 +21,6 @@ codex-protocol = { git = "https://github.com/openai/codex", rev = "e93dc98a48d59 codex-utils-absolute-path = { git = "https://github.com/openai/codex", rev = "e93dc98a48d597df322436ffe8d03bfd7ec63b3b", package = "codex-utils-absolute-path" } opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["trace", "gen-tonic-messages"] } prost = "0.14" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" diff --git a/crates/harness-server/src/server.rs b/crates/harness-server/src/server.rs index 1c448c5c4..6e41a2903 100644 --- a/crates/harness-server/src/server.rs +++ b/crates/harness-server/src/server.rs @@ -18,7 +18,6 @@ use codex_app_server_protocol::{ }; use serde::Deserialize; use serde_json::{Value, json}; -use url::Url; use uuid::Uuid; use crate::amp::AmpHarness; @@ -434,7 +433,7 @@ fn blocks_content_to_user_input( fn blocks_input_to_user_input( input: &BlocksInput, state: &mut BlocksState, - trace_context: &TraceContext, + _trace_context: &TraceContext, ) -> Result> { match input { BlocksInput::UserInput(input) => Ok(vec![input.clone()]), @@ -442,7 +441,7 @@ fn blocks_input_to_user_input( attachment_block_to_user_input(block, state) } BlocksInput::Attachment(block) if block.kind == "attachment_ref" => { - Ok(attachment_ref_block_to_user_input(block, trace_context)) + Ok(attachment_ref_block_to_user_input(block)) } BlocksInput::Attachment(block) => Ok(vec![UserInput::Text { text: format!("[Unsupported attachment block type: {}]", block.kind), @@ -451,38 +450,11 @@ fn blocks_input_to_user_input( } } -fn attachment_ref_block_to_user_input( - block: &AttachmentBlock, - trace_context: &TraceContext, -) -> Vec { +fn attachment_ref_block_to_user_input(block: &AttachmentBlock) -> Vec { let attachment_id = non_empty(block.attachment_id.as_deref()); let mime_type = non_empty(block.mime_type.as_deref()); - let attachment_type = non_empty(block.attachment_type.as_deref()); let name = non_empty(block.name.as_deref()).unwrap_or("attachment"); - if let (Some(attachment_id), Some(thread_key)) = ( - attachment_id, - non_empty(trace_context.thread_key.as_deref()), - ) { - match download_attachment_ref(attachment_id, thread_key, name, mime_type) { - Ok(path) => { - return local_file_inputs( - &path, - mime_type, - is_image_attachment(attachment_type, mime_type), - ); - } - Err(error) => { - return vec![UserInput::Text { - text: format!( - "[Attachment reference could not be downloaded: id={attachment_id} name={name} error={error}. The file is not preloaded in /home/agent/uploads; recover it locally before inspecting it.]" - ), - text_elements: Vec::new(), - }]; - } - } - } - let mut fields = Vec::new(); if let Some(attachment_id) = attachment_id { fields.push(format!("id={attachment_id}")); @@ -499,60 +471,12 @@ fn attachment_ref_block_to_user_input( }; vec![UserInput::Text { text: format!( - "[Attachment reference: {summary}. The file is not preloaded in /home/agent/uploads; recover it locally before inspecting it.]" + "[Attachment reference: {summary}. The file was not provided to this sandbox. Ask the caller to resend the attachment as an upload, inline file data, or staged attachment chunk before inspecting it.]" ), text_elements: Vec::new(), }] } -fn download_attachment_ref( - attachment_id: &str, - thread_key: &str, - name: &str, - mime_type: Option<&str>, -) -> std::result::Result { - let api_base = attachment_api_base().ok_or_else(|| "CENTAUR_API_URL is not set".to_string())?; - let url = attachment_download_url(&api_base, attachment_id, thread_key)?; - let response = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(|error| error.to_string())? - .get(url) - .send() - .map_err(|error| error.to_string())?; - let status = response.status(); - if !status.is_success() { - return Err(format!("download returned HTTP {status}")); - } - let bytes = response.bytes().map_err(|error| error.to_string())?; - let path = unique_upload_path(name, mime_type).map_err(|error| error.to_string())?; - std::fs::write(&path, &bytes).map_err(|error| error.to_string())?; - Ok(path) -} - -fn attachment_api_base() -> Option { - ["CENTAUR_API_URL", "SESSION_SANDBOX_CENTAUR_API_URL"] - .iter() - .find_map(|name| non_empty(env::var(name).ok().as_deref()).map(str::to_owned)) -} - -fn attachment_download_url( - api_base: &str, - attachment_id: &str, - thread_key: &str, -) -> std::result::Result { - let mut url = Url::parse(api_base.trim_end_matches('/')).map_err(|error| error.to_string())?; - { - let mut segments = url - .path_segments_mut() - .map_err(|_| "attachment API base URL cannot be a base".to_string())?; - segments.pop_if_empty(); - segments.extend(["agent", "attachments", attachment_id, "download"]); - } - url.query_pairs_mut().append_pair("thread_key", thread_key); - Ok(url) -} - fn attachment_block_to_user_input( block: &AttachmentBlock, state: &mut BlocksState, @@ -1462,7 +1386,6 @@ pub(crate) fn write_blocks_error( #[cfg(test)] mod tests { use super::*; - use std::io::Read as _; fn temp_upload_dir() -> PathBuf { let path = env::temp_dir().join(format!("harness-server-test-{}", Uuid::new_v4().simple())); @@ -1534,66 +1457,10 @@ mod tests { assert!(text.contains("id=att_123")); assert!(text.contains("name=report.pdf")); assert!(text.contains("mime=application/pdf")); - assert!(text.contains("not preloaded in /home/agent/uploads")); + assert!(text.contains("not provided to this sandbox")); assert!(!text.contains("Unsupported attachment block type")); } - #[test] - fn attachment_ref_downloads_to_uploads_dir_when_api_is_available() { - let upload_dir = temp_upload_dir(); - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind test server"); - let api_base = format!("http://{}", listener.local_addr().expect("local addr")); - let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept request"); - let mut request = String::new(); - let mut buffer = [0; 1024]; - loop { - let read = stream.read(&mut buffer).expect("read request"); - if read == 0 { - break; - } - request.push_str(&String::from_utf8_lossy(&buffer[..read])); - if request.contains("\r\n\r\n") { - break; - } - } - assert!( - request.starts_with("GET /agent/attachments/att_123/download?thread_key=web%3At1 ") - ); - stream - .write_all( - b"HTTP/1.1 200 OK\r\nContent-Length: 9\r\nConnection: close\r\n\r\nhello-ref", - ) - .expect("write response"); - }); - - unsafe { - env::set_var("CENTAUR_API_URL", api_base); - } - let line = r#"{"type":"user","thread_key":"web:t1","message":{"role":"user","content":[{"type":"text","text":"inspect this"},{"type":"attachment_ref","attachment_id":"att_123","name":"report.txt","mime_type":"text/plain"}]}}"#; - let BlocksCommand::User { input, .. } = parse_blocks_line(line).expect("parses") else { - panic!("expected user command"); - }; - server.join().expect("server thread"); - - assert_eq!(input.len(), 2); - let UserInput::Text { text, .. } = &input[1] else { - panic!("expected attachment_ref to become text input"); - }; - assert!(text.contains("Attached file saved to")); - assert!(!text.contains("Unsupported attachment block type")); - let path = text - .strip_prefix("[Attached file saved to ") - .and_then(|value| value.strip_suffix(']')) - .map(PathBuf::from) - .expect("saved path"); - assert!(path.starts_with(&upload_dir) || path.exists()); - assert_eq!( - std::fs::read_to_string(path).expect("downloaded file"), - "hello-ref" - ); - } - #[test] fn parses_blocks_user_line_with_provider_override() { let line = r#"{"type":"user","thread_key":"web:t1","provider":"amazon-bedrock","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#; From c7d708c72c6a95e087002fbc870133718c78e67a Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:06:32 -0700 Subject: [PATCH 033/198] feat: add console threads view (#843) * feat: add console threads view * fix: stabilize console threads ci * fix: center console thread content * fix: make console threads read-only * fix: scope console thread direct-selection to the owner (#854) * fix: harden console markdown rendering and thread-title metadata (#855) * fix: remove passwordless ?auth= console sign-in backdoor (#857) * fix: transcript ordering, sidebar query scope, and session DB fallback (#856) * fix: relax console Slack thread scope to team-when-present (#861) * feat(slackbotv2): link first Slack message to Console session (#860) * feat: console threads split view and thinking traces (#863) * feat: always show the model on Console session links and thread header (#865) * style: show model before harness and uppercase model names (#867) * feat: console chats naming, empty state, and chat-not-found 404 (#866) * chore: bump chart version to 0.1.86 Co-authored-by: Claude Fable 5 --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/console-worker.yaml | 5 + contrib/chart/templates/console.yaml | 19 + contrib/chart/templates/slackbotv2.yaml | 18 + contrib/chart/values.yaml | 8 +- docs/pages/reference/configuration.mdx | 2 +- docs/public/md/reference/configuration.md | 2 +- scripts/mirror-prod-threads-snapshot.sh | 732 ++++++++++ services/console/README.md | 37 + .../app/assets/tailwind/application.css | 24 + .../app/controllers/application_controller.rb | 243 ++- .../controllers/console/threads_controller.rb | 1061 ++++++++++++++ .../app/controllers/sessions_controller.rb | 2 +- .../console/app/helpers/application_helper.rb | 339 ++++- .../controllers/localtime_controller.js | 22 +- .../console/app/models/centaur_session.rb | 28 + .../app/models/centaur_session_event.rb | 16 + .../app/models/centaur_session_execution.rb | 12 + .../app/models/centaur_session_message.rb | 20 + .../app/models/centaur_session_record.rb | 57 + .../console/app/models/slack_sync_user.rb | 7 + .../app/services/centaur_api_client.rb | 26 + .../app/views/console/_control_tabs.html.erb | 19 + .../app/views/console/_page_header.html.erb | 29 + .../app/views/console/credentials.html.erb | 13 +- .../app/views/console/etls/index.html.erb | 17 +- .../app/views/console/oauth_app.html.erb | 2 +- .../app/views/console/oauth_apps.html.erb | 17 +- .../views/console/oauth_apps/edit.html.erb | 2 +- .../app/views/console/oauth_apps/new.html.erb | 6 +- .../app/views/console/principals.html.erb | 9 +- .../app/views/console/roles/index.html.erb | 13 +- .../app/views/console/secrets.html.erb | 14 +- .../console/threads/_sidebar_threads.html.erb | 39 + .../console/threads/_thread_panel.html.erb | 42 + .../console/threads/_transcript.html.erb | 40 + .../app/views/console/threads/index.html.erb | 93 ++ .../app/views/console/users/index.html.erb | 9 +- .../app/views/layouts/console.html.erb | 1301 ++++++++++++++++- services/console/config/routes.rb | 7 + services/console/config/tailwind.config.js | 11 +- services/console/public/icon-dark.svg | 19 + services/console/public/icon-light.svg | 19 + .../console/etls_controller_test.rb | 6 +- .../console/threads_controller_test.rb | 932 ++++++++++++ .../console/users_controller_test.rb | 10 + .../test/helpers/application_helper_test.rb | 49 + .../models/centaur_session_record_test.rb | 123 ++ .../test/services/centaur_api_client_test.rb | 48 + services/slackbotv2/Dockerfile | 5 + .../slackbotv2/src/console-session-link.ts | 125 ++ services/slackbotv2/src/index.ts | 52 +- services/slackbotv2/src/server.ts | 8 + services/slackbotv2/src/session-api.ts | 17 +- services/slackbotv2/src/toml.d.ts | 6 + services/slackbotv2/src/types.ts | 21 + .../slackbotv2/test/chat-sdk-emulate.test.ts | 135 ++ .../test/console-session-link.test.ts | 128 ++ services/slackbotv2/tsconfig.json | 1 + 59 files changed, 5946 insertions(+), 123 deletions(-) create mode 100755 scripts/mirror-prod-threads-snapshot.sh create mode 100644 services/console/app/controllers/console/threads_controller.rb create mode 100644 services/console/app/models/centaur_session.rb create mode 100644 services/console/app/models/centaur_session_event.rb create mode 100644 services/console/app/models/centaur_session_execution.rb create mode 100644 services/console/app/models/centaur_session_message.rb create mode 100644 services/console/app/models/centaur_session_record.rb create mode 100644 services/console/app/models/slack_sync_user.rb create mode 100644 services/console/app/views/console/_control_tabs.html.erb create mode 100644 services/console/app/views/console/_page_header.html.erb create mode 100644 services/console/app/views/console/threads/_sidebar_threads.html.erb create mode 100644 services/console/app/views/console/threads/_thread_panel.html.erb create mode 100644 services/console/app/views/console/threads/_transcript.html.erb create mode 100644 services/console/app/views/console/threads/index.html.erb create mode 100644 services/console/public/icon-dark.svg create mode 100644 services/console/public/icon-light.svg create mode 100644 services/console/test/controllers/console/threads_controller_test.rb create mode 100644 services/console/test/models/centaur_session_record_test.rb create mode 100644 services/slackbotv2/src/console-session-link.ts create mode 100644 services/slackbotv2/src/toml.d.ts create mode 100644 services/slackbotv2/test/console-session-link.test.ts diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 488f5cc63..c8c4570d5 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.85 +version: 0.1.86 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/console-worker.yaml b/contrib/chart/templates/console-worker.yaml index a448d342a..b4babee67 100644 --- a/contrib/chart/templates/console-worker.yaml +++ b/contrib/chart/templates/console-worker.yaml @@ -86,6 +86,11 @@ spec: secretKeyRef: name: {{ $secretEnv }} key: {{ printf "%sIRON_CONTROL_DATABASE_URL" $prefix }} + - name: CENTAUR_CONSOLE_CENTAUR_DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sDATABASE_URL" $prefix }} - name: IRON_CONTROL_INITIAL_USER_EMAIL valueFrom: secretKeyRef: diff --git a/contrib/chart/templates/console.yaml b/contrib/chart/templates/console.yaml index d462dd362..6c75a8c29 100644 --- a/contrib/chart/templates/console.yaml +++ b/contrib/chart/templates/console.yaml @@ -112,6 +112,25 @@ spec: secretKeyRef: name: {{ $secretEnv }} key: {{ printf "%sIRON_CONTROL_DATABASE_URL" $prefix }} + # Thread browsing reads api-rs session rows from its logical DB. + # Keep this under a Console-specific name so Rails' primary + # DATABASE_URL remains pointed at the console database. + - name: CENTAUR_CONSOLE_CENTAUR_DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sDATABASE_URL" $prefix }} + - name: CENTAUR_CONSOLE_SLACKBOTV2_USER_NAME + value: {{ .Values.slackbotv2.userName | quote }} +{{- range $name := tuple "CLAUDE_MODEL" "CODEX_MODEL" }} +{{- if hasKey $.Values.sandbox.extraEnv $name }} + # Mirror the deployer's harness default-model override + # (sandbox.extraEnv) so the Threads view names the model threads + # without a recorded override actually ran on. + - name: {{ $name }} + value: {{ index $.Values.sandbox.extraEnv $name | toString | quote }} +{{- end }} +{{- end }} - name: IRON_CONTROL_INITIAL_USER_EMAIL valueFrom: secretKeyRef: diff --git a/contrib/chart/templates/slackbotv2.yaml b/contrib/chart/templates/slackbotv2.yaml index 6fd5d0a78..3f73158a4 100644 --- a/contrib/chart/templates/slackbotv2.yaml +++ b/contrib/chart/templates/slackbotv2.yaml @@ -1,5 +1,6 @@ {{- if .Values.slackbotv2.enabled }} {{- $apiRsName := include "centaur.componentName" (dict "root" . "component" "api-rs") -}} +{{- $console := include "centaur.consoleValues" . | fromYaml -}} {{- $slackbotV2MetricsAnnotations := dict -}} {{- if .Values.slackbotv2.metrics.scrapeAnnotations -}} {{- $slackbotV2MetricsAnnotations = dict "prometheus.io/scrape" "true" "prometheus.io/path" .Values.slackbotv2.metrics.path "prometheus.io/port" "3001" -}} @@ -69,6 +70,13 @@ spec: value: {{ .Values.slackbotv2.userName | quote }} - name: SLACKBOTV2_ACTIVITY_SUMMARY_STATUS_ENABLED value: {{ .Values.apiRs.activitySummary.enabled | quote }} +{{- if $console.publicUrl }} + # Public origin of the Console UI (matches the Console's own + # CENTAUR_CONSOLE_PUBLIC_URL). When set, the first assistant message + # in a Slack thread gets an "Open session in Console" link. + - name: CENTAUR_CONSOLE_PUBLIC_URL + value: {{ $console.publicUrl | quote }} +{{- end }} {{- if .Values.slackbotv2.assistantStatus }} - name: SLACKBOTV2_ASSISTANT_STATUS value: {{ .Values.slackbotv2.assistantStatus | quote }} @@ -81,6 +89,16 @@ spec: - name: SLACKBOT_TRIGGER_BOT_ALLOWLIST value: {{ .Values.slackbotv2.triggerBotAllowlist | quote }} {{- end }} +{{- range $name := tuple "CLAUDE_MODEL" "CODEX_MODEL" }} +{{- if and (hasKey $.Values.sandbox.extraEnv $name) (not (hasKey $.Values.slackbotv2.extraEnv $name)) }} + # Mirror the deployer's harness default-model override + # (sandbox.extraEnv) so the Slack Console-link line names the model + # sandboxes actually run. slackbotv2.extraEnv wins if it sets the + # same variable. + - name: {{ $name }} + value: {{ index $.Values.sandbox.extraEnv $name | toString | quote }} +{{- end }} +{{- end }} {{- range $name, $value := .Values.slackbotv2.extraEnv }} - name: {{ $name }} value: {{ $value | quote }} diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index c2626f6b7..5c8becab7 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -85,8 +85,11 @@ toolServer: console: enabled: false replicaCount: 1 - # Public URL users reach in a browser. Set this when console is exposed behind + # Public URL users reach in a browser (e.g. https://console.example.com), used + # as CENTAUR_CONSOLE_PUBLIC_URL. Set this when console is exposed behind # Tailscale/Ingress so MCP OAuth issuer metadata and JWT validation agree. + # When set, the slackbotv2 deployment also links the first assistant message + # in a Slack thread to the Console session view; leave empty to omit the link. publicUrl: "" image: repository: centaur-console @@ -227,6 +230,9 @@ sandbox: # their session's harness via container args; the sandbox image CMD only # matters for containers started outside api-rs. harnessEngine: codex + # Copied into every sandbox pod. CLAUDE_MODEL / CODEX_MODEL set here (the + # harness default-model overrides) are also mirrored into slackbotv2 and the + # Console so their model displays match what sandboxes actually run. extraEnv: {} stateVolume: enabled: false diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index b546e4461..41fdf5cba 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -70,7 +70,7 @@ Optional required-by-mode variables: | `SLACKBOT_URL` | Chart-rendered Slackbot service URL. | API callback target for Slack delivery. | | `FINAL_DELIVERY_MAX_ATTEMPTS`, `FINAL_DELIVERY_READY_GRACE_S` | `api.extraEnv`. | Final-delivery retry and claim timing. | | `CENTAUR_ENABLE_GCLOUD_BOOTSTRAP`, `GCP_GCLOUD_CREDENTIAL`, `GCLOUD_PROJECT` | `api.extraEnv` or Secret. | Optional gcloud ADC bootstrap in the API container. | -| `CLAUDE_MODEL`, `CODEX_MODEL` | `api.extraEnv` or request model override. | Harness model selection defaults. | +| `CLAUDE_MODEL`, `CODEX_MODEL` | `api.extraEnv` or request model override. | Harness model selection defaults. When set via `sandbox.extraEnv`, the chart also mirrors them into slackbotv2 and the Console so their model displays track the deployment. | ## API-RS diff --git a/docs/public/md/reference/configuration.md b/docs/public/md/reference/configuration.md index 2f6a739fe..d41ac776c 100644 --- a/docs/public/md/reference/configuration.md +++ b/docs/public/md/reference/configuration.md @@ -70,7 +70,7 @@ Optional required-by-mode variables: | `SLACKBOT_URL` | Chart-rendered Slackbot service URL. | API callback target for Slack delivery. | | `FINAL_DELIVERY_MAX_ATTEMPTS`, `FINAL_DELIVERY_READY_GRACE_S` | `api.extraEnv`. | Final-delivery retry and claim timing. | | `CENTAUR_ENABLE_GCLOUD_BOOTSTRAP`, `GCP_GCLOUD_CREDENTIAL`, `GCLOUD_PROJECT` | `api.extraEnv` or Secret. | Optional gcloud ADC bootstrap in the API container. | -| `CLAUDE_MODEL`, `CODEX_MODEL` | `api.extraEnv` or request model override. | Harness model selection defaults. | +| `CLAUDE_MODEL`, `CODEX_MODEL` | `api.extraEnv` or request model override. | Harness model selection defaults. When set via `sandbox.extraEnv`, the chart also mirrors them into slackbotv2 and the Console so their model displays track the deployment. | ## API-RS diff --git a/scripts/mirror-prod-threads-snapshot.sh b/scripts/mirror-prod-threads-snapshot.sh new file mode 100755 index 000000000..f81154dab --- /dev/null +++ b/scripts/mirror-prod-threads-snapshot.sh @@ -0,0 +1,732 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + scripts/mirror-prod-threads-snapshot.sh [all|snapshot|import] + +Creates a bounded, local-only snapshot of production thread/session data for +Centaur Console UX work. The source connection is forced into a read-only +transaction mode with PGOPTIONS. The import target is expected to be a local +ai_v2 database and is truncated by default. + +Modes: + all Export from source and import into local target. Default. + snapshot Export CSV files only. + import Import CSV files from SNAPSHOT_DIR only. + +Required for snapshot/all: + CENTAUR_PROD_DATABASE_URL + Read-only Postgres DSN for the production ai_v2 database. + +Optional production secret lookup: + CENTAUR_PROD_KUBE_CONTEXT + CENTAUR_PROD_NAMESPACE=centaur + CENTAUR_PROD_DATABASE_URL_SECRET_NAME + CENTAUR_PROD_DATABASE_URL_SECRET_KEY=DATABASE_URL + +Optional import target: + CENTAUR_LOCAL_DB_CONTAINER=codex-centaur-console-db + CENTAUR_LOCAL_CENTAUR_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ai_v2 + +Snapshot sizing: + THREAD_LIMIT=250 + MESSAGE_LIMIT_PER_THREAD=120 + EXECUTION_LIMIT_PER_THREAD=20 + EVENT_LIMIT_PER_THREAD=40 + THINKING_EVENT_LIMIT_PER_THREAD=200 + +Safety: + TRUNCATE_LOCAL_SESSION_TABLES=1 + ALLOW_NONLOCAL_TARGET=0 + +After import, run Console with: + CENTAUR_CONSOLE_THREADS_READ_ONLY=1 +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +info() { + echo "==> $*" +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +validate_integer() { + local name="$1" + local value="$2" + [[ "$value" =~ ^[0-9]+$ ]] || die "$name must be a non-negative integer" +} + +sql_quote_path() { + local value="$1" + printf "%s" "${value//\'/\'\'}" +} + +resolve_source_database_url() { + if [[ -n "${CENTAUR_PROD_DATABASE_URL:-}" ]]; then + printf "%s" "$CENTAUR_PROD_DATABASE_URL" + return + fi + + if [[ -n "${CENTAUR_PROD_KUBE_CONTEXT:-}" ]]; then + require_command kubectl + local secret_name="${CENTAUR_PROD_DATABASE_URL_SECRET_NAME:-}" + local secret_key="${CENTAUR_PROD_DATABASE_URL_SECRET_KEY:-DATABASE_URL}" + local namespace="${CENTAUR_PROD_NAMESPACE:-centaur}" + [[ -n "$secret_name" ]] || die \ + "set CENTAUR_PROD_DATABASE_URL to a read-only DSN, or set CENTAUR_PROD_DATABASE_URL_SECRET_NAME for kube lookup" + + kubectl --context "$CENTAUR_PROD_KUBE_CONTEXT" \ + -n "$namespace" \ + get secret "$secret_name" \ + -o "jsonpath={.data.${secret_key}}" | + python3 -c 'import base64, sys; sys.stdout.write(base64.b64decode(sys.stdin.read()).decode())' + return + fi + + die "set CENTAUR_PROD_DATABASE_URL to a read-only production ai_v2 DSN" +} + +assert_import_target_is_local() { + local source_url="${1:-}" + local target_url="$2" + + require_command python3 + python3 - "$source_url" "$target_url" "${ALLOW_NONLOCAL_TARGET:-0}" <<'PY' +import sys +from urllib.parse import urlparse + +source = urlparse(sys.argv[1]) if sys.argv[1] else None +target = urlparse(sys.argv[2]) +allow_nonlocal = sys.argv[3].lower() in {"1", "true", "yes"} + +local_hosts = { + "", + "localhost", + "127.0.0.1", + "::1", + "codex-centaur-console-db", + "host.docker.internal", +} +target_host = target.hostname or "" +target_db = (target.path or "").lstrip("/") + +if not allow_nonlocal and target_host not in local_hosts: + raise SystemExit( + f"target host {target_host!r} is not local; set ALLOW_NONLOCAL_TARGET=1 to override" + ) + +if source: + source_db = (source.path or "").lstrip("/") + same_host = (source.hostname or "") == target_host + same_port = (source.port or 5432) == (target.port or 5432) + same_db = source_db == target_db + if same_host and same_port and same_db: + raise SystemExit("source and target appear to point at the same database") +PY +} + +copy_to_csv() { + local database_url="$1" + local output_file="$2" + local sql="$3" + + PGOPTIONS="-c default_transaction_read_only=on -c statement_timeout=600000" \ + psql --no-psqlrc -X "$database_url" \ + -v ON_ERROR_STOP=1 \ + -c "copy ($sql) to stdout with (format csv, header true, force_quote *);" \ + > "$output_file" +} + +create_snapshot() { + local source_url="$1" + local snapshot_dir="$2" + + require_command psql + mkdir -p "$snapshot_dir" + + local recent_sessions_sql=" + select thread_key + from sessions + order by coalesce(updated_at, created_at) desc, thread_key + limit ${THREAD_LIMIT} + " + + info "exporting ${THREAD_LIMIT} recent sessions" + copy_to_csv "$source_url" "$snapshot_dir/sessions.csv" " + with recent_sessions as (${recent_sessions_sql}) + select + s.thread_key, + s.sandbox_id, + s.harness_type, + s.harness_thread_id, + s.iron_control_principal, + s.persona_id, + s.status, + s.metadata, + s.created_at, + s.updated_at + from sessions s + join recent_sessions r using (thread_key) + order by coalesce(s.updated_at, s.created_at) desc, s.thread_key + " + + info "exporting up to ${MESSAGE_LIMIT_PER_THREAD} messages per thread" + copy_to_csv "$source_url" "$snapshot_dir/session_messages.csv" " + with recent_sessions as (${recent_sessions_sql}), + ranked as ( + select + m.message_id, + m.thread_key, + m.client_message_id, + m.role, + m.parts, + m.metadata, + m.created_at, + row_number() over ( + partition by m.thread_key + order by m.created_at desc, m.message_id desc + ) as rn + from session_messages m + join recent_sessions r using (thread_key) + ) + select message_id, thread_key, client_message_id, role, parts, metadata, created_at + from ranked + where rn <= ${MESSAGE_LIMIT_PER_THREAD} + order by thread_key, created_at, message_id + " + + info "exporting up to ${EXECUTION_LIMIT_PER_THREAD} executions per thread" + copy_to_csv "$source_url" "$snapshot_dir/session_executions.csv" " + with recent_sessions as (${recent_sessions_sql}), + ranked as ( + select + e.execution_id, + e.thread_key, + e.idempotency_key, + e.status, + e.metadata, + e.error, + e.created_at, + e.updated_at, + e.started_at, + e.completed_at, + row_number() over ( + partition by e.thread_key + order by e.created_at desc, e.execution_id desc + ) as rn + from session_executions e + join recent_sessions r using (thread_key) + ) + select + execution_id, + thread_key, + idempotency_key, + status, + metadata, + error, + created_at, + updated_at, + started_at, + completed_at + from ranked + where rn <= ${EXECUTION_LIMIT_PER_THREAD} + order by thread_key, created_at, execution_id + " + + info "exporting up to ${EVENT_LIMIT_PER_THREAD} terminal events and ${THINKING_EVENT_LIMIT_PER_THREAD} reasoning lines per thread" + copy_to_csv "$source_url" "$snapshot_dir/session_events.csv" " + with recent_sessions as (${recent_sessions_sql}), + ranked as ( + select + ev.thread_key, + ev.execution_id, + ev.event_type, + ev.payload, + ev.created_at, + row_number() over ( + partition by ev.thread_key + order by ev.event_id desc + ) as rn + from session_events ev + join recent_sessions r using (thread_key) + where ev.event_type in ( + 'session.execution_completed', + 'session.execution_failed', + 'session.execution_cancelled' + ) + ), + -- Reasoning traces live in the session.output.line firehose as + -- item/completed notifications for reasoning items. The LIKE filter keeps + -- the export from paging every stdout line; Console re-filters exactly. + ranked_thinking as ( + select + ev.thread_key, + ev.execution_id, + ev.event_type, + ev.payload, + ev.created_at, + row_number() over ( + partition by ev.thread_key + order by ev.event_id desc + ) as rn + from session_events ev + join recent_sessions r using (thread_key) + where ev.event_type = 'session.output.line' + and ev.payload::text like '%reasoning%' + ) + select thread_key, execution_id, event_type, payload, created_at + from ( + select thread_key, execution_id, event_type, payload, created_at + from ranked + where rn <= ${EVENT_LIMIT_PER_THREAD} + union all + select thread_key, execution_id, event_type, payload, created_at + from ranked_thinking + where rn <= ${THINKING_EVENT_LIMIT_PER_THREAD} + ) combined + order by thread_key, created_at + " + + info "exporting Slack users referenced by mirrored threads" + copy_to_csv "$source_url" "$snapshot_dir/slack_sync_users.csv" " + with recent_sessions as (${recent_sessions_sql}), + message_mentions as ( + select coalesce(mention.match[1], mention.match[2]) as user_id + from session_messages m + join recent_sessions r using (thread_key) + cross join lateral regexp_matches( + m.parts::text, + '<@([UW][A-Z0-9]+)(?:\\|[^>]+)?>|@([UW][A-Z0-9]+)', + 'g' + ) as mention(match) + ), + event_mentions as ( + select coalesce(mention.match[1], mention.match[2]) as user_id + from session_events ev + join recent_sessions r using (thread_key) + cross join lateral regexp_matches( + ev.payload::text, + '<@([UW][A-Z0-9]+)(?:\\|[^>]+)?>|@([UW][A-Z0-9]+)', + 'g' + ) as mention(match) + ), + metadata_users as ( + select s.metadata ->> key.name as user_id + from sessions s + join recent_sessions r using (thread_key) + cross join (values ('slack_user_id'), ('user_id'), ('actor_user_id')) as key(name) + union all + select m.metadata ->> key.name as user_id + from session_messages m + join recent_sessions r using (thread_key) + cross join (values ('slack_user_id'), ('user_id'), ('actor_user_id')) as key(name) + ), + referenced_users as ( + select distinct nullif(user_id, '') as user_id + from ( + select user_id from message_mentions + union all + select user_id from event_mentions + union all + select user_id from metadata_users + ) ids + where nullif(user_id, '') is not null + ) + select + u.user_id, + u.user_name, + u.real_name, + u.display_name, + u.is_bot, + u.is_deleted, + u.team_id, + u.raw_payload, + u.first_seen_at, + u.last_seen_at, + u.updated_at + from slack_sync_users u + join referenced_users r using (user_id) + order by u.user_id + " + + { + echo "created_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "thread_limit=${THREAD_LIMIT}" + echo "message_limit_per_thread=${MESSAGE_LIMIT_PER_THREAD}" + echo "execution_limit_per_thread=${EXECUTION_LIMIT_PER_THREAD}" + echo "event_limit_per_thread=${EVENT_LIMIT_PER_THREAD}" + echo "thinking_event_limit_per_thread=${THINKING_EVENT_LIMIT_PER_THREAD}" + echo "slack_sync_users=referenced" + } > "$snapshot_dir/manifest.env" + + info "snapshot written to $snapshot_dir" +} + +write_import_sql() { + local import_root="$1" + local output_file="$2" + local sessions_csv messages_csv executions_csv events_csv slack_users_csv + sessions_csv="$(sql_quote_path "$import_root/sessions.csv")" + messages_csv="$(sql_quote_path "$import_root/session_messages.csv")" + executions_csv="$(sql_quote_path "$import_root/session_executions.csv")" + events_csv="$(sql_quote_path "$import_root/session_events.csv")" + slack_users_csv="$(sql_quote_path "$import_root/slack_sync_users.csv")" + + cat > "$output_file" <> "$output_file" <<'SQL' +truncate table session_events, session_messages, session_executions, sessions cascade; + +SQL + fi + + cat >> "$output_file" <<'SQL' +create table if not exists slack_sync_users ( + user_id text primary key, + user_name text not null default '', + real_name text not null default '', + display_name text not null default '', + is_bot boolean not null default false, + is_deleted boolean not null default false, + team_id text not null default '', + raw_payload jsonb not null default '{}'::jsonb, + first_seen_at timestamptz not null default now(), + last_seen_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_slack_sync_users_real_name + on slack_sync_users (real_name); + +insert into sessions ( + thread_key, + sandbox_id, + harness_type, + harness_thread_id, + iron_control_principal, + persona_id, + status, + metadata, + created_at, + updated_at +) +select + thread_key, + sandbox_id, + harness_type, + harness_thread_id, + iron_control_principal, + persona_id, + status, + metadata, + created_at, + updated_at +from import_sessions +on conflict (thread_key) do update set + sandbox_id = excluded.sandbox_id, + harness_type = excluded.harness_type, + harness_thread_id = excluded.harness_thread_id, + iron_control_principal = excluded.iron_control_principal, + persona_id = excluded.persona_id, + status = excluded.status, + metadata = excluded.metadata, + created_at = excluded.created_at, + updated_at = excluded.updated_at; + +insert into session_messages ( + message_id, + thread_key, + client_message_id, + role, + parts, + metadata, + created_at +) +select + message_id, + thread_key, + client_message_id, + role, + parts, + metadata, + created_at +from import_session_messages +on conflict (message_id) do update set + thread_key = excluded.thread_key, + client_message_id = excluded.client_message_id, + role = excluded.role, + parts = excluded.parts, + metadata = excluded.metadata, + created_at = excluded.created_at; + +insert into session_executions ( + execution_id, + thread_key, + idempotency_key, + status, + metadata, + error, + created_at, + updated_at, + started_at, + completed_at +) +select + execution_id, + thread_key, + idempotency_key, + status, + metadata, + error, + created_at, + updated_at, + started_at, + completed_at +from import_session_executions +on conflict (execution_id) do update set + thread_key = excluded.thread_key, + idempotency_key = excluded.idempotency_key, + status = excluded.status, + metadata = excluded.metadata, + error = excluded.error, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + started_at = excluded.started_at, + completed_at = excluded.completed_at; + +insert into session_events ( + thread_key, + execution_id, + event_type, + payload, + created_at +) +select + ev.thread_key, + ev.execution_id, + ev.event_type, + ev.payload, + ev.created_at +from import_session_events ev +where ev.execution_id is null + or exists ( + select 1 + from session_executions imported_execution + where imported_execution.execution_id = ev.execution_id + ); + +insert into slack_sync_users ( + user_id, + user_name, + real_name, + display_name, + is_bot, + is_deleted, + team_id, + raw_payload, + first_seen_at, + last_seen_at, + updated_at +) +select + user_id, + coalesce(user_name, ''), + coalesce(real_name, ''), + coalesce(display_name, ''), + coalesce(is_bot, false), + coalesce(is_deleted, false), + coalesce(team_id, ''), + coalesce(raw_payload, '{}'::jsonb), + coalesce(first_seen_at, now()), + coalesce(last_seen_at, now()), + coalesce(updated_at, now()) +from import_slack_sync_users +where nullif(user_id, '') is not null +on conflict (user_id) do update set + user_name = excluded.user_name, + real_name = excluded.real_name, + display_name = excluded.display_name, + is_bot = excluded.is_bot, + is_deleted = excluded.is_deleted, + team_id = excluded.team_id, + raw_payload = excluded.raw_payload, + first_seen_at = excluded.first_seen_at, + last_seen_at = excluded.last_seen_at, + updated_at = excluded.updated_at; + +analyze sessions; +analyze session_messages; +analyze session_executions; +analyze session_events; +analyze slack_sync_users; + +commit; +SQL +} + +import_snapshot() { + local snapshot_dir="$1" + local target_url="$2" + + [[ -f "$snapshot_dir/sessions.csv" ]] || die "missing $snapshot_dir/sessions.csv" + [[ -f "$snapshot_dir/session_messages.csv" ]] || die "missing $snapshot_dir/session_messages.csv" + [[ -f "$snapshot_dir/session_executions.csv" ]] || die "missing $snapshot_dir/session_executions.csv" + [[ -f "$snapshot_dir/session_events.csv" ]] || die "missing $snapshot_dir/session_events.csv" + [[ -f "$snapshot_dir/slack_sync_users.csv" ]] || die "missing $snapshot_dir/slack_sync_users.csv" + + local local_container="${CENTAUR_LOCAL_DB_CONTAINER:-codex-centaur-console-db}" + local use_container="${USE_LOCAL_DB_CONTAINER:-auto}" + local import_root="$snapshot_dir" + local import_sql="$snapshot_dir/import.sql" + + if [[ "$use_container" == "auto" ]] && command -v docker >/dev/null 2>&1 \ + && docker inspect "$local_container" >/dev/null 2>&1; then + use_container="1" + fi + + if [[ "$use_container" == "1" || "$use_container" == "true" ]]; then + require_command docker + import_root="/tmp/centaur-thread-snapshot" + write_import_sql "$import_root" "$import_sql" + + info "copying snapshot into local database container $local_container" + docker exec "$local_container" sh -c "rm -rf '$import_root' && mkdir -p '$import_root'" + docker cp "$snapshot_dir/." "$local_container:$import_root/" + + info "importing snapshot into local target via $local_container" + docker exec -i "$local_container" psql --no-psqlrc -X "$target_url" < "$import_sql" + else + require_command psql + write_import_sql "$import_root" "$import_sql" + + info "importing snapshot into local target" + psql --no-psqlrc -X "$target_url" < "$import_sql" + fi +} + +main() { + local mode="${1:-all}" + if [[ "$mode" == "-h" || "$mode" == "--help" ]]; then + usage + exit 0 + fi + [[ "$mode" =~ ^(all|snapshot|import)$ ]] || die "unknown mode: $mode" + + THREAD_LIMIT="${THREAD_LIMIT:-250}" + MESSAGE_LIMIT_PER_THREAD="${MESSAGE_LIMIT_PER_THREAD:-120}" + EXECUTION_LIMIT_PER_THREAD="${EXECUTION_LIMIT_PER_THREAD:-20}" + EVENT_LIMIT_PER_THREAD="${EVENT_LIMIT_PER_THREAD:-40}" + THINKING_EVENT_LIMIT_PER_THREAD="${THINKING_EVENT_LIMIT_PER_THREAD:-200}" + TRUNCATE_LOCAL_SESSION_TABLES="${TRUNCATE_LOCAL_SESSION_TABLES:-1}" + + validate_integer THREAD_LIMIT "$THREAD_LIMIT" + validate_integer MESSAGE_LIMIT_PER_THREAD "$MESSAGE_LIMIT_PER_THREAD" + validate_integer EXECUTION_LIMIT_PER_THREAD "$EXECUTION_LIMIT_PER_THREAD" + validate_integer EVENT_LIMIT_PER_THREAD "$EVENT_LIMIT_PER_THREAD" + + local snapshot_dir="${SNAPSHOT_DIR:-}" + if [[ -z "$snapshot_dir" ]]; then + snapshot_dir="$(mktemp -d "${TMPDIR:-/tmp}/centaur-thread-snapshot.XXXXXX")" + fi + + local source_url="" + if [[ "$mode" == "all" || "$mode" == "snapshot" ]]; then + source_url="$(resolve_source_database_url)" + fi + + local target_url="${CENTAUR_LOCAL_CENTAUR_DATABASE_URL:-${CENTAUR_CONSOLE_CENTAUR_DATABASE_URL:-${TARGET_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/ai_v2}}}" + if [[ "$mode" == "all" || "$mode" == "import" ]]; then + assert_import_target_is_local "$source_url" "$target_url" + fi + + if [[ "$mode" == "all" || "$mode" == "snapshot" ]]; then + create_snapshot "$source_url" "$snapshot_dir" + fi + + if [[ "$mode" == "all" || "$mode" == "import" ]]; then + import_snapshot "$snapshot_dir" "$target_url" + info "import complete" + info "restart Console with CENTAUR_CONSOLE_THREADS_READ_ONLY=1 before browsing mirrored data" + fi +} + +main "$@" diff --git a/services/console/README.md b/services/console/README.md index fabdc0dc3..6c279e285 100644 --- a/services/console/README.md +++ b/services/console/README.md @@ -30,6 +30,43 @@ Operators manage credentials, principals, roles, and grants through the API or t All of the console's environment variables use the `CENTAUR_CONSOLE_` prefix. For backwards compatibility, every variable also resolves from the legacy `IRON_CONTROL_` name when the `CENTAUR_CONSOLE_` one is unset, so existing deployments keep working until they migrate. The `CENTAUR_CONSOLE_` name wins when both are set. +The Threads tab reads api-rs session rows from the Centaur API database. Set +`CENTAUR_CONSOLE_CENTAUR_DATABASE_URL` to that database URL when it differs from +the console's primary database. In the Helm chart this is sourced from the +shared `DATABASE_URL` secret key. + +For local development, sign in through the normal login form at +`http://localhost:3000/login` with the seeded initial user's +`CENTAUR_CONSOLE_INITIAL_USER_EMAIL` / `CENTAUR_CONSOLE_INITIAL_USER_PASSWORD` +credentials, the same as every other environment. + +To build the Threads UX against production-shaped data without connecting the +Console to production, create a bounded local snapshot: + +```bash +export CENTAUR_PROD_DATABASE_URL=postgresql://readonly:...@.../ai_v2 +scripts/mirror-prod-threads-snapshot.sh all +``` + +The script exports recent `sessions`, `session_messages`, +`session_executions`, terminal `session_events` plus reasoning +`session.output.line` events (capped by `THINKING_EVENT_LIMIT_PER_THREAD`, +default 200 per thread), and referenced `slack_sync_users` rows with the source +connection forced read-only, then imports them into the local `ai_v2` database +used by the Console dev container. The Threads surface is read-only: it does +not render a composer and rejects POSTs server-side. + +Threads extras beyond the Slack surface: + +- Thinking traces: reasoning items the harness streamed over stdout are + persisted by api-rs as `session.output.line` events; the transcript renders + each completed reasoning block as a collapsed "Thinking" disclosure. +- Split view: Cmd/Ctrl-click a sidebar thread to open it alongside the current + one, up to four threads in a grid. The `thread` param carries the open keys + comma-separated (`?thread=,,,`), primary first. + All keys resolve through the same owner scope as a single thread, and each + panel has a close control. + ## First Boot The console requires an authenticated user and API key before any API endpoint will respond. To bootstrap a fresh deployment without a console, set the following environment variables on startup: diff --git a/services/console/app/assets/tailwind/application.css b/services/console/app/assets/tailwind/application.css index 9f2af7945..8e7455b9e 100644 --- a/services/console/app/assets/tailwind/application.css +++ b/services/console/app/assets/tailwind/application.css @@ -44,6 +44,30 @@ @apply mt-1 text-sm text-zinc-500; } + .console-page-header { + @apply mb-6 flex min-h-11 items-start justify-between gap-4; + } + + .console-page-heading { + @apply flex min-w-0 items-start gap-3; + } + + .console-page-icon { + @apply mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-ink-850/75 text-zinc-400; + } + + .console-page-title-copy { + @apply min-w-0; + } + + .console-page-actions { + @apply flex shrink-0 items-center gap-2; + } + + .console-page-meta { + @apply pt-1 text-xs text-zinc-500; + } + .back-link { @apply text-xs text-zinc-500 hover:text-centaur-400; } diff --git a/services/console/app/controllers/application_controller.rb b/services/console/app/controllers/application_controller.rb index c54547a9e..997ff1b2b 100644 --- a/services/console/app/controllers/application_controller.rb +++ b/services/console/app/controllers/application_controller.rb @@ -37,6 +37,25 @@ def oauth_callback_redirect_uri(slug) # and pending controllers skip this so pending users can reach the holding page # and sign out. before_action :require_active_account + # The sidebar thread list is global chrome (rendered by layouts/console.html.erb + # on every page), but populating it issues several queries against the api-rs + # ai_v2 sessions DB, including an unindexed sequential scan + sort of the + # sessions table. Running that in every console request blocked pages that only + # render the empty-state list (principals, roles, secrets, ...). Instead we + # initialize the ivars empty here and load the real list lazily via a Turbo + # Frame (Console::ThreadsController#sidebar), so the cross-database work happens + # once, out of band, and never blocks the primary page render. + before_action :init_console_sidebar_threads + + CONSOLE_SIDEBAR_THREAD_LIMIT = 30 + CONSOLE_SIDEBAR_SLACK_PROVIDER = Oauth::Providers::Slack::KEY + CONSOLE_SIDEBAR_SLACK_THREAD_OWNER_METADATA_KEYS = %w[slack_user_id actor_user_id user_id].freeze + CONSOLE_SIDEBAR_SLACK_THREAD_TEAM_METADATA_KEYS = %w[slack_team_id team_id home_team_id].freeze + CONSOLE_SIDEBAR_SLACK_CREDENTIAL_USER_LABEL_KEYS = %w[slack_user_id].freeze + CONSOLE_SIDEBAR_SLACK_CREDENTIAL_EMAIL_LABEL_KEYS = %w[email slack_email].freeze + CONSOLE_SIDEBAR_SLACK_TEAM_LABEL = "slack_team_id".freeze + CONSOLE_SIDEBAR_THREAD_OWNER_METADATA_KEYS = %w[actor_email user_email].freeze + ConsoleSidebarSlackThreadOwner = Struct.new(:user_id, :team_id, keyword_init: true) private @@ -69,10 +88,34 @@ def require_admin redirect_to root_path, alert: "That page is restricted to admins." unless current_user&.admin? end + # Cheap default so every page renders the empty sidebar list without touching + # the sessions DB. The real list is filled in by #load_console_sidebar_threads, + # invoked only from the lazy sidebar Turbo Frame. + def init_console_sidebar_threads + @console_sidebar_threads = [] + @console_sidebar_latest_messages = {} + end + + def load_console_sidebar_threads + @console_sidebar_threads = [] + @console_sidebar_latest_messages = {} + return unless current_user&.active? + + threads = console_sidebar_visible_thread_scope + .recent_first + .limit(CONSOLE_SIDEBAR_THREAD_LIMIT) + .to_a + threads = console_sidebar_threads_with_direct_selection(threads) + @console_sidebar_threads = threads + @console_sidebar_latest_messages = console_sidebar_latest_messages_for(threads.map(&:thread_key)) + rescue ActiveRecord::ActiveRecordError, PG::Error => e + Rails.logger.debug("console_sidebar_threads_unavailable error=#{e.class}: #{e.message}") + end + # Establishes the console cookie session and sends the user to the right # post-login page. Password login re-renders for disabled accounts; SSO login # redirects because it is returning from an external provider. - def sign_in_console_user(user, disabled: :redirect) + def sign_in_console_user(user, disabled: :redirect, destination: nil) if user.disabled? if disabled == :render flash.now[:alert] = "Your account is disabled." @@ -87,7 +130,7 @@ def sign_in_console_user(user, disabled: :redirect) session[:user_id] = user.id session[:return_to] = return_to if return_to.present? if user.active? - redirect_to post_login_redirect_path, notice: "Signed in as #{user.email}." + redirect_to(destination.presence || post_login_redirect_path, notice: "Signed in as #{user.email}.") else redirect_to pending_path, notice: "Your account is awaiting approval." end @@ -99,7 +142,203 @@ def post_login_redirect_path path end + def safe_console_return_path(default: console_principals_path) + raw = params[:return_to].presence || params[:next].presence + return default if raw.blank? + + uri = URI.parse(raw.to_s) + return default if uri.scheme.present? || uri.host.present? + + path = uri.path.presence + return default unless path == "/" || path&.start_with?("/console") + + uri.to_s + rescue URI::InvalidURIError + default + end + def render_not_found(e) render plain: e.message, status: :not_found end + + def console_sidebar_visible_thread_scope + slack_owners = console_sidebar_slack_thread_owners_for_current_user + conditions = [ + console_sidebar_console_thread_owner_sql, + (console_sidebar_slack_thread_owner_sql(slack_owners) if slack_owners.any?) + ].compact + + return CentaurSession.where("1=0") if conditions.empty? + + CentaurSession.where(conditions.map { |condition| "(#{condition})" }.join(" OR ")) + end + + def console_sidebar_threads_with_direct_selection(threads) + selected = console_sidebar_direct_selected_threads(threads) + selected.any? ? [ *selected, *threads ] : threads + end + + def console_sidebar_direct_selected_threads(threads) + thread_keys = console_sidebar_selected_thread_keys - threads.map(&:thread_key) + return [] if thread_keys.empty? + + # Resolve through the owner scope, not a raw find_by, so a directly linked + # thread only surfaces in the sidebar when the current user started it. This + # mirrors Console::ThreadsController#selected_session. + console_sidebar_visible_thread_scope.where(thread_key: thread_keys).to_a + end + + # The thread param carries up to PANEL_LIMIT comma-separated keys when the + # split view is open; every open thread should surface and highlight. + def console_sidebar_selected_thread_keys + return [] unless params[:controller] == "console/threads" + + params[:thread].to_s.split(",").map(&:strip).reject(&:blank?).uniq + .first(Console::ThreadsController::PANEL_LIMIT) + end + + def console_sidebar_console_thread_owner_sql + email = console_sidebar_normalize_email(current_user&.email) + return if email.blank? + + console_source = [ + "thread_key LIKE 'console:%'", + "metadata ->> 'platform' = 'console'", + "metadata ->> 'source' = 'console'" + ].join(" OR ") + owner_clauses = CONSOLE_SIDEBAR_THREAD_OWNER_METADATA_KEYS.map do |key| + "lower(metadata ->> #{console_sidebar_sql_quote(key)}) = #{console_sidebar_sql_quote(email)}" + end + + "(#{console_source}) AND (#{owner_clauses.join(" OR ")})" + end + + def console_sidebar_slack_thread_owners_for_current_user + @console_sidebar_slack_thread_owners_for_current_user ||= begin + subjects = console_sidebar_slack_identity_subjects_for_current_user + emails = console_sidebar_slack_identity_emails_for_current_user + + if subjects.empty? && emails.empty? + [] + else + credentials = BrokerCredential + .joins(:oauth_app) + .includes(:oauth_app) + .where(oauth_apps: { provider: CONSOLE_SIDEBAR_SLACK_PROVIDER }) + .where(console_sidebar_slack_oauth_credential_owner_sql(subjects: subjects, emails: emails)) + + credentials.filter_map do |credential| + user_id = console_sidebar_first_present( + credential.provider_subject, + *CONSOLE_SIDEBAR_SLACK_CREDENTIAL_USER_LABEL_KEYS.map { |key| credential.labels&.[](key) } + ) + next if user_id.blank? + + ConsoleSidebarSlackThreadOwner.new( + user_id: user_id, + team_id: console_sidebar_first_present( + credential.labels&.[](CONSOLE_SIDEBAR_SLACK_TEAM_LABEL), + credential.oauth_app&.labels&.[](CONSOLE_SIDEBAR_SLACK_TEAM_LABEL) + ) + ) + end.uniq { |owner| [ console_sidebar_normalize_key(owner.user_id), console_sidebar_normalize_key(owner.team_id) ] } + end + end + end + + def console_sidebar_slack_identity_subjects_for_current_user + current_user.user_identities + .where(provider: CONSOLE_SIDEBAR_SLACK_PROVIDER) + .pluck(:subject) + .filter_map { |value| console_sidebar_normalize_key(value) } + .uniq + end + + def console_sidebar_slack_identity_emails_for_current_user + ([ current_user.email ] + current_user.user_identities.where(provider: CONSOLE_SIDEBAR_SLACK_PROVIDER).pluck(:email)) + .filter_map { |value| console_sidebar_normalize_email(value) } + .uniq + end + + def console_sidebar_slack_oauth_credential_owner_sql(subjects:, emails:) + clauses = [] + if subjects.any? + subject_values = console_sidebar_sql_list(subjects) + clauses << "lower(broker_credentials.provider_subject) IN (#{subject_values})" + CONSOLE_SIDEBAR_SLACK_CREDENTIAL_USER_LABEL_KEYS.each do |key| + clauses << "lower(broker_credentials.labels ->> #{console_sidebar_sql_quote(key)}) IN (#{subject_values})" + end + end + + if emails.any? + email_values = console_sidebar_sql_list(emails) + clauses << "lower(broker_credentials.provider_email) IN (#{email_values})" + CONSOLE_SIDEBAR_SLACK_CREDENTIAL_EMAIL_LABEL_KEYS.each do |key| + clauses << "lower(broker_credentials.labels ->> #{console_sidebar_sql_quote(key)}) IN (#{email_values})" + end + end + + clauses.join(" OR ") + end + + def console_sidebar_slack_thread_owner_sql(owners) + slack_source = [ + "thread_key LIKE 'slack:%'", + "metadata ->> 'platform' = 'slack'", + "metadata ->> 'source' = 'slackbotv2'" + ].join(" OR ") + + owner_clauses = owners.map do |owner| + user_id = console_sidebar_normalize_key(owner.user_id) + user_clauses = CONSOLE_SIDEBAR_SLACK_THREAD_OWNER_METADATA_KEYS.map do |key| + "lower(metadata ->> #{console_sidebar_sql_quote(key)}) = #{console_sidebar_sql_quote(user_id)}" + end + owner_clause = "(#{user_clauses.join(" OR ")})" + + # Team scoping narrows the match only when the credential exposes a team; + # see Console::ThreadsController#slack_thread_owner_sql. + if owner.team_id.present? + team_id = console_sidebar_normalize_key(owner.team_id) + team_clauses = CONSOLE_SIDEBAR_SLACK_THREAD_TEAM_METADATA_KEYS.map do |key| + "lower(metadata ->> #{console_sidebar_sql_quote(key)}) = #{console_sidebar_sql_quote(team_id)}" + end + team_clauses << "lower(split_part(thread_key, ':', 2)) = #{console_sidebar_sql_quote(team_id)}" + owner_clause = "(#{owner_clause} AND (#{team_clauses.join(" OR ")}))" + end + + owner_clause + end + + "(#{slack_source}) AND (#{owner_clauses.join(" OR ")})" + end + + def console_sidebar_latest_messages_for(keys) + return {} if keys.empty? + + CentaurSessionMessage + .where(thread_key: keys) + .select("distinct on (thread_key) session_messages.*") + .order(Arel.sql("thread_key, created_at desc, message_id desc")) + .index_by(&:thread_key) + end + + def console_sidebar_first_present(*values) + values.find(&:present?) + end + + def console_sidebar_normalize_key(value) + value.to_s.strip.downcase.presence + end + + def console_sidebar_normalize_email(value) + value.to_s.strip.downcase.presence + end + + def console_sidebar_sql_list(values) + values.map { |value| console_sidebar_sql_quote(value) }.join(", ") + end + + def console_sidebar_sql_quote(value) + ActiveRecord::Base.connection.quote(value.to_s) + end end diff --git a/services/console/app/controllers/console/threads_controller.rb b/services/console/app/controllers/console/threads_controller.rb new file mode 100644 index 000000000..ff986bac9 --- /dev/null +++ b/services/console/app/controllers/console/threads_controller.rb @@ -0,0 +1,1061 @@ +class Console::ThreadsController < ApplicationController + layout "console" + + THREAD_LIMIT = 250 + MESSAGE_LIMIT = 80 + EXECUTION_LIMIT = 8 + TRANSCRIPT_EVENT_LIMIT = 80 + PANEL_LIMIT = 4 + THINKING_EVENT_LIMIT = 200 + # Messages and thinking precede the terminal event for a same-timestamp tie. + TRANSCRIPT_SOURCE_ORDER = { message: 0, thinking: 1, event: 2 }.freeze + SLACK_PROVIDER = Oauth::Providers::Slack::KEY + SLACK_THREAD_OWNER_METADATA_KEYS = %w[slack_user_id actor_user_id user_id].freeze + SLACK_THREAD_TEAM_METADATA_KEYS = %w[slack_team_id team_id home_team_id].freeze + SLACK_CREDENTIAL_USER_LABEL_KEYS = %w[slack_user_id].freeze + SLACK_CREDENTIAL_EMAIL_LABEL_KEYS = %w[email slack_email].freeze + SLACK_TEAM_LABEL = "slack_team_id" + CONSOLE_THREAD_OWNER_METADATA_KEYS = %w[actor_email user_email].freeze + SLACK_USER_ID_PATTERN = /\A[UW][A-Z0-9]+\z/.freeze + SLACK_MENTION_PATTERN = /<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|@([UW][A-Z0-9]+)/.freeze + READ_ONLY_REASON = + "Chats are read-only while browsing a mirrored production snapshot.".freeze + # Deploy-time default-model overrides: the same env vars deployers set in + # sandbox.extraEnv to change the harness model, mirrored onto the Console by + # the chart. Amp has no fixed default model, so it is intentionally absent. + HARNESS_DEFAULT_MODEL_ENVS = { + "claudecode" => "CLAUDE_MODEL", + "codex" => "CODEX_MODEL" + }.freeze + # Harness config files carrying each harness's baked-in default model, used + # when no env override is set. Resolved against CENTAUR_HARNESS_CONFIG_DIR + # (the sandbox entrypoint's variable) or the repo checkout's harness/ + # directory; absent files (e.g. in the production image, whose build context + # is services/console) simply yield no default. + HARNESS_CONFIG_FILES = { + "claudecode" => "claude/settings.json", + "codex" => "codex/config.toml" + }.freeze + + SlackThreadOwner = Struct.new(:user_id, :team_id, keyword_init: true) + + helper_method :thread_title, + :thread_source_icon, + :thread_source_label, + :thread_harness_label, + :thread_model_label, + :thread_user_label, + :thread_message_text, + :thread_text_preview, + :thread_status_classes + + def index + @query = params[:q].to_s.strip + requested_keys = requested_thread_keys + @selected_thread_key = requested_keys.first.to_s + @pane_thread_keys = requested_keys.drop(1) + @starting_new_thread = params[:new].present? + @thread_db_unavailable = false + @thread_not_found = false + + load_threads + if @thread_not_found + render status: :not_found + return + end + redirect_to_first_thread if auto_select_first_thread? + rescue ActiveRecord::ActiveRecordError, PG::Error => e + Rails.logger.warn("console_threads_load_failed error=#{e.class}: #{e.message}") + empty_thread_state + @thread_db_unavailable = true + end + + def create + redirect_to( + console_threads_path(thread: params[:thread_key].presence), + alert: READ_ONLY_REASON + ) + end + + # Lazily-loaded sidebar thread list, requested by the Turbo Frame in + # layouts/console.html.erb. Runs the cross-database sessions query out of band + # so it never blocks the primary page render. Renders only the frame partial + # (no layout). DB errors leave the list empty via load_console_sidebar_threads. + def sidebar + load_console_sidebar_threads + render partial: "console/threads/sidebar_threads", layout: false + end + + private + + def load_threads + session_scope = visible_thread_scope + base_sessions = session_scope.recent_first.limit(THREAD_LIMIT).to_a + keys = base_sessions.map(&:thread_key).uniq + + @latest_messages = latest_messages_for(keys) + @latest_executions = latest_executions_for(keys) + @message_counts = count_records(CentaurSessionMessage, keys) + @execution_counts = count_records(CentaurSessionExecution, keys) + + @sessions = base_sessions.select { |session| matches_query?(session) } + @selected_session = selected_session(session_scope, base_sessions) + if @thread_not_found + @pane_sessions = [] + @thread_panels = [] + @selected_messages = [] + @selected_executions = [] + @selected_events = [] + @selected_transcript_items = [] + return + end + @pane_sessions = resolve_pane_sessions(session_scope, base_sessions) + load_selected_session_summaries(keys) + @selected_thread_key = @selected_session&.thread_key.to_s + @thread_panels = build_thread_panels + @selected_transcript_items = @thread_panels.first&.dig(:transcript_items) || [] + end + + def empty_thread_state + @thread_not_found = false + @sessions = [] + @selected_session = nil + @pane_sessions = [] + @thread_panels = [] + @selected_messages = [] + @selected_executions = [] + @selected_events = [] + @selected_transcript_items = [] + @latest_messages = {} + @latest_executions = {} + @message_counts = {} + @execution_counts = {} + end + + def matches_query?(session) + return true if @query.blank? + + needle = @query.downcase + [ + session.thread_key, + thread_title(session), + thread_source_label(session), + thread_user_label(session), + thread_text_preview(@latest_messages[session.thread_key]) + ].any? { |value| value.to_s.downcase.include?(needle) } + end + + def selected_session(session_scope, base_sessions) + return nil if @starting_new_thread + + if @selected_thread_key.present? + selected = base_sessions.find { |session| session.thread_key == @selected_thread_key } + # Resolve the key through the owner scope so a directly linked chat only + # loads when the current user started it. base_sessions is capped at + # THREAD_LIMIT, so this also recovers an owned thread beyond that window. + selected ||= session_scope.where(thread_key: @selected_thread_key).first + # A directly requested key outside the owner scope renders as 404 rather + # than silently falling back to another chat, so nonexistent and + # inaccessible chats are indistinguishable to the viewer. + @thread_not_found = selected.nil? + return selected + end + @sessions.first + end + + def auto_select_first_thread? + params[:thread].blank? && !@starting_new_thread && @query.blank? && @selected_session.present? + end + + # The thread param carries up to PANEL_LIMIT comma-separated thread keys; the + # first is the primary thread and the rest are extra split-view panes + # (Cmd/Ctrl-click on a sidebar thread appends its key). + def requested_thread_keys + params[:thread].to_s.split(",").map(&:strip).reject(&:blank?).uniq.first(PANEL_LIMIT) + end + + # Extra split-view panes resolve through the same owner scope as the primary + # thread, so a crafted ?thread= list cannot surface another user's thread. + # Unowned keys are dropped silently. + def resolve_pane_sessions(session_scope, base_sessions) + keys = @pane_thread_keys - [ @selected_session&.thread_key ] + keys.filter_map do |key| + base_sessions.find { |session| session.thread_key == key } || + session_scope.where(thread_key: key).first + end + end + + def build_thread_panels + sessions = ([ @selected_session ] + Array(@pane_sessions)).compact + .uniq(&:thread_key) + .first(PANEL_LIMIT) + return [] if sessions.empty? + + # Build the primary panel last so the @selected_* thread state (used by the + # page header and mention-resolution memos) ends on the primary thread. + extra_panels = sessions.drop(1).map { |session| thread_panel_for(session) } + [ thread_panel_for(sessions.first) ] + extra_panels + end + + def thread_panel_for(session) + @selected_session = session + @selected_messages = selected_messages + @selected_executions = selected_executions + @selected_events = selected_events + reset_selected_thread_memos + + { + session: session, + thread_key: session.thread_key, + transcript_items: selected_transcript_items + } + end + + # Mention labels and inferred bot ids are memoized off the selected thread's + # messages and events, so they must be recomputed per panel. + def reset_selected_thread_memos + @slack_mention_labels_by_id = nil + @slack_bot_user_ids = nil + end + + def redirect_to_first_thread + redirect_to console_threads_path(thread: @selected_session.thread_key) + end + + def load_selected_session_summaries(loaded_keys) + missing_keys = ([ @selected_session ] + Array(@pane_sessions)).compact + .map(&:thread_key) + .uniq + .reject { |key| loaded_keys.include?(key) } + return if missing_keys.empty? + + @latest_messages.merge!(latest_messages_for(missing_keys)) + @latest_executions.merge!(latest_executions_for(missing_keys)) + @message_counts.merge!(count_records(CentaurSessionMessage, missing_keys)) + @execution_counts.merge!(count_records(CentaurSessionExecution, missing_keys)) + end + + def visible_thread_scope + slack_owners = slack_thread_owners_for_current_user + conditions = [ + console_thread_owner_sql, + (slack_thread_owner_sql(slack_owners) if slack_owners.any?) + ].compact + + return CentaurSession.where("1=0") if conditions.empty? + + CentaurSession.where(conditions.map { |condition| "(#{condition})" }.join(" OR ")) + end + + def console_thread_owner_sql + email = normalize_email(current_user&.email) + return if email.blank? + + console_source = [ + "thread_key LIKE 'console:%'", + "metadata ->> 'platform' = 'console'", + "metadata ->> 'source' = 'console'" + ].join(" OR ") + owner_clauses = CONSOLE_THREAD_OWNER_METADATA_KEYS.map do |key| + "lower(metadata ->> #{sql_quote(key)}) = #{sql_quote(email)}" + end + + "(#{console_source}) AND (#{owner_clauses.join(" OR ")})" + end + + def slack_thread_owners_for_current_user + @slack_thread_owners_for_current_user ||= begin + if current_user + subjects = slack_identity_subjects_for_current_user + emails = slack_identity_emails_for_current_user + + if subjects.empty? && emails.empty? + [] + else + credentials = BrokerCredential + .joins(:oauth_app) + .includes(:oauth_app) + .where(oauth_apps: { provider: SLACK_PROVIDER }) + .where(slack_oauth_credential_owner_sql(subjects: subjects, emails: emails)) + + credentials.filter_map do |credential| + user_id = first_present( + credential.provider_subject, + *SLACK_CREDENTIAL_USER_LABEL_KEYS.map { |key| credential.labels&.[](key) } + ) + next if user_id.blank? + + SlackThreadOwner.new( + user_id: user_id, + team_id: first_present( + credential.labels&.[](SLACK_TEAM_LABEL), + credential.oauth_app&.labels&.[](SLACK_TEAM_LABEL) + ) + ) + end.uniq { |owner| [ normalize_key(owner.user_id), normalize_key(owner.team_id) ] } + end + else + [] + end + end + end + + def slack_identity_subjects_for_current_user + current_user.user_identities + .where(provider: SLACK_PROVIDER) + .pluck(:subject) + .filter_map { |value| normalize_key(value) } + .uniq + end + + def slack_identity_emails_for_current_user + ([ current_user.email ] + current_user.user_identities.where(provider: SLACK_PROVIDER).pluck(:email)) + .filter_map { |value| normalize_email(value) } + .uniq + end + + def slack_oauth_credential_owner_sql(subjects:, emails:) + clauses = [] + if subjects.any? + subject_values = sql_list(subjects) + clauses << "lower(broker_credentials.provider_subject) IN (#{subject_values})" + SLACK_CREDENTIAL_USER_LABEL_KEYS.each do |key| + clauses << "lower(broker_credentials.labels ->> #{sql_quote(key)}) IN (#{subject_values})" + end + end + + if emails.any? + email_values = sql_list(emails) + clauses << "lower(broker_credentials.provider_email) IN (#{email_values})" + SLACK_CREDENTIAL_EMAIL_LABEL_KEYS.each do |key| + clauses << "lower(broker_credentials.labels ->> #{sql_quote(key)}) IN (#{email_values})" + end + end + + clauses.join(" OR ") + end + + def slack_thread_owner_sql(owners) + slack_source = [ + "thread_key LIKE 'slack:%'", + "metadata ->> 'platform' = 'slack'", + "metadata ->> 'source' = 'slackbotv2'" + ].join(" OR ") + + owner_clauses = owners.map do |owner| + user_id = normalize_key(owner.user_id) + user_clauses = SLACK_THREAD_OWNER_METADATA_KEYS.map do |key| + "lower(metadata ->> #{sql_quote(key)}) = #{sql_quote(user_id)}" + end + owner_clause = "(#{user_clauses.join(" OR ")})" + + # Team scoping narrows the match only when the owning credential exposes a + # team. slackbotv2 uses slack:CHANNEL:TS thread keys and does not record a + # slack_team_id, so requiring a team would hide otherwise-owned threads. + if owner.team_id.present? + team_id = normalize_key(owner.team_id) + team_clauses = SLACK_THREAD_TEAM_METADATA_KEYS.map do |key| + "lower(metadata ->> #{sql_quote(key)}) = #{sql_quote(team_id)}" + end + team_clauses << "lower(split_part(thread_key, ':', 2)) = #{sql_quote(team_id)}" + owner_clause = "(#{owner_clause} AND (#{team_clauses.join(" OR ")}))" + end + + owner_clause + end + + "(#{slack_source}) AND (#{owner_clauses.join(" OR ")})" + end + + def first_present(*values) + values.find(&:present?) + end + + def normalize_key(value) + value.to_s.strip.downcase.presence + end + + def normalize_email(value) + value.to_s.strip.downcase.presence + end + + def sql_list(values) + values.map { |value| sql_quote(value) }.join(", ") + end + + def sql_quote(value) + ActiveRecord::Base.connection.quote(value.to_s) + end + + def selected_messages + return [] unless @selected_session + + # Fetch the newest MESSAGE_LIMIT messages, then reverse for oldest-first + # display. Ordering ascending before LIMIT would return the OLDEST N and + # drop the newest for long threads (mirrors selected_events below). + CentaurSessionMessage + .where(thread_key: @selected_session.thread_key) + .order(created_at: :desc, message_id: :desc) + .limit(MESSAGE_LIMIT) + .to_a + .reverse + end + + def selected_executions + return [] unless @selected_session + + CentaurSessionExecution + .where(thread_key: @selected_session.thread_key) + .order(created_at: :desc, execution_id: :desc) + .limit(EXECUTION_LIMIT) + .to_a + end + + def selected_events + return [] unless @selected_session + + CentaurSessionEvent + .where(thread_key: @selected_session.thread_key) + .where(event_type: %w[ + session.execution_completed + session.execution_failed + session.execution_cancelled + ]) + .order(event_id: :desc) + .limit(TRANSCRIPT_EVENT_LIMIT) + .to_a + .reverse + end + + def selected_transcript_items + message_items = @selected_messages.map { |message| transcript_item_for_message(message) } + + event_items = @selected_events.filter_map { |event| transcript_item_for_event(event) } + + thinking_items = selected_thinking_items + + (message_items + thinking_items + event_items).sort_by do |item| + [ item[:created_at] || Time.zone.at(0), TRANSCRIPT_SOURCE_ORDER[item[:source]] || 0 ] + end + end + + # The api-rs stdout pump persists every harness output line verbatim as a + # session.output.line event whose payload is a JSON-encoded string. Reasoning + # blocks arrive as item/completed notifications with item.type == "reasoning" + # carrying the full accumulated thinking text. The SQL LIKE filter keeps the + # query from paging through the whole firehose; exact matching happens here. + def selected_thinking_items + return [] unless @selected_session + + CentaurSessionEvent + .where(thread_key: @selected_session.thread_key) + .where(event_type: "session.output.line") + .where("payload::text LIKE '%reasoning%'") + .order(event_id: :desc) + .limit(THINKING_EVENT_LIMIT) + .to_a + .reverse + .filter_map { |event| thinking_transcript_item(event) } + end + + def thinking_transcript_item(event) + line = event.payload + return nil unless line.is_a?(String) + + value = JSON.parse(line) + return nil unless value.is_a?(Hash) + + method = (value["method"] || value["type"]).to_s.tr("/", ".") + return nil unless method == "item.completed" + + item = value.dig("params", "item") || value["item"] + return nil unless item.is_a?(Hash) && item["type"].to_s == "reasoning" + + text = reasoning_item_text(item) + return nil if text.blank? + + { + role: "thinking", + label: "Thinking", + align: :start, + text: text, + created_at: event.created_at, + source: :thinking + } + rescue JSON::ParserError + nil + end + + # Claude/Amp reasoning lands in content (full text); Codex-native reasoning + # may only carry a summary. Prefer the fullest field available. + def reasoning_item_text(item) + [ + item["text"], + reasoning_part_text(item["content"]), + reasoning_part_text(item["summary"]) + ].find(&:present?) + end + + def reasoning_part_text(value) + entries = value.is_a?(Array) ? value : [ value ] + entries.filter_map do |part| + case part + when String then part + when Hash then part["text"].to_s + end + end.join("\n").strip.presence + end + + def latest_messages_for(keys) + return {} if keys.empty? + + CentaurSessionMessage + .where(thread_key: keys) + .select("distinct on (thread_key) session_messages.*") + .order(Arel.sql("thread_key, created_at desc, message_id desc")) + .index_by(&:thread_key) + end + + def latest_executions_for(keys) + return {} if keys.empty? + + CentaurSessionExecution + .where(thread_key: keys) + .select("distinct on (thread_key) session_executions.*") + .order(Arel.sql("thread_key, created_at desc, execution_id desc")) + .index_by(&:thread_key) + end + + def transcript_item_for_message(message) + metadata = message_metadata_hash(message) + + { + role: message.role, + label: transcript_message_label(message.role, metadata), + align: transcript_message_align(message.role, metadata), + text: resolve_slack_mentions(thread_message_text(message)), + created_at: message.created_at, + source: :message + } + end + + def count_records(model, keys) + return {} if keys.empty? + + model.where(thread_key: keys).group(:thread_key).count + end + + def thread_title(session) + metadata = session.metadata_hash + summary = metadata["summary"] + title = metadata["title"].presence || + metadata["generated_title"].presence || + metadata["summary_title"].presence || + metadata["thread_title"].presence || + (metadata["thread"].is_a?(Hash) ? metadata["thread"]["title"] : nil).presence || + (metadata["summary"].is_a?(Hash) ? metadata["summary"]["title"] : nil).presence || + (summary if summary.is_a?(String)).presence || + metadata["subject"].presence || + metadata["issue_title"].presence + return generated_thread_title(title) if title + + preview = thread_text_preview(@latest_messages[session.thread_key]) + generated = generated_thread_title(preview) + return generated if generated.present? + + human_thread_key(session.thread_key) + end + + def thread_source_icon(session) + thread_source_key(session) == "slack" ? "slack" : "computer" + end + + def thread_source_label(session) + source_label(thread_source_key(session)) + end + + def thread_harness_label(session) + case session.harness_type.to_s + when "codex" then "Codex" + when "claudecode" then "Claude Code" + when "amp" then "Amp" + else source_label(session.harness_type) + end + end + + # Model the thread most recently ran on. slackbotv2 records the effective + # model in execution metadata; for older rows without it, fall back to the + # deployment's default the way the sandbox resolves it: CLAUDE_MODEL / + # CODEX_MODEL env override first, then the model pinned in the harness + # config files when they are present. Nil (segment omitted) when none of + # those sources know the model. + def thread_model_label(session) + model = recorded_model(@latest_executions&.[](session.thread_key)&.metadata) || + recorded_model(session.metadata_hash) || + default_model_for_harness(session.harness_type.to_s) + # Uppercased for display, matching the Slack Console-link context line. + model&.upcase + end + + def recorded_model(metadata) + return unless metadata.is_a?(Hash) + + metadata["model"].presence + end + + def default_model_for_harness(harness_type) + env_name = HARNESS_DEFAULT_MODEL_ENVS[harness_type] + return unless env_name + + ENV[env_name].presence || self.class.baked_harness_default_model(harness_type) + end + + # Cached per (config dir, harness): the files are immutable within a deploy, + # and the dir key keeps tests with CENTAUR_HARNESS_CONFIG_DIR overrides + # isolated. + def self.baked_harness_default_model(harness_type) + relative = HARNESS_CONFIG_FILES[harness_type] + return unless relative + + dir = ENV["CENTAUR_HARNESS_CONFIG_DIR"].presence || + Rails.root.join("..", "..", "harness").to_s + cache = (@baked_harness_default_models ||= {}) + key = [ dir, harness_type ] + return cache[key] if cache.key?(key) + + cache[key] = parse_harness_default_model(File.join(dir, relative)) + end + + def self.parse_harness_default_model(path) + return unless File.file?(path) + + contents = File.read(path) + model = + if path.end_with?(".json") + parsed = JSON.parse(contents) + parsed["model"] if parsed.is_a?(Hash) + else + # Minimal TOML: the top-level `model = "..."` line in codex/config.toml. + contents[/^model\s*=\s*"([^"]+)"/, 1] + end + model.presence + rescue JSON::ParserError, SystemCallError + nil + end + + def thread_source_key(session) + metadata = session.metadata_hash + ( + metadata["repository"].presence || + metadata["repo"].presence || + metadata["platform"].presence || + metadata["source"].presence || + session.thread_key.to_s.split(":").first.presence || + "unknown" + ).to_s.downcase + end + + def source_label(value) + normalized = value.to_s.tr("_-", " ").squish + return "Slack" if normalized.casecmp("slack").zero? + return "Console" if normalized.casecmp("console").zero? + return "Unknown" if normalized.blank? + + normalized.split.map(&:capitalize).join(" ") + end + + def thread_user_label(session) + metadata = session.metadata_hash + metadata["user_name"].presence || + metadata["user_email"].presence || + metadata["actor_email"].presence || + metadata["slack_user_name"].presence || + metadata["actor_user_id"].presence || + metadata["user_id"].presence || + "unknown" + end + + def thread_message_text(message) + return "" unless message + + message.parts_array.filter_map do |part| + next unless part.is_a?(Hash) + + case part["type"] + when "text" then part["text"].to_s + when "image" then "[image]" + when "document" then "[document]" + end + end.join("\n").squish + end + + def thread_text_preview(message) + thread_message_text(message).truncate(120) + end + + def generated_thread_title(text) + title = text.to_s + .gsub(/<@[A-Z0-9]+(?:\|[^>]+)?>/, "") + .sub(/\A\s*@?centaur\b[:,]?\s*/i, "") + .sub(/\A\s*@?U[A-Z0-9]+\b[:,]?\s*/i, "") + .sub(/\A\s*@\S+\s+/, "") + .strip + title = title.sub(/\A[*_]{1,2}(.+?)[*_]{1,2}\s*/, "\\1 ").squish + clip_one_line(title, 80) + end + + def clip_one_line(value, max) + one_line = value.to_s.gsub(/\s+/, " ").strip + return one_line if one_line.length <= max + + "#{one_line.slice(0, [ max - 3, 0 ].max).rstrip}..." + end + + def transcript_item_for_event(event) + case event.event_type + when "session.execution_completed" + text = resolve_slack_mentions( + terminal_payload_text(event.payload_hash["result_text"] || event.payload_hash) + ) + role = "assistant" + label = assistant_author_label + when "session.execution_failed" + text = terminal_payload_text(event.payload_hash["error"] || event.payload_hash) + role = "system" + label = role + when "session.execution_cancelled" + text = "Execution cancelled." + role = "system" + label = role + end + + return nil if text.blank? + + { + role: role, + label: label, + align: :start, + text: text, + created_at: event.created_at, + source: :event + } + end + + def transcript_message_align(role, metadata) + return :end if slack_message_from_current_user?(metadata) + return :start if slack_message?(metadata) + + role == "user" ? :end : :start + end + + def transcript_message_label(role, metadata) + return slack_message_author_label(metadata) if slack_message?(metadata) + return assistant_author_label if role == "assistant" + + role + end + + def slack_message?(metadata) + metadata["platform"] == "slack" || metadata["source"] == "slackbotv2" + end + + def slack_message_from_current_user?(metadata) + slack_user_id = normalize_key(metadata["slack_user_id"] || metadata["user_id"]) + + slack_user_id.present? && current_slack_user_ids.include?(slack_user_id) + end + + def current_slack_user_ids + @current_slack_user_ids ||= slack_thread_owners_for_current_user + .filter_map { |owner| normalize_key(owner.user_id) } + .uniq + end + + def slack_message_author_label(metadata) + return assistant_author_label if slack_bot_user_id?(metadata["slack_user_id"]) + + current_user_metadata = + slack_message_from_current_user?(metadata) ? @selected_session&.metadata_hash : nil + + label_from_metadata(current_user_metadata) || + slack_resolved_user_label(metadata) || + label_from_metadata(metadata) || + "slack" + end + + def slack_resolved_user_label(metadata) + slack_user_id = normalize_key(metadata["slack_user_id"] || metadata["user_id"]) + return if slack_user_id.blank? + + slack_mention_labels_by_id[slack_user_id] + end + + def label_from_metadata(metadata) + return nil unless metadata + + [ + metadata["slack_display_name"], + metadata["slack_user_name"], + metadata["user_name"], + metadata["actor_user_id"], + metadata["user_id"], + metadata["slack_user_id"] + ].find(&:present?) + end + + def resolve_slack_mentions(text) + text.to_s.gsub(SLACK_MENTION_PATTERN) do + user_id = Regexp.last_match(1).presence || Regexp.last_match(3) + explicit_label = Regexp.last_match(2) + mention_label = slack_mention_labels_by_id[normalize_key(user_id)] || + format_slack_mention_label(explicit_label) || + "@#{user_id}" + + mention_label + end + end + + def slack_mention_labels_by_id + @slack_mention_labels_by_id ||= begin + user_ids = slack_user_ids_from_selected_thread + database_labels = slack_user_display_labels_from_database(user_ids) + session_metadata_labels = slack_user_display_labels_from_session_messages(user_ids) + metadata_labels = slack_user_display_labels_from_metadata + bot_labels = slack_bot_user_ids.index_with { assistant_author_label } + + metadata_labels.merge(session_metadata_labels).merge(database_labels).merge(bot_labels) + end + end + + def slack_user_ids_from_selected_thread + ids = [] + ids.concat(slack_user_ids_from_metadata(@selected_session&.metadata_hash)) + + Array(@selected_messages).each do |message| + ids.concat(slack_user_ids_from_metadata(message_metadata_hash(message))) + ids.concat(slack_mention_user_ids(thread_message_text(message))) + end + + Array(@selected_events).each do |event| + ids.concat(slack_mention_user_ids(terminal_payload_text(event.payload_hash))) + end + + ids.filter_map { |value| normalize_key(value) }.uniq + end + + def slack_user_display_labels_from_metadata + labels = {} + metadata_sources = [ @selected_session&.metadata_hash ] + metadata_sources.concat(Array(@selected_messages).map { |message| message_metadata_hash(message) }) + + metadata_sources.each do |metadata| + user_id = normalize_key(metadata&.[]("slack_user_id") || metadata&.[]("user_id")) + next if user_id.blank? + + label = slack_mention_label_from_metadata(metadata) + labels[user_id] = label if label.present? + end + + labels + end + + def slack_user_display_labels_from_database(user_ids) + user_ids = user_ids.filter_map { |value| normalize_key(value) }.uniq + return {} if user_ids.empty? + + connection = CentaurSessionRecord.connection + return {} unless connection.data_source_exists?("slack_sync_users") + + SlackSyncUser + .where("lower(user_id) IN (?)", user_ids) + .pluck(:user_id, :user_name, :display_name, :real_name) + .each_with_object({}) do |(user_id, user_name, display_name, real_name), labels| + user_id = normalize_key(user_id) + label = slack_mention_label_from_values(user_name, display_name, real_name) + labels[user_id] = label if user_id.present? && label.present? + end + rescue ActiveRecord::ActiveRecordError, PG::Error => e + Rails.logger.debug("console_threads_slack_user_lookup_failed error=#{e.class}: #{e.message}") + {} + end + + def slack_user_display_labels_from_session_messages(user_ids) + user_ids = user_ids.filter_map { |value| normalize_key(value) }.uniq + return {} if user_ids.empty? + + rows = CentaurSessionMessage + .where(<<~SQL.squish, user_ids) + lower(coalesce( + nullif(metadata ->> 'slack_user_id', ''), + nullif(metadata ->> 'user_id', ''), + nullif(metadata ->> 'actor_user_id', '') + )) IN (?) + SQL + .order(created_at: :desc, message_id: :desc) + .pluck( + Arel.sql("metadata ->> 'slack_user_id'"), + Arel.sql("metadata ->> 'user_id'"), + Arel.sql("metadata ->> 'actor_user_id'"), + Arel.sql("metadata ->> 'slack_user_name'"), + Arel.sql("metadata ->> 'user_name'"), + Arel.sql("metadata ->> 'slack_display_name'"), + Arel.sql("metadata ->> 'display_name'") + ) + + rows.each_with_object({}) do |row, labels| + slack_user_id, user_id_value, actor_user_id, slack_user_name, user_name, slack_display_name, display_name = row + user_id = normalize_key(slack_user_id || user_id_value || actor_user_id) + next if user_id.blank? || labels.key?(user_id) + + label = slack_mention_label_from_values( + slack_user_name, + user_name, + slack_display_name, + display_name + ) + labels[user_id] = label if label.present? + end + rescue ActiveRecord::ActiveRecordError, PG::Error => e + Rails.logger.debug("console_threads_slack_message_metadata_lookup_failed error=#{e.class}: #{e.message}") + {} + end + + def slack_mention_label_from_metadata(metadata) + return nil unless metadata + + slack_mention_label_from_values( + metadata["slack_user_name"], + metadata["user_name"], + metadata["slack_display_name"], + metadata["display_name"] + ) + end + + def slack_mention_label_from_values(*values) + values + .map { |value| value.to_s.strip } + .reject(&:blank?) + .reject { |value| slack_user_id?(value) } + .map { |value| format_slack_mention_label(value) } + .find(&:present?) + end + + def format_slack_mention_label(value) + value = value.to_s.strip + return nil if value.blank? + + "@#{value.delete_prefix("@")}" + end + + def slack_mention_user_ids(text) + text.to_s.scan(SLACK_MENTION_PATTERN).filter_map do |native_id, _label, plain_id| + native_id.presence || plain_id + end + end + + def slack_user_ids_from_metadata(metadata) + return [] unless metadata + + %w[slack_user_id user_id actor_user_id].filter_map { |key| metadata[key].presence } + end + + def slack_bot_user_id?(user_id) + slack_bot_user_ids.include?(normalize_key(user_id)) + end + + def slack_bot_user_ids + @slack_bot_user_ids ||= begin + ids = [ + ConsoleEnv["SLACK_BOT_USER_ID"], + ENV["SLACK_BOT_USER_ID"] + ] + + ids.concat(inferred_slack_bot_user_ids) + ids.filter_map { |value| normalize_key(value) }.uniq + end + end + + def inferred_slack_bot_user_ids + ids = [] + + Array(@selected_messages).each do |message| + metadata = message_metadata_hash(message) + if ActiveModel::Type::Boolean.new.cast(metadata["is_mention"]) + ids << slack_mention_user_ids(thread_message_text(message)).first + end + end + + terminal_texts = Array(@selected_events).filter_map do |event| + next unless event.event_type == "session.execution_completed" + + terminal_payload_text(event.payload_hash["result_text"] || event.payload_hash).presence + end + + if terminal_texts.any? + Array(@selected_messages).each do |message| + text = thread_message_text(message) + next unless terminal_texts.include?(text) + + ids.concat(slack_user_ids_from_metadata(message_metadata_hash(message))) + end + end + + ids.compact + end + + def slack_user_id?(value) + value.to_s.strip.match?(SLACK_USER_ID_PATTERN) + end + + def assistant_author_label + format_slack_mention_label( + ConsoleEnv["SLACKBOTV2_USER_NAME"].presence || + ENV["SLACKBOTV2_USER_NAME"].presence || + "ai" + ) + end + + def message_metadata_hash(message) + return message.metadata_hash if message.respond_to?(:metadata_hash) + + metadata = message.respond_to?(:metadata) ? message.metadata : nil + metadata.is_a?(Hash) ? metadata : {} + end + + def terminal_payload_text(value) + case value + when String + value.strip + when Array + value.lazy.map { |entry| terminal_payload_text(entry) }.find(&:present?).to_s + when Hash + %w[result result_text text final_text message delta content params].each do |key| + text = terminal_payload_text(value[key]) + return text if text.present? + end + "" + else + "" + end + end + + def thread_status_classes(status) + case status.to_s + when "active", "running", "queued" + "bg-centaur-500/10 text-centaur-300 ring-centaur-500/25" + when "failed", "error" + "bg-red-500/10 text-red-300 ring-red-500/25" + when "completed" + "bg-zinc-500/10 text-zinc-300 ring-zinc-500/25" + else + "bg-amber-500/10 text-amber-300 ring-amber-500/25" + end + end + + def human_thread_key(thread_key) + source, *parts = thread_key.to_s.split(":") + return thread_key if parts.empty? + + "#{source.titleize}: #{parts.last}" + end +end diff --git a/services/console/app/controllers/sessions_controller.rb b/services/console/app/controllers/sessions_controller.rb index 1db9fb8b6..baf4fe48a 100644 --- a/services/console/app/controllers/sessions_controller.rb +++ b/services/console/app/controllers/sessions_controller.rb @@ -14,7 +14,7 @@ class SessionsController < ApplicationController skip_before_action :require_active_account def new - redirect_to console_principals_path if current_user&.active? + redirect_to safe_console_return_path if current_user&.active? end # Holding page for a signed-in but not-yet-approved user. Active users have no diff --git a/services/console/app/helpers/application_helper.rb b/services/console/app/helpers/application_helper.rb index 467e01ae7..4749b0d44 100644 --- a/services/console/app/helpers/application_helper.rb +++ b/services/console/app/helpers/application_helper.rb @@ -1,4 +1,11 @@ +require "cgi" + module ApplicationHelper + MARKDOWN_ALLOWED_TAGS = %w[ + a blockquote br code del em h1 h2 h3 h4 li ol p pre strong ul + ].freeze + MARKDOWN_ALLOWED_ATTRIBUTES = %w[class href rel target].freeze + # Truncates a string in the middle with an ellipsis (e.g. "salesforce…rest-api"), # keeping the head and tail visible -- useful for opaque ids where both ends # carry meaning. Returns the value unchanged when it already fits within +max+. @@ -23,6 +30,173 @@ def credential_status_classes(status) end end + def console_icon(name, classes: "size-4") + case name + when "arrow-up" + outline_icon(classes, "M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18") + when "database" + outline_icon( + classes, + "M4.5 6.75c0 1.243 3.358 2.25 7.5 2.25s7.5-1.007 7.5-2.25S16.142 4.5 12 4.5 4.5 5.507 4.5 6.75Zm0 0v10.5c0 1.243 3.358 2.25 7.5 2.25s7.5-1.007 7.5-2.25V6.75M4.5 12c0 1.243 3.358 2.25 7.5 2.25s7.5-1.007 7.5-2.25" + ) + when "computer" + outline_icon( + classes, + "M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15A2.25 2.25 0 0 1 18.75 17.25H5.25A2.25 2.25 0 0 1 3 15V5.25A2.25 2.25 0 0 1 5.25 3h13.5A2.25 2.25 0 0 1 21 5.25Z" + ) + when "id-badge" + outline_icon( + classes, + "M6.75 3.75h10.5A2.25 2.25 0 0 1 19.5 6v12a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 18V6a2.25 2.25 0 0 1 2.25-2.25ZM9 8.25h6M9 15.75h6M9 12h6" + ) + when "ellipsis-horizontal" + tag.svg( + safe_join([ + tag.circle(cx: "6.75", cy: "12", r: "1"), + tag.circle(cx: "12", cy: "12", r: "1"), + tag.circle(cx: "17.25", cy: "12", r: "1") + ]), + xmlns: "http://www.w3.org/2000/svg", + viewBox: "0 0 24 24", + fill: "currentColor", + class: classes, + aria: { hidden: true }, + focusable: "false" + ) + when "key" + outline_icon( + classes, + "M15.75 7.5a4.5 4.5 0 1 1-1.118 2.966L21 16.834V19.5h-2.666l-1.5-1.5h-2.121l-1.5-1.5v-2.121l-1.179-1.179A4.5 4.5 0 0 1 15.75 7.5Z" + ) + when "link" + outline_icon( + classes, + "M13.5 6.75h2.25a4.5 4.5 0 0 1 0 9H13.5m-3-9H8.25a4.5 4.5 0 0 0 0 9h2.25M8.25 12h7.5" + ) + when "log-out" + outline_icon( + classes, + "M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6A2.25 2.25 0 0 0 5.25 5.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 12h9m0 0-3-3m3 3-3 3" + ) + when "moon" + outline_icon( + classes, + "M21.752 15.002A9.718 9.718 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.598.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" + ) + when "magnifying-glass" + outline_icon( + classes, + "m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" + ) + when "message-square" + outline_icon( + classes, + "M6.75 5.25h10.5A2.25 2.25 0 0 1 19.5 7.5v6A2.25 2.25 0 0 1 17.25 15.75H10.5L6 19.5v-3.75A2.25 2.25 0 0 1 3.75 13.5v-6A2.25 2.25 0 0 1 6.75 5.25Z" + ) + when "panel-left" + outline_icon( + classes, + "M4.5 5.25A1.5 1.5 0 0 1 6 3.75h12a1.5 1.5 0 0 1 1.5 1.5v13.5a1.5 1.5 0 0 1-1.5 1.5H6a1.5 1.5 0 0 1-1.5-1.5V5.25ZM9 3.75v16.5" + ) + when "panel-right" + outline_icon( + classes, + "M4.5 5.25A1.5 1.5 0 0 1 6 3.75h12a1.5 1.5 0 0 1 1.5 1.5v13.5a1.5 1.5 0 0 1-1.5 1.5H6a1.5 1.5 0 0 1-1.5-1.5V5.25ZM15 3.75v16.5" + ) + when "plus" + outline_icon(classes, "M12 4.5v15m7.5-7.5h-15") + when "chevron-right" + outline_icon(classes, "m8.25 4.5 7.5 7.5-7.5 7.5") + when "x-mark" + outline_icon(classes, "M6 18 18 6M6 6l12 12") + when "shield-check" + outline_icon( + classes, + "M12 3.75 19.5 6v5.25c0 4.207-2.765 8.04-7.5 9-4.735-.96-7.5-4.793-7.5-9V6L12 3.75Zm3.75 6-4.5 4.5-2.25-2.25" + ) + when "slack" + tag.svg( + tag.path( + d: "M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52ZM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.52-2.522v-6.313ZM8.834 5.042a2.528 2.528 0 0 1-2.52-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834ZM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312ZM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834ZM17.686 8.834a2.528 2.528 0 0 1-2.522 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.164 0a2.528 2.528 0 0 1 2.522 2.522v6.312ZM15.164 18.956a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.164 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52ZM15.164 17.686a2.527 2.527 0 0 1-2.52-2.521 2.527 2.527 0 0 1 2.52-2.52h6.314A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521h-6.314Z" + ), + xmlns: "http://www.w3.org/2000/svg", + viewBox: "0 0 24 24", + fill: "currentColor", + class: classes, + aria: { hidden: true }, + focusable: "false" + ) + when "sun" + outline_icon( + classes, + "M12 3v2.25M12 18.75V21M4.5 4.5l1.591 1.591M17.909 17.909 19.5 19.5M3 12h2.25M18.75 12H21M4.5 19.5l1.591-1.591M17.909 6.091 19.5 4.5M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" + ) + when "user-circle" + outline_icon( + classes, + "M15.75 9.75a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.5 19.5a8.25 8.25 0 1 1 15 0 9.72 9.72 0 0 0-15 0Z" + ) + when "users" + outline_icon( + classes, + "M9.75 10.5a3.75 3.75 0 1 1 7.5 0 3.75 3.75 0 0 1-7.5 0ZM4.5 18.75a6.75 6.75 0 0 1 13.5 0M18 8.25a3 3 0 0 1 0 6M19.5 18.75a5.25 5.25 0 0 0-2.25-4.307" + ) + end + end + + def console_markdown(text) + sanitize( + markdown_blocks(text.to_s).join, + tags: MARKDOWN_ALLOWED_TAGS, + attributes: MARKDOWN_ALLOWED_ATTRIBUTES + ) + end + + def console_sidebar_thread_title(session, latest_message = nil) + metadata = session.metadata_hash + summary = metadata["summary"] + title = metadata["title"].presence || + metadata["generated_title"].presence || + metadata["summary_title"].presence || + metadata["thread_title"].presence || + (metadata["thread"].is_a?(Hash) ? metadata["thread"]["title"] : nil).presence || + (metadata["summary"].is_a?(Hash) ? metadata["summary"]["title"] : nil).presence || + (summary if summary.is_a?(String)).presence || + metadata["subject"].presence || + metadata["issue_title"].presence + return console_sidebar_generated_thread_title(title) if title + + generated = console_sidebar_generated_thread_title(console_sidebar_thread_message_text(latest_message)) + return generated if generated.present? + + truncate_middle(session.thread_key, max: 42) + end + + def console_sidebar_thread_message_text(message) + return "" unless message + + message.parts_array.filter_map do |part| + next unless part.is_a?(Hash) + + case part["type"] + when "text" then part["text"].to_s + when "image" then "[image]" + when "document" then "[document]" + end + end.join("\n").squish + end + + def console_sidebar_generated_thread_title(text) + title = text.to_s + .gsub(/<@[A-Z0-9]+(?:\|[^>]+)?>/, "") + .sub(/\A\s*@?centaur\b[:,]?\s*/i, "") + .sub(/\A\s*@?U[A-Z0-9]+\b[:,]?\s*/i, "") + .sub(/\A\s*@\S+\s+/, "") + .strip + title = title.sub(/\A[*_]{1,2}(.+?)[*_]{1,2}\s*/, "\\1 ").squish + console_sidebar_clip_one_line(title, 48) + end + # The broker credential a record wraps when it is an OAuth-flow-managed static # secret; nil for ordinary secrets and for non-static kinds. Drives the "managed" # badge and the credential <-> secret cross-links. Lives in a helper (not a @@ -49,20 +223,171 @@ def id_meta_line(namespace, oid: nil) # Renders a UTC timestamp that the `localtime` Stimulus controller rewrites in # the viewer's local time zone. With relative: true it shows a "5 minutes ago" - # style string (absolute local time on hover). The ISO-8601 text is the + # style string (absolute local time on hover). Pass format: :compact with + # relative: true for short labels like "4d" or "1mo". The ISO-8601 text is the # pre-JS / no-JS fallback. Returns an em-dash placeholder for nil. - def local_time(time, relative: false) + def local_time(time, relative: false, format: nil) return tag.span("—", class: "text-zinc-600") if time.nil? iso = time.utc.iso8601 + data = { + controller: "localtime", + localtime_datetime_value: iso, + localtime_relative_value: relative + } + data[:localtime_format_value] = format.to_s if format.present? + tag.time( iso, datetime: iso, - data: { - controller: "localtime", - localtime_datetime_value: iso, - localtime_relative_value: relative - } + data: data + ) + end + + def outline_icon(classes, path) + tag.svg( + tag.path(d: path, "stroke-linecap": "round", "stroke-linejoin": "round"), + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewBox: "0 0 24 24", + "stroke-width": "1.8", + stroke: "currentColor", + class: classes, + aria: { hidden: true }, + focusable: "false" ) end + + def markdown_blocks(raw_text) + lines = raw_text.to_s.gsub("\r\n", "\n").split("\n", -1) + blocks = [] + index = 0 + + while index < lines.length + line = lines[index] + start_index = index + + if line.blank? + index += 1 + elsif line.start_with?("```") + code_lines = [] + index += 1 + while index < lines.length && !lines[index].start_with?("```") + code_lines << lines[index] + index += 1 + end + index += 1 if index < lines.length + blocks << %(
#{ERB::Util.html_escape(code_lines.join("\n"))}
) + elsif (heading = line.match(/\A(\#{1,4})\s+(.+)\z/)) + level = heading[1].length + classes = "mb-2 mt-4 text-sm font-semibold text-zinc-100 first:mt-0" + blocks << %(#{markdown_inline(heading[2])}) + index += 1 + elsif line.match?(/\A\s*[-*+]\s+/) + items = [] + while index < lines.length && (item = lines[index].match(/\A\s*[-*+]\s+(.+)\z/)) + items << item[1] + index += 1 + end + blocks << %(
    #{items.map { |item| %(
  • #{markdown_inline(item)}
  • ) }.join}
) + elsif line.match?(/\A\s*\d+\.\s+/) + items = [] + while index < lines.length && (item = lines[index].match(/\A\s*\d+\.\s+(.+)\z/)) + items << item[1] + index += 1 + end + blocks << %(
    #{items.map { |item| %(
  1. #{markdown_inline(item)}
  2. ) }.join}
) + elsif line.match?(/\A\s*>\s?/) + quoted = [] + while index < lines.length && (quote = lines[index].match(/\A\s*>\s?(.*)\z/)) + quoted << quote[1] + index += 1 + end + blocks << %(
#{markdown_inline(quoted.join(" "))}
) + else + paragraph = [] + while index < lines.length && lines[index].present? && !markdown_block_start?(lines[index]) + paragraph << lines[index] + index += 1 + end + blocks << %(

#{markdown_inline(paragraph.join(" "))}

) + end + + # Guarantee forward progress: a block-start marker with no content (e.g. + # a bare "- ", "1. ", or "# ") matches a branch guard but not its inner + # consuming regex, which would otherwise spin this loop forever. Emit such + # a line as an escaped paragraph and advance. + if index == start_index + blocks << %(

#{markdown_inline(line)}

) + index += 1 + end + end + + blocks + end + + def markdown_block_start?(line) + line.start_with?("```") || + line.match?(/\A\#{1,4}\s+/) || + line.match?(/\A\s*[-*+]\s+/) || + line.match?(/\A\s*\d+\.\s+/) || + line.match?(/\A\s*>\s?/) + end + + def markdown_inline(raw_text) + text = ERB::Util.html_escape(raw_text.to_s) + placeholders = [] + + text = text.gsub(/`([^`\n]+)`/) do + markdown_placeholder(placeholders, %(#{Regexp.last_match(1)})) + end + text = text.gsub(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/i) do + markdown_placeholder( + placeholders, + markdown_link(Regexp.last_match(1), CGI.unescapeHTML(Regexp.last_match(2))) + ) + end + text = text.gsub(%r{(?\1') + text = text.gsub(/__([^_\n]+)__/, '\1') + text = text.gsub(/~~([^~\n]+)~~/, '\1') + text = text.gsub(/(?\1') + text = text.gsub(/(?\1') + + placeholders.each_with_index do |html, offset| + text = text.gsub(markdown_token(offset), html) + end + + text + end + + def markdown_link(label, url) + unless url.to_s.match?(/\Ahttps?:\/\/[^\s<>"']+\z/i) + return label + end + + href = ERB::Util.html_escape(url) + %(#{label}) + end + + def markdown_placeholder(placeholders, html) + placeholders << html + markdown_token(placeholders.length - 1) + end + + def markdown_token(offset) + "%%MDPH#{offset}%%" + end + + def console_sidebar_clip_one_line(value, max) + one_line = value.to_s.gsub(/\s+/, " ").strip + return one_line if one_line.length <= max + + "#{one_line.slice(0, [ max - 3, 0 ].max).rstrip}..." + end end diff --git a/services/console/app/javascript/controllers/localtime_controller.js b/services/console/app/javascript/controllers/localtime_controller.js index 3bfa556d8..2b45c196e 100644 --- a/services/console/app/javascript/controllers/localtime_controller.js +++ b/services/console/app/javascript/controllers/localtime_controller.js @@ -6,8 +6,10 @@ import { Controller } from "@hotwired/stimulus" // // data-localtime-relative-value="true" -> "5 minutes ago", with the absolute // local time as a hover tooltip. +// data-localtime-format-value="compact" -> "4d", with the absolute local time +// as a hover tooltip. export default class extends Controller { - static values = { datetime: String, relative: Boolean } + static values = { datetime: String, format: String, relative: Boolean } connect() { const date = new Date(this.datetimeValue) @@ -15,11 +17,15 @@ export default class extends Controller { const absolute = this.formatAbsolute(date) - if (this.relativeValue) { + if (this.formatValue === "compact") { + this.element.textContent = this.compactRelativeFrom(date) + this.element.title = absolute + } else if (this.relativeValue) { this.element.textContent = this.relativeFrom(date) this.element.title = absolute } else { this.element.textContent = absolute + this.element.title = absolute } } @@ -43,4 +49,16 @@ export default class extends Controller { } return rtf.format(seconds, "second") } + + compactRelativeFrom(date) { + const seconds = Math.abs(Math.round((Date.now() - date.getTime()) / 1000)) + const units = [ + ["y", 31536000], ["mo", 2592000], ["w", 604800], + ["d", 86400], ["h", 3600], ["m", 60] + ] + for (const [unit, secs] of units) { + if (seconds >= secs) return `${Math.max(1, Math.round(seconds / secs))}${unit}` + } + return "now" + } } diff --git a/services/console/app/models/centaur_session.rb b/services/console/app/models/centaur_session.rb new file mode 100644 index 000000000..fdfd27e84 --- /dev/null +++ b/services/console/app/models/centaur_session.rb @@ -0,0 +1,28 @@ +class CentaurSession < CentaurSessionRecord + self.table_name = "sessions" + self.primary_key = "thread_key" + + has_many :messages, + class_name: "CentaurSessionMessage", + foreign_key: :thread_key, + primary_key: :thread_key, + inverse_of: :session + has_many :executions, + class_name: "CentaurSessionExecution", + foreign_key: :thread_key, + primary_key: :thread_key, + inverse_of: :session + has_many :events, + class_name: "CentaurSessionEvent", + foreign_key: :thread_key, + primary_key: :thread_key, + inverse_of: :session + + scope :recent_first, -> { order(Arel.sql("coalesce(updated_at, created_at) desc"), :thread_key) } + + def readonly? = true + + def metadata_hash + metadata.is_a?(Hash) ? metadata : {} + end +end diff --git a/services/console/app/models/centaur_session_event.rb b/services/console/app/models/centaur_session_event.rb new file mode 100644 index 000000000..4f5ed2d8d --- /dev/null +++ b/services/console/app/models/centaur_session_event.rb @@ -0,0 +1,16 @@ +class CentaurSessionEvent < CentaurSessionRecord + self.table_name = "session_events" + self.primary_key = "event_id" + + belongs_to :session, + class_name: "CentaurSession", + foreign_key: :thread_key, + primary_key: :thread_key, + inverse_of: :events + + def readonly? = true + + def payload_hash + payload.is_a?(Hash) ? payload : {} + end +end diff --git a/services/console/app/models/centaur_session_execution.rb b/services/console/app/models/centaur_session_execution.rb new file mode 100644 index 000000000..aec091050 --- /dev/null +++ b/services/console/app/models/centaur_session_execution.rb @@ -0,0 +1,12 @@ +class CentaurSessionExecution < CentaurSessionRecord + self.table_name = "session_executions" + self.primary_key = "execution_id" + + belongs_to :session, + class_name: "CentaurSession", + foreign_key: :thread_key, + primary_key: :thread_key, + inverse_of: :executions + + def readonly? = true +end diff --git a/services/console/app/models/centaur_session_message.rb b/services/console/app/models/centaur_session_message.rb new file mode 100644 index 000000000..4713c775f --- /dev/null +++ b/services/console/app/models/centaur_session_message.rb @@ -0,0 +1,20 @@ +class CentaurSessionMessage < CentaurSessionRecord + self.table_name = "session_messages" + self.primary_key = "message_id" + + belongs_to :session, + class_name: "CentaurSession", + foreign_key: :thread_key, + primary_key: :thread_key, + inverse_of: :messages + + def readonly? = true + + def parts_array + parts.is_a?(Array) ? parts : [] + end + + def metadata_hash + metadata.is_a?(Hash) ? metadata : {} + end +end diff --git a/services/console/app/models/centaur_session_record.rb b/services/console/app/models/centaur_session_record.rb new file mode 100644 index 000000000..a04c51fd2 --- /dev/null +++ b/services/console/app/models/centaur_session_record.rb @@ -0,0 +1,57 @@ +class CentaurSessionRecord < ActiveRecord::Base + self.abstract_class = true + + DEFAULT_DATABASE_NAME = "ai_v2".freeze + + class << self + private + + def session_database_configuration + explicit_url = + ConsoleEnv["CENTAUR_DATABASE_URL"].presence || ENV["CENTAUR_DATABASE_URL"].presence + if explicit_url + return { + adapter: "postgresql", + encoding: "unicode", + pool: ENV.fetch("RAILS_MAX_THREADS", 5), + url: explicit_url + } + end + + config = primary_database_configuration.deep_symbolize_keys + + # When the primary config carries a :url (the common single-URL dev setup + # where database.yml's default block sets url: ), Rails' + # UrlConfig merges the URL-derived keys OVER sibling hash keys. That means + # a database path inside the primary URL would override any :database we + # set here, silently pointing the session models at the console's own DB. + # Resolve the URL into discrete connection params and drop :url so the + # ai_v2 database name below is authoritative. + if config[:url].present? + resolved = ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver + .new(config.delete(:url)) + .to_hash + .symbolize_keys + config = config.merge(resolved) + else + config.delete(:url) + end + + config[:database] = session_database_name(config) + config + end + + def primary_database_configuration + env_config = Rails.application.config.database_configuration.fetch(Rails.env) + (env_config["primary"] || env_config).deep_dup + end + + def session_database_name(config) + ConsoleEnv["CENTAUR_DATABASE_NAME"].presence || + ENV["CENTAUR_DATABASE_NAME"].presence || + (Rails.env.test? ? config[:database] : DEFAULT_DATABASE_NAME) + end + end + + establish_connection session_database_configuration +end diff --git a/services/console/app/models/slack_sync_user.rb b/services/console/app/models/slack_sync_user.rb new file mode 100644 index 000000000..625803871 --- /dev/null +++ b/services/console/app/models/slack_sync_user.rb @@ -0,0 +1,7 @@ +class SlackSyncUser < CentaurSessionRecord + self.table_name = "slack_sync_users" + + def readonly? + true + end +end diff --git a/services/console/app/services/centaur_api_client.rb b/services/console/app/services/centaur_api_client.rb index ee280a4a8..93882e98f 100644 --- a/services/console/app/services/centaur_api_client.rb +++ b/services/console/app/services/centaur_api_client.rb @@ -69,6 +69,32 @@ def ingest_google_docs_sync_batch(payload) post("/api/admin/google/docs-sync/batch", payload) end + def create_session(thread_key:, harness_type:, metadata: {}, persona_id: nil, + on_harness_conflict: "reject") + payload = { + harness_type: harness_type, + metadata: metadata, + on_harness_conflict: on_harness_conflict + } + payload[:persona_id] = persona_id if persona_id.present? + + post("/api/session/#{escape_path(thread_key)}", payload) + end + + def append_session_messages(thread_key:, messages:) + post("/api/session/#{escape_path(thread_key)}/messages", { messages: messages }) + end + + def execute_session(thread_key:, input_lines:, idempotency_key: nil, metadata: {}) + payload = { + input_lines: input_lines, + metadata: metadata + } + payload[:idempotency_key] = idempotency_key if idempotency_key.present? + + post("/api/session/#{escape_path(thread_key)}/execute", payload) + end + private def get(path, params = {}) diff --git a/services/console/app/views/console/_control_tabs.html.erb b/services/console/app/views/console/_control_tabs.html.erb new file mode 100644 index 000000000..ed26d519e --- /dev/null +++ b/services/console/app/views/console/_control_tabs.html.erb @@ -0,0 +1,19 @@ +<% control_tabs = [ + { label: "Principals", path: console_principals_path, match: "/console/principals", root_active: true }, + { label: "Roles", path: console_roles_path, match: "/console/roles" }, + { label: "Secrets", path: console_secrets_path, match: "/console/secrets" }, + { label: "Credentials", path: console_credentials_path, match: "/console/credentials" }, + { label: "Apps", path: console_oauth_apps_path, match: "/console/oauth_apps" } +] %> +<% control_tabs << { label: "Users", path: console_users_path, match: "/console/users" } if current_user&.admin? %> + + diff --git a/services/console/app/views/console/_page_header.html.erb b/services/console/app/views/console/_page_header.html.erb new file mode 100644 index 000000000..33bb1e06a --- /dev/null +++ b/services/console/app/views/console/_page_header.html.erb @@ -0,0 +1,29 @@ +<% title = local_assigns.fetch(:title) %> +<% subtitle = local_assigns[:subtitle] %> +<% actions = local_assigns[:actions] %> +<% icon = local_assigns[:icon] %> +<% title_class = local_assigns[:title_class].presence || "page-title" %> +<% subtitle_class = local_assigns[:subtitle_class].presence || "page-subtitle" %> +<% header_class = [ "console-page-header", local_assigns[:class] ].compact.join(" ") %> + +
+
+ <% if icon.present? %> + + <%= console_icon(icon, classes: "size-4") %> + + <% end %> +
+

<%= title %>

+ <% if subtitle.present? %> +
<%= subtitle %>
+ <% end %> +
+
+ + <% if actions.present? %> +
+ <%= actions %> +
+ <% end %> +
diff --git a/services/console/app/views/console/credentials.html.erb b/services/console/app/views/console/credentials.html.erb index c86df06f9..91998686d 100644 --- a/services/console/app/views/console/credentials.html.erb +++ b/services/console/app/views/console/credentials.html.erb @@ -1,12 +1,11 @@ <% content_for :title, "Credentials · Centaur Console" %> -
-
-

Managed Credentials

-

<%= pluralize(@credentials.size, "broker credential") %>. Token material is never shown.

-
- + Add Credential -
+<%= render "control_tabs" %> + +<%= render "console/page_header", + title: "Managed Credentials", + subtitle: "#{pluralize(@credentials.size, "broker credential")}. Token material is never shown.", + actions: link_to("+ Add Credential", new_console_broker_credential_path, class: "btn-primary") %> <% if @credentials.empty? %>

No managed credentials.

diff --git a/services/console/app/views/console/etls/index.html.erb b/services/console/app/views/console/etls/index.html.erb index 5f27f9aff..89625ea0e 100644 --- a/services/console/app/views/console/etls/index.html.erb +++ b/services/console/app/views/console/etls/index.html.erb @@ -1,15 +1,16 @@ -<% content_for :title, "ETLs · Centaur Console" %> +<% content_for :title, "Data Sync · Centaur Console" %> -
-
-

ETLs

-

Upload Slack public-channel archive exports, start imports, and track ingestion status.

-
-
+<% sync_meta = capture do %> +
<%= pluralize(@archive_imports.size, "archive import") %> shown
-
+<% end %> + +<%= render "console/page_header", + title: "Data Sync", + subtitle: "Upload Slack public-channel archive exports, start imports, and track ingestion status.", + actions: sync_meta %>
- ← back to OAuth apps + ← back to Apps

<%= @oauth_app.slug %>

<% if @oauth_app.enabled? %> diff --git a/services/console/app/views/console/oauth_apps.html.erb b/services/console/app/views/console/oauth_apps.html.erb index 34cf3fda7..c30f757a9 100644 --- a/services/console/app/views/console/oauth_apps.html.erb +++ b/services/console/app/views/console/oauth_apps.html.erb @@ -1,15 +1,14 @@ -<% content_for :title, "OAuth Apps · Centaur Console" %> +<% content_for :title, "Apps · Centaur Console" %> -
-
-

OAuth Apps

-

<%= pluralize(@oauth_apps.size, "OAuth app") %> driving the public consent flow.

-
- + Add OAuth App -
+<%= render "console/control_tabs" %> + +<%= render "console/page_header", + title: "Apps", + subtitle: "#{pluralize(@oauth_apps.size, "OAuth app")} driving the public consent flow.", + actions: link_to("+ Add App", new_console_oauth_app_form_path, class: "btn-primary") %> <% if @oauth_apps.empty? %> -

No OAuth apps.

+

No apps.

<% else %>
diff --git a/services/console/app/views/console/oauth_apps/edit.html.erb b/services/console/app/views/console/oauth_apps/edit.html.erb index 7c835daef..a61872eb6 100644 --- a/services/console/app/views/console/oauth_apps/edit.html.erb +++ b/services/console/app/views/console/oauth_apps/edit.html.erb @@ -2,7 +2,7 @@ <% content_for :title, "Edit #{title} · Centaur Console" %>
- ← back to OAuth app + ← back to App

Edit <%= title %>

OAuth diff --git a/services/console/app/views/console/oauth_apps/new.html.erb b/services/console/app/views/console/oauth_apps/new.html.erb index 827fd9c03..a62d7666b 100644 --- a/services/console/app/views/console/oauth_apps/new.html.erb +++ b/services/console/app/views/console/oauth_apps/new.html.erb @@ -1,9 +1,9 @@ -<% content_for :title, "New OAuth app · Centaur Console" %> +<% content_for :title, "New App · Centaur Console" %>
- ← back to OAuth apps + ← back to Apps
-

New OAuth app

+

New App

OAuth
diff --git a/services/console/app/views/console/principals.html.erb b/services/console/app/views/console/principals.html.erb index 2ced27a42..0dbae961c 100644 --- a/services/console/app/views/console/principals.html.erb +++ b/services/console/app/views/console/principals.html.erb @@ -1,9 +1,10 @@ <% content_for :title, "Principals · Centaur Console" %> -
-

Principals

-

<%= pluralize(@principals.size, "principal") %> across all namespaces. Click a row to inspect its grants.

-
+<%= render "control_tabs" %> + +<%= render "console/page_header", + title: "Principals", + subtitle: "#{pluralize(@principals.size, "principal")} across all namespaces. Click a row to inspect its grants." %>
diff --git a/services/console/app/views/console/roles/index.html.erb b/services/console/app/views/console/roles/index.html.erb index 23eb39ccb..c51f0df9e 100644 --- a/services/console/app/views/console/roles/index.html.erb +++ b/services/console/app/views/console/roles/index.html.erb @@ -1,12 +1,11 @@ <% content_for :title, "Roles · Centaur Console" %> -
-
-

Roles

-

<%= pluralize(@roles.size, "role") %> across all namespaces.

-
- Add Role -
+<%= render "console/control_tabs" %> + +<%= render "console/page_header", + title: "Roles", + subtitle: "#{pluralize(@roles.size, "role")} across all namespaces.", + actions: link_to("Add Role", new_console_role_path, class: "btn-primary") %>
diff --git a/services/console/app/views/console/secrets.html.erb b/services/console/app/views/console/secrets.html.erb index 3f3ebe8fc..0145f380b 100644 --- a/services/console/app/views/console/secrets.html.erb +++ b/services/console/app/views/console/secrets.html.erb @@ -2,12 +2,9 @@ <% total = @secrets_by_kind.values.sum(&:size) %> -
-
-

Secrets

-

<%= pluralize(total, "secret") %> across <%= @secrets_by_kind.size %> kinds.

-
+<%= render "control_tabs" %> +<% add_secret_action = capture do %>
- +<% end %> + +<%= render "console/page_header", + title: "Secrets", + subtitle: "#{pluralize(total, "secret")} across #{@secrets_by_kind.size} kinds.", + actions: add_secret_action %>
<% @secrets_by_kind.each do |kind, records| %> diff --git a/services/console/app/views/console/threads/_sidebar_threads.html.erb b/services/console/app/views/console/threads/_sidebar_threads.html.erb new file mode 100644 index 000000000..218d5798f --- /dev/null +++ b/services/console/app/views/console/threads/_sidebar_threads.html.erb @@ -0,0 +1,39 @@ +<%# Sidebar thread list, rendered inside the lazy console_sidebar_threads Turbo + Frame. The turbo-frame id must match the one in layouts/console.html.erb so + the deferred fetch replaces the loading placeholder. Cmd/Ctrl-click on a + thread link adds it to the split-view grid (wired in the console layout). %> +<%= turbo_frame_tag "console_sidebar_threads" do %> + <% open_thread_keys = params[:thread].to_s.split(",").map(&:strip).reject(&:blank?) %> + <% if @console_sidebar_threads.blank? %> +
No recent chats
+ <% else %> + <% @console_sidebar_threads.each_with_index do |thread, index| %> + <%# Open threads carry their 1-based pane number, rendered as a small + numeral in the indent gutter (matches the grid order). No filled + pill on thread rows; the Threads group title keeps its own. %> + <% pane_index = open_thread_keys.index(thread.thread_key) %> + <% open_thread = !pane_index.nil? %> + <% latest_message = @console_sidebar_latest_messages[thread.thread_key] %> + <% title = console_sidebar_thread_title(thread, latest_message) %> + <%= link_to console_threads_path(thread: thread.thread_key), + class: "console-thread-link #{open_thread ? "console-thread-link-open" : ""} #{index >= 5 && !open_thread ? "console-thread-link-hidden" : ""}", + data: { + console_thread_link: true, + turbo_frame: "_top", + console_pane_index: (pane_index + 1 if open_thread && open_thread_keys.size > 1) + }.compact, + title: title do %> + <%= title %> + <%= local_time(thread.updated_at || thread.created_at, relative: true, format: :compact) %> + <% end %> + <% end %> + <% if @console_sidebar_threads.size > 5 %> + + <% end %> + <% end %> +<% end %> diff --git a/services/console/app/views/console/threads/_thread_panel.html.erb b/services/console/app/views/console/threads/_thread_panel.html.erb new file mode 100644 index 000000000..c3116bc19 --- /dev/null +++ b/services/console/app/views/console/threads/_thread_panel.html.erb @@ -0,0 +1,42 @@ +<%# One panel of the split-view grid. panel: {session:, thread_key:, + transcript_items:}; panels: all panels, used to build the close link that + drops this panel and promotes the first remaining thread to primary. The + thread param carries all open keys comma-separated, primary first. %> +<% session = panel[:session] %> +<% remaining_keys = panels.map { |other| other[:thread_key] } - [ panel[:thread_key] ] %> +<% close_path = console_threads_path(thread: remaining_keys.join(",")) %> +
+
+
+ <% panel_title = thread_title(session) %> + + <%= panel_title %> + +
+ <%= thread_source_label(session) %> + <% if (model_label = thread_model_label(session)) %> + · + <%= model_label %> + <% end %> + · + <%= thread_harness_label(session) %> + · + <%= local_time(session.updated_at || session.created_at, relative: true, format: :compact) %> +
+
+ + <%= console_icon("x-mark", classes: "size-4") %> + +
+
+
+ <%= render "console/threads/transcript", items: panel[:transcript_items] %> +
+
+
diff --git a/services/console/app/views/console/threads/_transcript.html.erb b/services/console/app/views/console/threads/_transcript.html.erb new file mode 100644 index 000000000..79907ceb6 --- /dev/null +++ b/services/console/app/views/console/threads/_transcript.html.erb @@ -0,0 +1,40 @@ +<%# Shared transcript stream for the single-thread view and split-view panels. + items: transcript items built by Console::ThreadsController. Thinking items + (source: :thinking) render as collapsed disclosures, Claude-app style. %> +<% if items.empty? %> +
+ No messages have been persisted for this chat yet. +
+<% end %> + +<% items.each do |item| %> + <% if item[:source] == :thinking %> +
+
+ + + Thinking + <%= item[:text].to_s.gsub(/\s+/, " ").truncate(96) %> + +
+ <%= console_markdown(item[:text]) %> +
+
+
+ <% else %> + <% user_message = item[:align] == :end %> +
"> +
"> +
"> + <% unless user_message %> +
<%= item[:label] || item[:role] %>
+ <% end %> +
<%= console_markdown(item[:text].presence || "No text content.") %>
+
+
"> + <%= local_time(item[:created_at]) if item[:created_at] %> +
+
+
+ <% end %> +<% end %> diff --git a/services/console/app/views/console/threads/index.html.erb b/services/console/app/views/console/threads/index.html.erb new file mode 100644 index 000000000..a12d5023a --- /dev/null +++ b/services/console/app/views/console/threads/index.html.erb @@ -0,0 +1,93 @@ +<% content_for :title, "Chats · Centaur Console" %> + +<% if @thread_db_unavailable %> +
+ Chat database is unavailable. Set CENTAUR_CONSOLE_CENTAUR_DATABASE_URL + so Console can read the API session database. +
+<% end %> + +
+ <% if @thread_panels.size > 1 %> + <% grid_classes = + case @thread_panels.size + when 2 then "grid-cols-2" + when 3 then "grid-cols-3" + else "grid-cols-2 grid-rows-2" + end %> +
+ <% @thread_panels.each do |panel| %> + <%= render "console/threads/thread_panel", panel: panel, panels: @thread_panels %> + <% end %> +
+ <% else %> +
+ <% if @selected_session %> +
+
+ <% selected_thread_title = thread_title(@selected_session) %> + <% thread_meta = capture do %> +
+ <%= thread_source_label(@selected_session) %> + <% if (model_label = thread_model_label(@selected_session)) %> + · + <%= model_label %> + <% end %> + · + <%= thread_harness_label(@selected_session) %> + · + <%= local_time(@selected_session.updated_at || @selected_session.created_at, relative: true, format: :compact) %> +
+ <% end %> + + <%= render "console/page_header", + title: selected_thread_title, + title_attr: selected_thread_title, + subtitle: thread_meta, + title_class: "truncate text-base font-semibold text-zinc-100", + subtitle_class: "mt-0.5 text-xs text-zinc-500", + class: "mb-0 min-h-16 items-center" %> +
+
+ <% end %> + +
+ <% if @selected_session %> +
+ <%= render "console/threads/transcript", items: @selected_transcript_items %> +
+ <% elsif @thread_not_found %> +
+
+
+ <%= console_icon("message-square", classes: "size-5") %> +
+
Chat not found
+
+
+ <% elsif @sessions.empty? %> +
+
+
+ <%= console_icon("message-square", classes: "size-5") %> +
+
No chats yet
+

+ Chats you start — from Slack or the Console — will show up here. +

+
+
+ <% else %> +
+
+
+ <%= console_icon("message-square", classes: "size-5") %> +
+
Select a chat from the sidebar
+
+
+ <% end %> +
+
+ <% end %> +
diff --git a/services/console/app/views/console/users/index.html.erb b/services/console/app/views/console/users/index.html.erb index 53266fd36..6f036a334 100644 --- a/services/console/app/views/console/users/index.html.erb +++ b/services/console/app/views/console/users/index.html.erb @@ -1,9 +1,10 @@ <% content_for :title, "Users · Centaur Console" %> -
-

Users

-

Operator accounts. Approve pending sign-ins, promote admins, or disable access.

-
+<%= render "console/control_tabs" %> + +<%= render "console/page_header", + title: "Users", + subtitle: "Operator accounts. Approve pending sign-ins, promote admins, or disable access." %> <% if @pending.any? %>

<%= pluralize(@pending.size, "pending approval") %>

diff --git a/services/console/app/views/layouts/console.html.erb b/services/console/app/views/layouts/console.html.erb index 84a35f76b..cfa4977df 100644 --- a/services/console/app/views/layouts/console.html.erb +++ b/services/console/app/views/layouts/console.html.erb @@ -5,7 +5,71 @@ <%= csrf_meta_tags %> - + + <%# Tailwind is compiled by the standalone binary into app/assets/builds/tailwind.css. See config/tailwind.config.js for the centaur/ink palette and radii overrides. %> @@ -18,64 +82,1215 @@ - -
-
-
-
- - <%= image_tag "centaur-lockup-white.svg", alt: "Centaur", class: "h-7 w-auto glow", width: 497, height: 127 %> + <% threads_view = request.path.start_with?("/console/threads") %> + <% control_matches = [ "/console/principals", "/console/roles", "/console/secrets", "/console/credentials", "/console/oauth_apps" ] %> + <% control_matches << "/console/users" if current_user&.admin? %> + <% nav_items = [ + { label: "Control", icon: "shield-check", path: console_principals_path, matches: control_matches, root_active: true }, + { label: "Data Sync", icon: "database", path: console_etls_path, matches: [ "/console/etls" ] } + ] %> + + +
+
-
- <% if flash[:notice] %> -
<%= flash[:notice] %>
- <% end %> - <% if flash[:alert] %> -
<%= flash[:alert] %>
+ <% if current_user %> + <% end %> - <%= yield %> -
+ +
"> +
"> + <% if flash[:notice] %> +
<%= flash[:notice] %>
+ <% end %> + <% if flash[:alert] %> +
<%= flash[:alert] %>
+ <% end %> + <%= yield %> +
+
+ + diff --git a/services/console/config/routes.rb b/services/console/config/routes.rb index d6cccd347..4be0a9631 100644 --- a/services/console/config/routes.rb +++ b/services/console/config/routes.rb @@ -35,6 +35,13 @@ root "console#principals" get "console/principals", to: "console#principals", as: :console_principals get "console/principals/:id", to: "console#principal", as: :console_principal + namespace :console do + resources :threads, only: %i[index create] + # Lazily-loaded sidebar thread list (Turbo Frame src). Kept off the main + # page render so the unindexed cross-database sessions query does not block + # every console page. See ApplicationController#load_console_sidebar_threads. + get "sidebar_threads", to: "threads#sidebar", as: :sidebar_threads + end namespace :console do resources :roles, only: %i[index show new create edit update] do member do diff --git a/services/console/config/tailwind.config.js b/services/console/config/tailwind.config.js index 353c0ea8d..9bcaafe3f 100644 --- a/services/console/config/tailwind.config.js +++ b/services/console/config/tailwind.config.js @@ -22,7 +22,16 @@ module.exports = { } }, fontFamily: { - mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'] + mono: [ + 'Berkeley Mono', + 'Berkeley Mono Variable', + 'BerkeleyMono', + 'JetBrains Mono', + 'ui-monospace', + 'SFMono-Regular', + 'Menlo', + 'monospace' + ] } }, // Very small radii everywhere for the sharp, terminal-ish look. diff --git a/services/console/public/icon-dark.svg b/services/console/public/icon-dark.svg new file mode 100644 index 000000000..4bdda32e2 --- /dev/null +++ b/services/console/public/icon-dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/services/console/public/icon-light.svg b/services/console/public/icon-light.svg new file mode 100644 index 000000000..a1f184c23 --- /dev/null +++ b/services/console/public/icon-light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/services/console/test/controllers/console/etls_controller_test.rb b/services/console/test/controllers/console/etls_controller_test.rb index 885a77d42..58f59bec5 100644 --- a/services/console/test/controllers/console/etls_controller_test.rb +++ b/services/console/test/controllers/console/etls_controller_test.rb @@ -60,7 +60,7 @@ def delete_slack_archive_import(import_id) assert_redirected_to login_path end - test "renders Slack archive imports on the ETLs page" do + test "renders Slack archive imports on the Data Sync page" do @client.imports = [ { "import_id" => "sai_uploaded", @@ -85,8 +85,8 @@ def delete_slack_archive_import(import_id) get console_etls_url assert_response :ok - assert_select "h1", text: "ETLs" - assert_select "nav a[href=?]", console_etls_path, text: "ETLs" + assert_select "h1", text: "Data Sync" + assert_select "nav a[href=?]", console_etls_path, text: "Data Sync" assert_select "td", text: /export\.zip/ assert_select "th", text: "Workspace", count: 0 assert_select "span", text: "uploaded" diff --git a/services/console/test/controllers/console/threads_controller_test.rb b/services/console/test/controllers/console/threads_controller_test.rb new file mode 100644 index 000000000..5aba6fce2 --- /dev/null +++ b/services/console/test/controllers/console/threads_controller_test.rb @@ -0,0 +1,932 @@ +require "test_helper" +require "tmpdir" + +class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest + TranscriptMessage = Struct.new(:role, :parts_array, :metadata_hash, :created_at, keyword_init: true) + TranscriptSession = Struct.new(:metadata_hash, :harness_type, keyword_init: true) + ModelSession = Struct.new(:thread_key, :metadata_hash, :harness_type, keyword_init: true) + ModelExecution = Struct.new(:metadata, keyword_init: true) + TranscriptEvent = Struct.new(:event_type, :payload_hash, :created_at, keyword_init: true) + SelectedSession = Struct.new(:thread_key, keyword_init: true) + + setup do + @operator = users(:acme_admin) + post login_url, params: { email: @operator.email, password: "password123456" } + end + + test "threads page does not render composer when session database is unavailable" do + with_recent_first_error do + get console_threads_url + end + + assert_response :ok + assert_select "input[name=q]", count: 0 + assert_select ".console-main-thread-frame aside", count: 0 + # No chat selected: like the not-found state, the page renders only the + # centered empty state — no detail header. + assert_select ".console-thread-detail-header", count: 0 + assert_select "a[aria-label=?]", "New chat", count: 0 + assert_select "span[aria-label=?]", "New chat disabled", count: 0 + assert_select "textarea[name=prompt]", count: 0 + assert_select "select[name=harness_type]", count: 0 + assert_select "form[action=?]", console_threads_path, count: 0 + assert_select "body", text: /No chats yet/ + assert_select "body", text: /Chat database is unavailable/ + end + + test "blank prompt is blocked by read only mode" do + post console_threads_url, params: { prompt: " " } + + assert_redirected_to console_threads_path + assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + end + + test "threads page hides composer controls" do + with_recent_first_error do + get console_threads_url + end + + assert_response :ok + assert_select "textarea[name=prompt]", count: 0 + assert_select "form[action=?]", console_threads_path, count: 0 + assert_select "body", text: /Read-only snapshot/, count: 0 + assert_select "span[aria-label=?]", "New chat disabled", count: 0 + assert_select "a[aria-label=?]", "New chat", count: 0 + end + + test "posts are blocked without calling the session api" do + post console_threads_url, params: { prompt: "Do not run this." } + + assert_redirected_to console_threads_path + assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + end + + test "plain threads page redirects to first visible thread" do + skip_unless_session_table + + thread_key = "console:auto-select-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + + get console_threads_url + + assert_redirected_to console_threads_path(thread: thread_key) + end + + test "direct selected thread renders chat not found when the current user did not start it" do + skip_unless_session_table + + thread_key = "slack:C0DIRECT:#{SecureRandom.hex(6)}" + insert_slack_session( + thread_key, + slack_user_id: "U_OTHER", + slack_user_name: "someone-else" + ) + + # @operator has no Slack OAuth credential matching U_OTHER, so this thread is + # outside their owner scope. A direct ?thread= link must render a 404 chat + # not found state instead of surfacing it or falling back to another chat. + get console_threads_url(thread: thread_key) + + assert_response :not_found + assert_select "body", text: /Chat not found/ + # The not-found rendering carries no page header and no explainer copy — + # just the centered "Chat not found" state. + assert_select ".console-thread-detail-header", count: 0 + assert_select "body", text: /may not exist/, count: 0 + assert_select "[data-thread-panel]", count: 0 + assert_select ".console-thread-list a.console-thread-link-active[href=?]", + console_threads_path(thread: thread_key), + count: 0 + end + + test "direct link to a nonexistent thread renders chat not found" do + skip_unless_session_table + + # Even with an owned chat present, a bogus key must 404 rather than fall + # back to the first visible chat. + insert_console_session("console:owned-#{SecureRandom.hex(6)}") + + get console_threads_url(thread: "console:missing-#{SecureRandom.hex(6)}") + + assert_response :not_found + assert_select "body", text: /Chat not found/ + end + + test "slack assistant-role messages from the current Slack user render as user authored" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [ "u123" ] } + controller.instance_variable_set( + :@selected_session, + TranscriptSession.new( + metadata_hash: { + "slack_user_id" => "U123", + "slack_display_name" => "Goksu Toprak", + "slack_user_name" => "goksu" + } + ) + ) + message = TranscriptMessage.new( + role: "assistant", + parts_array: [ { "type" => "text", "text" => "Root Slack bot post" } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "U123", + "slack_display_name" => "U123" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "assistant", item[:role] + assert_equal "Goksu Toprak", item[:label] + assert_equal :end, item[:align] + assert_equal "Root Slack bot post", item[:text] + end + + test "slack message text resolves mentions from bot identity and selected actor metadata" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [ "u123" ] } + controller.instance_variable_set( + :@selected_session, + TranscriptSession.new( + metadata_hash: { + "slack_user_id" => "U123", + "slack_display_name" => "Goksu Toprak", + "slack_user_name" => "goksu" + } + ) + ) + message = TranscriptMessage.new( + role: "user", + parts_array: [ + { + "type" => "text", + "text" => "@UBOT Are you working? Also loop in <@U123>." + } + ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "is_mention" => true, + "slack_user_id" => "U123", + "slack_display_name" => "Goksu Toprak", + "slack_user_name" => "goksu" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + controller.instance_variable_set(:@selected_messages, [ message ]) + controller.instance_variable_set(:@selected_events, []) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "@ai Are you working? Also loop in @goksu.", item[:text] + end + + test "slack mention resolution prefers synced user names when available" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [] } + controller.define_singleton_method(:slack_user_display_labels_from_database) do |_user_ids| + { "u456" => "@alice" } + end + message = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "cc @U456" } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "U123" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + controller.instance_variable_set(:@selected_session, TranscriptSession.new(metadata_hash: {})) + controller.instance_variable_set(:@selected_messages, [ message ]) + controller.instance_variable_set(:@selected_events, []) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "cc @alice", item[:text] + end + + test "slack messages from other actors keep their author label" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [ "u123" ] } + controller.instance_variable_set( + :@selected_session, + TranscriptSession.new(metadata_hash: { "slack_user_id" => "U123" }) + ) + message = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "Another person replied" } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "U456", + "slack_display_name" => "Alice" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "Alice", item[:label] + assert_equal :start, item[:align] + end + + test "slack messages from selected thread owner still show author when not current Slack user" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [ "u999" ] } + controller.define_singleton_method(:slack_mention_labels_by_id) { { "u123" => "@goksu" } } + controller.instance_variable_set( + :@selected_session, + TranscriptSession.new( + metadata_hash: { + "slack_user_id" => "U123", + "slack_display_name" => "Goksu Toprak", + "slack_user_name" => "goksu" + } + ) + ) + message = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "Owner message in a direct linked thread" } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "U123", + "slack_display_name" => "U123" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "@goksu", item[:label] + assert_equal :start, item[:align] + end + + test "slack bot messages use configured bot username as author label" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [] } + mention = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "@UBOT Please check this." } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "is_mention" => true, + "slack_user_id" => "U123" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + bot_message = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "Working on it." } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "UBOT", + "slack_display_name" => "UBOT" + }, + created_at: Time.zone.parse("2026-06-26 17:16:58 UTC") + ) + controller.instance_variable_set(:@selected_session, TranscriptSession.new(metadata_hash: {})) + controller.instance_variable_set(:@selected_messages, [ mention, bot_message ]) + controller.instance_variable_set(:@selected_events, []) + + item = controller.send(:transcript_item_for_message, bot_message) + + assert_equal "@ai", item[:label] + assert_equal :start, item[:align] + end + + test "terminal execution events render as bot output" do + controller = Console::ThreadsController.new + event = TranscriptEvent.new( + event_type: "session.execution_completed", + payload_hash: { "result_text" => "The issue is real for @U123." }, + created_at: Time.zone.parse("2026-06-26 17:16:44 UTC") + ) + controller.define_singleton_method(:slack_user_display_labels_from_database) do |_user_ids| + { "u123" => "@goksu" } + end + controller.instance_variable_set(:@selected_session, TranscriptSession.new(metadata_hash: {})) + controller.instance_variable_set(:@selected_messages, []) + controller.instance_variable_set(:@selected_events, [ event ]) + + item = controller.send(:transcript_item_for_event, event) + + assert_equal "assistant", item[:role] + assert_equal "@ai", item[:label] + assert_equal :start, item[:align] + assert_equal "The issue is real for @goksu.", item[:text] + end + + test "generated thread title strips slack mentions and clips to assistant title length" do + controller = Console::ThreadsController.new + title = controller.send( + :generated_thread_title, + "@U0ANX3AM5RR Approach truth-seeking to max and let me know if this is actually " \ + "a legit issue with extra context that should not fit" + ) + + assert_not_includes title, "@U0ANX3AM5RR" + assert title.start_with?("Approach truth-seeking") + assert_operator title.length, :<=, 80 + assert title.end_with?("...") + end + + test "thread title prefers stored summary metadata when present" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "summary" => { "title" => "Investigate rollout failure" } }, + harness_type: "codex" + ) + + assert_equal "Investigate rollout failure", controller.send(:thread_title, session) + end + + test "thread title tolerates a plain string summary without raising" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "summary" => "a plain string" }, + harness_type: "codex" + ) + + assert_nothing_raised do + assert_equal "a plain string", controller.send(:thread_title, session) + end + end + + test "thread title tolerates a string thread metadata without raising" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "thread" => "x", "subject" => "Fallback subject" }, + harness_type: "codex" + ) + + assert_nothing_raised do + assert_equal "Fallback subject", controller.send(:thread_title, session) + end + end + + test "thread source and harness labels are display cased" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "platform" => "slack" }, + harness_type: "codex" + ) + + assert_equal "Slack", controller.send(:thread_source_label, session) + assert_equal "slack", controller.send(:thread_source_icon, session) + assert_equal "Codex", controller.send(:thread_harness_label, session) + end + + test "thread model label prefers the latest execution's recorded model override" do + controller = Console::ThreadsController.new + session = ModelSession.new( + thread_key: "slack:C1:1", + metadata_hash: {}, + harness_type: "claudecode" + ) + execution = ModelExecution.new(metadata: { "model" => "claude-sonnet-4-6" }) + controller.instance_variable_set(:@latest_executions, { "slack:C1:1" => execution }) + + assert_equal "CLAUDE-SONNET-4-6", controller.send(:thread_model_label, session) + end + + test "thread model label reads session metadata before the harness default" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "model" => "claude-fable-5" }, + harness_type: "claudecode" + ) + + assert_equal "CLAUDE-FABLE-5", controller.send(:thread_model_label, session) + end + + test "thread model label falls back to the deployment's model env override" do + controller = Console::ThreadsController.new + + with_env("CLAUDE_MODEL" => "claude-fable-5", "CODEX_MODEL" => "gpt-6") do + assert_equal "CLAUDE-FABLE-5", controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "claudecode") + ) + assert_equal "GPT-6", controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "codex") + ) + end + end + + test "thread model label falls back to the models pinned in the harness config files" do + controller = Console::ThreadsController.new + + Dir.mktmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "claude")) + FileUtils.mkdir_p(File.join(dir, "codex")) + File.write(File.join(dir, "claude", "settings.json"), { model: "claude-baked-1" }.to_json) + File.write(File.join(dir, "codex", "config.toml"), <<~TOML) + model = "gpt-baked-1" + model_reasoning_effort = "low" + TOML + + with_env("CLAUDE_MODEL" => nil, "CODEX_MODEL" => nil, "CENTAUR_HARNESS_CONFIG_DIR" => dir) do + assert_equal "CLAUDE-BAKED-1", controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "claudecode") + ) + assert_equal "GPT-BAKED-1", controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "codex") + ) + end + end + end + + test "thread model label is nil for harnesses without a fixed default" do + controller = Console::ThreadsController.new + + assert_nil controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "amp") + ) + end + + test "visible thread scope matches Slack threads owned by the current user's Slack OAuth record" do + app = oauth_apps(:acme_slack) + app.update!(client_secret: "slack-secret", labels: { "slack_team_id" => "T123" }) + create_slack_oauth_credential( + app, + subject: "UOWNER", + email: @operator.email, + labels: { "slack_team_id" => "T123" } + ) + controller = threads_controller_for(@operator) + + sql = controller.send(:visible_thread_scope).to_sql + + assert_includes sql, "thread_key LIKE 'slack:%'" + assert_includes sql, "metadata ->> 'slack_user_id'" + assert_includes sql, "uowner" + assert_includes sql, "split_part(thread_key, ':', 2)" + assert_includes sql, "t123" + end + + test "visible thread scope keeps current user's console threads without Slack OAuth" do + controller = threads_controller_for(@operator) + sql = controller.send(:visible_thread_scope).to_sql + + assert_includes sql, "thread_key LIKE 'console:%'" + assert_includes sql, @operator.email + refute_includes sql, "slack_user_id" + end + + test "visible thread scope matches Slack threads by user id when the credential has no team" do + app = oauth_apps(:acme_slack) + app.update!(client_secret: "slack-secret", labels: {}) + create_slack_oauth_credential( + app, + subject: "UOWNER", + email: @operator.email, + labels: {} + ) + controller = threads_controller_for(@operator) + + sql = controller.send(:visible_thread_scope).to_sql + + # slackbotv2 threads carry no team (slack:CHANNEL:TS keys, no slack_team_id), + # so a team-less credential still matches on slack_user_id alone; team scoping + # is added only when the credential exposes a team. + assert_includes sql, "thread_key LIKE 'slack:%'" + assert_includes sql, "metadata ->> 'slack_user_id'" + assert_includes sql, "uowner" + refute_includes sql, "split_part(thread_key, ':', 2)" + end + + test "selected session resolves a directly linked thread only within the owner scope" do + controller = Console::ThreadsController.new + owned_thread = SelectedSession.new(thread_key: "slack:C123:1782339173.755169") + scoped_relation = Object.new + scoped_relation.define_singleton_method(:where) do |thread_key:| + thread_key == owned_thread.thread_key ? [ owned_thread ] : [] + end + controller.instance_variable_set(:@starting_new_thread, false) + controller.instance_variable_set(:@sessions, []) + + # An owned key outside the base window is recovered through the scope. + controller.instance_variable_set(:@selected_thread_key, owned_thread.thread_key) + assert_equal owned_thread, controller.send(:selected_session, scoped_relation, []) + + # A key the scope does not own has no unscoped fallback, so it stays hidden. + controller.instance_variable_set(:@selected_thread_key, "slack:C999:1782339173.999999") + assert_nil controller.send(:selected_session, scoped_relation, []) + end + + test "starting a thread is blocked without calling the session api" do + post console_threads_url, params: { prompt: "Reply with PONG.", harness_type: "amp" } + + assert_redirected_to console_threads_path + assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + end + + test "posting to an existing thread is blocked without calling the session api" do + post console_threads_url, + params: { + prompt: "Continue from here.", + thread_key: "console:existing", + harness_type: "codex" + } + + assert_redirected_to console_threads_path(thread: "console:existing") + assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + end + + # Fix 6: the sidebar thread list is loaded lazily via a Turbo Frame so the + # cross-database sessions query never runs during the primary page render. + test "console pages defer the sidebar thread list to a lazy turbo frame" do + # A non-thread page must not run the sessions query during its render: if it + # did, load_console_sidebar_threads would be invoked. Track invocations and + # assert none happen while rendering the primary page. + original = ApplicationController.instance_method(:load_console_sidebar_threads) + Thread.current[:sidebar_loaded] = false + ApplicationController.send(:define_method, :load_console_sidebar_threads) do + Thread.current[:sidebar_loaded] = true + original.bind(self).call + end + + begin + get console_principals_url + + assert_response :ok + assert_not Thread.current[:sidebar_loaded], + "primary page render must not load the sidebar thread list" + assert_select "turbo-frame#console_sidebar_threads[src=?]", console_sidebar_threads_path + assert_select "turbo-frame#console_sidebar_threads[loading=?]", "lazy" + ensure + ApplicationController.send(:define_method, :load_console_sidebar_threads, original) + Thread.current[:sidebar_loaded] = nil + end + end + + test "sidebar action renders the empty thread list when the session DB is unavailable" do + with_recent_first_error do + get console_sidebar_threads_url + end + + assert_response :ok + assert_select "turbo-frame#console_sidebar_threads" + assert_select ".console-thread-empty", text: /No recent chats/ + end + + # Fix 5: selected_messages must return the NEWEST MESSAGE_LIMIT messages, in + # oldest-first display order. A previous ascending order + limit returned the + # oldest N and dropped the newest for long threads. + test "selected_messages query fetches newest messages first with a limit" do + # Building the SQL type-casts against the session_messages schema, which + # only exists where the api-rs session tables are present. + skip_unless_session_table + + relation = CentaurSessionMessage + .where(thread_key: "console:ordering") + .order(created_at: :desc, message_id: :desc) + .limit(Console::ThreadsController::MESSAGE_LIMIT) + sql = relation.to_sql + + assert_match(/ORDER BY.*created_at.*DESC.*message_id.*DESC/i, sql) + assert_match(/LIMIT #{Console::ThreadsController::MESSAGE_LIMIT}\b/, sql) + end + + test "selected_messages returns newest messages in ascending display order" do + skip_unless_session_table + + thread_key = "console:transcript-order" + insert_console_session(thread_key) + + limit = Console::ThreadsController::MESSAGE_LIMIT + total = limit + 5 + total.times do |i| + insert_session_message(thread_key, index: i) + end + + controller = Console::ThreadsController.new + controller.instance_variable_set(:@selected_session, SelectedSession.new(thread_key: thread_key)) + + messages = controller.send(:selected_messages) + + assert_equal limit, messages.size + indices = messages.map { |m| m.message_id.split("-").last.to_i } + # Oldest-first display order over the newest `limit` messages: the earliest + # (index 0..4) are dropped, and what remains is ascending. + assert_equal (total - limit...total).to_a, indices + assert_equal indices, indices.sort + end + + OutputLineEvent = Struct.new(:payload, :created_at, keyword_init: true) + + test "thinking transcript item is extracted from a completed reasoning output line" do + controller = Console::ThreadsController.new + line = { + method: "item/completed", + params: { + item: { + type: "reasoning", + content: [ "First I will check the schema.", "Then write the query." ] + } + } + }.to_json + event = OutputLineEvent.new(payload: line, created_at: Time.zone.parse("2026-06-26 17:15:58 UTC")) + + item = controller.send(:thinking_transcript_item, event) + + assert_equal "thinking", item[:role] + assert_equal "Thinking", item[:label] + assert_equal :thinking, item[:source] + assert_equal :start, item[:align] + assert_equal "First I will check the schema.\nThen write the query.", item[:text] + assert_equal event.created_at, item[:created_at] + end + + test "thinking extraction accepts dot-form types and summary-only reasoning" do + controller = Console::ThreadsController.new + line = { + type: "item.completed", + item: { type: "reasoning", summary: [ { text: "Condensed thought." } ] } + }.to_json + event = OutputLineEvent.new(payload: line, created_at: Time.zone.now) + + item = controller.send(:thinking_transcript_item, event) + + assert_equal "Condensed thought.", item[:text] + end + + test "thinking extraction ignores non-reasoning and non-completed output lines" do + controller = Console::ThreadsController.new + now = Time.zone.now + + delta = { method: "item/reasoning/textDelta", params: { delta: "partial" } }.to_json + tool = { method: "item/completed", params: { item: { type: "mcpToolCall" } } }.to_json + non_json = "plain stdout noise mentioning reasoning" + non_string = { "result" => "reasoning" } + + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: delta, created_at: now)) + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: tool, created_at: now)) + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: non_json, created_at: now)) + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: non_string, created_at: now)) + end + + test "requested thread keys are deduped, stripped, and capped at the panel limit" do + controller = Console::ThreadsController.new + controller.params = ActionController::Parameters.new( + thread: " a , b,a,, c ,d,e " + ) + + assert_equal %w[a b c d], controller.send(:requested_thread_keys) + end + + test "thinking trace renders as a collapsed disclosure in the transcript" do + skip_unless_session_table + + thread_key = "console:thinking-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + insert_session_message(thread_key, index: 0) + insert_reasoning_event(thread_key, text: "I should compare the two schemas before answering.") + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "details.console-thinking summary", text: /Thinking/ + assert_select "details.console-thinking", text: /compare the two schemas/ + end + + test "split view renders owned panes as panels and drops unowned keys" do + skip_unless_session_table + + primary_key = "console:panel-a-#{SecureRandom.hex(6)}" + pane_key = "console:panel-b-#{SecureRandom.hex(6)}" + unowned_key = "slack:C0PANEL:#{SecureRandom.hex(6)}" + insert_console_session(primary_key) + insert_console_session(pane_key) + insert_slack_session(unowned_key, slack_user_id: "U_OTHER", slack_user_name: "someone-else") + + get console_threads_url(thread: [ primary_key, pane_key, unowned_key ].join(",")) + + assert_response :ok + assert_select "[data-thread-panel]", count: 2 + assert_select "[data-thread-panel=?]", primary_key + assert_select "[data-thread-panel=?]", pane_key + assert_select "[data-thread-panel=?]", unowned_key, count: 0 + # Each panel exposes a close control back to the remaining threads. + assert_select "[data-thread-panel] a[aria-label='Close panel']", count: 2 + end + + test "split view caps the grid at four panels" do + skip_unless_session_table + + keys = Array.new(5) { |i| "console:panel-cap-#{i}-#{SecureRandom.hex(4)}" } + keys.each { |key| insert_console_session(key) } + + get console_threads_url(thread: keys.join(",")) + + assert_response :ok + assert_select "[data-thread-panel]", count: Console::ThreadsController::PANEL_LIMIT + end + + test "single thread view does not render the split grid" do + skip_unless_session_table + + thread_key = "console:solo-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "[data-thread-panel]", count: 0 + # column-reverse scroll container opens the thread at its newest message. + assert_select "#thread-transcript-scroll.console-transcript-scroll" + end + + test "sidebar thread links carry the cmd-click split view hook" do + skip_unless_session_table + + thread_key = "console:sidebar-split-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + + get console_sidebar_threads_url + + assert_response :ok + # The layout's Cmd/Ctrl-click handler targets this attribute to add the + # thread to the split-view grid. + assert_select "a[data-console-thread-link][href=?]", + console_threads_path(thread: thread_key) + end + + # The sidebar list loads out of band via a lazy Turbo Frame, so the page must + # forward the current thread selection on the frame src for the active + # highlight to render. + test "threads page forwards the thread selection to the sidebar frame src" do + skip_unless_session_table + + thread_key = "console:sidebar-active-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "turbo-frame#console_sidebar_threads[src=?]", + console_sidebar_threads_path(thread: thread_key) + end + + test "sidebar highlights every open thread of a split view" do + skip_unless_session_table + + keys = Array.new(2) { |i| "console:sidebar-open-#{i}-#{SecureRandom.hex(4)}" } + keys.each { |key| insert_console_session(key) } + + get console_sidebar_threads_url(thread: keys.join(",")) + + assert_response :ok + # Open threads carry their 1-based pane number in grid order; no filled + # pill on thread rows. + assert_select "a.console-thread-link-open[data-console-pane-index='1'][href=?]", + console_threads_path(thread: keys.first) + assert_select "a.console-thread-link-open[data-console-pane-index='2'][href=?]", + console_threads_path(thread: keys.last) + assert_select "a.console-thread-link-active", count: 0 + end + + test "split view close control drops one thread and keeps the rest open" do + skip_unless_session_table + + keys = Array.new(3) { |i| "console:panel-close-#{i}-#{SecureRandom.hex(4)}" } + keys.each { |key| insert_console_session(key) } + + get console_threads_url(thread: keys.join(",")) + + assert_response :ok + # Closing the middle panel keeps the primary and the last pane. + assert_select "[data-thread-panel=?] a[aria-label='Close panel'][href=?]", + keys[1], + console_threads_path(thread: [ keys[0], keys[2] ].join(",")) + # Closing the primary panel promotes the next thread to primary. + assert_select "[data-thread-panel=?] a[aria-label='Close panel'][href=?]", + keys[0], + console_threads_path(thread: [ keys[1], keys[2] ].join(",")) + end + + private + + # Sets each env var for the block (nil deletes) and restores the previous + # values afterwards. + def with_env(overrides) + previous = overrides.keys.index_with { |name| ENV[name] } + overrides.each { |name, value| value.nil? ? ENV.delete(name) : ENV[name] = value } + yield + ensure + previous.each { |name, value| value.nil? ? ENV.delete(name) : ENV[name] = value } + end + + def with_recent_first_error + singleton = class << CentaurSession; self; end + original = CentaurSession.method(:recent_first) + singleton.define_method(:recent_first) { raise ActiveRecord::ConnectionNotEstablished } + yield + ensure + singleton.define_method(:recent_first, original) + end + + def threads_controller_for(user) + Console::ThreadsController.new.tap do |controller| + controller.define_singleton_method(:current_user) { user } + end + end + + def create_slack_oauth_credential(app, subject:, email:, labels: {}) + BrokerCredential.create!( + namespace: app.credential_namespace, + oauth_app: app, + provider_subject: subject, + provider_email: email, + labels: labels, + token_endpoint: app.provider_strategy.token_endpoint, + refresh_token: "refresh-#{subject}", + access_token: "access-#{subject}", + expires_at: 1.hour.from_now, + last_refresh: Time.current, + external_user_key: "user-#{subject}" + ) + end + + def insert_console_session(thread_key) + connection = CentaurSession.connection + metadata = { platform: "console", actor_email: @operator.email }.to_json + insert_session(thread_key, metadata) + end + + def skip_unless_session_table + skip("api-rs session tables are unavailable") unless CentaurSession.connection.data_source_exists?("sessions") + end + + def insert_slack_session(thread_key, slack_user_id:, slack_user_name:) + metadata = { + source: "slackbotv2", + platform: "slack", + thread_id: thread_key, + slack_user_id: slack_user_id, + slack_user_name: slack_user_name + }.to_json + insert_session(thread_key, metadata) + end + + def insert_session_message(thread_key, index:) + connection = CentaurSession.connection + parts = [ { type: "text", text: "message #{index}" } ].to_json + connection.execute(<<~SQL.squish) + insert into session_messages (message_id, thread_key, role, parts, metadata, created_at) + values ( + #{connection.quote("#{thread_key}-msg-#{index}")}, + #{connection.quote(thread_key)}, + 'user', + #{connection.quote(parts)}::jsonb, + '{}'::jsonb, + now() + (#{index} * interval '1 second') + ) + SQL + end + + # Mirrors how api-rs persists harness stdout: the payload column is a + # JSON-encoded *string* holding one protocol notification line. + def insert_reasoning_event(thread_key, text:) + connection = CentaurSession.connection + line = { + method: "item/completed", + params: { item: { type: "reasoning", content: [ text ] } } + }.to_json + connection.execute(<<~SQL.squish) + insert into session_events (thread_key, event_type, payload, created_at) + values ( + #{connection.quote(thread_key)}, + 'session.output.line', + #{connection.quote(line.to_json)}::jsonb, + now() + ) + SQL + end + + def insert_session(thread_key, metadata) + connection = CentaurSession.connection + connection.execute(<<~SQL.squish) + insert into sessions (thread_key, harness_type, status, metadata, created_at, updated_at) + values ( + #{connection.quote(thread_key)}, + 'codex', + 'active', + #{connection.quote(metadata)}::jsonb, + now() + interval '1 day', + now() + interval '1 day' + ) + SQL + end +end diff --git a/services/console/test/controllers/console/users_controller_test.rb b/services/console/test/controllers/console/users_controller_test.rb index d0944e934..85edda931 100644 --- a/services/console/test/controllers/console/users_controller_test.rb +++ b/services/console/test/controllers/console/users_controller_test.rb @@ -25,6 +25,16 @@ def sign_in(user) get console_users_url assert_response :ok assert_select "td", /pending@acme.example/ + assert_select ".console-nav-link", text: "Control" + assert_select ".console-nav-link", text: "Apps", count: 0 + assert_select ".console-nav-link", text: "Users", count: 0 + assert_select ".console-control-tab", text: "Apps" + assert_select ".console-control-tab-active", text: "Users" + assert_select "button[data-console-theme-toggle]", text: "Light mode" + assert_select "link[data-console-favicon][href=?]", "/icon-dark.svg" + assert_includes response.body, "/icon-light.svg" + assert_includes response.body, "prefers-color-scheme: light" + assert_includes response.body, "centaur-console-theme-source" end test "the index shows IdP chips for linked identities and a password chip otherwise" do diff --git a/services/console/test/helpers/application_helper_test.rb b/services/console/test/helpers/application_helper_test.rb index a04d35d3b..9dc509fa1 100644 --- a/services/console/test/helpers/application_helper_test.rb +++ b/services/console/test/helpers/application_helper_test.rb @@ -1,4 +1,5 @@ require "test_helper" +require "timeout" class ApplicationHelperTest < ActionView::TestCase test "truncate_middle leaves short values unchanged" do @@ -33,10 +34,58 @@ class ApplicationHelperTest < ActionView::TestCase assert_select_in html, "time[data-localtime-relative-value=true]" end + test "local_time can request compact relative formatting" do + html = local_time(Time.utc(2026, 6, 4, 18, 30, 0), relative: true, format: :compact) + + assert_select_in html, "time[data-localtime-relative-value=true]" + assert_select_in html, "time[data-localtime-format-value=compact]" + end + test "local_time renders a placeholder for nil" do assert_select_in local_time(nil), "span", text: "—" end + test "console_markdown renders common github-flavored markdown" do + html = console_markdown(<<~MARKDOWN) + Yes, **partially legit**. + + Issue 1 is real on current `main`. + + - one + - two + + https://github.com/paradigmxyz/centaur/issues/792 + MARKDOWN + + assert_select_in html, "p", text: /Yes, partially legit/ + assert_select_in html, "strong", text: "partially legit" + assert_select_in html, "code", text: "main" + assert_select_in html, "ul li", count: 2 + assert_select_in html, "a.console-markdown-link[href='https://github.com/paradigmxyz/centaur/issues/792']", + text: "https://github.com/paradigmxyz/centaur/issues/792" + end + + test "console_markdown escapes unsafe html" do + html = console_markdown(" **safe**") + + refute_includes html, " |") + + refute_includes html, " **safe**") From 35ce829bc39c19e332d05d825c978080b8077aad Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Thu, 2 Jul 2026 14:58:49 -0700 Subject: [PATCH 045/198] Add interview prep tool (#886) Co-authored-by: Centaur AI --- tools/business/interview-prep/.env.example | 4 + .../centaur_tool_interview_prep/__init__.py | 1 + .../centaur_tool_interview_prep/cli.py | 87 ++++ .../centaur_tool_interview_prep/client.py | 443 ++++++++++++++++++ tools/business/interview-prep/pyproject.toml | 39 ++ tools/business/interview-prep/test_client.py | 73 +++ 6 files changed, 647 insertions(+) create mode 100644 tools/business/interview-prep/.env.example create mode 100644 tools/business/interview-prep/centaur_tool_interview_prep/__init__.py create mode 100644 tools/business/interview-prep/centaur_tool_interview_prep/cli.py create mode 100644 tools/business/interview-prep/centaur_tool_interview_prep/client.py create mode 100644 tools/business/interview-prep/pyproject.toml create mode 100644 tools/business/interview-prep/test_client.py diff --git a/tools/business/interview-prep/.env.example b/tools/business/interview-prep/.env.example new file mode 100644 index 000000000..5a6aef02e --- /dev/null +++ b/tools/business/interview-prep/.env.example @@ -0,0 +1,4 @@ +ASHBY_API_KEY=Ashby API key with candidate, application, interview, feedback, and user read access +SLACK_BOT_TOKEN=Slack bot token with users:read.email +GOOGLE_TOKEN_JSON=Google OAuth authorized-user JSON with calendar read access +INTERVIEW_PREP_CALENDAR_ID=c_5d7gf9ut9magpm8vta36608i40@group.calendar.google.com diff --git a/tools/business/interview-prep/centaur_tool_interview_prep/__init__.py b/tools/business/interview-prep/centaur_tool_interview_prep/__init__.py new file mode 100644 index 000000000..9e4bfaad2 --- /dev/null +++ b/tools/business/interview-prep/centaur_tool_interview_prep/__init__.py @@ -0,0 +1 @@ +"""Interview prep tool.""" diff --git a/tools/business/interview-prep/centaur_tool_interview_prep/cli.py b/tools/business/interview-prep/centaur_tool_interview_prep/cli.py new file mode 100644 index 000000000..0335562c1 --- /dev/null +++ b/tools/business/interview-prep/centaur_tool_interview_prep/cli.py @@ -0,0 +1,87 @@ +"""CLI for interview prep briefs.""" + +from dotenv import load_dotenv + +load_dotenv() + +import json +import os +import sys + +import typer +from rich.console import Console + +from .client import InterviewPrepClient + +app = typer.Typer(name="interview-prep", help="Permission-gated interview prep briefs") +console = Console() + + +def _render_markdown(data: dict) -> str: + if not data.get("access_granted"): + return ( + f"Access denied for {data.get('requester_email') or 'unknown requester'}: " + f"{data.get('reason')}" + ) + + lines = [] + candidate = data["candidate"]["name"] + lines.append(f"**Interview prep: {candidate}**") + lines.append("") + if data.get("upcoming_interviews"): + lines.append("**Upcoming interview**") + for event in data["upcoming_interviews"]: + lines.append(f"- {event['start']}: {event['format']}") + else: + lines.append("**Upcoming interview:** none found on the interviews calendar.") + lines.append("") + lines.append(f"**Background:** {data['background_summary']}") + previous = data.get("previous_interviews") or [] + lines.append("") + lines.append( + "**Previous interviews:** " + (", ".join(previous) if previous else "none visible in Ashby/calendar.") + ) + lines.append("") + lines.append(f"**Cover:** {data['focus']}") + return "\n".join(lines) + + +@app.command() +def brief( + candidate_name: str = typer.Argument(..., help="Candidate name, e.g. 'Lot Kwarteng'"), + slack_user_id: str | None = typer.Option( + None, + "--slack-user-id", + help="Slack user ID of requester. Defaults to SLACK_REQUESTER_ID.", + ), + requester_email: str | None = typer.Option( + None, + "--requester-email", + help="Requester email override for tests/admin use.", + ), + days_ahead: int = typer.Option(30, "--days-ahead", help="Upcoming schedule window"), + json_output: bool = typer.Option(False, "--json", help="Output JSON"), +): + """Generate a brief after checking requester access.""" + slack_user_id = slack_user_id or os.environ.get("SLACK_REQUESTER_ID", "").strip() or None + client = InterviewPrepClient() + try: + data = client.brief( + candidate_name, + slack_user_id=slack_user_id, + requester_email=requester_email, + days_ahead=days_ahead, + ) + finally: + client.close() + + if json_output: + print(json.dumps(data, indent=2, default=str), file=sys.stdout) + raise typer.Exit(0 if data.get("access_granted") else 1) + + console.print(_render_markdown(data)) + raise typer.Exit(0 if data.get("access_granted") else 1) + + +if __name__ == "__main__": + app() diff --git a/tools/business/interview-prep/centaur_tool_interview_prep/client.py b/tools/business/interview-prep/centaur_tool_interview_prep/client.py new file mode 100644 index 000000000..9ef56cd12 --- /dev/null +++ b/tools/business/interview-prep/centaur_tool_interview_prep/client.py @@ -0,0 +1,443 @@ +"""Permission-gated interview prep briefs from Ashby and Google Calendar.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import urlsplit + +import httplib2 +import httpx +import socks +from centaur_sdk import secret +from googleapiclient.discovery import build + +ASHBY_BASE_URL = "https://api.ashbyhq.com" +DEFAULT_INTERVIEWS_CALENDAR_ID = "c_5d7gf9ut9magpm8vta36608i40@group.calendar.google.com" + +try: + from api.integrations.gsuite.http import build_http as _shared_build_http +except ModuleNotFoundError: + _shared_build_http = None + + +def _build_google_http() -> httplib2.Http: + if _shared_build_http is not None: + return _shared_build_http() + + proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") + proxy_info = None + if proxy_url: + parts = urlsplit(proxy_url) + proxy_info = httplib2.ProxyInfo( + proxy_type=socks.PROXY_TYPE_HTTP, + proxy_host=parts.hostname, + proxy_port=parts.port or 8080, + ) + ca_certs = os.environ.get("SSL_CERT_FILE") or os.environ.get("REQUESTS_CA_BUNDLE") + return httplib2.Http(proxy_info=proxy_info, ca_certs=ca_certs) + + +def _calendar_service(): + return build("calendar", "v3", http=_build_google_http()) + + +def _parse_dt(value: str) -> datetime | None: + if not value: + return None + normalized = value.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _full_name(user: dict[str, Any] | None) -> str: + if not user: + return "" + name = f"{user.get('firstName', '')} {user.get('lastName', '')}".strip() + return name or user.get("name", "") or user.get("email", "") + + +def _lower(value: str | None) -> str: + return (value or "").strip().lower() + + +@dataclass +class AccessDecision: + granted: bool + reason: str + requester_email: str | None = None + requester_ashby_user: dict[str, Any] | None = None + + +class InterviewPrepClient: + """Build interview prep briefs with requester-level authorization.""" + + def __init__( + self, + ashby_api_key: str | None = None, + slack_bot_token: str | None = None, + calendar_id: str | None = None, + timeout: float = 30.0, + ): + self.ashby_api_key = ashby_api_key or secret("ASHBY_API_KEY", "") + self.slack_bot_token = slack_bot_token or secret("SLACK_BOT_TOKEN", "") + self.calendar_id = ( + calendar_id + or os.environ.get("INTERVIEW_PREP_CALENDAR_ID", "").strip() + or DEFAULT_INTERVIEWS_CALENDAR_ID + ) + self.timeout = timeout + self._http = httpx.Client(timeout=timeout) + + def _ashby_request(self, endpoint: str, data: dict[str, Any] | None = None) -> dict[str, Any]: + if not self.ashby_api_key: + raise RuntimeError("ASHBY_API_KEY not set") + response = self._http.post( + f"{ASHBY_BASE_URL}/{endpoint}", + json=data or {}, + auth=(self.ashby_api_key, ""), + headers={"Accept": "application/json; version=1", "Content-Type": "application/json"}, + ) + if response.status_code == 401: + raise RuntimeError("Ashby API key is missing or invalid") + if response.status_code == 403: + raise RuntimeError("Ashby API key lacks required permissions") + result = response.json() + if not result.get("success", True): + errors = result.get("errors", []) + messages = [ + e.get("message", str(e)) if isinstance(e, dict) else str(e) + for e in errors + ] + raise RuntimeError(f"Ashby API error: {'; '.join(messages)}") + return result + + def _ashby_paginate( + self, endpoint: str, data: dict[str, Any] | None = None, limit: int = 100 + ) -> list[dict[str, Any]]: + payload = dict(data or {}) + payload["limit"] = min(limit, 100) + results: list[dict[str, Any]] = [] + cursor = None + while len(results) < limit: + request = dict(payload) + if cursor: + request["cursor"] = cursor + page = self._ashby_request(endpoint, request) + results.extend(page.get("results", [])) + if not page.get("moreDataAvailable"): + break + cursor = page.get("nextCursor") + if not cursor: + break + return results[:limit] + + def _slack_user_email(self, slack_user_id: str) -> str | None: + if not self.slack_bot_token: + return None + response = self._http.get( + "https://slack.com/api/users.info", + params={"user": slack_user_id}, + headers={"Authorization": f"Bearer {self.slack_bot_token}"}, + ) + data = response.json() + if not data.get("ok"): + return None + return data.get("user", {}).get("profile", {}).get("email") + + def _calendar_events( + self, + query: str, + start: datetime, + end: datetime, + max_results: int = 50, + ) -> list[dict[str, Any]]: + service = _calendar_service() + result = ( + service.events() + .list( + calendarId=self.calendar_id, + q=query, + timeMin=start.isoformat(), + timeMax=end.isoformat(), + maxResults=max_results, + singleEvents=True, + orderBy="startTime", + ) + .execute() + ) + events = [] + for event in result.get("items", []): + events.append( + { + "id": event.get("id", ""), + "summary": event.get("summary", ""), + "start": event.get("start", {}).get("dateTime") + or event.get("start", {}).get("date", ""), + "end": event.get("end", {}).get("dateTime") + or event.get("end", {}).get("date", ""), + "location": event.get("location", ""), + "description": event.get("description", ""), + "attendees": [a.get("email", "") for a in event.get("attendees", [])], + "html_link": event.get("htmlLink", ""), + } + ) + return events + + def _candidate_search(self, name: str) -> list[dict[str, Any]]: + return self._ashby_request("candidate.search", {"name": name}).get("results", []) + + def _candidate(self, candidate_id: str) -> dict[str, Any] | None: + return self._ashby_request("candidate.info", {"id": candidate_id}).get("results") + + def _application(self, application_id: str) -> dict[str, Any] | None: + return self._ashby_request("application.info", {"applicationId": application_id}).get( + "results" + ) + + def _users(self, limit: int = 500) -> list[dict[str, Any]]: + return self._ashby_paginate("user.list", {"includeDeactivated": True}, limit=limit) + + def _feedback(self, application_id: str, limit: int = 100) -> list[dict[str, Any]]: + return self._ashby_paginate( + "applicationFeedback.list", {"applicationId": application_id}, limit=limit + ) + + def _interview_events(self, limit: int = 500) -> list[dict[str, Any]]: + return self._ashby_paginate("interviewEvent.list", limit=limit) + + def _requester_email( + self, slack_user_id: str | None = None, requester_email: str | None = None + ) -> str | None: + if requester_email: + return requester_email.strip().lower() + env_email = os.environ.get("SLACK_REQUESTER_EMAIL", "").strip().lower() + if env_email: + return env_email + slack_id = slack_user_id or os.environ.get("SLACK_REQUESTER_ID", "").strip() + if slack_id: + return self._slack_user_email(slack_id) + return None + + def _is_admin(self, user: dict[str, Any]) -> bool: + role = " ".join( + str(user.get(key, "")) + for key in ("globalRole", "role", "accessRole", "roleName") + ).lower() + return "admin" in role + + def _authorize( + self, + requester_email: str | None, + application: dict[str, Any], + feedback: list[dict[str, Any]], + schedule: list[dict[str, Any]], + ) -> AccessDecision: + if not requester_email: + return AccessDecision(False, "Could not resolve the Slack requester email") + + users = self._users() + requester = next((u for u in users if _lower(u.get("email")) == requester_email), None) + if not requester: + return AccessDecision(False, "Requester is not an Ashby user", requester_email) + if self._is_admin(requester): + return AccessDecision(True, "Requester is an Ashby admin", requester_email, requester) + + team_emails = { + _lower(member.get("email")) + for member in application.get("hiringTeam", []) + if member.get("email") + } + if requester_email in team_emails: + return AccessDecision( + True, "Requester is on the candidate's Ashby hiring team", requester_email, requester + ) + + feedback_emails = { + _lower(fb.get("submittedByUser", {}).get("email")) + for fb in feedback + if fb.get("submittedByUser", {}).get("email") + } + if requester_email in feedback_emails: + return AccessDecision( + True, "Requester submitted feedback for this candidate", requester_email, requester + ) + + attendee_emails = { + _lower(email) + for event in schedule + for email in event.get("attendees", []) + if email + } + if requester_email in attendee_emails: + return AccessDecision( + True, "Requester is an interviewer on the candidate calendar event", requester_email, requester + ) + + return AccessDecision( + False, + "Requester is not an Ashby admin, hiring-team member, feedback submitter, or scheduled interviewer for this candidate", + requester_email, + requester, + ) + + def _candidate_summary(self, candidate: dict[str, Any]) -> str: + role = candidate.get("position") or "candidate" + company = candidate.get("company") + school = candidate.get("school") + location = candidate.get("location", {}).get("locationSummary") + sentence = f"{candidate.get('name')} is a {role}" + if company: + sentence += f" at {company}" + if location: + sentence += f" based in {location}" + sentence += "." + second = "Ashby" + if school: + second += f" lists {school} as his school" + links = candidate.get("socialLinks", []) + if links: + second += " and includes a LinkedIn profile" + if second == "Ashby": + second += " has limited background detail beyond the current role" + second += "." + return f"{sentence} {second}" + + def _event_format(self, event: dict[str, Any]) -> dict[str, Any]: + start = _parse_dt(event.get("start", "")) + end = _parse_dt(event.get("end", "")) + duration = None + if start and end: + duration = int((end - start).total_seconds() // 60) + location = event.get("location", "") or "" + text = "Zoom" if "zoom" in location.lower() else "in person" if location else "scheduled" + if duration: + text = f"{duration} minute {text} interview" + if location and "zoom" not in location.lower(): + text += f" at {location}" + return { + "summary": event.get("summary", ""), + "start": event.get("start", ""), + "end": event.get("end", ""), + "duration_minutes": duration, + "medium": "Zoom" if "zoom" in location.lower() else "in person" if location else "unknown", + "location": location, + "format": text, + } + + def _previous_interviews( + self, candidate_name: str, feedback: list[dict[str, Any]], schedule: list[dict[str, Any]] + ) -> list[str]: + now = datetime.now(timezone.utc) + previous: list[str] = [] + seen: set[str] = set() + for fb in feedback: + user = _full_name(fb.get("submittedByUser", {})) + submitted = fb.get("submittedAt") or fb.get("createdAt") or "" + date = submitted[:10] if submitted else "" + label = f"met with {user} {date}" if user and date else user or date + if label and label not in seen: + seen.add(label) + previous.append(label) + for event in schedule: + start = _parse_dt(event.get("start", "")) + summary = event.get("summary", "") + if not start or start >= now or candidate_name.lower() not in summary.lower(): + continue + date = start.strftime("%-m/%-d") if hasattr(start, "strftime") else event["start"][:10] + label = f"{summary} {date}" + if label not in seen: + seen.add(label) + previous.append(label) + return previous[:5] + + def _focus_areas(self, application: dict[str, Any], feedback: list[dict[str, Any]]) -> str: + job_title = application.get("job", {}).get("title", "") + stage_title = application.get("currentInterviewStage", {}).get("title", "") + concerns: list[str] = [] + for fb in feedback: + for key in ("overallSummary", "summary", "notes", "privateNotes"): + value = fb.get(key) + if isinstance(value, str) and value.strip(): + concerns.append(value.strip()) + if concerns: + return "Follow up on prior feedback themes: " + "; ".join(concerns[:2]) + if "government" in job_title.lower() or "policy" in stage_title.lower(): + return ( + "Democratic congressional relationships, crypto policy judgment, pace, " + "horsepower, and ability to turn DC context into specific tactics." + ) + return "Validate role-specific judgment, pace, horsepower, and any gaps not covered in prior interviews." + + def brief( + self, + candidate_name: str, + slack_user_id: str | None = None, + requester_email: str | None = None, + days_ahead: int = 30, + ) -> dict[str, Any]: + """Generate a permission-gated interview prep brief.""" + matches = self._candidate_search(candidate_name) + if not matches: + raise RuntimeError(f"No Ashby candidate found for {candidate_name!r}") + candidate = self._candidate(matches[0]["id"]) or matches[0] + application_ids = candidate.get("applicationIds", []) + if not application_ids: + raise RuntimeError(f"Candidate {candidate.get('name')} has no Ashby applications") + application = self._application(application_ids[0]) + if not application: + raise RuntimeError(f"Application {application_ids[0]} was not found") + + now = datetime.now(timezone.utc) + schedule = self._calendar_events(candidate.get("name", candidate_name), now - timedelta(days=45), now + timedelta(days=days_ahead)) + upcoming = [event for event in schedule if (_parse_dt(event.get("start", "")) or now) >= now] + feedback = self._feedback(application["id"]) + email = self._requester_email(slack_user_id=slack_user_id, requester_email=requester_email) + decision = self._authorize(email, application, feedback, schedule) + if not decision.granted: + return { + "access_granted": False, + "reason": decision.reason, + "requester_email": decision.requester_email, + "candidate_name": candidate.get("name"), + } + + return { + "access_granted": True, + "access_reason": decision.reason, + "requester_email": decision.requester_email, + "candidate": { + "id": candidate.get("id"), + "name": candidate.get("name"), + "profile_url": candidate.get("profileUrl"), + }, + "application": { + "id": application.get("id"), + "job": application.get("job", {}).get("title"), + "stage": application.get("currentInterviewStage", {}).get("title"), + }, + "upcoming_interviews": [self._event_format(event) for event in upcoming], + "background_summary": self._candidate_summary(candidate), + "previous_interviews": self._previous_interviews(candidate.get("name", candidate_name), feedback, schedule), + "focus": self._focus_areas(application, feedback), + } + + def close(self): + self._http.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +def _client() -> InterviewPrepClient: + return InterviewPrepClient() diff --git a/tools/business/interview-prep/pyproject.toml b/tools/business/interview-prep/pyproject.toml new file mode 100644 index 000000000..2efb76078 --- /dev/null +++ b/tools/business/interview-prep/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "interview-prep" +description = "Generate permission-gated interview prep briefs from Ashby and Google Calendar" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "google-api-python-client>=2.100.0", + "google-auth>=2.0.0", + "httplib2>=0.20.0", + "httpx>=0.27.0", + "pysocks>=1.7.1", + "python-dotenv>=1.0.0", + "rich>=13.0.0", + "typer>=0.12.0", +] + +[project.scripts] +interview-prep = "centaur_tool_interview_prep.cli:app" + +[tool.hatch.build.targets.wheel] +packages = ["centaur_tool_interview_prep"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.centaur] +module = "centaur_tool_interview_prep/client.py" +hosts = [ + "api.ashbyhq.com", + "slack.com", + "calendar.googleapis.com", + "www.googleapis.com", +] +secrets = [ + {type = "http", name = "ASHBY_API_KEY", mode = "inject", inject_header = "Authorization", inject_formatter = 'Basic {{ base64 .Value ":" }}', hosts = ["api.ashbyhq.com"]}, + {type = "http", name = "SLACK_BOT_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com"]}, + {type = "oauth_token", grant = "refresh_token", name = "GOOGLE_TOKEN_JSON", token_endpoint = "https://oauth2.googleapis.com/token", hosts = ["calendar.googleapis.com", "www.googleapis.com"], fields = { refresh_token = { secret_ref = "GOOGLE_TOKEN_JSON", json_key = "refresh_token" }, client_id = { secret_ref = "GOOGLE_TOKEN_JSON", json_key = "client_id" }, client_secret = { secret_ref = "GOOGLE_TOKEN_JSON", json_key = "client_secret" } } }, +] diff --git a/tools/business/interview-prep/test_client.py b/tools/business/interview-prep/test_client.py new file mode 100644 index 000000000..f7287aea7 --- /dev/null +++ b/tools/business/interview-prep/test_client.py @@ -0,0 +1,73 @@ +from datetime import datetime, timedelta, timezone + +from centaur_tool_interview_prep.client import InterviewPrepClient + + +def test_admin_requester_is_authorized(): + client = InterviewPrepClient(ashby_api_key="x", slack_bot_token="") + client._users = lambda limit=500: [ # type: ignore[method-assign] + {"email": "admin@paradigm.xyz", "globalRole": "Organization Admin"} + ] + + decision = client._authorize( + "admin@paradigm.xyz", + {"hiringTeam": []}, + [], + [], + ) + + assert decision.granted is True + assert "admin" in decision.reason.lower() + + +def test_unrelated_requester_is_denied(): + client = InterviewPrepClient(ashby_api_key="x", slack_bot_token="") + client._users = lambda limit=500: [ # type: ignore[method-assign] + {"email": "user@paradigm.xyz", "globalRole": "Limited Team Member"} + ] + + decision = client._authorize( + "user@paradigm.xyz", + {"hiringTeam": []}, + [], + [], + ) + + assert decision.granted is False + assert "not an ashby admin" in decision.reason.lower() + + +def test_hiring_team_requester_is_authorized(): + client = InterviewPrepClient(ashby_api_key="x", slack_bot_token="") + client._users = lambda limit=500: [ # type: ignore[method-assign] + {"email": "interviewer@paradigm.xyz", "globalRole": "Limited Team Member"} + ] + + decision = client._authorize( + "interviewer@paradigm.xyz", + {"hiringTeam": [{"email": "interviewer@paradigm.xyz"}]}, + [], + [], + ) + + assert decision.granted is True + assert "hiring team" in decision.reason.lower() + + +def test_event_format_includes_duration_and_zoom(): + client = InterviewPrepClient(ashby_api_key="x", slack_bot_token="") + start = datetime.now(timezone.utc).replace(microsecond=0) + end = start + timedelta(minutes=30) + + formatted = client._event_format( + { + "summary": "Interview with Candidate", + "start": start.isoformat(), + "end": end.isoformat(), + "location": "https://paradigmxyz.zoom.us/j/123", + } + ) + + assert formatted["duration_minutes"] == 30 + assert formatted["medium"] == "Zoom" + assert formatted["format"] == "30 minute Zoom interview" From b3cdad1ff6ca6ca2f7166475358e2f3ea2a7ce2b Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Thu, 2 Jul 2026 15:11:12 -0700 Subject: [PATCH 046/198] Revert interview prep tool from core (#888) Revert "Add interview prep tool (#886)" This reverts commit 35ce829bc39c19e332d05d825c978080b8077aad. --- tools/business/interview-prep/.env.example | 4 - .../centaur_tool_interview_prep/__init__.py | 1 - .../centaur_tool_interview_prep/cli.py | 87 ---- .../centaur_tool_interview_prep/client.py | 443 ------------------ tools/business/interview-prep/pyproject.toml | 39 -- tools/business/interview-prep/test_client.py | 73 --- 6 files changed, 647 deletions(-) delete mode 100644 tools/business/interview-prep/.env.example delete mode 100644 tools/business/interview-prep/centaur_tool_interview_prep/__init__.py delete mode 100644 tools/business/interview-prep/centaur_tool_interview_prep/cli.py delete mode 100644 tools/business/interview-prep/centaur_tool_interview_prep/client.py delete mode 100644 tools/business/interview-prep/pyproject.toml delete mode 100644 tools/business/interview-prep/test_client.py diff --git a/tools/business/interview-prep/.env.example b/tools/business/interview-prep/.env.example deleted file mode 100644 index 5a6aef02e..000000000 --- a/tools/business/interview-prep/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -ASHBY_API_KEY=Ashby API key with candidate, application, interview, feedback, and user read access -SLACK_BOT_TOKEN=Slack bot token with users:read.email -GOOGLE_TOKEN_JSON=Google OAuth authorized-user JSON with calendar read access -INTERVIEW_PREP_CALENDAR_ID=c_5d7gf9ut9magpm8vta36608i40@group.calendar.google.com diff --git a/tools/business/interview-prep/centaur_tool_interview_prep/__init__.py b/tools/business/interview-prep/centaur_tool_interview_prep/__init__.py deleted file mode 100644 index 9e4bfaad2..000000000 --- a/tools/business/interview-prep/centaur_tool_interview_prep/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Interview prep tool.""" diff --git a/tools/business/interview-prep/centaur_tool_interview_prep/cli.py b/tools/business/interview-prep/centaur_tool_interview_prep/cli.py deleted file mode 100644 index 0335562c1..000000000 --- a/tools/business/interview-prep/centaur_tool_interview_prep/cli.py +++ /dev/null @@ -1,87 +0,0 @@ -"""CLI for interview prep briefs.""" - -from dotenv import load_dotenv - -load_dotenv() - -import json -import os -import sys - -import typer -from rich.console import Console - -from .client import InterviewPrepClient - -app = typer.Typer(name="interview-prep", help="Permission-gated interview prep briefs") -console = Console() - - -def _render_markdown(data: dict) -> str: - if not data.get("access_granted"): - return ( - f"Access denied for {data.get('requester_email') or 'unknown requester'}: " - f"{data.get('reason')}" - ) - - lines = [] - candidate = data["candidate"]["name"] - lines.append(f"**Interview prep: {candidate}**") - lines.append("") - if data.get("upcoming_interviews"): - lines.append("**Upcoming interview**") - for event in data["upcoming_interviews"]: - lines.append(f"- {event['start']}: {event['format']}") - else: - lines.append("**Upcoming interview:** none found on the interviews calendar.") - lines.append("") - lines.append(f"**Background:** {data['background_summary']}") - previous = data.get("previous_interviews") or [] - lines.append("") - lines.append( - "**Previous interviews:** " + (", ".join(previous) if previous else "none visible in Ashby/calendar.") - ) - lines.append("") - lines.append(f"**Cover:** {data['focus']}") - return "\n".join(lines) - - -@app.command() -def brief( - candidate_name: str = typer.Argument(..., help="Candidate name, e.g. 'Lot Kwarteng'"), - slack_user_id: str | None = typer.Option( - None, - "--slack-user-id", - help="Slack user ID of requester. Defaults to SLACK_REQUESTER_ID.", - ), - requester_email: str | None = typer.Option( - None, - "--requester-email", - help="Requester email override for tests/admin use.", - ), - days_ahead: int = typer.Option(30, "--days-ahead", help="Upcoming schedule window"), - json_output: bool = typer.Option(False, "--json", help="Output JSON"), -): - """Generate a brief after checking requester access.""" - slack_user_id = slack_user_id or os.environ.get("SLACK_REQUESTER_ID", "").strip() or None - client = InterviewPrepClient() - try: - data = client.brief( - candidate_name, - slack_user_id=slack_user_id, - requester_email=requester_email, - days_ahead=days_ahead, - ) - finally: - client.close() - - if json_output: - print(json.dumps(data, indent=2, default=str), file=sys.stdout) - raise typer.Exit(0 if data.get("access_granted") else 1) - - console.print(_render_markdown(data)) - raise typer.Exit(0 if data.get("access_granted") else 1) - - -if __name__ == "__main__": - app() diff --git a/tools/business/interview-prep/centaur_tool_interview_prep/client.py b/tools/business/interview-prep/centaur_tool_interview_prep/client.py deleted file mode 100644 index 9ef56cd12..000000000 --- a/tools/business/interview-prep/centaur_tool_interview_prep/client.py +++ /dev/null @@ -1,443 +0,0 @@ -"""Permission-gated interview prep briefs from Ashby and Google Calendar.""" - -from __future__ import annotations - -import os -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from typing import Any -from urllib.parse import urlsplit - -import httplib2 -import httpx -import socks -from centaur_sdk import secret -from googleapiclient.discovery import build - -ASHBY_BASE_URL = "https://api.ashbyhq.com" -DEFAULT_INTERVIEWS_CALENDAR_ID = "c_5d7gf9ut9magpm8vta36608i40@group.calendar.google.com" - -try: - from api.integrations.gsuite.http import build_http as _shared_build_http -except ModuleNotFoundError: - _shared_build_http = None - - -def _build_google_http() -> httplib2.Http: - if _shared_build_http is not None: - return _shared_build_http() - - proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") - proxy_info = None - if proxy_url: - parts = urlsplit(proxy_url) - proxy_info = httplib2.ProxyInfo( - proxy_type=socks.PROXY_TYPE_HTTP, - proxy_host=parts.hostname, - proxy_port=parts.port or 8080, - ) - ca_certs = os.environ.get("SSL_CERT_FILE") or os.environ.get("REQUESTS_CA_BUNDLE") - return httplib2.Http(proxy_info=proxy_info, ca_certs=ca_certs) - - -def _calendar_service(): - return build("calendar", "v3", http=_build_google_http()) - - -def _parse_dt(value: str) -> datetime | None: - if not value: - return None - normalized = value.replace("Z", "+00:00") - try: - parsed = datetime.fromisoformat(normalized) - except ValueError: - return None - if parsed.tzinfo is None: - return parsed.replace(tzinfo=timezone.utc) - return parsed - - -def _full_name(user: dict[str, Any] | None) -> str: - if not user: - return "" - name = f"{user.get('firstName', '')} {user.get('lastName', '')}".strip() - return name or user.get("name", "") or user.get("email", "") - - -def _lower(value: str | None) -> str: - return (value or "").strip().lower() - - -@dataclass -class AccessDecision: - granted: bool - reason: str - requester_email: str | None = None - requester_ashby_user: dict[str, Any] | None = None - - -class InterviewPrepClient: - """Build interview prep briefs with requester-level authorization.""" - - def __init__( - self, - ashby_api_key: str | None = None, - slack_bot_token: str | None = None, - calendar_id: str | None = None, - timeout: float = 30.0, - ): - self.ashby_api_key = ashby_api_key or secret("ASHBY_API_KEY", "") - self.slack_bot_token = slack_bot_token or secret("SLACK_BOT_TOKEN", "") - self.calendar_id = ( - calendar_id - or os.environ.get("INTERVIEW_PREP_CALENDAR_ID", "").strip() - or DEFAULT_INTERVIEWS_CALENDAR_ID - ) - self.timeout = timeout - self._http = httpx.Client(timeout=timeout) - - def _ashby_request(self, endpoint: str, data: dict[str, Any] | None = None) -> dict[str, Any]: - if not self.ashby_api_key: - raise RuntimeError("ASHBY_API_KEY not set") - response = self._http.post( - f"{ASHBY_BASE_URL}/{endpoint}", - json=data or {}, - auth=(self.ashby_api_key, ""), - headers={"Accept": "application/json; version=1", "Content-Type": "application/json"}, - ) - if response.status_code == 401: - raise RuntimeError("Ashby API key is missing or invalid") - if response.status_code == 403: - raise RuntimeError("Ashby API key lacks required permissions") - result = response.json() - if not result.get("success", True): - errors = result.get("errors", []) - messages = [ - e.get("message", str(e)) if isinstance(e, dict) else str(e) - for e in errors - ] - raise RuntimeError(f"Ashby API error: {'; '.join(messages)}") - return result - - def _ashby_paginate( - self, endpoint: str, data: dict[str, Any] | None = None, limit: int = 100 - ) -> list[dict[str, Any]]: - payload = dict(data or {}) - payload["limit"] = min(limit, 100) - results: list[dict[str, Any]] = [] - cursor = None - while len(results) < limit: - request = dict(payload) - if cursor: - request["cursor"] = cursor - page = self._ashby_request(endpoint, request) - results.extend(page.get("results", [])) - if not page.get("moreDataAvailable"): - break - cursor = page.get("nextCursor") - if not cursor: - break - return results[:limit] - - def _slack_user_email(self, slack_user_id: str) -> str | None: - if not self.slack_bot_token: - return None - response = self._http.get( - "https://slack.com/api/users.info", - params={"user": slack_user_id}, - headers={"Authorization": f"Bearer {self.slack_bot_token}"}, - ) - data = response.json() - if not data.get("ok"): - return None - return data.get("user", {}).get("profile", {}).get("email") - - def _calendar_events( - self, - query: str, - start: datetime, - end: datetime, - max_results: int = 50, - ) -> list[dict[str, Any]]: - service = _calendar_service() - result = ( - service.events() - .list( - calendarId=self.calendar_id, - q=query, - timeMin=start.isoformat(), - timeMax=end.isoformat(), - maxResults=max_results, - singleEvents=True, - orderBy="startTime", - ) - .execute() - ) - events = [] - for event in result.get("items", []): - events.append( - { - "id": event.get("id", ""), - "summary": event.get("summary", ""), - "start": event.get("start", {}).get("dateTime") - or event.get("start", {}).get("date", ""), - "end": event.get("end", {}).get("dateTime") - or event.get("end", {}).get("date", ""), - "location": event.get("location", ""), - "description": event.get("description", ""), - "attendees": [a.get("email", "") for a in event.get("attendees", [])], - "html_link": event.get("htmlLink", ""), - } - ) - return events - - def _candidate_search(self, name: str) -> list[dict[str, Any]]: - return self._ashby_request("candidate.search", {"name": name}).get("results", []) - - def _candidate(self, candidate_id: str) -> dict[str, Any] | None: - return self._ashby_request("candidate.info", {"id": candidate_id}).get("results") - - def _application(self, application_id: str) -> dict[str, Any] | None: - return self._ashby_request("application.info", {"applicationId": application_id}).get( - "results" - ) - - def _users(self, limit: int = 500) -> list[dict[str, Any]]: - return self._ashby_paginate("user.list", {"includeDeactivated": True}, limit=limit) - - def _feedback(self, application_id: str, limit: int = 100) -> list[dict[str, Any]]: - return self._ashby_paginate( - "applicationFeedback.list", {"applicationId": application_id}, limit=limit - ) - - def _interview_events(self, limit: int = 500) -> list[dict[str, Any]]: - return self._ashby_paginate("interviewEvent.list", limit=limit) - - def _requester_email( - self, slack_user_id: str | None = None, requester_email: str | None = None - ) -> str | None: - if requester_email: - return requester_email.strip().lower() - env_email = os.environ.get("SLACK_REQUESTER_EMAIL", "").strip().lower() - if env_email: - return env_email - slack_id = slack_user_id or os.environ.get("SLACK_REQUESTER_ID", "").strip() - if slack_id: - return self._slack_user_email(slack_id) - return None - - def _is_admin(self, user: dict[str, Any]) -> bool: - role = " ".join( - str(user.get(key, "")) - for key in ("globalRole", "role", "accessRole", "roleName") - ).lower() - return "admin" in role - - def _authorize( - self, - requester_email: str | None, - application: dict[str, Any], - feedback: list[dict[str, Any]], - schedule: list[dict[str, Any]], - ) -> AccessDecision: - if not requester_email: - return AccessDecision(False, "Could not resolve the Slack requester email") - - users = self._users() - requester = next((u for u in users if _lower(u.get("email")) == requester_email), None) - if not requester: - return AccessDecision(False, "Requester is not an Ashby user", requester_email) - if self._is_admin(requester): - return AccessDecision(True, "Requester is an Ashby admin", requester_email, requester) - - team_emails = { - _lower(member.get("email")) - for member in application.get("hiringTeam", []) - if member.get("email") - } - if requester_email in team_emails: - return AccessDecision( - True, "Requester is on the candidate's Ashby hiring team", requester_email, requester - ) - - feedback_emails = { - _lower(fb.get("submittedByUser", {}).get("email")) - for fb in feedback - if fb.get("submittedByUser", {}).get("email") - } - if requester_email in feedback_emails: - return AccessDecision( - True, "Requester submitted feedback for this candidate", requester_email, requester - ) - - attendee_emails = { - _lower(email) - for event in schedule - for email in event.get("attendees", []) - if email - } - if requester_email in attendee_emails: - return AccessDecision( - True, "Requester is an interviewer on the candidate calendar event", requester_email, requester - ) - - return AccessDecision( - False, - "Requester is not an Ashby admin, hiring-team member, feedback submitter, or scheduled interviewer for this candidate", - requester_email, - requester, - ) - - def _candidate_summary(self, candidate: dict[str, Any]) -> str: - role = candidate.get("position") or "candidate" - company = candidate.get("company") - school = candidate.get("school") - location = candidate.get("location", {}).get("locationSummary") - sentence = f"{candidate.get('name')} is a {role}" - if company: - sentence += f" at {company}" - if location: - sentence += f" based in {location}" - sentence += "." - second = "Ashby" - if school: - second += f" lists {school} as his school" - links = candidate.get("socialLinks", []) - if links: - second += " and includes a LinkedIn profile" - if second == "Ashby": - second += " has limited background detail beyond the current role" - second += "." - return f"{sentence} {second}" - - def _event_format(self, event: dict[str, Any]) -> dict[str, Any]: - start = _parse_dt(event.get("start", "")) - end = _parse_dt(event.get("end", "")) - duration = None - if start and end: - duration = int((end - start).total_seconds() // 60) - location = event.get("location", "") or "" - text = "Zoom" if "zoom" in location.lower() else "in person" if location else "scheduled" - if duration: - text = f"{duration} minute {text} interview" - if location and "zoom" not in location.lower(): - text += f" at {location}" - return { - "summary": event.get("summary", ""), - "start": event.get("start", ""), - "end": event.get("end", ""), - "duration_minutes": duration, - "medium": "Zoom" if "zoom" in location.lower() else "in person" if location else "unknown", - "location": location, - "format": text, - } - - def _previous_interviews( - self, candidate_name: str, feedback: list[dict[str, Any]], schedule: list[dict[str, Any]] - ) -> list[str]: - now = datetime.now(timezone.utc) - previous: list[str] = [] - seen: set[str] = set() - for fb in feedback: - user = _full_name(fb.get("submittedByUser", {})) - submitted = fb.get("submittedAt") or fb.get("createdAt") or "" - date = submitted[:10] if submitted else "" - label = f"met with {user} {date}" if user and date else user or date - if label and label not in seen: - seen.add(label) - previous.append(label) - for event in schedule: - start = _parse_dt(event.get("start", "")) - summary = event.get("summary", "") - if not start or start >= now or candidate_name.lower() not in summary.lower(): - continue - date = start.strftime("%-m/%-d") if hasattr(start, "strftime") else event["start"][:10] - label = f"{summary} {date}" - if label not in seen: - seen.add(label) - previous.append(label) - return previous[:5] - - def _focus_areas(self, application: dict[str, Any], feedback: list[dict[str, Any]]) -> str: - job_title = application.get("job", {}).get("title", "") - stage_title = application.get("currentInterviewStage", {}).get("title", "") - concerns: list[str] = [] - for fb in feedback: - for key in ("overallSummary", "summary", "notes", "privateNotes"): - value = fb.get(key) - if isinstance(value, str) and value.strip(): - concerns.append(value.strip()) - if concerns: - return "Follow up on prior feedback themes: " + "; ".join(concerns[:2]) - if "government" in job_title.lower() or "policy" in stage_title.lower(): - return ( - "Democratic congressional relationships, crypto policy judgment, pace, " - "horsepower, and ability to turn DC context into specific tactics." - ) - return "Validate role-specific judgment, pace, horsepower, and any gaps not covered in prior interviews." - - def brief( - self, - candidate_name: str, - slack_user_id: str | None = None, - requester_email: str | None = None, - days_ahead: int = 30, - ) -> dict[str, Any]: - """Generate a permission-gated interview prep brief.""" - matches = self._candidate_search(candidate_name) - if not matches: - raise RuntimeError(f"No Ashby candidate found for {candidate_name!r}") - candidate = self._candidate(matches[0]["id"]) or matches[0] - application_ids = candidate.get("applicationIds", []) - if not application_ids: - raise RuntimeError(f"Candidate {candidate.get('name')} has no Ashby applications") - application = self._application(application_ids[0]) - if not application: - raise RuntimeError(f"Application {application_ids[0]} was not found") - - now = datetime.now(timezone.utc) - schedule = self._calendar_events(candidate.get("name", candidate_name), now - timedelta(days=45), now + timedelta(days=days_ahead)) - upcoming = [event for event in schedule if (_parse_dt(event.get("start", "")) or now) >= now] - feedback = self._feedback(application["id"]) - email = self._requester_email(slack_user_id=slack_user_id, requester_email=requester_email) - decision = self._authorize(email, application, feedback, schedule) - if not decision.granted: - return { - "access_granted": False, - "reason": decision.reason, - "requester_email": decision.requester_email, - "candidate_name": candidate.get("name"), - } - - return { - "access_granted": True, - "access_reason": decision.reason, - "requester_email": decision.requester_email, - "candidate": { - "id": candidate.get("id"), - "name": candidate.get("name"), - "profile_url": candidate.get("profileUrl"), - }, - "application": { - "id": application.get("id"), - "job": application.get("job", {}).get("title"), - "stage": application.get("currentInterviewStage", {}).get("title"), - }, - "upcoming_interviews": [self._event_format(event) for event in upcoming], - "background_summary": self._candidate_summary(candidate), - "previous_interviews": self._previous_interviews(candidate.get("name", candidate_name), feedback, schedule), - "focus": self._focus_areas(application, feedback), - } - - def close(self): - self._http.close() - - def __enter__(self): - return self - - def __exit__(self, *args): - self.close() - - -def _client() -> InterviewPrepClient: - return InterviewPrepClient() diff --git a/tools/business/interview-prep/pyproject.toml b/tools/business/interview-prep/pyproject.toml deleted file mode 100644 index 2efb76078..000000000 --- a/tools/business/interview-prep/pyproject.toml +++ /dev/null @@ -1,39 +0,0 @@ -[project] -name = "interview-prep" -description = "Generate permission-gated interview prep briefs from Ashby and Google Calendar" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = [ - "google-api-python-client>=2.100.0", - "google-auth>=2.0.0", - "httplib2>=0.20.0", - "httpx>=0.27.0", - "pysocks>=1.7.1", - "python-dotenv>=1.0.0", - "rich>=13.0.0", - "typer>=0.12.0", -] - -[project.scripts] -interview-prep = "centaur_tool_interview_prep.cli:app" - -[tool.hatch.build.targets.wheel] -packages = ["centaur_tool_interview_prep"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.centaur] -module = "centaur_tool_interview_prep/client.py" -hosts = [ - "api.ashbyhq.com", - "slack.com", - "calendar.googleapis.com", - "www.googleapis.com", -] -secrets = [ - {type = "http", name = "ASHBY_API_KEY", mode = "inject", inject_header = "Authorization", inject_formatter = 'Basic {{ base64 .Value ":" }}', hosts = ["api.ashbyhq.com"]}, - {type = "http", name = "SLACK_BOT_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com"]}, - {type = "oauth_token", grant = "refresh_token", name = "GOOGLE_TOKEN_JSON", token_endpoint = "https://oauth2.googleapis.com/token", hosts = ["calendar.googleapis.com", "www.googleapis.com"], fields = { refresh_token = { secret_ref = "GOOGLE_TOKEN_JSON", json_key = "refresh_token" }, client_id = { secret_ref = "GOOGLE_TOKEN_JSON", json_key = "client_id" }, client_secret = { secret_ref = "GOOGLE_TOKEN_JSON", json_key = "client_secret" } } }, -] diff --git a/tools/business/interview-prep/test_client.py b/tools/business/interview-prep/test_client.py deleted file mode 100644 index f7287aea7..000000000 --- a/tools/business/interview-prep/test_client.py +++ /dev/null @@ -1,73 +0,0 @@ -from datetime import datetime, timedelta, timezone - -from centaur_tool_interview_prep.client import InterviewPrepClient - - -def test_admin_requester_is_authorized(): - client = InterviewPrepClient(ashby_api_key="x", slack_bot_token="") - client._users = lambda limit=500: [ # type: ignore[method-assign] - {"email": "admin@paradigm.xyz", "globalRole": "Organization Admin"} - ] - - decision = client._authorize( - "admin@paradigm.xyz", - {"hiringTeam": []}, - [], - [], - ) - - assert decision.granted is True - assert "admin" in decision.reason.lower() - - -def test_unrelated_requester_is_denied(): - client = InterviewPrepClient(ashby_api_key="x", slack_bot_token="") - client._users = lambda limit=500: [ # type: ignore[method-assign] - {"email": "user@paradigm.xyz", "globalRole": "Limited Team Member"} - ] - - decision = client._authorize( - "user@paradigm.xyz", - {"hiringTeam": []}, - [], - [], - ) - - assert decision.granted is False - assert "not an ashby admin" in decision.reason.lower() - - -def test_hiring_team_requester_is_authorized(): - client = InterviewPrepClient(ashby_api_key="x", slack_bot_token="") - client._users = lambda limit=500: [ # type: ignore[method-assign] - {"email": "interviewer@paradigm.xyz", "globalRole": "Limited Team Member"} - ] - - decision = client._authorize( - "interviewer@paradigm.xyz", - {"hiringTeam": [{"email": "interviewer@paradigm.xyz"}]}, - [], - [], - ) - - assert decision.granted is True - assert "hiring team" in decision.reason.lower() - - -def test_event_format_includes_duration_and_zoom(): - client = InterviewPrepClient(ashby_api_key="x", slack_bot_token="") - start = datetime.now(timezone.utc).replace(microsecond=0) - end = start + timedelta(minutes=30) - - formatted = client._event_format( - { - "summary": "Interview with Candidate", - "start": start.isoformat(), - "end": end.isoformat(), - "location": "https://paradigmxyz.zoom.us/j/123", - } - ) - - assert formatted["duration_minutes"] == 30 - assert formatted["medium"] == "Zoom" - assert formatted["format"] == "30 minute Zoom interview" From 17247c17972a14368a69d59c4a5eb9ccfd9b0d23 Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:11:32 -0700 Subject: [PATCH 047/198] =?UTF-8?q?feat(console):=20remove=20SSO=20approva?= =?UTF-8?q?l=20requirement=20=E2=80=94=20users=20land=20on=20the=20console?= =?UTF-8?q?=20after=20login=20(#885)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 --- services/console/README.md | 2 +- services/console/app/models/user.rb | 55 ++++++++++++------- .../session_oauth_controller_test.rb | 10 ++-- services/console/test/models/user_test.rb | 29 +++++++++- 4 files changed, 67 insertions(+), 29 deletions(-) diff --git a/services/console/README.md b/services/console/README.md index 6c279e285..5d0315f51 100644 --- a/services/console/README.md +++ b/services/console/README.md @@ -96,7 +96,7 @@ The operator console always supports email and password sign-in. To add Google o | `CENTAUR_CONSOLE_GOOGLE_CLIENT_SECRET` | for Google | Google OAuth client secret for console login. | | `CENTAUR_CONSOLE_SLACK_CLIENT_ID` | for Slack | Slack OpenID Connect client ID for console login. | | `CENTAUR_CONSOLE_SLACK_CLIENT_SECRET` | for Slack | Slack OpenID Connect client secret for console login. | -| `CENTAUR_CONSOLE_BOOTSTRAP_ADMINS` | no | Comma- or whitespace-separated email allowlist. Matching users become active admins on first SSO login. Other new SSO users are created as pending users. | +| `CENTAUR_CONSOLE_BOOTSTRAP_ADMINS` | no | Comma- or whitespace-separated email allowlist. Matching users become active admins on first SSO login. Other SSO users become active non-admin operators and land on the console directly -- the deployment's network boundary is the access control, there is no approval queue. | Register these callback URLs with the provider: diff --git a/services/console/app/models/user.rb b/services/console/app/models/user.rb index 76b8bdadd..1c5760b6e 100644 --- a/services/console/app/models/user.rb +++ b/services/console/app/models/user.rb @@ -14,8 +14,9 @@ class User < ApplicationRecord after_update :revoke_mcp_oauth_refresh_tokens_when_disabled, if: -> { saved_change_to_status? && disabled? } - # pending: signed in via SSO but not yet approved -- cannot use the console. - # active: approved operator. disabled: access revoked. + # active: normal operator (SSO users are provisioned active). pending: legacy + # state from the retired approval queue, flipped to active on next SSO login. + # disabled: access revoked. enum :status, { pending: "pending", active: "active", disabled: "disabled" }, default: :pending, validate: true @@ -41,23 +42,26 @@ def revoke_mcp_oauth_refresh_tokens! # as needed, and (re)caches the identity's email/name. A returning login matches # by the stable (provider, subject). A new identity links to an existing user # only when the IdP-verified email matches -- an unverified email must never - # adopt an account -- otherwise a new user is created: active + admin when the - # email is on the bootstrap allowlist, pending otherwise. +identity+ is the - # provider strategy's { subject:, email:, email_verified:, name: } hash. + # adopt an account -- otherwise a new active user is created (admin when the + # verified email is on the bootstrap allowlist). +identity+ is the provider + # strategy's { subject:, email:, email_verified:, name: } hash. def self.link_or_provision(provider:, identity:) transaction do - if (existing = UserIdentity.find_by(provider: provider, subject: identity[:subject])) - existing.update!(email: identity[:email], email_verified: identity[:email_verified]) - user = existing.user - user.update!(name: identity[:name]) if identity[:name].present? && user.name.blank? - next user - end - - user = linkable_user(identity) || create!(provisioned_attributes(identity)) - user.user_identities.create!( - provider: provider, subject: identity[:subject], - email: identity[:email], email_verified: identity[:email_verified] - ) + user = + if (existing = UserIdentity.find_by(provider: provider, subject: identity[:subject])) + existing.update!(email: identity[:email], email_verified: identity[:email_verified]) + existing.user.tap do |u| + u.update!(name: identity[:name]) if identity[:name].present? && u.name.blank? + end + else + (linkable_user(identity) || create!(provisioned_attributes(identity))).tap do |u| + u.user_identities.create!( + provider: provider, subject: identity[:subject], + email: identity[:email], email_verified: identity[:email_verified] + ) + end + end + activate_on_login(user) user end end @@ -70,14 +74,25 @@ def self.linkable_user(identity) end private_class_method :linkable_user - # Attributes for a brand-new SSO user: active + admin when bootstrap-allowlisted - # by a verified IdP email, pending otherwise. + # Attributes for a brand-new SSO user: everyone is provisioned active -- the + # console is only reachable on the internal network, so a completed SSO login + # is sufficient and there is no admin-approval queue. Admin additionally + # requires a bootstrap-allowlisted, IdP-verified email. def self.provisioned_attributes(identity) admin = identity[:email_verified] == true && ConsoleAuth.bootstrap_admin?(identity[:email]) - { email: identity[:email], name: identity[:name], status: admin ? :active : :pending, admin: admin } + { email: identity[:email], name: identity[:name], status: :active, admin: admin } end private_class_method :provisioned_attributes + # Flips a pending user to active on login: covers accounts provisioned pending + # under the old approval-queue policy. Never touches disabled accounts and + # never grants admin. + def self.activate_on_login(user) + return unless user.pending? + user.update!(status: :active, approved_at: Time.current) + end + private_class_method :activate_on_login + private def revoke_mcp_oauth_refresh_tokens_when_disabled diff --git a/services/console/test/controllers/session_oauth_controller_test.rb b/services/console/test/controllers/session_oauth_controller_test.rb index 5381d6ce6..7ba73379c 100644 --- a/services/console/test/controllers/session_oauth_controller_test.rb +++ b/services/console/test/controllers/session_oauth_controller_test.rb @@ -101,13 +101,13 @@ def run_callback(sub:, email:, provider: "google", **token_overrides) # --- callback: provisioning ------------------------------------------------ - test "callback provisions a pending user for a non-bootstrap email and signs them in" do + test "callback provisions an active user for a non-bootstrap email and lands on the console" do assert_difference -> { User.count }, 1 do run_callback(sub: "new-sub", email: "newcomer@example.com") end - assert_redirected_to pending_path + assert_redirected_to console_threads_path user = User.find_by(email: "newcomer@example.com") - assert user.pending? + assert user.active? assert_not user.admin? assert_equal "Test User", user.name assert_equal user.id, session[:user_id] @@ -144,12 +144,12 @@ def run_callback(sub:, email:, provider: "google", **token_overrides) assert_nil session[:user_id] end - test "callback creates a pending user for an unverified, unrecognized email" do + test "callback creates an active user for an unverified, unrecognized email" do assert_difference -> { User.count }, 1 do run_callback(sub: "unv-sub", email: "stranger@example.com", email_verified: false) end user = User.find_by(email: "stranger@example.com") - assert user.pending? + assert user.active? assert_not user.user_identities.first.email_verified end diff --git a/services/console/test/models/user_test.rb b/services/console/test/models/user_test.rb index 83f1f5e76..d767e3758 100644 --- a/services/console/test/models/user_test.rb +++ b/services/console/test/models/user_test.rb @@ -103,14 +103,14 @@ def identity(overrides = {}) { subject: "sub-1", email: "newcomer@example.com", email_verified: true, name: "New Comer" }.merge(overrides) end - test "link_or_provision creates a pending user + identity for an unknown email" do + test "link_or_provision creates an active user + identity for an unknown email" do user = nil assert_difference -> { User.count }, 1 do assert_difference -> { UserIdentity.count }, 1 do user = User.link_or_provision(provider: "google", identity: identity) end end - assert user.pending? + assert user.active? assert_not user.admin? assert_equal "New Comer", user.name assert_equal [ [ "google", "sub-1" ] ], user.user_identities.pluck(:provider, :subject) @@ -160,9 +160,32 @@ def identity(overrides = {}) user = User.link_or_provision(provider: "google", identity: identity(subject: "boss-sub", email: "boss@example.com", email_verified: false)) - assert user.pending? + assert user.active? assert_not user.admin? ensure ENV.delete("CENTAUR_CONSOLE_BOOTSTRAP_ADMINS") end + + test "link_or_provision activates a returning legacy-pending user" do + user = User.link_or_provision(provider: "slack", + identity: identity(subject: "late-sub", email: "worker@acme.example")) + user.update!(status: :pending) + + returning = User.link_or_provision(provider: "slack", + identity: identity(subject: "late-sub", email: "worker@acme.example")) + assert_equal user, returning + assert returning.active? + assert_not returning.admin? + assert_not_nil returning.approved_at + end + + test "link_or_provision never reactivates a disabled user" do + user = User.link_or_provision(provider: "slack", + identity: identity(subject: "gone-sub", email: "worker@acme.example")) + user.update!(status: :disabled) + + returning = User.link_or_provision(provider: "slack", + identity: identity(subject: "gone-sub", email: "worker@acme.example")) + assert returning.disabled? + end end From 317eec57544c41ad542fd61aba8b09b222db7f00 Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:18:36 -0700 Subject: [PATCH 048/198] fix(slackbotv2): rename console link to "Open chat in Console" (#889) Co-authored-by: Claude Fable 5 --- services/slackbotv2/src/console-session-link.ts | 8 ++++---- services/slackbotv2/src/index.ts | 2 +- services/slackbotv2/src/types.ts | 2 +- services/slackbotv2/test/chat-sdk-emulate.test.ts | 6 +++--- services/slackbotv2/test/console-session-link.test.ts | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/services/slackbotv2/src/console-session-link.ts b/services/slackbotv2/src/console-session-link.ts index e45230a29..e713a0a44 100644 --- a/services/slackbotv2/src/console-session-link.ts +++ b/services/slackbotv2/src/console-session-link.ts @@ -1,5 +1,5 @@ /** - * Slack-only "Open session in Console" context line. + * Slack-only "Open chat in Console" context line. * * On the first assistant message in a Slack thread, slackbotv2 appends a Block * Kit `context` block linking to the Console session view. The block is passed @@ -98,8 +98,8 @@ export type SlackContextBlock = { } /** - * Builds the "Open session in Console · {MODEL} · {Harness}" context block, or - * undefined when no Console base URL is configured (a bare "Open session in + * Builds the "Open chat in Console · {MODEL} · {Harness}" context block, or + * undefined when no Console base URL is configured (a bare "Open chat in * Console" with no link is pointless, so the whole block is skipped). The * model id is uppercased for display. */ @@ -111,7 +111,7 @@ export function buildConsoleSessionContextBlock(params: { }): SlackContextBlock | undefined { const url = consoleSessionUrl(params.consoleBaseUrl, params.threadKey) if (!url) return undefined - const segments = [`<${url}|Open session in Console>`] + const segments = [`<${url}|Open chat in Console>`] const model = params.model?.trim() if (model) segments.push(escapeSlackMrkdwn(model.toUpperCase())) const harness = harnessDisplayName(params.harnessType) diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 7d4ac7f68..a402c06c6 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -645,7 +645,7 @@ async function syncThreadMessageToSession( setMessageText(serializedMessage, overrides.cleanedText) const stickyOverridesUpdate = stickyThreadOverrideUpdate(overrides) const effectiveOverrides = resolveStickyThreadOverrides(state, stickyOverridesUpdate) - // Slack-only "Open session in Console" link on the FIRST assistant message in + // Slack-only "Open chat in Console" link on the FIRST assistant message in // a thread (the reply to the first message that starts an execution). The // block is undefined when no Console base URL is configured. `thread.id` // (`slack:CHANNEL:THREAD_TS`) is the exact value sent to the session API as diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 2571d7367..46d8a742a 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -108,7 +108,7 @@ export type SlackbotV2Options = { /** * Public origin of the Console UI (same value the Console itself uses, * `CENTAUR_CONSOLE_PUBLIC_URL`). When set, the first assistant message in a - * Slack thread gets an "Open session in Console" context link. Unset skips + * Slack thread gets an "Open chat in Console" context link. Unset skips * the block entirely. */ consolePublicUrl?: string diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 92ac80267..d13636358 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -441,7 +441,7 @@ describe('slackbotv2', () => { .filter(call => call.method === 'chat.stopStream') .flatMap(call => (Array.isArray(call.body.blocks) ? (call.body.blocks as unknown[]) : [])) .map(block => JSON.stringify(block)) - .filter(text => text.includes('Open session in Console')) + .filter(text => text.includes('Open chat in Console')) const parent = await postUserMessage('Console link thread context.') const firstMention = await postUserMessage( @@ -475,7 +475,7 @@ describe('slackbotv2', () => { expect(firstBlocks[0]).toContain( `https://console.example.dev/console/threads?thread=${encodedThread}` ) - expect(firstBlocks[0]).toContain('Open session in Console') + expect(firstBlocks[0]).toContain('Open chat in Console') expect(firstBlocks[0]).toContain('Claude Code') expect(firstBlocks[0]).toContain('CLAUDE-OPUS-4-8') expect(firstBlocks[0]).toContain(' · ') @@ -523,7 +523,7 @@ describe('slackbotv2', () => { .filter(call => call.method === 'chat.stopStream') .flatMap(call => (Array.isArray(call.body.blocks) ? (call.body.blocks as unknown[]) : [])) .map(block => JSON.stringify(block)) - .filter(text => text.includes('Open session in Console')) + .filter(text => text.includes('Open chat in Console')) const parent = await postUserMessage('Default model thread context.') const mention = await postUserMessage( diff --git a/services/slackbotv2/test/console-session-link.test.ts b/services/slackbotv2/test/console-session-link.test.ts index 3bd58ca6b..3e67fa77f 100644 --- a/services/slackbotv2/test/console-session-link.test.ts +++ b/services/slackbotv2/test/console-session-link.test.ts @@ -98,7 +98,7 @@ describe('buildConsoleSessionContextBlock', () => { { type: 'mrkdwn', text: - ' · GPT-5.2 · Codex' + ' · GPT-5.2 · Codex' } ] }) @@ -111,7 +111,7 @@ describe('buildConsoleSessionContextBlock', () => { harnessType: 'claudecode' }) expect(block?.elements[0]?.text).toBe( - ' · Claude Code' + ' · Claude Code' ) }) From ef92f70a10058ae131800ec164d26fc3c830bbd7 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Thu, 2 Jul 2026 16:44:26 -0600 Subject: [PATCH 049/198] feat: gate sandbox API access by capability (#884) * feat: gate sandbox API access by capability * fix: preserve sandbox API ingress during rollout * chore: bump chart version * fix: harden sandbox API capability rollout * fix: default session API capability to enabled * fix: keep session API capability nullable * fix: narrow sandbox API rollout compatibility * fix: omit API label when disabled * fix: satisfy sandbox API label clippy * fix: remove sandbox capability stamp label --- centaur_sdk/tests/test_tool_sdk.py | 33 +++++++ centaur_sdk/tool_sdk.py | 10 +++ contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/networkpolicy.yaml | 83 ++++++++++++++++- .../crates/centaur-iron-control/src/models.rs | 2 + .../src/iron_proxy.rs | 90 +++++++++++++++---- .../centaur-sandbox-agent-k8s/src/lib.rs | 45 +++++++++- .../crates/centaur-sandbox-core/src/spec.rs | 4 +- .../crates/centaur-session-core/src/lib.rs | 4 +- .../crates/centaur-session-runtime/src/lib.rs | 16 ++++ ..._session_sandbox_api_server_capability.sql | 7 ++ .../crates/centaur-session-sqlx/src/lib.rs | 34 ++++--- .../api/v1/principals_controller.rb | 2 + .../console/principals_controller.rb | 3 +- .../app/views/console/principal.html.erb | 11 +++ ...box_api_server_capability_to_principals.rb | 5 ++ services/console/db/schema.rb | 3 +- .../api/v1/principals_controller_test.rb | 11 ++- .../console/principals_controller_test.rb | 6 +- .../console/test/models/principal_test.rb | 1 + services/sandbox/entrypoint.sh | 10 +++ tools/productivity/gsuite/client.py | 12 ++- tools/productivity/slack/feedback.py | 7 ++ 23 files changed, 356 insertions(+), 45 deletions(-) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0035_session_sandbox_api_server_capability.sql create mode 100644 services/console/db/migrate/20260702000000_add_sandbox_api_server_capability_to_principals.rb diff --git a/centaur_sdk/tests/test_tool_sdk.py b/centaur_sdk/tests/test_tool_sdk.py index 5064a6f33..754a2f3d9 100644 --- a/centaur_sdk/tests/test_tool_sdk.py +++ b/centaur_sdk/tests/test_tool_sdk.py @@ -111,6 +111,22 @@ def fake_urlopen(request, timeout): reset_tool_context(token) +def test_current_session_context_requires_api_server_capability( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr( + registry, + "_backend", + MappingBackend({"CENTAUR_SANDBOX_API_SERVER_ENABLED": "false"}), + ) + token = set_tool_context(ToolContext(name="fake-tool", thread_key="slack:C123:123.456")) + try: + with pytest.raises(RuntimeError, match="API server sandbox capability"): + current_session_context() + finally: + reset_tool_context(token) + + def test_current_slack_thread_returns_api_slack_destination( monkeypatch: pytest.MonkeyPatch, ): @@ -171,6 +187,23 @@ def fail_urlopen(*_args, **_kwargs): } +def test_save_attachment_requires_api_server_capability_without_uploads_dir( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.delenv("CENTAUR_UPLOADS_DIR", raising=False) + monkeypatch.setattr( + registry, + "_backend", + MappingBackend({"CENTAUR_SANDBOX_API_SERVER_ENABLED": "false"}), + ) + token = set_tool_context(ToolContext(name="fake-tool", thread_key="slack:C123:123.456")) + try: + with pytest.raises(RuntimeError, match="API server sandbox capability"): + save_attachment(name="report.txt", data=b"hello") + finally: + reset_tool_context(token) + + def test_save_attachment_uses_unique_local_name_on_collision( monkeypatch: pytest.MonkeyPatch, tmp_path ): diff --git a/centaur_sdk/tool_sdk.py b/centaur_sdk/tool_sdk.py index 04a3861f6..65f51fa93 100644 --- a/centaur_sdk/tool_sdk.py +++ b/centaur_sdk/tool_sdk.py @@ -79,6 +79,14 @@ def secret(key: str, default: str | None = None) -> str: raise KeyError(f"Missing secret '{key}'{ctx_name}") +def _require_api_server_enabled(operation: str) -> None: + if secret("CENTAUR_SANDBOX_API_SERVER_ENABLED", "true").strip().lower() == "false": + raise RuntimeError( + f"{operation} requires the API server sandbox capability, but it is disabled " + "for this principal." + ) + + def current_thread_key() -> str: """Return the active thread key for a tool call.""" try: @@ -100,6 +108,7 @@ def current_session_context() -> dict[str, Any]: ``slack.thread_ts``. The API remains the source of truth so warm pooled sandboxes do not need per-thread environment mutation. """ + _require_api_server_enabled("current_session_context") thread_key = current_thread_key() base_url = secret("CENTAUR_API_URL", "http://api:8000").rstrip("/") headers: dict[str, str] = {} @@ -188,6 +197,7 @@ def save_attachment( uploads_dir=uploads_dir, ) + _require_api_server_enabled("save_attachment") thread_key = current_thread_key() base_url = secret("CENTAUR_API_URL", "http://api:8000").rstrip("/") payload = json.dumps( diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index c8c4570d5..7903745af 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.86 +version: 0.1.87 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/networkpolicy.yaml b/contrib/chart/templates/networkpolicy.yaml index f171e8297..acde52724 100644 --- a/contrib/chart/templates/networkpolicy.yaml +++ b/contrib/chart/templates/networkpolicy.yaml @@ -150,7 +150,6 @@ spec: {{ include "centaur.componentSelectorLabels" (dict "root" . "component" "api-rs") | nindent 6 }} policyTypes: - Ingress - - Egress ingress: - from: {{- if .Values.slackbotv2.enabled }} @@ -185,12 +184,22 @@ spec: {{ include "centaur.componentSelectorLabels" (dict "root" . "component" "console-worker") | nindent 14 }} {{- end }} {{- end }} - # Sandboxes call back into the control plane. agent-k8s labels its pods - # centaur.ai/managed-by=api-rs (MANAGED_BY_VALUE in centaur-sandbox-agent-k8s), - # which also covers the per-sandbox iron-proxy pods. + # New sandboxes call back into the control plane only when their + # principal has the API server sandbox capability. + - podSelector: + matchLabels: + centaur.ai/api-server-enabled: "true" + # Transitional compatibility: pre-deploy sandbox/proxy pods carry only + # centaur.ai/managed-by=api-rs, so this also covers newly-created + # API-disabled pods while the rollout policy is present. Remove with the + # matching egress compatibility policy below after old api-rs-managed + # pods have aged out. - podSelector: matchLabels: centaur.ai/managed-by: api-rs + matchExpressions: + - key: centaur.ai/api-server-enabled + operator: DoesNotExist {{- range .Values.networkPolicy.apiIngressSourceNamespaces }} - namespaceSelector: matchLabels: @@ -199,6 +208,72 @@ spec: ports: - protocol: TCP port: {{ .Values.apiRs.port }} +--- +# Transitional compatibility for pre-deploy sandbox/proxy pods. They carry only +# centaur.ai/managed-by=api-rs, so this also covers newly-created API-disabled +# pods while the rollout policy is present. Remove this with the matching +# api-rs ingress compatibility selector once old api-rs-managed pods have aged +# out. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "centaur.fullname" . }}-sandbox-api-server-compat + labels: +{{ include "centaur.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + centaur.ai/managed-by: api-rs + matchExpressions: + - key: centaur.ai/api-server-enabled + operator: DoesNotExist + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: +{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "api-rs") | nindent 14 }} + ports: + - protocol: TCP + port: {{ .Values.apiRs.port }} +--- +# Sandboxes with the API server capability may call api-rs. New sandbox and +# proxy pods receive centaur.ai/api-server-enabled=true from api-rs only when +# the principal has the capability, so default-deny blocks new pods without it. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "centaur.fullname" . }}-sandbox-api-server + labels: +{{ include "centaur.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + centaur.ai/api-server-enabled: "true" + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: +{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "api-rs") | nindent 14 }} + ports: + - protocol: TCP + port: {{ .Values.apiRs.port }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "centaur.componentName" (dict "root" . "component" "api-rs") }}-egress + labels: +{{ include "centaur.componentLabels" (dict "root" . "component" "api-rs") | nindent 4 }} +spec: + podSelector: + matchLabels: +{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "api-rs") | nindent 6 }} + policyTypes: + - Egress egress: {{- if .Values.postgres.enabled }} - to: diff --git a/services/api-rs/crates/centaur-iron-control/src/models.rs b/services/api-rs/crates/centaur-iron-control/src/models.rs index ee1743409..ef51a6c06 100644 --- a/services/api-rs/crates/centaur-iron-control/src/models.rs +++ b/services/api-rs/crates/centaur-iron-control/src/models.rs @@ -478,6 +478,8 @@ pub struct Principal { pub sandbox_repo_cache_enabled: bool, #[serde(default = "default_true")] pub sandbox_observability_enabled: bool, + #[serde(default = "default_true")] + pub sandbox_api_server_enabled: bool, } /// A principal's effective config — the same secrets/postgres the principal's diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs index 1a9e2a6de..59dfd3d4d 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs @@ -20,8 +20,8 @@ use serde_json::{Value, json}; use tokio::time::{Instant, sleep}; use crate::{ - AgentSandboxBackend, MANAGED_BY_LABEL, MANAGED_BY_VALUE, OtlpEgressTarget, SANDBOX_ID_LABEL, - is_not_found, map_kube_error, + API_SERVER_ENABLED_LABEL, AgentSandboxBackend, MANAGED_BY_LABEL, MANAGED_BY_VALUE, + OtlpEgressTarget, SANDBOX_ID_LABEL, is_not_found, map_kube_error, }; const IRON_PROXY_LABEL: &str = "centaur.ai/iron-proxy"; @@ -137,6 +137,7 @@ pub(crate) struct ResolvedIronProxy { // env, so it survives api-rs restarts and respects env overrides. management_api_key: String, observability_enabled: bool, + api_server_enabled: bool, } /// The single Postgres listener the proxy multiplexes every upstream through. @@ -201,6 +202,7 @@ impl AgentSandboxBackend { pg, replace_placeholders, spec.capabilities.observability_enabled, + spec.capabilities.api_server_enabled, ))) } @@ -282,12 +284,22 @@ impl AgentSandboxBackend { ); true }); + let api_server_enabled = sandbox_api_server_enabled(&sandbox, &self.config.container_name) + .unwrap_or_else(|| { + tracing::warn!( + sandbox_id = id.as_str(), + container_name = self.config.container_name.as_str(), + "sandbox API server capability env is missing or invalid; defaulting to enabled network policy" + ); + true + }); Ok(Some(self.resolved_iron_proxy_for_principal( id, principal_id, pg, replace_placeholders, observability_enabled, + api_server_enabled, ))) } @@ -298,6 +310,7 @@ impl AgentSandboxBackend { pg: Option, replace_placeholders: BTreeMap, observability_enabled: bool, + api_server_enabled: bool, ) -> ResolvedIronProxy { ResolvedIronProxy { proxy_host: iron_proxy_service_name(id), @@ -308,6 +321,7 @@ impl AgentSandboxBackend { replace_placeholders, management_api_key: new_proxy_management_api_key(), observability_enabled, + api_server_enabled, } } @@ -584,12 +598,24 @@ impl AgentSandboxBackend { ); true }); + let api_server_enabled = sandbox + .as_ref() + .and_then(|sandbox| sandbox_api_server_enabled(sandbox, &self.config.container_name)) + .unwrap_or_else(|| { + tracing::warn!( + sandbox_id = id.as_str(), + container_name = self.config.container_name.as_str(), + "sandbox API server capability env is missing or invalid during proxy repair; defaulting to enabled network policy" + ); + true + }); let resolved = self.resolved_iron_proxy_for_principal( id, principal_id, pg, replace_placeholders, observability_enabled, + api_server_enabled, ); self.create_iron_proxy_resources(id, Some(&resolved)) .await?; @@ -1128,7 +1154,7 @@ fn build_iron_proxy_pod( Pod { metadata: object_meta_with_annotations( resolved.proxy_pod_name.clone(), - iron_proxy_labels(id), + iron_proxy_labels(id, resolved.api_server_enabled), annotations, ), spec: Some(PodSpec { @@ -1288,9 +1314,12 @@ fn build_iron_proxy_service(id: &SandboxId, resolved: &ResolvedIronProxy) -> Ser ports.push(service_port("pg", pg.port)); } Service { - metadata: object_meta(iron_proxy_service_name(id), iron_proxy_labels(id)), + metadata: object_meta( + iron_proxy_service_name(id), + iron_proxy_labels(id, resolved.api_server_enabled), + ), spec: Some(ServiceSpec { - selector: Some(iron_proxy_labels(id)), + selector: Some(iron_proxy_labels(id, resolved.api_server_enabled)), ports: Some(ports), ..Default::default() }), @@ -1309,14 +1338,10 @@ fn build_iron_proxy_network_policies( let sandbox_to_proxy_ports = sandbox_to_proxy_ports(resolved); let sandbox_egress = vec![ egress_to( - vec![pod_peer(iron_proxy_labels(id))], + vec![pod_peer(iron_proxy_labels(id, resolved.api_server_enabled))], sandbox_to_proxy_ports.clone(), ), dns_egress_rule(), - egress_to( - vec![pod_peer(iron_proxy.api_pod_labels.clone())], - vec![network_port(8000), network_port(8080)], - ), ]; vec![ NetworkPolicy { @@ -1332,9 +1357,15 @@ fn build_iron_proxy_network_policies( }), }, NetworkPolicy { - metadata: object_meta(iron_proxy_policy_name(id), iron_proxy_labels(id)), + metadata: object_meta( + iron_proxy_policy_name(id), + iron_proxy_labels(id, resolved.api_server_enabled), + ), spec: Some(NetworkPolicySpec { - pod_selector: Some(label_selector(iron_proxy_labels(id))), + pod_selector: Some(label_selector(iron_proxy_labels( + id, + resolved.api_server_enabled, + ))), policy_types: Some(vec!["Ingress".to_owned(), "Egress".to_owned()]), ingress: Some(vec![ NetworkPolicyIngressRule { @@ -1603,6 +1634,15 @@ fn sandbox_observability_enabled( .and_then(|value| value.parse().ok()) } +fn sandbox_api_server_enabled(sandbox: &crate::crd::Sandbox, container_name: &str) -> Option { + sandbox_env_value( + sandbox, + "CENTAUR_SANDBOX_API_SERVER_ENABLED", + container_name, + ) + .and_then(|value| value.parse().ok()) +} + fn sandbox_env_value( sandbox: &crate::crd::Sandbox, name: &str, @@ -1869,12 +1909,16 @@ fn sandbox_labels(id: &SandboxId) -> BTreeMap { ]) } -fn iron_proxy_labels(id: &SandboxId) -> BTreeMap { - BTreeMap::from([ +fn iron_proxy_labels(id: &SandboxId, api_server_enabled: bool) -> BTreeMap { + let mut labels = BTreeMap::from([ (MANAGED_BY_LABEL.to_owned(), MANAGED_BY_VALUE.to_owned()), (SANDBOX_ID_LABEL.to_owned(), id.as_str().to_owned()), (IRON_PROXY_LABEL.to_owned(), "true".to_owned()), - ]) + ]); + if api_server_enabled { + labels.insert(API_SERVER_ENABLED_LABEL.to_owned(), "true".to_owned()); + } + labels } fn unique_suffix() -> String { @@ -1899,6 +1943,7 @@ mod tests { replace_placeholders: BTreeMap::new(), management_api_key: "test-management-key".to_owned(), observability_enabled: true, + api_server_enabled: true, } } @@ -2003,6 +2048,19 @@ mod tests { assert_eq!(peer_component(control_peer(&target)), Some("console")); } + #[test] + fn iron_proxy_labels_api_server_capability_when_enabled() { + let id = SandboxId::new("asbx-test"); + + assert_eq!( + iron_proxy_labels(&id, true) + .get(API_SERVER_ENABLED_LABEL) + .map(String::as_str), + Some("true") + ); + assert!(!iron_proxy_labels(&id, false).contains_key(API_SERVER_ENABLED_LABEL)); + } + #[test] fn sandbox_egress_policy_does_not_inline_otlp_collector_rule() { let id = SandboxId::new("asbx-test"); @@ -2089,7 +2147,7 @@ mod tests { .iter() .any(|rule| rule_allows_namespace_port(rule, "laminar", 8000)) ); - assert!(sandbox_egress.iter().any(|rule| { + assert!(!sandbox_egress.iter().any(|rule| { rule.to.as_ref().is_some_and(|peers| { peers.iter().any(|peer| { peer.pod_selector.as_ref().is_some_and(|selector| { diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index 21d961b32..a682e338f 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -38,6 +38,7 @@ const DEFAULT_CONTAINER_NAME: &str = "agent"; const MANAGED_BY_LABEL: &str = "centaur.ai/managed-by"; const SANDBOX_ID_LABEL: &str = "centaur.ai/sandbox-id"; const OBSERVABILITY_ENABLED_LABEL: &str = "centaur.ai/observability-enabled"; +const API_SERVER_ENABLED_LABEL: &str = "centaur.ai/api-server-enabled"; const MANAGED_BY_VALUE: &str = "api-rs"; // iron-control principal OID the sandbox's proxy binds to, stamped at create // so resume (which has only the sandbox id) can rebind without the spec or any @@ -574,6 +575,9 @@ fn build_agent_sandbox( if spec.capabilities.observability_enabled { labels.insert(OBSERVABILITY_ENABLED_LABEL.to_owned(), "true".to_owned()); } + if spec.capabilities.api_server_enabled { + labels.insert(API_SERVER_ENABLED_LABEL.to_owned(), "true".to_owned()); + } let mut pod_labels = labels.clone(); pod_labels.insert( @@ -911,6 +915,7 @@ mod tests { let spec = SandboxSpec::new("centaur-agent:latest").capabilities(SandboxCapabilities { repo_cache_enabled: true, observability_enabled: true, + api_server_enabled: true, }); let config = AgentSandboxConfig::new("centaur"); @@ -936,13 +941,34 @@ mod tests { .map(String::as_str), Some("true") ); + assert_eq!( + sandbox + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(API_SERVER_ENABLED_LABEL)) + .map(String::as_str), + Some("true") + ); + assert_eq!( + sandbox + .spec + .pod_template + .metadata + .as_ref() + .and_then(|metadata| metadata.labels.as_ref()) + .and_then(|labels| labels.get(API_SERVER_ENABLED_LABEL)) + .map(String::as_str), + Some("true") + ); } #[test] - fn omits_observability_enabled_label_for_restricted_sandboxes() { + fn omits_api_server_label_for_restricted_sandboxes() { let spec = SandboxSpec::new("centaur-agent:latest").capabilities(SandboxCapabilities { repo_cache_enabled: true, observability_enabled: false, + api_server_enabled: false, }); let config = AgentSandboxConfig::new("centaur"); @@ -964,6 +990,22 @@ mod tests { .and_then(|metadata| metadata.labels.as_ref()) .is_none_or(|labels| !labels.contains_key(OBSERVABILITY_ENABLED_LABEL)) ); + assert!( + sandbox + .metadata + .labels + .as_ref() + .is_none_or(|labels| !labels.contains_key(API_SERVER_ENABLED_LABEL)) + ); + assert!( + sandbox + .spec + .pod_template + .metadata + .as_ref() + .and_then(|metadata| metadata.labels.as_ref()) + .is_none_or(|labels| !labels.contains_key(API_SERVER_ENABLED_LABEL)) + ); } #[test] @@ -1021,6 +1063,7 @@ mod tests { let spec = SandboxSpec::new("centaur-agent:latest").capabilities(SandboxCapabilities { repo_cache_enabled: false, observability_enabled: true, + api_server_enabled: true, }); let mut tools = ToolsConfig::new("paradigmxyz/centaur", "api:test"); tools.repo_cache_path = Some("/var/lib/centaur/repos".to_owned()); diff --git a/services/api-rs/crates/centaur-sandbox-core/src/spec.rs b/services/api-rs/crates/centaur-sandbox-core/src/spec.rs index b88f30a0a..1fb4c1d5f 100644 --- a/services/api-rs/crates/centaur-sandbox-core/src/spec.rs +++ b/services/api-rs/crates/centaur-sandbox-core/src/spec.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; pub struct SandboxCapabilities { pub repo_cache_enabled: bool, pub observability_enabled: bool, + pub api_server_enabled: bool, } impl SandboxCapabilities { @@ -11,11 +12,12 @@ impl SandboxCapabilities { Self { repo_cache_enabled: true, observability_enabled: true, + api_server_enabled: true, } } pub const fn is_default_enabled(&self) -> bool { - self.repo_cache_enabled && self.observability_enabled + self.repo_cache_enabled && self.observability_enabled && self.api_server_enabled } } diff --git a/services/api-rs/crates/centaur-session-core/src/lib.rs b/services/api-rs/crates/centaur-session-core/src/lib.rs index ee8b1b90a..22de84f84 100644 --- a/services/api-rs/crates/centaur-session-core/src/lib.rs +++ b/services/api-rs/crates/centaur-session-core/src/lib.rs @@ -141,6 +141,7 @@ pub enum SessionStatus { pub struct SandboxCapabilities { pub repo_cache_enabled: bool, pub observability_enabled: bool, + pub api_server_enabled: bool, } impl SandboxCapabilities { @@ -148,11 +149,12 @@ impl SandboxCapabilities { Self { repo_cache_enabled: true, observability_enabled: true, + api_server_enabled: true, } } pub const fn is_default_enabled(&self) -> bool { - self.repo_cache_enabled && self.observability_enabled + self.repo_cache_enabled && self.observability_enabled && self.api_server_enabled } } diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 613059dd3..05f856034 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -2225,6 +2225,7 @@ impl SessionRuntime { sandbox_boot_mode = boot_mode.as_str(), sandbox_repo_cache_enabled = desired_capabilities.repo_cache_enabled, sandbox_observability_enabled = desired_capabilities.observability_enabled, + sandbox_api_server_enabled = desired_capabilities.api_server_enabled, ); let ensure_started = Instant::now(); let result = async { @@ -2261,6 +2262,7 @@ impl SessionRuntime { sandbox_id, sandbox_repo_cache_enabled = desired_capabilities.repo_cache_enabled, sandbox_observability_enabled = desired_capabilities.observability_enabled, + sandbox_api_server_enabled = desired_capabilities.api_server_enabled, "replacing existing sandbox whose capabilities do not match" ); } else { @@ -2581,6 +2583,7 @@ impl SessionRuntime { Ok(SessionSandboxCapabilities { repo_cache_enabled: principal.sandbox_repo_cache_enabled, observability_enabled: principal.sandbox_observability_enabled, + api_server_enabled: principal.sandbox_api_server_enabled, }) } @@ -5282,6 +5285,7 @@ fn apply_sandbox_capabilities(spec: &mut SandboxSpec, capabilities: &SessionSand spec.capabilities = BackendSandboxCapabilities { repo_cache_enabled: capabilities.repo_cache_enabled, observability_enabled: capabilities.observability_enabled, + api_server_enabled: capabilities.api_server_enabled, }; upsert_spec_env( spec, @@ -5293,6 +5297,11 @@ fn apply_sandbox_capabilities(spec: &mut SandboxSpec, capabilities: &SessionSand "CENTAUR_SANDBOX_OBSERVABILITY_ENABLED", capabilities.observability_enabled.to_string(), ); + upsert_spec_env( + spec, + "CENTAUR_SANDBOX_API_SERVER_ENABLED", + capabilities.api_server_enabled.to_string(), + ); if !capabilities.repo_cache_enabled { spec.mounts .retain(|mount| mount.target_path != SANDBOX_REPOS_MOUNT_PATH); @@ -7758,6 +7767,7 @@ mod adoption_tests { SessionSandboxCapabilities { repo_cache_enabled: false, observability_enabled: false, + api_server_enabled: false, } } @@ -7853,10 +7863,15 @@ mod adoption_tests { let spec = backend.created_specs().pop().expect("created cold spec"); assert!(!spec.capabilities.repo_cache_enabled); assert!(!spec.capabilities.observability_enabled); + assert!(!spec.capabilities.api_server_enabled); assert_eq!( env_value(&spec, "CENTAUR_SANDBOX_OBSERVABILITY_ENABLED"), Some("false") ); + assert_eq!( + env_value(&spec, "CENTAUR_SANDBOX_API_SERVER_ENABLED"), + Some("false") + ); let blocklist = env_value(&spec, "TOOL_BLOCKLIST").unwrap_or(""); for tool in OBSERVABILITY_TOOL_BLOCKLIST.split(',') { assert!(blocklist.split(',').any(|blocked| blocked == tool)); @@ -7937,6 +7952,7 @@ mod adoption_tests { let spec = backend.created_specs().pop().expect("created cold spec"); assert!(!spec.capabilities.repo_cache_enabled); assert!(!spec.capabilities.observability_enabled); + assert!(!spec.capabilities.api_server_enabled); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0035_session_sandbox_api_server_capability.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0035_session_sandbox_api_server_capability.sql new file mode 100644 index 000000000..f7faa61c5 --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0035_session_sandbox_api_server_capability.sql @@ -0,0 +1,7 @@ +alter table sessions + add column if not exists sandbox_api_server_enabled boolean; + +update sessions +set sandbox_api_server_enabled = true +where sandbox_observability_enabled is not null + and sandbox_api_server_enabled is null; diff --git a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs index 6b911c11b..98d971627 100644 --- a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs +++ b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs @@ -157,7 +157,7 @@ impl PgSessionStore { pub async fn get_session(&self, thread_key: &ThreadKey) -> Result { let row = sqlx::query_as::<_, SessionRow>( r#" - select thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at + select thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, sandbox_api_server_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at from sessions where thread_key = $1 "#, @@ -1120,13 +1120,14 @@ impl PgSessionStore { sandbox_id = $2, sandbox_repo_cache_enabled = null, sandbox_observability_enabled = null, + sandbox_api_server_enabled = null, sandbox_last_active_at = case when $2::text is null then null else now() end, updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, sandbox_api_server_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -1150,16 +1151,18 @@ impl PgSessionStore { sandbox_id = $2, sandbox_repo_cache_enabled = $3, sandbox_observability_enabled = $4, + sandbox_api_server_enabled = $5, sandbox_last_active_at = now(), updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, sandbox_api_server_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) .bind(sandbox_id) .bind(capabilities.repo_cache_enabled) .bind(capabilities.observability_enabled) + .bind(capabilities.api_server_enabled) .fetch_one(&self.pool) .await?; @@ -1178,6 +1181,7 @@ impl PgSessionStore { sandbox_id = null, sandbox_repo_cache_enabled = null, sandbox_observability_enabled = null, + sandbox_api_server_enabled = null, sandbox_last_active_at = null, updated_at = now() where thread_key = $1 and sandbox_id = $2 @@ -1207,11 +1211,12 @@ impl PgSessionStore { sandbox_id = null, sandbox_repo_cache_enabled = null, sandbox_observability_enabled = null, + sandbox_api_server_enabled = null, sandbox_last_active_at = null, status = $3, updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, sandbox_api_server_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -1236,7 +1241,7 @@ impl PgSessionStore { update sessions set iron_control_principal = $2, updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, sandbox_api_server_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -1406,7 +1411,7 @@ impl PgSessionStore { update sessions set harness_thread_id = $2, updated_at = now() where thread_key = $1 - returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_observability_enabled, sandbox_api_server_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, sandbox_last_active_at, created_at, updated_at "#, ) .bind(thread_key.as_str()) @@ -1546,6 +1551,7 @@ struct SessionRow { sandbox_id: Option, sandbox_repo_cache_enabled: Option, sandbox_observability_enabled: Option, + sandbox_api_server_enabled: Option, harness_type: String, harness_thread_id: Option, persona_id: Option, @@ -1567,13 +1573,17 @@ impl TryFrom for Session { sandbox_capabilities: match ( row.sandbox_repo_cache_enabled, row.sandbox_observability_enabled, + row.sandbox_api_server_enabled, ) { - (Some(repo_cache_enabled), Some(observability_enabled)) => { - Some(SandboxCapabilities { - repo_cache_enabled, - observability_enabled, - }) - } + ( + Some(repo_cache_enabled), + Some(observability_enabled), + Some(api_server_enabled), + ) => Some(SandboxCapabilities { + repo_cache_enabled, + observability_enabled, + api_server_enabled, + }), _ => None, }, harness_type: parse_persisted(row.harness_type)?, diff --git a/services/console/app/controllers/api/v1/principals_controller.rb b/services/console/app/controllers/api/v1/principals_controller.rb index 13b1e31e3..05def1ef9 100644 --- a/services/console/app/controllers/api/v1/principals_controller.rb +++ b/services/console/app/controllers/api/v1/principals_controller.rb @@ -76,6 +76,7 @@ def record_payload(principal) labels: principal.labels, sandbox_repo_cache_enabled: principal.sandbox_repo_cache_enabled, sandbox_observability_enabled: principal.sandbox_observability_enabled, + sandbox_api_server_enabled: principal.sandbox_api_server_enabled, created_at: principal.created_at, updated_at: principal.updated_at } @@ -86,6 +87,7 @@ def principal_params :name, :sandbox_repo_cache_enabled, :sandbox_observability_enabled, + :sandbox_api_server_enabled, labels: {} ) end diff --git a/services/console/app/controllers/console/principals_controller.rb b/services/console/app/controllers/console/principals_controller.rb index 9c8c0419b..84449cefd 100644 --- a/services/console/app/controllers/console/principals_controller.rb +++ b/services/console/app/controllers/console/principals_controller.rb @@ -14,7 +14,8 @@ class PrincipalsController < ApplicationController def update_sandbox_access @principal.update!( sandbox_repo_cache_enabled: ActiveModel::Type::Boolean.new.cast(params[:sandbox_repo_cache_enabled]), - sandbox_observability_enabled: ActiveModel::Type::Boolean.new.cast(params[:sandbox_observability_enabled]) + sandbox_observability_enabled: ActiveModel::Type::Boolean.new.cast(params[:sandbox_observability_enabled]), + sandbox_api_server_enabled: ActiveModel::Type::Boolean.new.cast(params[:sandbox_api_server_enabled]) ) redirect_to console_principal_path(@principal.oid), notice: "Updated sandbox access." rescue ActiveRecord::RecordInvalid => e diff --git a/services/console/app/views/console/principal.html.erb b/services/console/app/views/console/principal.html.erb index f5eb311e1..bda1d5059 100644 --- a/services/console/app/views/console/principal.html.erb +++ b/services/console/app/views/console/principal.html.erb @@ -56,6 +56,17 @@ Allow sandbox access to logs and metrics surfaces. +
<%= submit_tag "Save sandbox access", class: "btn-primary" %>
diff --git a/services/console/db/migrate/20260702000000_add_sandbox_api_server_capability_to_principals.rb b/services/console/db/migrate/20260702000000_add_sandbox_api_server_capability_to_principals.rb new file mode 100644 index 000000000..b6ab4e207 --- /dev/null +++ b/services/console/db/migrate/20260702000000_add_sandbox_api_server_capability_to_principals.rb @@ -0,0 +1,5 @@ +class AddSandboxApiServerCapabilityToPrincipals < ActiveRecord::Migration[8.1] + def change + add_column :principals, :sandbox_api_server_enabled, :boolean, null: false, default: true + end +end diff --git a/services/console/db/schema.rb b/services/console/db/schema.rb index eae5df582..8d4c4559a 100644 --- a/services/console/db/schema.rb +++ b/services/console/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_30_090002) do +ActiveRecord::Schema[8.1].define(version: 2026_07_02_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -310,6 +310,7 @@ t.jsonb "labels", default: {}, null: false t.string "name" t.string "namespace", default: "default", null: false + t.boolean "sandbox_api_server_enabled", default: true, null: false t.boolean "sandbox_observability_enabled", default: true, null: false t.boolean "sandbox_repo_cache_enabled", default: true, null: false t.bigint "sync_config_cache_version", default: 0, null: false diff --git a/services/console/test/controllers/api/v1/principals_controller_test.rb b/services/console/test/controllers/api/v1/principals_controller_test.rb index 3990d8f7f..4b1d13e6e 100644 --- a/services/console/test/controllers/api/v1/principals_controller_test.rb +++ b/services/console/test/controllers/api/v1/principals_controller_test.rb @@ -44,6 +44,7 @@ def json_body assert_equal({ "kind" => "slack_channel", "team" => "platform" }, data["labels"]) assert_equal true, data["sandbox_repo_cache_enabled"] assert_equal true, data["sandbox_observability_enabled"] + assert_equal true, data["sandbox_api_server_enabled"] end test "GET returns 404 for an unknown oid" do @@ -79,6 +80,7 @@ def json_body assert_equal({ "kind" => "user", "team" => "platform" }, data["labels"]) assert_equal true, data["sandbox_repo_cache_enabled"] assert_equal true, data["sandbox_observability_enabled"] + assert_equal true, data["sandbox_api_server_enabled"] end test "POST creates a Principal with only a human-readable name" do @@ -99,7 +101,8 @@ def json_body principal = principals(:acme_channel) principal.update!( sandbox_repo_cache_enabled: false, - sandbox_observability_enabled: false + sandbox_observability_enabled: false, + sandbox_api_server_enabled: false ) body = { data: { name: "Acme Slack channel" } } @@ -110,6 +113,7 @@ def json_body assert_equal "Acme Slack channel", principal.name assert_equal false, principal.sandbox_repo_cache_enabled assert_equal false, principal.sandbox_observability_enabled + assert_equal false, principal.sandbox_api_server_enabled end test "PUT updates sandbox access flags" do @@ -117,7 +121,8 @@ def json_body body = { data: { sandbox_repo_cache_enabled: false, - sandbox_observability_enabled: false + sandbox_observability_enabled: false, + sandbox_api_server_enabled: false } } @@ -127,10 +132,12 @@ def json_body principal.reload assert_equal false, principal.sandbox_repo_cache_enabled assert_equal false, principal.sandbox_observability_enabled + assert_equal false, principal.sandbox_api_server_enabled data = json_body.fetch("data") assert_equal false, data["sandbox_repo_cache_enabled"] assert_equal false, data["sandbox_observability_enabled"] + assert_equal false, data["sandbox_api_server_enabled"] end test "POST returns 422 when (namespace, foreign_id) already exists" do diff --git a/services/console/test/controllers/console/principals_controller_test.rb b/services/console/test/controllers/console/principals_controller_test.rb index f4c6f8dbe..b1034524d 100644 --- a/services/console/test/controllers/console/principals_controller_test.rb +++ b/services/console/test/controllers/console/principals_controller_test.rb @@ -17,13 +17,14 @@ class PrincipalsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to login_path end - test "update_sandbox_access toggles repo cache and observability access" do + test "update_sandbox_access toggles sandbox capabilities" do principal = principals(:acme_user_bob) patch console_principal_sandbox_access_url(principal.oid), params: { sandbox_repo_cache_enabled: "0", - sandbox_observability_enabled: "0" + sandbox_observability_enabled: "0", + sandbox_api_server_enabled: "0" } assert_redirected_to console_principal_path(principal.oid) @@ -31,6 +32,7 @@ class PrincipalsControllerTest < ActionDispatch::IntegrationTest principal.reload assert_equal false, principal.sandbox_repo_cache_enabled assert_equal false, principal.sandbox_observability_enabled + assert_equal false, principal.sandbox_api_server_enabled end test "assign_role attaches the role and redirects with a notice" do diff --git a/services/console/test/models/principal_test.rb b/services/console/test/models/principal_test.rb index 91c250d90..91644bb85 100644 --- a/services/console/test/models/principal_test.rb +++ b/services/console/test/models/principal_test.rb @@ -56,6 +56,7 @@ def default_attrs(overrides = {}) assert_predicate principal, :sandbox_repo_cache_enabled assert_predicate principal, :sandbox_observability_enabled + assert_predicate principal, :sandbox_api_server_enabled end test "labels accepts arbitrary string map" do diff --git a/services/sandbox/entrypoint.sh b/services/sandbox/entrypoint.sh index 46996c337..ba4064069 100644 --- a/services/sandbox/entrypoint.sh +++ b/services/sandbox/entrypoint.sh @@ -439,6 +439,16 @@ This sandbox does not have Centaur observability access. Do not use vlogs, vmetr EOF fi +if [ "${CENTAUR_SANDBOX_API_SERVER_ENABLED:-true}" = "false" ] && [ -f "$TARGET_PROMPT" ]; then + cat >> "$TARGET_PROMPT" <<'EOF' + +--- + +[API server access] +This sandbox does not have Centaur API server access. Do not use workflows or tool options that call the api-rs control plane, such as dispatching background agent sessions or downloading Centaur attachment handles. +EOF +fi + # Persona prompt injection is done by the API when it writes AGENTS_BASE.md. # Switch to workspace so the harness reads workspace/AGENTS.md (with persona overlay) diff --git a/tools/productivity/gsuite/client.py b/tools/productivity/gsuite/client.py index 9dfaf3532..b654a158c 100644 --- a/tools/productivity/gsuite/client.py +++ b/tools/productivity/gsuite/client.py @@ -12,11 +12,12 @@ import httplib2 import socks -from centaur_sdk import current_thread_key, save_attachment, secret from google.auth.credentials import AnonymousCredentials from googleapiclient.discovery import build from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload +from centaur_sdk import current_thread_key, save_attachment, secret + try: from api.integrations.gsuite.http import build_http as _shared_build_http except ModuleNotFoundError: @@ -288,9 +289,9 @@ def gmail_reply( Dict with id, thread_id """ import mimetypes - from email.mime.multipart import MIMEMultipart - from email.mime.base import MIMEBase from email import encoders + from email.mime.base import MIMEBase + from email.mime.multipart import MIMEMultipart service = get_gmail_service() @@ -746,6 +747,11 @@ def _download_attachment_bytes( attachment_url: str | None = None, ) -> bytes: """Fetch bytes from Centaur's thread-scoped attachment API.""" + if secret("CENTAUR_SANDBOX_API_SERVER_ENABLED", "true").strip().lower() == "false": + raise RuntimeError( + "Drive uploads from Centaur attachments require the API server sandbox capability, " + "but it is disabled for this principal." + ) path = attachment_url if attachment_id: path = f"/agent/attachments/{attachment_id}/download" diff --git a/tools/productivity/slack/feedback.py b/tools/productivity/slack/feedback.py index 9831e74b7..cc894b45e 100644 --- a/tools/productivity/slack/feedback.py +++ b/tools/productivity/slack/feedback.py @@ -20,6 +20,8 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError +from centaur_sdk import secret + from .client import ( _retry_on_ratelimit, get_slack_client, @@ -117,6 +119,11 @@ class CentaurAgentClient: """Minimal client for starting a background improvement agent session.""" def __init__(self, base_url: str | None = None, api_key: str | None = None): + if secret("CENTAUR_SANDBOX_API_SERVER_ENABLED", "true").strip().lower() == "false": + raise RuntimeError( + "Dispatching feedback improvement runs requires the API server sandbox " + "capability, but it is disabled for this principal." + ) self.base_url = (base_url or os.getenv("CENTAUR_API_URL") or "http://api:8000").rstrip("/") self.api_key = api_key or _load_centaur_api_key() if not self.api_key: From 4b38d2b185a8f53e5ef2550842ba1a8a77d451ce Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:55:04 -0700 Subject: [PATCH 050/198] feat(console): command/tool traces in transcripts with activity-summary previews (#890) Co-authored-by: Claude Fable 5 --- .../controllers/console/threads_controller.rb | 371 +++++++++++++++++- .../console/threads/_transcript.html.erb | 46 ++- .../app/views/layouts/console.html.erb | 257 +++++++++++- .../console/threads_controller_test.rb | 239 ++++++++++- 4 files changed, 876 insertions(+), 37 deletions(-) diff --git a/services/console/app/controllers/console/threads_controller.rb b/services/console/app/controllers/console/threads_controller.rb index 80a58d9c9..c7498ef7b 100644 --- a/services/console/app/controllers/console/threads_controller.rb +++ b/services/console/app/controllers/console/threads_controller.rb @@ -7,6 +7,47 @@ class Console::ThreadsController < ApplicationController TRANSCRIPT_EVENT_LIMIT = 80 PANEL_LIMIT = 4 THINKING_EVENT_LIMIT = 200 + ACTIVITY_SUMMARY_EVENT_LIMIT = 200 + RAW_TRACE_OUTPUT_LINE_PATTERNS = %w[ + reasoning + thinking + tooluse + tool_use + toolresult + tool_result + ].freeze + COMPLETED_TRACE_METHOD_PATTERNS = %w[ + item/completed + item.completed + ].freeze + COMPLETED_TRACE_ITEM_PATTERNS = %w[ + commandexecution + command_execution + mcptoolcall + mcp_tool_call + toolcall + tool_call + tooluse + tool_use + functioncall + function_call + filechange + file_change + ].freeze + TOOL_TRACE_ITEM_TYPES = %w[ + commandExecution + command_execution + mcpToolCall + mcp_tool_call + toolCall + tool_call + toolUse + tool_use + functionCall + function_call + fileChange + file_change + ].freeze # Messages and thinking precede the terminal event for a same-timestamp tie. TRANSCRIPT_SOURCE_ORDER = { message: 0, thinking: 1, event: 2 }.freeze SLACK_PROVIDER = Oauth::Providers::Slack::KEY @@ -453,22 +494,60 @@ def selected_transcript_items # The api-rs stdout pump persists every harness output line verbatim as a # session.output.line event whose payload is a JSON-encoded string. Codex # reasoning arrives as item/completed notifications with item.type == - # "reasoning" carrying the full accumulated thinking text; Claude Code - # stream-json persists each assistant API message whose content can include - # "thinking" blocks. The SQL LIKE filter keeps the query from paging through + # "reasoning" carrying the full accumulated thinking text; tool activity + # arrives as completed command/tool items. Claude Code stream-json persists + # each assistant API message whose content can include "thinking" and + # "tool_use" blocks. The SQL LIKE filter keeps the query from paging through # the whole firehose; exact matching happens here. def selected_thinking_items return [] unless @selected_session - CentaurSessionEvent + items = CentaurSessionEvent .where(thread_key: @selected_session.thread_key) .where(event_type: "session.output.line") - .where("payload::text LIKE '%reasoning%' OR payload::text LIKE '%thinking%'") + .where(trace_output_line_filter_sql, *trace_output_line_filter_values) .order(event_id: :desc) .limit(THINKING_EVENT_LIMIT) .to_a .reverse .filter_map { |event| thinking_transcript_item(event) } + + apply_activity_summaries(compact_trace_items(items)) + end + + # api-rs's activity-summary worker condenses harness output into short + # first-person status lines persisted as session.activity_summary events, + # each pointing at the output-line event that triggered it via + # source_event_id. A summary belongs to the latest trace item at or before + # its source line, so each disclosure's collapsed preview shows the newest + # status generated during that block; items no summary covers keep the + # raw-text fallback rendered by the transcript partial. + def apply_activity_summaries(items) + anchored = items.select { |item| item[:event_id] } + return items if anchored.empty? + + selected_activity_summaries.each do |event| + payload = event.payload_hash + summary = payload["summary"].to_s.strip + source_event_id = payload["source_event_id"] + next if summary.blank? || source_event_id.nil? + + item = anchored.reverse_each.find { |candidate| candidate[:event_id] <= source_event_id.to_i } + item[:summary] = summary if item + end + items + end + + def selected_activity_summaries + return [] unless @selected_session + + CentaurSessionEvent + .where(thread_key: @selected_session.thread_key) + .where(event_type: "session.activity_summary") + .order(event_id: :desc) + .limit(ACTIVITY_SUMMARY_EVENT_LIMIT) + .to_a + .reverse end def thinking_transcript_item(event) @@ -478,14 +557,19 @@ def thinking_transcript_item(event) value = JSON.parse(line) return nil unless value.is_a?(Hash) - text = reasoning_event_text(value) || claude_thinking_text(value) - return nil if text.blank? + trace = reasoning_trace(value) || claude_thinking_trace(value) || tool_trace(value) + return nil unless trace { role: "thinking", - label: "Thinking", + label: trace[:label], align: :start, - text: text, + text: trace[:text], + trace_kind: trace[:kind] || "thinking", + commands: trace[:commands], + tools: trace[:tools], + execution_id: event.execution_id, + event_id: event.event_id, created_at: event.created_at, source: :thinking } @@ -493,6 +577,104 @@ def thinking_transcript_item(event) nil end + def compact_trace_items(items) + grouped = [] + command_group = [] + + flush_command_group = lambda do + grouped << command_trace_group(command_group) if command_group.any? + command_group = [] + end + + items.each do |item| + if item[:trace_kind] == "command" && + (command_group.empty? || same_trace_group?(command_group.last, item)) + command_group << item + else + flush_command_group.call + item[:trace_kind] == "command" ? command_group << item : grouped << item + end + end + + flush_command_group.call + grouped + end + + def same_trace_group?(left, right) + left_execution = left[:execution_id].presence + right_execution = right[:execution_id].presence + return left_execution == right_execution if left_execution && right_execution + + # Older imported fixtures can lack execution ids. Keep immediately adjacent + # command traces together, but avoid merging activity from distinct turns. + left_time = left[:created_at] + right_time = right[:created_at] + left_time.present? && right_time.present? && (right_time - left_time).abs <= 5.minutes + end + + def command_trace_group(items) + commands = items.flat_map { |item| Array(item[:commands]) } + failed_count = commands.count { |command| command[:failed] } + command_count = commands.length + + { + role: "thinking", + label: "Ran #{pluralized_count(command_count, "command")}", + failed_label: failed_count.positive? ? "#{failed_count} failed" : nil, + align: :start, + text: command_group_text(commands), + trace_kind: "commands", + commands: commands, + execution_id: items.first[:execution_id], + event_id: items.first[:event_id], + created_at: items.first[:created_at], + source: :thinking + } + end + + def command_group_text(commands) + commands.map do |command| + [ + "$ #{command[:command]}", + ("Status: #{command[:status]}" if command[:status].present?), + ("Exit code: #{command[:exit_code]}" if command[:exit_code].present?), + command[:output] + ].compact.join("\n") + end.join("\n\n").strip + end + + def pluralized_count(count, singular) + "#{count} #{singular}#{count == 1 ? "" : "s"}" + end + + def trace_output_line_filter_sql + @trace_output_line_filter_sql ||= begin + raw = RAW_TRACE_OUTPUT_LINE_PATTERNS.map { "lower(payload::text) LIKE ?" }.join(" OR ") + completed_methods = + COMPLETED_TRACE_METHOD_PATTERNS.map { "lower(payload::text) LIKE ?" }.join(" OR ") + completed_items = + COMPLETED_TRACE_ITEM_PATTERNS.map { "lower(payload::text) LIKE ?" }.join(" OR ") + "(#{raw}) OR ((#{completed_methods}) AND (#{completed_items}))" + end + end + + def trace_output_line_filter_values + @trace_output_line_filter_values ||= begin + patterns = + RAW_TRACE_OUTPUT_LINE_PATTERNS + + COMPLETED_TRACE_METHOD_PATTERNS + + COMPLETED_TRACE_ITEM_PATTERNS + patterns.map { |pattern| "%#{pattern}%" } + end + end + + def reasoning_trace(value) + text = reasoning_event_text(value) + return nil if text.blank? + + { label: "Thinking", text: text } + end + def reasoning_event_text(value) method = (value["method"] || value["type"]).to_s.tr("/", ".") return nil unless method == "item.completed" @@ -508,6 +690,13 @@ def reasoning_event_text(value) # arrives in content blocks of type "thinking" (text under the "thinking" # key). Partial stream_event lines never carry type == "assistant", so each # thinking block surfaces exactly once. + def claude_thinking_trace(value) + text = claude_thinking_text(value) + return nil if text.blank? + + { label: "Thinking", text: text } + end + def claude_thinking_text(value) return nil unless value["type"].to_s == "assistant" @@ -522,6 +711,170 @@ def claude_thinking_text(value) end.join("\n").strip.presence end + def tool_trace(value) + completed_item_trace(value) || claude_tool_use_trace(value) || claude_tool_result_trace(value) + end + + def completed_item_trace(value) + method = (value["method"] || value["type"]).to_s.tr("/", ".") + return nil unless method == "item.completed" + + item = value.dig("params", "item") || value["item"] + return nil unless item.is_a?(Hash) + + case item["type"].to_s + when "commandExecution", "command_execution" + command_execution_trace(item) + when *TOOL_TRACE_ITEM_TYPES + generic_tool_item_trace(item) + end + end + + def command_execution_trace(item) + command = first_present(item["command"], item["cmd"]) + output = first_present( + item["aggregatedOutput"], + item["aggregated_output"], + item["output"], + item["stdout"], + item["stderr"] + ) + exit_code = first_present(item["exitCode"], item["exit_code"]) + status = first_present(item["status"], exit_code.present? ? "completed" : nil) + + sections = [] + sections << "Status: #{status}" if status.present? + sections << "Exit code: #{exit_code}" if exit_code.present? + sections << markdown_code_block(command, language: shell_language_for_command(command)) if command.present? + sections << "Output:\n\n#{markdown_code_block(output, language: "text")}" if output.present? + + text = sections.compact.join("\n\n").strip + return nil if text.blank? + + { + kind: "command", + label: "Ran 1 command", + text: text, + commands: [ + { + command: command.to_s, + output: output.to_s, + exit_code: exit_code, + status: status, + failed: command_failed?(status, exit_code) + } + ] + } + end + + def command_failed?(status, exit_code) + status.to_s.match?(/\A(?:failed|error|cancelled|timed_out)\z/i) || + (exit_code.present? && exit_code.to_i != 0) + end + + def generic_tool_item_trace(item) + label = trace_label_for_item(item) + name = first_present(item["name"], item["tool"], item["toolName"], item["tool_name"]) + input = item["input"] || item["arguments"] || item["args"] + output = item["output"] || item["result"] + + sections = [] + sections << "Status: #{item["status"]}" if item["status"].present? + sections << "Name: #{name}" if name.present? + sections << "Input:\n\n#{markdown_code_block(pretty_json(input))}" if input.present? + sections << "Output:\n\n#{markdown_code_block(pretty_json(output))}" if output.present? + + text = sections.compact.join("\n\n").strip + return nil if text.blank? + + { label: label, text: text } + end + + def claude_tool_use_trace(value) + return nil unless value["type"].to_s == "assistant" + + content = message_content(value) + return nil unless content.is_a?(Array) + + traces = content.filter_map do |part| + next unless part.is_a?(Hash) && part["type"].to_s == "tool_use" + + name = first_present(part["name"], part["tool"], "tool") + input = part["input"] || part["arguments"] + [ + "Use #{name}", + ("Input:\n\n#{markdown_code_block(pretty_json(input))}" if input.present?) + ].compact.join("\n\n") + end + + text = traces.join("\n\n").strip + return nil if text.blank? + + { label: traces.size == 1 ? "Tool call" : "Tool calls", text: text } + end + + def claude_tool_result_trace(value) + return nil unless %w[user tool].include?(value["type"].to_s) + + content = message_content(value) + return nil unless content.is_a?(Array) + + traces = content.filter_map do |part| + next unless part.is_a?(Hash) + next unless part["type"].to_s == "tool_result" || part["tool_use_id"].present? + + body = first_present(part["content"], part["text"], part["result"]) + next if body.blank? + + [ + ("Tool use: #{part["tool_use_id"]}" if part["tool_use_id"].present?), + markdown_code_block(pretty_json(body), language: "text") + ].compact.join("\n\n") + end + + text = traces.join("\n\n").strip + return nil if text.blank? + + { label: traces.size == 1 ? "Tool result" : "Tool results", text: text } + end + + def message_content(value) + message = value["message"] + message.is_a?(Hash) ? message["content"] : value["content"] + end + + def trace_label_for_item(item) + case item["type"].to_s + when "fileChange", "file_change" then "File change" + when "mcpToolCall", "mcp_tool_call" then "Tool call" + else "Tool call" + end + end + + def markdown_code_block(value, language: nil) + body = value.to_s.rstrip + return nil if body.blank? + + fence = "```" + fence += "`" while body.include?(fence) + "#{fence}#{language}\n#{body}\n#{fence}" + end + + def pretty_json(value) + case value + when String + value + else + JSON.pretty_generate(value) + end + rescue JSON::GeneratorError + value.to_s + end + + def shell_language_for_command(command) + command.to_s.match?(/\A(?:SELECT|WITH|INSERT|UPDATE|DELETE)\b/i) ? "sql" : "sh" + end + # Claude/Amp reasoning lands in content (full text); Codex-native reasoning # may only carry a summary. Prefer the fullest field available. def reasoning_item_text(item) diff --git a/services/console/app/views/console/threads/_transcript.html.erb b/services/console/app/views/console/threads/_transcript.html.erb index 79907ceb6..59bd747b1 100644 --- a/services/console/app/views/console/threads/_transcript.html.erb +++ b/services/console/app/views/console/threads/_transcript.html.erb @@ -10,15 +10,49 @@ <% items.each do |item| %> <% if item[:source] == :thinking %>
-
+
w-full max-w-3xl"> + <%= item[:label].presence || "Thinking" %> + <% if item[:failed_label].present? %> + <%= item[:failed_label] %> + <% end %> + <% if item[:trace_kind] == "commands" %> + <% if item[:summary].present? %> + <%= item[:summary].truncate(96) %> + <% end %> + <% else %> + <%= (item[:summary].presence || item[:text].to_s.gsub(/\s+/, " ")).truncate(96) %> + <% end %> - Thinking - <%= item[:text].to_s.gsub(/\s+/, " ").truncate(96) %> -
- <%= console_markdown(item[:text]) %> -
+ <% if item[:trace_kind] == "commands" %> +
+ <% item[:commands].to_a.each do |command| %> +
+ + ">$ + <%= command[:command].presence || "command" %> + <% if command[:failed] %> + failed + <% end %> + + +
+
">$ <%= command[:command].presence || "command" %>
+ <% if command[:output].present? %> +
<%= command[:output] %>
+ <% else %> +
No output captured.
+ <% end %> +
+
+ <% end %> +
+ <% else %> +
+ <%= console_markdown(item[:text]) %> +
+ <% end %>
<% else %> diff --git a/services/console/app/views/layouts/console.html.erb b/services/console/app/views/layouts/console.html.erb index dfadbfb72..d144546d1 100644 --- a/services/console/app/views/layouts/console.html.erb +++ b/services/console/app/views/layouts/console.html.erb @@ -319,34 +319,54 @@ .console-thinking { min-width: 0; - border-left: 2px solid rgba(255, 255, 255, 0.12); - padding-left: 0.875rem; + padding: 0.125rem 0; } .console-thinking-summary { display: flex; min-width: 0; align-items: center; - gap: 0.375rem; + gap: 0.4rem; cursor: pointer; list-style: none; user-select: none; - color: #71717a; - font-size: 0.75rem; + color: #8b8b94; + font-size: 0.8125rem; + line-height: 1.35; + border-radius: 0.5rem; + padding: 0.2rem 0.5rem; + margin: -0.2rem -0.5rem; + transition: background 120ms ease, color 120ms ease; } .console-thinking-summary::-webkit-details-marker { display: none; } + .console-thinking-summary:focus, + .console-thinking-command-row:focus { + outline: none; + } + + .console-thinking-summary:focus-visible, + .console-thinking-command-row:focus-visible { + outline: none; + } + .console-thinking-summary:hover { - color: #a1a1aa; + background: rgba(255, 255, 255, 0.045); + color: #c4c4cc; } .console-thinking-chevron { display: grid; flex: 0 0 auto; + margin-left: 0.125rem; place-items: center; + /* Optical centering: settle the arrow onto the lowercase x-band that + dominates the row text (geometric center reads slightly high). */ + position: relative; + top: 1px; transition: transform 120ms ease; } @@ -359,8 +379,30 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-style: italic; - color: #5f6068; + color: #6f7078; + } + + .console-thinking-title { + flex: 0 0 auto; + color: #d4d4d8; + font-weight: 500; + } + + .console-thinking-failed, + .console-command-status { + color: #f87171; + } + + /* The comma hugs the group title: pull the label back across the flex + gap so it reads "Ran 2 commands, 1 failed". */ + .console-thinking-failed { + margin-left: -0.4rem; + } + + .console-thinking-failed::before { + content: ","; + color: #8b8b94; + margin-right: 0.35rem; } .console-thinking[open] .console-thinking-preview { @@ -368,10 +410,128 @@ } .console-thinking-body { - margin-top: 0.5rem; + margin-top: 0.35rem; color: #8b8b94; } + /* Roomy enough that adjacent rows' hover pills (which extend 0.2rem + beyond their row) don't touch. */ + .console-thinking-command-list { + display: grid; + gap: 0.4rem; + margin-top: 0.55rem; + } + + .console-thinking-command { + min-width: 0; + } + + .console-thinking-command-row { + display: flex; + min-width: 0; + align-items: baseline; + gap: 0.45rem; + cursor: pointer; + list-style: none; + color: #a1a1aa; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; + line-height: 1.45; + user-select: none; + border-radius: 0.5rem; + padding: 0.2rem 0.5rem; + margin: -0.2rem -0.5rem; + transition: background 120ms ease, color 120ms ease; + } + + .console-thinking-command-row::-webkit-details-marker { + display: none; + } + + .console-thinking-command-row:hover { + background: rgba(255, 255, 255, 0.045); + color: #d4d4d8; + } + + .console-command-prompt { + flex: 0 0 auto; + color: #71717a; + } + + .console-command-prompt--failed { + color: #fb7185; + } + + .console-command-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .console-command-status { + flex: 0 0 auto; + font-family: inherit; + } + + .console-thinking-command-chevron { + display: grid; + flex: 0 0 auto; + margin-left: auto; + place-items: center; + color: #71717a; + /* Optical centering onto the lowercase x-band (baseline alignment + floats the icon ~2px high). */ + position: relative; + top: 2px; + transition: transform 120ms ease; + } + + .console-thinking-command[open] .console-thinking-command-chevron { + transform: rotate(90deg); + } + + /* The box chrome hangs in the same 0.5rem gutter as the row hover + pills, so its inner text (and the $ prompt) sits on the same vertical + line as the command rows above it. */ + .console-thinking-command-output { + margin: 0.45rem -0.5rem 0.6rem; + max-height: 18rem; + overflow: auto; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 0.5rem; + background: rgba(255, 255, 255, 0.045); + color: #a1a1aa; + } + + .console-thinking-command-full { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .console-thinking-command-output pre { + margin: 0; + padding: 0.6rem calc(0.5rem - 1px); + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; + line-height: 1.45; + } + + .console-thinking-command-full { + color: #d4d4d8; + } + + .console-thinking-command-result { + color: #a1a1aa; + } + + .console-thinking-command-empty { + padding: 0.6rem calc(0.5rem - 1px); + color: #71717a; + font-size: 0.75rem; + } + .console-thread-empty, .console-thread-show-all { display: block; @@ -572,6 +732,17 @@ min-width: 0; } + /* markdown_table interpolates Tailwind border classes inside a Ruby + string, so the compiled build can drop them and the borders fall back + to currentColor. Scope the real theme colors here instead. */ + .console-markdown table th { + border-bottom: 1px solid rgba(255, 255, 255, 0.14); + } + + .console-markdown table td { + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + } + .console-markdown-link { overflow-wrap: anywhere; word-break: normal; @@ -744,26 +915,84 @@ color: #24272b; } - html[data-console-theme="light"] .console-thinking { - border-left-color: #d8d8d0; + html[data-console-theme="light"] .console-thinking-summary { + color: #565c63; } - html[data-console-theme="light"] .console-thinking-summary { - color: #7a7f86; + html[data-console-theme="light"] .console-thinking-summary:hover, + html[data-console-theme="light"] .console-thinking-command-row:hover { + background: #e8e9e3; } html[data-console-theme="light"] .console-thinking-summary:hover { - color: #303438; + color: #24272b; } html[data-console-theme="light"] .console-thinking-preview { color: #8c9198; } + html[data-console-theme="light"] .console-thinking-title { + color: #41454a; + } + + html[data-console-theme="light"] .console-thinking-failed, + html[data-console-theme="light"] .console-command-status { + color: #cf3f3f; + } + + html[data-console-theme="light"] .console-command-prompt { + color: #8c9198; + } + + html[data-console-theme="light"] .console-command-prompt--failed { + color: #cf3f3f; + } + + html[data-console-theme="light"] .console-markdown table th { + border-bottom-color: #d3d3cb; + } + + html[data-console-theme="light"] .console-markdown table td { + border-bottom-color: #e5e5de; + } + html[data-console-theme="light"] .console-thinking-body { color: #565c63; } + html[data-console-theme="light"] .console-thinking-command-row { + color: #595f66; + } + + html[data-console-theme="light"] .console-thinking-command-row:hover { + color: #303438; + } + + html[data-console-theme="light"] .console-thinking-command-chevron { + color: #7a7f86; + } + + html[data-console-theme="light"] .console-thinking-command-output { + border-color: #e8e8e2; + background: #ffffff; + color: #565c63; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.38); + } + + html[data-console-theme="light"] .console-thinking-command-full { + border-bottom-color: #ecece6; + color: #303438; + } + + html[data-console-theme="light"] .console-thinking-command-result { + color: #565c63; + } + + html[data-console-theme="light"] .console-thinking-command-empty { + color: #8c9198; + } + html[data-console-theme="light"] .console-user-avatar { background: #dff2e7; color: #12733f; diff --git a/services/console/test/controllers/console/threads_controller_test.rb b/services/console/test/controllers/console/threads_controller_test.rb index ded9b4f60..900cfc0f7 100644 --- a/services/console/test/controllers/console/threads_controller_test.rb +++ b/services/console/test/controllers/console/threads_controller_test.rb @@ -724,7 +724,7 @@ class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest assert_equal indices, indices.sort end - OutputLineEvent = Struct.new(:payload, :created_at, keyword_init: true) + OutputLineEvent = Struct.new(:payload, :created_at, :execution_id, :event_id, keyword_init: true) test "thinking transcript item is extracted from a completed reasoning output line" do controller = Console::ThreadsController.new @@ -762,17 +762,138 @@ class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest assert_equal "Condensed thought.", item[:text] end - test "thinking extraction ignores non-reasoning and non-completed output lines" do + test "thinking extraction formats completed command execution output lines" do + controller = Console::ThreadsController.new + line = { + method: "item/completed", + params: { + item: { + id: "cmd-1", + type: "commandExecution", + command: "pnpm test", + status: "completed", + aggregatedOutput: "ok\n", + exitCode: 0 + } + } + }.to_json + event = OutputLineEvent.new(payload: line, created_at: Time.zone.parse("2026-06-26 17:15:58 UTC")) + + item = controller.send(:thinking_transcript_item, event) + + assert_equal "thinking", item[:role] + assert_equal "Ran 1 command", item[:label] + assert_equal :thinking, item[:source] + assert_equal "command", item[:trace_kind] + assert_equal 1, item[:commands].length + assert_equal "pnpm test", item[:commands].first[:command] + assert_equal "ok\n", item[:commands].first[:output] + assert_equal 0, item[:commands].first[:exit_code] + assert_not item[:commands].first[:failed] + assert_includes item[:text], "Status: completed" + assert_includes item[:text], "Exit code: 0" + assert_includes item[:text], "```sh\npnpm test\n```" + assert_includes item[:text], "Output:" + assert_includes item[:text], "```text\nok\n```" + end + + test "compact trace grouping combines adjacent command executions for one run" do + controller = Console::ThreadsController.new + now = Time.zone.now + first = { + role: "thinking", + label: "Ran 1 command", + text: "$ pnpm test", + trace_kind: "command", + commands: [ { command: "pnpm test", output: "ok\n", exit_code: 0, status: "completed", failed: false } ], + execution_id: "exe-1", + created_at: now, + source: :thinking + } + second = { + role: "thinking", + label: "Ran 1 command", + text: "$ curl bad", + trace_kind: "command", + commands: [ { command: "curl bad", output: "failed\n", exit_code: 22, status: "completed", failed: true } ], + execution_id: "exe-1", + created_at: now + 1.second, + source: :thinking + } + thought = { + role: "thinking", + label: "Thinking", + text: "Need one more check.", + trace_kind: "thinking", + created_at: now + 2.seconds, + source: :thinking + } + + grouped = controller.send(:compact_trace_items, [ first, second, thought ]) + + assert_equal 2, grouped.length + assert_equal "commands", grouped.first[:trace_kind] + assert_equal "Ran 2 commands", grouped.first[:label] + assert_equal "1 failed", grouped.first[:failed_label] + assert_equal [ "pnpm test", "curl bad" ], grouped.first[:commands].map { |command| command[:command] } + assert_equal thought, grouped.second + end + + test "activity summaries attach to the latest trace item at or before their source line" do + controller = Console::ThreadsController.new + items = [ + { event_id: 10, text: "first" }, + { event_id: 20, text: "second" } + ] + summaries = [ + TranscriptEvent.new(event_type: "session.activity_summary", payload_hash: { "summary" => "I found the bug", "source_event_id" => 9 }), + TranscriptEvent.new(event_type: "session.activity_summary", payload_hash: { "summary" => "I'm reading the schema", "source_event_id" => 11 }), + TranscriptEvent.new(event_type: "session.activity_summary", payload_hash: { "summary" => "I'm writing the query", "source_event_id" => 15 }), + TranscriptEvent.new(event_type: "session.activity_summary", payload_hash: { "summary" => "", "source_event_id" => 21 }) + ] + controller.define_singleton_method(:selected_activity_summaries) { summaries } + + controller.send(:apply_activity_summaries, items) + + # The newest summary in an item's window wins; blank summaries and + # summaries preceding every trace item are dropped. + assert_equal "I'm writing the query", items[0][:summary] + assert_nil items[1][:summary] + end + + test "thinking extraction formats claude stream-json tool calls" do + controller = Console::ThreadsController.new + line = { + type: "assistant", + message: { + content: [ + { type: "tool_use", id: "toolu_1", name: "websearch", input: { query: "centaur" } } + ] + } + }.to_json + event = OutputLineEvent.new(payload: line, created_at: Time.zone.now) + + item = controller.send(:thinking_transcript_item, event) + + assert_equal "Tool call", item[:label] + assert_includes item[:text], "Use websearch" + assert_includes item[:text], '"query": "centaur"' + end + + test "thinking extraction ignores partial and unrelated output lines" do controller = Console::ThreadsController.new now = Time.zone.now delta = { method: "item/reasoning/textDelta", params: { delta: "partial" } }.to_json - tool = { method: "item/completed", params: { item: { type: "mcpToolCall" } } }.to_json + started_tool = { + method: "item/started", + params: { item: { type: "commandExecution", command: "pnpm test" } } + }.to_json non_json = "plain stdout noise mentioning reasoning" non_string = { "result" => "reasoning" } assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: delta, created_at: now)) - assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: tool, created_at: now)) + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: started_tool, created_at: now)) assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: non_json, created_at: now)) assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: non_string, created_at: now)) end @@ -851,6 +972,70 @@ class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest assert_select "details.console-thinking", text: /compare the two schemas/ end + test "tool trace renders as a collapsed disclosure in the transcript" do + skip_unless_session_table + + thread_key = "console:tool-trace-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + insert_session_message(thread_key, index: 0) + insert_command_trace_event(thread_key, command: "pnpm test", output: "ok\n") + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "details.console-thinking summary", text: /Ran 1 command/ + assert_select ".console-thinking-command-row", text: /pnpm test/ + assert_select ".console-thinking-command-full", text: /\$ pnpm test/ + assert_select ".console-thinking-command-result", text: /ok/ + assert_select ".console-thinking-command-meta", count: 0 + assert_select "details.console-thinking", text: /Status:/, count: 0 + assert_select "details.console-thinking", text: /pnpm test/ + assert_select "details.console-thinking", text: /ok/ + end + + test "thinking preview shows the activity summary covering its block" do + skip_unless_session_table + + thread_key = "console:activity-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + insert_session_message(thread_key, index: 0) + source_event_id = insert_reasoning_event(thread_key, text: "I should compare the two schemas before answering.") + insert_activity_summary_event( + thread_key, + summary: "I'm comparing the two schemas", + source_event_id: source_event_id + ) + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "details.console-thinking .console-thinking-preview", + text: /I'm comparing the two schemas/ + # The full thinking text stays available in the disclosure body. + assert_select "details.console-thinking", text: /compare the two schemas before answering/ + end + + test "command trace group shows the activity summary as its collapsed preview" do + skip_unless_session_table + + thread_key = "console:activity-cmd-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + insert_session_message(thread_key, index: 0) + source_event_id = insert_command_trace_event(thread_key, command: "pnpm test", output: "ok\n") + insert_activity_summary_event( + thread_key, + summary: "I'm running the test suite", + source_event_id: source_event_id + ) + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "details.console-thinking summary", text: /Ran 1 command/ + assert_select "details.console-thinking .console-thinking-preview", + text: /I'm running the test suite/ + end + test "split view renders owned panes as panels and drops unowned keys" do skip_unless_session_table @@ -1049,12 +1234,33 @@ def insert_session_message(thread_key, index:) # Mirrors how api-rs persists harness stdout: the payload column is a # JSON-encoded *string* holding one protocol notification line. def insert_reasoning_event(thread_key, text:) - connection = CentaurSession.connection - line = { + insert_output_line_event( + thread_key, method: "item/completed", params: { item: { type: "reasoning", content: [ text ] } } - }.to_json - connection.execute(<<~SQL.squish) + ) + end + + def insert_command_trace_event(thread_key, command:, output:) + insert_output_line_event( + thread_key, + method: "item/completed", + params: { + item: { + type: "commandExecution", + command: command, + status: "completed", + aggregatedOutput: output, + exitCode: 0 + } + } + ) + end + + def insert_output_line_event(thread_key, method:, params:) + connection = CentaurSession.connection + line = { method: method, params: params }.to_json + connection.select_value(<<~SQL.squish).to_i insert into session_events (thread_key, event_type, payload, created_at) values ( #{connection.quote(thread_key)}, @@ -1062,6 +1268,23 @@ def insert_reasoning_event(thread_key, text:) #{connection.quote(line.to_json)}::jsonb, now() ) + returning event_id + SQL + end + + # Mirrors api-rs's activity-summary worker: the payload is a JSON object + # whose source_event_id points at the output line that triggered it. + def insert_activity_summary_event(thread_key, summary:, source_event_id:) + connection = CentaurSession.connection + payload = { summary: summary, source_event_id: source_event_id }.to_json + connection.execute(<<~SQL.squish) + insert into session_events (thread_key, event_type, payload, created_at) + values ( + #{connection.quote(thread_key)}, + 'session.activity_summary', + #{connection.quote(payload)}::jsonb, + now() + ) SQL end From a015486b4e9910eb7b1daf4b826e6602f4acb356 Mon Sep 17 00:00:00 2001 From: Luke Youngblood Date: Thu, 2 Jul 2026 23:29:02 -0700 Subject: [PATCH 051/198] fix: capture Slack app message content and unfreeze busy-channel ETL sync (#887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: capture Slack app message content and unfreeze busy-channel ETL sync Two coupled Slack ETL defects, both verified on a live deployment: 1) Content loss: SlackEtlClient._serialize_message allowlists keys and dropped 'attachments'/'blocks' — bot integrations (e.g. the GitHub app) post with empty top-level text and all content in legacy attachments, so their messages were stored content-free (915/916 rows in one live channel), invisible to the FTS index over text and to company_context_documents. Pass both keys through into raw_payload and synthesize text from attachment fallback (else pretext/title/text) only when top-level text is empty. 2) Busy-channel deadlock: conversations.history anchors at 'oldest' when only 'oldest' is passed (verified empirically — the page holds the OLDEST slice, though the code labeled it order:desc). With a backlog wider than one page the incremental tick re-read the same oldest page forever, the watermark froze at the channel's density fixed point (six live channels frozen for weeks), and the hourly continuation re-enqueue clobbered the backfill worker's cursor progress via enqueue_backfill_job's unconditional ON CONFLICT overwrite (status->pending, attempts->0, stale cursor restored). Fixes: (a) when the window page overflows, probe the live head with a default newest-first fetch and take a monotonic max for the checkpoint watermark (never regress); (b) refresh_pending=False for periodic enqueuers so pending/running jobs are never rewritten — continuations now genuinely drain and complete; (c) claim jobs orphaned in 'running' after a stale interval, since the accidental rescue-via-clobber is gone. No schema changes; frozen checkpoints self-heal on the first tick after deploy, and re-ingesting historical pages repairs raw_payload/text via the existing upsert. Co-Authored-By: Claude Fable 5 * review fixes: probe resilience, stable continuation key, reclaim race, serializer polish - Head probe is best-effort and gated on SLACK_BACKFILL_ENABLED: a probe failure (rate limit, likeliest on exactly the busy channels that probe) no longer discards the fetched window page, and the watermark is never jumped past a backlog nothing will drain. - The standing incremental continuation uses one stable job_key per channel: keying on the window's oldest (which now advances with the watermark) minted a new ~fully-overlapping job every tick. - touch_backfill_job_started re-stamps last_started_at as the worker reaches each claimed job, so a slow-but-alive run's tail jobs aren't reclaimed as stale by a concurrent run. - _sync_etl_channel_history never returns a watermark below the one it was given (monotonic at the source, not just in the sync handler). - Attachment fallback text goes through _resolve_mentions like the primary path; blocks are persisted only for bot/empty-text messages (human rich_text blocks just mirror text — jsonb bloat otherwise). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Akshaan Kakar --- workflows/slack/backfill.py | 3 + workflows/slack/shared.py | 104 ++++- workflows/slack/sync.py | 96 ++++- .../slack/tests/test_shared_attachments.py | 296 +++++++++++++ workflows/slack/tests/test_sync_head_probe.py | 389 ++++++++++++++++++ 5 files changed, 866 insertions(+), 22 deletions(-) create mode 100644 workflows/slack/tests/test_sync_head_probe.py diff --git a/workflows/slack/backfill.py b/workflows/slack/backfill.py index 95ad56d1e..02c248535 100644 --- a/workflows/slack/backfill.py +++ b/workflows/slack/backfill.py @@ -55,6 +55,7 @@ record_run_finish, record_run_start, replace_thread_replies, + touch_backfill_job_started, upsert_messages, workflow_run_id_to_sync_run_id, ) @@ -295,6 +296,7 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: job_type = str(job.get("job_type") or "backfill") record_slack_retention_backfill_job(job_type, "claimed") try: + await touch_backfill_job_started(ctx._pool, job_id) if job_type == BACKFILL_JOB_THREAD_REFRESH: payload = _thread_refresh_payload(job) thread_ts = str(payload["thread_ts"]) @@ -444,6 +446,7 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: run_id=run_id, priority=200, refresh_completed=False, + refresh_pending=False, ) record_etl_items_enqueued( "slack", "channel", "thread_refresh_job", 1 diff --git a/workflows/slack/shared.py b/workflows/slack/shared.py index d08cbf2e2..dc0c4a823 100644 --- a/workflows/slack/shared.py +++ b/workflows/slack/shared.py @@ -29,6 +29,9 @@ BACKFILL_JOB_CHANNEL_BOOTSTRAP = "channel_bootstrap" BACKFILL_JOB_THREAD_REFRESH = "thread_refresh" BACKFILL_JOB_PAYLOAD_VERSION = 1 +# Reclaim jobs orphaned in `running` by a dead worker after this long. Must +# exceed the longest plausible backfill workflow run. +BACKFILL_JOB_STALE_RUNNING_HOURS = 2 BACKFILL_JOB_TYPES = ( BACKFILL_JOB_CHANNEL_BOOTSTRAP, BACKFILL_JOB_CHANNEL_CONTINUATION, @@ -180,6 +183,33 @@ def _attachment_raw_payload(attachment: dict[str, Any]) -> dict[str, Any]: } +def _attachment_fallback_text(attachments: Any) -> str: + """Plain-text stand-in for app messages whose content lives in legacy attachments. + + Bot integrations (GitHub, alerting apps) often post with an empty top-level + `text` and put everything in `attachments`; Slack defines `fallback` as the + plain-text summary for exactly this case. + """ + if not isinstance(attachments, list): + return "" + parts: list[str] = [] + for attachment in attachments: + if not isinstance(attachment, dict): + continue + fallback = str(attachment.get("fallback") or "").strip() + if fallback: + parts.append(fallback) + continue + pieces = [ + str(attachment.get(key) or "").strip() + for key in ("pretext", "title", "text") + ] + joined = "\n".join(piece for piece in pieces if piece) + if joined: + parts.append(joined) + return "\n".join(parts) + + def _safe_int(value: Any, default: int = 0) -> int: try: parsed = int(value) @@ -1016,10 +1046,19 @@ def _serialize_message( username = msg.get("bot_profile", {}).get("name", "") or user_id ts = msg.get("ts", "") + text = self._resolve_mentions(msg.get("text", ""), user_cache) + if not text.strip(): + text = self._resolve_mentions( + _attachment_fallback_text(msg.get("attachments")), user_cache + ) + # Human rich_text blocks just mirror `text`; keep blocks only where they + # can carry unique content (app/bot posts or empty-text messages) so + # raw_payload doesn't double for every ordinary message. + keep_blocks = bool(msg.get("bot_id")) or not (msg.get("text") or "").strip() message = { "user": username, "user_id": user_id, - "text": self._resolve_mentions(msg.get("text", ""), user_cache), + "text": text, "timestamp": ts, "permalink": self._message_permalink(channel_id, ts), "channel_id": channel_id, @@ -1031,6 +1070,8 @@ def _serialize_message( "subtype": msg.get("subtype"), "parent_user_id": msg.get("parent_user_id"), "bot_id": msg.get("bot_id"), + "attachments": msg.get("attachments") or [], + "blocks": (msg.get("blocks") or []) if keep_blocks else [], "files": [ normalized for file_obj in msg.get("files", []) or [] @@ -1361,9 +1402,12 @@ def _sync_etl_channel_history( latest_seen = watermark if page["messages"]: - latest_seen = self._format_ts( - max(float(message["timestamp"]) for message in page["messages"]) - ) + page_max = max(float(message["timestamp"]) for message in page["messages"]) + # An oldest-anchored page can sit entirely below the prior watermark; + # never let the returned watermark regress past it. + if watermark is not None: + page_max = max(page_max, float(watermark)) + latest_seen = self._format_ts(page_max) next_state: dict[str, Any] = { "cursor": page["next_cursor"] if page["has_more"] else None, @@ -1442,15 +1486,30 @@ async def enqueue_backfill_job( run_id: str, priority: int = 100, refresh_completed: bool = True, + refresh_pending: bool = True, ) -> None: - """Store or refresh a queued backfill job outside the incremental checkpoint.""" + """Store or refresh a queued backfill job outside the incremental checkpoint. + + `refresh_pending=False` protects in-flight work: the conflict update then + only reopens finished (or just failed) jobs and never rewrites the payload, + status, or attempt count of a `pending`/`running` row. Periodic enqueuers + (the incremental sync) must use it so they cannot clobber the cursor a + backfill worker is actively advancing under the same job_key. + """ if not payload: return - completion_guard = ( - "" - if refresh_completed - else " WHERE slack_sync_backfill_jobs.status <> 'completed'" - ) + if refresh_pending: + completion_guard = ( + "" + if refresh_completed + else " WHERE slack_sync_backfill_jobs.status <> 'completed'" + ) + else: + completion_guard = ( + " WHERE slack_sync_backfill_jobs.status IN ('completed', 'failed')" + if refresh_completed + else " WHERE slack_sync_backfill_jobs.status = 'failed'" + ) await pool.execute( "INSERT INTO slack_sync_backfill_jobs (" "job_key, job_type, payload_version, channel_id, status, payload_json, " @@ -1584,7 +1643,12 @@ async def widen_channel_bootstrap_job( async def claim_backfill_jobs(pool, limit: int) -> list[dict[str, Any]]: - """Claim a bounded batch of pending backfill jobs for one workflow run.""" + """Claim a bounded batch of pending backfill jobs for one workflow run. + + Jobs stuck in `running` past the stale interval are reclaimed too: a worker + that died mid-job leaves its row `running` forever, and nothing else may + touch in-flight rows (see `enqueue_backfill_job(refresh_pending=False)`). + """ async with pool.acquire() as conn: async with conn.transaction(): rows = await conn.fetch( @@ -1592,6 +1656,8 @@ async def claim_backfill_jobs(pool, limit: int) -> list[dict[str, Any]]: " SELECT job_id " " FROM slack_sync_backfill_jobs " " WHERE status IN ('pending', 'failed') " + " OR (status = 'running' AND last_started_at < " + f" NOW() - INTERVAL '{BACKFILL_JOB_STALE_RUNNING_HOURS} hours') " " ORDER BY priority, updated_at, job_id " " LIMIT $1 " " FOR UPDATE SKIP LOCKED" @@ -1611,6 +1677,22 @@ async def claim_backfill_jobs(pool, limit: int) -> list[dict[str, Any]]: return [dict(row) for row in rows] +async def touch_backfill_job_started(pool, job_id: int) -> None: + """Re-stamp a claimed job as this worker starts actually processing it. + + A claim batch stamps `last_started_at` once for up to 50 jobs; without the + per-job re-stamp, a job at the tail of a slow (rate-limited) but alive run + can cross the stale-`running` threshold and be reclaimed by a concurrent + run while still owned. + """ + await pool.execute( + "UPDATE slack_sync_backfill_jobs " + "SET last_started_at = NOW(), updated_at = NOW() " + "WHERE job_id = $1 AND status = 'running'", + job_id, + ) + + async def load_backfill_job_metrics(pool) -> list[dict[str, Any]]: """Summarize Slack backfill queue state for dashboard gauges.""" rows = await pool.fetch( diff --git a/workflows/slack/sync.py b/workflows/slack/sync.py index 55508fe1a..efbe626e0 100644 --- a/workflows/slack/sync.py +++ b/workflows/slack/sync.py @@ -192,6 +192,23 @@ def _watermark_lag_seconds(ts: str | None) -> float | None: return max((dt.datetime.now(dt.timezone.utc) - occurred_at).total_seconds(), 0.0) +def _max_slack_ts(*values: Any) -> str | None: + """Return the numerically greatest Slack ts string, ignoring empty/invalid.""" + best: str | None = None + best_value = float("-inf") + for value in values: + if not value: + continue + try: + numeric = float(value) + except (TypeError, ValueError): + continue + if numeric > best_value: + best_value = numeric + best = str(value) + return best + + async def _upsert_channels(pool, channels: list[dict[str, Any]]) -> None: """Refresh public Slack sync channel rows and mark absent channels out of scope.""" async with pool.acquire() as conn: @@ -504,6 +521,45 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: ) record_slack_retention_api_request("fetch_history", "success") messages = page.get("messages") or [] + head_page: dict[str, Any] | None = None + if ( + page.get("has_more") + and inp.latest is None + # Jumping the watermark to the head is only safe when the + # continuation jobs that cover the middle actually drain. + and env_flag_enabled("SLACK_BACKFILL_ENABLED", default=True) + ): + # An overflowing window anchors at `oldest`, so this page holds + # the oldest slice and the live head stays unfetched — on a busy + # channel the watermark would otherwise freeze below the backlog + # forever. Probe the head with a default (newest-first) fetch; + # the middle is drained by the continuation job. Best-effort: + # a probe failure must not discard the window page already + # fetched (the likeliest failure is a rate limit, on exactly + # the busy channels that probe every tick). + try: + head_page = client._sync_etl_channel_history( + channel_id, + state={ + "cursor": None, + "watermark": None, + "oldest": None, + "latest": None, + }, + limit=limit, + lookback_days=0, + ) + except Exception as exc: # noqa: BLE001 — degrade to window-only + head_page = None + ctx.log( + "slack_sync_head_probe_failed", + channel_id=channel_id, + channel_name=channel_name, + error=str(exc), + ) + else: + record_slack_retention_api_request("fetch_history", "success") + messages = messages + (head_page.get("messages") or []) message_rows = [message_row(msg, run_id) for msg in messages] counts["messages_fetched"] += len(message_rows) record_slack_retention_messages_processed( @@ -555,6 +611,7 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: payload={"thread_ts": thread_ts}, run_id=run_id, priority=200, + refresh_pending=False, ) record_etl_items_enqueued("slack", "channel", "thread_refresh_job", 1) ctx.log( @@ -595,13 +652,24 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: window_oldest_ts=desired_oldest, ) if next_state.get("cursor"): - await enqueue_backfill_job( - ctx._pool, - job_key=_continuation_backfill_job_key( + # The standing incremental continuation uses ONE stable key per + # channel: the window's `oldest` tracks the (now advancing) + # watermark, so keying on it would mint a new, almost fully + # overlapping job every tick on a persistently busy channel. + # With refresh_pending=False a pending/running row keeps its + # older (wider) window; a completed/failed row reopens with the + # fresh cursor. Explicitly bounded runs keep the windowed key. + if inp.oldest is None and inp.latest is None: + continuation_job_key = f"continuation:{channel_id}:incremental" + else: + continuation_job_key = _continuation_backfill_job_key( channel_id, oldest_ts=str(next_state.get("oldest") or "") or None, latest_ts=str(next_state.get("latest") or "") or None, - ), + ) + await enqueue_backfill_job( + ctx._pool, + job_key=continuation_job_key, job_type=BACKFILL_JOB_CHANNEL_CONTINUATION, channel_id=channel_id, payload={ @@ -613,11 +681,9 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: }, run_id=run_id, priority=100, - ) - continuation_job_key = _continuation_backfill_job_key( - channel_id, - oldest_ts=str(next_state.get("oldest") or "") or None, - latest_ts=str(next_state.get("latest") or "") or None, + # Never clobber a pending/running continuation: the backfill + # worker owns its cursor progress under this job_key. + refresh_pending=False, ) record_etl_items_enqueued( "slack", "channel", "channel_continuation_job", 1 @@ -632,13 +698,21 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: latest_ts=next_state.get("latest"), has_cursor=True, ) + # Monotonic watermark: an oldest-anchored window page can carry a + # max ts *below* the stored watermark; never let it regress. The + # head probe (when it ran) contributes the true live head. + watermark_ts = _max_slack_ts( + next_state.get("watermark"), + ((head_page or {}).get("sync_state") or {}).get("watermark"), + checkpoint_watermark, + ) await _update_checkpoint_success( ctx._pool, channel_id=channel_id, - watermark_ts=next_state.get("watermark"), + watermark_ts=watermark_ts, run_id=run_id, ) - lag_s = _watermark_lag_seconds(next_state.get("watermark")) + lag_s = _watermark_lag_seconds(watermark_ts) if lag_s is not None: set_slack_retention_watermark_lag_seconds(mode, lag_s) synced.append(channel_ref(channel)) diff --git a/workflows/slack/tests/test_shared_attachments.py b/workflows/slack/tests/test_shared_attachments.py index e1c06c373..5f4141537 100644 --- a/workflows/slack/tests/test_shared_attachments.py +++ b/workflows/slack/tests/test_shared_attachments.py @@ -308,6 +308,83 @@ def fail_download(*_args, **_kwargs): assert message["files"][0]["content_bytes"] is None +def test_serialize_message_preserves_bot_attachments_and_blocks(): + client = object.__new__(shared.SlackEtlClient) + attachments = [ + { + "fallback": "Deployment approved by <@U04ABCDEF>", + "title": "unused when fallback is present", + "color": "36a64f", + } + ] + blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "hi"}}] + + message = client._serialize_message( + { + "bot_id": "B02T451BQRK", + "text": "", + "ts": "1770000000.000600", + "attachments": attachments, + "blocks": blocks, + }, + "C123", + {"U04ABCDEF": "artur"}, + ) + + assert message["attachments"] == attachments + assert message["blocks"] == blocks + # Fallback text is mention-resolved, same as the primary text path. + assert message["text"] == "Deployment approved by @artur" + + row = shared.message_row(message, "run_123") + assert row["raw_payload"]["attachments"] == attachments + assert row["raw_payload"]["blocks"] == blocks + assert row["text"] == "Deployment approved by @artur" + # Slack legacy attachments are message content, not file rows. + assert row["attachments"] == [] + + +def test_serialize_message_keeps_user_text_over_attachment_fallback(): + client = object.__new__(shared.SlackEtlClient) + + message = client._serialize_message( + { + "user": "U123", + "text": "see the graph below", + "ts": "1770000000.000700", + "attachments": [{"fallback": "graph.png"}], + "blocks": [{"type": "rich_text", "elements": []}], + }, + "C123", + {"U123": "alice"}, + ) + + assert message["text"] == "see the graph below" + assert message["attachments"] == [{"fallback": "graph.png"}] + # Human rich_text blocks mirror the text; not persisted. + assert message["blocks"] == [] + + +def test_attachment_fallback_text_prefers_fallback_then_joins_fields(): + assert shared._attachment_fallback_text(None) == "" + assert shared._attachment_fallback_text("not-a-list") == "" + assert ( + shared._attachment_fallback_text( + [ + "not-a-dict", + {"fallback": " first alert "}, + { + "pretext": "PR merged", + "title": "moonwell-fi/mamo", + "text": "fix: handle empty originTx", + }, + {"color": "dddddd"}, + ] + ) + == "first alert\nPR merged\nmoonwell-fi/mamo\nfix: handle empty originTx" + ) + + class FakeConn: """Records statements issued inside ``upsert_messages``/attachment batch.""" @@ -501,6 +578,7 @@ async def fake_terminal_skip(_pool, **kwargs): monkeypatch.setattr(backfill, "_emit_backfill_job_metrics", fake_noop) monkeypatch.setattr(backfill, "emit_slack_checkpoint_metrics", fake_noop) monkeypatch.setattr(backfill, "claim_backfill_jobs", fake_claim_jobs) + monkeypatch.setattr(backfill, "touch_backfill_job_started", fake_noop) monkeypatch.setattr(backfill, "shared_client", lambda **_kwargs: FakeClient()) monkeypatch.setattr(backfill, "record_run_start", fake_noop) monkeypatch.setattr(backfill, "record_run_finish", fake_record_finish) @@ -673,3 +751,221 @@ def test_upsert_messages_dedupes_duplicate_message_keys_last_row_wins(): assert len(conn.execute_calls) == 1 _delete_sql, delete_args = conn.execute_calls[0] assert delete_args == (["C123"], ["1770000000.000500"], [], [], []) + + +def test_enqueue_backfill_job_refresh_guards(): + pool = FakeExecutePool() + base = { + "job_key": "continuation:C123:1770000000.000100:", + "job_type": shared.BACKFILL_JOB_CHANNEL_CONTINUATION, + "channel_id": "C123", + "payload": {"cursor": "abc"}, + "run_id": "run_123", + } + + asyncio.run(shared.enqueue_backfill_job(pool, **base)) + asyncio.run(shared.enqueue_backfill_job(pool, **base, refresh_completed=False)) + asyncio.run(shared.enqueue_backfill_job(pool, **base, refresh_pending=False)) + asyncio.run( + shared.enqueue_backfill_job( + pool, **base, refresh_completed=False, refresh_pending=False + ) + ) + + sqls = [sql for sql, _args in pool.execute_calls] + assert len(sqls) == 4 + # Default: unconditional refresh (the backfill worker saving its progress). + assert "WHERE slack_sync_backfill_jobs.status" not in sqls[0] + assert sqls[1].endswith("WHERE slack_sync_backfill_jobs.status <> 'completed'") + # refresh_pending=False must never touch in-flight pending/running rows. + assert sqls[2].endswith( + "WHERE slack_sync_backfill_jobs.status IN ('completed', 'failed')" + ) + assert sqls[3].endswith("WHERE slack_sync_backfill_jobs.status = 'failed'") + + +class FakeClaimConn(FakeConn): + def __init__(self) -> None: + super().__init__() + self.fetch_calls: list[tuple] = [] + + async def fetch(self, sql, *args): + self.fetch_calls.append((sql, args)) + return [] + + +def test_claim_backfill_jobs_reclaims_stale_running_rows(): + conn = FakeClaimConn() + pool = FakePool(conn) + + result = asyncio.run(shared.claim_backfill_jobs(pool, 5)) + + assert result == [] + assert len(conn.fetch_calls) == 1 + sql = " ".join(conn.fetch_calls[0][0].split()) + assert "status IN ('pending', 'failed')" in sql + assert ( + "status = 'running' AND last_started_at < " + f"NOW() - INTERVAL '{shared.BACKFILL_JOB_STALE_RUNNING_HOURS} hours'" + ) in sql + + +def test_touch_backfill_job_started_restamps_running_row(): + pool = FakeExecutePool() + + asyncio.run(shared.touch_backfill_job_started(pool, 42)) + + assert len(pool.execute_calls) == 1 + sql, args = pool.execute_calls[0] + assert "SET last_started_at = NOW()" in sql + assert "status = 'running'" in sql + assert args == (42,) + + +def test_sync_etl_channel_history_watermark_never_regresses(monkeypatch): + client = object.__new__(shared.SlackEtlClient) + + def fake_page(**kwargs): + return { + "channel": "busy", + "channel_id": "C123", + "messages": [{"timestamp": "100.000000"}], + "count": 1, + "has_more": False, + "next_cursor": None, + "window": { + "oldest": kwargs.get("oldest"), + "latest": kwargs.get("latest"), + "inclusive": True, + }, + "order": "desc", + } + + monkeypatch.setattr( + client, "_get_etl_channel_history_page", lambda **kw: fake_page(**kw) + ) + + out = client._sync_etl_channel_history( + "C123", state={"watermark": "200.000000"}, limit=10 + ) + + # An oldest-anchored page below the prior watermark must not regress it. + assert out["sync_state"]["watermark"] == "200.000000" + + +def test_backfill_handler_drains_continuation_pages_then_completes(monkeypatch): + monkeypatch.setenv("SLACK_ETL_ENABLED", "true") + monkeypatch.setenv("SLACK_BACKFILL_ENABLED", "true") + backfill = _load_backfill() + calls: dict[str, list] = {"enqueued": [], "completed": [], "upserted": []} + + class FakeClient: + def __init__(self) -> None: + self.history_calls: list[dict] = [] + + def _etl_access_mode(self): + return "test" + + def _sync_etl_channel_history(self, channel_id, *, state, limit, lookback_days): + self.history_calls.append(dict(state)) + if state.get("cursor") == "cursor-0": + return { + "messages": [ + { + "channel_id": channel_id, + "timestamp": "1770000001.000100", + "text": "page one", + } + ], + "sync_state": { + "cursor": "cursor-1", + "watermark": "1770000001.000100", + "oldest": state.get("oldest"), + "latest": None, + }, + } + return { + "messages": [ + { + "channel_id": channel_id, + "timestamp": "1770000002.000100", + "text": "page two", + } + ], + "sync_state": { + "cursor": None, + "watermark": "1770000002.000100", + "oldest": None, + "latest": None, + }, + } + + class FakeContext: + run_id = "wfr_drain" + _pool = object() + + def __init__(self) -> None: + self.logs: list[tuple] = [] + + def log(self, name, **fields): + self.logs.append((name, fields)) + + async def fake_claim_jobs(_pool, _limit): + return [ + { + "job_id": 586, + "job_key": "continuation:C123:1770000000.000100:", + "job_type": shared.BACKFILL_JOB_CHANNEL_CONTINUATION, + "payload_version": shared.BACKFILL_JOB_PAYLOAD_VERSION, + "channel_id": "C123", + "payload_json": { + "cursor": "cursor-0", + "oldest": "1770000000.000100", + "latest": None, + "lookback_days": 30, + "thread_lookback_days": 3, + }, + "priority": 100, + "attempt_count": 1, + } + ] + + async def fake_noop(*_args, **_kwargs): + return None + + async def fake_upsert_messages(_pool, rows): + calls["upserted"].extend(rows) + return len(rows) + + async def fake_enqueue(_pool, **kwargs): + calls["enqueued"].append(kwargs) + + async def fake_completed(_pool, **kwargs): + calls["completed"].append(kwargs) + + fake_client = FakeClient() + monkeypatch.setattr(backfill, "_emit_backfill_job_metrics", fake_noop) + monkeypatch.setattr(backfill, "emit_slack_checkpoint_metrics", fake_noop) + monkeypatch.setattr(backfill, "claim_backfill_jobs", fake_claim_jobs) + monkeypatch.setattr(backfill, "touch_backfill_job_started", fake_noop) + monkeypatch.setattr(backfill, "shared_client", lambda **_kwargs: fake_client) + monkeypatch.setattr(backfill, "upsert_messages", fake_upsert_messages) + monkeypatch.setattr(backfill, "enqueue_backfill_job", fake_enqueue) + monkeypatch.setattr(backfill, "mark_backfill_job_completed", fake_completed) + monkeypatch.setattr(backfill, "record_run_start", fake_noop) + monkeypatch.setattr(backfill, "record_run_finish", fake_noop) + + result = asyncio.run(backfill.handler(backfill.Input(channel_batch_limit=1), FakeContext())) + + assert result["status"] == "completed" + # The drain advanced through the cursor chain instead of refetching page one. + assert [call.get("cursor") for call in fake_client.history_calls] == [ + "cursor-0", + "cursor-1", + ] + assert len(calls["upserted"]) == 2 + # Exhausted cursor => the job completes; nothing requeues it. + assert calls["enqueued"] == [] + assert len(calls["completed"]) == 1 + assert calls["completed"][0]["job_id"] == 586 + assert calls["completed"][0]["payload"]["cursor"] is None diff --git a/workflows/slack/tests/test_sync_head_probe.py b/workflows/slack/tests/test_sync_head_probe.py new file mode 100644 index 000000000..9f69eca5e --- /dev/null +++ b/workflows/slack/tests/test_sync_head_probe.py @@ -0,0 +1,389 @@ +"""Busy-channel incremental sync: head probe, watermark monotonicity, enqueue guards. + +Regression tests for the deadlock where a channel with more than one page of +backlog froze forever: the oldest-anchored window page kept the watermark at +the backlog's density fixed point while the hourly continuation re-enqueue +clobbered the backfill worker's cursor progress. +""" + +from __future__ import annotations + +import asyncio +import importlib +import json +import sys +import time +import types +from pathlib import Path + + +def _load_sync(): + repo_root = Path(__file__).resolve().parents[3] + if str(repo_root) not in sys.path: + sys.path.insert(0, str(repo_root)) + + api_module = sys.modules.setdefault("api", types.ModuleType("api")) + + runtime_control = types.ModuleType("api.runtime_control") + runtime_control.canonical_json = lambda value: json.dumps(value, sort_keys=True) + api_module.runtime_control = runtime_control + sys.modules["api.runtime_control"] = runtime_control + + etl_metrics = types.ModuleType("workflows.etl_metrics") + for name in ( + "record_etl_items_enqueued", + "record_etl_items_failed", + "record_etl_items_seen", + "record_etl_items_upserted", + "set_etl_active_scopes", + "set_etl_failed_scopes", + "set_etl_scope_sync_freshness_seconds", + ): + setattr(etl_metrics, name, lambda *_args, **_kwargs: None) + sys.modules["workflows.etl_metrics"] = etl_metrics + + slack_metrics = types.ModuleType("workflows.slack.metrics") + for name in ( + "observe_slack_retention_run_duration", + "record_slack_etl_rate_limit", + "record_slack_retention_api_rate_limited", + "record_slack_retention_api_request", + "record_slack_retention_channel_failure", + "record_slack_retention_failure", + "record_slack_retention_messages_processed", + "record_slack_retention_run", + "set_slack_retention_last_failure_timestamp", + "set_slack_retention_watermark_lag_seconds", + ): + setattr(slack_metrics, name, lambda *_args, **_kwargs: None) + sys.modules["workflows.slack.metrics"] = slack_metrics + + workflow_engine = types.ModuleType("api.workflow_engine") + workflow_engine.WorkflowContext = object + api_module.workflow_engine = workflow_engine + sys.modules["api.workflow_engine"] = workflow_engine + + centaur_sdk = sys.modules.setdefault("centaur_sdk", types.ModuleType("centaur_sdk")) + centaur_sdk.secret = lambda _name, default=None: default + + return importlib.import_module("workflows.slack.sync") + + +class FakeContext: + run_id = "wfr_test" + _pool = object() + + def __init__(self) -> None: + self.logs: list[tuple[str, dict]] = [] + + def log(self, name: str, **fields): + self.logs.append((name, fields)) + + +class FakeBusyClient: + """First call returns an overflowing oldest-anchored window page; the head + probe (no oldest, lookback 0) returns the live newest page.""" + + def __init__(self, *, head_ts: str, window_watermark: str = "1770000100.000001") -> None: + self.history_calls: list[dict] = [] + self.head_ts = head_ts + self.window_watermark = window_watermark + + def _etl_access_mode(self): + return "test" + + def _list_etl_channels(self, *_args, **_kwargs): + return [{"id": "C123", "name": "busy"}] + + def _list_etl_users(self, *_args, **_kwargs): + return [] + + def _sync_etl_channel_history(self, channel_id, **kwargs): + self.history_calls.append({"channel_id": channel_id, **kwargs}) + if len(self.history_calls) == 1: + return { + "messages": [ + { + "channel_id": channel_id, + "timestamp": self.window_watermark, + "text": "stale backlog slice", + } + ], + "has_more": True, + "next_cursor": "cursor-window", + "sync_state": { + "cursor": "cursor-window", + "watermark": self.window_watermark, + "oldest": kwargs.get("oldest"), + "latest": None, + }, + } + return { + "messages": [ + { + "channel_id": channel_id, + "timestamp": self.head_ts, + "thread_ts": self.head_ts, + "reply_count": 2, + "text": "live head", + } + ], + "has_more": False, + "next_cursor": None, + "sync_state": { + "cursor": None, + "watermark": self.head_ts, + "oldest": None, + "latest": None, + }, + } + + +async def _noop(*_args, **_kwargs): + return None + + +async def _zero(*_args, **_kwargs): + return 0 + + +def _patch_handler_io(monkeypatch, sync, *, checkpoint=None, client=None): + calls: dict[str, list] = { + "checkpoint_success": [], + "enqueued": [], + "upserted": [], + } + fake_client = client + + async def fake_load_checkpoint(_pool, _channel_id): + return checkpoint + + async def fake_upsert_messages(_pool, rows): + calls["upserted"].extend(rows) + return len(rows) + + async def fake_load_thread_refresh_times(*_args, **_kwargs): + return {} + + async def fake_update_checkpoint_success(_pool, **kwargs): + calls["checkpoint_success"].append(kwargs) + + async def fake_enqueue_backfill_job(_pool, **kwargs): + calls["enqueued"].append(kwargs) + + async def fake_widen(_pool, **_kwargs): + return False + + monkeypatch.setattr(sync, "_client", lambda: fake_client) + monkeypatch.setattr(sync, "_upsert_channels", _noop) + monkeypatch.setattr(sync, "_upsert_users", _zero) + monkeypatch.setattr(sync, "_load_checkpoint", fake_load_checkpoint) + monkeypatch.setattr(sync, "_upsert_messages", fake_upsert_messages) + monkeypatch.setattr(sync, "load_thread_refresh_times", fake_load_thread_refresh_times) + monkeypatch.setattr(sync, "_update_checkpoint_success", fake_update_checkpoint_success) + monkeypatch.setattr(sync, "_update_checkpoint_failure", _noop) + monkeypatch.setattr(sync, "enqueue_backfill_job", fake_enqueue_backfill_job) + monkeypatch.setattr(sync, "emit_slack_checkpoint_metrics", _noop) + monkeypatch.setattr(sync, "record_run_start", _noop) + monkeypatch.setattr(sync, "record_run_finish", _noop) + monkeypatch.setattr(sync, "widen_channel_bootstrap_job", fake_widen) + + return calls + + +def test_overflowing_window_probes_head_and_advances_watermark(monkeypatch): + monkeypatch.setenv("SLACK_ETL_ENABLED", "true") + monkeypatch.delenv("SLACK_BACKFILL_ENABLED", raising=False) + sync = _load_sync() + head_ts = f"{time.time():.6f}" + client = FakeBusyClient(head_ts=head_ts) + calls = _patch_handler_io( + monkeypatch, + sync, + checkpoint={"watermark_ts": "1770000050.000001", "last_error": ""}, + client=client, + ) + + result = asyncio.run(sync.handler(sync.Input(), FakeContext())) + + assert result["status"] == "completed" + # Window fetch, then the head probe with a default newest-first call. + assert len(client.history_calls) == 2 + head_call = client.history_calls[1] + assert head_call["lookback_days"] == 0 + assert "oldest" not in head_call or head_call.get("oldest") is None + assert head_call["state"] == { + "cursor": None, + "watermark": None, + "oldest": None, + "latest": None, + } + + # Both the stale slice and the live head were upserted. + upserted_ts = {row["message_ts"] for row in calls["upserted"]} + assert upserted_ts == {"1770000100.000001", head_ts} + + # Watermark jumps to the live head, not the window page max. + assert calls["checkpoint_success"] == [ + { + "channel_id": "C123", + "watermark_ts": head_ts, + "run_id": "slack_sync_wfr_test", + } + ] + + # The continuation carries the window cursor and must not clobber + # in-flight jobs; the head thread refresh gets the same protection. + continuations = [ + c + for c in calls["enqueued"] + if c["job_type"] == sync.BACKFILL_JOB_CHANNEL_CONTINUATION + ] + assert len(continuations) == 1 + assert continuations[0]["payload"]["cursor"] == "cursor-window" + assert continuations[0]["refresh_pending"] is False + # One STABLE key per channel for the standing incremental continuation — + # a window-derived key would mint a new overlapping job every tick now + # that the watermark advances. + assert continuations[0]["job_key"] == "continuation:C123:incremental" + + thread_refreshes = [ + c + for c in calls["enqueued"] + if c["job_type"] == sync.BACKFILL_JOB_THREAD_REFRESH + ] + assert len(thread_refreshes) == 1 + assert thread_refreshes[0]["payload"] == {"thread_ts": head_ts} + assert thread_refreshes[0]["refresh_pending"] is False + + +class FakeProbeFailClient(FakeBusyClient): + """Window page succeeds; the head probe raises (e.g. rate limit).""" + + def _sync_etl_channel_history(self, channel_id, **kwargs): + if self.history_calls: + self.history_calls.append({"channel_id": channel_id, **kwargs}) + raise RuntimeError("Slack API error: ratelimited") + return super()._sync_etl_channel_history(channel_id, **kwargs) + + +def test_head_probe_failure_keeps_window_progress(monkeypatch): + monkeypatch.setenv("SLACK_ETL_ENABLED", "true") + monkeypatch.delenv("SLACK_BACKFILL_ENABLED", raising=False) + sync = _load_sync() + client = FakeProbeFailClient(head_ts="unused") + calls = _patch_handler_io( + monkeypatch, + sync, + checkpoint={"watermark_ts": "1770000050.000001", "last_error": ""}, + client=client, + ) + + ctx = FakeContext() + result = asyncio.run(sync.handler(sync.Input(), ctx)) + + # The probe is best-effort: its failure must not discard the fetched + # window page, the continuation enqueue, or the checkpoint write. + assert result["status"] == "completed" + assert len(client.history_calls) == 2 + assert [row["message_ts"] for row in calls["upserted"]] == ["1770000100.000001"] + assert calls["checkpoint_success"][0]["watermark_ts"] == "1770000100.000001" + assert any( + c["job_type"] == sync.BACKFILL_JOB_CHANNEL_CONTINUATION + for c in calls["enqueued"] + ) + assert any(name == "slack_sync_head_probe_failed" for name, _ in ctx.logs) + + +def test_head_probe_skipped_when_backfill_disabled(monkeypatch): + monkeypatch.setenv("SLACK_ETL_ENABLED", "true") + monkeypatch.setenv("SLACK_BACKFILL_ENABLED", "false") + sync = _load_sync() + client = FakeBusyClient(head_ts="1779999999.000001") + calls = _patch_handler_io(monkeypatch, sync, client=client) + + result = asyncio.run(sync.handler(sync.Input(), FakeContext())) + + # Without the backfill worker there is nothing to drain the middle of the + # backlog, so jumping the watermark would certify a permanent hole. + assert result["status"] == "completed" + assert len(client.history_calls) == 1 + assert calls["checkpoint_success"][0]["watermark_ts"] == "1770000100.000001" + + +def test_head_probe_skipped_for_bounded_manual_runs(monkeypatch): + monkeypatch.setenv("SLACK_ETL_ENABLED", "true") + monkeypatch.delenv("SLACK_BACKFILL_ENABLED", raising=False) + sync = _load_sync() + client = FakeBusyClient(head_ts="1779999999.000001") + _calls = _patch_handler_io(monkeypatch, sync, client=client) + + result = asyncio.run( + sync.handler(sync.Input(latest="1770000200.000000"), FakeContext()) + ) + + assert result["status"] == "completed" + # An explicit `latest` bound means a deliberate historical window: no probe. + assert len(client.history_calls) == 1 + + +class FakeQuietClient(FakeBusyClient): + """Single page, no backlog, watermark below the stored checkpoint.""" + + def _sync_etl_channel_history(self, channel_id, **kwargs): + self.history_calls.append({"channel_id": channel_id, **kwargs}) + return { + "messages": [ + { + "channel_id": channel_id, + "timestamp": self.window_watermark, + "text": "old overlap page", + } + ], + "has_more": False, + "next_cursor": None, + "sync_state": { + "cursor": None, + "watermark": self.window_watermark, + "oldest": kwargs.get("oldest"), + "latest": None, + }, + } + + +def test_watermark_never_regresses_below_checkpoint(monkeypatch): + monkeypatch.setenv("SLACK_ETL_ENABLED", "true") + monkeypatch.delenv("SLACK_BACKFILL_ENABLED", raising=False) + sync = _load_sync() + client = FakeQuietClient( + head_ts="unused", window_watermark="1770000100.000001" + ) + calls = _patch_handler_io( + monkeypatch, + sync, + checkpoint={"watermark_ts": "1775000000.000100", "last_error": ""}, + client=client, + ) + + result = asyncio.run(sync.handler(sync.Input(), FakeContext())) + + assert result["status"] == "completed" + assert len(client.history_calls) == 1 + assert calls["checkpoint_success"] == [ + { + "channel_id": "C123", + "watermark_ts": "1775000000.000100", + "run_id": "slack_sync_wfr_test", + } + ] + + +def test_max_slack_ts_ignores_invalid_and_orders_numerically(): + sync = _load_sync() + assert sync._max_slack_ts(None, "", "not-a-ts") is None + assert ( + sync._max_slack_ts("1770000000.000100", "1770000000.000099", None) + == "1770000000.000100" + ) + # Numeric, not lexicographic: "9.5" > "10" lexicographically but not numerically. + assert sync._max_slack_ts("9.5", "10.0") == "10.0" From c40a59bbd00ca7295ec1ae797787a76c6350041e Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Fri, 3 Jul 2026 22:08:59 -0600 Subject: [PATCH 052/198] fix: gate sandbox observability egress (#898) * fix: gate sandbox observability egress * chore: bump chart version * fix: preserve sandbox api netpol name --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/networkpolicy.yaml | 63 +++++++++++----------- contrib/chart/values.schema.json | 48 +++++++++++++++++ contrib/chart/values.yaml | 25 +++++++++ 4 files changed, 107 insertions(+), 31 deletions(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 7903745af..590860794 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.87 +version: 0.1.88 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/networkpolicy.yaml b/contrib/chart/templates/networkpolicy.yaml index acde52724..4b232995b 100644 --- a/contrib/chart/templates/networkpolicy.yaml +++ b/contrib/chart/templates/networkpolicy.yaml @@ -189,17 +189,6 @@ spec: - podSelector: matchLabels: centaur.ai/api-server-enabled: "true" - # Transitional compatibility: pre-deploy sandbox/proxy pods carry only - # centaur.ai/managed-by=api-rs, so this also covers newly-created - # API-disabled pods while the rollout policy is present. Remove with the - # matching egress compatibility policy below after old api-rs-managed - # pods have aged out. - - podSelector: - matchLabels: - centaur.ai/managed-by: api-rs - matchExpressions: - - key: centaur.ai/api-server-enabled - operator: DoesNotExist {{- range .Values.networkPolicy.apiIngressSourceNamespaces }} - namespaceSelector: matchLabels: @@ -209,24 +198,19 @@ spec: - protocol: TCP port: {{ .Values.apiRs.port }} --- -# Transitional compatibility for pre-deploy sandbox/proxy pods. They carry only -# centaur.ai/managed-by=api-rs, so this also covers newly-created API-disabled -# pods while the rollout policy is present. Remove this with the matching -# api-rs ingress compatibility selector once old api-rs-managed pods have aged -# out. +# Sandboxes with the API server capability may call api-rs. New sandbox and +# proxy pods receive centaur.ai/api-server-enabled=true from api-rs only when +# the principal has the capability, so default-deny blocks new pods without it. apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: - name: {{ include "centaur.fullname" . }}-sandbox-api-server-compat + name: {{ include "centaur.fullname" . }}-sandbox-api-server labels: {{ include "centaur.labels" . | nindent 4 }} spec: podSelector: matchLabels: - centaur.ai/managed-by: api-rs - matchExpressions: - - key: centaur.ai/api-server-enabled - operator: DoesNotExist + centaur.ai/api-server-enabled: "true" policyTypes: - Egress egress: @@ -238,30 +222,49 @@ spec: - protocol: TCP port: {{ .Values.apiRs.port }} --- -# Sandboxes with the API server capability may call api-rs. New sandbox and -# proxy pods receive centaur.ai/api-server-enabled=true from api-rs only when -# the principal has the capability, so default-deny blocks new pods without it. +{{- if .Values.networkPolicy.observabilityEgress.enabled }} +{{- if not .Values.networkPolicy.observabilityEgress.destinations }} +{{- fail "networkPolicy.observabilityEgress.destinations must contain at least one destination when networkPolicy.observabilityEgress.enabled is true" }} +{{- end }} +# Sandboxes with the observability capability may call the configured +# in-cluster observability backends. apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: - name: {{ include "centaur.fullname" . }}-sandbox-api-server + name: {{ include "centaur.fullname" . }}-observability-egress labels: {{ include "centaur.labels" . | nindent 4 }} spec: podSelector: matchLabels: - centaur.ai/api-server-enabled: "true" + centaur.ai/observability-enabled: "true" policyTypes: - Egress egress: +{{- range $index, $destination := .Values.networkPolicy.observabilityEgress.destinations }} +{{- if not $destination.ports }} +{{- fail (printf "networkPolicy.observabilityEgress.destinations[%d].ports must contain at least one port" $index) }} +{{- end }} - to: - - podSelector: + - namespaceSelector: +{{- if $destination.namespaceSelector }} +{{ toYaml $destination.namespaceSelector | nindent 12 }} +{{- else }} matchLabels: -{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "api-rs") | nindent 14 }} + kubernetes.io/metadata.name: {{ required (printf "networkPolicy.observabilityEgress.destinations[%d].namespace is required when namespaceSelector is unset" $index) $destination.namespace | quote }} +{{- end }} +{{- with $destination.podSelector }} + podSelector: +{{ toYaml . | nindent 12 }} +{{- end }} ports: - - protocol: TCP - port: {{ .Values.apiRs.port }} +{{- range $portIndex, $port := $destination.ports }} + - protocol: {{ default "TCP" $port.protocol }} + port: {{ required (printf "networkPolicy.observabilityEgress.destinations[%d].ports[%d].port is required" $index $portIndex) $port.port }} +{{- end }} +{{- end }} --- +{{- end }} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: diff --git a/contrib/chart/values.schema.json b/contrib/chart/values.schema.json index 2799b633c..43078566f 100644 --- a/contrib/chart/values.schema.json +++ b/contrib/chart/values.schema.json @@ -386,6 +386,54 @@ "ingressControllerNamespaces": { "type": "array", "items": { "type": "string" } + }, + "apiServerPort": { "type": "integer" }, + "otlpEgress": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "namespace": { "type": "string" }, + "port": { "type": "integer" } + } + }, + "observabilityEgress": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "destinations": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "properties": { + "namespace": { "type": "string" }, + "namespaceSelector": { "type": "object" }, + "podSelector": { "type": "object" }, + "ports": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "port": { + "oneOf": [ + { "type": "integer" }, + { "type": "string" } + ] + }, + "protocol": { + "type": "string", + "enum": ["TCP", "UDP", "SCTP"] + } + }, + "required": ["port"] + } + } + }, + "required": ["ports"] + } + } + } } } } diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index 5c8becab7..82b86373d 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -566,6 +566,31 @@ networkPolicy: # kubernetes.io/metadata.name of the collector's namespace, e.g. "laminar". namespace: "" port: 8000 + # Egress to observability backends such as VictoriaLogs or VictoriaMetrics. + # This policy selects only pods labeled centaur.ai/observability-enabled=true. + # Sandboxes receive that label only when their principal has the observability + # sandbox capability. + observabilityEgress: + enabled: false + destinations: [] + # Example: + # destinations: + # - namespace: observability + # podSelector: + # matchLabels: + # app.kubernetes.io/instance: vls + # app.kubernetes.io/name: victoria-logs-single + # ports: + # - port: 9428 + # protocol: TCP + # - namespace: observability + # podSelector: + # matchLabels: + # app.kubernetes.io/instance: vms + # app.kubernetes.io/name: victoria-metrics-single + # ports: + # - port: 8428 + # protocol: TCP podSecurityContext: fsGroupChangePolicy: OnRootMismatch From 344a4a7c059d072d2a388c98cc9668e7513553bd Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Sun, 5 Jul 2026 12:25:35 -0600 Subject: [PATCH 053/198] feat: add principal create and delete UI (#896) * feat: allow deleting principals from console * feat: add console principal creation * chore: titlecase principal add button --- .../console/principals_controller.rb | 39 ++++++++- services/console/app/models/principal.rb | 2 + .../app/views/console/principal.html.erb | 13 ++- .../app/views/console/principals.html.erb | 3 +- .../views/console/principals/_errors.html.erb | 10 +++ .../views/console/principals/_form.html.erb | 48 +++++++++++ .../app/views/console/principals/new.html.erb | 8 ++ services/console/config/routes.rb | 5 ++ .../console/principals_controller_test.rb | 85 +++++++++++++++++++ .../controllers/console_controller_test.rb | 16 ++++ 10 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 services/console/app/views/console/principals/_errors.html.erb create mode 100644 services/console/app/views/console/principals/_form.html.erb create mode 100644 services/console/app/views/console/principals/new.html.erb diff --git a/services/console/app/controllers/console/principals_controller.rb b/services/console/app/controllers/console/principals_controller.rb index 84449cefd..29557b113 100644 --- a/services/console/app/controllers/console/principals_controller.rb +++ b/services/console/app/controllers/console/principals_controller.rb @@ -4,12 +4,33 @@ module Console # controller only handles the POST/DELETE actions wired from that page. Gated by # the app-wide require_login (not admin -- mirrors the secret/credential forms). class PrincipalsController < ApplicationController + include KvRowParams include SecretKinds layout "console" before_action :require_admin - before_action :set_principal + before_action :set_principal, except: %i[new create] + + def new + @principal = Principal.new(namespace: "default") + end + + def create + @principal = Principal.new(created_by: current_user) + assign_form(@principal) + if @principal.save + redirect_to console_principal_path(@principal.oid), notice: "Principal created." + else + render :new, status: :unprocessable_entity + end + end + + def destroy + label = principal_label(@principal) + @principal.destroy! + redirect_to console_principals_path, notice: "Deleted principal #{label}." + end def update_sandbox_access @principal.update!( @@ -65,6 +86,18 @@ def revoke_grant private + def assign_form(principal) + fields = principal_params.permit(:namespace, :foreign_id, :name) + fields[:namespace] = fields[:namespace].presence || "default" + fields[:foreign_id] = fields[:foreign_id].presence + principal.assign_attributes(fields) + principal.labels = label_params + end + + def principal_params + params.fetch(:principal, ActionController::Parameters.new) + end + # Parse the ":" value from the grant dropdown into a secret record. # Returns nil for a blank/unknown selection so the action can flash and bail. def resolve_grantable(value) @@ -88,6 +121,10 @@ def secret_label(secret) secret.try(:name).presence || secret.foreign_id.presence || secret.oid end + def principal_label(principal) + principal.name.presence || principal.foreign_id.presence || principal.oid + end + def set_principal @principal = Principal.find_by_oid!(params[:id]) end diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index 2fa980b92..2aba7d7c8 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -12,6 +12,8 @@ class Principal < ApplicationRecord has_many :principal_roles, dependent: :destroy has_many :roles, through: :principal_roles has_many :sync_config_snapshots, class_name: "PrincipalSyncConfigSnapshot", dependent: :destroy + has_many :mcp_oauth_authorization_codes, dependent: :destroy + has_many :mcp_oauth_refresh_tokens, dependent: :destroy belongs_to :created_by, class_name: "User" after_commit :auto_grant_matching_oauth_credentials, on: %i[create update] diff --git a/services/console/app/views/console/principal.html.erb b/services/console/app/views/console/principal.html.erb index bda1d5059..86024c589 100644 --- a/services/console/app/views/console/principal.html.erb +++ b/services/console/app/views/console/principal.html.erb @@ -2,9 +2,16 @@
← back to principals -

- <%= @principal.name.presence || @principal.foreign_id.presence || "Principal" %> -

+
+

+ <%= @principal.name.presence || @principal.foreign_id.presence || "Principal" %> +

+
+ <%= button_to "Delete", console_delete_principal_path(@principal.oid), method: :delete, + class: "cursor-pointer rounded border border-red-500/40 px-3 py-1.5 text-sm text-red-300 transition-colors hover:border-red-500/60 hover:bg-red-500/10", + data: { turbo_confirm: "Delete this principal? Direct grants, role assignments, MCP tokens, and sync snapshots are removed. Proxies are unassigned. This cannot be undone." } %> +
+
<%= @principal.oid %> · diff --git a/services/console/app/views/console/principals.html.erb b/services/console/app/views/console/principals.html.erb index 0dbae961c..39ac0dae6 100644 --- a/services/console/app/views/console/principals.html.erb +++ b/services/console/app/views/console/principals.html.erb @@ -4,7 +4,8 @@ <%= render "console/page_header", title: "Principals", - subtitle: "#{pluralize(@principals.size, "principal")} across all namespaces. Click a row to inspect its grants." %> + subtitle: "#{pluralize(@principals.size, "principal")} across all namespaces. Click a row to inspect its grants.", + actions: link_to("Add Principal", console_new_principal_path, class: "btn-primary") %>
diff --git a/services/console/app/views/console/principals/_errors.html.erb b/services/console/app/views/console/principals/_errors.html.erb new file mode 100644 index 000000000..30c4a5cfb --- /dev/null +++ b/services/console/app/views/console/principals/_errors.html.erb @@ -0,0 +1,10 @@ +<% if principal.errors.any? %> +
+

Principal could not be saved.

+
    + <% principal.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+<% end %> diff --git a/services/console/app/views/console/principals/_form.html.erb b/services/console/app/views/console/principals/_form.html.erb new file mode 100644 index 000000000..dfa82b47b --- /dev/null +++ b/services/console/app/views/console/principals/_form.html.erb @@ -0,0 +1,48 @@ +<%= render "errors", principal: principal %> + +<%= form_with url: console_create_principal_path, method: :post, data: { turbo: false }, class: "space-y-6" do %> +
+

Identity

+
+
+ + <%= text_field_tag "principal[namespace]", principal.namespace.presence || "default", id: "principal_namespace", class: "form-input #{field_error_class(principal, :namespace)}" %> + <%= field_error(principal, :namespace) %> +
+
+ + <%= text_field_tag "principal[foreign_id]", principal.foreign_id, id: "principal_foreign_id", class: "form-input #{field_error_class(principal, :foreign_id)}", placeholder: "slack-channel-T123-C456" %> + <%= field_error(principal, :foreign_id) %> +

Optional. A stable, URL-safe handle for the chat or service identity.

+
+
+ + <%= text_field_tag "principal[name]", principal.name, id: "principal_name", class: "form-input #{field_error_class(principal, :name)}" %> + <%= field_error(principal, :name) %> +
+
+ <%= field_error(principal, :labels) %> +
+ +
+
+

Labels

+ +
+ +
+ <% (principal.labels.presence || {}).each_with_index do |(k, v), i| %> + <%= render "console/base_secrets/label_row", index: i, key: k, value: v %> + <% end %> +
+ + +
+ +
+ <%= submit_tag "Add Principal", class: "btn-primary" %> + Cancel +
+<% end %> diff --git a/services/console/app/views/console/principals/new.html.erb b/services/console/app/views/console/principals/new.html.erb new file mode 100644 index 000000000..c3aece55a --- /dev/null +++ b/services/console/app/views/console/principals/new.html.erb @@ -0,0 +1,8 @@ +<% content_for :title, "New Principal · Centaur Console" %> + +
+ ← back to principals +

New Principal

+
+ +<%= render "form", principal: @principal %> diff --git a/services/console/config/routes.rb b/services/console/config/routes.rb index 4be0a9631..886037642 100644 --- a/services/console/config/routes.rb +++ b/services/console/config/routes.rb @@ -34,6 +34,10 @@ # Operator console (server-rendered HTML UI). root "console#principals" get "console/principals", to: "console#principals", as: :console_principals + namespace :console do + get "principals/new", to: "principals#new", as: :new_principal + post "principals", to: "principals#create", as: :create_principal + end get "console/principals/:id", to: "console#principal", as: :console_principal namespace :console do resources :threads, only: %i[index create] @@ -54,6 +58,7 @@ # extra /roles and /grants path segments keep these clear of the show route above # and avoid clobbering the console_principal_path helper. namespace :console do + delete "principals/:id", to: "principals#destroy", as: :delete_principal patch "principals/:id/sandbox_access", to: "principals#update_sandbox_access", as: :principal_sandbox_access post "principals/:id/roles", to: "principals#assign_role", as: :principal_assign_role delete "principals/:id/roles/:role_id", to: "principals#unassign_role", as: :principal_unassign_role diff --git a/services/console/test/controllers/console/principals_controller_test.rb b/services/console/test/controllers/console/principals_controller_test.rb index b1034524d..eb51f459d 100644 --- a/services/console/test/controllers/console/principals_controller_test.rb +++ b/services/console/test/controllers/console/principals_controller_test.rb @@ -17,6 +17,53 @@ class PrincipalsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to login_path end + test "new renders the create form" do + get console_new_principal_url + assert_response :ok + assert_select "form[action=?][method=?]", console_create_principal_path, "post" do + assert_select "input[name='principal[namespace]'][value=default]" + assert_select "input[name='principal[foreign_id]']" + assert_select "input[name='principal[name]']" + assert_select "button", "Add label" + assert_select "input[type=submit][value='Add Principal']" + end + end + + test "create persists a principal and redirects to its detail page" do + assert_difference -> { Principal.count }, 1 do + post console_create_principal_url, + params: { + principal: { namespace: "acme", foreign_id: "C-new-console", name: "New console principal" }, + labels: { + "0" => { key: "kind", value: "slack_channel" }, + "1" => { key: "team", value: "platform" } + } + } + end + + principal = Principal.find_by!(namespace: "acme", foreign_id: "C-new-console") + assert_redirected_to console_principal_path(principal.oid) + assert_equal "Principal created.", flash[:notice] + assert_equal "New console principal", principal.name + assert_equal({ "kind" => "slack_channel", "team" => "platform" }, principal.labels) + assert_equal @operator, principal.created_by + end + + test "create re-renders validation errors" do + existing = principals(:acme_channel) + + assert_no_difference -> { Principal.count } do + post console_create_principal_url, + params: { + principal: { namespace: existing.namespace, foreign_id: existing.foreign_id, name: "Duplicate" } + } + end + + assert_response :unprocessable_entity + assert_select ".alert-error", text: /Principal could not be saved/ + assert_select ".field-error", text: /has already been taken/ + end + test "update_sandbox_access toggles sandbox capabilities" do principal = principals(:acme_user_bob) @@ -35,6 +82,44 @@ class PrincipalsControllerTest < ActionDispatch::IntegrationTest assert_equal false, principal.sandbox_api_server_enabled end + test "destroy deletes the principal and dependent access records" do + principal = principals(:acme_channel) + proxy = proxies(:acme_proxy) + client = McpOauthClient.create!(redirect_uris: [ "http://localhost/callback" ]) + McpOauthAuthorizationCode.create!( + mcp_oauth_client: client, + user: users(:acme_admin), + principal: principal, + redirect_uri: "http://localhost/callback", + code_challenge: "challenge", + resource: "https://api.example.test", + scopes: %w[mcp:tools] + ) + McpOauthRefreshToken.create!( + mcp_oauth_client: client, + user: users(:acme_admin), + principal: principal, + resource: "https://api.example.test", + scopes: %w[mcp:tools] + ) + + assert_difference -> { Principal.count }, -1 do + assert_difference -> { Grant.where(principal: principal).count }, -3 do + assert_difference -> { PrincipalRole.where(principal: principal).count }, -1 do + assert_difference -> { McpOauthAuthorizationCode.where(principal: principal).count }, -1 do + assert_difference -> { McpOauthRefreshToken.where(principal: principal).count }, -1 do + delete console_delete_principal_url(principal.oid) + end + end + end + end + end + + assert_redirected_to console_principals_path + assert_equal "Deleted principal #{principal.foreign_id}.", flash[:notice] + assert_nil proxy.reload.principal + end + test "assign_role attaches the role and redirects with a notice" do principal = principals(:acme_user_bob) role = roles(:acme_admin_role) diff --git a/services/console/test/controllers/console_controller_test.rb b/services/console/test/controllers/console_controller_test.rb index 8ce7f6301..a3074ba15 100644 --- a/services/console/test/controllers/console_controller_test.rb +++ b/services/console/test/controllers/console_controller_test.rb @@ -143,6 +143,22 @@ class ConsoleControllerTest < ActionDispatch::IntegrationTest assert_select "div", text: /#{Regexp.escape(principal.oid)}.*#{Regexp.escape(principal.namespace)}/ end + test "principals table links to add principal" do + get console_principals_url + assert_response :ok + assert_select "a[href=?]", console_new_principal_path, text: "Add Principal" + end + + test "principal detail page offers delete" do + principal = principals(:acme_channel) + get console_principal_url(principal.oid) + assert_response :ok + assert_select "form[action=?][method=?]", console_delete_principal_path(principal.oid), "post" do + assert_select "input[name=_method][value=delete]" + assert_select "button[type=submit]", "Delete" + end + end + test "credentials table combines id, shows status, and links to detail" do credential = broker_credentials(:acme_managed_gmail) get console_credentials_url From c705d3755c3991d35d46c6b249dfba6d3363f246 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Sun, 5 Jul 2026 12:27:13 -0600 Subject: [PATCH 054/198] fix: handle newline after model override (#900) --- services/linearbot/src/overrides.ts | 18 +++++++++-- services/linearbot/test/overrides.test.ts | 32 +++++++++++++++++++ services/slackbotv2/src/overrides.ts | 23 +++++++++++-- .../slackbotv2/test/chat-sdk-emulate.test.ts | 11 ++++--- services/slackbotv2/test/overrides.test.ts | 32 +++++++++++++++++++ 5 files changed, 106 insertions(+), 10 deletions(-) diff --git a/services/linearbot/src/overrides.ts b/services/linearbot/src/overrides.ts index 01307351d..75469a0e3 100644 --- a/services/linearbot/src/overrides.ts +++ b/services/linearbot/src/overrides.ts @@ -44,7 +44,15 @@ const MODEL_SHORTCUTS: Record = ]), ); -const MODEL_FLAG_PATTERN = /(?:^|\s)--model[=\s]+([A-Za-z0-9._/-]+)(?=\s|$)/i; +// Values are one horizontal-whitespace-delimited token; a newline after the +// value starts the user's prompt, not part of the model value. +const MODEL_VALUE_SEPARATOR = String.raw`(?:[^\S\r\n]*=[^\S\r\n]*|[^\S\r\n]+)`; +const FLAG_VALUE_BOUNDARY = String.raw`(?=[^\S\r\n]|\r?\n|\r||$)`; + +const MODEL_FLAG_PATTERN = new RegExp( + String.raw`(?:^|\s)--model${MODEL_VALUE_SEPARATOR}([A-Za-z0-9._/-]+)${FLAG_VALUE_BOUNDARY}`, + "i", +); export function extractMessageOverrides(text: string): MessageOverrides { let cleaned = text; @@ -88,5 +96,11 @@ function flagPattern(flag: string): RegExp { } function stripMatch(text: string, match: RegExpExecArray): string { - return `${text.slice(0, match.index)}${text.slice(match.index + match[0].length)}`; + const before = text.slice(0, match.index); + const after = text + .slice(match.index + match[0].length) + .replace(/^(?:(?:\r\n?|\n)+|)+/i, ""); + const separator = + before && after && !/\s$/.test(before) && !/^\s/.test(after) ? " " : ""; + return `${before}${separator}${after}`; } diff --git a/services/linearbot/test/overrides.test.ts b/services/linearbot/test/overrides.test.ts index fd83a1161..73e6bd29c 100644 --- a/services/linearbot/test/overrides.test.ts +++ b/services/linearbot/test/overrides.test.ts @@ -90,6 +90,33 @@ describe("extractMessageOverrides", () => { ); }); + test("--model accepts a newline immediately after the value", () => { + expect( + extractMessageOverrides("--claude --model=fable\nwhat model are you"), + ).toEqual({ + cleanedText: "what model are you", + harnessType: "claudecode", + model: "claude-fable-5", + }); + expect( + extractMessageOverrides("@Centaur AI --claude --model=fable\r\nwhat model are you"), + ).toEqual({ + cleanedText: "@Centaur AI what model are you", + harnessType: "claudecode", + model: "claude-fable-5", + }); + }); + + test("--model accepts a rendered line break immediately after the value", () => { + expect( + extractMessageOverrides("--claude --model=fable
what model are you"), + ).toEqual({ + cleanedText: "what model are you", + harnessType: "claudecode", + model: "claude-fable-5", + }); + }); + test("--model passes non-alias values through verbatim", () => { expect( extractMessageOverrides("--codex --model gpt-5.2-codex go").model, @@ -132,5 +159,10 @@ describe("extractMessageOverrides", () => { harnessType: undefined, model: undefined, }); + expect(extractMessageOverrides("--model\nwhat model are you")).toEqual({ + cleanedText: "--model\nwhat model are you", + harnessType: undefined, + model: undefined, + }); }); }); diff --git a/services/slackbotv2/src/overrides.ts b/services/slackbotv2/src/overrides.ts index 4a1c3f542..98ead6191 100644 --- a/services/slackbotv2/src/overrides.ts +++ b/services/slackbotv2/src/overrides.ts @@ -61,11 +61,22 @@ const MODEL_SHORTCUTS: Record = ]) ) -const MODEL_FLAG_PATTERN = /(?:^|\s)--model[=\s]+([A-Za-z0-9._/-]+)(?=\s|$)/i +// Values are one horizontal-whitespace-delimited token; a newline after the +// value starts the user's prompt, not part of the model/reasoning value. +const MODEL_VALUE_SEPARATOR = String.raw`(?:[^\S\r\n]*=[^\S\r\n]*|[^\S\r\n]+)` +const FLAG_VALUE_BOUNDARY = String.raw`(?=[^\S\r\n]|\r?\n|\r||$)` + +const MODEL_FLAG_PATTERN = new RegExp( + String.raw`(?:^|\s)--model${MODEL_VALUE_SEPARATOR}([A-Za-z0-9._/-]+)${FLAG_VALUE_BOUNDARY}`, + 'i' +) // Single dash by design: a short per-turn knob (`-rsn high`), so it can't reuse // the `--`-prefixed flagPattern() helper. Value-capturing like --model. -const REASONING_FLAG_PATTERN = /(?:^|\s)-rsn[=\s]+([A-Za-z-]+)(?=\s|$)/i +const REASONING_FLAG_PATTERN = new RegExp( + String.raw`(?:^|\s)-rsn${MODEL_VALUE_SEPARATOR}([A-Za-z-]+)${FLAG_VALUE_BOUNDARY}`, + 'i' +) // Codex reasoning efforts (turn/start `effort`), plus convenience aliases. const REASONING_EFFORTS: Record = { @@ -142,5 +153,11 @@ function flagPattern(flag: string): RegExp { } function stripMatch(text: string, match: RegExpExecArray): string { - return `${text.slice(0, match.index)}${text.slice(match.index + match[0].length)}` + const before = text.slice(0, match.index) + const after = text + .slice(match.index + match[0].length) + .replace(/^(?:(?:\r\n?|\n)+|)+/i, '') + const separator = + before && after && !/\s$/.test(before) && !/^\s/.test(after) ? ' ' : '' + return `${before}${separator}${after}` } diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index d13636358..c53e503a7 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -352,7 +352,7 @@ describe('slackbotv2', () => { const parent = await postUserMessage('Thread default context.') const firstMention = await postUserMessage( - `<@${BOT_USER_ID}> --claude --model claude-opus-4-8 first pass`, + `<@${BOT_USER_ID}> --claude --model=fable\nfirst pass`, parent.ts ) const firstWaits: Promise[] = [] @@ -367,7 +367,7 @@ describe('slackbotv2', () => { team: TEAM_ID, ts: firstMention.ts, thread_ts: parent.ts, - text: `<@${BOT_USER_ID}> --claude --model claude-opus-4-8 first pass` + text: `<@${BOT_USER_ID}> --claude --model=fable\nfirst pass` } }), {}, @@ -414,10 +414,11 @@ describe('slackbotv2', () => { string, unknown > - expect(firstInput.model).toBe('claude-opus-4-8') - expect(secondInput.model).toBe('claude-opus-4-8') + expect(firstInput.model).toBe('claude-fable-5') + expect(secondInput.model).toBe('claude-fable-5') expect(JSON.stringify(firstInput)).not.toContain('--claude') expect(JSON.stringify(firstInput)).not.toContain('--model') + expect(JSON.stringify(firstInput)).toContain('first pass') expect(JSON.stringify(secondInput)).toContain('continue without flags') const state = await sharedState.get>( @@ -426,7 +427,7 @@ describe('slackbotv2', () => { expect(state).toEqual( expect.objectContaining({ harnessType: 'claudecode', - model: 'claude-opus-4-8' + model: 'claude-fable-5' }) ) }) diff --git a/services/slackbotv2/test/overrides.test.ts b/services/slackbotv2/test/overrides.test.ts index 2ee4042df..34b2ca77c 100644 --- a/services/slackbotv2/test/overrides.test.ts +++ b/services/slackbotv2/test/overrides.test.ts @@ -74,6 +74,32 @@ describe('extractMessageOverrides', () => { expect(extractMessageOverrides('--model fable go').model).toBe('claude-fable-5') }) + test('--model accepts a newline immediately after the value', () => { + expect(extractMessageOverrides('--claude --model=fable\nwhat model are you')).toEqual({ + cleanedText: 'what model are you', + harnessType: 'claudecode', + model: 'claude-fable-5', + reasoning: undefined + }) + expect( + extractMessageOverrides('@Centaur AI --claude --model=fable\r\nwhat model are you') + ).toEqual({ + cleanedText: '@Centaur AI what model are you', + harnessType: 'claudecode', + model: 'claude-fable-5', + reasoning: undefined + }) + }) + + test('--model accepts a rendered line break immediately after the value', () => { + expect(extractMessageOverrides('--claude --model=fable
what model are you')).toEqual({ + cleanedText: 'what model are you', + harnessType: 'claudecode', + model: 'claude-fable-5', + reasoning: undefined + }) + }) + test('--model passes non-alias values through verbatim', () => { expect(extractMessageOverrides('--codex --model gpt-5.2-codex go').model).toBe('gpt-5.2-codex') expect(extractMessageOverrides('--amp --model fast go').model).toBe('fast') @@ -113,6 +139,12 @@ describe('extractMessageOverrides', () => { model: undefined, reasoning: undefined }) + expect(extractMessageOverrides('--model\nwhat model are you')).toEqual({ + cleanedText: '--model\nwhat model are you', + harnessType: undefined, + model: undefined, + reasoning: undefined + }) }) test('parses -rsn with space or equals', () => { From 00371dbe69c6884d008db69cec7a8aaad79e4560 Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Sun, 5 Jul 2026 13:27:54 -0700 Subject: [PATCH 055/198] fix: complete Claude turns on assistant end_turn (#903) fix: complete claude turns on assistant end_turn Co-authored-by: Centaur AI --- crates/harness-server/src/claude.rs | 4 ++++ crates/harness-server/src/server.rs | 23 +++++++++++++++--- .../harness-server/tests/app_server_stdio.rs | 24 +++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/harness-server/src/claude.rs b/crates/harness-server/src/claude.rs index e4a897cc0..0d1d68b53 100644 --- a/crates/harness-server/src/claude.rs +++ b/crates/harness-server/src/claude.rs @@ -277,6 +277,10 @@ impl HarnessServer for ClaudeCodeHarness { ) -> Result> { Ok(normalizer.normalize(event)) } + + fn finish_turn_on_assistant_end_turn(&self) -> bool { + true + } } #[cfg(test)] diff --git a/crates/harness-server/src/server.rs b/crates/harness-server/src/server.rs index 6e41a2903..1d104f3c4 100644 --- a/crates/harness-server/src/server.rs +++ b/crates/harness-server/src/server.rs @@ -1111,6 +1111,8 @@ fn run_harness_turn( let event = harness.parse_stdout_line(trimmed)?; let normalized_events = harness.normalize_events(&mut event_normalizer, event)?; let mut terminal = false; + let mut native_terminal_in_batch = false; + let mut assistant_end_turn_terminal_in_batch = false; for normalized in normalized_events { if let Some(usage) = normalized.token_usage() { latest_usage = Some(usage.clone()); @@ -1123,11 +1125,26 @@ fn run_harness_turn( for notification in normalizer.process_event(&normalized)? { write_value(stdout, ¬ification_to_wire_value(¬ification)?)?; } - terminal |= normalized.is_terminal() - || (harness.finish_turn_on_assistant_end_turn() - && normalized.is_assistant_end_turn()); + let native_terminal = normalized.is_terminal(); + let assistant_end_turn_terminal = + harness.finish_turn_on_assistant_end_turn() && normalized.is_assistant_end_turn(); + native_terminal_in_batch |= native_terminal; + assistant_end_turn_terminal_in_batch |= assistant_end_turn_terminal; + terminal |= native_terminal || assistant_end_turn_terminal; } if terminal { + if assistant_end_turn_terminal_in_batch + && !native_terminal_in_batch + && matches!(harness.kind(), HarnessKind::ClaudeCode) + { + eprintln!( + "event=harness_turn_completed_on_assistant_end_turn harness_kind={:?} thread_id={} turn_id={} session_id={}", + harness.kind(), + state.id, + normalizer.turn_id(), + state.harness_session_id.as_deref().unwrap_or("") + ); + } export_harness_usage_if_available( trace_context, harness.kind(), diff --git a/crates/harness-server/tests/app_server_stdio.rs b/crates/harness-server/tests/app_server_stdio.rs index e6b1217cd..b2facba7c 100644 --- a/crates/harness-server/tests/app_server_stdio.rs +++ b/crates/harness-server/tests/app_server_stdio.rs @@ -106,6 +106,30 @@ fn fake_claude_app_server_streams_codex_v2_notifications() { assert_codex_v2_turn(&run.turn); } +#[test] +fn fake_claude_app_server_completes_on_final_answer_end_turn_without_result() { + let fake_claude = concat!( + "printf '%s\\n' ", + "'{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"claude-session\"}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[]}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"cold answer\"}}}' ", + "'{\"type\":\"assistant\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[{\"type\":\"text\",\"text\":\"cold answer\"}]}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}}'" + ); + + let run = run_bridge_turn(BridgeTurnConfig { + harness: Harness::ClaudeCode, + command_override: Some(fake_claude.to_string()), + prompt: "say hello".to_string(), + timeout: Duration::from_secs(10), + }); + + assert_completed_turn(&run.turn); + assert_eq!(run.turn.text_from_deltas, "cold answer"); + assert_codex_v2_turn(&run.turn); +} + #[test] fn fake_codex_blocks_mode_uses_openrouter_provider_when_model_is_configured() { let fake_codex = temp_path("fake-openrouter-codex.sh"); From f69582e537ee812a618eee23a5566caed7644572 Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Sun, 5 Jul 2026 13:31:59 -0700 Subject: [PATCH 056/198] Trigger image publish for harness-server changes (#904) fix: publish images for harness server changes Co-authored-by: Centaur AI --- .github/workflows/publish-images.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 0a7647732..195d47566 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -7,6 +7,7 @@ on: paths: - .github/workflows/publish-images.yml - services/** + - crates/harness-server/** - centaur_sdk/** - packages/** - tools/** From b4bdff93e11a58e49b285845af86220e5f4d8922 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:14:52 +0300 Subject: [PATCH 057/198] Use nightly for Rust fmt and clippy instructions (#908) --- services/sandbox/Dockerfile | 8 +++++--- services/sandbox/SYSTEM_PROMPT.md | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/services/sandbox/Dockerfile b/services/sandbox/Dockerfile index d28b2f9ad..9cc911b52 100644 --- a/services/sandbox/Dockerfile +++ b/services/sandbox/Dockerfile @@ -149,11 +149,13 @@ ARG BUN_VERSION=bun-v1.3.13 # ── Agent-scoped toolchains ───────────────────────────────────────────────── RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --component rustfmt --component clippy \ + | sh -s -- -y --profile minimal --default-toolchain nightly --component rustfmt --component clippy \ + && rustup toolchain install stable --profile minimal \ && rustc --version \ && cargo --version \ - && rustfmt --version \ - && clippy-driver --version \ + && cargo +stable --version \ + && cargo +nightly fmt --version \ + && cargo +nightly clippy --version \ && rm -rf "$HOME/.rustup/toolchains"/*/share/doc "$HOME/.rustup/toolchains"/*/share/man RUN --mount=type=cache,target=/home/agent/.cargo/registry,uid=1001,gid=1001,sharing=locked \ --mount=type=cache,target=/home/agent/.cargo/git,uid=1001,gid=1001,sharing=locked \ diff --git a/services/sandbox/SYSTEM_PROMPT.md b/services/sandbox/SYSTEM_PROMPT.md index b006a1e75..f17211a85 100644 --- a/services/sandbox/SYSTEM_PROMPT.md +++ b/services/sandbox/SYSTEM_PROMPT.md @@ -70,6 +70,11 @@ |*NEVER run git commit/push inside* ~/github/ — it is read-only. Always use git-branch first. |Prefer `rg` (ripgrep) over `grep` for all codebase operations. +[Rust policy — ALWAYS use nightly for formatting and clippy] +|ALWAYS install both the Rust stable and nightly toolchains when provisioning Rust tooling, with nightly as the default toolchain. +|ALWAYS run Rust formatting and clippy through nightly: use `cargo +nightly fmt ` and `cargo +nightly clippy ` instead of `cargo fmt` or `cargo clippy`. +|For other cargo commands, prefer the repository's pinned/default toolchain unless the repo or user asks for nightly. + [GitHub PR Attribution] |When opening a GitHub PR for a Slack request, attribute the requester in the PR body with one standalone `Prompted by: ...` line. |Use the [Requester Context] block when present: prefer the verified GitHub handle resolved from the requester's Slack profile; if none is configured, use the requester's Slack display name or username. From b08fde846e01c19a1cc3bad2d23b0b9ea74accb8 Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:24:26 -0700 Subject: [PATCH 058/198] feat: interrupt process through slackbot stop (#911) --- crates/harness-server/src/codex.rs | 247 +++++++-- crates/harness-server/src/error.rs | 2 + crates/harness-server/src/server.rs | 253 +++++++-- crates/harness-server/src/traits.rs | 7 + crates/harness-server/src/turn.rs | 17 +- .../harness-server/tests/app_server_stdio.rs | 488 +++++++++++++++++- .../rendering/src/codex-app-server.test.ts | 24 + packages/rendering/src/codex-app-server.ts | 15 +- packages/rendering/src/index.ts | 2 +- .../crates/centaur-api-server/src/routes.rs | 33 +- .../crates/centaur-api-server/src/types.rs | 13 + .../crates/centaur-session-runtime/src/lib.rs | 116 ++++- .../crates/centaur-session-sqlx/src/lib.rs | 38 ++ services/slackbotv2/src/index.ts | 69 ++- services/slackbotv2/src/session-api.ts | 39 +- services/slackbotv2/src/stop-command.ts | 15 + services/slackbotv2/src/types.ts | 7 + .../slackbotv2/test/chat-sdk-emulate.test.ts | 77 +++ services/slackbotv2/test/session-api.test.ts | 69 +++ services/slackbotv2/test/stop-command.test.ts | 39 ++ 20 files changed, 1456 insertions(+), 114 deletions(-) create mode 100644 services/slackbotv2/src/stop-command.ts create mode 100644 services/slackbotv2/test/stop-command.test.ts diff --git a/crates/harness-server/src/codex.rs b/crates/harness-server/src/codex.rs index 4d2cfd95a..9a6c170e7 100644 --- a/crates/harness-server/src/codex.rs +++ b/crates/harness-server/src/codex.rs @@ -1,7 +1,11 @@ use std::env; use std::io::{self, BufRead, Write}; use std::process::{Child, ChildStdin, Command as ProcessCommand, Stdio}; -use std::sync::mpsc::{self, Receiver}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, RecvTimeoutError}, +}; use std::thread; use std::time::Duration; @@ -122,18 +126,66 @@ pub(crate) fn run_codex_blocks_server(config: CodexHarnessServer) -> Result<()> // thread start (the app-server protocol has no per-turn provider), so this // lets a later conflicting override be surfaced rather than silently dropped. let mut thread_provider: Option = None; - let mut blocks_state = BlocksState::default(); - - let stdin = io::stdin(); - for raw in stdin.lock().lines() { - let line = raw?; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } + let (command_tx, command_rx) = mpsc::channel(); + let (active_turn_tx, active_turn_rx) = mpsc::channel(); + let turn_active = Arc::new(AtomicBool::new(false)); + + { + let turn_active = Arc::clone(&turn_active); + thread::spawn(move || { + let stdin = io::stdin(); + let mut blocks_state = BlocksState::default(); + for raw in stdin.lock().lines() { + let Ok(line) = raw else { + break; + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + match parse_blocks_line_with_state(trimmed, &mut blocks_state) { + Ok(BlocksCommand::Interrupt) if turn_active.load(Ordering::SeqCst) => { + if active_turn_tx + .send(CodexActiveTurnRequest::Interrupt) + .is_err() + { + break; + } + } + Ok(command @ BlocksCommand::User { .. }) => { + turn_active.store(true, Ordering::SeqCst); + if command_tx + .send(CodexBlocksReaderInput::Command(command)) + .is_err() + { + break; + } + } + Ok(command) => { + if command_tx + .send(CodexBlocksReaderInput::Command(command)) + .is_err() + { + break; + } + } + Err(error) => { + if command_tx + .send(CodexBlocksReaderInput::Error(error.to_string())) + .is_err() + { + break; + } + } + } + } + }); + } - match parse_blocks_line_with_state(trimmed, &mut blocks_state) { - Ok(BlocksCommand::User { + while let Ok(input) = command_rx.recv() { + match input { + CodexBlocksReaderInput::Command(BlocksCommand::User { input, client_user_message_id, model, @@ -142,51 +194,56 @@ pub(crate) fn run_codex_blocks_server(config: CodexHarnessServer) -> Result<()> trace_context, }) => { let traceparent = trace_context.effective_traceparent(); - if codex.is_none() { - otel::configure_codex_otel_for_startup(&trace_context)?; - let mut child = CodexJsonRpcChild::spawn()?; - initialize_codex( - &mut child, + turn_active.store(true, Ordering::SeqCst); + let result = (|| -> Result<()> { + if codex.is_none() { + otel::configure_codex_otel_for_startup(&trace_context)?; + let mut child = CodexJsonRpcChild::spawn()?; + initialize_codex( + &mut child, + &mut stdout, + &mut request_id, + traceparent.as_deref(), + )?; + codex = Some(child); + } + let model = model.or_else(|| config.default_model()); + let model_provider = + config.model_provider_for(provider.as_deref(), model.as_deref()); + run_codex_user_turn( + codex.as_mut().expect("codex initialized"), &mut stdout, &mut request_id, + &mut thread_id, + &mut thread_provider, + input, + client_user_message_id, + (model, model_provider), + provider, + reasoning, + &active_turn_rx, traceparent.as_deref(), - )?; - codex = Some(child); - } - let model = model.or_else(|| config.default_model()); - let model_provider = - config.model_provider_for(provider.as_deref(), model.as_deref()); - if let Err(error) = run_codex_user_turn( - codex.as_mut().expect("codex initialized"), - &mut stdout, - &mut request_id, - &mut thread_id, - &mut thread_provider, - input, - client_user_message_id, - (model, model_provider), - provider, - reasoning, - traceparent.as_deref(), - ) { + ) + })(); + turn_active.store(false, Ordering::SeqCst); + drain_codex_active_turn_requests(&active_turn_rx); + if let Err(error) = result { let fallback_thread_id = thread_id.as_deref().unwrap_or("codex"); eprintln!("Codex blocks turn failed: {error:#}"); write_blocks_error(&mut stdout, fallback_thread_id, "turn", error.to_string())?; } } - Ok(BlocksCommand::Interrupt) => { - eprintln!( - "Codex blocks interrupt ignored: no active stdin reader while a turn runs" - ); + CodexBlocksReaderInput::Command(BlocksCommand::Interrupt) => { + eprintln!("Codex blocks interrupt ignored: no active turn runs"); } - Ok(BlocksCommand::AttachmentChunk) => {} - Err(error) => { - eprintln!("invalid Codex blocks input: {error:#}"); + CodexBlocksReaderInput::Command(BlocksCommand::AttachmentChunk) => {} + CodexBlocksReaderInput::Error(error) => { + eprintln!("invalid Codex blocks input: {error}"); write_blocks_error( &mut stdout, thread_id.as_deref().unwrap_or("codex"), "input", - error.to_string(), + error, )?; } } @@ -195,6 +252,19 @@ pub(crate) fn run_codex_blocks_server(config: CodexHarnessServer) -> Result<()> Ok(()) } +enum CodexBlocksReaderInput { + Command(BlocksCommand), + Error(String), +} + +enum CodexActiveTurnRequest { + Interrupt, +} + +fn drain_codex_active_turn_requests(rx: &Receiver) { + while rx.try_recv().is_ok() {} +} + fn initialize_codex( codex: &mut CodexJsonRpcChild, stdout: &mut W, @@ -231,6 +301,7 @@ fn run_codex_user_turn( model_and_provider: (Option, String), requested_provider: Option, reasoning: Option, + active_turn_rx: &Receiver, traceparent: Option<&str>, ) -> Result<()> { let (model, model_provider) = model_and_provider; @@ -306,6 +377,9 @@ fn run_codex_user_turn( stdout, thread_id.as_deref().unwrap_or_default(), &turn_id, + active_turn_rx, + request_id, + traceparent, )? { TurnTermination::Done => return Ok(()), TurnTermination::RetriableEngineError { withheld } => { @@ -504,14 +578,42 @@ impl CodexJsonRpcChild { stdout: &mut W, thread_id: &str, turn_id: &str, + active_turn_rx: &Receiver, + request_id: &mut i64, + traceparent: Option<&str>, ) -> Result { let mut guard = TurnGuard::default(); + let mut interrupt_request_id = None; loop { - let value = self.read_value()?; + let value = match self.read_value_timeout(Duration::from_millis(50))? { + Some(value) => value, + None => { + self.forward_pending_interrupt( + active_turn_rx, + &mut interrupt_request_id, + request_id, + thread_id, + turn_id, + traceparent, + )?; + continue; + } + }; if is_server_request(&value) { self.send_error_response(&value)?; continue; } + if let Some(id) = response_id(&value) { + if Some(id) == interrupt_request_id { + if let Some(error) = value.get("error") { + return Err(HarnessServerError::Protocol(format!( + "Codex app-server turn/interrupt request {id} failed: {error}" + ))); + } + continue; + } + continue; + } if notification_method(&value).is_none() { continue; } @@ -532,9 +634,46 @@ impl CodexJsonRpcChild { return Ok(TurnTermination::Done); } } + self.forward_pending_interrupt( + active_turn_rx, + &mut interrupt_request_id, + request_id, + thread_id, + turn_id, + traceparent, + )?; } } + fn forward_pending_interrupt( + &mut self, + active_turn_rx: &Receiver, + interrupt_request_id: &mut Option, + request_id: &mut i64, + thread_id: &str, + turn_id: &str, + traceparent: Option<&str>, + ) -> Result<()> { + while let Ok(CodexActiveTurnRequest::Interrupt) = active_turn_rx.try_recv() { + if interrupt_request_id.is_some() { + eprintln!("Codex blocks interrupt ignored: interrupt already requested"); + continue; + } + let id = next_request_id(request_id); + self.send_request( + id, + "turn/interrupt", + json!({ + "threadId": thread_id, + "turnId": turn_id, + }), + traceparent, + )?; + *interrupt_request_id = Some(id); + } + Ok(()) + } + fn read_value(&mut self) -> Result { loop { let line = match self.stdout.recv() { @@ -551,6 +690,24 @@ impl CodexJsonRpcChild { return Ok(serde_json::from_str(trimmed)?); } } + + fn read_value_timeout(&mut self, timeout: Duration) -> Result> { + loop { + let line = match self.stdout.recv_timeout(timeout) { + Ok(line) => line?, + Err(RecvTimeoutError::Timeout) => return Ok(None), + Err(RecvTimeoutError::Disconnected) => { + let status = self.child.wait()?; + return Err(HarnessServerError::CodexExited { status }); + } + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + return Ok(Some(serde_json::from_str(trimmed)?)); + } + } } impl Drop for CodexJsonRpcChild { diff --git a/crates/harness-server/src/error.rs b/crates/harness-server/src/error.rs index bc1b43be9..b38a53209 100644 --- a/crates/harness-server/src/error.rs +++ b/crates/harness-server/src/error.rs @@ -49,6 +49,8 @@ pub enum HarnessServerError { status: ExitStatus, stderr: String, }, + #[error("{kind:?} turn interrupted")] + TurnInterrupted { kind: HarnessKind }, #[error("failed to spawn {bin} app-server: {source}")] SpawnCodex { bin: String, diff --git a/crates/harness-server/src/server.rs b/crates/harness-server/src/server.rs index 1d104f3c4..9d3f666ef 100644 --- a/crates/harness-server/src/server.rs +++ b/crates/harness-server/src/server.rs @@ -4,7 +4,11 @@ use std::fs::OpenOptions; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, RecvTimeoutError}, +}; use std::time::Duration; use base64::Engine; @@ -75,21 +79,65 @@ pub fn run_validate_jsonrpc() -> Result<()> { } pub(crate) fn run_blocks_app_server(harness: &H) -> Result<()> { - let stdin = io::stdin(); let mut stdout = io::stdout().lock(); let mut state = initial_blocks_thread_state(harness)?; - let mut blocks_state = BlocksState::default(); - let (_request_tx, request_rx) = mpsc::channel(); + let (command_tx, command_rx) = mpsc::channel(); + let (request_tx, request_rx) = mpsc::channel(); + let turn_active = Arc::new(AtomicBool::new(false)); - for raw in stdin.lock().lines() { - let line = raw?; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } + { + let turn_active = Arc::clone(&turn_active); + std::thread::spawn(move || { + let stdin = io::stdin(); + let mut blocks_state = BlocksState::default(); + for raw in stdin.lock().lines() { + let Ok(line) = raw else { + break; + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } - match parse_blocks_line_with_state(trimmed, &mut blocks_state) { - Ok(BlocksCommand::User { + match parse_blocks_line_with_state(trimmed, &mut blocks_state) { + Ok(BlocksCommand::Interrupt) if turn_active.load(Ordering::SeqCst) => { + if request_tx.send(ActiveTurnRequest::BlocksInterrupt).is_err() { + break; + } + } + Ok(command @ BlocksCommand::User { .. }) => { + turn_active.store(true, Ordering::SeqCst); + if command_tx + .send(BlocksReaderInput::Command(command)) + .is_err() + { + break; + } + } + Ok(command) => { + if command_tx + .send(BlocksReaderInput::Command(command)) + .is_err() + { + break; + } + } + Err(error) => { + if command_tx + .send(BlocksReaderInput::Error(error.to_string())) + .is_err() + { + break; + } + } + } + } + }); + } + + while let Ok(input) = command_rx.recv() { + match input { + BlocksReaderInput::Command(BlocksCommand::User { input, client_user_message_id, model, @@ -103,7 +151,7 @@ pub(crate) fn run_blocks_app_server(harness: &H) -> Result<()> if let Some(model) = model { state.model = model; } - if let Err(error) = run_blocks_turn( + let result = run_blocks_turn( harness, &mut state, input, @@ -111,18 +159,21 @@ pub(crate) fn run_blocks_app_server(harness: &H) -> Result<()> &trace_context, &mut stdout, &request_rx, - ) { + &turn_active, + ); + drain_active_turn_requests(&request_rx); + if let Err(error) = result { eprintln!("blocks turn failed: {error:#}"); write_blocks_error(&mut stdout, &state.id, "turn", error.to_string())?; } } - Ok(BlocksCommand::Interrupt) => { - eprintln!("blocks interrupt ignored: no active stdin reader while a turn runs"); + BlocksReaderInput::Command(BlocksCommand::Interrupt) => { + eprintln!("blocks interrupt ignored: no active turn runs"); } - Ok(BlocksCommand::AttachmentChunk) => {} - Err(error) => { - eprintln!("invalid blocks input: {error:#}"); - write_blocks_error(&mut stdout, &state.id, "input", error.to_string())?; + BlocksReaderInput::Command(BlocksCommand::AttachmentChunk) => {} + BlocksReaderInput::Error(error) => { + eprintln!("invalid blocks input: {error}"); + write_blocks_error(&mut stdout, &state.id, "input", error)?; } } } @@ -152,7 +203,10 @@ pub(crate) fn run_app_server(harness: &H) -> Result<()> { let JSONRPCMessage::Request(request) = message else { continue; }; - if request_tx.send(request).is_err() { + if request_tx + .send(ActiveTurnRequest::JsonRpc(request)) + .is_err() + { break; } } @@ -162,9 +216,17 @@ pub(crate) fn run_app_server(harness: &H) -> Result<()> { let mut threads: HashMap = HashMap::new(); while let Ok(request) = request_rx.recv() { - if let Err(error) = handle_request(harness, request, &request_rx, &mut threads, &mut stdout) - { - eprintln!("request failed: {error:#}"); + match request { + ActiveTurnRequest::JsonRpc(request) => { + if let Err(error) = + handle_request(harness, request, &request_rx, &mut threads, &mut stdout) + { + eprintln!("request failed: {error:#}"); + } + } + ActiveTurnRequest::BlocksInterrupt => { + eprintln!("blocks interrupt ignored: no active turn runs"); + } } } @@ -184,11 +246,13 @@ fn run_blocks_turn( client_user_message_id: Option, trace_context: &TraceContext, stdout: &mut W, - request_rx: &Receiver, + request_rx: &Receiver, + turn_active: &AtomicBool, ) -> Result<()> { let turn_id = format!("turn-{}", Uuid::new_v4().simple()); let mut normalizer = normalizer_for(harness, state, &turn_id); - run_normalized_turn( + turn_active.store(true, Ordering::SeqCst); + let result = run_normalized_turn( harness, state, &input, @@ -197,7 +261,23 @@ fn run_blocks_turn( &mut normalizer, stdout, request_rx, - ) + ); + turn_active.store(false, Ordering::SeqCst); + result +} + +enum BlocksReaderInput { + Command(BlocksCommand), + Error(String), +} + +enum ActiveTurnRequest { + JsonRpc(JSONRPCRequest), + BlocksInterrupt, +} + +fn drain_active_turn_requests(rx: &Receiver) { + while rx.try_recv().is_ok() {} } #[derive(Debug)] @@ -707,7 +787,7 @@ fn clean_string(value: Option<&str>) -> Option { fn handle_request( harness: &H, request: JSONRPCRequest, - request_rx: &Receiver, + request_rx: &Receiver, threads: &mut HashMap, stdout: &mut W, ) -> Result<()> { @@ -932,9 +1012,14 @@ fn handle_active_turn_request( harness: &H, process: &mut HarnessChild, normalizer: &mut CodexTurnNormalizer, - request: JSONRPCRequest, + request: ActiveTurnRequest, stdout: &mut W, -) -> Result<()> { +) -> Result { + let ActiveTurnRequest::JsonRpc(request) = request else { + process.kill_and_wait()?; + return Ok(true); + }; + match request.method.as_str() { "turn/steer" => { let params: TurnSteerParams = request_params(request.params)?; @@ -945,7 +1030,7 @@ fn handle_active_turn_request( -32600, format!("unknown threadId {}", params.thread_id), )?; - return Ok(()); + return Ok(false); } if params.expected_turn_id != normalizer.turn_id() { write_error( @@ -958,7 +1043,7 @@ fn handle_active_turn_request( normalizer.turn_id() ), )?; - return Ok(()); + return Ok(false); } process .stdin @@ -978,9 +1063,33 @@ fn handle_active_turn_request( { write_value(stdout, ¬ification_to_wire_value(¬ification)?)?; } - Ok(()) + Ok(false) } "turn/interrupt" => { + let params: TurnInterruptParams = request_params(request.params)?; + if params.thread_id != normalizer.thread_id() { + write_error( + stdout, + request.id, + -32600, + format!("unknown threadId {}", params.thread_id), + )?; + return Ok(false); + } + if params.turn_id != normalizer.turn_id() { + write_error( + stdout, + request.id, + -32600, + format!( + "expected active turn id `{}` but found `{}`", + params.turn_id, + normalizer.turn_id() + ), + )?; + return Ok(false); + } + process.kill_and_wait()?; write_client_response( stdout, ClientResponse::TurnInterrupt { @@ -988,7 +1097,7 @@ fn handle_active_turn_request( response: TurnInterruptResponse {}, }, )?; - Ok(()) + Ok(true) } _ => { write_error( @@ -997,7 +1106,7 @@ fn handle_active_turn_request( -32600, format!("cannot handle {} while a turn is active", request.method), )?; - Ok(()) + Ok(false) } } } @@ -1010,7 +1119,7 @@ fn run_normalized_turn( trace_context: Option<&TraceContext>, normalizer: &mut CodexTurnNormalizer, stdout: &mut W, - request_rx: &Receiver, + request_rx: &Receiver, ) -> Result<()> { for notification in normalizer.start_notifications(!state.thread_started_sent)? { if matches!(notification, ServerNotification::ThreadStarted(_)) { @@ -1033,7 +1142,28 @@ fn run_normalized_turn( ) { Ok(Some(turn)) => state.completed_turns.push(turn), Ok(None) => {} - Err(error) => finish_turn_with_error(state, normalizer, stdout, error)?, + Err(HarnessServerError::TurnInterrupted { .. }) => { + state.process = None; + finish_turn_interrupted(state, normalizer, stdout)?; + } + Err(error) => { + state.process = None; + finish_turn_with_error(state, normalizer, stdout, error)?; + } + } + Ok(()) +} + +fn finish_turn_interrupted( + state: &mut ThreadState, + normalizer: &mut CodexTurnNormalizer, + stdout: &mut W, +) -> Result<()> { + if let Some(notification) = normalizer.finish_turn_interrupted()? { + if let ServerNotification::TurnCompleted(completed) = ¬ification { + state.completed_turns.push(completed.turn.clone()); + } + write_value(stdout, ¬ification_to_wire_value(¬ification)?)?; } Ok(()) } @@ -1067,7 +1197,7 @@ fn run_harness_turn( trace_context: Option<&TraceContext>, normalizer: &mut CodexTurnNormalizer, stdout: &mut W, - request_rx: &Receiver, + request_rx: &Receiver, ) -> Result> { let usage_span_start = otel::unix_time_nanos(); let usage_span_model = state.model.clone(); @@ -1076,12 +1206,14 @@ fn run_harness_turn( let usage_span_input = usage_span_input_value(input); let mut usage_span_output = UsageSpanOutput::default(); ensure_harness_process(harness, state)?; - let process = state - .process - .as_mut() - .ok_or(HarnessServerError::HarnessStdinUnavailable)?; - process.stdin.write_all(&harness.stdin_for_turn(input)?)?; - process.stdin.flush()?; + { + let process = state + .process + .as_mut() + .ok_or(HarnessServerError::HarnessStdinUnavailable)?; + process.stdin.write_all(&harness.stdin_for_turn(input)?)?; + process.stdin.flush()?; + } let mut last_session_id = state.harness_session_id.clone(); let mut event_normalizer = H::EventNormalizer::default(); @@ -1089,14 +1221,34 @@ fn run_harness_turn( let mut latest_usage = None; loop { while let Ok(request) = request_rx.try_recv() { - handle_active_turn_request(harness, process, normalizer, request, stdout)?; + let process = state + .process + .as_mut() + .ok_or(HarnessServerError::HarnessStdinUnavailable)?; + if handle_active_turn_request(harness, process, normalizer, request, stdout)? { + state.process = None; + return Err(HarnessServerError::TurnInterrupted { + kind: harness.kind(), + }); + } } - let line = match process.stdout.recv_timeout(Duration::from_millis(50)) { + let line = match state + .process + .as_mut() + .ok_or(HarnessServerError::HarnessStdoutUnavailable)? + .stdout + .recv_timeout(Duration::from_millis(50)) + { Ok(line) => line?, Err(RecvTimeoutError::Timeout) => continue, Err(RecvTimeoutError::Disconnected) => { - let status = process.child.wait()?; + let status = state + .process + .as_mut() + .ok_or(HarnessServerError::HarnessStdoutUnavailable)? + .child + .wait()?; return Err(HarnessServerError::HarnessExited { kind: harness.kind(), status, @@ -1298,8 +1450,11 @@ fn append_usage_span_output(event: &NormalizedEvent, output: &mut UsageSpanOutpu } fn ensure_harness_process(harness: &H, state: &mut ThreadState) -> Result<()> { - if state.process.is_some() { - return Ok(()); + if let Some(process) = state.process.as_mut() { + if process.child.try_wait()?.is_none() { + return Ok(()); + } + state.process = None; } let mut command = harness.command_for_turn(state); diff --git a/crates/harness-server/src/traits.rs b/crates/harness-server/src/traits.rs index f7c736de9..61f5393df 100644 --- a/crates/harness-server/src/traits.rs +++ b/crates/harness-server/src/traits.rs @@ -41,6 +41,13 @@ impl Drop for HarnessChild { } } +impl HarnessChild { + pub fn kill_and_wait(&mut self) -> io::Result<()> { + let _ = self.child.kill(); + self.child.wait().map(|_| ()) + } +} + pub trait AppServerRuntime { fn run_stdio(&self) -> Result<()>; } diff --git a/crates/harness-server/src/turn.rs b/crates/harness-server/src/turn.rs index d5463934b..75f37b30f 100644 --- a/crates/harness-server/src/turn.rs +++ b/crates/harness-server/src/turn.rs @@ -237,13 +237,28 @@ impl CodexTurnNormalizer { if self.completed { return Ok(None); } - self.completed = true; let error = failed.or_else(|| self.last_error.clone()); let status = if error.is_some() { TurnStatus::Failed } else { TurnStatus::Completed }; + self.finish_turn_with_status(status, error) + } + + pub fn finish_turn_interrupted(&mut self) -> Result> { + self.finish_turn_with_status(TurnStatus::Interrupted, None) + } + + fn finish_turn_with_status( + &mut self, + status: TurnStatus, + error: Option, + ) -> Result> { + if self.completed { + return Ok(None); + } + self.completed = true; let completed_at = now_secs(); Ok(Some(ServerNotification::TurnCompleted( TurnCompletedNotification { diff --git a/crates/harness-server/tests/app_server_stdio.rs b/crates/harness-server/tests/app_server_stdio.rs index b2facba7c..5869c143a 100644 --- a/crates/harness-server/tests/app_server_stdio.rs +++ b/crates/harness-server/tests/app_server_stdio.rs @@ -385,6 +385,35 @@ fn fake_amp_blocks_mode_accepts_user_blocks_by_default() { assert_codex_v2_turn(&run.turn); } +#[test] +fn fake_claude_blocks_mode_interrupts_back_to_back_stop() { + let fake_claude = concat!( + "printf '%s\\n' ", + "'{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"claude-session\"}'; ", + "while IFS= read -r _; do sleep 60; done" + ); + + let mut bridge = BridgeProcess::spawn_harness_blocks( + Harness::ClaudeCode, + Some(fake_claude.to_string()), + None, + ); + let turn = bridge.run_blocks_interrupted_turn("hang until stopped", Duration::from_secs(10)); + bridge.finish_successfully(); + + assert_eq!(turn.terminal_status.as_deref(), Some("interrupted")); + assert!( + turn.methods.contains(&"turn/started".to_string()), + "missing turn/started; got {:?}", + turn.methods + ); + assert!( + turn.methods.contains(&"turn/completed".to_string()), + "missing turn/completed; got {:?}", + turn.methods + ); +} + #[test] fn fake_codex_blocks_mode_spawns_app_server_and_translates_user_blocks() { let fake_codex = temp_path("fake-codex.sh"); @@ -450,6 +479,73 @@ fn fake_codex_blocks_mode_spawns_app_server_and_translates_user_blocks() { let _ = std::fs::remove_file(fake_codex_log); } +#[test] +fn fake_codex_blocks_mode_interrupts_active_turn() { + let fake_codex = temp_path("fake-interruptible-codex.sh"); + let fake_codex_log = temp_path("fake-interruptible-codex-requests.jsonl"); + let script = fake_codex_interruptible_app_server_script(&fake_codex_log); + std::fs::write(&fake_codex, script).expect("write fake codex script"); + let mut permissions = std::fs::metadata(&fake_codex) + .expect("fake codex metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&fake_codex, permissions).expect("chmod fake codex script"); + + let mut bridge = BridgeProcess::spawn_harness_blocks( + Harness::Codex, + None, + Some(( + "CODEX_BIN", + fake_codex.to_str().expect("utf-8 fake codex path"), + )), + ); + let turn = bridge.run_blocks_interrupted_turn("hang until stopped", Duration::from_secs(10)); + let stdout_lines = bridge.finish_successfully(); + + assert_eq!(turn.terminal_status.as_deref(), Some("interrupted")); + assert!( + turn.methods.contains(&"turn/started".to_string()), + "missing turn/started; got {:?}", + turn.methods + ); + assert!( + turn.methods.contains(&"turn/completed".to_string()), + "missing turn/completed; got {:?}", + turn.methods + ); + assert!( + stdout_lines + .iter() + .all(|line| response_id(&serde_json::from_str(line).expect("JSON stdout")).is_none()), + "blocks mode should emit notifications only, not JSON-RPC responses" + ); + + let requests = std::fs::read_to_string(&fake_codex_log).expect("read fake codex request log"); + let requests: Vec = requests + .lines() + .map(|line| serde_json::from_str(line).expect("fake codex request JSON")) + .collect(); + let interrupt = requests + .iter() + .find(|value| value.get("method").and_then(Value::as_str) == Some("turn/interrupt")) + .unwrap_or_else(|| { + panic!("blocks mode did not send turn/interrupt; requests={requests:?}") + }); + assert_eq!( + interrupt + .pointer("/params/threadId") + .and_then(Value::as_str), + Some("thread-1") + ); + assert_eq!( + interrupt.pointer("/params/turnId").and_then(Value::as_str), + Some("turn-1") + ); + + let _ = std::fs::remove_file(fake_codex); + let _ = std::fs::remove_file(fake_codex_log); +} + #[test] fn fake_codex_blocks_mode_forwards_traceparent_to_app_server_requests() { let fake_codex = temp_path("fake-codex-trace.sh"); @@ -641,6 +737,89 @@ fn fake_harness_process_is_started_once_across_two_turns() { let _ = std::fs::remove_file(start_log); } +#[test] +fn turn_interrupt_kills_harness_process_and_finishes_turn() { + let start_log = temp_path("harness-interrupt-starts.log"); + let marker = temp_path("harness-interrupt-marker"); + let command = format!( + "printf 'start\\n' >> {start_log}; \ + trap 'printf \"killed\\n\" >> {start_log}; exit 143' TERM INT; \ + printf '%s\\n' '{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"fake-session\"}}'; \ + while IFS= read -r _; do \ + if [ -f {marker} ]; then \ + printf '%s\\n' '{{\"type\":\"assistant\",\"is_partial\":false,\"message\":{{\"id\":\"msg_1\",\"content\":[{{\"type\":\"text\",\"text\":\"fresh turn\"}}]}}}}'; \ + printf '%s\\n' '{{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"fresh turn\"}}'; \ + else \ + sleep 60; \ + fi; \ + done", + start_log = shell_quote(start_log.as_path()), + marker = shell_quote(marker.as_path()) + ); + let mut bridge = BridgeProcess::spawn_harness(Harness::ClaudeCode, Some(command), None); + let thread_id = + bridge.initialize_and_start_thread(Harness::ClaudeCode, Duration::from_secs(10)); + + let interrupted = bridge.run_interrupted_turn( + &thread_id, + 3, + 4, + "hang until stopped", + Duration::from_secs(10), + ); + assert_eq!(interrupted.terminal_status.as_deref(), Some("interrupted")); + + std::fs::write(&marker, b"fresh turn ready").expect("write fresh-turn marker"); + let fresh = bridge.run_turn( + &thread_id, + 5, + "run after interrupt", + None, + Duration::from_secs(10), + ); + assert_completed_turn(&fresh); + assert_eq!(fresh.text_from_deltas, "fresh turn"); + let _ = bridge.child.kill(); + let _ = bridge.child.wait(); + let _ = std::fs::remove_file(start_log); + let _ = std::fs::remove_file(marker); +} + +#[test] +fn turn_interrupt_rejects_wrong_thread_and_turn_without_killing_process() { + let start_log = temp_path("harness-rejected-interrupt-starts.log"); + let command = format!( + "printf 'start\\n' >> {start_log}; \ + printf '%s\\n' '{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"fake-session\"}}'; \ + while IFS= read -r _; do sleep 60; done", + start_log = shell_quote(start_log.as_path()), + ); + let mut bridge = BridgeProcess::spawn_harness(Harness::ClaudeCode, Some(command), None); + let thread_id = + bridge.initialize_and_start_thread(Harness::ClaudeCode, Duration::from_secs(10)); + + let interrupted = bridge.run_turn_with_rejected_interrupts( + &thread_id, + 3, + 4, + 5, + 6, + "hang until stopped", + Duration::from_secs(10), + ); + assert_eq!(interrupted.terminal_status.as_deref(), Some("interrupted")); + + let starts = std::fs::read_to_string(&start_log).expect("read start log"); + assert_eq!( + starts.lines().count(), + 1, + "rejected interrupts must not kill and restart the harness before the valid interrupt" + ); + let _ = bridge.child.kill(); + let _ = bridge.child.wait(); + let _ = std::fs::remove_file(start_log); +} + #[test] #[ignore = "runs real Claude Code and Codex/Amp-style networked binaries"] fn real_claude_code_long_streaming_is_anchored_to_native_cli() { @@ -1158,6 +1337,198 @@ impl BridgeProcess { capture } + fn run_interrupted_turn( + &mut self, + thread_id: &str, + request_id: i64, + interrupt_request_id: i64, + prompt: &str, + timeout: Duration, + ) -> TurnCapture { + self.send(json!({ + "id": request_id, + "method": "turn/start", + "params": { + "threadId": thread_id, + "input": [{"type": "text", "text": prompt, "text_elements": []}], + }, + })); + + let deadline = Instant::now() + timeout; + let mut capture = TurnCapture::default(); + let mut interrupt_sent = false; + let mut interrupt_acknowledged = false; + + loop { + let value = self.read_json(deadline); + if let Some(id) = response_id(&value) { + if id == request_id { + capture.turn_id = value + .pointer("/result/turn/id") + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("turn/start did not return turn id: {value}")) + .to_string(); + } else if id == interrupt_request_id { + interrupt_acknowledged = true; + } + continue; + } + + if let Some(method) = value.get("method").and_then(Value::as_str) { + assert_notification_thread_id(&value, thread_id); + capture.consume_notification(method, &value); + if method == "turn/started" + && capture.turn_id.is_empty() + && let Some(turn_id) = value.pointer("/params/turn/id").and_then(Value::as_str) + { + capture.turn_id = turn_id.to_string(); + } + if method == "turn/started" && !interrupt_sent { + self.send(json!({ + "id": interrupt_request_id, + "method": "turn/interrupt", + "params": { + "threadId": thread_id, + "turnId": capture.turn_id, + }, + })); + interrupt_sent = true; + } + if method == "turn/completed" { + assert!( + interrupt_acknowledged, + "turn completed before interrupt response" + ); + break; + } + } + } + + capture + } + + fn run_turn_with_rejected_interrupts( + &mut self, + thread_id: &str, + request_id: i64, + wrong_thread_interrupt_request_id: i64, + wrong_turn_interrupt_request_id: i64, + valid_interrupt_request_id: i64, + prompt: &str, + timeout: Duration, + ) -> TurnCapture { + self.send(json!({ + "id": request_id, + "method": "turn/start", + "params": { + "threadId": thread_id, + "input": [{"type": "text", "text": prompt, "text_elements": []}], + }, + })); + + let deadline = Instant::now() + timeout; + let mut capture = TurnCapture::default(); + let mut wrong_thread_interrupt_sent = false; + let mut wrong_thread_interrupt_rejected = false; + let mut wrong_turn_interrupt_sent = false; + let mut wrong_turn_interrupt_rejected = false; + let mut valid_interrupt_sent = false; + let mut valid_interrupt_acknowledged = false; + + loop { + let value = self.read_json_allowing_error(deadline); + if let Some(id) = response_id(&value) { + if id == request_id { + capture.turn_id = value + .pointer("/result/turn/id") + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("turn/start did not return turn id: {value}")) + .to_string(); + } else if id == wrong_thread_interrupt_request_id { + assert!( + value.get("error").is_some(), + "wrong-thread interrupt should be rejected: {value}" + ); + wrong_thread_interrupt_rejected = true; + self.send(json!({ + "id": wrong_turn_interrupt_request_id, + "method": "turn/interrupt", + "params": { + "threadId": thread_id, + "turnId": "wrong-turn", + }, + })); + wrong_turn_interrupt_sent = true; + } else if id == wrong_turn_interrupt_request_id { + assert!( + value.get("error").is_some(), + "wrong-turn interrupt should be rejected: {value}" + ); + wrong_turn_interrupt_rejected = true; + self.send(json!({ + "id": valid_interrupt_request_id, + "method": "turn/interrupt", + "params": { + "threadId": thread_id, + "turnId": capture.turn_id, + }, + })); + valid_interrupt_sent = true; + } else if id == valid_interrupt_request_id { + assert!( + value.get("error").is_none(), + "valid interrupt should be acknowledged: {value}" + ); + valid_interrupt_acknowledged = true; + } + continue; + } + + if let Some(method) = value.get("method").and_then(Value::as_str) { + assert_notification_thread_id(&value, thread_id); + capture.consume_notification(method, &value); + if method == "turn/started" + && capture.turn_id.is_empty() + && let Some(turn_id) = value.pointer("/params/turn/id").and_then(Value::as_str) + { + capture.turn_id = turn_id.to_string(); + } + if method == "turn/started" + && !wrong_thread_interrupt_sent + && !capture.turn_id.is_empty() + { + self.send(json!({ + "id": wrong_thread_interrupt_request_id, + "method": "turn/interrupt", + "params": { + "threadId": "wrong-thread", + "turnId": capture.turn_id, + }, + })); + wrong_thread_interrupt_sent = true; + } + if method == "turn/completed" { + assert!( + wrong_thread_interrupt_rejected, + "turn completed before wrong-thread interrupt rejection" + ); + assert!( + wrong_turn_interrupt_sent && wrong_turn_interrupt_rejected, + "turn completed before wrong-turn interrupt rejection" + ); + assert!(valid_interrupt_sent, "valid interrupt was never sent"); + assert!( + valid_interrupt_acknowledged, + "turn completed before valid interrupt response" + ); + break; + } + } + } + + capture + } + fn run_blocks_user_turn(&mut self, prompt: &str, timeout: Duration) -> TurnCapture { self.run_blocks_user_turn_with_model(prompt, None, timeout) } @@ -1215,6 +1586,54 @@ impl BridgeProcess { capture } + fn run_blocks_interrupted_turn(&mut self, prompt: &str, timeout: Duration) -> TurnCapture { + self.send(json!({ + "type": "user", + "thread_key": "slack:C123:123.456", + "trace_metadata": { + "source": "slackbotv2", + "action": "execute" + }, + "message": { + "role": "user", + "content": [{"type": "text", "text": prompt}], + }, + })); + self.send(json!({ + "type": "interrupt", + "thread_key": "slack:C123:123.456", + "trace_metadata": { + "source": "test", + "action": "interrupt_active_execution" + } + })); + + let deadline = Instant::now() + timeout; + let mut capture = TurnCapture::default(); + + loop { + let value = self.read_json(deadline); + assert!( + response_id(&value).is_none(), + "blocks mode emitted JSON-RPC response: {value}" + ); + if let Some(method) = value.get("method").and_then(Value::as_str) { + capture.consume_notification(method, &value); + if method == "turn/started" + && capture.turn_id.is_empty() + && let Some(turn_id) = value.pointer("/params/turn/id").and_then(Value::as_str) + { + capture.turn_id = turn_id.to_string(); + } + if method == "turn/completed" { + break; + } + } + } + + capture + } + fn send(&mut self, value: Value) { eprintln!("stdin JSON: {value}"); let stdin = self.stdin.as_mut().expect("stdin still open"); @@ -1224,6 +1643,14 @@ impl BridgeProcess { } fn read_json(&mut self, deadline: Instant) -> Value { + self.read_json_checked(deadline, false) + } + + fn read_json_allowing_error(&mut self, deadline: Instant) -> Value { + self.read_json_checked(deadline, true) + } + + fn read_json_checked(&mut self, deadline: Instant, allow_error: bool) -> Value { loop { let now = Instant::now(); assert!(now < deadline, "timed out waiting for app-server stdout"); @@ -1236,7 +1663,7 @@ impl BridgeProcess { self.stdout_lines.push(line.clone()); let value: Value = serde_json::from_str(line.trim()).expect("valid JSON stdout line"); - validate_jsonrpc_value(&value); + validate_jsonrpc_value(&value, allow_error); return value; } Ok(Err(error)) => panic!("read app-server stdout: {error}"), @@ -1545,7 +1972,7 @@ impl RawProcess { } } -fn validate_jsonrpc_value(value: &Value) { +fn validate_jsonrpc_value(value: &Value, allow_error: bool) { let message: JSONRPCMessage = serde_json::from_value(value.clone()).expect("valid JSON-RPC message"); match message { @@ -1560,7 +1987,9 @@ fn validate_jsonrpc_value(value: &Value) { } } JSONRPCMessage::Response(_) => {} - JSONRPCMessage::Error(error) => panic!("app-server returned JSON-RPC error: {error:?}"), + JSONRPCMessage::Error(error) => { + assert!(allow_error, "app-server returned JSON-RPC error: {error:?}"); + } JSONRPCMessage::Request(request) => { panic!("app-server emitted unexpected request: {request:?}") } @@ -1742,6 +2171,59 @@ done script } +fn fake_codex_interruptible_app_server_script(log_path: &Path) -> String { + let mut script = String::new(); + script.push_str("#!/bin/sh\n"); + script.push_str("log="); + script.push_str(&shell_quote(log_path)); + script.push_str( + r#" +touch "$log" +if [ "${1:-}" = "app-server" ] && [ "${2:-}" = "--help" ]; then + printf '%s\n' '--listen stdio://' + exit 0 +fi +if [ "${1:-}" != "app-server" ]; then + printf '%s\n' 'expected app-server command' >&2 + exit 64 +fi + +request_id() { + printf '%s' "$1" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' +} + +while IFS= read -r line; do + printf '%s\n' "$line" >> "$log" + case "$line" in + *'"method":"initialize"'*) + id=$(request_id "$line") + printf '{"id":%s,"result":{"userAgent":"fake-codex"}}\n' "$id" + ;; + *'"method":"thread/start"'*) + id=$(request_id "$line") + printf '{"id":%s,"result":{"thread":{"id":"thread-1"}}}\n' "$id" + ;; + *'"method":"turn/start"'*) + id=$(request_id "$line") + printf '{"id":%s,"result":{"turn":{"id":"turn-1"}}}\n' "$id" + printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"itemsView":"full","status":"inProgress","error":null,"startedAt":1,"completedAt":null,"durationMs":null}}}' + ;; + *'"method":"turn/interrupt"'*) + id=$(request_id "$line") + printf '{"id":%s,"result":{}}\n' "$id" + printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"itemsView":"full","status":"interrupted","error":null,"startedAt":1,"completedAt":2,"durationMs":1}}}' + ;; + *) + printf '%s\n' "unexpected request: $line" >&2 + exit 65 + ;; + esac +done +"#, + ); + script +} + fn temp_path(name: &str) -> PathBuf { std::env::temp_dir().join(format!( "harness-server-{name}-{}-{}", diff --git a/packages/rendering/src/codex-app-server.test.ts b/packages/rendering/src/codex-app-server.test.ts index 3c837005a..7b3b117a3 100644 --- a/packages/rendering/src/codex-app-server.test.ts +++ b/packages/rendering/src/codex-app-server.test.ts @@ -735,6 +735,30 @@ describe('CodexAppServerRendererEventMapper', () => { error: 'sandbox exited' }) }) + + it('emits interrupted final text for cancelled Rust sessions', async () => { + const chunks = await collect( + codexAppServerToChatSdkStream( + toAsyncIterable([ + { + type: 'item.started', + item: { id: 'cmd-1', type: 'commandExecution', command: 'sleep 60' } + }, + { + eventKind: 'session.execution_cancelled', + data: { error: 'Execution interrupted' } + } + ]) + ) + ) + + expect(chunks.filter(chunk => chunk.type === 'markdown_text')).toEqual([ + { + type: 'markdown_text', + text: 'Execution interrupted' + } + ]) + }) }) async function collect(source: AsyncIterable): Promise { diff --git a/packages/rendering/src/codex-app-server.ts b/packages/rendering/src/codex-app-server.ts index 5cd323eae..ef8f9e640 100644 --- a/packages/rendering/src/codex-app-server.ts +++ b/packages/rendering/src/codex-app-server.ts @@ -668,10 +668,17 @@ export function rustSessionEventToServerNotification(source: unknown): RustSessi return { kind: 'failed', error: String(data.error ?? 'Execution failed') } } - if ( - eventKind === 'session.execution_completed' || - eventKind === 'session.execution_cancelled' - ) { + if (eventKind === 'session.execution_cancelled') { + const data = isRecord(source.data) ? source.data : source + const resultText = + terminalResultText(data).trim() || String(data.error ?? 'Execution interrupted').trim() + return { + kind: 'completed', + ...(resultText ? { resultText } : {}) + } + } + + if (eventKind === 'session.execution_completed') { const data = isRecord(source.data) ? source.data : source const resultText = terminalResultText(data).trim() return { diff --git a/packages/rendering/src/index.ts b/packages/rendering/src/index.ts index 1b03f25e7..6301a36a2 100644 --- a/packages/rendering/src/index.ts +++ b/packages/rendering/src/index.ts @@ -5,7 +5,7 @@ export { isTerminalCodexAppServerEvent, rustSessionEventToServerNotification } from './codex-app-server' -export { ChatSDKRenderer } from './chat-sdk' +export { ChatSDKRenderer, EMPTY_FINAL_ANSWER_TEXT } from './chat-sdk' export type { CodexAppServerToChatStreamOptions } from './codex-app-server' export type { RendererInterface, RendererSession } from './interface' export { rendererEventTypes } from './schema' diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index 3898c4e18..389ae3583 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -58,8 +58,9 @@ use crate::{ types::{ AppendMessagesRequest, AppendMessagesResponse, CreateSessionRequest, CreateSessionResponse, EmitWorkflowEventRequest, EventsQuery, ExecuteSessionRequest, ExecuteSessionResponse, - ListWorkflowRunsQuery, OnHarnessConflict, SessionContextResponse, SessionSseEvent, - SlackThreadContext, stream_error_sse, + InterruptSessionExecutionRequest, InterruptSessionExecutionResponse, ListWorkflowRunsQuery, + OnHarnessConflict, SessionContextResponse, SessionSseEvent, SlackThreadContext, + stream_error_sse, }, }; @@ -219,6 +220,10 @@ pub fn build_router_with_app_state(state: AppState) -> Router { "/api/session/{thread_key}/execute", post(execute_session).layer(DefaultBodyLimit::disable()), ) + .route( + "/api/session/{thread_key}/interrupt", + post(interrupt_session_execution), + ) .route("/api/session/{thread_key}/events", get(stream_events)) .route("/api/sandboxes/drain", post(drain_sandboxes)) .route("/api/workflows/schedules", get(list_workflow_schedules)) @@ -514,6 +519,30 @@ async fn execute_session( })) } +async fn interrupt_session_execution( + State(state): State, + Path(raw_thread_key): Path, + Json(request): Json, +) -> Result, ApiError> { + let thread_key = ThreadKey::try_from(raw_thread_key)?; + let reason = request + .reason + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("Interrupted from Slack"); + let outcome = state + .runtime()? + .interrupt_active_execution(&thread_key, reason) + .await?; + Ok(Json(InterruptSessionExecutionResponse { + ok: true, + interrupted: outcome.interrupted, + execution_id: outcome.execution_id, + thread_key, + })) +} + async fn drain_sandboxes(State(state): State) -> Result, ApiError> { let report = state.runtime()?.drain().await?; let failed = report diff --git a/services/api-rs/crates/centaur-api-server/src/types.rs b/services/api-rs/crates/centaur-api-server/src/types.rs index b8030b5aa..57bd00a13 100644 --- a/services/api-rs/crates/centaur-api-server/src/types.rs +++ b/services/api-rs/crates/centaur-api-server/src/types.rs @@ -77,6 +77,19 @@ pub struct ExecuteSessionResponse { pub status: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct InterruptSessionExecutionRequest { + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct InterruptSessionExecutionResponse { + pub ok: bool, + pub interrupted: bool, + pub execution_id: Option, + pub thread_key: ThreadKey, +} + #[derive(Clone, Debug, Deserialize)] pub struct EventsQuery { pub after_event_id: Option, diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 05f856034..2c0abcf52 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -302,6 +302,12 @@ pub struct ExecuteSessionInput { pub max_duration_ms: Option, } +#[derive(Clone, Debug)] +pub struct InterruptExecutionOutcome { + pub interrupted: bool, + pub execution_id: Option, +} + #[derive(Debug)] pub struct ToolHostCallInput { pub principal_id: String, @@ -2080,6 +2086,63 @@ impl SessionRuntime { } } + pub async fn interrupt_active_execution( + &self, + thread_key: &ThreadKey, + reason: &str, + ) -> Result { + let Some(execution) = self.store.active_execution_for_thread(thread_key).await? else { + return Ok(InterruptExecutionOutcome { + interrupted: false, + execution_id: None, + }); + }; + + let execution_span = self + .execution_spans + .lock() + .await + .get(&execution.execution_id) + .cloned(); + let trace = SessionTraceContext::new(thread_key, execution_span.as_ref()); + let input_lines = input_lines_with_session_context( + thread_key, + &trace, + &[interrupt_input_line(thread_key, reason)], + ); + + let pipe = self + .wait_for_active_steering_pipe(thread_key, &execution.execution_id) + .await + .map_err(SessionRuntimeError::BadRequest)?; + write_input_lines( + &pipe, + &input_lines, + thread_key, + &execution.execution_id, + None, + ) + .await?; + + self.store + .append_event( + thread_key, + Some(&execution.execution_id), + "session.interrupt_delivered", + json!({ + "execution_id": execution.execution_id, + "thread_key": thread_key.as_str(), + "reason": reason, + }), + ) + .await?; + + Ok(InterruptExecutionOutcome { + interrupted: true, + execution_id: Some(execution.execution_id), + }) + } + async fn wait_for_active_steering_pipe( &self, thread_key: &ThreadKey, @@ -4893,6 +4956,9 @@ enum TerminalOutput { reason: &'static str, result_text: Option, }, + Cancelled { + reason: &'static str, + }, Failed { error: String, }, @@ -4938,6 +5004,32 @@ async fn record_terminal_output( .await?; (execution, "completed") } + TerminalOutput::Cancelled { reason } => { + let Some(execution) = ctx + .store + .cancel_execution_if_active_and_stdout_owner( + execution_id, + &ctx.stdout_owner_id, + reason, + ) + .await? + else { + return Ok(()); + }; + ctx.store + .append_event( + thread_key, + Some(execution_id), + "session.execution_cancelled", + json!({ + "execution_id": execution_id, + "thread_key": thread_key.as_str(), + "reason": reason, + }), + ) + .await?; + (execution, "cancelled") + } TerminalOutput::Failed { error } => { failure_class = Some(terminal_failure_class(&error)); let Some(execution) = ctx @@ -5506,6 +5598,11 @@ fn completed_turn_terminal_output(value: &Value, prior_final_answer_text: &str) prior_final_answer_text, ) } + Some("interrupted") if prior_final_answer_text.trim().is_empty() => { + TerminalOutput::Cancelled { + reason: "turn_interrupted", + } + } Some(_status) if !prior_final_answer_text.trim().is_empty() => { completed_terminal_output_with_fallback( value, @@ -5922,6 +6019,19 @@ fn steering_input_line( .ok() } +fn interrupt_input_line(thread_key: &ThreadKey, reason: &str) -> String { + serde_json::to_string(&json!({ + "type": "interrupt", + "thread_key": thread_key.as_str(), + "trace_metadata": { + "source": "session.interrupt_active_execution", + "action": "interrupt_active_execution", + "reason": reason, + }, + })) + .expect("interrupt input line serializes") +} + async fn append_output_line( ctx: &RuntimeContext, thread_key: &ThreadKey, @@ -6424,7 +6534,7 @@ mod tests { } #[test] - fn interrupted_turn_completed_without_answer_is_failure() { + fn interrupted_turn_completed_without_answer_is_cancelled() { let event = json!({ "type": "turn.completed", "turn": {"id": "turn-1", "status": "interrupted"}, @@ -6432,8 +6542,8 @@ mod tests { assert_eq!( terminal_output(&event, ""), - Some(TerminalOutput::Failed { - error: "turn completed with status interrupted before final answer".to_owned() + Some(TerminalOutput::Cancelled { + reason: "turn_interrupted" }) ); } diff --git a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs index 98d971627..d71e8d2fc 100644 --- a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs +++ b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs @@ -820,6 +820,44 @@ impl PgSessionStore { row.try_into().map(Some) } + pub async fn cancel_execution_if_active_and_stdout_owner( + &self, + execution_id: &str, + owner_id: &str, + reason: &str, + ) -> Result, SessionStoreError> { + let row = sqlx::query_as::<_, SessionExecutionRow>( + r#" + update session_executions + set status = $2, + error = $3, + completed_at = coalesce(completed_at, now()), + stdout_owner_id = null, + stdout_owner_lease_expires_at = null, + updated_at = now() + where execution_id = $1 + and status in ($4, $5) + and stdout_owner_id = $6 + returning execution_id, idempotency_key, thread_key, status, metadata, error, created_at, updated_at, started_at, completed_at + "#, + ) + .bind(execution_id) + .bind(ExecutionStatus::Cancelled.as_ref()) + .bind(reason) + .bind(ExecutionStatus::Queued.as_ref()) + .bind(ExecutionStatus::Running.as_ref()) + .bind(owner_id) + .fetch_optional(&self.pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + self.set_session_status(&row.thread_key, SessionStatus::Idle) + .await?; + row.try_into().map(Some) + } + pub async fn append_event( &self, thread_key: &ThreadKey, diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index a402c06c6..05d185505 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -18,6 +18,7 @@ import { createPostgresState } from '@chat-adapter/state-pg' import pg from 'pg' import { codexAppServerToChatSdkStream, + EMPTY_FINAL_ANSWER_TEXT, type CodexAppServerToChatStreamOptions, type ChatSDKStreamChunk, type RendererEvent @@ -30,6 +31,7 @@ import { collectInitialContext, forwardToSessionApi, harnessRestartPreamble, + interruptSessionExecution, isRetryableSessionApiError, openSessionEventStream, serializeAttachment, @@ -45,6 +47,7 @@ import { } from './console-session-link' import { extractMessageOverrides } from './overrides' import { isAllowedSlackMessage, isAllowedSlackWebhookBody } from './slack-events' +import { isSlackStopCommand } from './stop-command' import type { ForwardSessionInput, JsonObject, @@ -361,6 +364,9 @@ async function handleSlackMessageHandoff( backgroundWaitUntil(assistantStatus.then(() => undefined).catch(() => undefined)) } try { + if (await handleStopCommand(thread, message, input.options, input.trigger)) { + return + } if (input.subscribe) { await subscribeSlackThreadForHandoff(thread, input.options, trace, input.trigger) } @@ -397,6 +403,40 @@ async function handleSlackMessageHandoff( } } +async function handleStopCommand( + thread: Thread, + message: ChatMessage, + options: SlackbotV2Options, + trigger: string +): Promise { + if (!isSlackStopCommand(message)) return false + const trace = createHandoffTrace(thread, message, 'append') + traceLog(options, 'slackbotv2_stop_command_started', trace, { trigger }) + const latest = (await thread.state) ?? {} + const reason = `Interrupted from Slack by ${slackUserIdForMessage(message) ?? 'unknown user'}` + try { + const response = await interruptSessionExecution(options, thread.id, reason) + await thread.setState({ + activeExecution: false, + lastEventId: latest.lastEventId ?? latest.renderObligation?.afterEventId ?? 0, + renderObligation: null + }) + await setAssistantStatus(thread, '', options, trace) + traceLog(options, 'slackbotv2_stop_command_complete', trace, { + execution_id: response.execution_id, + interrupted: response.interrupted, + trigger + }) + return true + } catch (error) { + traceWarn(options, 'slackbotv2_stop_command_failed', trace, { + error: errorMessage(error), + trigger + }) + throw error + } +} + async function subscribeSlackThreadForHandoff( thread: Thread, options: SlackbotV2Options, @@ -1251,8 +1291,8 @@ async function renderFallbackFinalAnswer( for await (const _chunk of chatStream) { void _chunk } - const text = fallback.text() - if (!text) { + const capturedText = fallback.text() + if (!capturedText && !fallback.isInterrupted()) { outcome = 'empty' traceLog(options, 'slackbotv2_render_fallback_empty', trace, { last_event_id: lastEventId, @@ -1260,6 +1300,7 @@ async function renderFallbackFinalAnswer( }) return null } + const text = fallback.textOrDefault() const fallbackText = truncateSlackText(text, SLACK_FALLBACK_TEXT_MAX_CHARS, 'Slack final answer') if (replacement) { await thread.adapter.editMessage(thread.id, replacement.replaceMessageId, fallbackText) @@ -1983,7 +2024,7 @@ async function renderPlainTextExecutionStream( void _chunk } const text = truncateSlackText( - fallback.text() || 'Execution completed, but no final text was captured.', + fallback.textOrDefault(), SLACK_FALLBACK_TEXT_MAX_CHARS, 'Slack final answer' ) @@ -1999,6 +2040,7 @@ async function renderPlainTextExecutionStream( class SlackRenderFallback { private markdownText = '' private terminalText = '' + private interrupted = false async *collectSource( stream: AsyncIterable @@ -2019,7 +2061,23 @@ class SlackRenderFallback { } text(): string { - return (this.terminalText || this.markdownText).trim() + const terminalText = this.terminalText.trim() + const markdownText = this.markdownText.trim() + if (this.interrupted && !terminalText && markdownText === EMPTY_FINAL_ANSWER_TEXT) return '' + return terminalText || markdownText + } + + textOrDefault(): string { + return ( + this.text() || + (this.interrupted + ? 'Execution interrupted' + : EMPTY_FINAL_ANSWER_TEXT) + ) + } + + isInterrupted(): boolean { + return this.interrupted } private captureTerminalText(event: SlackbotV2RendererSource): void { @@ -2027,6 +2085,9 @@ class SlackRenderFallback { const eventKind = String( 'eventKind' in event ? event.eventKind : 'event' in event ? event.event : '' ) + if (eventKind === 'session.execution_cancelled') { + this.interrupted = true + } if ( eventKind !== 'session.execution_completed' && eventKind !== 'session.execution_cancelled' && diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index 5722d39d4..96064dc3f 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -13,6 +13,7 @@ import type { SlackbotV2ExecuteSessionRequest, SlackbotV2ExecuteSessionResponse, SlackbotV2Fetch, + SlackbotV2InterruptSessionResponse, SlackbotV2Options, SlackbotV2RendererSource, SlackbotV2SessionMessage @@ -557,6 +558,19 @@ export async function openSessionEventStream( return stream } +export async function interruptSessionExecution( + options: SlackbotV2Options, + threadId: string, + reason: string +): Promise { + return recordSessionApiOperation( + 'interrupt_session', + () => postInterruptSessionExecution(options, threadId, reason), + sessionApiTimeoutMs(options), + 'interrupt session' + ) +} + const RESTART_CONTEXT_MAX_CHARS = 24_000 /** @@ -1228,6 +1242,27 @@ async function executeSession( return (await response.json()) as SlackbotV2ExecuteSessionResponse } +async function postInterruptSessionExecution( + options: SlackbotV2Options, + threadId: string, + reason: string +): Promise { + const fetchFn = options.fetch ?? fetch + const response = await fetchWithTimeout( + fetchFn, + apiSessionUrl(options.apiUrl, threadId, 'interrupt'), + { + method: 'POST', + headers: apiHeaders(options), + body: JSON.stringify({ reason }) + }, + sessionApiTimeoutMs(options), + 'interrupt session' + ) + await ensureApiOk(response, 'interrupt session') + return (await response.json()) as SlackbotV2InterruptSessionResponse +} + async function ensureApiOk(response: Response, action: string): Promise { if (response.ok) return let body = '' @@ -1278,7 +1313,7 @@ async function streamSessionNotifications( function apiSessionUrl( apiUrl: string, threadId: string, - suffix?: 'messages' | 'execute' | 'events' + suffix?: 'messages' | 'execute' | 'events' | 'interrupt' ): string { const path = `/api/session/${encodeURIComponent(threadId)}${suffix ? `/${suffix}` : ''}` return new URL(path, ensureTrailingSlash(apiUrl)).toString() @@ -1794,7 +1829,7 @@ async function* parseSessionEventStream( } if (event.event === 'session.execution_cancelled') { yield { - data: { error: sessionErrorMessage(event, 'Execution cancelled') }, + data: { error: sessionErrorMessage(event, 'Execution interrupted') }, event: event.event, eventId: event.id, eventKind: event.event diff --git a/services/slackbotv2/src/stop-command.ts b/services/slackbotv2/src/stop-command.ts new file mode 100644 index 000000000..c781587e0 --- /dev/null +++ b/services/slackbotv2/src/stop-command.ts @@ -0,0 +1,15 @@ +const STOP_COMMAND_PATTERN = new RegExp( + [ + String.raw`(?:^|[^A-Za-z0-9_-])`, + String.raw`(?:stop+|kill(?:ed|ing|s)?|end(?:ed|ing|s)?|cancell?(?:ed|ing|s)?)`, + String.raw`(?=$|[^A-Za-z0-9_-])` + ].join(''), + 'i' +) + +export function isSlackStopCommand(message: { text: string }): boolean { + const text = message.text.trim() + if (!text) return false + const withoutMentions = text.replace(/<@[A-Z0-9]+(?:\|[^>]+)?>/g, ' ').trim() + return STOP_COMMAND_PATTERN.test(withoutMentions) +} diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 46d8a742a..6fb2c64ae 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -91,6 +91,13 @@ export type SlackbotV2ExecuteSessionResponse = { thread_key: string } +export type SlackbotV2InterruptSessionResponse = { + execution_id?: string + interrupted: boolean + ok: boolean + thread_key: string +} + export type SlackbotV2Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise export type SlackbotV2Options = { diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index c53e503a7..230a6e6a2 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -1712,6 +1712,83 @@ describe('slackbotv2', () => { ) }) + it('renders interrupted executions with no final answer as interrupted', async () => { + codexApi.autoRespond = false + + const parent = await postUserMessage('Context before an interrupt.') + const mention = await postUserMessage(`<@${BOT_USER_ID}> run until stopped`, parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-interrupted-empty', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> run until stopped` + } + }), + {}, + waitUntilContext(waits) + ) + + expect(response.status).toBe(200) + await waitFor(() => codexApi.executes.length === 1) + await waitFor(() => codexApi.eventRequests.length === 1) + await waitFor(() => codexApi.streamCount === 1) + + codexApi.emitOutputLine( + threadKey(parent.ts), + JSON.stringify({ + type: 'item.started', + item: { + id: 'cmd-1', + type: 'commandExecution', + command: 'sleep 60', + status: 'inProgress' + } + }) + ) + codexApi.emitOutputLine( + threadKey(parent.ts), + JSON.stringify({ + type: 'item.completed', + item: { + id: 'cmd-1', + type: 'commandExecution', + command: 'sleep 60', + status: 'failed', + aggregatedOutput: '', + exitCode: 130 + } + }) + ) + codexApi.emitSessionEvent(threadKey(parent.ts), 'session.execution_cancelled', { + execution_id: 'exe-interrupted', + status: 'cancelled', + reason: 'turn_interrupted' + }) + + await Promise.all(waits) + const transcripts = slackStreamTranscripts(slackApi.calls) + expect(transcripts).toHaveLength(1) + const markdownChunks = transcripts[0]!.chunks.filter(chunk => chunk.type === 'markdown_text') + expect(markdownChunks).toEqual([ + { + type: 'markdown_text', + text: 'Execution interrupted' + } + ]) + const renderedText = transcripts[0]!.chunks.map(chunkText).filter(Boolean).join('\n') + expect(renderedText).toContain('Command execution') + expect(renderedText.trim().endsWith('Execution interrupted')).toBe(true) + expect(renderedText).not.toContain('Execution completed, but no final text was captured.') + }) + it('renders api-rs completion result text when no final answer delta streamed', async () => { codexApi.autoRespond = false diff --git a/services/slackbotv2/test/session-api.test.ts b/services/slackbotv2/test/session-api.test.ts index 03667133f..60c9efadf 100644 --- a/services/slackbotv2/test/session-api.test.ts +++ b/services/slackbotv2/test/session-api.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_SESSION_IDLE_TIMEOUT_MS, forwardToSessionApi, harnessRestartPreamble, + interruptSessionExecution, openSessionEventStream, serializeAttachment, serializeMessage @@ -83,6 +84,14 @@ function fakeApi(responses: { createSession?: Array<{ body?: unknown; status: nu thread_key: 'slack:C1:1700000000.000100' }) } + if (url.endsWith('/interrupt')) { + return Response.json({ + execution_id: 'exec-1', + interrupted: true, + ok: true, + thread_key: 'slack:C1:1700000000.000100' + }) + } if (!url.endsWith('/messages') && createResponses.length > 0) { const next = createResponses.shift()! return Response.json(next.body ?? { ok: next.status < 400 }, { status: next.status }) @@ -185,6 +194,66 @@ describe('session event streaming', () => { }) expect(seenEventIds).toEqual([1, 2]) }) + + test('uses interrupted wording for cancelled executions without error text', async () => { + const encoded = new TextEncoder().encode( + [ + 'id: 1', + 'event: session.execution_cancelled', + 'data: {"status":"cancelled","reason":"turn_interrupted"}', + '', + ].join('\n') + ) + const fetchFn: SlackbotV2Options['fetch'] = async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoded) + controller.close() + } + }), + { headers: { 'content-type': 'text/event-stream' } } + ) + const seenEventIds: number[] = [] + + const stream = await openSessionEventStream(options(fetchFn), { + afterEventId: 0, + executionId: 'exec-1', + onEventId: eventId => seenEventIds.push(eventId), + threadId: 'slack:C1:1700000000.000100' + }) + const events = [] + for await (const event of stream) events.push(event) + + expect(events).toEqual([ + { + data: { error: 'Execution interrupted' }, + event: 'session.execution_cancelled', + eventId: 1, + eventKind: 'session.execution_cancelled' + } + ]) + expect(seenEventIds).toEqual([1]) + }) +}) + +describe('session interruption', () => { + test('posts interruption reason to the thread interrupt endpoint', async () => { + const { fetchFn, requests } = fakeApi() + + const response = await interruptSessionExecution( + options(fetchFn), + 'slack:C1:1700000000.000100', + 'Interrupted from Slack by U1' + ) + + expect(response.interrupted).toBe(true) + const interrupt = requests.find(request => request.url.endsWith('/interrupt')) + expect(interrupt?.url).toBe( + 'http://api.test/api/session/slack%3AC1%3A1700000000.000100/interrupt' + ) + expect(interrupt?.body).toEqual({ reason: 'Interrupted from Slack by U1' }) + }) }) describe('Slack display text fallback', () => { diff --git a/services/slackbotv2/test/stop-command.test.ts b/services/slackbotv2/test/stop-command.test.ts new file mode 100644 index 000000000..bc6feace5 --- /dev/null +++ b/services/slackbotv2/test/stop-command.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test' +import { isSlackStopCommand } from '../src/stop-command' + +describe('Slack stop command detection', () => { + test('matches mention plus stop keyword', () => { + expect(isSlackStopCommand({ text: '<@UCENTAUR> stop' })).toBe(true) + expect(isSlackStopCommand({ text: 'please <@UCENTAUR> STOP now' })).toBe(true) + expect(isSlackStopCommand({ text: '<@UCENTAUR> Stop' })).toBe(true) + expect(isSlackStopCommand({ text: '<@UCENTAUR> stoppp' })).toBe(true) + }) + + test('matches kill, end, cancel, and common variants', () => { + for (const text of [ + 'kill', + 'kill it', + 'killed', + 'killing', + 'end', + 'end it', + 'ended', + 'ending', + 'cancel', + 'cancels', + 'canceled', + 'cancelled', + 'canceling', + 'cancelling' + ]) { + expect(isSlackStopCommand({ text: `<@UCENTAUR> ${text}` })).toBe(true) + } + }) + + test('does not match unrelated mentions', () => { + expect(isSlackStopCommand({ text: '<@UCENTAUR> status' })).toBe(false) + expect(isSlackStopCommand({ text: '<@UCENTAUR> stopping by to ask' })).toBe(false) + expect(isSlackStopCommand({ text: '<@UCENTAUR> run an end-to-end test' })).toBe(false) + expect(isSlackStopCommand({ text: '<@UCENTAUR> cancellation policy' })).toBe(false) + }) +}) From a0c290478be71634195816c15856ab02fac19d4e Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:13:32 -0700 Subject: [PATCH 059/198] fix(console): correct light table dividers (#914) --- services/console/app/assets/tailwind/application.css | 6 +++++- services/console/app/views/layouts/console.html.erb | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/services/console/app/assets/tailwind/application.css b/services/console/app/assets/tailwind/application.css index 8e7455b9e..ce1059d30 100644 --- a/services/console/app/assets/tailwind/application.css +++ b/services/console/app/assets/tailwind/application.css @@ -81,7 +81,11 @@ } .console-table { - @apply min-w-full divide-y divide-ink-600 text-sm; + @apply min-w-full text-sm; + } + + .console-table > thead { + @apply border-b border-ink-600; } .console-table-head { diff --git a/services/console/app/views/layouts/console.html.erb b/services/console/app/views/layouts/console.html.erb index d144546d1..997e8126f 100644 --- a/services/console/app/views/layouts/console.html.erb +++ b/services/console/app/views/layouts/console.html.erb @@ -1063,8 +1063,9 @@ color: #7a7f86; } - html[data-console-theme="light"] .console-table > :not(:last-child) { - border-color: #e6e5de; + html[data-console-theme="light"] .console-table > thead { + border-block-end-color: #e6e5de; + border-bottom-color: #e6e5de; } html[data-console-theme="light"] .form-input { @@ -1139,6 +1140,7 @@ html[data-console-theme="light"] .divide-ink-600 > :not([hidden]) ~ :not([hidden]), html[data-console-theme="light"] .divide-ink-700 > :not([hidden]) ~ :not([hidden]) { + border-block-color: #deded7; border-color: #deded7; } From ad91d6c04546647aba7c95a07af76cb6b7009f43 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Mon, 6 Jul 2026 14:20:33 -0600 Subject: [PATCH 060/198] fix: tighten slack stop command detection (#915) --- services/slackbotv2/src/stop-command.ts | 12 +++++++++--- services/slackbotv2/test/stop-command.test.ts | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/services/slackbotv2/src/stop-command.ts b/services/slackbotv2/src/stop-command.ts index c781587e0..89e133e17 100644 --- a/services/slackbotv2/src/stop-command.ts +++ b/services/slackbotv2/src/stop-command.ts @@ -1,8 +1,11 @@ const STOP_COMMAND_PATTERN = new RegExp( [ - String.raw`(?:^|[^A-Za-z0-9_-])`, + String.raw`^`, + String.raw`(?:(?:please|pls)\s+)?`, + String.raw`(?:(?:can|could|would|will)\s+you\s+)?`, String.raw`(?:stop+|kill(?:ed|ing|s)?|end(?:ed|ing|s)?|cancell?(?:ed|ing|s)?)`, - String.raw`(?=$|[^A-Za-z0-9_-])` + String.raw`(?:\s+(?:it|this|that|now|please|pls|the\s+(?:run|execution|request|job|thread|turn)))*`, + String.raw`[.!?]*$` ].join(''), 'i' ) @@ -10,6 +13,9 @@ const STOP_COMMAND_PATTERN = new RegExp( export function isSlackStopCommand(message: { text: string }): boolean { const text = message.text.trim() if (!text) return false - const withoutMentions = text.replace(/<@[A-Z0-9]+(?:\|[^>]+)?>/g, ' ').trim() + const withoutMentions = text + .replace(/<@[A-Z0-9]+(?:\|[^>]+)?>/g, ' ') + .replace(/\s+/g, ' ') + .trim() return STOP_COMMAND_PATTERN.test(withoutMentions) } diff --git a/services/slackbotv2/test/stop-command.test.ts b/services/slackbotv2/test/stop-command.test.ts index bc6feace5..813e2a797 100644 --- a/services/slackbotv2/test/stop-command.test.ts +++ b/services/slackbotv2/test/stop-command.test.ts @@ -7,6 +7,7 @@ describe('Slack stop command detection', () => { expect(isSlackStopCommand({ text: 'please <@UCENTAUR> STOP now' })).toBe(true) expect(isSlackStopCommand({ text: '<@UCENTAUR> Stop' })).toBe(true) expect(isSlackStopCommand({ text: '<@UCENTAUR> stoppp' })).toBe(true) + expect(isSlackStopCommand({ text: '<@UCENTAUR> could you stop the execution?' })).toBe(true) }) test('matches kill, end, cancel, and common variants', () => { @@ -35,5 +36,9 @@ describe('Slack stop command detection', () => { expect(isSlackStopCommand({ text: '<@UCENTAUR> stopping by to ask' })).toBe(false) expect(isSlackStopCommand({ text: '<@UCENTAUR> run an end-to-end test' })).toBe(false) expect(isSlackStopCommand({ text: '<@UCENTAUR> cancellation policy' })).toBe(false) + expect(isSlackStopCommand({ text: '<@UCENTAUR> if so, stop.' })).toBe(false) + expect( + isSlackStopCommand({ text: '<@UCENTAUR> please check the service; if it is broken, stop.' }) + ).toBe(false) }) }) From f41b9dfa53cce5e13a0ef0c9d878562a00ac56b5 Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Mon, 6 Jul 2026 13:38:23 -0700 Subject: [PATCH 061/198] fix: include harness-server source in sandbox image cache key (#905) Co-authored-by: Centaur AI --- services/sandbox/Dockerfile | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/services/sandbox/Dockerfile b/services/sandbox/Dockerfile index 9cc911b52..d7053d65f 100644 --- a/services/sandbox/Dockerfile +++ b/services/sandbox/Dockerfile @@ -231,21 +231,18 @@ COPY --link --chmod=755 services/sandbox/centaur_tool_host.py /usr/local/bin/cen COPY --link --chmod=755 services/sandbox/repo_cache_sync.py /usr/local/bin/repo-cache-sync COPY --link --chmod=755 services/sandbox/repo_cache_watch.py /usr/local/bin/repo-cache-watch COPY --link --chmod=755 services/sandbox/entrypoint.sh /entrypoint.sh +COPY --link --chown=1001:1001 crates/harness-server/ /opt/centaur/harness-server-src/ USER agent WORKDIR /home/agent -# Build from a bind mount instead of COPY: COPY --link into /tmp rebuilds the -# parent directory chain in its own layer, clobbering /tmp's root:root 1777 -# sticky metadata with agent:agent 0755, which broke /tmp writes for any -# non-1001 user (e.g. the root-with-dropped-caps repo-cache). A bind mount -# leaves no trace in the image and caches on source content just like COPY. -RUN --mount=type=bind,source=crates/harness-server,target=/harness-server-src \ - --mount=type=cache,target=/home/agent/.cargo/registry,uid=1001,gid=1001,sharing=locked \ +# Keep harness-server source as a real image input so crate changes invalidate +# the install layer. Do not COPY into /tmp; that can clobber /tmp's sticky bit. +RUN --mount=type=cache,target=/home/agent/.cargo/registry,uid=1001,gid=1001,sharing=locked \ --mount=type=cache,target=/home/agent/.cargo/git,uid=1001,gid=1001,sharing=locked \ --mount=type=cache,target=/home/agent/.cache/harness-server-target,uid=1001,gid=1001,sharing=locked \ CARGO_TARGET_DIR=/home/agent/.cache/harness-server-target \ - cargo install --locked --path /harness-server-src --root /home/agent/.local + cargo install --locked --path /opt/centaur/harness-server-src --root /home/agent/.local # No RUN instructions after these — all repo-local content at the very end. COPY --link --chown=1001:1001 .agents/skills/ /home/agent/.agents/skills/ From d6d96fbc20ae1917b3d444964dce3578f138cb33 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:53:57 +0300 Subject: [PATCH 062/198] feat(slackbotv2): gauge open session event stream connections (#916) Amp-Thread-ID: https://ampcode.com/threads/T-019f3913-b24f-72d7-b4a1-2efb316d97d3 Co-authored-by: Amp --- services/slackbotv2/src/metrics.ts | 12 +++++++++++ services/slackbotv2/src/session-api.ts | 29 ++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/services/slackbotv2/src/metrics.ts b/services/slackbotv2/src/metrics.ts index 56952c51b..6785316b6 100644 --- a/services/slackbotv2/src/metrics.ts +++ b/services/slackbotv2/src/metrics.ts @@ -321,6 +321,18 @@ export const slackbotMetrics = { labelNames: ['operation', 'outcome'], name: 'slackbotv2_session_api_operations_total' }), + sessionEventStreamClosures: counter({ + help: 'Session API /events stream network connections released, by reason.', + labelNames: ['reason'], + name: 'slackbotv2_session_event_stream_closures_total' + }), + sessionEventStreamsOpen: gauge({ + help: + 'Session API /events SSE connections Slackbot currently holds open. Each one occupies a ' + + 'slot in Bun\'s global fetch pool (BUN_CONFIG_MAX_HTTP_REQUESTS, default 256); at the cap ' + + 'every outbound HTTP request from this process queues forever.', + name: 'slackbotv2_session_event_streams_open' + }), webhookDuration: histogram({ help: 'Slack webhook request handling duration, in seconds.', labelNames: ['route', 'event_type', 'outcome'], diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index 96064dc3f..7eb263d9a 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -1850,6 +1850,21 @@ async function* parseSessionEventStream( async function* parseSseEvents(stream: ReadableStream): AsyncIterable { const reader = stream.getReader() + // Tracks the underlying network connection, NOT this generator's lifetime. + // Nothing cancels `reader` when a consumer abandons this generator early + // (e.g. parseSessionEventStream returning on a terminal event), so the + // connection stays open and keeps holding a slot in Bun's global fetch pool + // (BUN_CONFIG_MAX_HTTP_REQUESTS, default 256). A generator-finally here + // would hide exactly that leak, so the gauge is only decremented when the + // socket actually settles: server EOF or a read error. + slackbotMetrics.sessionEventStreamsOpen.inc() + let connectionReleased = false + const releaseConnection = (reason: 'done' | 'error') => { + if (connectionReleased) return + connectionReleased = true + slackbotMetrics.sessionEventStreamsOpen.dec() + slackbotMetrics.sessionEventStreamClosures.inc({ reason }) + } const decoder = new TextDecoder() let buffer = '' let eventName: string | undefined @@ -1857,8 +1872,18 @@ async function* parseSseEvents(stream: ReadableStream): AsyncIterabl let data: string[] = [] while (true) { - const { done, value } = await reader.read() - if (done) break + let done: boolean + let value: Uint8Array | undefined + try { + ;({ done, value } = await reader.read()) + } catch (error) { + releaseConnection('error') + throw error + } + if (done) { + releaseConnection('done') + break + } buffer += decoder.decode(value, { stream: true }) const lines = buffer.split(/\r?\n/) buffer = lines.pop() ?? '' From 885a62810611543aa4ce1ab03e0de698f4303fbf Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Mon, 6 Jul 2026 14:03:08 -0700 Subject: [PATCH 063/198] Revert Claude end-turn completion fallback (#918) Revert "fix: complete claude turns on assistant end_turn" This reverts commit 791bd244a0d6d692719970b64e9386c07f374108. Co-authored-by: Centaur AI --- crates/harness-server/src/claude.rs | 4 ---- crates/harness-server/src/server.rs | 23 +++--------------- .../harness-server/tests/app_server_stdio.rs | 24 ------------------- 3 files changed, 3 insertions(+), 48 deletions(-) diff --git a/crates/harness-server/src/claude.rs b/crates/harness-server/src/claude.rs index 0d1d68b53..e4a897cc0 100644 --- a/crates/harness-server/src/claude.rs +++ b/crates/harness-server/src/claude.rs @@ -277,10 +277,6 @@ impl HarnessServer for ClaudeCodeHarness { ) -> Result> { Ok(normalizer.normalize(event)) } - - fn finish_turn_on_assistant_end_turn(&self) -> bool { - true - } } #[cfg(test)] diff --git a/crates/harness-server/src/server.rs b/crates/harness-server/src/server.rs index 9d3f666ef..4f3761bde 100644 --- a/crates/harness-server/src/server.rs +++ b/crates/harness-server/src/server.rs @@ -1263,8 +1263,6 @@ fn run_harness_turn( let event = harness.parse_stdout_line(trimmed)?; let normalized_events = harness.normalize_events(&mut event_normalizer, event)?; let mut terminal = false; - let mut native_terminal_in_batch = false; - let mut assistant_end_turn_terminal_in_batch = false; for normalized in normalized_events { if let Some(usage) = normalized.token_usage() { latest_usage = Some(usage.clone()); @@ -1277,26 +1275,11 @@ fn run_harness_turn( for notification in normalizer.process_event(&normalized)? { write_value(stdout, ¬ification_to_wire_value(¬ification)?)?; } - let native_terminal = normalized.is_terminal(); - let assistant_end_turn_terminal = - harness.finish_turn_on_assistant_end_turn() && normalized.is_assistant_end_turn(); - native_terminal_in_batch |= native_terminal; - assistant_end_turn_terminal_in_batch |= assistant_end_turn_terminal; - terminal |= native_terminal || assistant_end_turn_terminal; + terminal |= normalized.is_terminal() + || (harness.finish_turn_on_assistant_end_turn() + && normalized.is_assistant_end_turn()); } if terminal { - if assistant_end_turn_terminal_in_batch - && !native_terminal_in_batch - && matches!(harness.kind(), HarnessKind::ClaudeCode) - { - eprintln!( - "event=harness_turn_completed_on_assistant_end_turn harness_kind={:?} thread_id={} turn_id={} session_id={}", - harness.kind(), - state.id, - normalizer.turn_id(), - state.harness_session_id.as_deref().unwrap_or("") - ); - } export_harness_usage_if_available( trace_context, harness.kind(), diff --git a/crates/harness-server/tests/app_server_stdio.rs b/crates/harness-server/tests/app_server_stdio.rs index 5869c143a..692ccba29 100644 --- a/crates/harness-server/tests/app_server_stdio.rs +++ b/crates/harness-server/tests/app_server_stdio.rs @@ -106,30 +106,6 @@ fn fake_claude_app_server_streams_codex_v2_notifications() { assert_codex_v2_turn(&run.turn); } -#[test] -fn fake_claude_app_server_completes_on_final_answer_end_turn_without_result() { - let fake_claude = concat!( - "printf '%s\\n' ", - "'{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"claude-session\"}' ", - "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[]}}}' ", - "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}' ", - "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"cold answer\"}}}' ", - "'{\"type\":\"assistant\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[{\"type\":\"text\",\"text\":\"cold answer\"}]}}' ", - "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}}'" - ); - - let run = run_bridge_turn(BridgeTurnConfig { - harness: Harness::ClaudeCode, - command_override: Some(fake_claude.to_string()), - prompt: "say hello".to_string(), - timeout: Duration::from_secs(10), - }); - - assert_completed_turn(&run.turn); - assert_eq!(run.turn.text_from_deltas, "cold answer"); - assert_codex_v2_turn(&run.turn); -} - #[test] fn fake_codex_blocks_mode_uses_openrouter_provider_when_model_is_configured() { let fake_codex = temp_path("fake-openrouter-codex.sh"); From 56cd426f833d3f99e1f637ec6d1592b54acf70a5 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:59:10 +0300 Subject: [PATCH 064/198] fix: release session event stream connections after terminal events (#920) --- .../crates/centaur-session-runtime/src/lib.rs | 101 +++++++++++++++++ services/slackbotv2/src/session-api.ts | 81 +++++++------ .../slackbotv2/test/chat-sdk-emulate.test.ts | 107 ++++++++++++++++++ 3 files changed, 252 insertions(+), 37 deletions(-) diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 2c0abcf52..feaa23b4c 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -3695,6 +3695,20 @@ fn session_event_stream( if let Some(event) = state.pending.pop_front() { state.after_event_id = event.event_id; state.emitted_count += 1; + // Execution-scoped streams are per-turn: after the + // execution's terminal event nothing else will ever + // arrive, so complete the response instead of parking + // forever. Abandoned client connections otherwise pin + // this stream's dedicated LISTEN connection until the + // TCP peer is proven dead (the 2026-07-06 incident + // exhausted both the Slackbot fetch pool and staging + // Postgres this way). The 30s safety tick makes this + // robust even when the notify is missed. + if state.execution_id.is_some() + && is_terminal_execution_event(&event.event_type) + { + state.done = true; + } return Some((Ok(event), state)); } if state.done { @@ -3750,6 +3764,15 @@ fn session_event_stream( ) } +/// Terminal event types for a single execution: once one of these is emitted +/// on an execution-scoped stream, the stream has nothing left to deliver. +fn is_terminal_execution_event(event_type: &str) -> bool { + matches!( + event_type, + "session.execution_completed" | "session.execution_failed" | "session.execution_cancelled" + ) +} + /// How a stdout pump pass ended once the attach stream closed. enum StdoutPumpEnd { /// The stream closed with no execution in flight, or the execution was @@ -7752,6 +7775,84 @@ mod adoption_tests { ) } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn execution_scoped_event_stream_completes_after_terminal_event() { + let Some(store) = test_store().await else { + return; + }; + let _serial = TEST_LOCK.lock().await; + let thread_key = + ThreadKey::parse(format!("test:stream-close-{}", uuid::Uuid::new_v4())).unwrap(); + let execution_id = orphaned_execution(&store, &thread_key, None, false).await; + store + .append_event( + &thread_key, + Some(&execution_id), + "session.output.line", + json!({ "line": "working" }), + ) + .await + .expect("append output event"); + store + .append_event( + &thread_key, + Some(&execution_id), + "session.execution_completed", + json!({ "execution_id": execution_id }), + ) + .await + .expect("append terminal event"); + + // Execution-scoped: the stream must end on its own after emitting the + // terminal event, releasing the response and its listener connection. + let listener = store.listen_session_events().await.expect("listener"); + let scoped = session_event_stream( + store.clone(), + thread_key.clone(), + 0, + Some(execution_id.clone()), + listener, + tracing::Span::none(), + ); + let emitted = tokio::time::timeout(Duration::from_secs(10), scoped.collect::>()) + .await + .expect("execution-scoped stream should complete after the terminal event"); + let kinds: Vec<_> = emitted + .into_iter() + .map(|result| result.expect("stream event").event_type) + .collect(); + assert_eq!( + kinds, + vec!["session.output.line", "session.execution_completed"] + ); + + // Control: an unscoped stream over the same events stays open for + // future events instead of completing. + let listener = store.listen_session_events().await.expect("listener"); + let unscoped = session_event_stream( + store.clone(), + thread_key.clone(), + 0, + None, + listener, + tracing::Span::none(), + ); + let mut unscoped = std::pin::pin!(unscoped); + for _ in 0..2 { + unscoped + .next() + .await + .expect("buffered event") + .expect("stream event"); + } + assert!( + tokio::time::timeout(Duration::from_millis(300), unscoped.next()) + .await + .is_err(), + "unscoped stream should stay open after a terminal event" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn append_messages_generates_missing_session_title_once() { let Some(store) = test_store().await else { diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index 7eb263d9a..4fede84fb 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -1850,16 +1850,13 @@ async function* parseSessionEventStream( async function* parseSseEvents(stream: ReadableStream): AsyncIterable { const reader = stream.getReader() - // Tracks the underlying network connection, NOT this generator's lifetime. - // Nothing cancels `reader` when a consumer abandons this generator early - // (e.g. parseSessionEventStream returning on a terminal event), so the - // connection stays open and keeps holding a slot in Bun's global fetch pool - // (BUN_CONFIG_MAX_HTTP_REQUESTS, default 256). A generator-finally here - // would hide exactly that leak, so the gauge is only decremented when the - // socket actually settles: server EOF or a read error. + // Tracks the underlying network connection. Each open stream occupies one + // slot of Bun's global fetch pool (BUN_CONFIG_MAX_HTTP_REQUESTS, default + // 256); the 2026-07-06 incident wedged Slackbot by leaking one abandoned + // stream per completed turn until the pool was exhausted. slackbotMetrics.sessionEventStreamsOpen.inc() let connectionReleased = false - const releaseConnection = (reason: 'done' | 'error') => { + const releaseConnection = (reason: 'cancelled' | 'done' | 'error') => { if (connectionReleased) return connectionReleased = true slackbotMetrics.sessionEventStreamsOpen.dec() @@ -1871,42 +1868,52 @@ async function* parseSseEvents(stream: ReadableStream): AsyncIterabl let eventId: number | undefined let data: string[] = [] - while (true) { - let done: boolean - let value: Uint8Array | undefined - try { - ;({ done, value } = await reader.read()) - } catch (error) { - releaseConnection('error') - throw error - } - if (done) { - releaseConnection('done') - break + try { + while (true) { + let done: boolean + let value: Uint8Array | undefined + try { + ;({ done, value } = await reader.read()) + } catch (error) { + releaseConnection('error') + throw error + } + if (done) { + releaseConnection('done') + break + } + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() ?? '' + + for (const line of lines) { + const emitted = parseSseLine(line, { data, eventId, eventName }) + data = emitted.state.data + eventId = emitted.state.eventId + eventName = emitted.state.eventName + if (emitted.event) yield emitted.event + } } - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split(/\r?\n/) - buffer = lines.pop() ?? '' - for (const line of lines) { - const emitted = parseSseLine(line, { data, eventId, eventName }) + buffer += decoder.decode() + if (buffer) { + const emitted = parseSseLine(buffer, { data, eventId, eventName }) data = emitted.state.data eventId = emitted.state.eventId eventName = emitted.state.eventName if (emitted.event) yield emitted.event } - } - - buffer += decoder.decode() - if (buffer) { - const emitted = parseSseLine(buffer, { data, eventId, eventName }) - data = emitted.state.data - eventId = emitted.state.eventId - eventName = emitted.state.eventName - if (emitted.event) yield emitted.event - } - if (data.length > 0) { - yield { data: data.join('\n'), event: eventName, id: eventId } + if (data.length > 0) { + yield { data: data.join('\n'), event: eventName, id: eventId } + } + } finally { + // Runs when a consumer abandons this generator early — typically + // parseSessionEventStream returning on a terminal event. Cancelling the + // reader closes the connection, freeing its fetch-pool slot and letting + // api-rs drop the stream's server-side resources. Without this, every + // completed turn leaked one connection until all outbound HTTP wedged. + releaseConnection('cancelled') + await reader.cancel().catch(() => {}) } } diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 230a6e6a2..fcd867442 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -22,6 +22,7 @@ import { type SlackbotV2SessionMessage } from '../src/index' import { clearRequesterIdentityCacheForTests } from '../src/session-api' +import { slackbotMetrics } from '../src/metrics' import claudeSettings from '../../../harness/claude/settings.json' const BOT_TOKEN = 'xoxb-slackbotv2-emulate' @@ -5666,3 +5667,109 @@ async function isPortOpen(port: number): Promise { }) }) } + +// Regression coverage for the 2026-07-06 incident: every execute turn opens a +// GET /api/session/{key}/events SSE stream; abandoning it after the terminal +// event without cancelling the reader leaked one connection per turn. At +// Bun's global fetch cap (BUN_CONFIG_MAX_HTTP_REQUESTS, default 256) every +// outbound fetch queued forever and all handoffs failed. parseSseEvents now +// cancels the reader when the consumer stops, so connections are released. +describe('session event stream connection lifecycle', () => { + function openEventStreamGauge(): number { + const match = /^slackbotv2_session_event_streams_open (\d+)$/m.exec(slackbotMetrics.expose()) + return match?.[1] ? Number.parseInt(match[1], 10) : 0 + } + + function cancelledClosures(): number { + const match = /^slackbotv2_session_event_stream_closures_total\{reason="cancelled"\} (\d+)$/m + .exec(slackbotMetrics.expose()) + return match?.[1] ? Number.parseInt(match[1], 10) : 0 + } + + async function waitForGaugeAtMost(limit: number): Promise { + const deadline = Date.now() + 5_000 + let open = openEventStreamGauge() + while (open > limit && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 10)) + open = openEventStreamGauge() + } + return open + } + + async function deliverMention(bot: SlackbotV2, turn: number, threadTs: string, ts: string) { + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: `Ev-stream-lifecycle-${threadTs}-${turn}`, + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts, + thread_ts: threadTs, + text: `<@${BOT_USER_ID}> stream lifecycle turn ${turn}` + } + }), + {}, + waitUntilContext(waits) + ) + await Promise.all(waits).catch(() => {}) + return response + } + + it('cancels the events connection once a turn reaches its terminal event', async () => { + const gaugeBefore = openEventStreamGauge() + const closuresBefore = cancelledClosures() + const parent = await postUserMessage('Stream lifecycle thread.') + const mention = await postUserMessage(`<@${BOT_USER_ID}> stream lifecycle turn 0`, parent.ts) + + const response = await deliverMention(bot, 0, parent.ts, mention.ts) + expect(response.status).toBe(200) + + const open = await waitForGaugeAtMost(gaugeBefore) + expect(open).toBeLessThanOrEqual(gaugeBefore) + expect(cancelledClosures()).toBeGreaterThan(closuresBefore) + }) + + // Drives past Bun's 256-request cap to prove the wedge is gone. `bun test` + // ignores the BUN_CONFIG_MAX_HTTP_REQUESTS override but still enforces the + // built-in 256 cap, so crossing 260 turns exercises the real limit. Takes + // ~30s, so it is opt-in: + // + // SLACKBOTV2_POOL_WEDGE_REPRO=1 bun test test/chat-sdk-emulate.test.ts -t 'pool cap' + const runCapCrossing = process.env.SLACKBOTV2_POOL_WEDGE_REPRO === '1' ? it : it.skip + runCapCrossing( + 'keeps handing off past the 256-connection pool cap without wedging', + async () => { + const poolCap = 256 + const gaugeBefore = openEventStreamGauge() + const repro = createTestBot({ + sessionApiTimeoutMs: 5_000, + slackApiTimeoutMs: 2_000 + }) + + let peak = 0 + for (let turn = 0; turn < poolCap + 4; turn++) { + // One thread per turn, like production traffic: reusing a single + // thread makes per-turn context collection grow quadratically. + const parent = await postUserMessage(`Pool cap crossing thread ${turn + 1}.`) + const mention = await postUserMessage( + `<@${BOT_USER_ID}> stream lifecycle turn ${turn + 1}`, + parent.ts + ) + const response = await deliverMention(repro, turn + 1, parent.ts, mention.ts) + expect(response.status).toBe(200) + const open = await waitForGaugeAtMost(gaugeBefore) + peak = Math.max(peak, open) + if ((turn + 1) % 64 === 0 || turn >= poolCap) { + console.log(`turn ${turn + 1}/${poolCap + 4}: ok, open streams settled at ${open}`) + } + expect(open).toBeLessThanOrEqual(gaugeBefore) + } + console.log(`peak settled gauge: ${peak}; cancelled closures: ${cancelledClosures()}`) + }, + 480_000 + ) +}) From e247cf8bde7fae270f826fa90be78690cb81385b Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Mon, 6 Jul 2026 20:31:03 -0600 Subject: [PATCH 065/198] fix: complete harness turns on terminal assistant stops (#921) * fix: complete harness turns on terminal assistant stops * fix: settle claude terminal stops and unblock turn completion Three interacting fixes for claude/fable turns hanging as "thinking": - Copy harness child stderr through the unlocked handle: the claude CLI outlives each turn, so holding the StderrLock for the copy's lifetime deadlocks any eprintln! at turn completion (e.g. OTLP export failures), leaving the execution running after the answer already streamed. - Replace the immediate terminal-stop completion with a settle window: the native result still completes the turn when it arrives, the stop completes it when the stream goes quiet without one, and stale trailing output is drained before the next turn's input so a late result cannot instantly terminate the following turn. Amp keeps a zero window (its stream has no result event). - Drop Task subagent sidechain events (parent_tool_use_id) so a subagent's end_turn cannot complete or pollute the parent turn. --- crates/harness-server/src/amp.rs | 8 +- crates/harness-server/src/anthropic.rs | 33 +++- crates/harness-server/src/claude.rs | 21 +++ crates/harness-server/src/codex.rs | 5 +- crates/harness-server/src/server.rs | 110 ++++++++++---- crates/harness-server/src/traits.rs | 25 ++- .../harness-server/tests/app_server_stdio.rs | 142 ++++++++++++++++++ 7 files changed, 303 insertions(+), 41 deletions(-) diff --git a/crates/harness-server/src/amp.rs b/crates/harness-server/src/amp.rs index 63ecd93cc..7b7ea8f06 100644 --- a/crates/harness-server/src/amp.rs +++ b/crates/harness-server/src/amp.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::env; use std::process::Command as ProcessCommand; +use std::time::Duration; use codex_app_server_protocol::UserInput; use serde_json::json; @@ -165,8 +166,11 @@ impl HarnessServer for AmpHarness { Ok(normalizer.normalize(event)) } - fn finish_turn_on_assistant_end_turn(&self) -> bool { - true + /// Amp's stream has no native `result` event: the terminal assistant stop + /// IS the end of the turn, so complete immediately (a settle window would + /// add its full length to every turn). + fn terminal_assistant_stop_settle(&self) -> Option { + Some(Duration::ZERO) } } diff --git a/crates/harness-server/src/anthropic.rs b/crates/harness-server/src/anthropic.rs index ea2dd476e..a43f3b483 100644 --- a/crates/harness-server/src/anthropic.rs +++ b/crates/harness-server/src/anthropic.rs @@ -19,13 +19,19 @@ pub enum AnthropicStreamEvent { #[serde(default)] is_partial: bool, message: AnthropicMessage, + #[serde(default)] + parent_tool_use_id: Option, }, User { message: AnthropicMessage, tool_use_result: Option, + #[serde(default)] + parent_tool_use_id: Option, }, StreamEvent { event: AnthropicRawStreamEvent, + #[serde(default)] + parent_tool_use_id: Option, }, Result { subtype: Option, @@ -61,11 +67,34 @@ impl AnthropicStreamEvent { AnthropicRawStreamEvent::MessageDelta { delta: Some(delta), .. }, + .. } => delta.stop_reason.as_deref(), _ => None, } } + /// The Task tool-use id owning this event when it belongs to a subagent + /// sidechain. Sidechain messages stop with their own `end_turn` while the + /// parent turn keeps running, so they must never settle the turn. + pub fn parent_tool_use_id(&self) -> Option<&str> { + match self { + Self::Assistant { + parent_tool_use_id, .. + } + | Self::User { + parent_tool_use_id, .. + } + | Self::StreamEvent { + parent_tool_use_id, .. + } => parent_tool_use_id.as_deref(), + _ => None, + } + } + + pub fn is_sidechain(&self) -> bool { + self.parent_tool_use_id().is_some() + } + pub fn token_usage(&self) -> Option { match self { Self::Assistant { message, .. } => { @@ -140,7 +169,7 @@ pub struct AnthropicEventNormalizer { impl AnthropicEventNormalizer { pub fn normalize(&mut self, event: AnthropicStreamEvent) -> NormalizedEvent { match event { - AnthropicStreamEvent::StreamEvent { event } => self.normalize_stream_event(event), + AnthropicStreamEvent::StreamEvent { event, .. } => self.normalize_stream_event(event), event => self.normalize_message_event(event), } } @@ -218,6 +247,7 @@ impl AnthropicEventNormalizer { AnthropicStreamEvent::Assistant { is_partial, message, + .. } => NormalizedEvent::AssistantMessage { partial: is_partial, stop_reason: message.stop_reason.clone(), @@ -226,6 +256,7 @@ impl AnthropicEventNormalizer { AnthropicStreamEvent::User { message, tool_use_result, + .. } => { let tool_use_result = tool_use_result.as_ref(); let results = message diff --git a/crates/harness-server/src/claude.rs b/crates/harness-server/src/claude.rs index e4a897cc0..d93c8c5a3 100644 --- a/crates/harness-server/src/claude.rs +++ b/crates/harness-server/src/claude.rs @@ -1,6 +1,7 @@ use std::env; use std::path::PathBuf; use std::process::Command as ProcessCommand; +use std::time::Duration; use codex_app_server_protocol::UserInput; use serde_json::json; @@ -275,8 +276,28 @@ impl HarnessServer for ClaudeCodeHarness { normalizer: &mut Self::EventNormalizer, event: Self::Event, ) -> Result> { + // Subagent sidechains (Task tool) interleave their own messages into + // the stream, ending with their own `end_turn` while the parent turn + // keeps running. Letting them through corrupts the pending-text state + // (their message ids clobber the main chain's) and their stop reasons + // would settle — and with the stop fallback, terminate — the parent + // turn. The subagent's report reaches the turn through the main + // chain's Task tool result. + if event.is_sidechain() { + return Ok(Vec::new()); + } Ok(normalizer.normalize(event)) } + + /// Claude Code normally ends a turn with a native `result` line, but + /// streams have been observed to stop at `message_delta.stop_reason` + /// without one (leaving the execution hung as "thinking" forever). Wait a + /// short window for the native result before completing on the stop, so + /// the trailing `result` is consumed by this turn instead of instantly + /// terminating the next one. + fn terminal_assistant_stop_settle(&self) -> Option { + Some(Duration::from_secs(2)) + } } #[cfg(test)] diff --git a/crates/harness-server/src/codex.rs b/crates/harness-server/src/codex.rs index 9a6c170e7..c248be6bd 100644 --- a/crates/harness-server/src/codex.rs +++ b/crates/harness-server/src/codex.rs @@ -483,7 +483,10 @@ impl CodexJsonRpcChild { .take() .ok_or(HarnessServerError::CodexStderrUnavailable)?; thread::spawn(move || { - let mut parent_stderr = io::stderr().lock(); + // Unlocked handle on purpose: this child lives across turns, so + // holding the StderrLock for the copy's lifetime would block every + // eprintln! in the server until the child exits. + let mut parent_stderr = io::stderr(); let _ = io::copy(&mut stderr, &mut parent_stderr); }); diff --git a/crates/harness-server/src/server.rs b/crates/harness-server/src/server.rs index 4f3761bde..c7b4040d1 100644 --- a/crates/harness-server/src/server.rs +++ b/crates/harness-server/src/server.rs @@ -9,7 +9,7 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, mpsc::{self, Receiver, RecvTimeoutError}, }; -use std::time::Duration; +use std::time::{Duration, Instant}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; @@ -1211,10 +1211,21 @@ fn run_harness_turn( .process .as_mut() .ok_or(HarnessServerError::HarnessStdinUnavailable)?; + // Anything already buffered predates this turn's input: a previous + // turn completed via the terminal-stop fallback can leave the CLI's + // late `result` (and trailing rate-limit noise) behind, which would + // otherwise read as this turn's instant terminal. + while process.stdout.try_recv().is_ok() {} process.stdin.write_all(&harness.stdin_for_turn(input)?)?; process.stdin.flush()?; } + let settle_window = harness.terminal_assistant_stop_settle(); + // Armed after a terminal assistant stop with no native terminal event yet: + // once the stream stays quiet past this deadline the turn completes via + // the fallback. Any further output (the native result on its way, trailing + // noise, or a continuation of the turn) pushes the deadline back. + let mut settle_deadline: Option = None; let mut last_session_id = state.harness_session_id.clone(); let mut event_normalizer = H::EventNormalizer::default(); let mut completed_turn = None; @@ -1231,17 +1242,60 @@ fn run_harness_turn( kind: harness.kind(), }); } + // A steer re-opens the turn: the harness now owes a response whose + // first token can take longer than the settle window, so the + // pending fallback completion no longer applies. The response's + // own terminal stop re-arms it. + settle_deadline = None; } - let line = match state + let mut terminal = false; + match state .process .as_mut() .ok_or(HarnessServerError::HarnessStdoutUnavailable)? .stdout .recv_timeout(Duration::from_millis(50)) { - Ok(line) => line?, - Err(RecvTimeoutError::Timeout) => continue, + Ok(line) => { + let line = line?; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let event = harness.parse_stdout_line(trimmed)?; + let normalized_events = harness.normalize_events(&mut event_normalizer, event)?; + let mut terminal_stop = false; + for normalized in normalized_events { + if let Some(usage) = normalized.token_usage() { + latest_usage = Some(usage.clone()); + } + append_usage_span_output(&normalized, &mut usage_span_output); + if let Some(session_id) = normalized.session_id() { + last_session_id = Some(session_id.to_string()); + state.harness_session_id = Some(session_id.to_string()); + } + for notification in normalizer.process_event(&normalized)? { + write_value(stdout, ¬ification_to_wire_value(¬ification)?)?; + } + terminal |= normalized.is_terminal(); + terminal_stop |= + settle_window.is_some() && normalized.is_terminal_assistant_stop(); + } + if !terminal { + match settle_window { + Some(window) if terminal_stop && window.is_zero() => terminal = true, + Some(window) if terminal_stop || settle_deadline.is_some() => { + settle_deadline = Some(Instant::now() + window); + } + _ => {} + } + } + } + Err(RecvTimeoutError::Timeout) => match settle_deadline { + Some(deadline) if Instant::now() >= deadline => terminal = true, + _ => continue, + }, Err(RecvTimeoutError::Disconnected) => { let status = state .process @@ -1249,35 +1303,20 @@ fn run_harness_turn( .ok_or(HarnessServerError::HarnessStdoutUnavailable)? .child .wait()?; - return Err(HarnessServerError::HarnessExited { - kind: harness.kind(), - status, - stderr: String::new(), - }); - } - }; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let event = harness.parse_stdout_line(trimmed)?; - let normalized_events = harness.normalize_events(&mut event_normalizer, event)?; - let mut terminal = false; - for normalized in normalized_events { - if let Some(usage) = normalized.token_usage() { - latest_usage = Some(usage.clone()); - } - append_usage_span_output(&normalized, &mut usage_span_output); - if let Some(session_id) = normalized.session_id() { - last_session_id = Some(session_id.to_string()); - state.harness_session_id = Some(session_id.to_string()); - } - for notification in normalizer.process_event(&normalized)? { - write_value(stdout, ¬ification_to_wire_value(¬ification)?)?; + // A clean exit while waiting out the settle window means the + // native result is never coming: the terminal stop already + // seen ends the turn. + if settle_deadline.is_some() && status.success() { + state.process = None; + terminal = true; + } else { + return Err(HarnessServerError::HarnessExited { + kind: harness.kind(), + status, + stderr: String::new(), + }); + } } - terminal |= normalized.is_terminal() - || (harness.finish_turn_on_assistant_end_turn() - && normalized.is_assistant_end_turn()); } if terminal { export_harness_usage_if_available( @@ -1465,7 +1504,12 @@ fn ensure_harness_process(harness: &H, state: &mut ThreadState .take() .ok_or(HarnessServerError::HarnessStderrUnavailable)?; std::thread::spawn(move || { - let mut parent_stderr = io::stderr().lock(); + // Copy through the unlocked handle (it locks per write): the harness + // process outlives each turn, so its stderr never EOFs, and holding + // the StderrLock here for the copy's lifetime deadlocks every other + // eprintln! in the server — including turn-completion paths, which + // then never emit turn/completed. + let mut parent_stderr = io::stderr(); let _ = io::copy(&mut stderr, &mut parent_stderr); }); let (stdout_tx, stdout_rx) = mpsc::channel(); diff --git a/crates/harness-server/src/traits.rs b/crates/harness-server/src/traits.rs index 61f5393df..9779df361 100644 --- a/crates/harness-server/src/traits.rs +++ b/crates/harness-server/src/traits.rs @@ -2,6 +2,7 @@ use std::io; use std::path::PathBuf; use std::process::{Child, ChildStdin, Command as ProcessCommand}; use std::sync::mpsc::Receiver; +use std::time::Duration; use codex_app_server_protocol::{ThreadStartParams, Turn, UserInput}; use serde_json::Value; @@ -71,8 +72,17 @@ pub trait HarnessServer { normalizer: &mut Self::EventNormalizer, event: Self::Event, ) -> Result>; - fn finish_turn_on_assistant_end_turn(&self) -> bool { - false + /// How to treat an assistant message that stops with a terminal stop + /// reason (`end_turn`, ...) when no native terminal event has arrived. + /// `None` keeps the turn open until a native result/error (the default). + /// `Some(window)` completes the turn once the stream stays quiet for + /// `window` after the stop: a zero window completes immediately (for + /// streams with no native result event), a nonzero window gives the + /// harness's own `result` a chance to settle the turn first — and keeps + /// that trailing `result` from being read as the *next* turn's terminal — + /// while still completing when the result never comes. + fn terminal_assistant_stop_settle(&self) -> Option { + None } fn thread_state(&self, params: &ThreadStartParams, cwd: PathBuf) -> ThreadState { @@ -153,18 +163,25 @@ impl NormalizedEvent { } } - pub(crate) fn is_assistant_end_turn(&self) -> bool { + pub(crate) fn is_terminal_assistant_stop(&self) -> bool { matches!( self, Self::AssistantMessage { partial: false, stop_reason: Some(stop_reason), .. - } if stop_reason == "end_turn" + } if is_terminal_assistant_stop_reason(stop_reason) ) } } +fn is_terminal_assistant_stop_reason(reason: &str) -> bool { + matches!( + reason, + "end_turn" | "stop_sequence" | "max_tokens" | "refusal" + ) +} + #[derive(Debug, Clone)] pub enum NormalizedContent { AgentText { diff --git a/crates/harness-server/tests/app_server_stdio.rs b/crates/harness-server/tests/app_server_stdio.rs index 692ccba29..49b1d394a 100644 --- a/crates/harness-server/tests/app_server_stdio.rs +++ b/crates/harness-server/tests/app_server_stdio.rs @@ -106,6 +106,148 @@ fn fake_claude_app_server_streams_codex_v2_notifications() { assert_codex_v2_turn(&run.turn); } +#[test] +fn fake_claude_app_server_completes_on_stop_sequence_without_result() { + let fake_claude = concat!( + "printf '%s\\n' ", + "'{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"claude-session\"}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[]}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"fable answer\"}}}' ", + "'{\"type\":\"assistant\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[{\"type\":\"text\",\"text\":\"fable answer\"}]}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"stop_sequence\"}}}'" + ); + + let run = run_bridge_turn(BridgeTurnConfig { + harness: Harness::ClaudeCode, + command_override: Some(fake_claude.to_string()), + prompt: "say hello".to_string(), + timeout: Duration::from_secs(10), + }); + + assert_completed_turn(&run.turn); + assert_eq!(run.turn.text_from_deltas, "fable answer"); + assert_codex_v2_turn(&run.turn); +} + +#[test] +fn fake_claude_completes_on_terminal_stop_while_process_outlives_the_turn() { + // The real CLI does not exit after a turn — harness-server keeps it (and + // its stdin) alive for the next one. When the stream stops at the + // message_delta stop reason with no trailing `result`, the settle window + // must complete the turn instead of waiting on the live process forever + // (the fable "thinking..." hang). + let fake_claude = concat!( + "printf '%s\\n' ", + "'{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"claude-session\"}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[]}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"fable answer\"}}}' ", + "'{\"type\":\"assistant\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[{\"type\":\"text\",\"text\":\"fable answer\"}]}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}}'", + "; sleep 60" + ); + + let run = run_bridge_turn(BridgeTurnConfig { + harness: Harness::ClaudeCode, + command_override: Some(fake_claude.to_string()), + prompt: "say hello".to_string(), + timeout: Duration::from_secs(10), + }); + + assert_completed_turn(&run.turn); + assert_eq!(run.turn.text_from_deltas, "fable answer"); + assert_codex_v2_turn(&run.turn); +} + +#[test] +fn fake_claude_trailing_result_settles_the_turn_and_does_not_poison_the_next() { + // The native `result` trails the message_delta stop reason in real CLI + // output. The stop must not complete the turn so eagerly that the result + // is left buffered, where the next turn would read it as its own instant + // terminal and complete with no content. + let fake_claude = concat!( + "printf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"claude-session\"}'; ", + "IFS= read -r _; ", + "printf '%s\\n' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[]}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"first answer\"}}}' ", + "'{\"type\":\"assistant\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[{\"type\":\"text\",\"text\":\"first answer\"}]}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}' ", + "'{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"first answer\"}'; ", + "IFS= read -r _; ", + "printf '%s\\n' ", + "'{\"type\":\"assistant\",\"is_partial\":false,\"message\":{\"id\":\"msg_2\",\"content\":[{\"type\":\"text\",\"text\":\"second answer\"}]}}' ", + "'{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"second answer\"}'; ", + "sleep 60" + ); + + let run = run_bridge_two_turns(BridgeTwoTurnConfig { + harness: Harness::ClaudeCode, + command_override: Some(fake_claude.to_string()), + first_prompt: "first".to_string(), + second_prompt: "second".to_string(), + timeout: Duration::from_secs(10), + }); + + assert_completed_turn(&run.turns[0]); + assert_eq!(run.turns[0].text_from_deltas, "first answer"); + assert_completed_turn(&run.turns[1]); + assert_eq!(run.turns[1].text_from_deltas, "second answer"); +} + +#[test] +fn fake_claude_subagent_sidechain_stop_does_not_complete_the_turn() { + // A Task subagent's sidechain messages end with their own end_turn while + // the parent turn keeps running (here: 3s of quiet before the main chain + // resumes, longer than the settle window). The sidechain stop must not + // complete the turn or leak subagent text into it. + let fake_claude = concat!( + "printf '%s\\n' ", + "'{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"claude-session\"}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[]}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Delegating.\"}}}' ", + "'{\"type\":\"assistant\",\"message\":{\"id\":\"msg_1\",\"stop_reason\":null,\"content\":[{\"type\":\"text\",\"text\":\"Delegating.\"},{\"type\":\"tool_use\",\"id\":\"toolu_task\",\"name\":\"Task\",\"input\":{\"prompt\":\"look it up\"}}]}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_sub\",\"stop_reason\":null,\"content\":[]}},\"parent_tool_use_id\":\"toolu_task\"}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}},\"parent_tool_use_id\":\"toolu_task\"}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"sub answer\"}},\"parent_tool_use_id\":\"toolu_task\"}' ", + "'{\"type\":\"assistant\",\"message\":{\"id\":\"msg_sub\",\"stop_reason\":null,\"content\":[{\"type\":\"text\",\"text\":\"sub answer\"}]},\"parent_tool_use_id\":\"toolu_task\"}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}},\"parent_tool_use_id\":\"toolu_task\"}'", + "; sleep 3; printf '%s\\n' ", + "'{\"type\":\"user\",\"message\":{\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_task\",\"content\":\"sub answer\",\"is_error\":false}]}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_2\",\"stop_reason\":null,\"content\":[]}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"main answer\"}}}' ", + "'{\"type\":\"assistant\",\"message\":{\"id\":\"msg_2\",\"stop_reason\":null,\"content\":[{\"type\":\"text\",\"text\":\"main answer\"}]}}' ", + "'{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}}' ", + "'{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"main answer\"}'" + ); + + let run = run_bridge_turn(BridgeTurnConfig { + harness: Harness::ClaudeCode, + command_override: Some(fake_claude.to_string()), + prompt: "delegate then answer".to_string(), + timeout: Duration::from_secs(15), + }); + + assert_completed_turn(&run.turn); + assert!( + run.turn.text_from_deltas.contains("main answer"), + "main-chain answer missing: {:?}", + run.turn.text_from_deltas + ); + assert!( + !run.turn.text_from_deltas.contains("sub answer"), + "sidechain text leaked into the turn: {:?}", + run.turn.text_from_deltas + ); + assert_codex_v2_turn(&run.turn); +} + #[test] fn fake_codex_blocks_mode_uses_openrouter_provider_when_model_is_configured() { let fake_codex = temp_path("fake-openrouter-codex.sh"); From d9ef4b765ebb99c572aaf45f710b8fbe637b21fb Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Mon, 6 Jul 2026 20:31:34 -0600 Subject: [PATCH 066/198] chore: format otel rust code (#922) --- crates/harness-server/src/otel.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/harness-server/src/otel.rs b/crates/harness-server/src/otel.rs index a2fed5ccd..407c586a5 100644 --- a/crates/harness-server/src/otel.rs +++ b/crates/harness-server/src/otel.rs @@ -37,16 +37,17 @@ impl TraceContext { pub(crate) fn effective_trace_id(&self) -> Option { self.trace_id .clone() - .or_else(|| self.traceparent.as_deref().and_then(trace_id_from_traceparent)) + .or_else(|| { + self.traceparent + .as_deref() + .and_then(trace_id_from_traceparent) + }) .or_else(|| clean_optional(env::var("CENTAUR_TRACE_ID").ok().as_deref())) } pub(crate) fn effective_traceparent(&self) -> Option { let trace_id = self.effective_trace_id()?; - if let Some(traceparent) = self - .traceparent - .as_deref() - .and_then(validate_traceparent) + if let Some(traceparent) = self.traceparent.as_deref().and_then(validate_traceparent) && trace_id_from_traceparent(traceparent).as_deref() == Some(trace_id.as_str()) { return Some(traceparent.to_owned()); @@ -1306,10 +1307,7 @@ trust_level = "trusted" metadata: BTreeMap::new(), }; - assert_eq!( - trace.effective_trace_id().as_deref(), - Some(thread_trace_id) - ); + assert_eq!(trace.effective_trace_id().as_deref(), Some(thread_trace_id)); let effective_traceparent = trace.effective_traceparent().expect("traceparent"); assert_ne!(effective_traceparent, execution_traceparent); assert_eq!( From cd57433aec0ba2e9abd25a9137ecff2e2ec29974 Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Mon, 6 Jul 2026 21:52:28 -0700 Subject: [PATCH 067/198] fix(discordbot): port live activity summaries; unstick answer-only streaming (#925) 4c17d8b8 (live activity summaries) moved reasoning/'Thinking' task synthesis out of the shared renderer and adapted slackbotv2 and linearbot, but not discordbot. Two regressions: - Discord's narrator built its -# reasoning blurbs from the removed Thinking tasks, so runs narrated nothing. Port: forward session.activity_summary session events (previously dropped by discordbot's SSE whitelist) and route renderer.status to a new DiscordNarrator.status(), the Discord analog of Slack's assistant status (dedupes consecutive repeats, drops the end-of-run clear). - The synthetic starting item no longer primes the renderer's task state, and the pre-stream grace check is event-driven, so an answer-only turn's deltas sat buffered until the next event or stream end. Fix: make the grace configurable (preStreamGraceMs, default 500ms unchanged) and pass 0 in discordbot, which streams answer text into its own append-only messages and has no card to wait for. Tests: new activity-summary blurb test; stale blurb expectations updated to the server-side model; the failing-edit test now waits for the first post so the tail must land as an edit (previously the deltas could coalesce into the initial post and skip the path under test); mock gains hasStream(threadKey) because streamCount counts live streams across all threads and a lingering stream from the previous test could satisfy a bare count wait. These failures were invisible on main: the discordbot CI job is path-filtered and 4c17d8b8 landed without a PR run. Co-authored-by: Centaur AI --- packages/rendering/src/codex-app-server.ts | 10 +- services/discordbot/src/discord-narrator.ts | 18 +++ services/discordbot/src/index.ts | 20 ++- services/discordbot/src/session-api.ts | 9 ++ .../discordbot/test/chat-sdk-emulate.test.ts | 123 ++++++++++++++++-- 5 files changed, 163 insertions(+), 17 deletions(-) diff --git a/packages/rendering/src/codex-app-server.ts b/packages/rendering/src/codex-app-server.ts index ef8f9e640..a8fe0885a 100644 --- a/packages/rendering/src/codex-app-server.ts +++ b/packages/rendering/src/codex-app-server.ts @@ -72,6 +72,12 @@ export type CodexAppServerRendererEventMapperOptions = { logInfo?: RendererLogInfo unknownAgentMessagePhase?: AgentMessagePhase taskOutput?: 'full' | 'omit' + // How long buffered assistant text waits for plan/tasks to arrive before it + // streams anyway. The check is event-driven — with no tasks and no further + // events, buffered text sits until the next event or stream end — so + // consumers that stream text into their own surface (discordbot) pass 0 to + // emit deltas immediately. Default 500ms (Slack card-first rendering). + preStreamGraceMs?: number } export type CodexAppServerToChatStreamOptions = CodexAppServerRendererEventMapperOptions & { @@ -87,12 +93,14 @@ export class CodexAppServerRendererEventMapper private readonly logInfo?: RendererLogInfo private readonly unknownAgentMessagePhase: AgentMessagePhase private readonly includeTaskOutput: boolean + private readonly preStreamGraceMs: number constructor(options: CodexAppServerRendererEventMapperOptions = {}) { this.sessionId = options.sessionId ?? '' this.logInfo = options.logInfo this.unknownAgentMessagePhase = options.unknownAgentMessagePhase ?? 'final_answer' this.includeTaskOutput = options.taskOutput === 'full' + this.preStreamGraceMs = options.preStreamGraceMs ?? PRE_STREAM_GRACE_MS } process(source: ServerNotification | RustSessionStreamEvent | unknown): RendererEvent[] { @@ -387,7 +395,7 @@ export class CodexAppServerRendererEventMapper const hasPlan = this.state.taskByUseId.size > 0 const graceExpired = this.state.firstBufferedTextAt !== null && - Date.now() - this.state.firstBufferedTextAt >= PRE_STREAM_GRACE_MS + Date.now() - this.state.firstBufferedTextAt >= this.preStreamGraceMs const canStream = hasPlan || opts.force || graceExpired if (!canStream) return diff --git a/services/discordbot/src/discord-narrator.ts b/services/discordbot/src/discord-narrator.ts index 0b7e543cf..416194c56 100644 --- a/services/discordbot/src/discord-narrator.ts +++ b/services/discordbot/src/discord-narrator.ts @@ -62,6 +62,7 @@ export class DiscordNarrator { // concatenate; a commentary item re-uses its id and replaces its body. private pendingParts = new Map(); private queuedBlurbs: string[] = []; + private lastStatus = ""; private postedCount = 0; private droppedBlurbs = 0; private lastPostAtMs = 0; @@ -101,6 +102,23 @@ export class DiscordNarrator { return narrator; } + /** + * Server-side activity summaries (renderer.status events) — the Discord + * analog of Slack's assistant status. Discord has no ephemeral status + * surface, so summaries post as append-only subtext blurbs, like thoughts + * did before reasoning synthesis moved out of the renderer. Empty statuses + * (the end-of-run clear) and consecutive repeats are dropped. + */ + status(text: string): void { + if (this.finished) return; + const trimmed = text.trim(); + if (!trimmed || trimmed === this.lastStatus) return; + this.lastStatus = trimmed; + if (trimmed.length < NARRATOR_MIN_BLURB_CHARS) return; + this.queuedBlurbs.push(truncateBlurb(trimmed)); + this.schedulePost(); + } + update(chunk: DiscordNarratorChunk): void { if (this.finished) return; if (chunk.type !== "task_update") return; diff --git a/services/discordbot/src/index.ts b/services/discordbot/src/index.ts index 8babf096f..24cdbd04e 100644 --- a/services/discordbot/src/index.ts +++ b/services/discordbot/src/index.ts @@ -1246,7 +1246,7 @@ async function renderSplitExecutionStreams( try { for await (const chunk of codexAppServerToChatSdkStream( stream, - rendererOptions(options), + rendererOptions(options, narrator), )) { if (chunk.type === "markdown_text") { if (!answerPost) { @@ -1613,17 +1613,29 @@ function backgroundWaitUntil(promise: Promise): void { void promise.catch(() => undefined); } -// Vestigial wrapper kept so call sites diff cleanly against slackbotv2, whose -// rendererOptions hooks onRendererEvent to update the Slack assistant title -// (no Discord analog). Today it only forwards the configured mapper. +// Mirrors slackbotv2's rendererOptions: forwards the configured mapper and +// hooks onRendererEvent. Slack routes renderer.status (server-side activity +// summaries) to its native assistant status; Discord has no such surface, so +// they post as the narrator's append-only subtext blurbs. Paths without a +// narrator (plain-text runs) drop status events by design. function rendererOptions( options: DiscordbotOptions, + narrator?: DiscordNarrator, ): CodexAppServerToChatStreamOptions { const mapper = options.mapper; return { ...mapper, + // Discord streams answer text into its own append-only messages, so + // there is no card to wait for: stream deltas immediately. Non-zero + // grace here can strand an answer-only turn's deltas entirely — the + // grace check is event-driven, and with no tasks and no later events + // the buffered text only flushes at stream end. + preStreamGraceMs: 0, async onRendererEvent(event: RendererEvent) { await mapper?.onRendererEvent?.(event); + if (event.type === "renderer.status") { + narrator?.status(event.status); + } }, }; } diff --git a/services/discordbot/src/session-api.ts b/services/discordbot/src/session-api.ts index 7ddeffc1d..e6cd8d3c9 100644 --- a/services/discordbot/src/session-api.ts +++ b/services/discordbot/src/session-api.ts @@ -763,6 +763,15 @@ async function* parseSessionEventStream( if (isTerminalCodexOutputLine(event.data)) return; continue; } + if (event.event === "session.activity_summary") { + yield { + data: sessionEventData(event), + event: event.event, + eventId: event.id, + eventKind: event.event, + } satisfies RustSessionStreamEvent; + continue; + } if ( event.event === "session.execution_failed" || event.event === "session.stream_error" diff --git a/services/discordbot/test/chat-sdk-emulate.test.ts b/services/discordbot/test/chat-sdk-emulate.test.ts index c46da4481..326d931a2 100644 --- a/services/discordbot/test/chat-sdk-emulate.test.ts +++ b/services/discordbot/test/chat-sdk-emulate.test.ts @@ -180,17 +180,15 @@ describe("discordbot", () => { JSON.stringify(JSON.parse(secondExecute.body.input_lines[0]!)), ).toContain("now execute with the latest"); - // Append-only narration: reasoning blurbs as `-# ` subtext messages. - // Thoughts that complete close together may merge into one blurb, so - // assert the contents and the per-line subtext prefix, not boundaries. - const blurbText = blurbPostsIn(threadId).join("\n"); - expect(blurbText).toContain("Checking the command output"); - expect(blurbText).toContain("Inspecting the event stream"); - for (const blurb of blurbPostsIn(threadId)) { - for (const line of blurb.split("\n")) { - if (line.trim()) expect(line.startsWith("-# ")).toBe(true); - } - } + // Reasoning synthesis moved server-side (live activity summaries): raw + // commentary/reasoning deltas no longer render as blurbs or anywhere + // else. Summaries arrive as session.activity_summary events instead — + // covered by the dedicated activity-summary test below. + expect(blurbPostsIn(threadId)).toEqual([]); + const allPosts = botPostsIn(threadId).join("\n"); + expect(allPosts).not.toContain("Checking the command output"); + expect(allPosts).not.toContain("Inspecting the event stream"); + expect(allPosts).not.toContain("Thinking"); // The final answers land as their own lazily-created messages. const answers = answerPostsIn(threadId); @@ -635,7 +633,7 @@ describe("discordbot", () => { thread: { id: threadId, parentId: CHANNEL_ID }, }); await waitFor(() => codexApi.executes.length === 1); - await waitFor(() => codexApi.streamCount === 1); + await waitFor(() => codexApi.hasStream(key)); codexApi.emitOutputLine( key, @@ -657,6 +655,12 @@ describe("discordbot", () => { delta: "partial answer ", }), ); + // Wait for the first message post so the tail must land as an edit — + // emitting both deltas back to back can coalesce into the initial post, + // which would never exercise the failing-edit path under test. + await waitFor(() => + botPostsIn(threadId).some((content) => content.includes("partial answer")), + ); codexApi.emitOutputLine( key, JSON.stringify({ @@ -686,6 +690,88 @@ describe("discordbot", () => { expect(hasReaction(threadId, mentionId, "PUT", "❌")).toBe(false); }); + it("posts session activity summaries as subtext blurbs", async () => { + codexApi.autoRespond = false; + + const threadId = discordApi.nextId(); + discordApi.seedThreadChannel(threadId, CHANNEL_ID); + const key = threadKey(threadId); + const mentionId = await dispatchMessage({ + channelId: threadId, + content: `<@${APP_ID}> summarize activity`, + mention: true, + thread: { id: threadId, parentId: CHANNEL_ID }, + }); + await waitFor(() => codexApi.executes.length === 1); + await waitFor(() => codexApi.eventRequests.length === 1); + await waitFor(() => codexApi.hasStream(key)); + + const firstSummary = "Checking the benchmark page and related logs."; + const secondSummary = "Comparing the chart against the raw data."; + codexApi.emitSessionEvent(key, "session.activity_summary", { + execution_id: "exe-activity-summary", + summary: firstSummary, + }); + // A consecutive repeat is dropped instead of posting a duplicate blurb. + codexApi.emitSessionEvent(key, "session.activity_summary", { + execution_id: "exe-activity-summary", + summary: firstSummary, + }); + await waitFor(() => + blurbPostsIn(threadId).some((content) => content.includes(firstSummary)), + ); + codexApi.emitSessionEvent(key, "session.activity_summary", { + execution_id: "exe-activity-summary", + summary: secondSummary, + }); + codexApi.emitOutputLine( + key, + JSON.stringify({ + type: "item.started", + item: { + id: "answer-1", + type: "agentMessage", + text: "", + phase: "final_answer", + }, + }), + ); + codexApi.emitOutputLine( + key, + JSON.stringify({ + type: "item.agentMessage.delta", + itemId: "answer-1", + delta: "Done with status.", + }), + ); + codexApi.emitOutputLine( + key, + JSON.stringify({ + type: "turn.completed", + turn: { id: "turn-1", items: [] }, + }), + ); + + await waitForSettle(threadId, mentionId); + // finish() flushes the second summary even inside the min-post-gap window. + const blurbs = blurbPostsIn(threadId); + expect(blurbs.join("\n")).toContain(firstSummary); + expect(blurbs.join("\n")).toContain(secondSummary); + expect( + blurbs.join("\n").split(`-# ${firstSummary}`), + ).toHaveLength(2); + for (const blurb of blurbs) { + for (const line of blurb.split("\n")) { + if (line.trim()) expect(line.startsWith("-# ")).toBe(true); + } + } + // Summaries stay in the subtext lane; the answer stays summary-free. + const answers = answerPostsIn(threadId); + expect(answers.join("\n")).toContain("Done with status."); + expect(answers.join("\n")).not.toContain(firstSummary); + expect(hasReaction(threadId, mentionId, "PUT", "✅")).toBe(true); + }); + // Regression (g) — transient create/append failure is retried in place. it("retries a transient createSession failure and succeeds without user-visible error", async () => { codexApi.failNextCreate = true; @@ -2016,6 +2102,7 @@ type MockSessionApi = { failNextEvents: boolean; failNextExecute: boolean; failNextExecuteAfterAccept: boolean; + hasStream(threadKey: string): boolean; holdNextExecute(): () => void; reset(): void; streamCount: number; @@ -2030,6 +2117,7 @@ async function startMockCodexApi(): Promise { const executes: MockSessionRequest[] = []; const idempotentExecutions = new Map(); const streams = new Set(); + const streamThreadKeys = new Map(); let autoRespond = true; let executeHold: Promise | null = null; let executeHoldRelease: (() => void) | null = null; @@ -2043,6 +2131,7 @@ async function startMockCodexApi(): Promise { const closeStreams = () => { for (const stream of streams) stream.end(); streams.clear(); + streamThreadKeys.clear(); }; const server = createServer((req, res) => { void handleMockCodexRequest(req, res, { @@ -2091,6 +2180,7 @@ async function startMockCodexApi(): Promise { failNextExecuteAfterAccept = value; }, streams, + streamThreadKeys, }).catch((error) => { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String(error) })); @@ -2175,6 +2265,12 @@ async function startMockCodexApi(): Promise { get streamCount() { return streams.size; }, + // streamCount counts LIVE streams across all threads; a stream from the + // previous test that has not closed yet can satisfy a bare count wait. + // Order-sensitive tests wait for THEIR thread's stream instead. + hasStream(threadKey: string) { + return Array.from(streamThreadKeys.values()).includes(threadKey); + }, emitOutputLine(threadKey: string, line: string, executionId?: string) { emitMockSessionEvent({ data: line, @@ -2238,6 +2334,7 @@ async function handleMockCodexRequest( setFailNextExecute(value: boolean): void; setFailNextExecuteAfterAccept(value: boolean): void; streams: Set; + streamThreadKeys: Map; }, ): Promise { const url = new URL(req.url ?? "/", `http://127.0.0.1:${input.port}`); @@ -2302,6 +2399,7 @@ async function handleMockCodexRequest( "content-type": "text/event-stream", }); input.streams.add(res); + input.streamThreadKeys.set(res, threadKey); for (const event of input.events) { if ( event.threadKey === threadKey && @@ -2315,6 +2413,7 @@ async function handleMockCodexRequest( } req.once("close", () => { input.streams.delete(res); + input.streamThreadKeys.delete(res); }); return; } From 41e305f9a89fcc49dff0f1afb4a79b867dca2d1e Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Mon, 6 Jul 2026 21:57:58 -0700 Subject: [PATCH 068/198] fix(slackbotv2): preserve paragraph breaks in Slack plain-text extraction (#924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(slackbotv2): preserve paragraph breaks in Slack plain-text extraction The @chat-adapter/slack SlackFormatConverter built message.text by flattening the parsed mrkdwn AST with mdast-util-to-string, which joins sibling block nodes with no separator. A message like --claude --model=fable examine ... reached extractMessageOverrides as '--claude --model=fableexamine ...', so the harness was started with the nonexistent model 'fableexamine'. The newline-boundary regex fix from #900 was correct but ran on input whose paragraph breaks had already been destroyed upstream — and every Slack message with a blank line had its paragraphs glued together in the text forwarded to the agent. Extend the @chat-adapter/slack patch to override extractPlainText with a block-aware conversion: paragraphs join with a blank line, list items and blockquote lines with a newline. Add pipeline regression tests that run the real converter output through extractMessageOverrides. * test: exercise patched paragraph-break handling end to end The sticky-overrides emulation test now sends --model=fable followed by a paragraph break — the exact production shape that selected the nonexistent model fableexamine — covering the patched extractPlainText through the full webhook pipeline. --------- Co-authored-by: Centaur AI --- patches/@chat-adapter__slack@4.31.0.patch | 48 ++++++++++++++++--- pnpm-lock.yaml | 6 +-- .../slackbotv2/test/chat-sdk-emulate.test.ts | 7 ++- services/slackbotv2/test/overrides.test.ts | 42 ++++++++++++++++ 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/patches/@chat-adapter__slack@4.31.0.patch b/patches/@chat-adapter__slack@4.31.0.patch index 89a75dd64..609dd35e6 100644 --- a/patches/@chat-adapter__slack@4.31.0.patch +++ b/patches/@chat-adapter__slack@4.31.0.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.js b/dist/index.js -index a7048fd884020cfd96a51c48b7071fa7293f3dc4..e5ff9dc2682a754202102f80d3178818ff52ce63 100644 +index a7048fd884020cfd96a51c48b7071fa7293f3dc4..e5bd30bb8f2d41f8f836e7dee742a67f32ccc951 100644 --- a/dist/index.js +++ b/dist/index.js @@ -31,6 +31,216 @@ import { @@ -219,7 +219,41 @@ index a7048fd884020cfd96a51c48b7071fa7293f3dc4..e5ff9dc2682a754202102f80d3178818 // src/cards.ts import { -@@ -2004,7 +2214,10 @@ var SlackAdapter = class _SlackAdapter { +@@ -352,6 +562,15 @@ import { + } from "chat"; + var BARE_MENTION_PATTERN = /(?]+/g; ++function plainTextPreservingBlocks(node) { ++ if (node.type === "root" || node.type === "blockquote") { ++ return getNodeChildren(node).map(plainTextPreservingBlocks).filter(Boolean).join(node.type === "root" ? "\n\n" : "\n"); ++ } ++ if (node.type === "list" || node.type === "listItem") { ++ return getNodeChildren(node).map(plainTextPreservingBlocks).filter(Boolean).join("\n"); ++ } ++ return toPlainText(node); ++} + var SlackFormatConverter = class extends BaseFormatConverter { + /** + * Render an AST to standard markdown. Slack accepts this directly via +@@ -366,6 +585,17 @@ var SlackFormatConverter = class extends BaseFormatConverter { + toAst(mrkdwn) { + return parseMarkdown(slackMrkdwnToMarkdown(mrkdwn)); + } ++ /** ++ * Extract plain text for incoming `message` events. The base implementation ++ * flattens the whole AST with mdast-util-to-string, which concatenates ++ * sibling block nodes with NO separator: `--model=fable\n\nexamine ...` ++ * became `--model=fableexamine ...`, gluing every paragraph boundary in the ++ * message. Preserve block boundaries instead — paragraphs join with a blank ++ * line, list items and blockquote lines with a newline. ++ */ ++ extractPlainText(mrkdwn) { ++ return plainTextPreservingBlocks(this.toAst(mrkdwn)); ++ } + /** + * Build the Slack API payload fields for a message. + * +@@ -2004,7 +2234,10 @@ var SlackAdapter = class _SlackAdapter { channel: event.channel, threadTs }); @@ -231,7 +265,7 @@ index a7048fd884020cfd96a51c48b7071fa7293f3dc4..e5ff9dc2682a754202102f80d3178818 const factory = async () => { const msg = await this.parseSlackMessage(event, threadId); if (isMention) { -@@ -2520,10 +2732,10 @@ var SlackAdapter = class _SlackAdapter { +@@ -2520,10 +2753,10 @@ var SlackAdapter = class _SlackAdapter { formatted: this.formatConverter.toAst(text), raw: event, author: { @@ -244,7 +278,7 @@ index a7048fd884020cfd96a51c48b7071fa7293f3dc4..e5ff9dc2682a754202102f80d3178818 isMe }, metadata: { -@@ -3452,26 +3664,251 @@ var SlackAdapter = class _SlackAdapter { +@@ -3452,26 +3685,251 @@ var SlackAdapter = class _SlackAdapter { } this.logger.debug("Slack: starting stream", { channel, threadTs }); const token = await this.getToken(); @@ -505,7 +539,7 @@ index a7048fd884020cfd96a51c48b7071fa7293f3dc4..e5ff9dc2682a754202102f80d3178818 const sendStructuredChunk = async (chunk) => { if (!structuredChunksSupported) { return; -@@ -3481,8 +3918,45 @@ var SlackAdapter = class _SlackAdapter { +@@ -3481,8 +3939,45 @@ var SlackAdapter = class _SlackAdapter { await flushMarkdownDelta(delta); lastAppended = committable; try { @@ -552,7 +586,7 @@ index a7048fd884020cfd96a51c48b7071fa7293f3dc4..e5ff9dc2682a754202102f80d3178818 structuredChunksSupported = false; this.logger.warn( "Structured streaming chunk failed, falling back to text-only streaming. Ensure your Slack app manifest includes assistant_view, assistant:write scope, and @slack/web-api >= 7.14.0", -@@ -3497,31 +3971,91 @@ var SlackAdapter = class _SlackAdapter { +@@ -3497,31 +3992,91 @@ var SlackAdapter = class _SlackAdapter { await flushMarkdownDelta(delta); lastAppended = committable; }; @@ -663,7 +697,7 @@ index a7048fd884020cfd96a51c48b7071fa7293f3dc4..e5ff9dc2682a754202102f80d3178818 }; } /** -@@ -3798,10 +4332,10 @@ var SlackAdapter = class _SlackAdapter { +@@ -3798,10 +4353,10 @@ var SlackAdapter = class _SlackAdapter { formatted: this.formatConverter.toAst(text), raw: event, author: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52802f311..95d9ac57d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ patchedDependencies: hash: fce7a692b030cfe3d325b020a4472b9424b8976aa2f7faded6ad4c83421e9132 path: patches/@chat-adapter__linear@4.31.0.patch '@chat-adapter/slack@4.31.0': - hash: 4a13e39aa1f023696e9b930391146ffcb8ac50adfcf394dcf1905e4e445213f7 + hash: b005d7fc3498bc499bfd30ff79be29138a48b63944761da6f1b68bec59ef9d68 path: patches/@chat-adapter__slack@4.31.0.patch '@chat-adapter/state-pg@4.31.0': hash: 69262b03c278ca9d4af0bed7b4e05f9d7a5a36d3d79fad31df2a85da4d349274 @@ -171,7 +171,7 @@ importers: version: link:../../packages/rendering '@chat-adapter/slack': specifier: ^4.31.0 - version: 4.31.0(patch_hash=4a13e39aa1f023696e9b930391146ffcb8ac50adfcf394dcf1905e4e445213f7)(zod@4.4.3) + version: 4.31.0(patch_hash=b005d7fc3498bc499bfd30ff79be29138a48b63944761da6f1b68bec59ef9d68)(zod@4.4.3) '@chat-adapter/state-pg': specifier: ^4.31.0 version: 4.31.0(patch_hash=69262b03c278ca9d4af0bed7b4e05f9d7a5a36d3d79fad31df2a85da4d349274)(zod@4.4.3) @@ -1754,7 +1754,7 @@ snapshots: - supports-color - zod - '@chat-adapter/slack@4.31.0(patch_hash=4a13e39aa1f023696e9b930391146ffcb8ac50adfcf394dcf1905e4e445213f7)(zod@4.4.3)': + '@chat-adapter/slack@4.31.0(patch_hash=b005d7fc3498bc499bfd30ff79be29138a48b63944761da6f1b68bec59ef9d68)(zod@4.4.3)': dependencies: '@chat-adapter/shared': 4.31.0(zod@4.4.3) '@slack/socket-mode': 2.0.7 diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index fcd867442..921c445ee 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -346,6 +346,9 @@ describe('slackbotv2', () => { expectSlackRenderedReply(renderedReplies[1]!, 'Executed request 2.') }) + // The paragraph break (`\n\n`) after the model value is deliberate: the + // unpatched chat SDK dropped it, gluing the value to the next word + // (`fablefirst`); this exercises the patched extractPlainText end to end. it('keeps harness and model flags sticky within a Slack thread', async () => { const sharedState = createMemoryState() await sharedState.connect() @@ -353,7 +356,7 @@ describe('slackbotv2', () => { const parent = await postUserMessage('Thread default context.') const firstMention = await postUserMessage( - `<@${BOT_USER_ID}> --claude --model=fable\nfirst pass`, + `<@${BOT_USER_ID}> --claude --model=fable\n\nfirst pass`, parent.ts ) const firstWaits: Promise[] = [] @@ -368,7 +371,7 @@ describe('slackbotv2', () => { team: TEAM_ID, ts: firstMention.ts, thread_ts: parent.ts, - text: `<@${BOT_USER_ID}> --claude --model=fable\nfirst pass` + text: `<@${BOT_USER_ID}> --claude --model=fable\n\nfirst pass` } }), {}, diff --git a/services/slackbotv2/test/overrides.test.ts b/services/slackbotv2/test/overrides.test.ts index 34b2ca77c..d408a46df 100644 --- a/services/slackbotv2/test/overrides.test.ts +++ b/services/slackbotv2/test/overrides.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import { SlackFormatConverter } from '@chat-adapter/slack' import { extractMessageOverrides } from '../src/overrides' describe('extractMessageOverrides', () => { @@ -223,3 +224,44 @@ describe('extractMessageOverrides', () => { expect(extractMessageOverrides('the --bedrock flag').provider).toBe('amazon-bedrock') }) }) + +// The adapter's plain-text extraction feeds extractMessageOverrides. The +// unpatched @chat-adapter/slack flattened the parsed AST with +// mdast-util-to-string, which joins sibling paragraphs with NO separator — +// `--model=fable\n\nexamine ...` reached the parser as `--model=fableexamine +// ...` and the harness got a nonexistent model. The patched converter +// preserves block boundaries; these tests exercise the real pipeline. +describe('SlackFormatConverter.extractPlainText + extractMessageOverrides', () => { + const converter = new SlackFormatConverter() + + test('paragraph break after --model survives plain-text extraction', () => { + const mrkdwn = + '--claude --model=fable\n\nexamine . cross reference that PR.' + const text = converter.extractPlainText(mrkdwn) + expect(text).toBe( + '--claude --model=fable\n\nexamine github.com/paradigmxyz/centaur/pull/921. cross reference that PR.' + ) + expect(extractMessageOverrides(text)).toEqual({ + cleanedText: 'examine github.com/paradigmxyz/centaur/pull/921. cross reference that PR.', + harnessType: 'claudecode', + model: 'claude-fable-5', + reasoning: undefined + }) + }) + + test('single newlines and paragraph breaks are both preserved', () => { + expect(converter.extractPlainText('--model=fable\nexamine this')).toBe( + '--model=fable\nexamine this' + ) + expect(converter.extractPlainText('line1\n\nline2\nline3')).toBe('line1\n\nline2\nline3') + }) + + test('list items and blockquotes keep line boundaries', () => { + expect(converter.extractPlainText('- item1\n- item2\n\nafter list')).toBe( + 'item1\nitem2\n\nafter list' + ) + expect(converter.extractPlainText('> quoted line\n\nafter quote')).toBe( + 'quoted line\n\nafter quote' + ) + }) +}) From a503b4b069d0a3aad2863fafb24f96cf022a56b8 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:17:54 +0300 Subject: [PATCH 069/198] fix(slackbotv2): retry retryable handoff failures in-process instead of relying on Slack redelivery (#931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(console): serve stale proxy sync snapshots while one session rebuilds Every iron-proxy polls /api/v1/proxy/sync on a 5s cadence. When a principal's snapshot went stale (10min TTL) or its cache version was bumped (e.g. broker credential refresh fans out to every referencing principal), all of that principal's proxies stampeded into PrincipalSyncConfigSnapshot.build_for and queued on the principal row lock, each holding a Puma thread and DB connection for the full effective_config rebuild. With one busy Slack channel principal shared by 30+ sandboxes, every invalidation wave saturated all console threads, starved the /up probe, and kubelet killed all replicas at once (prd-centaur-na outage, 2026-07-07). fetch_for is now stale-while-revalidate: exactly one caller rebuilds under a non-blocking FOR UPDATE SKIP LOCKED row lock; concurrent callers are served the stale current-version snapshot, or the newest previous-version snapshot after a cache bump. Serving stale is safe: iron-proxy treats the config hash as an ETag and re-applies on its next poll. Only a cold start (no snapshot at any version) still blocks. Also fixes a rebuild-churn bug the new tests exposed: a rebuild that produced a byte-identical payload no-op'd on save!, so updated_at never advanced, the snapshot stayed permanently stale, and every poll re-ran the expensive effective_config rebuild. build_within_lock now touches the row to restart the TTL. Amp-Thread-ID: https://ampcode.com/threads/T-019f3cf4-8484-71a8-a459-5286174dc20b Co-authored-by: Amp * fix(slackbotv2): retry retryable handoff failures in-process instead of relying on Slack redelivery Slack only redelivers webhook events when the handler fails within ~3s, so the old design (return 503, clear dedupe, wait for redelivery) silently dropped prompts whenever a retryable session API failure surfaced after a slow create/load — as seen during centaur-console instability. Retryable handoff failures now schedule local retries (5s/30s/120s) while Slack gets an immediate 200. The assistant status stays visible through the retry window; exhaustion renders the visible error notice. If another mention starts an execution before a retry fires, the retry conflates into it (the message is already appended to the session), matching healthy-path semantics for near-simultaneous mentions. Replaces slackbotv2_webhook_retry_requests_total with slackbotv2_handoff_retries_total{outcome}. Amp-Thread-ID: https://ampcode.com/threads/T-019f3cd5-b870-7386-8c36-e28a23ec5808 Co-authored-by: Amp --------- Co-authored-by: Amp --- .../models/principal_sync_config_snapshot.rb | 64 ++++-- .../principal_sync_config_snapshot_test.rb | 112 +++++++++++ services/slackbotv2/src/index.ts | 146 ++++++++------ services/slackbotv2/src/metrics.ts | 9 +- services/slackbotv2/src/types.ts | 9 + .../slackbotv2/test/chat-sdk-emulate.test.ts | 183 ++++++++++++++---- 6 files changed, 417 insertions(+), 106 deletions(-) create mode 100644 services/console/test/models/principal_sync_config_snapshot_test.rb diff --git a/services/console/app/models/principal_sync_config_snapshot.rb b/services/console/app/models/principal_sync_config_snapshot.rb index 1c09bb9f0..72b8f168d 100644 --- a/services/console/app/models/principal_sync_config_snapshot.rb +++ b/services/console/app/models/principal_sync_config_snapshot.rb @@ -10,12 +10,25 @@ class PrincipalSyncConfigSnapshot < ApplicationRecord validates :principal_cache_version, presence: true validates :principal_id, uniqueness: { scope: :principal_cache_version } + # Returns the freshest usable snapshot, stale-while-revalidate style. When + # the current-version snapshot is stale or missing, exactly one caller + # rebuilds it (non-blocking row lock on the principal); concurrent callers + # are served the stale snapshot immediately instead of queuing behind the + # rebuild. Config invalidations fan out to every proxy of a principal at + # once (cache-version bumps, TTL expiry), so blocking here previously + # stampeded all of them onto one row lock, each holding a request thread + # and DB connection for the full rebuild. + # + # Serving a stale snapshot is safe: iron-proxy treats the config hash as an + # ETag and re-applies on its next 5s poll once the rebuild lands. Only a + # cold start (no snapshot at any version) blocks until the build finishes, + # because there is nothing stale to serve. def self.fetch_for(principal) version = principal.sync_config_cache_version snapshot = find_by(principal: principal, principal_cache_version: version) return snapshot if snapshot&.fresh? - build_for(principal) + try_build_for(principal) || snapshot || latest_for(principal) || build_for(principal) end def self.prune_expired! @@ -26,19 +39,48 @@ def fresh? updated_at >= TTL.ago end + # Most recent snapshot at any cache version; the stale fallback while + # another session rebuilds. Old versions survive until prune_expired! + # (RETENTION), which comfortably covers a rebuild. + def self.latest_for(principal) + where(principal: principal).order(updated_at: :desc).first + end + def self.build_for(principal) - principal.with_lock do - principal.reload - version = principal.sync_config_cache_version - snapshot = find_or_initialize_by(principal: principal, principal_cache_version: version) - return snapshot if snapshot.persisted? && snapshot.fresh? - - config = principal.effective_config(redact_secrets: false) - snapshot.payload = config - snapshot.save! - snapshot + principal.with_lock { build_within_lock(principal) } + rescue ActiveRecord::RecordNotUnique + retry + end + + # Non-blocking variant of build_for: acquires the principal row lock with + # SKIP LOCKED and returns nil when another session already holds it. + def self.try_build_for(principal) + transaction do + locked = Principal.lock("FOR UPDATE SKIP LOCKED").find_by(id: principal.id) + next nil unless locked + + build_within_lock(locked) end rescue ActiveRecord::RecordNotUnique retry end + + # Assumes the caller holds the principal's row lock and passes the freshly + # locked (reloaded) record, so sync_config_cache_version is current. + def self.build_within_lock(principal) + version = principal.sync_config_cache_version + snapshot = find_or_initialize_by(principal: principal, principal_cache_version: version) + return snapshot if snapshot.persisted? && snapshot.fresh? + + snapshot.payload = principal.effective_config(redact_secrets: false) + if snapshot.changed? + snapshot.save! + else + # A rebuild that yields an identical payload must still restart the TTL, + # or the snapshot stays permanently stale and every poll re-runs the + # expensive effective_config rebuild. + snapshot.touch + end + snapshot + end end diff --git a/services/console/test/models/principal_sync_config_snapshot_test.rb b/services/console/test/models/principal_sync_config_snapshot_test.rb new file mode 100644 index 000000000..55cb43dad --- /dev/null +++ b/services/console/test/models/principal_sync_config_snapshot_test.rb @@ -0,0 +1,112 @@ +require "test_helper" + +class PrincipalSyncConfigSnapshotTest < ActiveSupport::TestCase + setup do + @principal = principals(:acme_channel) + end + + # Simulates losing the non-blocking rebuild race: another session holds the + # principal row lock, so try_build_for's SKIP LOCKED select comes back empty. + # (Real cross-session lock contention is not reproducible under transactional + # tests, where all sessions share one connection.) + def while_rebuild_lock_held + singleton = PrincipalSyncConfigSnapshot.singleton_class + original = PrincipalSyncConfigSnapshot.method(:try_build_for) + singleton.define_method(:try_build_for) { |_principal| nil } + yield + ensure + singleton.define_method(:try_build_for, original) + end + + test "fetch_for builds a snapshot on cold start" do + assert_difference -> { PrincipalSyncConfigSnapshot.count }, 1 do + snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) + assert_equal @principal.sync_config_cache_version, snapshot.principal_cache_version + assert_equal @principal.effective_config(redact_secrets: false), snapshot.payload + end + end + + test "fetch_for returns the fresh snapshot without rebuilding" do + snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) + + assert_no_difference -> { PrincipalSyncConfigSnapshot.count } do + assert_equal snapshot, PrincipalSyncConfigSnapshot.fetch_for(@principal) + end + assert_equal snapshot.updated_at, snapshot.reload.updated_at + end + + test "fetch_for rebuilds a snapshot stale past TTL" do + snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) + stale_time = (PrincipalSyncConfigSnapshot::TTL + 1.minute).ago + snapshot.update_columns(updated_at: stale_time) + + refreshed = PrincipalSyncConfigSnapshot.fetch_for(@principal) + assert_equal snapshot.id, refreshed.id + assert refreshed.fresh? + end + + test "fetch_for builds a new snapshot after a cache version bump" do + old = PrincipalSyncConfigSnapshot.fetch_for(@principal) + Principal.bump_sync_config_cache_versions(@principal.id) + @principal.reload + + fresh = PrincipalSyncConfigSnapshot.fetch_for(@principal) + refute_equal old.id, fresh.id + assert_equal @principal.sync_config_cache_version, fresh.principal_cache_version + end + + # The stampede regression: when another session holds the rebuild lock, + # fetch_for must serve the stale current-version snapshot instead of + # queuing behind the row lock. + test "fetch_for serves the stale snapshot while another session rebuilds" do + snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) + stale_time = (PrincipalSyncConfigSnapshot::TTL + 1.minute).ago + snapshot.update_columns(updated_at: stale_time) + + while_rebuild_lock_held do + assert_no_difference -> { PrincipalSyncConfigSnapshot.count } do + served = PrincipalSyncConfigSnapshot.fetch_for(@principal) + assert_equal snapshot.id, served.id + refute served.fresh? + end + end + end + + test "fetch_for serves the previous-version snapshot while another session rebuilds after a bump" do + old = PrincipalSyncConfigSnapshot.fetch_for(@principal) + Principal.bump_sync_config_cache_versions(@principal.id) + @principal.reload + + while_rebuild_lock_held do + assert_no_difference -> { PrincipalSyncConfigSnapshot.count } do + served = PrincipalSyncConfigSnapshot.fetch_for(@principal) + assert_equal old.id, served.id + refute_equal @principal.sync_config_cache_version, served.principal_cache_version + end + end + end + + test "fetch_for falls back to a blocking build on cold start when the non-blocking build loses" do + while_rebuild_lock_held do + assert_difference -> { PrincipalSyncConfigSnapshot.count }, 1 do + snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) + assert_equal @principal.sync_config_cache_version, snapshot.principal_cache_version + end + end + end + + test "try_build_for builds when the principal row lock is free" do + assert_difference -> { PrincipalSyncConfigSnapshot.count }, 1 do + snapshot = PrincipalSyncConfigSnapshot.try_build_for(@principal) + assert_equal @principal.sync_config_cache_version, snapshot.principal_cache_version + end + end + + test "try_build_for returns the existing snapshot when already fresh" do + snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) + + assert_no_difference -> { PrincipalSyncConfigSnapshot.count } do + assert_equal snapshot, PrincipalSyncConfigSnapshot.try_build_for(@principal) + end + end +end diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 05d185505..362124e17 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -106,7 +106,6 @@ type SlackAssistantAdapter = { const MAX_SLACK_MESSAGE_ATTACHMENTS = 20 type SlackbotV2RequestContext = { - retryableErrors: unknown[] waitUntil(promise: Promise): void } @@ -126,6 +125,7 @@ const SLACK_TASK_DETAILS_MAX_CHARS = 500 const SLACK_FALLBACK_TEXT_MAX_CHARS = 35_000 const POSTGRES_CONNECT_INITIAL_DELAY_MS = 250 const POSTGRES_CONNECT_MAX_DELAY_MS = 10_000 +const HANDOFF_RETRY_DELAYS_MS: readonly number[] = [5_000, 30_000, 120_000] const LATE_SLACK_FILE_MATCH_WINDOW_MS = 15_000 const LATE_SLACK_FILE_PENDING_TTL_MS = 60_000 const LATE_SLACK_FILE_CONSUMED_TTL_MS = 5 * 60_000 @@ -253,7 +253,6 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { const webhookFields = slackWebhookLogFields(rawBody) const handoffTasks: Promise[] = [] const context: SlackbotV2RequestContext = { - retryableErrors: [], waitUntil: promise => waitUntil(c, promise) } const response = await requestContext.run(context, () => { @@ -287,24 +286,14 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { await Promise.all(handoffTasks) } catch (error) { waitError = error - if (isRetryableSessionApiError(error)) context.retryableErrors.push(error) } finally { stopPendingLog() traceLog(options, 'slackbotv2_webhook_handoff_wait_complete', undefined, { ...waitFields, error: waitError ? errorMessage(waitError) : undefined, - phase_ms: elapsedMs(waitStartedAtMs), - retryable_error_count: context.retryableErrors.length + phase_ms: elapsedMs(waitStartedAtMs) }) } - if (context.retryableErrors.length > 0) { - outcome = 'retry_requested' - slackbotMetrics.webhookRetryRequests.inc() - traceLog(options, 'slackbotv2_webhook_retry_requested', undefined, { - error: errorMessage(context.retryableErrors[0]) - }) - return new globalThis.Response('temporary upstream unavailable', { status: 503 }) - } } const lateFileTask = lateSlackFiles.repairFromWebhook(rawBody) if (lateFileTask) waitUntil(c, lateFileTask) @@ -617,6 +606,68 @@ async function ensureStateConnected(state: StateAdapter, options: SlackbotV2Opti } } +type SyncThreadMessageInput = { + initialAssistantStatusRequested?: boolean + initialAssistantStatusVisible?: boolean + mode: SlackbotV2MessageMode + options: SlackbotV2Options + /** Number of in-process retries already spent on this message's handoff. */ + retryAttempt?: number + state: StateAdapter +} + +/** + * Schedules an in-process retry of a Slack→session handoff after a retryable + * session API failure. Slack's own webhook redelivery cannot drive retries: + * Slack times deliveries out after ~3s, so its redelivery races the + * still-running original attempt, is deduped by the chat SDK, and is + * acknowledged before the original attempt fails. Retrying locally keeps the + * dedupe intact and never depends on Slack redelivering. + * + * Returns false when the retry budget is exhausted; the caller then surfaces + * the failure instead of retrying. + */ +function scheduleHandoffRetry( + thread: Thread, + message: ChatMessage, + input: SyncThreadMessageInput, + error: unknown, + trace: SlackbotV2Trace +): boolean { + const delays = input.options.handoffRetryDelaysMs ?? HANDOFF_RETRY_DELAYS_MS + const attempt = input.retryAttempt ?? 0 + if (attempt >= delays.length) return false + const delayMs = delays[attempt] ?? 0 + slackbotMetrics.handoffRetries.inc({ outcome: 'scheduled' }) + traceLog(input.options, 'slackbotv2_handoff_retry_scheduled', trace, { + attempt: attempt + 1, + delay_ms: delayMs, + error: errorMessage(error), + max_attempts: delays.length + }) + backgroundWaitUntil( + (async () => { + await sleep(delayMs) + await syncThreadMessageToSession(thread, message, { ...input, retryAttempt: attempt + 1 }) + })().catch(async retryError => { + traceWarn(input.options, 'slackbotv2_handoff_retry_failed', trace, { + attempt: attempt + 1, + error: errorMessage(retryError) + }) + // A retry chain that dies outside the normal failure paths (which clear + // the status themselves) must not leave "Thinking..." stuck on the thread. + if (input.mode === 'execute') { + try { + await setAssistantStatus(thread, '', input.options, trace) + } catch { + // Best-effort; the original failure is already logged. + } + } + }) + ) + return true +} + /** * Persists a Slack thread update into the session API. In execute mode the create/append/execute * handoff completes before Slack is acknowledged; SSE rendering continues in background. @@ -624,13 +675,7 @@ async function ensureStateConnected(state: StateAdapter, options: SlackbotV2Opti async function syncThreadMessageToSession( thread: Thread, message: ChatMessage, - input: { - initialAssistantStatusRequested?: boolean - initialAssistantStatusVisible?: boolean - mode: SlackbotV2MessageMode - options: SlackbotV2Options - state: StateAdapter - } + input: SyncThreadMessageInput ): Promise { const traceStartedAtMs = nowMs() const state = (await thread.state) ?? {} @@ -880,30 +925,21 @@ async function syncThreadMessageToSession( } } catch (error) { if (isRetryableSessionApiError(error)) { - const context = requestContext.getStore() - if (context) { - context.retryableErrors.push(error) - try { - await input.state.delete(`dedupe:slack:${message.id}`) - } catch (deleteError) { - traceLog(input.options, 'slackbotv2_webhook_retry_dedupe_clear_failed', trace, { - error: errorMessage(deleteError) - }) - } - traceLog(input.options, 'slackbotv2_webhook_retry_marked', trace, { - error: errorMessage(error) - }) + if (scheduleHandoffRetry(thread, message, input, error, trace)) { + recordForward(input.mode, 'retry_scheduled', traceStartedAtMs) + return } + slackbotMetrics.handoffRetries.inc({ outcome: 'exhausted' }) + traceWarn(input.options, 'slackbotv2_handoff_retry_exhausted', trace, { + error: errorMessage(error) + }) } - recordForward( - input.mode, - isRetryableSessionApiError(error) ? 'retry_requested' : 'error', - traceStartedAtMs - ) + recordForward(input.mode, 'error', traceStartedAtMs) throw error } traceLog(input.options, 'slackbotv2_forward_complete', trace) recordForward(input.mode, 'complete', traceStartedAtMs) + if (input.retryAttempt) slackbotMetrics.handoffRetries.inc({ outcome: 'succeeded' }) return } @@ -930,6 +966,7 @@ async function syncThreadMessageToSession( last_event_id: lastEventId }) recordForward(input.mode, 'complete', traceStartedAtMs) + if (input.retryAttempt) slackbotMetrics.handoffRetries.inc({ outcome: 'succeeded' }) } catch (error) { // The live render is not happening; let the recovery sweep claim the // obligation (if one was committed) as soon as it scans. @@ -940,23 +977,24 @@ async function syncThreadMessageToSession( lastEventId: Math.max(latest.lastEventId ?? 0, lastEventId) }) if (isRetryableSessionApiError(error)) { - const context = requestContext.getStore() - if (context) { - context.retryableErrors.push(error) - try { - await input.state.delete(`dedupe:slack:${message.id}`) - } catch (deleteError) { - traceLog(input.options, 'slackbotv2_webhook_retry_dedupe_clear_failed', trace, { - error: errorMessage(deleteError) - }) - } - traceLog(input.options, 'slackbotv2_webhook_retry_marked', trace, { - error: errorMessage(error) - }) - if (assistantStatusVisible) await setAssistantStatus(thread, '', input.options, trace) - recordForward(input.mode, 'retry_requested', traceStartedAtMs) - throw error + // The assistant status stays visible through the retry window; a + // successful retry replaces it with the live render, and exhaustion + // falls through to the visible error notice below (which clears it). + // + // If another mention starts an execution before the retry fires, the + // retry recomputes eligibility and downgrades to append/no-op. That is + // intentional: this message was already appended (or will be appended) + // to the session, so the newer execution sees it — the same conflation + // that happens when two mentions arrive seconds apart on a healthy + // system. The thread is never left silent in that case. + if (scheduleHandoffRetry(thread, message, input, error, trace)) { + recordForward(input.mode, 'retry_scheduled', traceStartedAtMs) + return } + slackbotMetrics.handoffRetries.inc({ outcome: 'exhausted' }) + traceWarn(input.options, 'slackbotv2_handoff_retry_exhausted', trace, { + error: errorMessage(error) + }) } try { await renderExecutionStream( diff --git a/services/slackbotv2/src/metrics.ts b/services/slackbotv2/src/metrics.ts index 6785316b6..8ad0c4d52 100644 --- a/services/slackbotv2/src/metrics.ts +++ b/services/slackbotv2/src/metrics.ts @@ -249,6 +249,11 @@ export const slackbotMetrics = { labelNames: ['mode', 'outcome'], name: 'slackbotv2_forward_messages_total' }), + handoffRetries: counter({ + help: 'In-process Slack handoff retries after retryable session API failures.', + labelNames: ['outcome'], + name: 'slackbotv2_handoff_retries_total' + }), info: gauge({ help: 'Static Slackbot v2 service info.', name: 'slackbotv2_info' @@ -342,10 +347,6 @@ export const slackbotMetrics = { help: 'Slack webhook requests handled by Slackbot.', labelNames: ['route', 'event_type', 'outcome'], name: 'slackbotv2_slack_webhook_requests_total' - }), - webhookRetryRequests: counter({ - help: 'Slack webhook requests answered with a retryable error so Slack retries delivery.', - name: 'slackbotv2_webhook_retry_requests_total' }) } diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 6fb2c64ae..b05c8e5ff 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -133,6 +133,15 @@ export type SlackbotV2Options = { * harness config files (see console-session-link.ts). */ harnessDefaultModels?: Record + /** + * Backoff delays between in-process retries of a Slack handoff after a + * retryable session API failure. Slack's own webhook redelivery cannot + * drive these retries: Slack times deliveries out after ~3s, so its + * redelivery races the still-running original attempt, is deduped, and is + * acknowledged before the original attempt fails. The bot retries locally + * instead and posts a visible error once the delays are exhausted. + */ + handoffRetryDelaysMs?: readonly number[] /** Milliseconds before an idle execution pauses its sandbox. Defaults to up to 3h. */ idleTimeoutMs?: number logger?: Logger diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 921c445ee..03ce53c66 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -3286,7 +3286,6 @@ describe('slackbotv2', () => { expect(logData(logs, 'slackbotv2_webhook_handoff_wait_complete')).toEqual( expect.objectContaining({ phase_ms: expect.any(Number), - retryable_error_count: 0, slack_event_id: 'Ev-slackbotv2-slow-execute' }) ) @@ -3908,7 +3907,8 @@ describe('slackbotv2', () => { expect(Number(recoveredThreadState?.lastEventId)).toBeGreaterThan(0) }) - it('returns 503 for retryable execute failure and lets Slack retry without duplicate append', async () => { + it('locally retries a retryable execute failure without duplicate append', async () => { + bot = createTestBot({ handoffRetryDelaysMs: [50] }) codexApi.failNextExecute = true const parent = await postUserMessage('History that must not be lost.') @@ -3925,40 +3925,46 @@ describe('slackbotv2', () => { text: `<@${BOT_USER_ID}> first try` } }) - const failedWaits: Promise[] = [] - const failedResponse = await bot.app.request( + const waits: Promise[] = [] + const response = await bot.app.request( '/api/webhooks/slack', retryableEvent, {}, - waitUntilContext(failedWaits) + waitUntilContext(waits) ) - expect(failedResponse.status).toBe(503) - await Promise.all(failedWaits) + // The retryable failure is retried in-process; Slack is acknowledged so + // its own redelivery (which would be deduped anyway) is never needed. + expect(response.status).toBe(200) expect(codexApi.appends).toHaveLength(1) expect(codexApi.executes).toHaveLength(1) expect(codexApi.eventRequests).toHaveLength(0) - expect(slackApi.calls.some(call => call.method === 'chat.startStream')).toBe(false) - const retryWaits: Promise[] = [] - const retryResponse = await bot.app.request( - '/api/webhooks/slack', - retryableEvent, - {}, - waitUntilContext(retryWaits) - ) - expect(retryResponse.status).toBe(200) - await Promise.all(retryWaits) + await waitFor(() => codexApi.executes.length === 2, 3000) + await waitFor(async () => (await threadText(parent.ts)).includes('Executed request 1.'), 3000) + await Promise.all(waits) - expect(codexApi.executes).toHaveLength(2) expect(codexApi.appends).toHaveLength(1) const retryContextTexts = sessionMessageTexts(codexApi.appends[0]?.body.messages ?? []) expect(retryContextTexts).toContain('History that must not be lost.') expect(retryContextTexts.some(text => text.includes('first try'))).toBe(true) expect(codexApi.eventRequests).toHaveLength(1) - expect(await threadText(parent.ts)).toContain('Executed request 1.') + + // A late Slack redelivery of the same event stays deduped and adds no work. + const redeliveryWaits: Promise[] = [] + const redeliveryResponse = await bot.app.request( + '/api/webhooks/slack', + retryableEvent, + {}, + waitUntilContext(redeliveryWaits) + ) + expect(redeliveryResponse.status).toBe(200) + await Promise.all(redeliveryWaits) + expect(codexApi.executes).toHaveLength(2) + expect(codexApi.appends).toHaveLength(1) }) - it('reuses an accepted execution when Slack retries after a lost execute response', async () => { + it('reuses an accepted execution when the local retry follows a lost execute response', async () => { + bot = createTestBot({ handoffRetryDelaysMs: [50] }) codexApi.failNextExecuteAfterAccept = true const parent = await postUserMessage('History before response loss.') @@ -3975,40 +3981,143 @@ describe('slackbotv2', () => { text: `<@${BOT_USER_ID}> first try accepted` } }) - const failedWaits: Promise[] = [] - const failedResponse = await bot.app.request( + const waits: Promise[] = [] + const response = await bot.app.request( '/api/webhooks/slack', retryableEvent, {}, - waitUntilContext(failedWaits) + waitUntilContext(waits) ) - expect(failedResponse.status).toBe(503) - await Promise.all(failedWaits) + expect(response.status).toBe(200) expect(codexApi.executes).toHaveLength(1) expect(codexApi.eventRequests).toHaveLength(0) - expect(slackApi.calls.some(call => call.method === 'chat.startStream')).toBe(false) - const retryWaits: Promise[] = [] - const retryResponse = await bot.app.request( - '/api/webhooks/slack', - retryableEvent, - {}, - waitUntilContext(retryWaits) - ) - expect(retryResponse.status).toBe(200) - await Promise.all(retryWaits) + await waitFor(() => codexApi.executes.length === 2, 3000) + await waitFor(async () => (await threadText(parent.ts)).includes('Executed request 1.'), 3000) + await Promise.all(waits) - expect(codexApi.executes).toHaveLength(2) expect(codexApi.executes.map(execute => execute.body.idempotency_key)).toEqual([ mention.ts, mention.ts ]) expect(codexApi.appends).toHaveLength(1) expect(codexApi.eventRequests).toHaveLength(1) - expect(await threadText(parent.ts)).toContain('Executed request 1.') expect(await threadText(parent.ts)).not.toContain('Executed request 2.') }) + it('conflates a pending execute retry into an execution started meanwhile', async () => { + bot = createTestBot({ handoffRetryDelaysMs: [300] }) + codexApi.failNextExecute = true + + const parent = await postUserMessage('History before conflation.') + const firstMention = await postUserMessage(`<@${BOT_USER_ID}> first conflated mention`, parent.ts) + const firstWaits: Promise[] = [] + const firstResponse = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-conflate-1', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: firstMention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> first conflated mention` + } + }), + {}, + waitUntilContext(firstWaits) + ) + expect(firstResponse.status).toBe(200) + expect(codexApi.executes).toHaveLength(1) + + // A second mention lands while the first message's retry is still pending + // and starts the thread's execution. Keep it running (no auto response) + // across the retry window. + codexApi.autoRespond = false + const secondMention = await postUserMessage( + `<@${BOT_USER_ID}> second conflated mention`, + parent.ts + ) + const secondWaits: Promise[] = [] + const secondResponse = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-conflate-2', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: secondMention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> second conflated mention` + } + }), + {}, + waitUntilContext(secondWaits) + ) + expect(secondResponse.status).toBe(200) + expect(codexApi.executes).toHaveLength(2) + + // The first message's retry fires into the active execution and must not + // start a third execution; its text is already in the session, so the + // running execution sees it. + await sleep(500) + expect(codexApi.executes).toHaveLength(2) + const appendedTexts = codexApi.appends.flatMap(append => + sessionMessageTexts(append.body.messages ?? []) + ) + expect(appendedTexts.some(text => text.includes('first conflated mention'))).toBe(true) + expect(appendedTexts.some(text => text.includes('second conflated mention'))).toBe(true) + + codexApi.emitOutputLines(threadKey(parent.ts), sampleCodexOutputLines('Conflated answer.')) + await Promise.all([...firstWaits, ...secondWaits]) + expect(await threadText(parent.ts)).toContain('Conflated answer.') + expect(await threadText(parent.ts)).not.toContain(BROKEN_STREAM_TEXT) + }) + + it('renders a visible error once local retries are exhausted', async () => { + bot = createTestBot({ handoffRetryDelaysMs: [200] }) + codexApi.failNextExecute = true + + const parent = await postUserMessage('History before exhaustion.') + const mention = await postUserMessage(`<@${BOT_USER_ID}> exhaust retries`, parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-retry-exhausted', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> exhaust retries` + } + }), + {}, + waitUntilContext(waits) + ) + expect(response.status).toBe(200) + expect(codexApi.executes).toHaveLength(1) + + // Fail the scheduled retry too so the budget of one retry is exhausted. + codexApi.failNextExecute = true + await waitFor(() => codexApi.executes.length === 2, 3000) + await waitFor(async () => (await threadText(parent.ts)).includes('Execution failed'), 3000) + await Promise.all(waits) + + expect(codexApi.eventRequests).toHaveLength(0) + const threadState = await bot.chat + .thread(threadKey(parent.ts)) + .state + expect(threadState).toEqual(expect.objectContaining({ activeExecution: false })) + }) + it('keeps v1 external org and trigger-bot allowlist behavior', async () => { const externalMention = await postUserMessage(`<@${BOT_USER_ID}> from external org`) const externalWaits: Promise[] = [] From dc28f76bff73ea0b33bf6889bfd4bd5baae18091 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:20:56 +0300 Subject: [PATCH 070/198] Revert "fix(slackbotv2): retry retryable handoff failures in-process instead of relying on Slack redelivery" (#934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "fix(slackbotv2): retry retryable handoff failures in-process instead …" This reverts commit a503b4b069d0a3aad2863fafb24f96cf022a56b8. --- .../models/principal_sync_config_snapshot.rb | 64 ++---- .../principal_sync_config_snapshot_test.rb | 112 ----------- services/slackbotv2/src/index.ts | 146 ++++++-------- services/slackbotv2/src/metrics.ts | 9 +- services/slackbotv2/src/types.ts | 9 - .../slackbotv2/test/chat-sdk-emulate.test.ts | 183 ++++-------------- 6 files changed, 106 insertions(+), 417 deletions(-) delete mode 100644 services/console/test/models/principal_sync_config_snapshot_test.rb diff --git a/services/console/app/models/principal_sync_config_snapshot.rb b/services/console/app/models/principal_sync_config_snapshot.rb index 72b8f168d..1c09bb9f0 100644 --- a/services/console/app/models/principal_sync_config_snapshot.rb +++ b/services/console/app/models/principal_sync_config_snapshot.rb @@ -10,25 +10,12 @@ class PrincipalSyncConfigSnapshot < ApplicationRecord validates :principal_cache_version, presence: true validates :principal_id, uniqueness: { scope: :principal_cache_version } - # Returns the freshest usable snapshot, stale-while-revalidate style. When - # the current-version snapshot is stale or missing, exactly one caller - # rebuilds it (non-blocking row lock on the principal); concurrent callers - # are served the stale snapshot immediately instead of queuing behind the - # rebuild. Config invalidations fan out to every proxy of a principal at - # once (cache-version bumps, TTL expiry), so blocking here previously - # stampeded all of them onto one row lock, each holding a request thread - # and DB connection for the full rebuild. - # - # Serving a stale snapshot is safe: iron-proxy treats the config hash as an - # ETag and re-applies on its next 5s poll once the rebuild lands. Only a - # cold start (no snapshot at any version) blocks until the build finishes, - # because there is nothing stale to serve. def self.fetch_for(principal) version = principal.sync_config_cache_version snapshot = find_by(principal: principal, principal_cache_version: version) return snapshot if snapshot&.fresh? - try_build_for(principal) || snapshot || latest_for(principal) || build_for(principal) + build_for(principal) end def self.prune_expired! @@ -39,48 +26,19 @@ def fresh? updated_at >= TTL.ago end - # Most recent snapshot at any cache version; the stale fallback while - # another session rebuilds. Old versions survive until prune_expired! - # (RETENTION), which comfortably covers a rebuild. - def self.latest_for(principal) - where(principal: principal).order(updated_at: :desc).first - end - def self.build_for(principal) - principal.with_lock { build_within_lock(principal) } - rescue ActiveRecord::RecordNotUnique - retry - end - - # Non-blocking variant of build_for: acquires the principal row lock with - # SKIP LOCKED and returns nil when another session already holds it. - def self.try_build_for(principal) - transaction do - locked = Principal.lock("FOR UPDATE SKIP LOCKED").find_by(id: principal.id) - next nil unless locked - - build_within_lock(locked) + principal.with_lock do + principal.reload + version = principal.sync_config_cache_version + snapshot = find_or_initialize_by(principal: principal, principal_cache_version: version) + return snapshot if snapshot.persisted? && snapshot.fresh? + + config = principal.effective_config(redact_secrets: false) + snapshot.payload = config + snapshot.save! + snapshot end rescue ActiveRecord::RecordNotUnique retry end - - # Assumes the caller holds the principal's row lock and passes the freshly - # locked (reloaded) record, so sync_config_cache_version is current. - def self.build_within_lock(principal) - version = principal.sync_config_cache_version - snapshot = find_or_initialize_by(principal: principal, principal_cache_version: version) - return snapshot if snapshot.persisted? && snapshot.fresh? - - snapshot.payload = principal.effective_config(redact_secrets: false) - if snapshot.changed? - snapshot.save! - else - # A rebuild that yields an identical payload must still restart the TTL, - # or the snapshot stays permanently stale and every poll re-runs the - # expensive effective_config rebuild. - snapshot.touch - end - snapshot - end end diff --git a/services/console/test/models/principal_sync_config_snapshot_test.rb b/services/console/test/models/principal_sync_config_snapshot_test.rb deleted file mode 100644 index 55cb43dad..000000000 --- a/services/console/test/models/principal_sync_config_snapshot_test.rb +++ /dev/null @@ -1,112 +0,0 @@ -require "test_helper" - -class PrincipalSyncConfigSnapshotTest < ActiveSupport::TestCase - setup do - @principal = principals(:acme_channel) - end - - # Simulates losing the non-blocking rebuild race: another session holds the - # principal row lock, so try_build_for's SKIP LOCKED select comes back empty. - # (Real cross-session lock contention is not reproducible under transactional - # tests, where all sessions share one connection.) - def while_rebuild_lock_held - singleton = PrincipalSyncConfigSnapshot.singleton_class - original = PrincipalSyncConfigSnapshot.method(:try_build_for) - singleton.define_method(:try_build_for) { |_principal| nil } - yield - ensure - singleton.define_method(:try_build_for, original) - end - - test "fetch_for builds a snapshot on cold start" do - assert_difference -> { PrincipalSyncConfigSnapshot.count }, 1 do - snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) - assert_equal @principal.sync_config_cache_version, snapshot.principal_cache_version - assert_equal @principal.effective_config(redact_secrets: false), snapshot.payload - end - end - - test "fetch_for returns the fresh snapshot without rebuilding" do - snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) - - assert_no_difference -> { PrincipalSyncConfigSnapshot.count } do - assert_equal snapshot, PrincipalSyncConfigSnapshot.fetch_for(@principal) - end - assert_equal snapshot.updated_at, snapshot.reload.updated_at - end - - test "fetch_for rebuilds a snapshot stale past TTL" do - snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) - stale_time = (PrincipalSyncConfigSnapshot::TTL + 1.minute).ago - snapshot.update_columns(updated_at: stale_time) - - refreshed = PrincipalSyncConfigSnapshot.fetch_for(@principal) - assert_equal snapshot.id, refreshed.id - assert refreshed.fresh? - end - - test "fetch_for builds a new snapshot after a cache version bump" do - old = PrincipalSyncConfigSnapshot.fetch_for(@principal) - Principal.bump_sync_config_cache_versions(@principal.id) - @principal.reload - - fresh = PrincipalSyncConfigSnapshot.fetch_for(@principal) - refute_equal old.id, fresh.id - assert_equal @principal.sync_config_cache_version, fresh.principal_cache_version - end - - # The stampede regression: when another session holds the rebuild lock, - # fetch_for must serve the stale current-version snapshot instead of - # queuing behind the row lock. - test "fetch_for serves the stale snapshot while another session rebuilds" do - snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) - stale_time = (PrincipalSyncConfigSnapshot::TTL + 1.minute).ago - snapshot.update_columns(updated_at: stale_time) - - while_rebuild_lock_held do - assert_no_difference -> { PrincipalSyncConfigSnapshot.count } do - served = PrincipalSyncConfigSnapshot.fetch_for(@principal) - assert_equal snapshot.id, served.id - refute served.fresh? - end - end - end - - test "fetch_for serves the previous-version snapshot while another session rebuilds after a bump" do - old = PrincipalSyncConfigSnapshot.fetch_for(@principal) - Principal.bump_sync_config_cache_versions(@principal.id) - @principal.reload - - while_rebuild_lock_held do - assert_no_difference -> { PrincipalSyncConfigSnapshot.count } do - served = PrincipalSyncConfigSnapshot.fetch_for(@principal) - assert_equal old.id, served.id - refute_equal @principal.sync_config_cache_version, served.principal_cache_version - end - end - end - - test "fetch_for falls back to a blocking build on cold start when the non-blocking build loses" do - while_rebuild_lock_held do - assert_difference -> { PrincipalSyncConfigSnapshot.count }, 1 do - snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) - assert_equal @principal.sync_config_cache_version, snapshot.principal_cache_version - end - end - end - - test "try_build_for builds when the principal row lock is free" do - assert_difference -> { PrincipalSyncConfigSnapshot.count }, 1 do - snapshot = PrincipalSyncConfigSnapshot.try_build_for(@principal) - assert_equal @principal.sync_config_cache_version, snapshot.principal_cache_version - end - end - - test "try_build_for returns the existing snapshot when already fresh" do - snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) - - assert_no_difference -> { PrincipalSyncConfigSnapshot.count } do - assert_equal snapshot, PrincipalSyncConfigSnapshot.try_build_for(@principal) - end - end -end diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 362124e17..05d185505 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -106,6 +106,7 @@ type SlackAssistantAdapter = { const MAX_SLACK_MESSAGE_ATTACHMENTS = 20 type SlackbotV2RequestContext = { + retryableErrors: unknown[] waitUntil(promise: Promise): void } @@ -125,7 +126,6 @@ const SLACK_TASK_DETAILS_MAX_CHARS = 500 const SLACK_FALLBACK_TEXT_MAX_CHARS = 35_000 const POSTGRES_CONNECT_INITIAL_DELAY_MS = 250 const POSTGRES_CONNECT_MAX_DELAY_MS = 10_000 -const HANDOFF_RETRY_DELAYS_MS: readonly number[] = [5_000, 30_000, 120_000] const LATE_SLACK_FILE_MATCH_WINDOW_MS = 15_000 const LATE_SLACK_FILE_PENDING_TTL_MS = 60_000 const LATE_SLACK_FILE_CONSUMED_TTL_MS = 5 * 60_000 @@ -253,6 +253,7 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { const webhookFields = slackWebhookLogFields(rawBody) const handoffTasks: Promise[] = [] const context: SlackbotV2RequestContext = { + retryableErrors: [], waitUntil: promise => waitUntil(c, promise) } const response = await requestContext.run(context, () => { @@ -286,14 +287,24 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { await Promise.all(handoffTasks) } catch (error) { waitError = error + if (isRetryableSessionApiError(error)) context.retryableErrors.push(error) } finally { stopPendingLog() traceLog(options, 'slackbotv2_webhook_handoff_wait_complete', undefined, { ...waitFields, error: waitError ? errorMessage(waitError) : undefined, - phase_ms: elapsedMs(waitStartedAtMs) + phase_ms: elapsedMs(waitStartedAtMs), + retryable_error_count: context.retryableErrors.length }) } + if (context.retryableErrors.length > 0) { + outcome = 'retry_requested' + slackbotMetrics.webhookRetryRequests.inc() + traceLog(options, 'slackbotv2_webhook_retry_requested', undefined, { + error: errorMessage(context.retryableErrors[0]) + }) + return new globalThis.Response('temporary upstream unavailable', { status: 503 }) + } } const lateFileTask = lateSlackFiles.repairFromWebhook(rawBody) if (lateFileTask) waitUntil(c, lateFileTask) @@ -606,68 +617,6 @@ async function ensureStateConnected(state: StateAdapter, options: SlackbotV2Opti } } -type SyncThreadMessageInput = { - initialAssistantStatusRequested?: boolean - initialAssistantStatusVisible?: boolean - mode: SlackbotV2MessageMode - options: SlackbotV2Options - /** Number of in-process retries already spent on this message's handoff. */ - retryAttempt?: number - state: StateAdapter -} - -/** - * Schedules an in-process retry of a Slack→session handoff after a retryable - * session API failure. Slack's own webhook redelivery cannot drive retries: - * Slack times deliveries out after ~3s, so its redelivery races the - * still-running original attempt, is deduped by the chat SDK, and is - * acknowledged before the original attempt fails. Retrying locally keeps the - * dedupe intact and never depends on Slack redelivering. - * - * Returns false when the retry budget is exhausted; the caller then surfaces - * the failure instead of retrying. - */ -function scheduleHandoffRetry( - thread: Thread, - message: ChatMessage, - input: SyncThreadMessageInput, - error: unknown, - trace: SlackbotV2Trace -): boolean { - const delays = input.options.handoffRetryDelaysMs ?? HANDOFF_RETRY_DELAYS_MS - const attempt = input.retryAttempt ?? 0 - if (attempt >= delays.length) return false - const delayMs = delays[attempt] ?? 0 - slackbotMetrics.handoffRetries.inc({ outcome: 'scheduled' }) - traceLog(input.options, 'slackbotv2_handoff_retry_scheduled', trace, { - attempt: attempt + 1, - delay_ms: delayMs, - error: errorMessage(error), - max_attempts: delays.length - }) - backgroundWaitUntil( - (async () => { - await sleep(delayMs) - await syncThreadMessageToSession(thread, message, { ...input, retryAttempt: attempt + 1 }) - })().catch(async retryError => { - traceWarn(input.options, 'slackbotv2_handoff_retry_failed', trace, { - attempt: attempt + 1, - error: errorMessage(retryError) - }) - // A retry chain that dies outside the normal failure paths (which clear - // the status themselves) must not leave "Thinking..." stuck on the thread. - if (input.mode === 'execute') { - try { - await setAssistantStatus(thread, '', input.options, trace) - } catch { - // Best-effort; the original failure is already logged. - } - } - }) - ) - return true -} - /** * Persists a Slack thread update into the session API. In execute mode the create/append/execute * handoff completes before Slack is acknowledged; SSE rendering continues in background. @@ -675,7 +624,13 @@ function scheduleHandoffRetry( async function syncThreadMessageToSession( thread: Thread, message: ChatMessage, - input: SyncThreadMessageInput + input: { + initialAssistantStatusRequested?: boolean + initialAssistantStatusVisible?: boolean + mode: SlackbotV2MessageMode + options: SlackbotV2Options + state: StateAdapter + } ): Promise { const traceStartedAtMs = nowMs() const state = (await thread.state) ?? {} @@ -925,21 +880,30 @@ async function syncThreadMessageToSession( } } catch (error) { if (isRetryableSessionApiError(error)) { - if (scheduleHandoffRetry(thread, message, input, error, trace)) { - recordForward(input.mode, 'retry_scheduled', traceStartedAtMs) - return + const context = requestContext.getStore() + if (context) { + context.retryableErrors.push(error) + try { + await input.state.delete(`dedupe:slack:${message.id}`) + } catch (deleteError) { + traceLog(input.options, 'slackbotv2_webhook_retry_dedupe_clear_failed', trace, { + error: errorMessage(deleteError) + }) + } + traceLog(input.options, 'slackbotv2_webhook_retry_marked', trace, { + error: errorMessage(error) + }) } - slackbotMetrics.handoffRetries.inc({ outcome: 'exhausted' }) - traceWarn(input.options, 'slackbotv2_handoff_retry_exhausted', trace, { - error: errorMessage(error) - }) } - recordForward(input.mode, 'error', traceStartedAtMs) + recordForward( + input.mode, + isRetryableSessionApiError(error) ? 'retry_requested' : 'error', + traceStartedAtMs + ) throw error } traceLog(input.options, 'slackbotv2_forward_complete', trace) recordForward(input.mode, 'complete', traceStartedAtMs) - if (input.retryAttempt) slackbotMetrics.handoffRetries.inc({ outcome: 'succeeded' }) return } @@ -966,7 +930,6 @@ async function syncThreadMessageToSession( last_event_id: lastEventId }) recordForward(input.mode, 'complete', traceStartedAtMs) - if (input.retryAttempt) slackbotMetrics.handoffRetries.inc({ outcome: 'succeeded' }) } catch (error) { // The live render is not happening; let the recovery sweep claim the // obligation (if one was committed) as soon as it scans. @@ -977,24 +940,23 @@ async function syncThreadMessageToSession( lastEventId: Math.max(latest.lastEventId ?? 0, lastEventId) }) if (isRetryableSessionApiError(error)) { - // The assistant status stays visible through the retry window; a - // successful retry replaces it with the live render, and exhaustion - // falls through to the visible error notice below (which clears it). - // - // If another mention starts an execution before the retry fires, the - // retry recomputes eligibility and downgrades to append/no-op. That is - // intentional: this message was already appended (or will be appended) - // to the session, so the newer execution sees it — the same conflation - // that happens when two mentions arrive seconds apart on a healthy - // system. The thread is never left silent in that case. - if (scheduleHandoffRetry(thread, message, input, error, trace)) { - recordForward(input.mode, 'retry_scheduled', traceStartedAtMs) - return + const context = requestContext.getStore() + if (context) { + context.retryableErrors.push(error) + try { + await input.state.delete(`dedupe:slack:${message.id}`) + } catch (deleteError) { + traceLog(input.options, 'slackbotv2_webhook_retry_dedupe_clear_failed', trace, { + error: errorMessage(deleteError) + }) + } + traceLog(input.options, 'slackbotv2_webhook_retry_marked', trace, { + error: errorMessage(error) + }) + if (assistantStatusVisible) await setAssistantStatus(thread, '', input.options, trace) + recordForward(input.mode, 'retry_requested', traceStartedAtMs) + throw error } - slackbotMetrics.handoffRetries.inc({ outcome: 'exhausted' }) - traceWarn(input.options, 'slackbotv2_handoff_retry_exhausted', trace, { - error: errorMessage(error) - }) } try { await renderExecutionStream( diff --git a/services/slackbotv2/src/metrics.ts b/services/slackbotv2/src/metrics.ts index 8ad0c4d52..6785316b6 100644 --- a/services/slackbotv2/src/metrics.ts +++ b/services/slackbotv2/src/metrics.ts @@ -249,11 +249,6 @@ export const slackbotMetrics = { labelNames: ['mode', 'outcome'], name: 'slackbotv2_forward_messages_total' }), - handoffRetries: counter({ - help: 'In-process Slack handoff retries after retryable session API failures.', - labelNames: ['outcome'], - name: 'slackbotv2_handoff_retries_total' - }), info: gauge({ help: 'Static Slackbot v2 service info.', name: 'slackbotv2_info' @@ -347,6 +342,10 @@ export const slackbotMetrics = { help: 'Slack webhook requests handled by Slackbot.', labelNames: ['route', 'event_type', 'outcome'], name: 'slackbotv2_slack_webhook_requests_total' + }), + webhookRetryRequests: counter({ + help: 'Slack webhook requests answered with a retryable error so Slack retries delivery.', + name: 'slackbotv2_webhook_retry_requests_total' }) } diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index b05c8e5ff..6fb2c64ae 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -133,15 +133,6 @@ export type SlackbotV2Options = { * harness config files (see console-session-link.ts). */ harnessDefaultModels?: Record - /** - * Backoff delays between in-process retries of a Slack handoff after a - * retryable session API failure. Slack's own webhook redelivery cannot - * drive these retries: Slack times deliveries out after ~3s, so its - * redelivery races the still-running original attempt, is deduped, and is - * acknowledged before the original attempt fails. The bot retries locally - * instead and posts a visible error once the delays are exhausted. - */ - handoffRetryDelaysMs?: readonly number[] /** Milliseconds before an idle execution pauses its sandbox. Defaults to up to 3h. */ idleTimeoutMs?: number logger?: Logger diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 03ce53c66..921c445ee 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -3286,6 +3286,7 @@ describe('slackbotv2', () => { expect(logData(logs, 'slackbotv2_webhook_handoff_wait_complete')).toEqual( expect.objectContaining({ phase_ms: expect.any(Number), + retryable_error_count: 0, slack_event_id: 'Ev-slackbotv2-slow-execute' }) ) @@ -3907,8 +3908,7 @@ describe('slackbotv2', () => { expect(Number(recoveredThreadState?.lastEventId)).toBeGreaterThan(0) }) - it('locally retries a retryable execute failure without duplicate append', async () => { - bot = createTestBot({ handoffRetryDelaysMs: [50] }) + it('returns 503 for retryable execute failure and lets Slack retry without duplicate append', async () => { codexApi.failNextExecute = true const parent = await postUserMessage('History that must not be lost.') @@ -3925,46 +3925,40 @@ describe('slackbotv2', () => { text: `<@${BOT_USER_ID}> first try` } }) - const waits: Promise[] = [] - const response = await bot.app.request( + const failedWaits: Promise[] = [] + const failedResponse = await bot.app.request( '/api/webhooks/slack', retryableEvent, {}, - waitUntilContext(waits) + waitUntilContext(failedWaits) ) - // The retryable failure is retried in-process; Slack is acknowledged so - // its own redelivery (which would be deduped anyway) is never needed. - expect(response.status).toBe(200) + expect(failedResponse.status).toBe(503) + await Promise.all(failedWaits) expect(codexApi.appends).toHaveLength(1) expect(codexApi.executes).toHaveLength(1) expect(codexApi.eventRequests).toHaveLength(0) + expect(slackApi.calls.some(call => call.method === 'chat.startStream')).toBe(false) - await waitFor(() => codexApi.executes.length === 2, 3000) - await waitFor(async () => (await threadText(parent.ts)).includes('Executed request 1.'), 3000) - await Promise.all(waits) - - expect(codexApi.appends).toHaveLength(1) - const retryContextTexts = sessionMessageTexts(codexApi.appends[0]?.body.messages ?? []) - expect(retryContextTexts).toContain('History that must not be lost.') - expect(retryContextTexts.some(text => text.includes('first try'))).toBe(true) - expect(codexApi.eventRequests).toHaveLength(1) - - // A late Slack redelivery of the same event stays deduped and adds no work. - const redeliveryWaits: Promise[] = [] - const redeliveryResponse = await bot.app.request( + const retryWaits: Promise[] = [] + const retryResponse = await bot.app.request( '/api/webhooks/slack', retryableEvent, {}, - waitUntilContext(redeliveryWaits) + waitUntilContext(retryWaits) ) - expect(redeliveryResponse.status).toBe(200) - await Promise.all(redeliveryWaits) + expect(retryResponse.status).toBe(200) + await Promise.all(retryWaits) + expect(codexApi.executes).toHaveLength(2) expect(codexApi.appends).toHaveLength(1) + const retryContextTexts = sessionMessageTexts(codexApi.appends[0]?.body.messages ?? []) + expect(retryContextTexts).toContain('History that must not be lost.') + expect(retryContextTexts.some(text => text.includes('first try'))).toBe(true) + expect(codexApi.eventRequests).toHaveLength(1) + expect(await threadText(parent.ts)).toContain('Executed request 1.') }) - it('reuses an accepted execution when the local retry follows a lost execute response', async () => { - bot = createTestBot({ handoffRetryDelaysMs: [50] }) + it('reuses an accepted execution when Slack retries after a lost execute response', async () => { codexApi.failNextExecuteAfterAccept = true const parent = await postUserMessage('History before response loss.') @@ -3981,143 +3975,40 @@ describe('slackbotv2', () => { text: `<@${BOT_USER_ID}> first try accepted` } }) - const waits: Promise[] = [] - const response = await bot.app.request( + const failedWaits: Promise[] = [] + const failedResponse = await bot.app.request( '/api/webhooks/slack', retryableEvent, {}, - waitUntilContext(waits) + waitUntilContext(failedWaits) ) - expect(response.status).toBe(200) + expect(failedResponse.status).toBe(503) + await Promise.all(failedWaits) expect(codexApi.executes).toHaveLength(1) expect(codexApi.eventRequests).toHaveLength(0) + expect(slackApi.calls.some(call => call.method === 'chat.startStream')).toBe(false) - await waitFor(() => codexApi.executes.length === 2, 3000) - await waitFor(async () => (await threadText(parent.ts)).includes('Executed request 1.'), 3000) - await Promise.all(waits) + const retryWaits: Promise[] = [] + const retryResponse = await bot.app.request( + '/api/webhooks/slack', + retryableEvent, + {}, + waitUntilContext(retryWaits) + ) + expect(retryResponse.status).toBe(200) + await Promise.all(retryWaits) + expect(codexApi.executes).toHaveLength(2) expect(codexApi.executes.map(execute => execute.body.idempotency_key)).toEqual([ mention.ts, mention.ts ]) expect(codexApi.appends).toHaveLength(1) expect(codexApi.eventRequests).toHaveLength(1) + expect(await threadText(parent.ts)).toContain('Executed request 1.') expect(await threadText(parent.ts)).not.toContain('Executed request 2.') }) - it('conflates a pending execute retry into an execution started meanwhile', async () => { - bot = createTestBot({ handoffRetryDelaysMs: [300] }) - codexApi.failNextExecute = true - - const parent = await postUserMessage('History before conflation.') - const firstMention = await postUserMessage(`<@${BOT_USER_ID}> first conflated mention`, parent.ts) - const firstWaits: Promise[] = [] - const firstResponse = await bot.app.request( - '/api/webhooks/slack', - signedSlackEvent({ - event_id: 'Ev-slackbotv2-conflate-1', - event: { - type: 'app_mention', - user: USER_ID, - channel: CHANNEL_ID, - team: TEAM_ID, - ts: firstMention.ts, - thread_ts: parent.ts, - text: `<@${BOT_USER_ID}> first conflated mention` - } - }), - {}, - waitUntilContext(firstWaits) - ) - expect(firstResponse.status).toBe(200) - expect(codexApi.executes).toHaveLength(1) - - // A second mention lands while the first message's retry is still pending - // and starts the thread's execution. Keep it running (no auto response) - // across the retry window. - codexApi.autoRespond = false - const secondMention = await postUserMessage( - `<@${BOT_USER_ID}> second conflated mention`, - parent.ts - ) - const secondWaits: Promise[] = [] - const secondResponse = await bot.app.request( - '/api/webhooks/slack', - signedSlackEvent({ - event_id: 'Ev-slackbotv2-conflate-2', - event: { - type: 'app_mention', - user: USER_ID, - channel: CHANNEL_ID, - team: TEAM_ID, - ts: secondMention.ts, - thread_ts: parent.ts, - text: `<@${BOT_USER_ID}> second conflated mention` - } - }), - {}, - waitUntilContext(secondWaits) - ) - expect(secondResponse.status).toBe(200) - expect(codexApi.executes).toHaveLength(2) - - // The first message's retry fires into the active execution and must not - // start a third execution; its text is already in the session, so the - // running execution sees it. - await sleep(500) - expect(codexApi.executes).toHaveLength(2) - const appendedTexts = codexApi.appends.flatMap(append => - sessionMessageTexts(append.body.messages ?? []) - ) - expect(appendedTexts.some(text => text.includes('first conflated mention'))).toBe(true) - expect(appendedTexts.some(text => text.includes('second conflated mention'))).toBe(true) - - codexApi.emitOutputLines(threadKey(parent.ts), sampleCodexOutputLines('Conflated answer.')) - await Promise.all([...firstWaits, ...secondWaits]) - expect(await threadText(parent.ts)).toContain('Conflated answer.') - expect(await threadText(parent.ts)).not.toContain(BROKEN_STREAM_TEXT) - }) - - it('renders a visible error once local retries are exhausted', async () => { - bot = createTestBot({ handoffRetryDelaysMs: [200] }) - codexApi.failNextExecute = true - - const parent = await postUserMessage('History before exhaustion.') - const mention = await postUserMessage(`<@${BOT_USER_ID}> exhaust retries`, parent.ts) - const waits: Promise[] = [] - const response = await bot.app.request( - '/api/webhooks/slack', - signedSlackEvent({ - event_id: 'Ev-slackbotv2-retry-exhausted', - event: { - type: 'app_mention', - user: USER_ID, - channel: CHANNEL_ID, - team: TEAM_ID, - ts: mention.ts, - thread_ts: parent.ts, - text: `<@${BOT_USER_ID}> exhaust retries` - } - }), - {}, - waitUntilContext(waits) - ) - expect(response.status).toBe(200) - expect(codexApi.executes).toHaveLength(1) - - // Fail the scheduled retry too so the budget of one retry is exhausted. - codexApi.failNextExecute = true - await waitFor(() => codexApi.executes.length === 2, 3000) - await waitFor(async () => (await threadText(parent.ts)).includes('Execution failed'), 3000) - await Promise.all(waits) - - expect(codexApi.eventRequests).toHaveLength(0) - const threadState = await bot.chat - .thread(threadKey(parent.ts)) - .state - expect(threadState).toEqual(expect.objectContaining({ activeExecution: false })) - }) - it('keeps v1 external org and trigger-bot allowlist behavior', async () => { const externalMention = await postUserMessage(`<@${BOT_USER_ID}> from external org`) const externalWaits: Promise[] = [] From f41ee3cae5b3d55d9f31a56d967e96bee05c24db Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:27:56 +0300 Subject: [PATCH 071/198] fix(slackbotv2): retry retryable handoff failures in-process instead of relying on Slack redelivery (reland) (#935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(slackbotv2): retry retryable handoff failures in-process instead of relying on Slack redelivery Slack only redelivers webhook events when the handler fails within ~3s, so the old design (return 503, clear dedupe, wait for redelivery) silently dropped prompts whenever a retryable session API failure surfaced after a slow create/load — as seen during centaur-console instability. Retryable handoff failures now schedule local retries (5s/30s/120s) while Slack gets an immediate 200. The assistant status stays visible through the retry window; exhaustion renders the visible error notice. If another mention starts an execution before a retry fires, the retry conflates into it (the message is already appended to the session), matching healthy-path semantics for near-simultaneous mentions. Replaces slackbotv2_webhook_retry_requests_total with slackbotv2_handoff_retries_total{outcome}. Amp-Thread-ID: https://ampcode.com/threads/T-019f3cd5-b870-7386-8c36-e28a23ec5808 Co-authored-by: Amp --- services/slackbotv2/src/index.ts | 146 ++++++++------ services/slackbotv2/src/metrics.ts | 9 +- services/slackbotv2/src/types.ts | 9 + .../slackbotv2/test/chat-sdk-emulate.test.ts | 183 ++++++++++++++---- 4 files changed, 252 insertions(+), 95 deletions(-) diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 05d185505..362124e17 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -106,7 +106,6 @@ type SlackAssistantAdapter = { const MAX_SLACK_MESSAGE_ATTACHMENTS = 20 type SlackbotV2RequestContext = { - retryableErrors: unknown[] waitUntil(promise: Promise): void } @@ -126,6 +125,7 @@ const SLACK_TASK_DETAILS_MAX_CHARS = 500 const SLACK_FALLBACK_TEXT_MAX_CHARS = 35_000 const POSTGRES_CONNECT_INITIAL_DELAY_MS = 250 const POSTGRES_CONNECT_MAX_DELAY_MS = 10_000 +const HANDOFF_RETRY_DELAYS_MS: readonly number[] = [5_000, 30_000, 120_000] const LATE_SLACK_FILE_MATCH_WINDOW_MS = 15_000 const LATE_SLACK_FILE_PENDING_TTL_MS = 60_000 const LATE_SLACK_FILE_CONSUMED_TTL_MS = 5 * 60_000 @@ -253,7 +253,6 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { const webhookFields = slackWebhookLogFields(rawBody) const handoffTasks: Promise[] = [] const context: SlackbotV2RequestContext = { - retryableErrors: [], waitUntil: promise => waitUntil(c, promise) } const response = await requestContext.run(context, () => { @@ -287,24 +286,14 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { await Promise.all(handoffTasks) } catch (error) { waitError = error - if (isRetryableSessionApiError(error)) context.retryableErrors.push(error) } finally { stopPendingLog() traceLog(options, 'slackbotv2_webhook_handoff_wait_complete', undefined, { ...waitFields, error: waitError ? errorMessage(waitError) : undefined, - phase_ms: elapsedMs(waitStartedAtMs), - retryable_error_count: context.retryableErrors.length + phase_ms: elapsedMs(waitStartedAtMs) }) } - if (context.retryableErrors.length > 0) { - outcome = 'retry_requested' - slackbotMetrics.webhookRetryRequests.inc() - traceLog(options, 'slackbotv2_webhook_retry_requested', undefined, { - error: errorMessage(context.retryableErrors[0]) - }) - return new globalThis.Response('temporary upstream unavailable', { status: 503 }) - } } const lateFileTask = lateSlackFiles.repairFromWebhook(rawBody) if (lateFileTask) waitUntil(c, lateFileTask) @@ -617,6 +606,68 @@ async function ensureStateConnected(state: StateAdapter, options: SlackbotV2Opti } } +type SyncThreadMessageInput = { + initialAssistantStatusRequested?: boolean + initialAssistantStatusVisible?: boolean + mode: SlackbotV2MessageMode + options: SlackbotV2Options + /** Number of in-process retries already spent on this message's handoff. */ + retryAttempt?: number + state: StateAdapter +} + +/** + * Schedules an in-process retry of a Slack→session handoff after a retryable + * session API failure. Slack's own webhook redelivery cannot drive retries: + * Slack times deliveries out after ~3s, so its redelivery races the + * still-running original attempt, is deduped by the chat SDK, and is + * acknowledged before the original attempt fails. Retrying locally keeps the + * dedupe intact and never depends on Slack redelivering. + * + * Returns false when the retry budget is exhausted; the caller then surfaces + * the failure instead of retrying. + */ +function scheduleHandoffRetry( + thread: Thread, + message: ChatMessage, + input: SyncThreadMessageInput, + error: unknown, + trace: SlackbotV2Trace +): boolean { + const delays = input.options.handoffRetryDelaysMs ?? HANDOFF_RETRY_DELAYS_MS + const attempt = input.retryAttempt ?? 0 + if (attempt >= delays.length) return false + const delayMs = delays[attempt] ?? 0 + slackbotMetrics.handoffRetries.inc({ outcome: 'scheduled' }) + traceLog(input.options, 'slackbotv2_handoff_retry_scheduled', trace, { + attempt: attempt + 1, + delay_ms: delayMs, + error: errorMessage(error), + max_attempts: delays.length + }) + backgroundWaitUntil( + (async () => { + await sleep(delayMs) + await syncThreadMessageToSession(thread, message, { ...input, retryAttempt: attempt + 1 }) + })().catch(async retryError => { + traceWarn(input.options, 'slackbotv2_handoff_retry_failed', trace, { + attempt: attempt + 1, + error: errorMessage(retryError) + }) + // A retry chain that dies outside the normal failure paths (which clear + // the status themselves) must not leave "Thinking..." stuck on the thread. + if (input.mode === 'execute') { + try { + await setAssistantStatus(thread, '', input.options, trace) + } catch { + // Best-effort; the original failure is already logged. + } + } + }) + ) + return true +} + /** * Persists a Slack thread update into the session API. In execute mode the create/append/execute * handoff completes before Slack is acknowledged; SSE rendering continues in background. @@ -624,13 +675,7 @@ async function ensureStateConnected(state: StateAdapter, options: SlackbotV2Opti async function syncThreadMessageToSession( thread: Thread, message: ChatMessage, - input: { - initialAssistantStatusRequested?: boolean - initialAssistantStatusVisible?: boolean - mode: SlackbotV2MessageMode - options: SlackbotV2Options - state: StateAdapter - } + input: SyncThreadMessageInput ): Promise { const traceStartedAtMs = nowMs() const state = (await thread.state) ?? {} @@ -880,30 +925,21 @@ async function syncThreadMessageToSession( } } catch (error) { if (isRetryableSessionApiError(error)) { - const context = requestContext.getStore() - if (context) { - context.retryableErrors.push(error) - try { - await input.state.delete(`dedupe:slack:${message.id}`) - } catch (deleteError) { - traceLog(input.options, 'slackbotv2_webhook_retry_dedupe_clear_failed', trace, { - error: errorMessage(deleteError) - }) - } - traceLog(input.options, 'slackbotv2_webhook_retry_marked', trace, { - error: errorMessage(error) - }) + if (scheduleHandoffRetry(thread, message, input, error, trace)) { + recordForward(input.mode, 'retry_scheduled', traceStartedAtMs) + return } + slackbotMetrics.handoffRetries.inc({ outcome: 'exhausted' }) + traceWarn(input.options, 'slackbotv2_handoff_retry_exhausted', trace, { + error: errorMessage(error) + }) } - recordForward( - input.mode, - isRetryableSessionApiError(error) ? 'retry_requested' : 'error', - traceStartedAtMs - ) + recordForward(input.mode, 'error', traceStartedAtMs) throw error } traceLog(input.options, 'slackbotv2_forward_complete', trace) recordForward(input.mode, 'complete', traceStartedAtMs) + if (input.retryAttempt) slackbotMetrics.handoffRetries.inc({ outcome: 'succeeded' }) return } @@ -930,6 +966,7 @@ async function syncThreadMessageToSession( last_event_id: lastEventId }) recordForward(input.mode, 'complete', traceStartedAtMs) + if (input.retryAttempt) slackbotMetrics.handoffRetries.inc({ outcome: 'succeeded' }) } catch (error) { // The live render is not happening; let the recovery sweep claim the // obligation (if one was committed) as soon as it scans. @@ -940,23 +977,24 @@ async function syncThreadMessageToSession( lastEventId: Math.max(latest.lastEventId ?? 0, lastEventId) }) if (isRetryableSessionApiError(error)) { - const context = requestContext.getStore() - if (context) { - context.retryableErrors.push(error) - try { - await input.state.delete(`dedupe:slack:${message.id}`) - } catch (deleteError) { - traceLog(input.options, 'slackbotv2_webhook_retry_dedupe_clear_failed', trace, { - error: errorMessage(deleteError) - }) - } - traceLog(input.options, 'slackbotv2_webhook_retry_marked', trace, { - error: errorMessage(error) - }) - if (assistantStatusVisible) await setAssistantStatus(thread, '', input.options, trace) - recordForward(input.mode, 'retry_requested', traceStartedAtMs) - throw error + // The assistant status stays visible through the retry window; a + // successful retry replaces it with the live render, and exhaustion + // falls through to the visible error notice below (which clears it). + // + // If another mention starts an execution before the retry fires, the + // retry recomputes eligibility and downgrades to append/no-op. That is + // intentional: this message was already appended (or will be appended) + // to the session, so the newer execution sees it — the same conflation + // that happens when two mentions arrive seconds apart on a healthy + // system. The thread is never left silent in that case. + if (scheduleHandoffRetry(thread, message, input, error, trace)) { + recordForward(input.mode, 'retry_scheduled', traceStartedAtMs) + return } + slackbotMetrics.handoffRetries.inc({ outcome: 'exhausted' }) + traceWarn(input.options, 'slackbotv2_handoff_retry_exhausted', trace, { + error: errorMessage(error) + }) } try { await renderExecutionStream( diff --git a/services/slackbotv2/src/metrics.ts b/services/slackbotv2/src/metrics.ts index 6785316b6..8ad0c4d52 100644 --- a/services/slackbotv2/src/metrics.ts +++ b/services/slackbotv2/src/metrics.ts @@ -249,6 +249,11 @@ export const slackbotMetrics = { labelNames: ['mode', 'outcome'], name: 'slackbotv2_forward_messages_total' }), + handoffRetries: counter({ + help: 'In-process Slack handoff retries after retryable session API failures.', + labelNames: ['outcome'], + name: 'slackbotv2_handoff_retries_total' + }), info: gauge({ help: 'Static Slackbot v2 service info.', name: 'slackbotv2_info' @@ -342,10 +347,6 @@ export const slackbotMetrics = { help: 'Slack webhook requests handled by Slackbot.', labelNames: ['route', 'event_type', 'outcome'], name: 'slackbotv2_slack_webhook_requests_total' - }), - webhookRetryRequests: counter({ - help: 'Slack webhook requests answered with a retryable error so Slack retries delivery.', - name: 'slackbotv2_webhook_retry_requests_total' }) } diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 6fb2c64ae..b05c8e5ff 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -133,6 +133,15 @@ export type SlackbotV2Options = { * harness config files (see console-session-link.ts). */ harnessDefaultModels?: Record + /** + * Backoff delays between in-process retries of a Slack handoff after a + * retryable session API failure. Slack's own webhook redelivery cannot + * drive these retries: Slack times deliveries out after ~3s, so its + * redelivery races the still-running original attempt, is deduped, and is + * acknowledged before the original attempt fails. The bot retries locally + * instead and posts a visible error once the delays are exhausted. + */ + handoffRetryDelaysMs?: readonly number[] /** Milliseconds before an idle execution pauses its sandbox. Defaults to up to 3h. */ idleTimeoutMs?: number logger?: Logger diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 921c445ee..03ce53c66 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -3286,7 +3286,6 @@ describe('slackbotv2', () => { expect(logData(logs, 'slackbotv2_webhook_handoff_wait_complete')).toEqual( expect.objectContaining({ phase_ms: expect.any(Number), - retryable_error_count: 0, slack_event_id: 'Ev-slackbotv2-slow-execute' }) ) @@ -3908,7 +3907,8 @@ describe('slackbotv2', () => { expect(Number(recoveredThreadState?.lastEventId)).toBeGreaterThan(0) }) - it('returns 503 for retryable execute failure and lets Slack retry without duplicate append', async () => { + it('locally retries a retryable execute failure without duplicate append', async () => { + bot = createTestBot({ handoffRetryDelaysMs: [50] }) codexApi.failNextExecute = true const parent = await postUserMessage('History that must not be lost.') @@ -3925,40 +3925,46 @@ describe('slackbotv2', () => { text: `<@${BOT_USER_ID}> first try` } }) - const failedWaits: Promise[] = [] - const failedResponse = await bot.app.request( + const waits: Promise[] = [] + const response = await bot.app.request( '/api/webhooks/slack', retryableEvent, {}, - waitUntilContext(failedWaits) + waitUntilContext(waits) ) - expect(failedResponse.status).toBe(503) - await Promise.all(failedWaits) + // The retryable failure is retried in-process; Slack is acknowledged so + // its own redelivery (which would be deduped anyway) is never needed. + expect(response.status).toBe(200) expect(codexApi.appends).toHaveLength(1) expect(codexApi.executes).toHaveLength(1) expect(codexApi.eventRequests).toHaveLength(0) - expect(slackApi.calls.some(call => call.method === 'chat.startStream')).toBe(false) - const retryWaits: Promise[] = [] - const retryResponse = await bot.app.request( - '/api/webhooks/slack', - retryableEvent, - {}, - waitUntilContext(retryWaits) - ) - expect(retryResponse.status).toBe(200) - await Promise.all(retryWaits) + await waitFor(() => codexApi.executes.length === 2, 3000) + await waitFor(async () => (await threadText(parent.ts)).includes('Executed request 1.'), 3000) + await Promise.all(waits) - expect(codexApi.executes).toHaveLength(2) expect(codexApi.appends).toHaveLength(1) const retryContextTexts = sessionMessageTexts(codexApi.appends[0]?.body.messages ?? []) expect(retryContextTexts).toContain('History that must not be lost.') expect(retryContextTexts.some(text => text.includes('first try'))).toBe(true) expect(codexApi.eventRequests).toHaveLength(1) - expect(await threadText(parent.ts)).toContain('Executed request 1.') + + // A late Slack redelivery of the same event stays deduped and adds no work. + const redeliveryWaits: Promise[] = [] + const redeliveryResponse = await bot.app.request( + '/api/webhooks/slack', + retryableEvent, + {}, + waitUntilContext(redeliveryWaits) + ) + expect(redeliveryResponse.status).toBe(200) + await Promise.all(redeliveryWaits) + expect(codexApi.executes).toHaveLength(2) + expect(codexApi.appends).toHaveLength(1) }) - it('reuses an accepted execution when Slack retries after a lost execute response', async () => { + it('reuses an accepted execution when the local retry follows a lost execute response', async () => { + bot = createTestBot({ handoffRetryDelaysMs: [50] }) codexApi.failNextExecuteAfterAccept = true const parent = await postUserMessage('History before response loss.') @@ -3975,40 +3981,143 @@ describe('slackbotv2', () => { text: `<@${BOT_USER_ID}> first try accepted` } }) - const failedWaits: Promise[] = [] - const failedResponse = await bot.app.request( + const waits: Promise[] = [] + const response = await bot.app.request( '/api/webhooks/slack', retryableEvent, {}, - waitUntilContext(failedWaits) + waitUntilContext(waits) ) - expect(failedResponse.status).toBe(503) - await Promise.all(failedWaits) + expect(response.status).toBe(200) expect(codexApi.executes).toHaveLength(1) expect(codexApi.eventRequests).toHaveLength(0) - expect(slackApi.calls.some(call => call.method === 'chat.startStream')).toBe(false) - const retryWaits: Promise[] = [] - const retryResponse = await bot.app.request( - '/api/webhooks/slack', - retryableEvent, - {}, - waitUntilContext(retryWaits) - ) - expect(retryResponse.status).toBe(200) - await Promise.all(retryWaits) + await waitFor(() => codexApi.executes.length === 2, 3000) + await waitFor(async () => (await threadText(parent.ts)).includes('Executed request 1.'), 3000) + await Promise.all(waits) - expect(codexApi.executes).toHaveLength(2) expect(codexApi.executes.map(execute => execute.body.idempotency_key)).toEqual([ mention.ts, mention.ts ]) expect(codexApi.appends).toHaveLength(1) expect(codexApi.eventRequests).toHaveLength(1) - expect(await threadText(parent.ts)).toContain('Executed request 1.') expect(await threadText(parent.ts)).not.toContain('Executed request 2.') }) + it('conflates a pending execute retry into an execution started meanwhile', async () => { + bot = createTestBot({ handoffRetryDelaysMs: [300] }) + codexApi.failNextExecute = true + + const parent = await postUserMessage('History before conflation.') + const firstMention = await postUserMessage(`<@${BOT_USER_ID}> first conflated mention`, parent.ts) + const firstWaits: Promise[] = [] + const firstResponse = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-conflate-1', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: firstMention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> first conflated mention` + } + }), + {}, + waitUntilContext(firstWaits) + ) + expect(firstResponse.status).toBe(200) + expect(codexApi.executes).toHaveLength(1) + + // A second mention lands while the first message's retry is still pending + // and starts the thread's execution. Keep it running (no auto response) + // across the retry window. + codexApi.autoRespond = false + const secondMention = await postUserMessage( + `<@${BOT_USER_ID}> second conflated mention`, + parent.ts + ) + const secondWaits: Promise[] = [] + const secondResponse = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-conflate-2', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: secondMention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> second conflated mention` + } + }), + {}, + waitUntilContext(secondWaits) + ) + expect(secondResponse.status).toBe(200) + expect(codexApi.executes).toHaveLength(2) + + // The first message's retry fires into the active execution and must not + // start a third execution; its text is already in the session, so the + // running execution sees it. + await sleep(500) + expect(codexApi.executes).toHaveLength(2) + const appendedTexts = codexApi.appends.flatMap(append => + sessionMessageTexts(append.body.messages ?? []) + ) + expect(appendedTexts.some(text => text.includes('first conflated mention'))).toBe(true) + expect(appendedTexts.some(text => text.includes('second conflated mention'))).toBe(true) + + codexApi.emitOutputLines(threadKey(parent.ts), sampleCodexOutputLines('Conflated answer.')) + await Promise.all([...firstWaits, ...secondWaits]) + expect(await threadText(parent.ts)).toContain('Conflated answer.') + expect(await threadText(parent.ts)).not.toContain(BROKEN_STREAM_TEXT) + }) + + it('renders a visible error once local retries are exhausted', async () => { + bot = createTestBot({ handoffRetryDelaysMs: [200] }) + codexApi.failNextExecute = true + + const parent = await postUserMessage('History before exhaustion.') + const mention = await postUserMessage(`<@${BOT_USER_ID}> exhaust retries`, parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-retry-exhausted', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> exhaust retries` + } + }), + {}, + waitUntilContext(waits) + ) + expect(response.status).toBe(200) + expect(codexApi.executes).toHaveLength(1) + + // Fail the scheduled retry too so the budget of one retry is exhausted. + codexApi.failNextExecute = true + await waitFor(() => codexApi.executes.length === 2, 3000) + await waitFor(async () => (await threadText(parent.ts)).includes('Execution failed'), 3000) + await Promise.all(waits) + + expect(codexApi.eventRequests).toHaveLength(0) + const threadState = await bot.chat + .thread(threadKey(parent.ts)) + .state + expect(threadState).toEqual(expect.objectContaining({ activeExecution: false })) + }) + it('keeps v1 external org and trigger-bot allowlist behavior', async () => { const externalMention = await postUserMessage(`<@${BOT_USER_ID}> from external org`) const externalWaits: Promise[] = [] From 9155b184c53d20ca357fbe3fef01091dfac0867c Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:36:13 +0300 Subject: [PATCH 072/198] Add sandbox commit-msg hook (#909) * Add sandbox commit message hook * fix: enforce conventional commit messages --- services/sandbox/Dockerfile | 2 ++ services/sandbox/git-hooks/commit-msg | 29 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100755 services/sandbox/git-hooks/commit-msg diff --git a/services/sandbox/Dockerfile b/services/sandbox/Dockerfile index d7053d65f..39defd00a 100644 --- a/services/sandbox/Dockerfile +++ b/services/sandbox/Dockerfile @@ -200,6 +200,7 @@ RUN --mount=type=cache,target=/home/agent/.npm,uid=1001,gid=1001,sharing=locked RUN mkdir -p ~/.amp/bin ~/.config/amp ~/.codex ~/.pi/agent ~/github ~/workspace \ && git config --global init.defaultBranch main \ && git config --global push.autoSetupRemote true \ + && git config --global core.hooksPath /opt/centaur/git-hooks \ && git config --global --add safe.directory '*' \ && git config --global user.name "Centaur AI" \ && git config --global user.email "ai@centaur.local" @@ -219,6 +220,7 @@ RUN rm -f /etc/sudoers.d/agent # Pre-create it as 0755 so the agent user can traverse into it at runtime. RUN install -d -m 0755 /etc/centaur /opt/centaur COPY --link centaur_sdk/ /opt/centaur/centaur_sdk/ +COPY --link --chmod=0755 services/sandbox/git-hooks/ /opt/centaur/git-hooks/ COPY --link --chown=1001:1001 tools/ /opt/centaur/tools/ COPY --link --chmod=644 services/sandbox/codex-auth.json /etc/centaur/codex-auth.default.json COPY --link --chmod=644 services/sandbox/claude-credentials.json /etc/centaur/claude-credentials.default.json diff --git a/services/sandbox/git-hooks/commit-msg b/services/sandbox/git-hooks/commit-msg new file mode 100755 index 000000000..de1ad6063 --- /dev/null +++ b/services/sandbox/git-hooks/commit-msg @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +message_file="${1:?commit message file is required}" +subject="$(sed -n '1p' "$message_file")" + +if ! [[ "$subject" =~ ^(feat|fix|docs|refactor|test|chore)(\([[:alnum:]_.-]+\))?!?:[[:space:]].+ ]]; then + cat >&2 <<'EOF' +Commit message must use a conventional commit subject: + + feat: add new behavior + fix(scope): correct existing behavior + +Allowed types: feat, fix, docs, refactor, test, chore. +EOF + exit 1 +fi + +if rg -i -q \ + -e 'generated with (claude|codex|amp)' \ + -e 'co-authored-by:.*(claude|codex|anthropic|openai|amp)' \ + "$message_file"; then + cat >&2 <<'EOF' +Commit message contains generated-by or AI co-author attribution. + +Remove the generated attribution footer and commit again. +EOF + exit 1 +fi From 2905084bed5fb7570f2c73bda6df8e552790ec40 Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:11:04 -0700 Subject: [PATCH 073/198] feat: add console workflows dashboard (#912) --- .../0036_readonly_all_workflow_queues.sql | 91 ++++++++++ .../console/workflows_controller.rb | 46 +++++ .../console/app/helpers/application_helper.rb | 25 +++ .../app/models/centaur_workflow_run.rb | 61 +++++++ .../views/console/workflows/index.html.erb | 82 +++++++++ .../app/views/console/workflows/show.html.erb | 118 +++++++++++++ .../app/views/layouts/console.html.erb | 12 ++ services/console/config/routes.rb | 1 + .../console/workflows_controller_test.rb | 164 ++++++++++++++++++ .../test/models/centaur_workflow_run_test.rb | 73 ++++++++ 10 files changed, 673 insertions(+) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0036_readonly_all_workflow_queues.sql create mode 100644 services/console/app/controllers/console/workflows_controller.rb create mode 100644 services/console/app/models/centaur_workflow_run.rb create mode 100644 services/console/app/views/console/workflows/index.html.erb create mode 100644 services/console/app/views/console/workflows/show.html.erb create mode 100644 services/console/test/controllers/console/workflows_controller_test.rb create mode 100644 services/console/test/models/centaur_workflow_run_test.rb diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0036_readonly_all_workflow_queues.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0036_readonly_all_workflow_queues.sql new file mode 100644 index 000000000..5a0095898 --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0036_readonly_all_workflow_queues.sql @@ -0,0 +1,91 @@ +select absurd.create_queue('centaur_workflows'); +select absurd.create_queue('centaur_workflows_slack_live'); +select absurd.create_queue('centaur_workflows_etl'); +select absurd.create_queue('centaur_workflows_etl_backfill'); + +create or replace view centaur_readonly_workflow_runs as +select + 'centaur_workflows'::text as queue_name, + r.run_id::text as run_id, + t.task_id::text as task_id, + t.task_name, + t.params ->> 'workflow_name' as workflow_name, + t.params ->> 'harness_type' as harness_type, + t.state, + t.attempts, + t.max_attempts, + t.enqueue_at as created_at, + t.first_started_at, + r.started_at, + r.completed_at, + r.failed_at, + r.available_at, + r.claimed_by is not null as claimed, + t.cancelled_at +from absurd.t_centaur_workflows t +left join absurd.r_centaur_workflows r on r.run_id = t.last_attempt_run +union all +select + 'centaur_workflows_slack_live'::text as queue_name, + r.run_id::text as run_id, + t.task_id::text as task_id, + t.task_name, + t.params ->> 'workflow_name' as workflow_name, + t.params ->> 'harness_type' as harness_type, + t.state, + t.attempts, + t.max_attempts, + t.enqueue_at as created_at, + t.first_started_at, + r.started_at, + r.completed_at, + r.failed_at, + r.available_at, + r.claimed_by is not null as claimed, + t.cancelled_at +from absurd.t_centaur_workflows_slack_live t +left join absurd.r_centaur_workflows_slack_live r on r.run_id = t.last_attempt_run +union all +select + 'centaur_workflows_etl'::text as queue_name, + r.run_id::text as run_id, + t.task_id::text as task_id, + t.task_name, + t.params ->> 'workflow_name' as workflow_name, + t.params ->> 'harness_type' as harness_type, + t.state, + t.attempts, + t.max_attempts, + t.enqueue_at as created_at, + t.first_started_at, + r.started_at, + r.completed_at, + r.failed_at, + r.available_at, + r.claimed_by is not null as claimed, + t.cancelled_at +from absurd.t_centaur_workflows_etl t +left join absurd.r_centaur_workflows_etl r on r.run_id = t.last_attempt_run +union all +select + 'centaur_workflows_etl_backfill'::text as queue_name, + r.run_id::text as run_id, + t.task_id::text as task_id, + t.task_name, + t.params ->> 'workflow_name' as workflow_name, + t.params ->> 'harness_type' as harness_type, + t.state, + t.attempts, + t.max_attempts, + t.enqueue_at as created_at, + t.first_started_at, + r.started_at, + r.completed_at, + r.failed_at, + r.available_at, + r.claimed_by is not null as claimed, + t.cancelled_at +from absurd.t_centaur_workflows_etl_backfill t +left join absurd.r_centaur_workflows_etl_backfill r on r.run_id = t.last_attempt_run; + +grant select on table centaur_readonly_workflow_runs to centaur_readonly; diff --git a/services/console/app/controllers/console/workflows_controller.rb b/services/console/app/controllers/console/workflows_controller.rb new file mode 100644 index 000000000..a24246292 --- /dev/null +++ b/services/console/app/controllers/console/workflows_controller.rb @@ -0,0 +1,46 @@ +class Console::WorkflowsController < ApplicationController + layout "console" + before_action :require_admin + + WORKFLOW_LIMIT = 200 + WORKFLOW_HISTORY_LIMIT = 1_000 + + def index + @workflow_db_unavailable = false + @workflow_runs = [] + + unless CentaurWorkflowRun.available? + @workflow_db_unavailable = true + return + end + + @workflow_runs = CentaurWorkflowRun.recent(limit: WORKFLOW_LIMIT) + rescue ActiveRecord::ActiveRecordError, PG::Error => e + Rails.logger.warn("console_workflows_load_failed error=#{e.class}: #{e.message}") + @workflow_db_unavailable = true + @workflow_runs = [] + end + + def show + @workflow_db_unavailable = false + @workflow_name = params[:id].to_s + @workflow_runs = [] + + unless CentaurWorkflowRun.available? + @workflow_db_unavailable = true + return + end + + @workflow_runs = CentaurWorkflowRun.for_workflow( + @workflow_name, + limit: WORKFLOW_HISTORY_LIMIT + ) + @latest_run = @workflow_runs.first + response.status = :not_found if @latest_run.blank? + rescue ActiveRecord::ActiveRecordError, PG::Error => e + Rails.logger.warn("console_workflow_load_failed workflow=#{@workflow_name} error=#{e.class}: #{e.message}") + @workflow_db_unavailable = true + @workflow_runs = [] + @latest_run = nil + end +end diff --git a/services/console/app/helpers/application_helper.rb b/services/console/app/helpers/application_helper.rb index 0e32593a7..d7dd5634d 100644 --- a/services/console/app/helpers/application_helper.rb +++ b/services/console/app/helpers/application_helper.rb @@ -31,6 +31,26 @@ def credential_status_classes(status) end end + def workflow_status_classes(status) + case status.to_s + when "completed" then "border-centaur-500/30 bg-centaur-500/10 text-centaur-300" + when "running" then "border-sky-500/40 bg-sky-500/10 text-sky-300" + when "failed" then "border-red-500/40 bg-red-500/10 text-red-300" + when "cancelled" then "border-zinc-600 bg-zinc-700/40 text-zinc-400" + when "pending", "sleeping" then "border-amber-500/40 bg-amber-500/10 text-amber-300" + else "border-ink-600 bg-ink-800/80 text-zinc-400" + end + end + + def workflow_duration_label(run) + started_at = run.started_or_created_at + finished_at = run.terminal_at + return "running" if started_at.present? && finished_at.blank? && run.display_status == "running" + return "—" if started_at.blank? || finished_at.blank? + + distance_of_time_in_words(started_at, finished_at) + end + def console_icon(name, classes: "size-4") case name when "arrow-up" @@ -137,6 +157,11 @@ def console_icon(name, classes: "size-4") classes, "M15.75 9.75a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.5 19.5a8.25 8.25 0 1 1 15 0 9.72 9.72 0 0 0-15 0Z" ) + when "workflow" + outline_icon( + classes, + "M6 6h3.75v3.75H6V6Zm8.25 8.25H18V18h-3.75v-3.75ZM6 14.25h3.75V18H6v-3.75Zm3.75-6.375H12a3 3 0 0 1 3 3v3.375M9.75 16.125H12a3 3 0 0 0 3-3V9.75" + ) when "users" outline_icon( classes, diff --git a/services/console/app/models/centaur_workflow_run.rb b/services/console/app/models/centaur_workflow_run.rb new file mode 100644 index 000000000..ec1b424f6 --- /dev/null +++ b/services/console/app/models/centaur_workflow_run.rb @@ -0,0 +1,61 @@ +class CentaurWorkflowRun < CentaurSessionRecord + self.table_name = "centaur_readonly_workflow_runs" + self.primary_key = "run_id" + + RECENT_ORDER = Arel.sql( + "coalesce(completed_at, failed_at, cancelled_at, started_at, " \ + "first_started_at, available_at, created_at) desc, task_id desc" + ) + + scope :recent_first, -> { order(RECENT_ORDER) } + + class << self + def available? + connection.data_source_exists?(table_name) + end + + def recent(limit:) + recent_first.limit(limit).to_a + end + + def for_workflow(workflow_name, limit:) + where( + "workflow_name = :workflow_name OR " \ + "((workflow_name IS NULL OR workflow_name = '') AND task_name = :workflow_name)", + workflow_name: workflow_name + ).recent_first.limit(limit).to_a + end + end + + def readonly? = true + + def workflow_name_label + workflow_name.presence || task_name.presence || "unknown workflow" + end + + def workflow_key + workflow_name.presence || task_name.presence + end + + def queue_label + suffix = queue_name.to_s.delete_prefix("centaur_workflows").delete_prefix("_") + suffix.presence&.tr("_", " ") || "default" + end + + def display_status + return "cancelled" if cancelled_at.present? + return "failed" if failed_at.present? + return "completed" if completed_at.present? + return "running" if claimed || state == "running" + + state.presence || "unknown" + end + + def started_or_created_at + started_at || first_started_at || created_at + end + + def terminal_at + completed_at || failed_at || cancelled_at + end +end diff --git a/services/console/app/views/console/workflows/index.html.erb b/services/console/app/views/console/workflows/index.html.erb new file mode 100644 index 000000000..dba0fdb1d --- /dev/null +++ b/services/console/app/views/console/workflows/index.html.erb @@ -0,0 +1,82 @@ +<% content_for :title, "Workflows · Centaur Console" %> + +<% if @workflow_db_unavailable %> +
+ Workflow database is unavailable. Console needs the API workflow read-only views to show runs. +
+<% end %> + +
+
+ + + + + + + + + + + + + <% if @workflow_runs.empty? %> + + + + <% end %> + + <% @workflow_runs.each do |run| %> + <% status = run.display_status %> + <% workflow_key = run.workflow_key %> + + + + + + + + + + + + + + + + <% end %> + +
WorkflowStatusQueueAttemptsStartedFinishedRun
+ <%= @workflow_db_unavailable ? "No workflow runs available." : "No workflow runs yet." %> +
+ <% if workflow_key.present? %> + <%= link_to truncate_middle(run.workflow_name_label, max: 48), + console_workflow_path(workflow_key), + class: "font-medium text-zinc-100 hover:text-centaur-300 hover:underline", + title: run.workflow_name_label %> + <% else %> +
+ <%= truncate_middle(run.workflow_name_label, max: 48) %> +
+ <% end %> +
<%= run.task_name.presence || "workflow" %>
+
+ + <%= status.tr("_", " ") %> + + + <%= run.queue_label %> + + <%= run.attempts || 0 %> / <%= run.max_attempts.presence || "unlimited" %> + + <%= local_time(run.started_or_created_at, relative: true) %> + <% if run.created_at.present? && run.started_or_created_at != run.created_at %> +
queued <%= local_time(run.created_at, relative: true, format: :compact) %>
+ <% end %> +
+ <%= local_time(run.terminal_at, relative: true) %> + +
<%= truncate_middle(run.run_id, max: 34) %>
+
<%= truncate_middle(run.task_id, max: 34) %>
+
+
diff --git a/services/console/app/views/console/workflows/show.html.erb b/services/console/app/views/console/workflows/show.html.erb new file mode 100644 index 000000000..cb28e1917 --- /dev/null +++ b/services/console/app/views/console/workflows/show.html.erb @@ -0,0 +1,118 @@ +<% title = @latest_run&.workflow_name_label || @workflow_name %> +<% content_for :title, "#{title} · Centaur Console" %> + +
+ <%= link_to "Back to Workflows", console_workflows_path, class: "back-link" %> +
+ +<% if @workflow_db_unavailable %> +
+ Workflow database is unavailable. Console needs the API workflow read-only views to show runs. +
+<% elsif @latest_run.blank? %> +
+ No workflow runs found for <%= @workflow_name %>. +
+<% else %> + <% status = @latest_run.display_status %> +
+
+
+
Workflow
+
+ <%= truncate_middle(@latest_run.workflow_name_label, max: 72) %> +
+
+
+
Queue
+
<%= @latest_run.queue_label %>
+
+
+
Duration
+
<%= workflow_duration_label(@latest_run) %>
+
+
+
Engine
+
<%= @latest_run.harness_type.presence || "—" %>
+
+
+ +
+
+
Status
+
+ + <%= status.tr("_", " ") %> + +
+
+
+
Started
+
<%= local_time(@latest_run.started_or_created_at) %>
+
+
+
Attempts
+
+ <%= @latest_run.attempts || 0 %> / <%= @latest_run.max_attempts.presence || "unlimited" %> +
+
+
+
Runs
+
<%= pluralize(@workflow_runs.size, "run") %>
+
+
+
+ +

Historical Runs

+ +
+ + + + + + + + + + + + + <% @workflow_runs.each do |run| %> + <% run_status = run.display_status %> + + + + + + + + + + + + + + <% end %> + +
StatusQueueAttemptsStartedFinishedRun
+ + <%= run_status.tr("_", " ") %> + + + <%= run.queue_label %> + + <%= run.attempts || 0 %> / <%= run.max_attempts.presence || "unlimited" %> + + <%= local_time(run.started_or_created_at, relative: true) %> + <% if run.created_at.present? && run.started_or_created_at != run.created_at %> +
queued <%= local_time(run.created_at, relative: true, format: :compact) %>
+ <% end %> +
+ <%= local_time(run.terminal_at, relative: true) %> + +
<%= truncate_middle(run.run_id, max: 34) %>
+
<%= truncate_middle(run.task_id, max: 34) %>
+
+
+<% end %> diff --git a/services/console/app/views/layouts/console.html.erb b/services/console/app/views/layouts/console.html.erb index 997e8126f..cbe20950e 100644 --- a/services/console/app/views/layouts/console.html.erb +++ b/services/console/app/views/layouts/console.html.erb @@ -1195,6 +1195,7 @@ <% threads_view = request.path.start_with?("/console/threads") %> + <% workflows_view = request.path.start_with?("/console/workflows") %> <%# The Control and Data Sync sections are admin-only (each controller enforces require_admin server-side); non-admins only get the Chats view below. %> <% nav_items = [] %> @@ -1235,6 +1236,17 @@ <% end %> + <% if current_user&.admin? %> + + <% end %> +
" diff --git a/services/console/config/routes.rb b/services/console/config/routes.rb index 886037642..38913f4a6 100644 --- a/services/console/config/routes.rb +++ b/services/console/config/routes.rb @@ -41,6 +41,7 @@ get "console/principals/:id", to: "console#principal", as: :console_principal namespace :console do resources :threads, only: %i[index create] + resources :workflows, only: %i[index show] # Lazily-loaded sidebar thread list (Turbo Frame src). Kept off the main # page render so the unindexed cross-database sessions query does not block # every console page. See ApplicationController#load_console_sidebar_threads. diff --git a/services/console/test/controllers/console/workflows_controller_test.rb b/services/console/test/controllers/console/workflows_controller_test.rb new file mode 100644 index 000000000..803bb5220 --- /dev/null +++ b/services/console/test/controllers/console/workflows_controller_test.rb @@ -0,0 +1,164 @@ +require "test_helper" + +class Console::WorkflowsControllerTest < ActionDispatch::IntegrationTest + FakeWorkflowRun = Struct.new( + :workflow_name, + :workflow_name_label, + :task_name, + :display_status, + :queue_label, + :attempts, + :max_attempts, + :started_or_created_at, + :created_at, + :terminal_at, + :run_id, + :task_id, + :harness_type, + keyword_init: true + ) do + def workflow_name_label + self[:workflow_name_label].presence || workflow_name.presence || task_name.presence || "unknown workflow" + end + + def workflow_key + workflow_name.presence || task_name.presence + end + end + + setup do + @operator = users(:acme_admin) + post login_url, params: { email: @operator.email, password: "password123456" } + end + + test "an admin sees workflow runs" do + run = fake_run(workflow_name: "slack_sync", display_status: "running") + + with_workflow_runs(run) do + get console_workflows_url + end + + assert_response :ok + assert_select "h1", count: 0 + assert_select ".console-thread-group-title-active", text: /Workflows/ + assert_select "a[href=?]", console_workflow_path("slack_sync"), text: /slack_sync/ + assert_select "span", text: "running" + assert_select "a[href=?]", console_workflows_path + assert response.body.index('href="/console/workflows"') < response.body.index('href="/console/threads"') + end + + test "a non-admin is redirected away from the workflow dashboard" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + get console_workflows_url + + assert_redirected_to console_threads_path + assert_equal "That page is restricted to admins.", flash[:alert] + end + + test "a non-admin does not see the workflows tab" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + get console_threads_url + + assert_response :ok + assert_select ".console-nav-link", text: "Control", count: 0 + assert_select ".console-nav-link", text: "Data Sync", count: 0 + assert_select ".console-thread-group-title", text: /Chats/ + assert_select ".console-thread-group-title", text: /Workflows/, count: 0 + end + + test "workflow show page lists core metadata and historical runs" do + run = fake_run(workflow_name: "slack_sync", display_status: "completed", harness_type: "codex") + + with_workflow_history("slack_sync", run) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "dt", text: "Workflow" + assert_select "dd", text: /slack_sync/ + assert_select "dt", text: "Engine" + assert_select "dd", text: "codex" + assert_select "h1", "Historical Runs" + assert_select "tbody tr", count: 1 + end + + test "workflow show page returns not found for unknown workflow" do + with_workflow_history("missing") do + get console_workflow_url("missing") + end + + assert_response :not_found + assert_select "body", text: /No workflow runs found for missing/ + end + + test "workflows page handles unavailable workflow database" do + with_centaur_workflow_run_methods(available?: -> { false }) do + get console_workflows_url + end + + assert_response :ok + assert_select "body", text: /Workflow database is unavailable/ + assert_select "body", text: /No workflow runs available/ + end + + private + + def fake_run(attrs = {}) + now = Time.zone.parse("2026-07-06 12:00:00 UTC") + FakeWorkflowRun.new({ + workflow_name: "echo", + workflow_name_label: nil, + task_name: "centaur_workflow", + display_status: "completed", + queue_label: "default", + attempts: 1, + max_attempts: 3, + started_or_created_at: now, + created_at: now, + terminal_at: now + 2.minutes, + run_id: "00000000-0000-0000-0000-000000000001", + task_id: "00000000-0000-0000-0000-000000000002", + harness_type: nil + }.merge(attrs)) + end + + def with_workflow_runs(*runs) + with_centaur_workflow_run_methods( + available?: -> { true }, + recent: ->(limit:) { + runs + } + ) do + yield + end + end + + def with_workflow_history(workflow_name, *runs) + with_centaur_workflow_run_methods( + available?: -> { true }, + for_workflow: ->(name, limit:) { + name == workflow_name && limit.positive? ? runs : [] + } + ) do + yield + end + end + + def with_centaur_workflow_run_methods(overrides) + originals = overrides.keys.to_h { |name| [ name, CentaurWorkflowRun.method(name) ] } + + overrides.each do |name, implementation| + CentaurWorkflowRun.define_singleton_method(name, &implementation) + end + + yield + ensure + originals&.each do |name, original| + CentaurWorkflowRun.define_singleton_method(name, original) + end + end +end diff --git a/services/console/test/models/centaur_workflow_run_test.rb b/services/console/test/models/centaur_workflow_run_test.rb new file mode 100644 index 000000000..aa9a77ba8 --- /dev/null +++ b/services/console/test/models/centaur_workflow_run_test.rb @@ -0,0 +1,73 @@ +require "test_helper" + +class CentaurWorkflowRunTest < ActiveSupport::TestCase + setup do + ensure_workflow_runs_table + CentaurWorkflowRun.reset_column_information + end + + test "workflow runs are read only" do + assert CentaurWorkflowRun.new.readonly? + end + + test "display status derives useful terminal and running states" do + assert_equal "cancelled", workflow_run(cancelled_at: Time.current).display_status + assert_equal "failed", workflow_run(failed_at: Time.current).display_status + assert_equal "completed", workflow_run(completed_at: Time.current).display_status + assert_equal "running", workflow_run(claimed: true, state: "pending").display_status + assert_equal "sleeping", workflow_run(state: "sleeping").display_status + end + + test "queue and workflow labels have readable fallbacks" do + run = workflow_run(queue_name: "centaur_workflows_etl", workflow_name: nil, task_name: "task") + + assert_equal "etl", run.queue_label + assert_equal "task", run.workflow_name_label + end + + test "queue label removes the common queue prefix" do + run = workflow_run(queue_name: "centaur_workflows_etl_backfill") + + assert_equal "etl backfill", run.queue_label + end + + private + + def ensure_workflow_runs_table + return if CentaurWorkflowRun.connection.data_source_exists?(CentaurWorkflowRun.table_name) + + CentaurWorkflowRun.connection.create_table( + CentaurWorkflowRun.table_name, + id: false, + temporary: true + ) do |t| + t.string :queue_name + t.string :run_id + t.string :task_id + t.string :task_name + t.string :workflow_name + t.string :harness_type + t.string :state + t.integer :attempts + t.integer :max_attempts + t.datetime :created_at + t.datetime :first_started_at + t.datetime :started_at + t.datetime :completed_at + t.datetime :failed_at + t.datetime :available_at + t.boolean :claimed + t.datetime :cancelled_at + end + end + + def workflow_run(attrs = {}) + CentaurWorkflowRun.new({ + queue_name: "centaur_workflows", + workflow_name: "echo", + task_name: "centaur_workflow", + state: "pending", + claimed: false + }.merge(attrs)) + end +end From d3400951ee0fac4241b06cd98ab09d21a2f84f69 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:23:24 +0300 Subject: [PATCH 074/198] feat(console): admin self-descope to operator permissions (#936) feat(console): let admins temporarily descope to operator permissions Adds a per-session "View as operator" toggle for admins: - session[:descoped] flag with acting_admin?/descoped? helpers; require_admin and the default landing path now go through acting_admin? - Console::DescopesController (POST pauses admin perms, DELETE restores); descope route is a singular resource under /console - Admin-only nav (Control, Data Sync, Workflows, Users tab) hides while descoped; account menu gains a View as operator item - Persistent high-contrast amber banner while descoped with a Restore admin button - Light mode only: amber notice boxes (thread-DB unavailable, managed secret) get the same solid amber treatment via a shared .console-amber-note class Descope is self-healing: the flag is dropped automatically if the user is no longer an admin, so it can never outlive the privileges it pauses. Amp-Thread-ID: https://ampcode.com/threads/T-019f3d7c-9cab-726b-9753-92eb40d7fe61 Co-authored-by: Amp --- .../app/controllers/application_controller.rb | 24 +++++- .../console/descopes_controller.rb | 28 +++++++ .../app/views/console/_control_tabs.html.erb | 2 +- .../views/console/base_secrets/edit.html.erb | 2 +- .../app/views/console/threads/index.html.erb | 2 +- .../app/views/layouts/console.html.erb | 76 ++++++++++++++++++- services/console/config/routes.rb | 3 + .../console/descopes_controller_test.rb | 76 +++++++++++++++++++ 8 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 services/console/app/controllers/console/descopes_controller.rb create mode 100644 services/console/test/controllers/console/descopes_controller_test.rb diff --git a/services/console/app/controllers/application_controller.rb b/services/console/app/controllers/application_controller.rb index d201205e0..50f4fab09 100644 --- a/services/console/app/controllers/application_controller.rb +++ b/services/console/app/controllers/application_controller.rb @@ -9,7 +9,7 @@ class ApplicationController < ActionController::Base # controllers don't each hand-roll a rescue. Mirrors Api::BaseController. rescue_from ActiveRecord::RecordNotFound, with: :render_not_found - helper_method :current_user + helper_method :current_user, :acting_admin?, :descoped? helper_method :public_base_url, :oauth_callback_redirect_uri # The public origin the console is reached at. Derived from the request by @@ -65,6 +65,24 @@ def current_user @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] end + # Whether this admin has temporarily descoped themselves to operator + # permissions ("view as operator"). Self-healing: the flag is dropped if the + # user is no longer an admin, so it can never outlive the privileges it pauses. + def descoped? + return false unless session[:descoped] + return true if current_user&.admin? + + session.delete(:descoped) + false + end + + # The permission check console gates use instead of current_user.admin?: a + # real admin who is not currently descoped. Keeping current_user untouched + # means audit trails and data displays still see the true account. + def acting_admin? + current_user&.admin? && !descoped? + end + # before_action gate for console pages: bounce anonymous requests to the login # form rather than rendering the page. def require_login @@ -87,13 +105,13 @@ def require_active_account # management). Not a global gate. Bounces to the threads view rather than root: # root is the admin-only principals page, so redirecting there would loop. def require_admin - redirect_to console_threads_path, alert: "That page is restricted to admins." unless current_user&.admin? + redirect_to console_threads_path, alert: "That page is restricted to admins." unless acting_admin? end # Where a signed-in user lands when no explicit destination applies: admins get # the Control section, everyone else the threads view (their only section). def default_console_landing_path - current_user&.admin? ? console_principals_path : console_threads_path + acting_admin? ? console_principals_path : console_threads_path end # Cheap default so every page renders the empty sidebar list without touching diff --git a/services/console/app/controllers/console/descopes_controller.rb b/services/console/app/controllers/console/descopes_controller.rb new file mode 100644 index 000000000..e9e136acd --- /dev/null +++ b/services/console/app/controllers/console/descopes_controller.rb @@ -0,0 +1,28 @@ +module Console + # Admin "view as operator" support: an admin can temporarily pause their own + # admin permissions to see the console as a regular operator would. The flag + # lives in the cookie session; acting_admin? (the check every admin gate uses) + # is false while it's set, and ApplicationController drops it automatically if + # the user is no longer an admin. + # + # create is admin-gated. destroy is deliberately not: while descoped, the user + # fails require_admin, but they must always be able to restore themselves. + class DescopesController < ApplicationController + before_action :require_admin, only: :create + + def create + session[:descoped] = true + Rails.logger.info("console_descope_started admin=#{current_user.email}") + # No flash: the persistent descope banner already announces the state. + redirect_to console_threads_path + end + + def destroy + return redirect_to default_console_landing_path unless descoped? + + session.delete(:descoped) + Rails.logger.info("console_descope_stopped admin=#{current_user.email}") + redirect_to console_principals_path, notice: "Admin permissions restored." + end + end +end diff --git a/services/console/app/views/console/_control_tabs.html.erb b/services/console/app/views/console/_control_tabs.html.erb index ed26d519e..ffbe3aca7 100644 --- a/services/console/app/views/console/_control_tabs.html.erb +++ b/services/console/app/views/console/_control_tabs.html.erb @@ -5,7 +5,7 @@ { label: "Credentials", path: console_credentials_path, match: "/console/credentials" }, { label: "Apps", path: console_oauth_apps_path, match: "/console/oauth_apps" } ] %> -<% control_tabs << { label: "Users", path: console_users_path, match: "/console/users" } if current_user&.admin? %> +<% control_tabs << { label: "Users", path: console_users_path, match: "/console/users" } if acting_admin? %>
<% if (cred = managed_credential(@secret)) %> -
+
Managed secret. Maintained by the <% if cred.oauth_app %><%= cred.oauth_app.slug %> <% end %>OAuth integration (broker credential <%= cred.oid %>). diff --git a/services/console/app/views/console/threads/index.html.erb b/services/console/app/views/console/threads/index.html.erb index a12d5023a..e55fbb394 100644 --- a/services/console/app/views/console/threads/index.html.erb +++ b/services/console/app/views/console/threads/index.html.erb @@ -1,7 +1,7 @@ <% content_for :title, "Chats · Centaur Console" %> <% if @thread_db_unavailable %> -
+
Chat database is unavailable. Set CENTAUR_CONSOLE_CENTAUR_DATABASE_URL so Console can read the API session database.
diff --git a/services/console/app/views/layouts/console.html.erb b/services/console/app/views/layouts/console.html.erb index cbe20950e..f3e704557 100644 --- a/services/console/app/views/layouts/console.html.erb +++ b/services/console/app/views/layouts/console.html.erb @@ -97,6 +97,47 @@ background: #050506; } + /* While descoped, the body becomes a column: banner on top, shell + filling the rest. Scoped so the normal layout is untouched. */ + .console-descoped { + display: flex; + flex-direction: column; + } + + .console-descoped .console-shell { + flex: 1 1 auto; + min-height: 0; + height: auto; + } + + .console-descope-banner { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 0.5rem 1rem; + font-size: 0.75rem; + font-weight: 700; + color: #1c1205; + background: #f59e0b; + } + + .console-descope-restore { + cursor: pointer; + border-radius: 0.25rem; + border: 1px solid #1c1205; + padding: 0.125rem 0.625rem; + font-weight: 700; + color: #fbbf24; + background: #1c1205; + transition: opacity 120ms ease; + } + + .console-descope-restore:hover { + opacity: 0.85; + } + .console-sidebar { width: 18rem; flex: 0 0 auto; @@ -839,6 +880,16 @@ background: #fbfbf8; } + /* Amber notice boxes (thread-DB unavailable, managed secret) keep their + translucent amber-on-dark look in dark mode, but that washes out on the + light background; match the solid descope-banner treatment instead. */ + html[data-console-theme="light"] .console-amber-note { + background: #f59e0b; + border-color: #b45309; + color: #1c1205; + font-weight: 600; + } + html[data-console-theme="light"] .console-sidebar-account { border-top-color: #deded7; } @@ -1199,7 +1250,7 @@ <%# The Control and Data Sync sections are admin-only (each controller enforces require_admin server-side); non-admins only get the Chats view below. %> <% nav_items = [] %> - <% if current_user&.admin? %> + <% if acting_admin? %> <% control_matches = [ "/console/principals", "/console/roles", "/console/secrets", "/console/credentials", "/console/oauth_apps", "/console/users" ] %> <% nav_items = [ { label: "Control", icon: "shield-check", path: console_principals_path, matches: control_matches, root_active: true }, @@ -1207,7 +1258,14 @@ ] %> <% end %> - + "> + <% if descoped? %> +
+ Admin permissions paused — viewing the console as an operator + <%= button_to "Restore admin", console_descope_path, method: :delete, + class: "console-descope-restore" %> +
+ <% end %>
+
+ <%= @latest_run.task_name.presence || "workflow" %> + · + <%= pluralize(number_with_delimiter(@total_runs), "run") %> + <% if source_url %> + · + + <%= source_path %> ↗ + + <% end %> +
+
-
-
-
Status
-
- - <%= status.tr("_", " ") %> - -
-
-
-
Started
-
<%= local_time(@latest_run.started_or_created_at) %>
-
-
-
Attempts
-
- <%= @latest_run.attempts || 0 %> / <%= @latest_run.max_attempts.presence || "unlimited" %> -
-
-
-
Runs
-
<%= pluralize(@workflow_runs.size, "run") %>
-
+
+

Overview

+ <% schedule_text = nil %> + <% if schedule %> + <% kind = schedule["kind"].is_a?(Hash) ? schedule["kind"] : {} %> + <% schedule_text = [ + workflow_schedule_label(schedule), + (schedule["timezone"].presence if kind["type"] == "cron"), + ("disabled" if schedule["enabled"] == false) + ].compact.join(" · ") %> + <% end %> +
+ <% [ + [ "Queue", @queue_names.map { |queue_name| CentaurWorkflowRun.queue_label_for(queue_name) }.join(", ").presence || @latest_run.queue_label ], + [ "Engine", workflow_engine_label(@latest_run.harness_type) ], + ([ "Schedule", schedule_text ] if schedule_text.present?), + [ "Started", local_time(@latest_run.started_or_created_at) ], + [ "Duration", workflow_duration_label(@latest_run) ], + [ "Attempts", safe_join([ (@latest_run.attempts || 0).to_s, tag.span(" / #{@latest_run.max_attempts.presence || "unlimited"}", class: "text-zinc-600") ]) ], + [ "Runs", pluralize(number_with_delimiter(@total_runs), "run") ] + ].compact.each do |label, value| %> +
+
<%= label %>
+
<%= value.presence || tag.span("—", class: "text-zinc-600") %>
+
+ <% end %>
-

Historical Runs

+ <% debug_rows = [] %> + <% if @latest_run_detail.present? %> + <% input = @latest_run_detail["input"] %> + <% debug_rows << [ "Input", input, nil ] unless input.nil? || input == {} || input == "" %> + <% debug_rows << [ "Result", @latest_run_detail["result"], nil ] unless @latest_run_detail["result"].nil? %> + <% debug_rows << [ "Failure", @latest_run_detail["failure"], @latest_run_detail["run_id"] ] unless @latest_run_detail["failure"].nil? %> + <% end %> + <% if @latest_failure_detail.present? && @latest_failure_detail["failure"].present? %> + <% debug_rows << [ "Last failure", @latest_failure_detail["failure"], @latest_failure_detail["run_id"] ] %> + <% end %> + <% if debug_rows.any? %> +
+

Debugging

+
+ <% debug_rows.each do |label, value, run_id| %> +
+
"><%= label %>
+
+ <% if run_id.present? %> +
run <%= run_id %>
+ <% end %> +
<%= workflow_debug_json(value) %>
+
+
+ <% end %> +
+
+ <% end %> + +

Historical Runs

+ + <%# Filter tabs. Changing a filter resets to page 1; the pager keeps filters. %> + <% filter_url = ->(status: @status, queue: @queue) { + query = { status: status, queue: queue }.compact + query.empty? ? request.path : "#{request.path}?#{query.to_query}" + } %> + <% status_order = %w[running pending sleeping completed failed cancelled] %> + <% status_tabs = (status_order & @status_counts.keys) + (@status_counts.keys - status_order).sort %> +
+ <%= link_to filter_url.call(status: nil), class: "chip #{'chip-on' if @status.blank?}" do %> + all <%= number_with_delimiter(@total_runs) %> + <% end %> + <% status_tabs.each do |status_tab| %> + <%= link_to filter_url.call(status: status_tab), class: "chip #{'chip-on' if @status == status_tab}" do %> + <%= status_tab.tr("_", " ") %> <%= number_with_delimiter(@status_counts[status_tab]) %> + <% end %> + <% end %> +
+ + <% if @queue_names.size > 1 %> +
+ Queue + <%= link_to "all", filter_url.call(queue: nil), class: "chip #{'chip-on' if @queue.blank?}" %> + <% @queue_names.each do |queue_name| %> + <%= link_to CentaurWorkflowRun.queue_label_for(queue_name), + filter_url.call(queue: queue_name), + class: "chip #{'chip-on' if @queue == queue_name}" %> + <% end %> +
+ <% end %>
@@ -78,6 +151,14 @@ + <% if @workflow_runs.empty? %> + + + + <% end %> + <% @workflow_runs.each do |run| %> <% run_status = run.display_status %> @@ -114,5 +195,11 @@ <% end %>
+ No runs match the selected filters. +
+ + <%= render "pagination", + page: @page, + total_pages: @total_pages, + total_count: @filtered_count.to_i, + unit: "run" %>
<% end %> diff --git a/services/console/app/views/layouts/console.html.erb b/services/console/app/views/layouts/console.html.erb index a3f2bac7f..b61911859 100644 --- a/services/console/app/views/layouts/console.html.erb +++ b/services/console/app/views/layouts/console.html.erb @@ -1136,6 +1136,35 @@ color: #5f656c; } + html[data-console-theme="light"] .page-title { + color: #303438; + } + + html[data-console-theme="light"] .page-subtitle { + color: #6f757c; + } + + /* Toggle chips (filter tabs, HTTP methods): the component's @apply'd + ink colors compile to raw values, so the utility-class remaps above + don't reach them -- restyle the component classes directly. */ + html[data-console-theme="light"] .chip { + border-color: #d2d3cc; + background: #ffffff; + color: #5f656c; + } + + html[data-console-theme="light"] .chip:hover { + border-color: #168f4a80; + color: #303438; + } + + html[data-console-theme="light"] .chip-on, + html[data-console-theme="light"] .chip-on:hover { + border-color: #168f4a; + background: rgba(22, 143, 74, 0.08); + color: #168f4a; + } + html[data-console-theme="light"] .empty-state { border-color: #d8d8d0; background: #f0f0eb; diff --git a/services/console/config/routes.rb b/services/console/config/routes.rb index e82be9250..db89381ae 100644 --- a/services/console/config/routes.rb +++ b/services/console/config/routes.rb @@ -41,7 +41,11 @@ get "console/principals/:id", to: "console#principal", as: :console_principal namespace :console do resources :threads, only: %i[index create] - resources :workflows, only: %i[index show] + resources :workflows, only: %i[index show] do + member do + post :run, action: :force_start + end + end # Lazily-loaded sidebar thread list (Turbo Frame src). Kept off the main # page render so the unindexed cross-database sessions query does not block # every console page. See ApplicationController#load_console_sidebar_threads. diff --git a/services/console/test/controllers/console/workflows_controller_test.rb b/services/console/test/controllers/console/workflows_controller_test.rb index b730f6c87..071f1b4bb 100644 --- a/services/console/test/controllers/console/workflows_controller_test.rb +++ b/services/console/test/controllers/console/workflows_controller_test.rb @@ -6,15 +6,18 @@ class Console::WorkflowsControllerTest < ActionDispatch::IntegrationTest :workflow_name_label, :task_name, :display_status, + :queue_name, :queue_label, :attempts, :max_attempts, :started_or_created_at, :created_at, :terminal_at, + :recency_at, :run_id, :task_id, :harness_type, + :queue_run_count, keyword_init: true ) do def workflow_name_label @@ -24,17 +27,59 @@ def workflow_name_label def workflow_key workflow_name.presence || task_name.presence end + + def recency_at + self[:recency_at] || terminal_at || started_or_created_at + end + end + + # Stands in for CentaurApiClient: schedules/run details for show-page + # enrichment, plus a capture of force-started runs. + class FakeApiClient + attr_reader :created_runs + + def initialize(schedules: [], run_details: {}, create_result: nil, create_error: nil) + @schedules = schedules + @run_details = run_details + @create_result = create_result || { "ok" => true, "run_id" => "run-new", "created" => true } + @create_error = create_error + @created_runs = [] + end + + def list_workflow_schedules + { "ok" => true, "schedules" => @schedules } + end + + def get_workflow_run(run_id) + detail = @run_details[run_id] + raise CentaurApiClient::Error, "run not found" unless detail + + { "ok" => true, "run" => detail } + end + + def create_workflow_run(workflow_name:, input: nil) + raise CentaurApiClient::Error, @create_error if @create_error + + @created_runs << { workflow_name: workflow_name, input: input } + @create_result + end end setup do + @original_client_factory = Console::WorkflowsController.client_factory + with_api_client(FakeApiClient.new) @operator = users(:acme_admin) post login_url, params: { email: @operator.email, password: "password123456" } end - test "an admin sees workflow runs" do + teardown do + Console::WorkflowsController.client_factory = @original_client_factory + end + + test "an admin sees one row per workflow" do run = fake_run(workflow_name: "slack_sync", display_status: "running") - with_workflow_runs(run) do + with_workflow_index(runs: [ run ]) do get console_workflows_url end @@ -47,6 +92,52 @@ def workflow_key assert response.body.index('href="/console/workflows"') < response.body.index('href="/console/threads"') end + test "a workflow with runs in several queues lists each queue on its own line" do + run = fake_run(workflow_name: "slack_backfill", queue_name: "centaur_workflows_etl_backfill", queue_label: "etl backfill") + queue_runs = [ + fake_run(workflow_name: "slack_backfill", queue_name: "centaur_workflows_etl_backfill", queue_label: "etl backfill", queue_run_count: 7), + fake_run(workflow_name: "slack_backfill", queue_name: "centaur_workflows_slack_live", queue_label: "slack live", display_status: "running", queue_run_count: 2) + ] + + with_workflow_index(runs: [ run ], queue_breakdown: { "slack_backfill" => queue_runs }) do + get console_workflows_url + end + + assert_response :ok + assert_select "tbody tr", count: 1 + assert_match "etl backfill", response.body + assert_match "slack live", response.body + assert_match "├", response.body + assert_match "└", response.body + assert_match "7 runs", response.body + end + + test "the workflow index does not show run ids" do + run = fake_run(workflow_name: "slack_sync") + + with_workflow_index(runs: [ run ]) do + get console_workflows_url + end + + assert_response :ok + assert_no_match run.run_id, response.body + assert_no_match run.task_id, response.body + end + + test "the workflow index is paginated" do + runs = 3.times.map { |i| fake_run(workflow_name: "wf_#{i}") } + + with_workflow_index(runs: runs, workflow_count: 120) do + get console_workflows_url, params: { page: 2 } + end + + assert_response :ok + assert_match "120 workflows", response.body + assert_match "page 2 of 3", response.body + assert_select "a", text: "Previous" + assert_select "a", text: "Next" + end + test "a non-admin is redirected away from the workflow dashboard" do delete logout_url post login_url, params: { email: users(:member_user).email, password: "password123456" } @@ -73,17 +164,184 @@ def workflow_key test "workflow show page lists core metadata and historical runs" do run = fake_run(workflow_name: "slack_sync", display_status: "completed", harness_type: "codex") - with_workflow_history("slack_sync", run) do + with_workflow_history("slack_sync", runs: [ run ]) do get console_workflow_url("slack_sync") end assert_response :ok - assert_select "dt", text: "Workflow" - assert_select "dd", text: /slack_sync/ + assert_select "h1.page-title", text: /slack_sync/ assert_select "dt", text: "Engine" - assert_select "dd", text: "codex" - assert_select "h1", "Historical Runs" + assert_select "dd", text: "Codex" + assert_select "h2", "Historical Runs" assert_select "tbody tr", count: 1 + assert_select "form[action=?]", run_console_workflow_path("slack_sync") + end + + test "workflow show page renders status filter tabs with counts" do + run = fake_run(workflow_name: "slack_sync", display_status: "completed") + + with_workflow_history( + "slack_sync", + runs: [ run ], + status_counts: { "completed" => 9, "failed" => 1 } + ) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "a.chip", text: /all\s*10/ + assert_select "a.chip", text: /completed\s*9/ + assert_select "a.chip", text: /failed\s*1/ + assert_select "dd", text: /10 runs/ + end + + test "workflow show page marks the active status tab and passes the filter through" do + run = fake_run(workflow_name: "slack_sync", display_status: "failed") + seen = {} + + with_workflow_history( + "slack_sync", + runs: [ run ], + status_counts: { "completed" => 9, "failed" => 1 }, + capture: seen + ) do + get console_workflow_url("slack_sync"), params: { status: "failed" } + end + + assert_response :ok + assert_equal "failed", seen[:status] + assert_select "a.chip-on", text: /failed\s*1/ + end + + test "workflow show page renders queue tabs when several queues exist" do + run = fake_run(workflow_name: "slack_sync") + + with_workflow_history( + "slack_sync", + runs: [ run ], + queue_names: %w[centaur_workflows_etl centaur_workflows_slack_live] + ) do + get console_workflow_url("slack_sync"), params: { queue: "centaur_workflows_slack_live" } + end + + assert_response :ok + assert_select "a.chip", text: "etl" + assert_select "a.chip-on", text: "slack live" + end + + test "workflow show page paginates historical runs" do + runs = 2.times.map { |i| fake_run(workflow_name: "slack_sync", run_id: "run-#{i}") } + + with_workflow_history("slack_sync", runs: runs, run_count: 130) do + get console_workflow_url("slack_sync"), params: { page: 2 } + end + + assert_response :ok + assert_match "130 runs", response.body + assert_match "page 2 of 3", response.body + end + + test "workflow show page shows the schedule and source link when registered" do + run = fake_run(workflow_name: "slack_sync", harness_type: "codex") + with_api_client(FakeApiClient.new(schedules: [ slack_sync_schedule ])) + + with_workflow_history("slack_sync", runs: [ run ]) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "dt", text: "Schedule" + assert_select "dd", text: /cron \*\/5 \* \* \* \* · America\/Los_Angeles/ + assert_select "a[href=?]", + "https://github.com/paradigmxyz/centaur/blob/main/workflows/slack/sync.py", + text: /workflows\/slack\/sync\.py/ + end + + test "workflow show page links overlay-repo workflow sources to the overlay repo" do + run = fake_run(workflow_name: "consensus_ci_triage") + schedule = slack_sync_schedule.merge( + "workflow_name" => "consensus_ci_triage", + "source_path" => "centaur-tempo/workflows/consensus_ci_triage.py" + ) + with_api_client(FakeApiClient.new(schedules: [ schedule ])) + + with_workflow_history("consensus_ci_triage", runs: [ run ]) do + get console_workflow_url("consensus_ci_triage") + end + + assert_response :ok + assert_select "a[href=?]", + "https://github.com/tempoxyz/centaur-tempo/blob/main/workflows/consensus_ci_triage.py" + end + + test "workflow show page surfaces the latest run's input and failure for debugging" do + run = fake_run(workflow_name: "slack_sync", display_status: "failed") + with_api_client( + FakeApiClient.new( + run_details: { + run.run_id => { + "run_id" => run.run_id, + "input" => { "mode" => "full" }, + "failure" => { "error" => "boom exploded" } + } + } + ) + ) + + with_workflow_history("slack_sync", runs: [ run ]) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "h2", text: "Debugging" + assert_select "dt", text: "Input" + assert_select "dt", text: "Failure" + assert_match "boom exploded", response.body + end + + test "workflow show page renders without api enrichment when the api is down" do + run = fake_run(workflow_name: "slack_sync") + with_api_client(FakeApiClient.new(run_details: {})) + + with_workflow_history("slack_sync", runs: [ run ]) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "h2", text: "Debugging", count: 0 + assert_select "dt", text: "Schedule", count: 0 + end + + test "force starting a workflow queues a run with the schedule input" do + client = FakeApiClient.new(schedules: [ slack_sync_schedule ]) + with_api_client(client) + + post run_console_workflow_url("slack_sync") + + assert_redirected_to console_workflow_path("slack_sync") + assert_match(/Run queued \(run-new\)/, flash[:notice]) + assert_equal [ { workflow_name: "slack_sync", input: { "mode" => "incremental" } } ], client.created_runs + end + + test "force starting a workflow surfaces api errors" do + with_api_client(FakeApiClient.new(create_error: "workflow runtime is not enabled")) + + post run_console_workflow_url("slack_sync") + + assert_redirected_to console_workflow_path("slack_sync") + assert_match(/workflow runtime is not enabled/, flash[:alert]) + end + + test "a non-admin cannot force start a workflow" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + client = FakeApiClient.new + with_api_client(client) + + post run_console_workflow_url("slack_sync") + + assert_redirected_to console_threads_path + assert_empty client.created_runs end test "workflow show page returns not found for unknown workflow" do @@ -107,6 +365,23 @@ def workflow_key private + def with_api_client(client) + Console::WorkflowsController.client_factory = -> { client } + end + + def slack_sync_schedule + { + "schedule_id" => "slack_sync", + "workflow_name" => "slack_sync", + "source_path" => "workflows/slack/sync.py", + "kind" => { "type" => "cron", "cron" => "*/5 * * * *" }, + "timezone" => "America/Los_Angeles", + "input" => { "mode" => "incremental" }, + "enabled" => true, + "no_delivery" => false + } + end + def fake_run(attrs = {}) now = Time.zone.parse("2026-07-06 12:00:00 UTC") FakeWorkflowRun.new({ @@ -114,35 +389,43 @@ def fake_run(attrs = {}) workflow_name_label: nil, task_name: "centaur_workflow", display_status: "completed", + queue_name: "centaur_workflows", queue_label: "default", attempts: 1, max_attempts: 3, started_or_created_at: now, created_at: now, terminal_at: now + 2.minutes, + recency_at: nil, run_id: "00000000-0000-0000-0000-000000000001", task_id: "00000000-0000-0000-0000-000000000002", - harness_type: nil + harness_type: nil, + queue_run_count: 1 }.merge(attrs)) end - def with_workflow_runs(*runs) + def with_workflow_index(runs:, queue_breakdown: {}, workflow_count: nil) with_centaur_workflow_run_methods( available?: -> { true }, - recent: ->(limit:) { - runs - } + workflow_count: -> { workflow_count || runs.size }, + latest_per_workflow: ->(limit:, offset: 0) { runs }, + latest_per_queue: ->(keys) { queue_breakdown } ) do yield end end - def with_workflow_history(workflow_name, *runs) + def with_workflow_history(workflow_name, runs: [], status_counts: nil, queue_names: [], run_count: nil, capture: nil) + status_counts ||= runs.group_by(&:display_status).transform_values(&:size) with_centaur_workflow_run_methods( available?: -> { true }, - for_workflow: ->(name, limit:) { + for_workflow: ->(name, limit:, offset: 0, status: nil, queue: nil) { + capture&.merge!(status: status, queue: queue, offset: offset) name == workflow_name && limit.positive? ? runs : [] - } + }, + status_counts: ->(name) { name == workflow_name ? status_counts : {} }, + queue_names: ->(name) { name == workflow_name ? queue_names : [] }, + run_count: ->(name, status: nil, queue: nil) { run_count || runs.size } ) do yield end diff --git a/services/console/test/models/centaur_workflow_run_test.rb b/services/console/test/models/centaur_workflow_run_test.rb index aa9a77ba8..1cadbfe5b 100644 --- a/services/console/test/models/centaur_workflow_run_test.rb +++ b/services/console/test/models/centaur_workflow_run_test.rb @@ -4,6 +4,7 @@ class CentaurWorkflowRunTest < ActiveSupport::TestCase setup do ensure_workflow_runs_table CentaurWorkflowRun.reset_column_information + CentaurWorkflowRun.delete_all end test "workflow runs are read only" do @@ -31,6 +32,86 @@ class CentaurWorkflowRunTest < ActiveSupport::TestCase assert_equal "etl backfill", run.queue_label end + test "latest_per_workflow returns one row per workflow, newest activity first" do + insert_run(workflow_name: "alpha", completed_at: 3.hours.ago) + insert_run(workflow_name: "alpha", completed_at: 1.hour.ago) + insert_run(workflow_name: "beta", completed_at: 2.hours.ago) + + runs = CentaurWorkflowRun.latest_per_workflow(limit: 10) + + assert_equal %w[alpha beta], runs.map(&:workflow_key) + assert_in_delta 1.hour.ago.to_i, runs.first.completed_at.to_i, 5 + assert_equal 2, CentaurWorkflowRun.workflow_count + end + + test "latest_per_workflow groups blank workflow names under the task name" do + insert_run(workflow_name: nil, task_name: "legacy_task", completed_at: 1.hour.ago) + insert_run(workflow_name: "", task_name: "legacy_task", completed_at: 2.hours.ago) + + runs = CentaurWorkflowRun.latest_per_workflow(limit: 10) + + assert_equal [ "legacy_task" ], runs.map(&:workflow_key) + assert_equal 1, CentaurWorkflowRun.workflow_count + end + + test "latest_per_workflow paginates" do + insert_run(workflow_name: "alpha", completed_at: 1.hour.ago) + insert_run(workflow_name: "beta", completed_at: 2.hours.ago) + insert_run(workflow_name: "gamma", completed_at: 3.hours.ago) + + page_two = CentaurWorkflowRun.latest_per_workflow(limit: 2, offset: 2) + + assert_equal %w[gamma], page_two.map(&:workflow_key) + end + + test "latest_per_queue returns the newest run per queue with run counts" do + insert_run(workflow_name: "alpha", queue_name: "centaur_workflows_etl", completed_at: 3.hours.ago) + insert_run(workflow_name: "alpha", queue_name: "centaur_workflows_etl", completed_at: 2.hours.ago) + insert_run(workflow_name: "alpha", queue_name: "centaur_workflows_live", completed_at: 1.hour.ago) + insert_run(workflow_name: "beta", queue_name: "centaur_workflows_etl", completed_at: 1.hour.ago) + + breakdown = CentaurWorkflowRun.latest_per_queue(%w[alpha]) + + assert_equal %w[alpha], breakdown.keys + queue_runs = breakdown["alpha"] + assert_equal %w[centaur_workflows_live centaur_workflows_etl], queue_runs.map(&:queue_name) + assert_equal [ 1, 2 ], queue_runs.map { |run| run.queue_run_count.to_i } + end + + test "for_workflow filters by status and queue and paginates" do + insert_run(workflow_name: "alpha", queue_name: "centaur_workflows_etl", completed_at: 1.hour.ago) + insert_run(workflow_name: "alpha", queue_name: "centaur_workflows_etl", failed_at: 2.hours.ago) + insert_run(workflow_name: "alpha", queue_name: "centaur_workflows_live", completed_at: 3.hours.ago) + + completed = CentaurWorkflowRun.for_workflow("alpha", limit: 10, status: "completed") + assert_equal 2, completed.size + assert completed.all? { |run| run.display_status == "completed" } + + live_only = CentaurWorkflowRun.for_workflow("alpha", limit: 10, queue: "centaur_workflows_live") + assert_equal 1, live_only.size + + page_two = CentaurWorkflowRun.for_workflow("alpha", limit: 2, offset: 2) + assert_equal 1, page_two.size + + assert_equal 2, CentaurWorkflowRun.run_count("alpha", status: "completed") + assert_equal 3, CentaurWorkflowRun.run_count("alpha") + end + + test "status_counts and queue_names summarize a workflow's runs" do + insert_run(workflow_name: "alpha", queue_name: "centaur_workflows_etl", completed_at: 1.hour.ago) + insert_run(workflow_name: "alpha", queue_name: "centaur_workflows_live", failed_at: 2.hours.ago) + insert_run(workflow_name: "alpha", queue_name: "centaur_workflows_live", claimed: true, state: "running") + + assert_equal( + { "completed" => 1, "failed" => 1, "running" => 1 }, + CentaurWorkflowRun.status_counts("alpha") + ) + assert_equal( + %w[centaur_workflows_etl centaur_workflows_live], + CentaurWorkflowRun.queue_names("alpha") + ) + end + private def ensure_workflow_runs_table @@ -62,12 +143,30 @@ def ensure_workflow_runs_table end def workflow_run(attrs = {}) - CentaurWorkflowRun.new({ + CentaurWorkflowRun.new(default_run_attributes.merge(attrs)) + end + + def insert_run(attrs = {}) + @run_sequence = (@run_sequence || 0) + 1 + CentaurWorkflowRun.insert_all!( + [ + default_run_attributes.merge( + run_id: format("00000000-0000-0000-0000-%012d", @run_sequence), + task_id: format("11111111-0000-0000-0000-%012d", @run_sequence), + created_at: 1.day.ago + ).merge(attrs) + ], + returning: false + ) + end + + def default_run_attributes + { queue_name: "centaur_workflows", workflow_name: "echo", task_name: "centaur_workflow", state: "pending", claimed: false - }.merge(attrs)) + } end end diff --git a/services/console/test/services/centaur_api_client_test.rb b/services/console/test/services/centaur_api_client_test.rb index 6cdb96cd0..7dcc49eb5 100644 --- a/services/console/test/services/centaur_api_client_test.rb +++ b/services/console/test/services/centaur_api_client_test.rb @@ -162,4 +162,36 @@ def call(method:, url:, body:, headers:, timeout:) assert_equal [ '{"type":"user"}' ], body["input_lines"] assert_equal "idem-1", body["idempotency_key"] end + + test "lists workflow schedules and fetches run details" do + http = StubHTTP.new(status: 200, body: { ok: true, schedules: [] }.to_json) + client = CentaurApiClient.new(base_url: "http://api.internal:8080", http: http) + + client.list_workflow_schedules + client.get_workflow_run("run:1") + + schedules = http.requests.first + assert_equal :get, schedules[:method] + assert_equal "http://api.internal:8080/api/workflows/schedules", schedules[:url] + + run = http.requests.second + assert_equal :get, run[:method] + assert_equal "http://api.internal:8080/api/workflows/runs/run%3A1", run[:url] + end + + test "creates workflow runs with optional input" do + http = StubHTTP.new(status: 200, body: { ok: true, run_id: "r1" }.to_json) + client = CentaurApiClient.new(base_url: "http://api.internal:8080", http: http) + + client.create_workflow_run(workflow_name: "slack_sync") + client.create_workflow_run(workflow_name: "slack_sync", input: { "mode" => "full" }) + + bare = http.requests.first + assert_equal :post, bare[:method] + assert_equal "http://api.internal:8080/api/workflows/runs", bare[:url] + assert_equal({ "workflow_name" => "slack_sync" }, JSON.parse(bare[:body])) + + with_input = http.requests.second + assert_equal({ "workflow_name" => "slack_sync", "input" => { "mode" => "full" } }, JSON.parse(with_input[:body])) + end end From be77b982d2acf8a54478f24053fe139b0b8056dd Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 10:07:53 -0600 Subject: [PATCH 099/198] feat: add Slack file proxy API (#961) * feat: add Slack file proxy API * feat: support Slack upload metadata * fix: harden Slack file proxy responses * feat: add scoped Slack search proxy * refactor: rename Slack proxy module * fix: drop Slack search and harden file proxy Remove the Slack search endpoints entirely while keeping the Slack file upload and download proxy. Harden the remaining file proxy path with stricter download validation, shared timeout-aware Slack client setup, content type validation, case-insensitive bearer auth, safer response headers, and shared JWT/env configuration. --- services/api-rs/Cargo.lock | 47 +- services/api-rs/Cargo.toml | 3 +- .../crates/centaur-api-server/Cargo.toml | 1 + .../crates/centaur-api-server/src/lib.rs | 1 + .../crates/centaur-api-server/src/mcp.rs | 2 +- .../crates/centaur-api-server/src/routes.rs | 6 +- .../centaur-api-server/src/slack_proxy.rs | 908 ++++++++++++++++++ 7 files changed, 960 insertions(+), 8 deletions(-) create mode 100644 services/api-rs/crates/centaur-api-server/src/slack_proxy.rs diff --git a/services/api-rs/Cargo.lock b/services/api-rs/Cargo.lock index 8f80ffd79..2ac1c3739 100644 --- a/services/api-rs/Cargo.lock +++ b/services/api-rs/Cargo.lock @@ -186,6 +186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -814,6 +815,7 @@ dependencies = [ "futures-util", "hex", "hmac 0.12.1", + "jsonwebtoken", "kube", "reqwest", "rustls 0.23.40", @@ -2532,6 +2534,22 @@ dependencies = [ "thiserror", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature", + "zeroize", +] + [[package]] name = "k8s-openapi" version = "0.27.1" @@ -3638,6 +3656,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls 0.26.4", @@ -3672,7 +3691,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3808,7 +3827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3820,7 +3839,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3897,7 +3916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5048,6 +5067,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -5840,6 +5865,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/services/api-rs/Cargo.toml b/services/api-rs/Cargo.toml index ff383d4a2..07b789c09 100644 --- a/services/api-rs/Cargo.toml +++ b/services/api-rs/Cargo.toml @@ -67,13 +67,14 @@ futures-util = { version = "0.3", features = ["sink"] } hmac = "0.12" hex = "0.4" jiff = "0.2" +jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } k8s-openapi = { version = "0.27.1", features = ["latest"] } kube = "3.1.0" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" sha2 = "0.10" -reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream"] } +reqwest = { version = "0.13.4", default-features = false, features = ["form", "json", "rustls", "stream"] } ratatui = "0.29" rustls = "0.23" opentelemetry = "0.32.0" diff --git a/services/api-rs/crates/centaur-api-server/Cargo.toml b/services/api-rs/crates/centaur-api-server/Cargo.toml index 82d015e98..62b07a426 100644 --- a/services/api-rs/crates/centaur-api-server/Cargo.toml +++ b/services/api-rs/crates/centaur-api-server/Cargo.toml @@ -27,6 +27,7 @@ eventsource-stream.workspace = true futures-util.workspace = true hmac.workspace = true hex.workspace = true +jsonwebtoken.workspace = true kube.workspace = true reqwest.workspace = true rustls.workspace = true diff --git a/services/api-rs/crates/centaur-api-server/src/lib.rs b/services/api-rs/crates/centaur-api-server/src/lib.rs index a57358de4..775306686 100644 --- a/services/api-rs/crates/centaur-api-server/src/lib.rs +++ b/services/api-rs/crates/centaur-api-server/src/lib.rs @@ -2,6 +2,7 @@ pub mod client; mod error; mod mcp; mod routes; +mod slack_proxy; mod tool_discovery; pub mod types; diff --git a/services/api-rs/crates/centaur-api-server/src/mcp.rs b/services/api-rs/crates/centaur-api-server/src/mcp.rs index cf8b03760..6bb18801e 100644 --- a/services/api-rs/crates/centaur-api-server/src/mcp.rs +++ b/services/api-rs/crates/centaur-api-server/src/mcp.rs @@ -751,7 +751,7 @@ fn static_env(cell: &'static OnceLock>, name: &str) -> Option Option { +pub(crate) fn jwt_signing_secret() -> Option { static CELL: OnceLock> = OnceLock::new(); static_env(&CELL, "CENTAUR_JWT_SIGNING_SECRET") } diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index 389ae3583..c63cc2882 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -55,6 +55,7 @@ use uuid::Uuid; use crate::{ ApiError, mcp::{mcp_get, mcp_post, mcp_protected_resource_metadata}, + slack_proxy::slack_proxy_router, types::{ AppendMessagesRequest, AppendMessagesResponse, CreateSessionRequest, CreateSessionResponse, EmitWorkflowEventRequest, EventsQuery, ExecuteSessionRequest, ExecuteSessionResponse, @@ -226,6 +227,7 @@ pub fn build_router_with_app_state(state: AppState) -> Router { ) .route("/api/session/{thread_key}/events", get(stream_events)) .route("/api/sandboxes/drain", post(drain_sandboxes)) + .merge(slack_proxy_router()) .route("/api/workflows/schedules", get(list_workflow_schedules)) .route( "/api/workflows/runs", @@ -2329,14 +2331,14 @@ fn slack_archive_upload_config() -> Result { }) } -fn non_empty_env(name: &str) -> Option { +pub(crate) fn non_empty_env(name: &str) -> Option { env::var(name) .ok() .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()) } -fn positive_env_u64(name: &str, default: u64) -> u64 { +pub(crate) fn positive_env_u64(name: &str, default: u64) -> u64 { env::var(name) .ok() .and_then(|value| value.parse::().ok()) diff --git a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs new file mode 100644 index 000000000..93aa3deef --- /dev/null +++ b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs @@ -0,0 +1,908 @@ +use std::{collections::BTreeSet, sync::OnceLock, time::Duration}; + +use axum::{ + Json, Router, + body::Body, + extract::{DefaultBodyLimit, Path, Query}, + http::{HeaderMap, HeaderValue, header}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{ + ApiError, + mcp::jwt_signing_secret, + routes::{AppState, non_empty_env, positive_env_u64}, +}; + +const DEFAULT_SLACK_API_URL: &str = "https://slack.com/api"; +const DEFAULT_API_JWT_AUDIENCE: &str = "centaur-api"; +const DEFAULT_API_JWT_ISSUER: &str = "centaur-console"; +const DEFAULT_MAX_UPLOAD_BYTES: u64 = 100 * 1024 * 1024; +const JWT_CLOCK_SKEW_SECONDS: i64 = 30; +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(60); + +fn http_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .read_timeout(HTTP_READ_TIMEOUT) + .build() + .expect("reqwest client configuration is valid") + }) +} + +pub(crate) fn slack_proxy_router() -> Router { + Router::new() + .route( + "/api/slack/files/upload", + post(upload_slack_file).layer(DefaultBodyLimit::disable()), + ) + .route( + "/api/slack/files/{file_id}/download", + get(download_slack_file), + ) +} + +#[derive(Debug, Deserialize)] +struct SlackFileUploadQuery { + channel_id: String, + filename: String, + #[serde(default)] + thread_ts: Option, + #[serde(default)] + title: Option, + #[serde(default)] + initial_comment: Option, + #[serde(default)] + content_type: Option, + #[serde(default)] + alt_txt: Option, + #[serde(default)] + snippet_type: Option, +} + +#[derive(Debug, Deserialize)] +struct SlackFileDownloadQuery { + channel_id: String, +} + +#[derive(Debug, Deserialize)] +struct SlackFileProxyClaims { + iat: i64, + slack: SlackProxyClaims, + #[serde(default)] + sub: Option, +} + +#[derive(Debug, Deserialize)] +struct SlackProxyClaims { + #[serde(default)] + upload_channels: Vec, + #[serde(default)] + download_channels: Vec, +} + +#[derive(Debug, Serialize)] +struct SlackFileUploadResponse { + ok: bool, + file_id: String, + channel_id: String, + thread_ts: Option, + file: Value, +} + +async fn upload_slack_file( + headers: HeaderMap, + Query(query): Query, + body: Body, +) -> Result, ApiError> { + let claims = authorize_slack_file_proxy(&headers)?; + ensure_upload_channel_allowed(&claims, &query.channel_id)?; + validate_slack_channel_id(&query.channel_id)?; + validate_filename(&query.filename)?; + if let Some(thread_ts) = query.thread_ts.as_deref() { + validate_slack_thread_ts(thread_ts)?; + } + if let Some(content_type) = query.content_type.as_deref() { + validate_content_type(content_type)?; + } + let config = slack_proxy_config()?; + let content_length = content_length(&headers)?; + ensure_upload_size(content_length, config.max_upload_bytes)?; + let client = http_client(); + let upload_ticket = get_upload_url( + client, + config, + &query.filename, + content_length, + query.alt_txt.as_deref(), + query.snippet_type.as_deref(), + ) + .await?; + upload_file_bytes( + client, + &upload_ticket.upload_url, + body, + content_length, + query.content_type.as_deref(), + ) + .await?; + let file = complete_upload( + client, + config, + &upload_ticket.file_id, + &query.channel_id, + query.thread_ts.as_deref(), + query.title.as_deref().unwrap_or(&query.filename), + query.initial_comment.as_deref(), + ) + .await?; + + Ok(Json(SlackFileUploadResponse { + ok: true, + file_id: upload_ticket.file_id, + channel_id: query.channel_id, + thread_ts: query.thread_ts, + file, + })) +} + +async fn download_slack_file( + headers: HeaderMap, + Path(file_id): Path, + Query(query): Query, +) -> Result { + let claims = authorize_slack_file_proxy(&headers)?; + ensure_download_channel_allowed(&claims, &query.channel_id)?; + validate_slack_channel_id(&query.channel_id)?; + validate_slack_file_id(&file_id)?; + + let config = slack_proxy_config()?; + let client = http_client(); + let file = slack_file_info(client, config, &file_id).await?; + if !slack_file_in_channel(&file, &query.channel_id) { + return Err(ApiError::Forbidden( + "file is not shared in an allowed Slack channel".to_owned(), + )); + } + let download_url = file + .get("url_private_download") + .or_else(|| file.get("url_private")) + .and_then(Value::as_str) + .ok_or_else(|| ApiError::BadRequest("Slack file has no download URL".to_owned()))?; + + let upstream = client + .get(download_url) + .bearer_auth(&config.bot_token) + .send() + .await + .map_err(|error| ApiError::Internal(format!("Slack file download failed: {error}")))?; + if !upstream.status().is_success() { + return Err(ApiError::BadRequest(format!( + "Slack file download failed with status {}", + upstream.status().as_u16() + ))); + } + + let file_mimetype = file.get("mimetype").and_then(Value::as_str); + // Slack's file host serves login/error pages with a 200 status; without this + // check they would stream through labeled as the file's real mimetype. + let upstream_content_type = upstream + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()); + if upstream_body_is_unexpected_html(upstream_content_type, file_mimetype) { + return Err(ApiError::Internal( + "Slack file download returned an HTML page instead of the file contents".to_owned(), + )); + } + + let upstream_content_length = upstream.headers().get(header::CONTENT_LENGTH).cloned(); + let mut response = Body::from_stream(upstream.bytes_stream()).into_response(); + let headers = response.headers_mut(); + if let Some(value) = file_mimetype.and_then(|value| value.parse().ok()) { + headers.insert(header::CONTENT_TYPE, value); + } + if let Some(value) = upstream_content_length { + headers.insert(header::CONTENT_LENGTH, value); + } + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + let filename = file + .get("name") + .or_else(|| file.get("title")) + .and_then(Value::as_str) + .unwrap_or(&file_id); + if let Ok(value) = content_disposition_filename(filename).parse::() { + headers.insert(header::CONTENT_DISPOSITION, value); + } + Ok(response) +} + +fn upstream_body_is_unexpected_html( + upstream_content_type: Option<&str>, + file_mimetype: Option<&str>, +) -> bool { + let upstream_is_html = upstream_content_type.is_some_and(|value| { + value + .trim_start() + .to_ascii_lowercase() + .starts_with("text/html") + }); + let file_is_html = file_mimetype.is_some_and(|value| value.eq_ignore_ascii_case("text/html")); + upstream_is_html && !file_is_html +} + +// No Debug derive: bot_token must not end up in logs via {:?} formatting. +struct SlackFileProxyConfig { + api_url: String, + bot_token: String, + max_upload_bytes: u64, +} + +fn slack_proxy_config() -> Result<&'static SlackFileProxyConfig, ApiError> { + static CELL: OnceLock = OnceLock::new(); + if let Some(config) = CELL.get() { + return Ok(config); + } + let config = SlackFileProxyConfig::from_env()?; + Ok(CELL.get_or_init(|| config)) +} + +impl SlackFileProxyConfig { + fn from_env() -> Result { + let bot_token = non_empty_env("SLACK_BOT_TOKEN") + .ok_or_else(|| ApiError::Internal("SLACK_BOT_TOKEN is not configured".to_owned()))?; + Ok(Self { + api_url: non_empty_env("SLACK_API_URL") + .unwrap_or_else(|| DEFAULT_SLACK_API_URL.to_owned()) + .trim_end_matches('/') + .to_owned(), + bot_token, + max_upload_bytes: positive_env_u64( + "SLACK_FILE_PROXY_MAX_UPLOAD_BYTES", + DEFAULT_MAX_UPLOAD_BYTES, + ), + }) + } +} + +#[derive(Debug)] +struct SlackUploadTicket { + upload_url: String, + file_id: String, +} + +async fn get_upload_url( + client: &reqwest::Client, + config: &SlackFileProxyConfig, + filename: &str, + length: u64, + alt_txt: Option<&str>, + snippet_type: Option<&str>, +) -> Result { + let form = slack_get_upload_url_form(filename, length, alt_txt, snippet_type); + let value = slack_api_post_form(client, config, "files.getUploadURLExternal", &form).await?; + Ok(SlackUploadTicket { + upload_url: required_slack_string(&value, "upload_url")?, + file_id: required_slack_string(&value, "file_id")?, + }) +} + +fn slack_get_upload_url_form( + filename: &str, + length: u64, + alt_txt: Option<&str>, + snippet_type: Option<&str>, +) -> Vec<(&'static str, String)> { + let mut form = vec![ + ("filename", filename.to_owned()), + ("length", length.to_string()), + ("alt_txt", alt_txt.unwrap_or("").to_owned()), + ("snippet_type", snippet_type.unwrap_or("").to_owned()), + ]; + form.retain(|(_, value)| !value.is_empty()); + form +} + +async fn upload_file_bytes( + client: &reqwest::Client, + upload_url: &str, + body: Body, + content_length: u64, + content_type: Option<&str>, +) -> Result<(), ApiError> { + let response = client + .post(upload_url) + .header( + header::CONTENT_TYPE, + content_type.unwrap_or("application/octet-stream"), + ) + .header(header::CONTENT_LENGTH, content_length) + .body(reqwest::Body::wrap_stream(body.into_data_stream())) + .send() + .await + .map_err(|error| ApiError::Internal(format!("Slack upload failed: {error}")))?; + if !response.status().is_success() { + return Err(ApiError::BadRequest(format!( + "Slack upload failed with status {}", + response.status().as_u16() + ))); + } + Ok(()) +} + +async fn complete_upload( + client: &reqwest::Client, + config: &SlackFileProxyConfig, + file_id: &str, + channel_id: &str, + thread_ts: Option<&str>, + title: &str, + initial_comment: Option<&str>, +) -> Result { + let files = json!([{ "id": file_id, "title": title }]).to_string(); + let mut form = vec![ + ("files", files), + ("channel_id", channel_id.to_owned()), + ("thread_ts", thread_ts.unwrap_or("").to_owned()), + ("initial_comment", initial_comment.unwrap_or("").to_owned()), + ]; + form.retain(|(_, value)| !value.is_empty()); + let value = slack_api_post_form(client, config, "files.completeUploadExternal", &form).await?; + value + .get("files") + .and_then(Value::as_array) + .and_then(|files| files.first()) + .cloned() + .ok_or_else(|| { + ApiError::BadRequest("Slack upload response did not include file".to_owned()) + }) +} + +async fn slack_file_info( + client: &reqwest::Client, + config: &SlackFileProxyConfig, + file_id: &str, +) -> Result { + let value = slack_api_post_form( + client, + config, + "files.info", + &[("file", file_id.to_owned())], + ) + .await?; + value.get("file").cloned().ok_or_else(|| { + ApiError::BadRequest("Slack file info response did not include file".to_owned()) + }) +} + +async fn slack_api_post_form( + client: &reqwest::Client, + config: &SlackFileProxyConfig, + method: &str, + form: &[(&str, String)], +) -> Result { + let response = client + .post(format!("{}/{}", config.api_url, method)) + .bearer_auth(&config.bot_token) + .form(form) + .send() + .await + .map_err(|error| ApiError::Internal(format!("Slack API request failed: {error}")))?; + let status = response.status(); + let value = response + .json::() + .await + .map_err(|error| ApiError::Internal(format!("Slack API response was not JSON: {error}")))?; + if !status.is_success() || value.get("ok") != Some(&Value::Bool(true)) { + let slack_error = value + .get("error") + .and_then(Value::as_str) + .unwrap_or("unknown_error"); + return Err(ApiError::BadRequest(format!( + "Slack {method} failed: {slack_error}" + ))); + } + Ok(value) +} + +fn authorize_slack_file_proxy(headers: &HeaderMap) -> Result { + let token = bearer_token(headers)?; + let secret = jwt_signing_secret().ok_or_else(|| { + ApiError::Internal("CENTAUR_JWT_SIGNING_SECRET is not configured".to_owned()) + })?; + let audience = non_empty_env("CENTAUR_API_JWT_AUDIENCE") + .unwrap_or_else(|| DEFAULT_API_JWT_AUDIENCE.to_owned()); + let issuer = non_empty_env("CENTAUR_API_JWT_ISSUER") + .unwrap_or_else(|| DEFAULT_API_JWT_ISSUER.to_owned()); + verify_hs256_jwt(token, secret.as_bytes(), &audience, &issuer) +} + +fn bearer_token(headers: &HeaderMap) -> Result<&str, ApiError> { + let value = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| ApiError::Unauthorized("missing bearer token".to_owned()))?; + value + .split_once(' ') + .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("Bearer")) + .map(|(_, token)| token.trim()) + .filter(|token| !token.is_empty()) + .ok_or_else(|| ApiError::Unauthorized("missing bearer token".to_owned())) +} + +fn verify_hs256_jwt( + token: &str, + secret: &[u8], + expected_audience: &str, + expected_issuer: &str, +) -> Result { + let mut validation = Validation::new(Algorithm::HS256); + validation.leeway = JWT_CLOCK_SKEW_SECONDS as u64; + validation.validate_nbf = true; + validation.set_audience(&[expected_audience]); + validation.set_issuer(&[expected_issuer]); + validation.set_required_spec_claims(&["exp", "iss", "sub", "aud"]); + let token_data = + decode::(token, &DecodingKey::from_secret(secret), &validation) + .map_err(|_| ApiError::Unauthorized("invalid JWT".to_owned()))?; + validate_claims(&token_data.claims)?; + Ok(token_data.claims) +} + +fn validate_claims(claims: &SlackFileProxyClaims) -> Result<(), ApiError> { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + if claims.iat > now + JWT_CLOCK_SKEW_SECONDS { + return Err(ApiError::Unauthorized( + "JWT issued-at is in the future".to_owned(), + )); + } + if claims.sub.as_deref().unwrap_or_default().trim().is_empty() { + return Err(ApiError::Unauthorized("JWT subject is required".to_owned())); + } + Ok(()) +} + +fn ensure_upload_channel_allowed( + claims: &SlackFileProxyClaims, + channel_id: &str, +) -> Result<(), ApiError> { + ensure_channel_allowed( + &claims.slack.upload_channels, + channel_id, + "JWT is not authorized to upload to this Slack channel", + ) +} + +fn ensure_download_channel_allowed( + claims: &SlackFileProxyClaims, + channel_id: &str, +) -> Result<(), ApiError> { + ensure_channel_allowed( + &claims.slack.download_channels, + channel_id, + "JWT is not authorized to download from this Slack channel", + ) +} + +fn ensure_channel_allowed( + allowed_channels: &[String], + channel_id: &str, + message: &str, +) -> Result<(), ApiError> { + if allowed_channels.iter().any(|allowed| allowed == channel_id) { + return Ok(()); + } + Err(ApiError::Forbidden(message.to_owned())) +} + +fn slack_file_in_channel(file: &Value, channel_id: &str) -> bool { + slack_file_channel_ids(file).contains(channel_id) +} + +fn slack_file_channel_ids(file: &Value) -> BTreeSet { + let mut channels = BTreeSet::new(); + for key in ["channels", "groups", "ims"] { + if let Some(values) = file.get(key).and_then(Value::as_array) { + for value in values { + if let Some(channel) = value.as_str() { + channels.insert(channel.to_owned()); + } + } + } + } + if let Some(shares) = file.get("shares").and_then(Value::as_object) { + for share_type in shares.values().filter_map(Value::as_object) { + for (channel, _shares) in share_type { + channels.insert(channel.to_owned()); + } + } + } + channels +} + +fn required_slack_string(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| ApiError::BadRequest(format!("Slack response missing {field}"))) +} + +fn content_length(headers: &HeaderMap) -> Result { + headers + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| ApiError::BadRequest("Content-Length header is required".to_owned())) +} + +fn ensure_upload_size(len: u64, max: u64) -> Result<(), ApiError> { + if len == 0 { + return Err(ApiError::BadRequest( + "file body must not be empty".to_owned(), + )); + } + if len > max { + return Err(ApiError::PayloadTooLarge(format!( + "file body exceeds {max} byte limit" + ))); + } + Ok(()) +} + +fn validate_slack_channel_id(channel_id: &str) -> Result<(), ApiError> { + if channel_id.len() >= 9 + && matches!(channel_id.as_bytes().first(), Some(b'C' | b'D' | b'G')) + && channel_id + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + return Ok(()); + } + Err(ApiError::BadRequest("invalid Slack channel ID".to_owned())) +} + +fn validate_slack_file_id(file_id: &str) -> Result<(), ApiError> { + if file_id.len() >= 9 + && file_id.starts_with('F') + && file_id + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + return Ok(()); + } + Err(ApiError::BadRequest("invalid Slack file ID".to_owned())) +} + +fn validate_slack_thread_ts(thread_ts: &str) -> Result<(), ApiError> { + let Some((seconds, micros)) = thread_ts.split_once('.') else { + return Err(ApiError::BadRequest("invalid Slack thread_ts".to_owned())); + }; + if !seconds.is_empty() + && !micros.is_empty() + && seconds.bytes().all(|byte| byte.is_ascii_digit()) + && micros.bytes().all(|byte| byte.is_ascii_digit()) + { + return Ok(()); + } + Err(ApiError::BadRequest("invalid Slack thread_ts".to_owned())) +} + +fn validate_filename(filename: &str) -> Result<(), ApiError> { + let filename = filename.trim(); + if filename.is_empty() || filename.contains('/') || filename.contains('\\') { + return Err(ApiError::BadRequest("invalid filename".to_owned())); + } + Ok(()) +} + +fn validate_content_type(content_type: &str) -> Result<(), ApiError> { + if content_type.trim().is_empty() || content_type.parse::().is_err() { + return Err(ApiError::BadRequest("invalid content_type".to_owned())); + } + Ok(()) +} + +fn content_disposition_filename(filename: &str) -> String { + let sanitized = filename + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { + ch + } else { + '_' + } + }) + .collect::(); + format!("attachment; filename=\"{sanitized}\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{EncodingKey, Header, encode}; + + fn test_jwt(secret: &[u8], claims: Value) -> String { + encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(secret), + ) + .unwrap() + } + + #[test] + fn verifies_hs256_jwt_and_separate_slack_channel_claims() { + let token = test_jwt( + b"secret", + json!({ + "iss": "centaur-console", + "sub": "user_123", + "aud": "centaur-api", + "iat": 1_700_000_000i64, + "exp": 4_102_444_800i64, + "slack": { + "upload_channels": ["C123456789"], + "download_channels": ["C987654321"] + } + }), + ); + let claims = verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap(); + ensure_upload_channel_allowed(&claims, "C123456789").unwrap(); + ensure_download_channel_allowed(&claims, "C987654321").unwrap(); + assert!(matches!( + ensure_upload_channel_allowed(&claims, "C987654321").unwrap_err(), + ApiError::Forbidden(_) + )); + assert!(matches!( + ensure_download_channel_allowed(&claims, "C123456789").unwrap_err(), + ApiError::Forbidden(_) + )); + } + + #[test] + fn rejects_invalid_jwt_signature() { + let token = test_jwt( + b"secret", + json!({ + "iss": "centaur-console", + "sub": "user_123", + "aud": "centaur-api", + "iat": 1_700_000_000i64, + "exp": 4_102_444_800i64, + "slack": { + "upload_channels": ["C123456789"], + "download_channels": ["C123456789"] + } + }), + ); + assert!(matches!( + verify_hs256_jwt(&token, b"other-secret", "centaur-api", "centaur-console") + .unwrap_err(), + ApiError::Unauthorized(_) + )); + } + + #[test] + fn rejects_expired_jwt() { + let token = test_jwt( + b"secret", + json!({ + "iss": "centaur-console", + "sub": "user_123", + "aud": "centaur-api", + "iat": 1i64, + "exp": 1i64, + "slack": { + "upload_channels": ["C123456789"], + "download_channels": ["C123456789"] + } + }), + ); + assert!(matches!( + verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap_err(), + ApiError::Unauthorized(_) + )); + } + + #[test] + fn rejects_wrong_jwt_audience() { + let token = test_jwt( + b"secret", + json!({ + "iss": "centaur-console", + "sub": "user_123", + "aud": "other-api", + "iat": 1_700_000_000i64, + "exp": 4_102_444_800i64, + "slack": { + "upload_channels": ["C123456789"], + "download_channels": ["C123456789"] + } + }), + ); + assert!(matches!( + verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap_err(), + ApiError::Unauthorized(_) + )); + } + + #[test] + fn accepts_jwt_audience_array() { + let token = test_jwt( + b"secret", + json!({ + "iss": "centaur-console", + "sub": "user_123", + "aud": ["other-api", "centaur-api"], + "iat": 1_700_000_000i64, + "exp": 4_102_444_800i64, + "slack": { + "upload_channels": ["C123456789"], + "download_channels": ["C123456789"] + } + }), + ); + let claims = verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap(); + ensure_upload_channel_allowed(&claims, "C123456789").unwrap(); + ensure_download_channel_allowed(&claims, "C123456789").unwrap(); + } + + #[test] + fn rejects_missing_standard_jwt_claims() { + let token = test_jwt( + b"secret", + json!({ + "aud": "centaur-api", + "exp": 4_102_444_800i64, + "slack": { + "upload_channels": ["C123456789"], + "download_channels": ["C123456789"] + } + }), + ); + assert!(matches!( + verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap_err(), + ApiError::Unauthorized(_) + )); + } + + #[test] + fn extracts_channels_from_file_metadata() { + let file = json!({ + "channels": ["C111111111"], + "groups": ["G111111111"], + "ims": ["D111111111"], + "shares": { + "public": { + "C222222222": [{"ts": "1.000001"}] + }, + "private": { + "G222222222": [{"ts": "1.000002"}] + } + } + }); + let channels = slack_file_channel_ids(&file); + assert!(channels.contains("C111111111")); + assert!(channels.contains("G111111111")); + assert!(channels.contains("D111111111")); + assert!(channels.contains("C222222222")); + assert!(channels.contains("G222222222")); + } + + #[test] + fn upload_requires_content_length() { + let headers = HeaderMap::new(); + assert!(matches!( + content_length(&headers).unwrap_err(), + ApiError::BadRequest(_) + )); + + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_LENGTH, "42".parse().unwrap()); + assert_eq!(content_length(&headers).unwrap(), 42); + } + + #[test] + fn rejects_wrong_jwt_issuer() { + let token = test_jwt( + b"secret", + json!({ + "iss": "other-issuer", + "sub": "user_123", + "aud": "centaur-api", + "iat": 1_700_000_000i64, + "exp": 4_102_444_800i64, + "slack": { + "upload_channels": ["C123456789"], + "download_channels": ["C123456789"] + } + }), + ); + assert!(matches!( + verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap_err(), + ApiError::Unauthorized(_) + )); + } + + #[test] + fn bearer_token_scheme_is_case_insensitive() { + for value in ["Bearer token-1", "bearer token-1", "BEARER token-1"] { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, value.parse().unwrap()); + assert_eq!(bearer_token(&headers).unwrap(), "token-1"); + } + + for value in ["Bearer ", "token-1", "Basic token-1"] { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, value.parse().unwrap()); + assert!(matches!( + bearer_token(&headers).unwrap_err(), + ApiError::Unauthorized(_) + )); + } + } + + #[test] + fn detects_unexpected_html_download_body() { + assert!(upstream_body_is_unexpected_html( + Some("text/html; charset=utf-8"), + Some("image/png"), + )); + assert!(upstream_body_is_unexpected_html(Some("TEXT/HTML"), None)); + assert!(!upstream_body_is_unexpected_html( + Some("text/html"), + Some("text/html"), + )); + assert!(!upstream_body_is_unexpected_html( + Some("image/png"), + Some("image/png"), + )); + assert!(!upstream_body_is_unexpected_html(None, Some("image/png"))); + } + + #[test] + fn validates_content_type() { + validate_content_type("application/pdf").unwrap(); + validate_content_type("text/plain; charset=utf-8").unwrap(); + for content_type in ["", " ", "a\nb", "a\rb", "a\0b"] { + assert!(matches!( + validate_content_type(content_type).unwrap_err(), + ApiError::BadRequest(_) + )); + } + } + + #[test] + fn upload_url_form_includes_alt_text_and_snippet_type() { + let form = slack_get_upload_url_form("notes.txt", 42, Some("Release notes"), Some("text")); + assert_eq!( + form, + vec![ + ("filename", "notes.txt".to_owned()), + ("length", "42".to_owned()), + ("alt_txt", "Release notes".to_owned()), + ("snippet_type", "text".to_owned()), + ] + ); + + let form = slack_get_upload_url_form("notes.txt", 42, None, None); + assert_eq!( + form, + vec![ + ("filename", "notes.txt".to_owned()), + ("length", "42".to_owned()), + ] + ); + } +} From 050be2e29cdab4cdb202a93f40a8a8e71aaf9076 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:13:35 +0300 Subject: [PATCH 100/198] docs: add GitHub, Granola, Linear, and Attio to the OAuth Apps page (#969) * docs: document console OAuth integrations * docs: replace end-to-end flow and provider tables with short Console setup how-to * docs: fold new OAuth providers into existing OAuth Apps page, drop duplicate integrations page * docs: simplify OAuth Apps page to a short UI-only guide * docs: add Granola dynamic client registration to provider-specific setup --- docs/pages/secrets/oauth-apps.mdx | 223 ++++++--------------------- docs/public/md/secrets/oauth-apps.md | 223 ++++++--------------------- 2 files changed, 90 insertions(+), 356 deletions(-) diff --git a/docs/pages/secrets/oauth-apps.mdx b/docs/pages/secrets/oauth-apps.mdx index bdde378ff..3422b3676 100644 --- a/docs/pages/secrets/oauth-apps.mdx +++ b/docs/pages/secrets/oauth-apps.mdx @@ -7,203 +7,70 @@ description: Register OAuth clients, collect user consent, and grant refreshed a OAuth apps let users connect their own upstream accounts to Centaur. An operator registers an OAuth client in the console, shares a consent link, and each user -who completes the flow creates or updates a managed broker credential. - -The broker credential owns refresh-token lifecycle. It refreshes access tokens -inside the Centaur Console and exposes only the current access token to iron-proxy -through a `token_broker` secret source. The user's refresh token never leaves -the Centaur Console. - -OAuth apps are separate from console login. Console SSO uses -`/auth//start` and signs operators into the console. OAuth apps use -`/oauth//start` and mint credentials for tools. +who completes the flow gets a managed credential. The Centaur Console keeps the +token fresh and iron-proxy injects it as `Authorization: Bearer ` +into requests to the provider's API hosts. Refresh tokens never leave the +Centaur Console. ## Supported Providers | Provider | Use | |----------|-----| -| `google` | Google API credentials, such as Gmail or Drive scopes. | -| `slack` | Slack user-token credentials with normal Slack API scopes. | - -Google flows request offline access and force consent so the token response -includes a refresh token. Slack OAuth apps should enable token rotation so the -callback also receives a refresh token. - -## Create The Provider App - -Create an OAuth client in the upstream provider first. - -Register this callback URL: - -```text -/oauth//callback -``` - -For example: - -```text -https://control.example.com/oauth/google-drive/callback -``` +| `google` | Google APIs, such as Gmail or Drive scopes. | +| `slack` | Slack user tokens with normal Slack API scopes. | +| `github` | GitHub user tokens for `api.github.com`. | +| `granola` | Granola MCP tokens for `mcp.granola.ai`. | +| `linear` | Linear tokens for `api.linear.app`. | +| `attio` | Attio workspace tokens for `api.attio.com`. | -The slug is the stable name users see in the consent URL. It must contain only -URL-safe characters. +## Set Up An App -For Slack, use normal Slack API scopes such as `channels:history` or -`users:read`. Do not use Sign in with Slack scopes such as `openid`, `email`, or -`profile` for OAuth apps. +1. **Create an OAuth client with the provider** (for example in the Google + Cloud console or the Attio developer dashboard). Register this callback + URL: `/oauth//callback`. +2. **Register it in Centaur.** In the console, open **OAuth Apps**, click + **Add App**, and fill in the slug, provider, client id, client + secret, and allowed scopes (one per line). +3. **Share the consent link** shown on the app page: + `/oauth//start`. Each user who opens it + and approves the provider's consent screen gets a credential, wrapped in a + grantable secret. -## Register The App In Centaur +Re-consenting with the same account updates the existing credential instead of +creating another one. -In the console, open **OAuth Apps**, then create an app with: +## Provider-Specific Setup -| Field | Meaning | -|-------|---------| -| `Slug` | Globally unique consent-link name, for example `google-drive`. | -| `Provider` | `google` or `slack`. | -| `Client ID` | OAuth client id from the provider. | -| `Client Secret` | OAuth client secret from the provider. Stored encrypted. | -| `Credential Namespace` | Namespace for broker credentials minted by this app. | -| `Allowed Scopes` | One scope per line. Consent requests must be a subset. | -| `Enabled` | Disabled apps reject new consent flows. Existing credentials keep refreshing. | +### Granola -You can also create the app through the API: +Granola has no app dashboard; obtain the OAuth client once via dynamic client +registration, then use the returned `client_id` and `client_secret` when adding +the app in the console: ```bash -curl -sS -X POST "$IRON_CONTROL_URL/api/v1/oauth_apps" \ - -H "Authorization: Bearer $IRON_CONTROL_API_KEY" \ +curl -sS -X POST https://mcp-auth.granola.ai/oauth2/register \ -H "Content-Type: application/json" \ -d '{ - "data": { - "slug": "google-drive", - "description": "Google Drive user access", - "provider": "google", - "client_id": "client-id.apps.googleusercontent.com", - "client_secret": "client-secret", - "credential_namespace": "default", - "allowed_scopes": [ - "https://www.googleapis.com/auth/drive.metadata.readonly" - ], - "enabled": true, - "labels": { "team": "platform" } - } + "client_name": "Centaur Console", + "redirect_uris": ["/oauth/granola/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "client_secret_post", + "scope": "openid email profile offline_access mcp" }' ``` -`client_secret` is write-only. API responses never include it. Updating an app -without a new `client_secret` keeps the stored value. - -## Collect User Consent - -Share the app start URL with the user: - -```text -/oauth//start -``` - -Omitting `scopes` requests every allowed scope: - -```text -https://control.example.com/oauth/google-drive/start -``` - -To request a subset, pass scopes as a space-separated or comma-separated query -parameter: - -```text -https://control.example.com/oauth/google-drive/start?scopes=https://www.googleapis.com/auth/drive.metadata.readonly -``` - -The start endpoint rejects unknown slugs, disabled apps, and scopes outside the -app allowlist. After provider consent, the callback exchanges the code, records -the provider account identity, and renders a console result page. - -Re-consenting with the same app and provider account updates the existing broker -credential instead of creating another one. - -## What Gets Created - -A successful consent creates or updates: - -| Resource | Purpose | -|----------|---------| -| Broker credential | Stores provider identity, scopes, current access token, refresh token, expiry, and refresh state. | -| Static secret | Grantable wrapper that injects `Authorization: Bearer `. | - -The static secret uses a `token_broker` source that points at the broker -credential. At proxy sync time, the Centaur Console resolves the broker credential and -sends the current access token to iron-proxy. If the credential is still -bootstrapping or cannot refresh, the secret is omitted from proxy config until -it recovers. +Use `mcp` as the allowed scope for the app. -The auto-created request rules are provider-scoped: +## Grant The Credential -| Provider | Default API host rules | -|----------|------------------------| -| Google | `*.googleapis.com` | -| Slack | `slack.com` | +Consent does not automatically grant the token to every session. In the +console, open **Principals**, choose the user or channel, and use **Direct +Grants** to select the secret created for the credential — or grant it to a +reusable role. -Operators can tighten the static secret's rules in the console if a credential -should only be valid for specific API paths. - -## Grant The OAuth Credential - -OAuth consent does not automatically grant the token to every session. Grant the -auto-created static secret to the correct user, channel, or role. - -You can grant the secret in the Centaur Console. Open **Principals**, choose the -user or channel principal, then use **Direct Grants** to select the static secret -created for the broker credential. The same principal page can assign a role if -you grant the OAuth secret to a reusable role instead. - -For scripted changes, list secrets in the credential namespace and find the -static secret created for the broker credential: - -```bash -curl -sS "$IRON_CONTROL_URL/api/v1/static_secrets?namespace=default" \ - -H "Authorization: Bearer $IRON_CONTROL_API_KEY" | jq -``` - -Then grant the secret with `centaur-perms`: - -```bash -cd services/api-rs -cargo run -p centaur-perms -- \ - principals grant slack-user-u123 \ - --secret ssr_... -``` - -Grant the same credential to a channel when the channel should define access: - -```bash -cargo run -p centaur-perms -- \ - principals grant slack-channel-c456 \ - --secret ssr_... -``` - -Or grant it to a reusable role: - -```bash -cargo run -p centaur-perms -- \ - roles grant tool-google-drive \ - --secret ssr_... -``` - -## Rotate Or Disable - -Rotating the OAuth client's secret on the app updates every credential minted by -that app because minted broker credentials delegate `client_id` and -`client_secret` back to the app. - -Disable an app to stop new consent flows: - -```bash -curl -sS -X PATCH "$IRON_CONTROL_URL/api/v1/oauth_apps/google-drive" \ - -H "Authorization: Bearer $IRON_CONTROL_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ "data": { "enabled": false } }' -``` +## Disable Or Remove -Existing broker credentials keep refreshing while the app exists. To fully -remove access, revoke grants to the wrapper static secret, delete the wrapper -secret, then delete or unlink the broker credential. An app cannot be deleted -while minted credentials still reference it. +Toggle **Enabled** off on the app page to stop new consent flows; existing +credentials keep working. To fully remove access, revoke grants to the wrapper +secret, delete it, then delete the credential. diff --git a/docs/public/md/secrets/oauth-apps.md b/docs/public/md/secrets/oauth-apps.md index bdde378ff..3422b3676 100644 --- a/docs/public/md/secrets/oauth-apps.md +++ b/docs/public/md/secrets/oauth-apps.md @@ -7,203 +7,70 @@ description: Register OAuth clients, collect user consent, and grant refreshed a OAuth apps let users connect their own upstream accounts to Centaur. An operator registers an OAuth client in the console, shares a consent link, and each user -who completes the flow creates or updates a managed broker credential. - -The broker credential owns refresh-token lifecycle. It refreshes access tokens -inside the Centaur Console and exposes only the current access token to iron-proxy -through a `token_broker` secret source. The user's refresh token never leaves -the Centaur Console. - -OAuth apps are separate from console login. Console SSO uses -`/auth//start` and signs operators into the console. OAuth apps use -`/oauth//start` and mint credentials for tools. +who completes the flow gets a managed credential. The Centaur Console keeps the +token fresh and iron-proxy injects it as `Authorization: Bearer ` +into requests to the provider's API hosts. Refresh tokens never leave the +Centaur Console. ## Supported Providers | Provider | Use | |----------|-----| -| `google` | Google API credentials, such as Gmail or Drive scopes. | -| `slack` | Slack user-token credentials with normal Slack API scopes. | - -Google flows request offline access and force consent so the token response -includes a refresh token. Slack OAuth apps should enable token rotation so the -callback also receives a refresh token. - -## Create The Provider App - -Create an OAuth client in the upstream provider first. - -Register this callback URL: - -```text -/oauth//callback -``` - -For example: - -```text -https://control.example.com/oauth/google-drive/callback -``` +| `google` | Google APIs, such as Gmail or Drive scopes. | +| `slack` | Slack user tokens with normal Slack API scopes. | +| `github` | GitHub user tokens for `api.github.com`. | +| `granola` | Granola MCP tokens for `mcp.granola.ai`. | +| `linear` | Linear tokens for `api.linear.app`. | +| `attio` | Attio workspace tokens for `api.attio.com`. | -The slug is the stable name users see in the consent URL. It must contain only -URL-safe characters. +## Set Up An App -For Slack, use normal Slack API scopes such as `channels:history` or -`users:read`. Do not use Sign in with Slack scopes such as `openid`, `email`, or -`profile` for OAuth apps. +1. **Create an OAuth client with the provider** (for example in the Google + Cloud console or the Attio developer dashboard). Register this callback + URL: `/oauth//callback`. +2. **Register it in Centaur.** In the console, open **OAuth Apps**, click + **Add App**, and fill in the slug, provider, client id, client + secret, and allowed scopes (one per line). +3. **Share the consent link** shown on the app page: + `/oauth//start`. Each user who opens it + and approves the provider's consent screen gets a credential, wrapped in a + grantable secret. -## Register The App In Centaur +Re-consenting with the same account updates the existing credential instead of +creating another one. -In the console, open **OAuth Apps**, then create an app with: +## Provider-Specific Setup -| Field | Meaning | -|-------|---------| -| `Slug` | Globally unique consent-link name, for example `google-drive`. | -| `Provider` | `google` or `slack`. | -| `Client ID` | OAuth client id from the provider. | -| `Client Secret` | OAuth client secret from the provider. Stored encrypted. | -| `Credential Namespace` | Namespace for broker credentials minted by this app. | -| `Allowed Scopes` | One scope per line. Consent requests must be a subset. | -| `Enabled` | Disabled apps reject new consent flows. Existing credentials keep refreshing. | +### Granola -You can also create the app through the API: +Granola has no app dashboard; obtain the OAuth client once via dynamic client +registration, then use the returned `client_id` and `client_secret` when adding +the app in the console: ```bash -curl -sS -X POST "$IRON_CONTROL_URL/api/v1/oauth_apps" \ - -H "Authorization: Bearer $IRON_CONTROL_API_KEY" \ +curl -sS -X POST https://mcp-auth.granola.ai/oauth2/register \ -H "Content-Type: application/json" \ -d '{ - "data": { - "slug": "google-drive", - "description": "Google Drive user access", - "provider": "google", - "client_id": "client-id.apps.googleusercontent.com", - "client_secret": "client-secret", - "credential_namespace": "default", - "allowed_scopes": [ - "https://www.googleapis.com/auth/drive.metadata.readonly" - ], - "enabled": true, - "labels": { "team": "platform" } - } + "client_name": "Centaur Console", + "redirect_uris": ["/oauth/granola/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "client_secret_post", + "scope": "openid email profile offline_access mcp" }' ``` -`client_secret` is write-only. API responses never include it. Updating an app -without a new `client_secret` keeps the stored value. - -## Collect User Consent - -Share the app start URL with the user: - -```text -/oauth//start -``` - -Omitting `scopes` requests every allowed scope: - -```text -https://control.example.com/oauth/google-drive/start -``` - -To request a subset, pass scopes as a space-separated or comma-separated query -parameter: - -```text -https://control.example.com/oauth/google-drive/start?scopes=https://www.googleapis.com/auth/drive.metadata.readonly -``` - -The start endpoint rejects unknown slugs, disabled apps, and scopes outside the -app allowlist. After provider consent, the callback exchanges the code, records -the provider account identity, and renders a console result page. - -Re-consenting with the same app and provider account updates the existing broker -credential instead of creating another one. - -## What Gets Created - -A successful consent creates or updates: - -| Resource | Purpose | -|----------|---------| -| Broker credential | Stores provider identity, scopes, current access token, refresh token, expiry, and refresh state. | -| Static secret | Grantable wrapper that injects `Authorization: Bearer `. | - -The static secret uses a `token_broker` source that points at the broker -credential. At proxy sync time, the Centaur Console resolves the broker credential and -sends the current access token to iron-proxy. If the credential is still -bootstrapping or cannot refresh, the secret is omitted from proxy config until -it recovers. +Use `mcp` as the allowed scope for the app. -The auto-created request rules are provider-scoped: +## Grant The Credential -| Provider | Default API host rules | -|----------|------------------------| -| Google | `*.googleapis.com` | -| Slack | `slack.com` | +Consent does not automatically grant the token to every session. In the +console, open **Principals**, choose the user or channel, and use **Direct +Grants** to select the secret created for the credential — or grant it to a +reusable role. -Operators can tighten the static secret's rules in the console if a credential -should only be valid for specific API paths. - -## Grant The OAuth Credential - -OAuth consent does not automatically grant the token to every session. Grant the -auto-created static secret to the correct user, channel, or role. - -You can grant the secret in the Centaur Console. Open **Principals**, choose the -user or channel principal, then use **Direct Grants** to select the static secret -created for the broker credential. The same principal page can assign a role if -you grant the OAuth secret to a reusable role instead. - -For scripted changes, list secrets in the credential namespace and find the -static secret created for the broker credential: - -```bash -curl -sS "$IRON_CONTROL_URL/api/v1/static_secrets?namespace=default" \ - -H "Authorization: Bearer $IRON_CONTROL_API_KEY" | jq -``` - -Then grant the secret with `centaur-perms`: - -```bash -cd services/api-rs -cargo run -p centaur-perms -- \ - principals grant slack-user-u123 \ - --secret ssr_... -``` - -Grant the same credential to a channel when the channel should define access: - -```bash -cargo run -p centaur-perms -- \ - principals grant slack-channel-c456 \ - --secret ssr_... -``` - -Or grant it to a reusable role: - -```bash -cargo run -p centaur-perms -- \ - roles grant tool-google-drive \ - --secret ssr_... -``` - -## Rotate Or Disable - -Rotating the OAuth client's secret on the app updates every credential minted by -that app because minted broker credentials delegate `client_id` and -`client_secret` back to the app. - -Disable an app to stop new consent flows: - -```bash -curl -sS -X PATCH "$IRON_CONTROL_URL/api/v1/oauth_apps/google-drive" \ - -H "Authorization: Bearer $IRON_CONTROL_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ "data": { "enabled": false } }' -``` +## Disable Or Remove -Existing broker credentials keep refreshing while the app exists. To fully -remove access, revoke grants to the wrapper static secret, delete the wrapper -secret, then delete or unlink the broker credential. An app cannot be deleted -while minted credentials still reference it. +Toggle **Enabled** off on the app page to stop new consent flows; existing +credentials keep working. To fully remove access, revoke grants to the wrapper +secret, delete it, then delete the credential. From cf65e7d07c450e1a6e02676d18cba4aa1d8f5635 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Wed, 8 Jul 2026 09:34:31 -0700 Subject: [PATCH 101/198] fix: read workflow output watermark (#972) --- workflows/company_context_documents.py | 2 ++ .../tests/test_company_context_documents_attachments.py | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/workflows/company_context_documents.py b/workflows/company_context_documents.py index 2bd20a9f4..058e3652f 100644 --- a/workflows/company_context_documents.py +++ b/workflows/company_context_documents.py @@ -191,6 +191,8 @@ async def _latest_successful_watermark(pool, current_run_id: str) -> dt.datetime if not row: return None output = decode_jsonb(row["completed_payload"], {}) + if isinstance(output, dict) and isinstance(output.get("output"), dict): + output = output["output"] return _parse_datetime(str(output.get("watermark") or "")) diff --git a/workflows/tests/test_company_context_documents_attachments.py b/workflows/tests/test_company_context_documents_attachments.py index b4e1a638d..6179a9816 100644 --- a/workflows/tests/test_company_context_documents_attachments.py +++ b/workflows/tests/test_company_context_documents_attachments.py @@ -79,8 +79,12 @@ async def fetchrow(self, query, *args): self.args = args return { "completed_payload": { - "status": "completed", - "watermark": "2026-06-18T22:59:36+00:00", + "steps": ["python_host"], + "output": { + "status": "completed", + "watermark": "2026-06-18T22:59:36+00:00", + }, + "workflow_name": "company_context_documents", } } From 120940fd5233795f7e3f0a41afa4e2c2a4a2210b Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 11:20:52 -0600 Subject: [PATCH 102/198] feat: generate API server JWTs for sandbox proxy sync (#971) * feat: generate API server JWTs for sandbox proxy sync * fix: reject whitespace JWT signing secrets and jitter token rotation windows Restore the blank? guard the HS256 extraction narrowed to empty?, so a whitespace-only CENTAUR_JWT_SIGNING_SECRET fails closed again. Offset each principal's 15-minute rotation window by a deterministic per-oid jitter so snapshot rebuilds and config_hash flips spread across the window instead of stampeding at every global boundary. * fix: gate API JWT credentials on sandbox capability --- services/console/app/models/principal.rb | 45 ++++++++- .../models/principal_sync_config_snapshot.rb | 18 +++- services/console/lib/api_server/jwt.rb | 57 ++++++++++++ services/console/lib/centaur_jwt/hs256.rb | 23 +++++ services/console/lib/mcp/jwt.rb | 15 +-- .../test/lib/centaur_jwt/hs256_test.rb | 19 ++++ .../principal_sync_config_snapshot_test.rb | 64 +++++++++++++ .../console/test/models/principal_test.rb | 92 +++++++++++++++++++ 8 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 services/console/lib/api_server/jwt.rb create mode 100644 services/console/lib/centaur_jwt/hs256.rb create mode 100644 services/console/test/lib/centaur_jwt/hs256_test.rb diff --git a/services/console/app/models/principal.rb b/services/console/app/models/principal.rb index ee081dad4..751032d47 100644 --- a/services/console/app/models/principal.rb +++ b/services/console/app/models/principal.rb @@ -1,3 +1,5 @@ +require "uri" + class Principal < ApplicationRecord oid_prefix "prn" @@ -32,8 +34,10 @@ class Principal < ApplicationRecord # reports that a control_plane source carries a value without revealing it. REDACTED = "[redacted]".freeze SANDBOX_REPO_CACHE_LABEL = "centaur.sandbox_repo_cache".freeze + SLACK_CHANNEL_ID_LABEL = "slack_channel_id".freeze SANDBOX_REPO_CACHE_VALUES = %w[none public all].freeze SANDBOX_REPO_CACHE_ALIASES = { "pub" => "public" }.freeze + SLACK_CHANNEL_ID_FORMAT = /\A[CDG][A-Z0-9]{8,}\z/ # The config of a principal with no effective grants; also what an unassigned # proxy resolves to. @@ -121,7 +125,7 @@ def sync_postgres def effective_config(redact_secrets: true) served = served_credentials config = { - "secrets" => proxy_secrets_for(served), + "secrets" => proxy_secrets_for(served) + generated_proxy_secrets, "transforms" => proxy_transforms_for(served), "postgres" => sync_postgres } @@ -224,6 +228,39 @@ def proxy_secrets_for(served) served[:static].map(&:to_proxy_secret) end + def generated_proxy_secrets + secret = api_server_jwt_secret + secret ? [ secret ] : [] + end + + def api_server_jwt_secret + return nil unless sandbox_api_server_enabled? + + channel_id = labels.to_h[SLACK_CHANNEL_ID_LABEL].to_s.strip + return nil unless channel_id.match?(SLACK_CHANNEL_ID_FORMAT) + + token = ApiServer::Jwt.encode_for_principal(self) + return nil if token.blank? + + rules = api_server_hosts.map { |host| { "host" => host } } + return nil if rules.empty? + + { + "source" => { "type" => "control_plane", "value" => token }, + "inject" => { "header" => "Authorization", "formatter" => "Bearer {{ .Value }}" }, + "rules" => rules + } + end + + def api_server_hosts + configured = ENV["CENTAUR_API_SERVER_PROXY_HOSTS"].to_s.split(",") + from_url = self.class.host_from_url(ENV["CENTAUR_API_URL"]) + (configured + [ from_url, "centaur-api-rs", "api" ]) + .map { |host| host.to_s.strip.downcase.delete_suffix(".") } + .reject(&:blank?) + .uniq + end + def proxy_transforms_for(served) transforms = served[:gcp_auth].map(&:to_proxy_transform) transforms += served[:gcp_id_token].map(&:to_proxy_transform) @@ -236,6 +273,12 @@ def proxy_transforms_for(served) transforms end + def self.host_from_url(value) + URI.parse(value.to_s).host + rescue URI::InvalidURIError + nil + end + # Cross-type conflict resolution. The wire protocol applies the `secrets` array # (static secrets) before the `transforms` array (gcp_auth, aws_auth, hmac_sign, # oauth_token), so the proxy's last-transform-wins cannot let a direct static diff --git a/services/console/app/models/principal_sync_config_snapshot.rb b/services/console/app/models/principal_sync_config_snapshot.rb index 72b8f168d..0b287ee94 100644 --- a/services/console/app/models/principal_sync_config_snapshot.rb +++ b/services/console/app/models/principal_sync_config_snapshot.rb @@ -26,7 +26,7 @@ class PrincipalSyncConfigSnapshot < ApplicationRecord def self.fetch_for(principal) version = principal.sync_config_cache_version snapshot = find_by(principal: principal, principal_cache_version: version) - return snapshot if snapshot&.fresh? + return snapshot if snapshot&.fresh_for?(principal) try_build_for(principal) || snapshot || latest_for(principal) || build_for(principal) end @@ -39,6 +39,10 @@ def fresh? updated_at >= TTL.ago end + def fresh_for?(principal) + fresh? && !api_server_jwt_window_stale?(principal) + end + # Most recent snapshot at any cache version; the stale fallback while # another session rebuilds. Old versions survive until prune_expired! # (RETENTION), which comfortably covers a rebuild. @@ -70,7 +74,7 @@ def self.try_build_for(principal) def self.build_within_lock(principal) version = principal.sync_config_cache_version snapshot = find_or_initialize_by(principal: principal, principal_cache_version: version) - return snapshot if snapshot.persisted? && snapshot.fresh? + return snapshot if snapshot.persisted? && snapshot.fresh_for?(principal) snapshot.payload = principal.effective_config(redact_secrets: false) if snapshot.changed? @@ -83,4 +87,14 @@ def self.build_within_lock(principal) end snapshot end + + def api_server_jwt_window_stale?(principal) + return false unless principal.sandbox_api_server_enabled? + + channel_id = principal.labels.to_h[Principal::SLACK_CHANNEL_ID_LABEL].to_s.strip + return false unless channel_id.match?(Principal::SLACK_CHANNEL_ID_FORMAT) + return false if ENV["CENTAUR_JWT_SIGNING_SECRET"].to_s.blank? + + updated_at.to_i < ApiServer::Jwt.window_start_for(principal, Time.current.to_i) + end end diff --git a/services/console/lib/api_server/jwt.rb b/services/console/lib/api_server/jwt.rb new file mode 100644 index 000000000..6ba4ab528 --- /dev/null +++ b/services/console/lib/api_server/jwt.rb @@ -0,0 +1,57 @@ +require "zlib" + +module ApiServer + module Jwt + DEFAULT_AUDIENCE = "centaur-api".freeze + DEFAULT_ISSUER = "centaur-console".freeze + DEFAULT_WINDOW_SECONDS = 15.minutes.to_i + DEFAULT_TTL_SECONDS = 1.hour.to_i + + module_function + + def encode_for_principal(principal, now: Time.current) + channel_id = principal.labels.to_h[Principal::SLACK_CHANNEL_ID_LABEL].to_s.strip + return nil if channel_id.blank? + + signing_secret = ENV["CENTAUR_JWT_SIGNING_SECRET"].to_s + return nil if signing_secret.blank? + + issued_at = window_start_for(principal, now.to_i) + expires_at = issued_at + DEFAULT_TTL_SECONDS + CentaurJwt::Hs256.encode( + { + "iss" => issuer, + "sub" => principal.oid, + "aud" => audience, + "iat" => issued_at, + "exp" => expires_at, + "slack" => { + "upload_channels" => [ channel_id ], + "download_channels" => [ channel_id ] + } + }, + signing_secret: signing_secret + ) + end + + # Rotation boundaries are offset per principal (deterministically, from + # the oid) so the fleet's tokens don't all roll over — and force snapshot + # rebuilds — at the same instant. + def window_start_for(principal, timestamp) + offset = rotation_offset(principal) + timestamp - ((timestamp - offset) % DEFAULT_WINDOW_SECONDS) + end + + def rotation_offset(principal) + Zlib.crc32(principal.oid.to_s) % DEFAULT_WINDOW_SECONDS + end + + def audience + ENV["CENTAUR_API_JWT_AUDIENCE"].presence || DEFAULT_AUDIENCE + end + + def issuer + ENV["CENTAUR_API_JWT_ISSUER"].presence || DEFAULT_ISSUER + end + end +end diff --git a/services/console/lib/centaur_jwt/hs256.rb b/services/console/lib/centaur_jwt/hs256.rb new file mode 100644 index 000000000..cef627be5 --- /dev/null +++ b/services/console/lib/centaur_jwt/hs256.rb @@ -0,0 +1,23 @@ +require "base64" +require "json" +require "openssl" + +module CentaurJwt + module Hs256 + module_function + + def encode(payload, signing_secret:) + signing_secret = signing_secret.to_s + raise KeyError, "CENTAUR_JWT_SIGNING_SECRET is not configured" if signing_secret.blank? + + header = { "alg" => "HS256", "typ" => "JWT" } + signing_input = [ base64url_json(header), base64url_json(payload) ].join(".") + signature = OpenSSL::HMAC.digest("SHA256", signing_secret, signing_input) + "#{signing_input}.#{Base64.urlsafe_encode64(signature, padding: false)}" + end + + def base64url_json(value) + Base64.urlsafe_encode64(JSON.generate(value), padding: false) + end + end +end diff --git a/services/console/lib/mcp/jwt.rb b/services/console/lib/mcp/jwt.rb index 6a973d413..df8e7ff70 100644 --- a/services/console/lib/mcp/jwt.rb +++ b/services/console/lib/mcp/jwt.rb @@ -1,23 +1,10 @@ -require "base64" -require "json" -require "openssl" - module Mcp module Jwt module_function def encode(payload) signing_secret = ENV["CENTAUR_JWT_SIGNING_SECRET"].to_s - raise KeyError, "CENTAUR_JWT_SIGNING_SECRET is not configured" if signing_secret.blank? - - header = { "alg" => "HS256", "typ" => "JWT" } - signing_input = [ base64url_json(header), base64url_json(payload) ].join(".") - signature = OpenSSL::HMAC.digest("SHA256", signing_secret, signing_input) - "#{signing_input}.#{Base64.urlsafe_encode64(signature, padding: false)}" - end - - def base64url_json(value) - Base64.urlsafe_encode64(JSON.generate(value), padding: false) + CentaurJwt::Hs256.encode(payload, signing_secret: signing_secret) end end end diff --git a/services/console/test/lib/centaur_jwt/hs256_test.rb b/services/console/test/lib/centaur_jwt/hs256_test.rb new file mode 100644 index 000000000..be63064d8 --- /dev/null +++ b/services/console/test/lib/centaur_jwt/hs256_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class CentaurJwtHs256Test < ActiveSupport::TestCase + test "encode raises when the signing secret is missing or whitespace" do + assert_raises(KeyError) { CentaurJwt::Hs256.encode({ "sub" => "x" }, signing_secret: nil) } + assert_raises(KeyError) { CentaurJwt::Hs256.encode({ "sub" => "x" }, signing_secret: "") } + assert_raises(KeyError) { CentaurJwt::Hs256.encode({ "sub" => "x" }, signing_secret: " ") } + end + + test "encode signs with HS256" do + token = CentaurJwt::Hs256.encode({ "sub" => "x" }, signing_secret: "test-secret") + header, payload, signature = token.split(".") + + assert_equal({ "alg" => "HS256", "typ" => "JWT" }, JSON.parse(Base64.urlsafe_decode64(header))) + assert_equal({ "sub" => "x" }, JSON.parse(Base64.urlsafe_decode64(payload))) + expected = OpenSSL::HMAC.digest("SHA256", "test-secret", "#{header}.#{payload}") + assert_equal Base64.urlsafe_encode64(expected, padding: false), signature + end +end diff --git a/services/console/test/models/principal_sync_config_snapshot_test.rb b/services/console/test/models/principal_sync_config_snapshot_test.rb index 55cb43dad..c90a33eae 100644 --- a/services/console/test/models/principal_sync_config_snapshot_test.rb +++ b/services/console/test/models/principal_sync_config_snapshot_test.rb @@ -1,6 +1,8 @@ require "test_helper" class PrincipalSyncConfigSnapshotTest < ActiveSupport::TestCase + include ActiveSupport::Testing::TimeHelpers + setup do @principal = principals(:acme_channel) end @@ -45,6 +47,56 @@ def while_rebuild_lock_held assert refreshed.fresh? end + test "fetch_for rebuilds api server JWT snapshots when the jwt window advances" do + with_env("CENTAUR_JWT_SIGNING_SECRET" => "test-secret") do + @principal.update!(labels: { Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" }) + boundary = 1_700_001_000 + ApiServer::Jwt.rotation_offset(@principal) + current_time = Time.zone.at(boundary + 60) + previous_window_time = Time.zone.at(boundary - 60) + proxy = proxies(:acme_proxy) + + snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) + original_hash = proxy.sync_config_snapshot.fetch(:config_hash) + original_token = snapshot.payload.fetch("secrets").find do |secret| + secret.dig("inject", "header") == "Authorization" + end.dig("source", "value") + snapshot.update_columns(updated_at: previous_window_time) + + travel_to current_time do + refreshed = PrincipalSyncConfigSnapshot.fetch_for(@principal) + refreshed_token = refreshed.payload.fetch("secrets").find do |secret| + secret.dig("inject", "header") == "Authorization" + end.dig("source", "value") + + assert_equal snapshot.id, refreshed.id + assert refreshed.fresh? + refute_equal original_token, refreshed_token + refute_equal original_hash, proxy.reload.sync_config_snapshot.fetch(:config_hash) + end + end + end + + test "fetch_for does not rebuild api server JWT snapshots when sandbox api access is disabled" do + with_env("CENTAUR_JWT_SIGNING_SECRET" => "test-secret") do + @principal.update!( + labels: { Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" }, + sandbox_api_server_enabled: false + ) + boundary = 1_700_001_000 + ApiServer::Jwt.rotation_offset(@principal) + current_time = Time.zone.at(boundary + 60) + previous_window_time = Time.zone.at(boundary - 60) + + snapshot = PrincipalSyncConfigSnapshot.fetch_for(@principal) + snapshot.update_columns(updated_at: previous_window_time) + + travel_to current_time do + assert_no_changes -> { snapshot.reload.updated_at } do + assert_equal snapshot, PrincipalSyncConfigSnapshot.fetch_for(@principal) + end + end + end + end + test "fetch_for builds a new snapshot after a cache version bump" do old = PrincipalSyncConfigSnapshot.fetch_for(@principal) Principal.bump_sync_config_cache_versions(@principal.id) @@ -109,4 +161,16 @@ def while_rebuild_lock_held assert_equal snapshot, PrincipalSyncConfigSnapshot.try_build_for(@principal) end end + + def with_env(values) + previous = values.keys.to_h { |key| [ key, ENV[key] ] } + values.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + yield + ensure + previous.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + end end diff --git a/services/console/test/models/principal_test.rb b/services/console/test/models/principal_test.rb index 4ee1ce119..57de568fa 100644 --- a/services/console/test/models/principal_test.rb +++ b/services/console/test/models/principal_test.rb @@ -107,6 +107,81 @@ def default_attrs(overrides = {}) assert_equal({ "env" => "prod", "team" => "platform" }, principal.reload.labels) end + test "effective_config adds api server JWT for slack channel principals" do + with_env( + "CENTAUR_JWT_SIGNING_SECRET" => "test-secret", + "CENTAUR_API_URL" => "http://api.internal:8080", + "CENTAUR_API_SERVER_PROXY_HOSTS" => nil + ) do + principal = principals(:acme_channel) + principal.update!(labels: { Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" }) + + config = principal.effective_config(redact_secrets: false) + entry = config.fetch("secrets").find do |secret| + secret.dig("inject", "header") == "Authorization" && + secret.dig("source", "type") == "control_plane" + end + + refute_nil entry + assert_equal "Bearer {{ .Value }}", entry.dig("inject", "formatter") + assert_includes entry.fetch("rules"), { "host" => "api.internal" } + + claims = jwt_payload(entry.dig("source", "value")) + assert_equal "centaur-console", claims.fetch("iss") + assert_equal "centaur-api", claims.fetch("aud") + assert_equal principal.oid, claims.fetch("sub") + assert_equal [ "C0123456789" ], claims.dig("slack", "upload_channels") + assert_equal [ "C0123456789" ], claims.dig("slack", "download_channels") + assert_equal 1.hour.to_i, claims.fetch("exp") - claims.fetch("iat") + assert_equal ApiServer::Jwt.rotation_offset(principal), + claims.fetch("iat") % ApiServer::Jwt::DEFAULT_WINDOW_SECONDS + end + end + + test "effective_config omits api server JWT when sandbox api access is disabled" do + with_env("CENTAUR_JWT_SIGNING_SECRET" => "test-secret") do + principal = principals(:acme_channel) + principal.update!( + labels: { Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" }, + sandbox_api_server_enabled: false + ) + + config = principal.effective_config(redact_secrets: false) + entry = config.fetch("secrets").find do |secret| + secret.dig("inject", "header") == "Authorization" && + secret.dig("source", "type") == "control_plane" + end + + assert_nil entry + end + end + + test "api server JWT is deterministic inside the rotation window" do + with_env("CENTAUR_JWT_SIGNING_SECRET" => "test-secret") do + principal = principals(:acme_channel) + principal.update!(labels: { Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" }) + + window = ApiServer::Jwt::DEFAULT_WINDOW_SECONDS + boundary = 1_700_000_100 + ApiServer::Jwt.rotation_offset(principal) + + first = ApiServer::Jwt.encode_for_principal( + principal, + now: Time.zone.at(boundary + 23) + ) + second = ApiServer::Jwt.encode_for_principal( + principal, + now: Time.zone.at(boundary + window - 1) + ) + third = ApiServer::Jwt.encode_for_principal( + principal, + now: Time.zone.at(boundary + window) + ) + + assert_equal first, second + refute_equal first, third + end + end + test "namespace is immutable after creation" do principal = principals(:acme_channel) assert_raises(ActiveRecord::ReadonlyAttributeError) do @@ -506,4 +581,21 @@ def grant_role_oauth(secret = nil) .fetch("secrets").find { |s| s.dig("source", "type") == "control_plane" } assert_equal "s3cr3t", live.dig("source", "value") end + + def jwt_payload(token) + _header, payload, _signature = token.split(".") + JSON.parse(Base64.urlsafe_decode64(payload)) + end + + def with_env(values) + previous = values.keys.to_h { |key| [ key, ENV[key] ] } + values.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + yield + ensure + previous.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + end end From bb3bf2c4cfb7f64a6fedd4c42c2efb48eb0e1b2e Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:38:01 -0700 Subject: [PATCH 103/198] fix(slackbotv2): detect stop commands in Chat-SDK-normalized mention text (#970) Co-authored-by: Claude Fable 5 --- services/slackbotv2/src/stop-command.ts | 6 ++++++ services/slackbotv2/test/stop-command.test.ts | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/services/slackbotv2/src/stop-command.ts b/services/slackbotv2/src/stop-command.ts index 89e133e17..73020c80f 100644 --- a/services/slackbotv2/src/stop-command.ts +++ b/services/slackbotv2/src/stop-command.ts @@ -13,8 +13,14 @@ const STOP_COMMAND_PATTERN = new RegExp( export function isSlackStopCommand(message: { text: string }): boolean { const text = message.text.trim() if (!text) return false + // The Chat SDK normalizes Slack mention tokens before handlers run: + // <@U123|name> becomes @name and the bot's own <@U123> becomes @U123, so + // message.text never contains raw <@...> tokens. Strip both raw tokens + // (defensive) and normalized standalone @mentions; mid-word @ (emails + // like user@example.com) is left alone. const withoutMentions = text .replace(/<@[A-Z0-9]+(?:\|[^>]+)?>/g, ' ') + .replace(/(^|\s)@[A-Za-z0-9._-]+/g, '$1') .replace(/\s+/g, ' ') .trim() return STOP_COMMAND_PATTERN.test(withoutMentions) diff --git a/services/slackbotv2/test/stop-command.test.ts b/services/slackbotv2/test/stop-command.test.ts index 813e2a797..fa19f9fe6 100644 --- a/services/slackbotv2/test/stop-command.test.ts +++ b/services/slackbotv2/test/stop-command.test.ts @@ -31,6 +31,26 @@ describe('Slack stop command detection', () => { } }) + test('matches Chat-SDK-normalized mentions plus stop keyword', () => { + // The Chat SDK rewrites <@U123|name> to @name and the bot's own <@U123> + // to @U123 before handlers run, so live message.text carries these forms. + expect(isSlackStopCommand({ text: '@centaur_ai stop' })).toBe(true) + expect(isSlackStopCommand({ text: '@U08TEST123 stop' })).toBe(true) + expect(isSlackStopCommand({ text: 'please @centaur_ai STOP now' })).toBe(true) + expect(isSlackStopCommand({ text: '@centaur_ai could you stop the execution?' })).toBe(true) + expect(isSlackStopCommand({ text: '@centaur_ai cancel' })).toBe(true) + }) + + test('does not match unrelated normalized mentions', () => { + expect(isSlackStopCommand({ text: '@centaur_ai status' })).toBe(false) + expect(isSlackStopCommand({ text: '@centaur_ai stopping by to ask' })).toBe(false) + expect(isSlackStopCommand({ text: '@centaur_ai if so, stop.' })).toBe(false) + expect( + isSlackStopCommand({ text: '@centaur_ai please check the service; if it is broken, stop.' }) + ).toBe(false) + expect(isSlackStopCommand({ text: 'cancel the invite for user@example.com' })).toBe(false) + }) + test('does not match unrelated mentions', () => { expect(isSlackStopCommand({ text: '<@UCENTAUR> status' })).toBe(false) expect(isSlackStopCommand({ text: '<@UCENTAUR> stopping by to ask' })).toBe(false) From dc207180f436fb4bf1f9b13d3bc35cedc83279fa Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 12:01:24 -0600 Subject: [PATCH 104/198] feat: expose slack client jwt on healthz (#973) --- .../crates/centaur-api-server/src/api_jwt.rs | 204 ++++++++++++++++++ .../crates/centaur-api-server/src/lib.rs | 55 +++++ .../crates/centaur-api-server/src/mcp.rs | 6 +- .../crates/centaur-api-server/src/routes.rs | 27 ++- .../centaur-api-server/src/slack_proxy.rs | 140 +++++------- 5 files changed, 337 insertions(+), 95 deletions(-) create mode 100644 services/api-rs/crates/centaur-api-server/src/api_jwt.rs diff --git a/services/api-rs/crates/centaur-api-server/src/api_jwt.rs b/services/api-rs/crates/centaur-api-server/src/api_jwt.rs new file mode 100644 index 000000000..4e9830deb --- /dev/null +++ b/services/api-rs/crates/centaur-api-server/src/api_jwt.rs @@ -0,0 +1,204 @@ +use std::{env, sync::OnceLock}; + +use axum::http::{HeaderMap, header}; +use base64::{Engine as _, engine::general_purpose}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::ApiError; + +const DEFAULT_API_JWT_AUDIENCE: &str = "centaur-api"; +const DEFAULT_API_JWT_ISSUER: &str = "centaur-console"; +const JWT_CLOCK_SKEW_SECONDS: i64 = 30; + +pub(crate) fn bearer_token(headers: &HeaderMap) -> Result<&str, ApiError> { + let value = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| ApiError::Unauthorized("missing bearer token".to_owned()))?; + value + .split_once(' ') + .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("Bearer")) + .map(|(_, token)| token.trim()) + .filter(|token| !token.is_empty()) + .ok_or_else(|| ApiError::Unauthorized("missing bearer token".to_owned())) +} + +pub(crate) fn bearer_jwt_from_headers(headers: &HeaderMap) -> Option<&str> { + let token = bearer_token(headers).ok()?; + if token.matches('.').count() == 2 { + Some(token) + } else { + None + } +} + +pub(crate) fn decode_jwt_payload(token: &str) -> Result { + let mut parts = token.split('.'); + let _header = parts.next(); + let payload = parts + .next() + .ok_or_else(|| "JWT payload is missing".to_owned())?; + if parts.next().is_none() || parts.next().is_some() { + return Err("JWT must have three segments".to_owned()); + } + let decoded = general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| general_purpose::URL_SAFE.decode(payload)) + .map_err(|_| "JWT payload is not valid base64url".to_owned())?; + serde_json::from_slice(&decoded).map_err(|_| "JWT payload is not valid JSON".to_owned()) +} + +pub(crate) fn verify_console_jwt(token: &str) -> Result +where + T: DeserializeOwned, +{ + let secret = jwt_signing_secret().ok_or_else(|| { + ApiError::Internal("CENTAUR_JWT_SIGNING_SECRET is not configured".to_owned()) + })?; + let audience = non_empty_env("CENTAUR_API_JWT_AUDIENCE") + .unwrap_or_else(|| DEFAULT_API_JWT_AUDIENCE.to_owned()); + let issuer = non_empty_env("CENTAUR_API_JWT_ISSUER") + .unwrap_or_else(|| DEFAULT_API_JWT_ISSUER.to_owned()); + verify_hs256_jwt(token, secret.as_bytes(), &audience, &issuer) +} + +pub(crate) fn verify_hs256_jwt( + token: &str, + secret: &[u8], + expected_audience: &str, + expected_issuer: &str, +) -> Result +where + T: DeserializeOwned, +{ + let mut validation = Validation::new(Algorithm::HS256); + validation.leeway = JWT_CLOCK_SKEW_SECONDS as u64; + validation.validate_nbf = true; + validation.set_audience(&[expected_audience]); + validation.set_issuer(&[expected_issuer]); + validation.set_required_spec_claims(&["exp", "iss", "sub", "aud"]); + let token_data = decode::(token, &DecodingKey::from_secret(secret), &validation) + .map_err(|_| ApiError::Unauthorized("invalid JWT".to_owned()))?; + let payload = + decode_jwt_payload(token).map_err(|_| ApiError::Unauthorized("invalid JWT".to_owned()))?; + validate_standard_claims(&payload)?; + Ok(token_data.claims) +} + +fn validate_standard_claims(claims: &Value) -> Result<(), ApiError> { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let iat = claims + .get("iat") + .and_then(Value::as_i64) + .ok_or_else(|| ApiError::Unauthorized("JWT issued-at is required".to_owned()))?; + if iat > now + JWT_CLOCK_SKEW_SECONDS { + return Err(ApiError::Unauthorized( + "JWT issued-at is in the future".to_owned(), + )); + } + if claims + .get("sub") + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .is_empty() + { + return Err(ApiError::Unauthorized("JWT subject is required".to_owned())); + } + Ok(()) +} + +// Deployment JWT configuration is static, so it is resolved once per process. +// Tests mutate env per-case, so cfg!(test) reads live. +fn static_env(cell: &'static OnceLock>, name: &str) -> Option { + if cfg!(test) { + return env::var(name).ok(); + } + cell.get_or_init(|| env::var(name).ok()).clone() +} + +pub(crate) fn jwt_signing_secret() -> Option { + static CELL: OnceLock> = OnceLock::new(); + static_env(&CELL, "CENTAUR_JWT_SIGNING_SECRET") +} + +fn non_empty_env(name: &str) -> Option { + env::var(name) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + use jsonwebtoken::{EncodingKey, Header, encode}; + use serde_json::json; + + fn test_jwt(secret: &[u8], claims: Value) -> String { + encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(secret), + ) + .unwrap() + } + + #[test] + fn bearer_token_scheme_is_case_insensitive() { + for value in ["Bearer token-1", "bearer token-1", "BEARER token-1"] { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, value.parse().unwrap()); + assert_eq!(bearer_token(&headers).unwrap(), "token-1"); + } + + for value in ["Bearer ", "token-1", "Basic token-1"] { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, value.parse().unwrap()); + assert!(matches!( + bearer_token(&headers).unwrap_err(), + ApiError::Unauthorized(_) + )); + } + } + + #[test] + fn bearer_jwt_from_headers_requires_jwt_shape() { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer not-a-jwt"), + ); + assert!(bearer_jwt_from_headers(&headers).is_none()); + + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer header.payload.signature"), + ); + assert_eq!( + bearer_jwt_from_headers(&headers), + Some("header.payload.signature") + ); + } + + #[test] + fn verify_console_jwt_rejects_missing_issued_at() { + let token = test_jwt( + b"secret", + json!({ + "iss": "centaur-console", + "sub": "principal_123", + "aud": "centaur-api", + "exp": 4_102_444_800i64, + }), + ); + assert!(matches!( + verify_hs256_jwt::(&token, b"secret", "centaur-api", "centaur-console") + .unwrap_err(), + ApiError::Unauthorized(_) + )); + } +} diff --git a/services/api-rs/crates/centaur-api-server/src/lib.rs b/services/api-rs/crates/centaur-api-server/src/lib.rs index 775306686..5c2d6841e 100644 --- a/services/api-rs/crates/centaur-api-server/src/lib.rs +++ b/services/api-rs/crates/centaur-api-server/src/lib.rs @@ -1,3 +1,4 @@ +mod api_jwt; pub mod client; mod error; mod mcp; @@ -35,6 +36,8 @@ mod tests { }; use centaur_session_runtime::SandboxRuntime; use centaur_session_sqlx::PgSessionStore; + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + use serde_json::{Value, json}; use sqlx::PgPool; use tower::ServiceExt; @@ -112,6 +115,58 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test] + async fn healthz_decodes_slack_client_bearer_jwt_when_present() { + let app = build_router_with_app_state(AppState::unready()); + let token = encode( + &Header::new(Algorithm::HS256), + &json!({ + "iss": "centaur-console", + "sub": "principal_123", + "aud": "centaur-api", + "iat": 1_700_000_000i64, + "exp": 4_102_444_800i64, + "slack": { + "upload_channels": ["C123456789"], + "download_channels": ["C987654321"] + } + }), + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body.get("ok").and_then(Value::as_bool), Some(true)); + assert_eq!( + body.pointer("/slack_client_jwt/claims/sub") + .and_then(Value::as_str), + Some("principal_123") + ); + assert_eq!( + body.pointer("/slack_client_jwt/claims/slack/upload_channels/0") + .and_then(Value::as_str), + Some("C123456789") + ); + assert_eq!( + body.pointer("/slack_client_jwt/claims/slack/download_channels/0") + .and_then(Value::as_str), + Some("C987654321") + ); + } + #[tokio::test] async fn readyz_reports_starting_until_runtime_is_ready() { let state = AppState::unready(); diff --git a/services/api-rs/crates/centaur-api-server/src/mcp.rs b/services/api-rs/crates/centaur-api-server/src/mcp.rs index 6bb18801e..6df680ece 100644 --- a/services/api-rs/crates/centaur-api-server/src/mcp.rs +++ b/services/api-rs/crates/centaur-api-server/src/mcp.rs @@ -22,6 +22,7 @@ use time::OffsetDateTime; use crate::{ ApiError, + api_jwt::jwt_signing_secret, routes::{AppState, header_value}, tool_discovery::{DiscoveredTool, ToolDiscoveryConfig, discover_tool_catalog}, }; @@ -751,11 +752,6 @@ fn static_env(cell: &'static OnceLock>, name: &str) -> Option Option { - static CELL: OnceLock> = OnceLock::new(); - static_env(&CELL, "CENTAUR_JWT_SIGNING_SECRET") -} - fn mcp_public_url_env() -> Option { static CELL: OnceLock> = OnceLock::new(); static_env(&CELL, "CENTAUR_MCP_PUBLIC_URL") diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index c63cc2882..1318f3ba4 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -54,6 +54,7 @@ use uuid::Uuid; use crate::{ ApiError, + api_jwt::{bearer_jwt_from_headers, decode_jwt_payload, verify_console_jwt}, mcp::{mcp_get, mcp_post, mcp_protected_resource_metadata}, slack_proxy::slack_proxy_router, types::{ @@ -336,8 +337,30 @@ pub fn build_router_with_app_state(state: AppState) -> Router { .with_state(state) } -async fn healthz() -> Json { - Json(json!({"ok": true})) +async fn healthz(headers: HeaderMap) -> Json { + let mut body = json!({"ok": true}); + if let Some(token) = bearer_jwt_from_headers(&headers) { + body["slack_client_jwt"] = match decode_jwt_payload(token) { + Ok(claims) => { + let mut jwt = json!({ "claims": claims }); + match verify_console_jwt::(token) { + Ok(_) => { + jwt["valid"] = json!(true); + } + Err(error) => { + jwt["valid"] = json!(false); + jwt["error"] = json!(error.to_string()); + } + } + jwt + } + Err(error) => json!({ + "valid": false, + "error": error, + }), + }; + } + Json(body) } async fn readyz(State(state): State) -> impl IntoResponse { diff --git a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs index 93aa3deef..4abefbe46 100644 --- a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs +++ b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs @@ -8,21 +8,17 @@ use axum::{ response::{IntoResponse, Response}, routing::{get, post}, }; -use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::{ ApiError, - mcp::jwt_signing_secret, + api_jwt::{bearer_token, verify_console_jwt}, routes::{AppState, non_empty_env, positive_env_u64}, }; const DEFAULT_SLACK_API_URL: &str = "https://slack.com/api"; -const DEFAULT_API_JWT_AUDIENCE: &str = "centaur-api"; -const DEFAULT_API_JWT_ISSUER: &str = "centaur-console"; const DEFAULT_MAX_UPLOAD_BYTES: u64 = 100 * 1024 * 1024; -const JWT_CLOCK_SKEW_SECONDS: i64 = 30; const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(60); @@ -74,10 +70,7 @@ struct SlackFileDownloadQuery { #[derive(Debug, Deserialize)] struct SlackFileProxyClaims { - iat: i64, slack: SlackProxyClaims, - #[serde(default)] - sub: Option, } #[derive(Debug, Deserialize)] @@ -417,59 +410,7 @@ async fn slack_api_post_form( fn authorize_slack_file_proxy(headers: &HeaderMap) -> Result { let token = bearer_token(headers)?; - let secret = jwt_signing_secret().ok_or_else(|| { - ApiError::Internal("CENTAUR_JWT_SIGNING_SECRET is not configured".to_owned()) - })?; - let audience = non_empty_env("CENTAUR_API_JWT_AUDIENCE") - .unwrap_or_else(|| DEFAULT_API_JWT_AUDIENCE.to_owned()); - let issuer = non_empty_env("CENTAUR_API_JWT_ISSUER") - .unwrap_or_else(|| DEFAULT_API_JWT_ISSUER.to_owned()); - verify_hs256_jwt(token, secret.as_bytes(), &audience, &issuer) -} - -fn bearer_token(headers: &HeaderMap) -> Result<&str, ApiError> { - let value = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .ok_or_else(|| ApiError::Unauthorized("missing bearer token".to_owned()))?; - value - .split_once(' ') - .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("Bearer")) - .map(|(_, token)| token.trim()) - .filter(|token| !token.is_empty()) - .ok_or_else(|| ApiError::Unauthorized("missing bearer token".to_owned())) -} - -fn verify_hs256_jwt( - token: &str, - secret: &[u8], - expected_audience: &str, - expected_issuer: &str, -) -> Result { - let mut validation = Validation::new(Algorithm::HS256); - validation.leeway = JWT_CLOCK_SKEW_SECONDS as u64; - validation.validate_nbf = true; - validation.set_audience(&[expected_audience]); - validation.set_issuer(&[expected_issuer]); - validation.set_required_spec_claims(&["exp", "iss", "sub", "aud"]); - let token_data = - decode::(token, &DecodingKey::from_secret(secret), &validation) - .map_err(|_| ApiError::Unauthorized("invalid JWT".to_owned()))?; - validate_claims(&token_data.claims)?; - Ok(token_data.claims) -} - -fn validate_claims(claims: &SlackFileProxyClaims) -> Result<(), ApiError> { - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - if claims.iat > now + JWT_CLOCK_SKEW_SECONDS { - return Err(ApiError::Unauthorized( - "JWT issued-at is in the future".to_owned(), - )); - } - if claims.sub.as_deref().unwrap_or_default().trim().is_empty() { - return Err(ApiError::Unauthorized("JWT subject is required".to_owned())); - } - Ok(()) + verify_console_jwt(token) } fn ensure_upload_channel_allowed( @@ -630,7 +571,7 @@ fn content_disposition_filename(filename: &str) -> String { #[cfg(test)] mod tests { use super::*; - use jsonwebtoken::{EncodingKey, Header, encode}; + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; fn test_jwt(secret: &[u8], claims: Value) -> String { encode( @@ -657,7 +598,13 @@ mod tests { } }), ); - let claims = verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap(); + let claims = crate::api_jwt::verify_hs256_jwt::( + &token, + b"secret", + "centaur-api", + "centaur-console", + ) + .unwrap(); ensure_upload_channel_allowed(&claims, "C123456789").unwrap(); ensure_download_channel_allowed(&claims, "C987654321").unwrap(); assert!(matches!( @@ -687,8 +634,13 @@ mod tests { }), ); assert!(matches!( - verify_hs256_jwt(&token, b"other-secret", "centaur-api", "centaur-console") - .unwrap_err(), + crate::api_jwt::verify_hs256_jwt::( + &token, + b"other-secret", + "centaur-api", + "centaur-console" + ) + .unwrap_err(), ApiError::Unauthorized(_) )); } @@ -710,7 +662,13 @@ mod tests { }), ); assert!(matches!( - verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap_err(), + crate::api_jwt::verify_hs256_jwt::( + &token, + b"secret", + "centaur-api", + "centaur-console" + ) + .unwrap_err(), ApiError::Unauthorized(_) )); } @@ -732,7 +690,13 @@ mod tests { }), ); assert!(matches!( - verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap_err(), + crate::api_jwt::verify_hs256_jwt::( + &token, + b"secret", + "centaur-api", + "centaur-console" + ) + .unwrap_err(), ApiError::Unauthorized(_) )); } @@ -753,7 +717,13 @@ mod tests { } }), ); - let claims = verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap(); + let claims = crate::api_jwt::verify_hs256_jwt::( + &token, + b"secret", + "centaur-api", + "centaur-console", + ) + .unwrap(); ensure_upload_channel_allowed(&claims, "C123456789").unwrap(); ensure_download_channel_allowed(&claims, "C123456789").unwrap(); } @@ -772,7 +742,13 @@ mod tests { }), ); assert!(matches!( - verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap_err(), + crate::api_jwt::verify_hs256_jwt::( + &token, + b"secret", + "centaur-api", + "centaur-console" + ) + .unwrap_err(), ApiError::Unauthorized(_) )); } @@ -830,29 +806,17 @@ mod tests { }), ); assert!(matches!( - verify_hs256_jwt(&token, b"secret", "centaur-api", "centaur-console").unwrap_err(), + crate::api_jwt::verify_hs256_jwt::( + &token, + b"secret", + "centaur-api", + "centaur-console" + ) + .unwrap_err(), ApiError::Unauthorized(_) )); } - #[test] - fn bearer_token_scheme_is_case_insensitive() { - for value in ["Bearer token-1", "bearer token-1", "BEARER token-1"] { - let mut headers = HeaderMap::new(); - headers.insert(header::AUTHORIZATION, value.parse().unwrap()); - assert_eq!(bearer_token(&headers).unwrap(), "token-1"); - } - - for value in ["Bearer ", "token-1", "Basic token-1"] { - let mut headers = HeaderMap::new(); - headers.insert(header::AUTHORIZATION, value.parse().unwrap()); - assert!(matches!( - bearer_token(&headers).unwrap_err(), - ApiError::Unauthorized(_) - )); - } - } - #[test] fn detects_unexpected_html_download_body() { assert!(upstream_body_is_unexpected_html( From 16912633b45ffcc651327a52e8c803653084c553 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 12:17:34 -0600 Subject: [PATCH 105/198] fix: wire centaur api url into console chart (#974) --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/console-worker.yaml | 2 ++ contrib/chart/templates/console.yaml | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 87c79d36e..50792fc55 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.91 +version: 0.1.92 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/console-worker.yaml b/contrib/chart/templates/console-worker.yaml index b4babee67..2e3d4ab53 100644 --- a/contrib/chart/templates/console-worker.yaml +++ b/contrib/chart/templates/console-worker.yaml @@ -162,6 +162,8 @@ spec: - name: RAILS_LOG_TO_STDOUT value: "1" {{- if .Values.apiRs.enabled }} + - name: CENTAUR_API_URL + value: {{ printf "http://%s:%v" $apiRsName .Values.apiRs.port | quote }} - name: CENTAUR_CONSOLE_CENTAUR_API_URL value: {{ printf "http://%s:%v" $apiRsName .Values.apiRs.port | quote }} {{- end }} diff --git a/contrib/chart/templates/console.yaml b/contrib/chart/templates/console.yaml index 6c75a8c29..7bede93e4 100644 --- a/contrib/chart/templates/console.yaml +++ b/contrib/chart/templates/console.yaml @@ -225,6 +225,8 @@ spec: - name: RAILS_SERVE_STATIC_FILES value: "1" {{- if .Values.apiRs.enabled }} + - name: CENTAUR_API_URL + value: {{ printf "http://%s:%v" $apiRsName .Values.apiRs.port | quote }} - name: CENTAUR_CONSOLE_CENTAUR_API_URL value: {{ printf "http://%s:%v" $apiRsName .Values.apiRs.port | quote }} {{- end }} From 37b1143b668e1c48c1f47be91b554eda93960f8b Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 13:20:32 -0600 Subject: [PATCH 106/198] chore: update iron-proxy to 0.47.0 (#977) --- services/iron-proxy/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/iron-proxy/Dockerfile b/services/iron-proxy/Dockerfile index 419be953c..f8d9a3515 100644 --- a/services/iron-proxy/Dockerfile +++ b/services/iron-proxy/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM ironsh/iron-proxy:0.46.0@sha256:ce65e5efe68635b0867005d7b7b730f58b5aa112433b418a28d254682706a2fb +FROM ironsh/iron-proxy:0.47.0@sha256:dc13ff78c9e2c83389dc540c8f51cf551edc543900855200a49ccf7ca6fd9666 USER root RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \ From af7e7e809cf1dacc118471a07639498c5f0179e5 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 13:42:24 -0600 Subject: [PATCH 107/198] fix: revert iron-proxy to 0.46.0 (#978) --- services/iron-proxy/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/iron-proxy/Dockerfile b/services/iron-proxy/Dockerfile index f8d9a3515..419be953c 100644 --- a/services/iron-proxy/Dockerfile +++ b/services/iron-proxy/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM ironsh/iron-proxy:0.47.0@sha256:dc13ff78c9e2c83389dc540c8f51cf551edc543900855200a49ccf7ca6fd9666 +FROM ironsh/iron-proxy:0.46.0@sha256:ce65e5efe68635b0867005d7b7b730f58b5aa112433b418a28d254682706a2fb USER root RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \ From c53516c54b56fb9552e30e3c28569d6b2418d579 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Wed, 8 Jul 2026 12:47:49 -0700 Subject: [PATCH 108/198] fix: batch company context projection windows * fix: batch company context projection windows * fix: bump centaur chart version --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/apirs.yaml | 1 + contrib/chart/values.schema.json | 3 +- contrib/chart/values.yaml | 1 + docs/pages/operate/slack-etl.mdx | 1 + docs/pages/reference/configuration.mdx | 1 + docs/public/md/operate/slack-etl.md | 1 + docs/public/md/reference/configuration.md | 1 + workflows/company_context_documents.py | 182 +++++++++++++----- ...t_company_context_documents_attachments.py | 113 ++++++++++- 10 files changed, 254 insertions(+), 52 deletions(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 50792fc55..fd0e45acb 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.92 +version: 0.1.93 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index e5c3a271b..ef9bf673a 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -94,6 +94,7 @@ (dict "name" "GOOGLE_CALENDAR_SYNC_INTERVAL_SECONDS" "value" (dig "googleCalendar" "syncIntervalSeconds" 14400 $apiRsEtl)) (dict "name" "COMPANY_CONTEXT_DOCUMENTS_ENABLED" "value" (dig "companyContextDocuments" "enabled" true $apiRsEtl)) (dict "name" "COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS" "value" (dig "companyContextDocuments" "intervalSeconds" 14400 $apiRsEtl)) + (dict "name" "COMPANY_CONTEXT_DOCUMENTS_MAX_WINDOW_SECONDS" "value" (dig "companyContextDocuments" "maxWindowSeconds" 21600 $apiRsEtl)) -}} {{- $apiRsEtlPassthroughNames := list -}} {{- range $env := $apiRsEtlEnv -}} diff --git a/contrib/chart/values.schema.json b/contrib/chart/values.schema.json index 4ccff9840..df8c81705 100644 --- a/contrib/chart/values.schema.json +++ b/contrib/chart/values.schema.json @@ -330,7 +330,8 @@ "type": "object", "properties": { "enabled": { "type": "boolean" }, - "intervalSeconds": { "type": "integer" } + "intervalSeconds": { "type": "integer" }, + "maxWindowSeconds": { "type": "integer" } } } } diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index 1d2c776e9..9883a07ed 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -397,6 +397,7 @@ apiRs: companyContextDocuments: enabled: true intervalSeconds: 14400 + maxWindowSeconds: 21600 # Reaper: stop sandboxes older than the max lifetime, regardless of whether # they are running or suspended. 0 disables the sweep. Interval must be >= 1. sandboxMaxLifetimeSecs: 259200 # 3 days diff --git a/docs/pages/operate/slack-etl.mdx b/docs/pages/operate/slack-etl.mdx index d1bb69531..df047dcc5 100644 --- a/docs/pages/operate/slack-etl.mdx +++ b/docs/pages/operate/slack-etl.mdx @@ -89,6 +89,7 @@ apiRs: | `SLACK_DM_RETENTION_DAYS` | `0` | Deletes Slack DM messages, stale empty DM conversations, and terminal DM run/job rows older than this many days. `0` disables DM retention. | | `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `true` | Enables projection from Slack sync rows into company context documents. | | `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `14400` | How often to project changed Slack rows into documents. | +| `COMPANY_CONTEXT_DOCUMENTS_MAX_WINDOW_SECONDS` | `21600` | Maximum source `updated_at` window projected by one company context documents run. | Example exclusion list: diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index 1dbe028db..d15ca8e67 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -233,6 +233,7 @@ Slack ETL workflows: | `SLACK_BACKFILL_ENABLED`, `SLACK_BACKFILL_CHANNEL_BATCH_LIMIT`, `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `apiRs.etl.slack.backfill.*`. | Backfill enablement and batch sizing. | | `SLACK_RETENTION_ENABLED`, `SLACK_RETENTION_INTERVAL_MINUTES`, `SLACK_ETL_RETENTION_DAYS`, `SLACK_DM_RETENTION_DAYS` | `apiRs.etl.slack.retention.*`. | Slack retention enablement, cadence, and separate public ETL/DM TTLs. | | `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `apiRs.etl.companyContextDocuments.enabled`. | Enables company-context projection when any ETL is on. | +| `COMPANY_CONTEXT_DOCUMENTS_MAX_WINDOW_SECONDS` | `apiRs.etl.companyContextDocuments.maxWindowSeconds`. | Maximum source `updated_at` window projected by one company-context documents run. | Google Workspace ETL workflows: diff --git a/docs/public/md/operate/slack-etl.md b/docs/public/md/operate/slack-etl.md index e6e3bde3c..b4ffbc4f4 100644 --- a/docs/public/md/operate/slack-etl.md +++ b/docs/public/md/operate/slack-etl.md @@ -89,6 +89,7 @@ apiRs: | `SLACK_DM_RETENTION_DAYS` | `0` | Deletes Slack DM messages, stale empty DM conversations, and terminal DM run/job rows older than this many days. `0` disables DM retention. | | `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `true` | Enables projection from Slack sync rows into company context documents. | | `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `14400` | How often to project changed Slack rows into documents. | +| `COMPANY_CONTEXT_DOCUMENTS_MAX_WINDOW_SECONDS` | `21600` | Maximum source `updated_at` window projected by one company context documents run. | Example exclusion list: diff --git a/docs/public/md/reference/configuration.md b/docs/public/md/reference/configuration.md index ba1cc74d8..73716d901 100644 --- a/docs/public/md/reference/configuration.md +++ b/docs/public/md/reference/configuration.md @@ -236,6 +236,7 @@ Slack ETL workflows: | `SLACK_BACKFILL_ENABLED`, `SLACK_BACKFILL_CHANNEL_BATCH_LIMIT`, `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `apiRs.etl.slack.backfill.*`. | Backfill enablement and batch sizing. | | `SLACK_RETENTION_ENABLED`, `SLACK_RETENTION_INTERVAL_MINUTES`, `SLACK_ETL_RETENTION_DAYS`, `SLACK_DM_RETENTION_DAYS` | `apiRs.etl.slack.retention.*`. | Slack retention enablement, cadence, and separate public ETL/DM TTLs. | | `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `apiRs.etl.companyContextDocuments.enabled`. | Enables company-context projection when any ETL is on. | +| `COMPANY_CONTEXT_DOCUMENTS_MAX_WINDOW_SECONDS` | `apiRs.etl.companyContextDocuments.maxWindowSeconds`. | Maximum source `updated_at` window projected by one company-context documents run. | Google Workspace ETL workflows: diff --git a/workflows/company_context_documents.py b/workflows/company_context_documents.py index 058e3652f..f09d1c8aa 100644 --- a/workflows/company_context_documents.py +++ b/workflows/company_context_documents.py @@ -26,6 +26,7 @@ DEFAULT_SYNC_INTERVAL_SECONDS = 4 * 60 * 60 DEFAULT_WATERMARK_OVERLAP_SECONDS = 60 +DEFAULT_MAX_WINDOW_SECONDS = 6 * 60 * 60 MIN_THREAD_MESSAGES = 5 FALSE_ENV_VALUES = {"0", "false", "no", "off"} SLACK_MENTION_RE = re.compile(r"<@([A-Z0-9]+)>") @@ -95,6 +96,7 @@ class Input: since: str | None = None watermark_overlap_seconds: int = DEFAULT_WATERMARK_OVERLAP_SECONDS + max_window_seconds: int | None = None metadata: dict[str, Any] = field(default_factory=dict) @@ -112,6 +114,58 @@ def _parse_datetime(value: str | None) -> dt.datetime | None: return parsed.astimezone(dt.timezone.utc) +def _updated_at_bounds_clause( + column: str, + since: dt.datetime | None, + until: dt.datetime | None, +) -> tuple[str, list[Any]]: + args: list[Any] = [] + clauses: list[str] = [] + if since is not None: + args.append(since) + clauses.append(f"{column} > ${len(args)}") + if until is not None: + args.append(until) + clauses.append(f"{column} <= ${len(args)}") + return " AND ".join(clauses), args + + +def _updated_at_where( + column: str, + since: dt.datetime | None, + until: dt.datetime | None, + *, + base_clauses: tuple[str, ...] = (), +) -> tuple[str, list[Any]]: + bounds_clause, args = _updated_at_bounds_clause(column, since, until) + clauses = [*base_clauses] + if bounds_clause: + clauses.append(bounds_clause) + return (f"WHERE {' AND '.join(clauses)}" if clauses else ""), args + + +def _max_window_seconds(value: int | str | None = None) -> int: + configured = ( + value + if value is not None + else os.getenv("COMPANY_CONTEXT_DOCUMENTS_MAX_WINDOW_SECONDS") + ) + return _positive_int(configured, DEFAULT_MAX_WINDOW_SECONDS) + + +def _batch_until( + since: dt.datetime | None, + now: dt.datetime, + max_window_seconds: int, +) -> dt.datetime | None: + if since is None: + return None + return min( + now.astimezone(dt.timezone.utc), + since + dt.timedelta(seconds=max_window_seconds), + ) + + def _format_time(value: dt.datetime | None) -> str: """Format document timestamps consistently for context text.""" if not value: @@ -301,16 +355,14 @@ async def _load_slack_lookup_maps(pool) -> tuple[dict[str, str], dict[str, str]] return users_by_id, channels_by_id -async def _load_changed_message_keys(pool, since: dt.datetime | None) -> dict[str, Any]: +async def _load_changed_message_keys( + pool, + since: dt.datetime | None, + until: dt.datetime | None = None, +) -> dict[str, Any]: """Find channel/day and thread aggregates affected by changed Slack rows.""" - if since is None: - where_sql = "" - args: list[Any] = [] - attachment_where_sql = "" - else: - where_sql = "WHERE updated_at > $1" - attachment_where_sql = "WHERE a.updated_at > $1" - args = [since] + where_sql, args = _updated_at_where("updated_at", since, until) + attachment_where_sql, _ = _updated_at_where("a.updated_at", since, until) channel_day_rows = await pool.fetch( "SELECT DISTINCT channel_id, (occurred_at AT TIME ZONE 'UTC')::date AS day " @@ -380,14 +432,18 @@ async def _load_changed_message_keys(pool, since: dt.datetime | None) -> dict[st } -async def _load_changed_drive_files(pool, since: dt.datetime | None) -> dict[str, Any]: +async def _load_changed_drive_files( + pool, + since: dt.datetime | None, + until: dt.datetime | None = None, +) -> dict[str, Any]: """Find Google Drive files whose synced content changed.""" - if since is None: - where_sql = "WHERE last_error = '' AND trashed = FALSE" - args: list[Any] = [] - else: - where_sql = "WHERE last_error = '' AND trashed = FALSE AND updated_at > $1" - args = [since] + where_sql, args = _updated_at_where( + "updated_at", + since, + until, + base_clauses=("last_error = ''", "trashed = FALSE"), + ) rows = await pool.fetch( "SELECT file_id, name, mime_type, web_view_link, drive_id, parent_ids, owners, " @@ -415,14 +471,15 @@ async def _load_changed_drive_files(pool, since: dt.datetime | None) -> dict[str async def _load_changed_calendar_events( pool, since: dt.datetime | None, + until: dt.datetime | None = None, ) -> dict[str, Any]: """Find Google Calendar events whose synced content changed.""" - if since is None: - where_sql = "WHERE e.last_error = ''" - args: list[Any] = [] - else: - where_sql = "WHERE e.last_error = '' AND e.updated_at > $1" - args = [since] + where_sql, args = _updated_at_where( + "e.updated_at", + since, + until, + base_clauses=("e.last_error = ''",), + ) rows = await pool.fetch( "SELECT e.calendar_id, c.summary AS calendar_summary, c.time_zone, " @@ -457,24 +514,30 @@ async def _load_changed_calendar_events( async def _load_changed_linear_issues( pool, since: dt.datetime | None, + until: dt.datetime | None = None, ) -> dict[str, Any]: """Find Linear issues whose issue row or embedded comments changed.""" - if since is None: + bounds_clause, args = _updated_at_bounds_clause("i.updated_at", since, until) + comment_bounds_clause, _ = _updated_at_bounds_clause( + "c.updated_at", + since, + until, + ) + if not bounds_clause: args: list[Any] = [] where_sql = "WHERE i.last_error = ''" comment_where_sql = "" else: - args = [since] where_sql = ( "WHERE i.last_error = '' " - "AND (i.updated_at > $1 OR EXISTS (" + f"AND ({bounds_clause} OR EXISTS (" " SELECT 1 FROM linear_sync_comments c " " WHERE c.issue_id = i.issue_id " " AND c.last_error = '' " - " AND c.updated_at > $1" + f" AND {comment_bounds_clause}" "))" ) - comment_where_sql = "WHERE c.last_error = '' AND c.updated_at > $1" + comment_where_sql = f"WHERE c.last_error = '' AND {comment_bounds_clause}" rows = await pool.fetch( "SELECT i.issue_id, i.identifier, i.issue_number, i.title, i.description, " @@ -1300,6 +1363,9 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: if last_watermark is not None else None ) + now = dt.datetime.now(dt.timezone.utc) + max_window_seconds = _max_window_seconds(inp.max_window_seconds) + batch_until = _batch_until(since, now, max_window_seconds) slack_enabled = _env_flag_enabled("SLACK_ETL_ENABLED") google_drive_enabled = _env_flag_enabled("GOOGLE_DRIVE_ETL_ENABLED") @@ -1328,28 +1394,32 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: channels_by_id: dict[str, str] = {} if slack_enabled: users_by_id, channels_by_id = await _load_slack_lookup_maps(ctx._pool) - changed = await _load_changed_message_keys(ctx._pool, since) + changed = await _load_changed_message_keys(ctx._pool, since, batch_until) drive_changed = { "files": [], "changed_files": 0, "max_updated_at": None, } if google_drive_enabled: - drive_changed = await _load_changed_drive_files(ctx._pool, since) + drive_changed = await _load_changed_drive_files(ctx._pool, since, batch_until) calendar_changed = { "events": [], "changed_events": 0, "max_updated_at": None, } if google_calendar_enabled: - calendar_changed = await _load_changed_calendar_events(ctx._pool, since) + calendar_changed = await _load_changed_calendar_events( + ctx._pool, since, batch_until + ) linear_changed = { "issues": [], "changed_issues": 0, "max_updated_at": None, } if linear_enabled: - linear_changed = await _load_changed_linear_issues(ctx._pool, since) + linear_changed = await _load_changed_linear_issues( + ctx._pool, since, batch_until + ) documents_upserted = 0 documents_deleted = 0 @@ -1508,24 +1578,38 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: if action in {"inserted", "updated"}: documents_upserted += 1 - watermark_candidates = [ - value - for value in ( - changed["max_updated_at"], - drive_changed["max_updated_at"], - calendar_changed["max_updated_at"], - linear_changed["max_updated_at"], - last_watermark, - ) - if value is not None - ] - watermark = max(watermark_candidates) if watermark_candidates else None + if batch_until is not None: + watermark = batch_until + else: + watermark_candidates = [ + value + for value in ( + changed["max_updated_at"], + drive_changed["max_updated_at"], + calendar_changed["max_updated_at"], + linear_changed["max_updated_at"], + last_watermark, + ) + if value is not None + ] + watermark = max(watermark_candidates) if watermark_candidates else None source_watermarks = { - "slack": changed["max_updated_at"] or last_watermark, - "google_drive": drive_changed["max_updated_at"] or last_watermark, - "google_calendar": calendar_changed["max_updated_at"] or last_watermark, - "linear": linear_changed["max_updated_at"] or last_watermark, + "slack": watermark + if batch_until is not None + else changed["max_updated_at"] or last_watermark, + "google_drive": watermark + if batch_until is not None + else drive_changed["max_updated_at"] or last_watermark, + "google_calendar": watermark + if batch_until is not None + else calendar_changed["max_updated_at"] or last_watermark, + "linear": watermark + if batch_until is not None + else linear_changed["max_updated_at"] or last_watermark, } + remaining_lag_seconds = ( + max((now - watermark).total_seconds(), 0.0) if watermark is not None else None + ) _emit_company_context_projection_lag(enabled_sources, source_watermarks) await _emit_etl_scope_metrics(ctx._pool, enabled_sources) await _emit_company_context_document_size_snapshot(ctx._pool, enabled_sources) @@ -1544,6 +1628,10 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: "linear_issue_documents": len(linear_changed["issues"]), "documents_upserted": documents_upserted, "documents_deleted": documents_deleted, + "since": since.isoformat() if since else None, + "batch_until": batch_until.isoformat() if batch_until else None, + "max_window_seconds": max_window_seconds, + "remaining_lag_seconds": remaining_lag_seconds, "watermark": watermark.isoformat() if watermark else None, } ctx.log("company_context_documents_completed", **result) diff --git a/workflows/tests/test_company_context_documents_attachments.py b/workflows/tests/test_company_context_documents_attachments.py index 6179a9816..c8784b793 100644 --- a/workflows/tests/test_company_context_documents_attachments.py +++ b/workflows/tests/test_company_context_documents_attachments.py @@ -89,6 +89,34 @@ async def fetchrow(self, query, *args): } +class FakeChangedRowsPool: + def __init__(self) -> None: + self.fetch_calls: list[tuple[str, tuple]] = [] + self.fetchrow_calls: list[tuple[str, tuple]] = [] + + async def fetch(self, query, *args): + self.fetch_calls.append((query, args)) + return [] + + async def fetchrow(self, query, *args): + self.fetchrow_calls.append((query, args)) + return { + "changed_messages": 0, + "changed_attachments": 0, + "max_updated_at": None, + } + + +class FakeWorkflowContext: + def __init__(self) -> None: + self.run_id = "run_123" + self._pool = object() + self.logs: list[tuple[str, dict]] = [] + + def log(self, message, **fields): + self.logs.append((message, fields)) + + def test_latest_successful_watermark_reads_absurd_etl_queue(): pool = FakeWatermarkPool() @@ -112,6 +140,87 @@ def test_latest_successful_watermark_reads_absurd_etl_queue(): ) +def test_load_changed_message_keys_applies_upper_batch_bound(): + pool = FakeChangedRowsPool() + since = dt.datetime(2026, 6, 18, 22, 58, 36, tzinfo=dt.UTC) + until = dt.datetime(2026, 6, 19, 4, 58, 36, tzinfo=dt.UTC) + + result = asyncio.run(projection._load_changed_message_keys(pool, since, until)) + + assert result["changed_messages"] == 0 + assert pool.fetch_calls + assert pool.fetchrow_calls + queries = [query for query, _args in (*pool.fetch_calls, *pool.fetchrow_calls)] + assert any("updated_at > $1" in query for query in queries) + assert any("updated_at <= $2" in query for query in queries) + assert any("a.updated_at > $1" in query for query in queries) + assert any("a.updated_at <= $2" in query for query in queries) + for _query, args in (*pool.fetch_calls, *pool.fetchrow_calls): + assert args == (since, until) + + +def test_handler_advances_empty_bounded_window(monkeypatch): + last_watermark = dt.datetime(2026, 6, 18, 22, 59, 36, tzinfo=dt.UTC) + seen_bounds: dict[str, dt.datetime | None] = {} + + async def latest_watermark(_pool, _run_id): + return last_watermark + + async def load_slack_lookup_maps(_pool): + return {}, {} + + async def load_changed_message_keys(_pool, since, until=None): + seen_bounds["since"] = since + seen_bounds["until"] = until + return { + "channel_days": [], + "threads": [], + "attachments": [], + "changed_messages": 0, + "changed_attachments": 0, + "max_updated_at": None, + } + + async def noop_async(*_args, **_kwargs): + return None + + monkeypatch.setenv("SLACK_ETL_ENABLED", "true") + monkeypatch.setenv("GOOGLE_DRIVE_ETL_ENABLED", "false") + monkeypatch.setenv("GOOGLE_CALENDAR_ETL_ENABLED", "false") + monkeypatch.setenv("LINEAR_ETL_ENABLED", "false") + monkeypatch.setenv("COMPANY_CONTEXT_DOCUMENTS_ENABLED", "true") + monkeypatch.setattr(projection, "_latest_successful_watermark", latest_watermark) + monkeypatch.setattr(projection, "_load_slack_lookup_maps", load_slack_lookup_maps) + monkeypatch.setattr( + projection, + "_load_changed_message_keys", + load_changed_message_keys, + ) + monkeypatch.setattr( + projection, + "_emit_company_context_document_size_snapshot", + noop_async, + ) + monkeypatch.setattr(projection, "_emit_etl_scope_metrics", noop_async) + + ctx = FakeWorkflowContext() + result = asyncio.run( + projection.handler( + projection.Input(max_window_seconds=3600), + ctx, + ) + ) + + expected_since = dt.datetime(2026, 6, 18, 22, 58, 36, tzinfo=dt.UTC) + expected_until = dt.datetime(2026, 6, 18, 23, 58, 36, tzinfo=dt.UTC) + assert seen_bounds == {"since": expected_since, "until": expected_until} + assert result["changed_messages"] == 0 + assert result["batch_until"] == expected_until.isoformat() + assert result["watermark"] == expected_until.isoformat() + assert result["remaining_lag_seconds"] is not None + assert ctx.logs[-1][0] == "company_context_documents_completed" + + def test_etl_scope_metrics_no_longer_emit_slack_scope_gauges(monkeypatch): calls: list[tuple] = [] monkeypatch.setattr( @@ -131,9 +240,7 @@ def test_etl_scope_metrics_no_longer_emit_slack_scope_gauges(monkeypatch): ) pool = FakeScopeMetricsPool() - asyncio.run( - projection._emit_etl_scope_metrics(pool, ["slack", "google_drive"]) - ) + asyncio.run(projection._emit_etl_scope_metrics(pool, ["slack", "google_drive"])) assert len(pool.fetchrow_calls) == 1 assert "google_drive_sync_checkpoints" in pool.fetchrow_calls[0] From 87051a970a4ccc2645e87d08c453952b55d8b7f3 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 14:02:31 -0600 Subject: [PATCH 109/198] chore: update iron-proxy to v0.48.0 (#979) --- services/iron-proxy/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/iron-proxy/Dockerfile b/services/iron-proxy/Dockerfile index 419be953c..efb7bdfd3 100644 --- a/services/iron-proxy/Dockerfile +++ b/services/iron-proxy/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM ironsh/iron-proxy:0.46.0@sha256:ce65e5efe68635b0867005d7b7b730f58b5aa112433b418a28d254682706a2fb +FROM ironsh/iron-proxy:0.48.0@sha256:1b5de5556a5fa9855d33d5755a2d2b48cc4c7a5e2928e5c0d8cec67c80c247fb USER root RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \ From 310edbeb44a82e6226c084e9f784a2ec7e05834a Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:31:37 +0300 Subject: [PATCH 110/198] fix(slack): resolve user IDs and @usernames to DM channels in read paths (#981) _resolve_channel only matched channel IDs and bot channel names, so get_channel_history, get_thread_replies, and other read methods could not read the bot's DMs even though send_message/send_dm could open them. Resolve U.../<@U...> and @username references to the one-on-one DM conversation via conversations.open, reusing _open_dm_channel. --- tools/productivity/slack/client.py | 16 +++++++- tools/productivity/slack/tests/test_client.py | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/tools/productivity/slack/client.py b/tools/productivity/slack/client.py index 1f1487d1e..3d30145d3 100644 --- a/tools/productivity/slack/client.py +++ b/tools/productivity/slack/client.py @@ -382,7 +382,21 @@ def _collect_cursor_pages( return items, next_cursor, bool(next_cursor) def _resolve_channel(self, channel: str) -> str: - """Resolve a channel name to its ID using cached channel list.""" + """Resolve a channel name, channel ID, user ID, or @user DM to a conversation ID. + + User references (``U123``, ``<@U123>``, or ``@username``) resolve to the + bot's one-on-one DM channel with that user, opening it if needed. + """ + raw = str(channel).strip() + if self._looks_like_user_id(raw): + return self._open_dm_channel(raw) + if raw.startswith("@"): + username = raw[1:].strip() + user_cache = self._get_user_cache() + for user_id, name in user_cache.items(): + if name == username: + return self._open_dm_channel(user_id) + raise RuntimeError(f"User '{channel}' not found in workspace") normalized = self._clean_channel_ref(channel) if self._looks_like_channel_id(normalized): return normalized.upper() diff --git a/tools/productivity/slack/tests/test_client.py b/tools/productivity/slack/tests/test_client.py index 8a06241a5..63c14fb80 100644 --- a/tools/productivity/slack/tests/test_client.py +++ b/tools/productivity/slack/tests/test_client.py @@ -197,6 +197,45 @@ def test_send_dm_opens_dm_and_posts_message() -> None: assert fake_web_client.last_kwargs["unfurl_links"] is False +def _restore_real_resolve_channel(client: SlackClient) -> None: + client._resolve_channel = SlackClient._resolve_channel.__get__(client) # type: ignore[method-assign] + + +def test_resolve_channel_opens_dm_for_user_id() -> None: + client, fake_web_client = _make_client() + _restore_real_resolve_channel(client) + + assert client._resolve_channel("<@U123ABC>") == "D123" + assert fake_web_client.open_calls == [{"users": "U123ABC"}] + + +def test_resolve_channel_opens_dm_for_at_username() -> None: + client, fake_web_client = _make_client() + _restore_real_resolve_channel(client) + client._get_user_cache = lambda: {"U123ABC": "georgios"} # type: ignore[method-assign] + + assert client._resolve_channel("@georgios") == "D123" + assert fake_web_client.open_calls == [{"users": "U123ABC"}] + + +def test_resolve_channel_rejects_unknown_at_username() -> None: + client, _ = _make_client() + _restore_real_resolve_channel(client) + client._get_user_cache = lambda: {"U123ABC": "georgios"} # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="not found in workspace"): + client._resolve_channel("@nobody") + + +def test_resolve_channel_still_resolves_channel_names() -> None: + client, fake_web_client = _make_client() + _restore_real_resolve_channel(client) + + assert client._resolve_channel("paradigm-pulse") == "C123" + assert client._resolve_channel("C456DEF") == "C456DEF" + assert fake_web_client.open_calls == [] + + def test_retry_on_ratelimit_honors_retry_after(monkeypatch: pytest.MonkeyPatch) -> None: client, _ = _make_client() now = {"value": 100.0} From 551f7f7131a1d0c80f85896bc75d8a88e9d9fd7c Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 15:40:05 -0600 Subject: [PATCH 111/198] fix: wire slack bot token into api-rs chart (#982) --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/apirs.yaml | 6 ++++++ docs/pages/deploying-in-production.mdx | 2 +- docs/pages/reference/configuration.mdx | 3 ++- docs/public/md/deploying-in-production.md | 2 +- docs/public/md/reference/configuration.md | 3 ++- 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index fd0e45acb..1f61329f2 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.93 +version: 0.1.94 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index ef9bf673a..260ae79f1 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -214,6 +214,12 @@ spec: key: {{ printf "%sCENTAUR_JWT_SIGNING_SECRET" .Values.secretManager.envPrefix }} - name: BIND_ADDR value: {{ printf "0.0.0.0:%v" .Values.apiRs.port | quote }} + - name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "centaur.secretEnvName" . }} + key: {{ printf "%sSLACK_BOT_TOKEN" .Values.secretManager.envPrefix }} + optional: true {{- if $mcpPublicUrl }} - name: CENTAUR_MCP_PUBLIC_URL value: {{ $mcpPublicUrl | quote }} diff --git a/docs/pages/deploying-in-production.mdx b/docs/pages/deploying-in-production.mdx index 459fe04f0..347070204 100644 --- a/docs/pages/deploying-in-production.mdx +++ b/docs/pages/deploying-in-production.mdx @@ -62,7 +62,7 @@ Minimum keys: | `DATABASE_URL` | API | Postgres connection string. | | `IRON_MANAGEMENT_API_KEY` | [iron-proxy](https://docs.iron.sh) management API | Generate with `openssl rand -hex 32`. | | `SANDBOX_SIGNING_KEY` | Sandbox API tokens | Generate with `openssl rand -hex 32`; keeps sandbox tokens valid across API restarts. | -| `SLACK_BOT_TOKEN` | Slackbot | Bot User OAuth Token from the Slack app. | +| `SLACK_BOT_TOKEN` | Slackbot/API | Bot User OAuth Token from the Slack app. | | `SLACK_SIGNING_SECRET` | Slackbot/API | Used to verify Slack webhook signatures. | | `SLACKBOT_API_KEY` | Slackbot to API | Static service token; API bootstraps it into Postgres on startup with `agent` scope. | | `OP_CONNECT_TOKEN` | [iron-proxy](https://docs.iron.sh) 1Password Connect source (preferred) | Needed when `ironProxy.secretSource` is `onepassword-connect`. | diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index d15ca8e67..110c95ef0 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -35,7 +35,7 @@ These must exist for the normal Helm deployment. For local development, | `DATABASE_URL` | `secretManager.existingSecretName`; local bootstrap generates it. | API and Slackbot Postgres connection. | | `SLACK_SIGNING_SECRET` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Slack request signature verification. | | `SLACKBOT_API_KEY` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Static API key bootstrapped for Slackbot. | -| `SLACK_BOT_TOKEN` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Slack Web API access for Slackbot. | +| `SLACK_BOT_TOKEN` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Slack Web API access for Slackbot and api-rs Slack helpers. | | `SANDBOX_SIGNING_KEY` | `secretManager.existingSecretName`; local bootstrap generates it. | Signing key for short-lived sandbox API tokens. | | `IRON_MANAGEMENT_API_KEY` | `secretManager.existingSecretName`; local bootstrap generates it. | Management key for API-created iron-proxy pods. | | `IRON_BROKER_TOKEN` | `secretManager.existingSecretName`; required when `tokenBroker.enabled=true`. | Bearer token iron-proxy presents to iron-token-broker and the broker enforces on its HTTP API. | @@ -85,6 +85,7 @@ Optional required-by-mode variables: | `apiRs.metrics.path` | Helm value, default `/metrics`. | Metrics scrape path for annotation-based discovery. | | `apiRs.metrics.annotations` | Helm value. | Additional scrape annotations for Prometheus-compatible collectors. | | `apiRs.activitySummary.*` | Helm values, default disabled. | Enables API-RS to summarize live session activity into durable `session.activity_summary` events. | +| `SLACK_BOT_TOKEN` | Explicit `secretKeyRef` from `secretManager.existingSecretName`. | Slack Web API access for api-rs Slack proxy and workflow Slack helpers. | | `OPENAI_API_KEY` | Secret mounted into api-rs, or `apiRs.extraEnv` for local/dev overrides. | OpenAI credential for activity summaries; the feature stays disabled when no key is present. | | `SESSION_ACTIVITY_SUMMARY_MODEL` | `apiRs.activitySummary.model`, default `gpt-5.4-nano`. | Model used for the short live activity sentence. | diff --git a/docs/public/md/deploying-in-production.md b/docs/public/md/deploying-in-production.md index 459fe04f0..347070204 100644 --- a/docs/public/md/deploying-in-production.md +++ b/docs/public/md/deploying-in-production.md @@ -62,7 +62,7 @@ Minimum keys: | `DATABASE_URL` | API | Postgres connection string. | | `IRON_MANAGEMENT_API_KEY` | [iron-proxy](https://docs.iron.sh) management API | Generate with `openssl rand -hex 32`. | | `SANDBOX_SIGNING_KEY` | Sandbox API tokens | Generate with `openssl rand -hex 32`; keeps sandbox tokens valid across API restarts. | -| `SLACK_BOT_TOKEN` | Slackbot | Bot User OAuth Token from the Slack app. | +| `SLACK_BOT_TOKEN` | Slackbot/API | Bot User OAuth Token from the Slack app. | | `SLACK_SIGNING_SECRET` | Slackbot/API | Used to verify Slack webhook signatures. | | `SLACKBOT_API_KEY` | Slackbot to API | Static service token; API bootstraps it into Postgres on startup with `agent` scope. | | `OP_CONNECT_TOKEN` | [iron-proxy](https://docs.iron.sh) 1Password Connect source (preferred) | Needed when `ironProxy.secretSource` is `onepassword-connect`. | diff --git a/docs/public/md/reference/configuration.md b/docs/public/md/reference/configuration.md index 73716d901..6d24cc8f9 100644 --- a/docs/public/md/reference/configuration.md +++ b/docs/public/md/reference/configuration.md @@ -35,7 +35,7 @@ These must exist for the normal Helm deployment. For local development, | `DATABASE_URL` | `secretManager.existingSecretName`; local bootstrap generates it. | API and Slackbot Postgres connection. | | `SLACK_SIGNING_SECRET` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Slack request signature verification. | | `SLACKBOT_API_KEY` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Static API key bootstrapped for Slackbot. | -| `SLACK_BOT_TOKEN` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Slack Web API access for Slackbot. | +| `SLACK_BOT_TOKEN` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Slack Web API access for Slackbot and api-rs Slack helpers. | | `SANDBOX_SIGNING_KEY` | `secretManager.existingSecretName`; local bootstrap generates it. | Signing key for short-lived sandbox API tokens. | | `IRON_MANAGEMENT_API_KEY` | `secretManager.existingSecretName`; local bootstrap generates it. | Management key for API-created iron-proxy pods. | | `IRON_BROKER_TOKEN` | `secretManager.existingSecretName`; required when `tokenBroker.enabled=true`. | Bearer token iron-proxy presents to iron-token-broker and the broker enforces on its HTTP API. | @@ -85,6 +85,7 @@ Optional required-by-mode variables: | `apiRs.metrics.path` | Helm value, default `/metrics`. | Metrics scrape path for annotation-based discovery. | | `apiRs.metrics.annotations` | Helm value. | Additional scrape annotations for Prometheus-compatible collectors. | | `apiRs.activitySummary.*` | Helm values, default disabled. | Enables API-RS to summarize live session activity into durable `session.activity_summary` events. | +| `SLACK_BOT_TOKEN` | Explicit `secretKeyRef` from `secretManager.existingSecretName`. | Slack Web API access for api-rs Slack proxy and workflow Slack helpers. | | `OPENAI_API_KEY` | Secret mounted into api-rs, or `apiRs.extraEnv` for local/dev overrides. | OpenAI credential for activity summaries; the feature stays disabled when no key is present. | | `SESSION_ACTIVITY_SUMMARY_MODEL` | `apiRs.activitySummary.model`, default `gpt-5.4-nano`. | Model used for the short live activity sentence. | From e0af368859343016f002a16e53191352a6f11a11 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 17:52:17 -0600 Subject: [PATCH 112/198] feat: add scoped slack search proxy (#983) * feat: add scoped slack search proxy * fix: keep slack search claims channel scoped * fix: strip all slack search channel filters * fix: resolve slack search scopes * fix: update websocket driver advisory * fix: route slack proxy searches to requested channels * fix: simplify slack proxy fallback flow * feat: add slack proxy search command * fix: scope slack proxy search to current channel --- .../centaur-api-server/src/slack_proxy.rs | 353 ++++++++++++++++-- services/console/Gemfile.lock | 2 +- services/console/lib/api_server/jwt.rb | 10 +- .../console/test/models/principal_test.rb | 7 +- tools/productivity/slack/cli.py | 70 ++-- tools/productivity/slack/client.py | 107 +++++- tools/productivity/slack/tests/test_cli.py | 38 +- tools/productivity/slack/tests/test_client.py | 138 +++++++ 8 files changed, 663 insertions(+), 62 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs index 4abefbe46..96a8b499b 100644 --- a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs +++ b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs @@ -35,6 +35,7 @@ fn http_client() -> &'static reqwest::Client { pub(crate) fn slack_proxy_router() -> Router { Router::new() + .route("/api/slack/search", get(search_slack_messages)) .route( "/api/slack/files/upload", post(upload_slack_file).layer(DefaultBodyLimit::disable()), @@ -69,7 +70,15 @@ struct SlackFileDownloadQuery { } #[derive(Debug, Deserialize)] -struct SlackFileProxyClaims { +struct SlackSearchQuery { + query: String, + channels: String, + #[serde(default)] + count: Option, +} + +#[derive(Debug, Deserialize)] +struct SlackProxyJwtClaims { slack: SlackProxyClaims, } @@ -79,6 +88,8 @@ struct SlackProxyClaims { upload_channels: Vec, #[serde(default)] download_channels: Vec, + #[serde(default)] + search_channels: Vec, } #[derive(Debug, Serialize)] @@ -90,12 +101,53 @@ struct SlackFileUploadResponse { file: Value, } +async fn search_slack_messages( + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + let claims = authorize_slack_proxy(&headers)?; + let search_query = strip_slack_search_in_operators(&query.query); + if search_query.is_empty() { + return Err(ApiError::BadRequest( + "query must include search terms outside Slack in: filters".to_owned(), + )); + } + let channel_ids = slack_search_requested_channels(&query.channels)?; + ensure_search_channels_allowed(&claims, &channel_ids)?; + + let count = query.count.unwrap_or(20).clamp(1, 100) as usize; + let config = slack_proxy_config()?; + let client = http_client(); + let mut matches = Vec::new(); + for channel_id in &channel_ids { + let search_filter = slack_conversation_search_filter(client, config, channel_id).await?; + let scoped_query = format!("{search_query} {search_filter}"); + let value = slack_search_messages(client, config, &scoped_query, count).await?; + matches.extend( + slack_search_matches(&value) + .into_iter() + .filter(|slack_match| slack_search_match_in_channel(slack_match, channel_id)), + ); + } + sort_slack_search_matches(&mut matches); + matches.truncate(count); + + Ok(Json(json!({ + "ok": true, + "query": search_query, + "channels": channel_ids, + "messages": { + "matches": matches, + }, + }))) +} + async fn upload_slack_file( headers: HeaderMap, Query(query): Query, body: Body, ) -> Result, ApiError> { - let claims = authorize_slack_file_proxy(&headers)?; + let claims = authorize_slack_proxy(&headers)?; ensure_upload_channel_allowed(&claims, &query.channel_id)?; validate_slack_channel_id(&query.channel_id)?; validate_filename(&query.filename)?; @@ -151,7 +203,7 @@ async fn download_slack_file( Path(file_id): Path, Query(query): Query, ) -> Result { - let claims = authorize_slack_file_proxy(&headers)?; + let claims = authorize_slack_proxy(&headers)?; ensure_download_channel_allowed(&claims, &query.channel_id)?; validate_slack_channel_id(&query.channel_id)?; validate_slack_file_id(&file_id)?; @@ -235,22 +287,22 @@ fn upstream_body_is_unexpected_html( } // No Debug derive: bot_token must not end up in logs via {:?} formatting. -struct SlackFileProxyConfig { +struct SlackProxyConfig { api_url: String, bot_token: String, max_upload_bytes: u64, } -fn slack_proxy_config() -> Result<&'static SlackFileProxyConfig, ApiError> { - static CELL: OnceLock = OnceLock::new(); +fn slack_proxy_config() -> Result<&'static SlackProxyConfig, ApiError> { + static CELL: OnceLock = OnceLock::new(); if let Some(config) = CELL.get() { return Ok(config); } - let config = SlackFileProxyConfig::from_env()?; + let config = SlackProxyConfig::from_env()?; Ok(CELL.get_or_init(|| config)) } -impl SlackFileProxyConfig { +impl SlackProxyConfig { fn from_env() -> Result { let bot_token = non_empty_env("SLACK_BOT_TOKEN") .ok_or_else(|| ApiError::Internal("SLACK_BOT_TOKEN is not configured".to_owned()))?; @@ -276,7 +328,7 @@ struct SlackUploadTicket { async fn get_upload_url( client: &reqwest::Client, - config: &SlackFileProxyConfig, + config: &SlackProxyConfig, filename: &str, length: u64, alt_txt: Option<&str>, @@ -335,7 +387,7 @@ async fn upload_file_bytes( async fn complete_upload( client: &reqwest::Client, - config: &SlackFileProxyConfig, + config: &SlackProxyConfig, file_id: &str, channel_id: &str, thread_ts: Option<&str>, @@ -363,7 +415,7 @@ async fn complete_upload( async fn slack_file_info( client: &reqwest::Client, - config: &SlackFileProxyConfig, + config: &SlackProxyConfig, file_id: &str, ) -> Result { let value = slack_api_post_form( @@ -378,9 +430,65 @@ async fn slack_file_info( }) } +async fn slack_search_messages( + client: &reqwest::Client, + config: &SlackProxyConfig, + query: &str, + count: usize, +) -> Result { + slack_api_get( + client, + config, + "search.messages", + &[ + ("query", query.to_owned()), + ("count", count.to_string()), + ("sort", "timestamp".to_owned()), + ], + ) + .await +} + +async fn slack_conversation_search_filter( + client: &reqwest::Client, + config: &SlackProxyConfig, + channel_id: &str, +) -> Result { + let value = slack_api_get( + client, + config, + "conversations.info", + &[("channel", channel_id.to_owned())], + ) + .await?; + let channel = value + .get("channel") + .and_then(Value::as_object) + .ok_or_else(|| { + ApiError::BadRequest("Slack channel info response did not include channel".to_owned()) + })?; + if channel_id.starts_with('D') { + let user_id = channel + .get("user") + .and_then(Value::as_str) + .ok_or_else(|| ApiError::BadRequest("Slack DM has no user id".to_owned()))?; + return Ok(format!("in:<@{user_id}>")); + } + let name = channel + .get("name") + .and_then(Value::as_str) + .and_then(normalize_slack_search_filter_value) + .ok_or_else(|| { + ApiError::BadRequest( + "Slack channel info response did not include a searchable name".to_owned(), + ) + })?; + Ok(format!("in:{name}")) +} + async fn slack_api_post_form( client: &reqwest::Client, - config: &SlackFileProxyConfig, + config: &SlackProxyConfig, method: &str, form: &[(&str, String)], ) -> Result { @@ -408,13 +516,47 @@ async fn slack_api_post_form( Ok(value) } -fn authorize_slack_file_proxy(headers: &HeaderMap) -> Result { +async fn slack_api_get( + client: &reqwest::Client, + config: &SlackProxyConfig, + method: &str, + params: &[(&str, String)], +) -> Result { + let query = params + .iter() + .map(|(key, value)| format!("{key}={}", urlencoding::encode(value))) + .collect::>() + .join("&"); + let response = client + .get(format!("{}/{}?{}", config.api_url, method, query)) + .bearer_auth(&config.bot_token) + .send() + .await + .map_err(|error| ApiError::Internal(format!("Slack API request failed: {error}")))?; + let status = response.status(); + let value = response + .json::() + .await + .map_err(|error| ApiError::Internal(format!("Slack API response was not JSON: {error}")))?; + if !status.is_success() || value.get("ok") != Some(&Value::Bool(true)) { + let slack_error = value + .get("error") + .and_then(Value::as_str) + .unwrap_or("unknown_error"); + return Err(ApiError::BadRequest(format!( + "Slack {method} failed: {slack_error}" + ))); + } + Ok(value) +} + +fn authorize_slack_proxy(headers: &HeaderMap) -> Result { let token = bearer_token(headers)?; verify_console_jwt(token) } fn ensure_upload_channel_allowed( - claims: &SlackFileProxyClaims, + claims: &SlackProxyJwtClaims, channel_id: &str, ) -> Result<(), ApiError> { ensure_channel_allowed( @@ -425,7 +567,7 @@ fn ensure_upload_channel_allowed( } fn ensure_download_channel_allowed( - claims: &SlackFileProxyClaims, + claims: &SlackProxyJwtClaims, channel_id: &str, ) -> Result<(), ApiError> { ensure_channel_allowed( @@ -435,6 +577,24 @@ fn ensure_download_channel_allowed( ) } +fn ensure_search_channels_allowed( + claims: &SlackProxyJwtClaims, + channel_ids: &[String], +) -> Result<(), ApiError> { + if channel_ids.iter().all(|channel_id| { + claims + .slack + .search_channels + .iter() + .any(|allowed| allowed == channel_id) + }) { + return Ok(()); + } + Err(ApiError::Forbidden( + "JWT is not authorized to search this Slack channel".to_owned(), + )) +} + fn ensure_channel_allowed( allowed_channels: &[String], channel_id: &str, @@ -446,6 +606,85 @@ fn ensure_channel_allowed( Err(ApiError::Forbidden(message.to_owned())) } +fn slack_search_requested_channels(channels: &str) -> Result, ApiError> { + let mut deduped = BTreeSet::new(); + for channel in channels.split(',') { + let Some(channel_id) = normalize_slack_search_channel_id(channel) else { + return Err(ApiError::BadRequest( + "invalid Slack search channel".to_owned(), + )); + }; + deduped.insert(channel_id); + } + if deduped.is_empty() { + return Err(ApiError::BadRequest( + "at least one Slack search channel is required".to_owned(), + )); + } + Ok(deduped.into_iter().collect()) +} + +fn normalize_slack_search_channel_id(channel: &str) -> Option { + let mut value = channel.trim(); + if value.is_empty() { + return None; + } + if let Some(inner) = value + .strip_prefix("<#") + .and_then(|value| value.strip_suffix('>')) + { + value = inner.split_once('|').map_or(inner, |(id, _)| id); + } + let value = value.to_ascii_uppercase(); + validate_slack_channel_id(&value).ok()?; + Some(value) +} + +fn normalize_slack_search_filter_value(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() || value.chars().any(char::is_whitespace) { + return None; + } + Some(value.to_owned()) +} + +fn strip_slack_search_in_operators(query: &str) -> String { + query + .split_whitespace() + .filter(|term| !is_slack_search_in_operator(term)) + .collect::>() + .join(" ") +} + +fn is_slack_search_in_operator(term: &str) -> bool { + term.trim().to_ascii_lowercase().starts_with("in:") +} + +fn slack_search_matches(value: &Value) -> Vec { + value + .get("messages") + .and_then(|messages| messages.get("matches")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +fn slack_search_match_in_channel(slack_match: &Value, channel_id: &str) -> bool { + slack_match + .get("channel") + .and_then(|channel| channel.get("id")) + .and_then(Value::as_str) + == Some(channel_id) +} + +fn sort_slack_search_matches(matches: &mut [Value]) { + matches.sort_by(|left, right| { + let left_ts = left.get("ts").and_then(Value::as_str).unwrap_or_default(); + let right_ts = right.get("ts").and_then(Value::as_str).unwrap_or_default(); + right_ts.cmp(left_ts) + }); +} + fn slack_file_in_channel(file: &Value, channel_id: &str) -> bool { slack_file_channel_ids(file).contains(channel_id) } @@ -594,11 +833,12 @@ mod tests { "exp": 4_102_444_800i64, "slack": { "upload_channels": ["C123456789"], - "download_channels": ["C987654321"] + "download_channels": ["C987654321"], + "search_channels": ["G123456789"] } }), ); - let claims = crate::api_jwt::verify_hs256_jwt::( + let claims = crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -607,6 +847,7 @@ mod tests { .unwrap(); ensure_upload_channel_allowed(&claims, "C123456789").unwrap(); ensure_download_channel_allowed(&claims, "C987654321").unwrap(); + ensure_search_channels_allowed(&claims, &["G123456789".to_owned()]).unwrap(); assert!(matches!( ensure_upload_channel_allowed(&claims, "C987654321").unwrap_err(), ApiError::Forbidden(_) @@ -615,6 +856,72 @@ mod tests { ensure_download_channel_allowed(&claims, "C123456789").unwrap_err(), ApiError::Forbidden(_) )); + assert!(matches!( + ensure_search_channels_allowed(&claims, &["C123456789".to_owned()]).unwrap_err(), + ApiError::Forbidden(_) + )); + } + + #[test] + fn search_channels_are_normalized_and_authorized() { + let claims = SlackProxyJwtClaims { + slack: SlackProxyClaims { + upload_channels: vec![], + download_channels: vec![], + search_channels: vec![ + "C123456789".to_owned(), + "D111111111".to_owned(), + "G987654321".to_owned(), + ], + }, + }; + let channels = + slack_search_requested_channels("c123456789,D111111111,<#G987654321|eng-oncall>") + .unwrap(); + assert_eq!(channels, vec!["C123456789", "D111111111", "G987654321"]); + ensure_search_channels_allowed(&claims, &channels).unwrap(); + assert!(matches!( + slack_search_requested_channels("eng-oncall").unwrap_err(), + ApiError::BadRequest(_) + )); + } + + #[test] + fn search_query_strips_user_supplied_in_operators() { + assert_eq!( + strip_slack_search_in_operators("deploy in:#general in:<#C123456789|eng-oncall>"), + "deploy" + ); + assert_eq!( + strip_slack_search_in_operators( + "within:limits deploy IN:random in: in:not/a/channel after:2026-01-01" + ), + "within:limits deploy after:2026-01-01" + ); + } + + #[test] + fn slack_search_match_filter_keeps_only_requested_channel() { + let requested = json!({ + "channel": {"id": "C123456789", "name": "general"}, + "text": "deploy" + }); + let other = json!({ + "channel": {"id": "C987654321", "name": "random"}, + "text": "deploy" + }); + + assert!(slack_search_match_in_channel(&requested, "C123456789")); + assert!(!slack_search_match_in_channel(&other, "C123456789")); + } + + #[test] + fn slack_search_filter_values_allow_non_ascii_names() { + assert_eq!( + normalize_slack_search_filter_value("チーム-通知").as_deref(), + Some("チーム-通知") + ); + assert!(normalize_slack_search_filter_value("team alerts").is_none()); } #[test] @@ -634,7 +941,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"other-secret", "centaur-api", @@ -662,7 +969,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -690,7 +997,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -717,7 +1024,7 @@ mod tests { } }), ); - let claims = crate::api_jwt::verify_hs256_jwt::( + let claims = crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -742,7 +1049,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -806,7 +1113,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", diff --git a/services/console/Gemfile.lock b/services/console/Gemfile.lock index a5e95ca25..15fdfa8b6 100644 --- a/services/console/Gemfile.lock +++ b/services/console/Gemfile.lock @@ -352,7 +352,7 @@ GEM bindex (>= 0.4.0) railties (>= 8.0.0) websocket (1.2.11) - websocket-driver (0.8.1) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) diff --git a/services/console/lib/api_server/jwt.rb b/services/console/lib/api_server/jwt.rb index 6ba4ab528..2c2e1ea1b 100644 --- a/services/console/lib/api_server/jwt.rb +++ b/services/console/lib/api_server/jwt.rb @@ -18,6 +18,11 @@ def encode_for_principal(principal, now: Time.current) issued_at = window_start_for(principal, now.to_i) expires_at = issued_at + DEFAULT_TTL_SECONDS + slack_claims = { + "upload_channels" => [ channel_id ], + "download_channels" => [ channel_id ], + "search_channels" => [ channel_id ] + } CentaurJwt::Hs256.encode( { "iss" => issuer, @@ -25,10 +30,7 @@ def encode_for_principal(principal, now: Time.current) "aud" => audience, "iat" => issued_at, "exp" => expires_at, - "slack" => { - "upload_channels" => [ channel_id ], - "download_channels" => [ channel_id ] - } + "slack" => slack_claims }, signing_secret: signing_secret ) diff --git a/services/console/test/models/principal_test.rb b/services/console/test/models/principal_test.rb index 57de568fa..55148881e 100644 --- a/services/console/test/models/principal_test.rb +++ b/services/console/test/models/principal_test.rb @@ -114,7 +114,11 @@ def default_attrs(overrides = {}) "CENTAUR_API_SERVER_PROXY_HOSTS" => nil ) do principal = principals(:acme_channel) - principal.update!(labels: { Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" }) + principal.update!( + labels: { + Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" + } + ) config = principal.effective_config(redact_secrets: false) entry = config.fetch("secrets").find do |secret| @@ -132,6 +136,7 @@ def default_attrs(overrides = {}) assert_equal principal.oid, claims.fetch("sub") assert_equal [ "C0123456789" ], claims.dig("slack", "upload_channels") assert_equal [ "C0123456789" ], claims.dig("slack", "download_channels") + assert_equal [ "C0123456789" ], claims.dig("slack", "search_channels") assert_equal 1.hour.to_i, claims.fetch("exp") - claims.fetch("iat") assert_equal ApiServer::Jwt.rotation_offset(principal), claims.fetch("iat") % ApiServer::Jwt::DEFAULT_WINDOW_SECONDS diff --git a/tools/productivity/slack/cli.py b/tools/productivity/slack/cli.py index aa76b4f93..e3f3380ab 100644 --- a/tools/productivity/slack/cli.py +++ b/tools/productivity/slack/cli.py @@ -48,6 +48,34 @@ def _channel_arg_is_id(channel: str) -> bool: return bool(_SLACK_CHANNEL_ID_RE.fullmatch(value.upper())) +def _print_search_results(query: str, results: list[dict], full: bool) -> None: + if not results: + console.print("[yellow]No messages found.[/]") + raise typer.Exit() + + if full: + for i, msg in enumerate(results, 1): + console.print(f"\n[bold cyan]#{msg['channel']}[/] | [green]{msg['user']}[/]") + console.print(msg["text"]) + console.print(f"[dim]{msg['permalink']}[/]") + if i < len(results): + console.print("---") + return + + table = Table(title=f"Slack: '{query}' ({len(results)} results)") + table.add_column("Channel", style="cyan", max_width=15) + table.add_column("User", style="green", max_width=15) + table.add_column("Message", style="white", max_width=80) + + for msg in results: + text = msg["text"][:80].replace("\n", " ") + if len(msg["text"]) > 80: + text += "..." + table.add_row(f"#{msg['channel']}", msg["user"], text) + + console.print(table) + + @app.command() def send( channel: str = typer.Argument(..., help="Channel name, channel ID, or Slack user ID"), @@ -137,31 +165,29 @@ def search( from_user=from_user, messages_per_channel=depth, ) + _print_search_results(query, results, full) - if not results: - console.print("[yellow]No messages found.[/]") - raise typer.Exit() - - if full: - for i, msg in enumerate(results, 1): - console.print(f"\n[bold cyan]#{msg['channel']}[/] | [green]{msg['user']}[/]") - console.print(msg["text"]) - console.print(f"[dim]{msg['permalink']}[/]") - if i < len(results): - console.print("---") - else: - table = Table(title=f"Slack: '{query}' ({len(results)} results)") - table.add_column("Channel", style="cyan", max_width=15) - table.add_column("User", style="green", max_width=15) - table.add_column("Message", style="white", max_width=80) - for msg in results: - text = msg["text"][:80].replace("\n", " ") - if len(msg["text"]) > 80: - text += "..." - table.add_row(f"#{msg['channel']}", msg["user"], text) +@app.command("search-proxy") +def search_proxy( + query: str = typer.Argument(..., help="Text to search for"), + limit: int = typer.Option(20, "--limit", "-n", help="Max results"), + full: bool = typer.Option(False, "--full", "-f", help="Show full message text"), + from_user: str = typer.Option(None, "--from", help="Filter by username"), +): + """Search messages through the Centaur Slack search proxy.""" + from .client import search_messages_proxy - console.print(table) + try: + results = search_messages_proxy( + query, + max_results=limit, + from_user=from_user, + ) + except (RuntimeError, ValueError) as e: + stderr_console.print(f"[red]Error: {e}[/]") + raise typer.Exit(1) from e + _print_search_results(query, results, full) @app.command() diff --git a/tools/productivity/slack/client.py b/tools/productivity/slack/client.py index 3d30145d3..7d312d678 100644 --- a/tools/productivity/slack/client.py +++ b/tools/productivity/slack/client.py @@ -6,13 +6,14 @@ import os import re import time +import urllib.error import urllib.request from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import UTC, datetime from pathlib import Path from typing import Any, ClassVar -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse import structlog from slack_sdk import WebClient @@ -699,6 +700,11 @@ def search_messages( local_query, local_channels, local_from_user = self._extract_local_search_filters( query, channels, from_user ) + search_query = query + if from_user: + search_query += f" {self._slack_search_from_filter(from_user)}" + search_query = " ".join(search_query.split()) + if local_channels: return self._search_messages_local( local_query, @@ -708,11 +714,6 @@ def search_messages( messages_per_channel, ) - # Build the search query with modifiers - search_query = query - if from_user: - search_query += f" from:@{from_user.lstrip('@')}" - try: return self._search_messages_native(search_query, max_results) except (SlackApiError, RuntimeError, SlackRateLimitError): @@ -721,6 +722,28 @@ def search_messages( local_query, max_results, local_channels, local_from_user, messages_per_channel ) + def search_messages_proxy( + self, + query: str, + max_results: int = 20, + from_user: str | None = None, + ) -> list[dict]: + """Search messages through the Centaur Slack search proxy. + + Defaults to the current Slack thread's channel. Unlike ``search_messages``, + this method does not fall back to Slack native search or local history + scanning. + """ + search_query = query + if from_user: + search_query += f" {self._slack_search_from_filter(from_user)}" + search_query = " ".join(search_query.split()) + return self._search_messages_proxy( + search_query, + max_results, + self._current_slack_channel(), + ) + def _search_messages_native( self, query: str, @@ -737,6 +760,65 @@ def _search_messages_native( if not response.get("ok"): raise RuntimeError(response.get("error", "search.messages failed")) + return self._search_results_from_response(response) + + def _slack_search_from_filter(self, from_user: str) -> str: + user = self._clean_user_ref(from_user) + if self._looks_like_user_id(user): + return f"from:<@{user.upper()}>" + return f"from:{user.lstrip('@')}" + + def _search_messages_proxy( + self, + query: str, + max_results: int, + channel_id: str, + ) -> list[dict]: + """Search through the Centaur API Slack proxy with channel-scoped JWT auth.""" + base_url = self._search_proxy_base_url() + if not base_url: + raise RuntimeError("CENTAUR_API_URL is not configured") + params = urlencode( + { + "query": query, + "channels": channel_id, + "count": max_results, + } + ) + request = urllib.request.Request( + f"{base_url}/api/slack/search?{params}", + headers={"Accept": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=self._api_timeout_seconds()) as response: + payload = response.read() + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace").strip() + message = f"Slack search proxy failed with status {exc.code}" + if detail: + message = f"{message}: {detail}" + raise RuntimeError(message) from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Slack search proxy request failed: {exc}") from exc + + data = json.loads(payload.decode("utf-8")) + if not data.get("ok"): + raise RuntimeError(data.get("error", "Slack search proxy failed")) + return self._search_results_from_response(data) + + def _search_proxy_base_url(self) -> str | None: + value = str(secret("CENTAUR_API_URL", default="") or "").strip() + return value.rstrip("/") or None + + def _current_slack_channel(self) -> str: + from centaur_sdk.tool_sdk import current_slack_thread + + channel_id = current_slack_thread().get("channel_id") + if not channel_id: + raise RuntimeError("Slack search proxy requires a current Slack channel") + return channel_id + + def _search_results_from_response(self, response: dict) -> list[dict]: matches = response.get("messages", {}).get("matches", []) user_cache = self._get_user_cache() @@ -797,9 +879,13 @@ def from_repl(match: re.Match) -> str: query, ) + deduped_channels = self._dedupe_channel_refs(local_channels) + return " ".join(query.split()), deduped_channels or None, local_from_user + + def _dedupe_channel_refs(self, channels: list[str]) -> list[str]: deduped_channels = [] seen = set() - for channel in local_channels: + for channel in channels: normalized = self._clean_channel_ref(channel) if not normalized: continue @@ -808,8 +894,7 @@ def from_repl(match: re.Match) -> str: continue seen.add(key) deduped_channels.append(normalized) - - return " ".join(query.split()), deduped_channels or None, local_from_user + return deduped_channels def _channel_refs_for_search(self, channels: list[str]) -> list[dict]: """Resolve channel filters without listing channels when IDs are provided.""" @@ -2055,6 +2140,10 @@ def search_messages(*args, **kwargs): return _client().search_messages(*args, **kwargs) +def search_messages_proxy(*args, **kwargs): + return _client().search_messages_proxy(*args, **kwargs) + + def get_channel_history_page(*args, **kwargs): return _client().get_channel_history_page(*args, **kwargs) diff --git a/tools/productivity/slack/tests/test_cli.py b/tools/productivity/slack/tests/test_cli.py index f36464047..e68d8a38d 100644 --- a/tools/productivity/slack/tests/test_cli.py +++ b/tools/productivity/slack/tests/test_cli.py @@ -2,9 +2,8 @@ import types from pathlib import Path -from typer.testing import CliRunner - from slack.cli import _channel_arg_is_id, app +from typer.testing import CliRunner def test_channel_arg_is_id_accepts_channel_id_forms() -> None: @@ -78,3 +77,38 @@ def test_upload_rejects_channel_name(monkeypatch, tmp_path: Path) -> None: assert result.exit_code == 1 assert "must be a Slack conversation ID" in result.output + + +def test_search_proxy_command_calls_proxy_search(monkeypatch) -> None: + calls = [] + + def fake_search_messages_proxy(*args, **kwargs): + calls.append((args, kwargs)) + return [ + { + "channel": "eng-oncall", + "user": "alice", + "text": "deploy complete", + "permalink": "https://slack.example/archives/C123/p1", + } + ] + + fake_client = types.SimpleNamespace(search_messages_proxy=fake_search_messages_proxy) + monkeypatch.setitem(sys.modules, "slack.client", fake_client) + + result = CliRunner().invoke( + app, + ["search-proxy", "deploy", "--limit", "3", "--from", "alice"], + ) + + assert result.exit_code == 0 + assert calls == [ + ( + ("deploy",), + { + "max_results": 3, + "from_user": "alice", + }, + ) + ] + assert "deploy complete" in result.output diff --git a/tools/productivity/slack/tests/test_client.py b/tools/productivity/slack/tests/test_client.py index 63c14fb80..ab2ad6ef0 100644 --- a/tools/productivity/slack/tests/test_client.py +++ b/tools/productivity/slack/tests/test_client.py @@ -1,4 +1,5 @@ import email.message +import io import json import pytest @@ -886,6 +887,143 @@ def test_native_search_uses_dedicated_search_client() -> None: assert fake_bot_client.api_calls == [] +def test_search_messages_proxy_uses_api_proxy_for_channel_scoped_search( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import urllib.request + + import centaur_sdk.tool_sdk + + client, fake_web_client = _make_client() + client._get_user_cache = lambda: {"U1": "alice"} # type: ignore[method-assign] + monkeypatch.setenv("CENTAUR_API_URL", "http://api") + monkeypatch.setattr( + centaur_sdk.tool_sdk, + "current_slack_thread", + lambda: {"channel_id": "C777", "thread_ts": "123.456"}, + ) + + def fake_urlopen(req, *args, **kwargs): + assert req.full_url == ( + "http://api/api/slack/search?query=deploy+from%3A%3C%40UGZCSQTPE%3E+" + "in%3A%23other&channels=C777&count=5" + ) + body = json.dumps( + { + "ok": True, + "messages": { + "matches": [ + { + "user": "U1", + "text": "deploy complete", + "ts": "200.000000", + "permalink": "https://slack.com/archives/C777/p200000000", + "channel": {"id": "C777", "name": "paradigm-pulse"}, + } + ] + }, + } + ).encode() + return _FakeHTTPResponse(body, "application/json") + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + result = client.search_messages_proxy( + "deploy from:<@UGZCSQTPE> in:#other", + max_results=5, + ) + + assert result[0]["text"] == "deploy complete" + assert fake_web_client.api_calls == [] + assert fake_web_client.history_calls == [] + + +def test_search_messages_proxy_defaults_to_current_slack_channel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import urllib.request + + import centaur_sdk.tool_sdk + + client, _ = _make_client() + client._get_user_cache = lambda: {"U1": "alice"} # type: ignore[method-assign] + monkeypatch.setenv("CENTAUR_API_URL", "http://api") + monkeypatch.setattr( + centaur_sdk.tool_sdk, + "current_slack_thread", + lambda: {"channel_id": "C777", "thread_ts": "123.456"}, + ) + + def fake_urlopen(req, *args, **kwargs): + assert req.full_url == "http://api/api/slack/search?query=deploy&channels=C777&count=5" + body = json.dumps({"ok": True, "messages": {"matches": []}}).encode() + return _FakeHTTPResponse(body, "application/json") + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + assert client.search_messages_proxy("deploy", max_results=5) == [] + + +def test_search_messages_proxy_fails_without_current_slack_channel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import centaur_sdk.tool_sdk + + client, _ = _make_client() + monkeypatch.setattr( + centaur_sdk.tool_sdk, + "current_slack_thread", + lambda: (_ for _ in ()).throw(RuntimeError("not a Slack thread")), + ) + + with pytest.raises(RuntimeError, match="not a Slack thread"): + client.search_messages_proxy("deploy") + + +def test_search_messages_proxy_failure_does_not_fall_back_to_local_scan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import urllib.error + import urllib.request + + import centaur_sdk.tool_sdk + + client, fake_web_client = _make_client() + client._get_user_cache = lambda: {"U1": "alice"} # type: ignore[method-assign] + monkeypatch.setenv("CENTAUR_API_URL", "http://api") + monkeypatch.setattr( + centaur_sdk.tool_sdk, + "current_slack_thread", + lambda: {"channel_id": "C777", "thread_ts": "123.456"}, + ) + fake_web_client.history_pages = [ + {"messages": [{"user": "U1", "text": "deploy fallback", "ts": "300.000000"}]} + ] + + def fake_urlopen(req, *args, **kwargs): + raise urllib.error.HTTPError( + req.full_url, + 403, + "Forbidden", + hdrs=None, + fp=io.BytesIO(b'{"error":"forbidden"}'), + ) + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + with pytest.raises(RuntimeError, match="Slack search proxy failed with status 403"): + client.search_messages_proxy("deploy", max_results=5) + + assert fake_web_client.history_calls == [] + + +def test_slack_search_from_filter_uses_documented_forms() -> None: + client, _ = _make_client() + + assert client._slack_search_from_filter("<@UGZCSQTPE>") == "from:<@UGZCSQTPE>" + assert client._slack_search_from_filter("@deploybot") == "from:deploybot" + + def test_sync_channel_history_uses_watermark_lookback() -> None: client, _ = _make_client() captured: dict = {} From d6e627d17d36cfc77245db2ced9e12147a8353e4 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 19:17:48 -0600 Subject: [PATCH 113/198] Revert "feat: add scoped slack search proxy (#983)" (#985) * Revert "feat: add scoped slack search proxy (#983)" This reverts commit e0af368859343016f002a16e53191352a6f11a11. * restore websocket-driver fix --- .../centaur-api-server/src/slack_proxy.rs | 353 ++---------------- services/console/lib/api_server/jwt.rb | 10 +- .../console/test/models/principal_test.rb | 7 +- tools/productivity/slack/cli.py | 70 ++-- tools/productivity/slack/client.py | 107 +----- tools/productivity/slack/tests/test_cli.py | 38 +- tools/productivity/slack/tests/test_client.py | 138 ------- 7 files changed, 61 insertions(+), 662 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs index 96a8b499b..4abefbe46 100644 --- a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs +++ b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs @@ -35,7 +35,6 @@ fn http_client() -> &'static reqwest::Client { pub(crate) fn slack_proxy_router() -> Router { Router::new() - .route("/api/slack/search", get(search_slack_messages)) .route( "/api/slack/files/upload", post(upload_slack_file).layer(DefaultBodyLimit::disable()), @@ -70,15 +69,7 @@ struct SlackFileDownloadQuery { } #[derive(Debug, Deserialize)] -struct SlackSearchQuery { - query: String, - channels: String, - #[serde(default)] - count: Option, -} - -#[derive(Debug, Deserialize)] -struct SlackProxyJwtClaims { +struct SlackFileProxyClaims { slack: SlackProxyClaims, } @@ -88,8 +79,6 @@ struct SlackProxyClaims { upload_channels: Vec, #[serde(default)] download_channels: Vec, - #[serde(default)] - search_channels: Vec, } #[derive(Debug, Serialize)] @@ -101,53 +90,12 @@ struct SlackFileUploadResponse { file: Value, } -async fn search_slack_messages( - headers: HeaderMap, - Query(query): Query, -) -> Result, ApiError> { - let claims = authorize_slack_proxy(&headers)?; - let search_query = strip_slack_search_in_operators(&query.query); - if search_query.is_empty() { - return Err(ApiError::BadRequest( - "query must include search terms outside Slack in: filters".to_owned(), - )); - } - let channel_ids = slack_search_requested_channels(&query.channels)?; - ensure_search_channels_allowed(&claims, &channel_ids)?; - - let count = query.count.unwrap_or(20).clamp(1, 100) as usize; - let config = slack_proxy_config()?; - let client = http_client(); - let mut matches = Vec::new(); - for channel_id in &channel_ids { - let search_filter = slack_conversation_search_filter(client, config, channel_id).await?; - let scoped_query = format!("{search_query} {search_filter}"); - let value = slack_search_messages(client, config, &scoped_query, count).await?; - matches.extend( - slack_search_matches(&value) - .into_iter() - .filter(|slack_match| slack_search_match_in_channel(slack_match, channel_id)), - ); - } - sort_slack_search_matches(&mut matches); - matches.truncate(count); - - Ok(Json(json!({ - "ok": true, - "query": search_query, - "channels": channel_ids, - "messages": { - "matches": matches, - }, - }))) -} - async fn upload_slack_file( headers: HeaderMap, Query(query): Query, body: Body, ) -> Result, ApiError> { - let claims = authorize_slack_proxy(&headers)?; + let claims = authorize_slack_file_proxy(&headers)?; ensure_upload_channel_allowed(&claims, &query.channel_id)?; validate_slack_channel_id(&query.channel_id)?; validate_filename(&query.filename)?; @@ -203,7 +151,7 @@ async fn download_slack_file( Path(file_id): Path, Query(query): Query, ) -> Result { - let claims = authorize_slack_proxy(&headers)?; + let claims = authorize_slack_file_proxy(&headers)?; ensure_download_channel_allowed(&claims, &query.channel_id)?; validate_slack_channel_id(&query.channel_id)?; validate_slack_file_id(&file_id)?; @@ -287,22 +235,22 @@ fn upstream_body_is_unexpected_html( } // No Debug derive: bot_token must not end up in logs via {:?} formatting. -struct SlackProxyConfig { +struct SlackFileProxyConfig { api_url: String, bot_token: String, max_upload_bytes: u64, } -fn slack_proxy_config() -> Result<&'static SlackProxyConfig, ApiError> { - static CELL: OnceLock = OnceLock::new(); +fn slack_proxy_config() -> Result<&'static SlackFileProxyConfig, ApiError> { + static CELL: OnceLock = OnceLock::new(); if let Some(config) = CELL.get() { return Ok(config); } - let config = SlackProxyConfig::from_env()?; + let config = SlackFileProxyConfig::from_env()?; Ok(CELL.get_or_init(|| config)) } -impl SlackProxyConfig { +impl SlackFileProxyConfig { fn from_env() -> Result { let bot_token = non_empty_env("SLACK_BOT_TOKEN") .ok_or_else(|| ApiError::Internal("SLACK_BOT_TOKEN is not configured".to_owned()))?; @@ -328,7 +276,7 @@ struct SlackUploadTicket { async fn get_upload_url( client: &reqwest::Client, - config: &SlackProxyConfig, + config: &SlackFileProxyConfig, filename: &str, length: u64, alt_txt: Option<&str>, @@ -387,7 +335,7 @@ async fn upload_file_bytes( async fn complete_upload( client: &reqwest::Client, - config: &SlackProxyConfig, + config: &SlackFileProxyConfig, file_id: &str, channel_id: &str, thread_ts: Option<&str>, @@ -415,7 +363,7 @@ async fn complete_upload( async fn slack_file_info( client: &reqwest::Client, - config: &SlackProxyConfig, + config: &SlackFileProxyConfig, file_id: &str, ) -> Result { let value = slack_api_post_form( @@ -430,65 +378,9 @@ async fn slack_file_info( }) } -async fn slack_search_messages( - client: &reqwest::Client, - config: &SlackProxyConfig, - query: &str, - count: usize, -) -> Result { - slack_api_get( - client, - config, - "search.messages", - &[ - ("query", query.to_owned()), - ("count", count.to_string()), - ("sort", "timestamp".to_owned()), - ], - ) - .await -} - -async fn slack_conversation_search_filter( - client: &reqwest::Client, - config: &SlackProxyConfig, - channel_id: &str, -) -> Result { - let value = slack_api_get( - client, - config, - "conversations.info", - &[("channel", channel_id.to_owned())], - ) - .await?; - let channel = value - .get("channel") - .and_then(Value::as_object) - .ok_or_else(|| { - ApiError::BadRequest("Slack channel info response did not include channel".to_owned()) - })?; - if channel_id.starts_with('D') { - let user_id = channel - .get("user") - .and_then(Value::as_str) - .ok_or_else(|| ApiError::BadRequest("Slack DM has no user id".to_owned()))?; - return Ok(format!("in:<@{user_id}>")); - } - let name = channel - .get("name") - .and_then(Value::as_str) - .and_then(normalize_slack_search_filter_value) - .ok_or_else(|| { - ApiError::BadRequest( - "Slack channel info response did not include a searchable name".to_owned(), - ) - })?; - Ok(format!("in:{name}")) -} - async fn slack_api_post_form( client: &reqwest::Client, - config: &SlackProxyConfig, + config: &SlackFileProxyConfig, method: &str, form: &[(&str, String)], ) -> Result { @@ -516,47 +408,13 @@ async fn slack_api_post_form( Ok(value) } -async fn slack_api_get( - client: &reqwest::Client, - config: &SlackProxyConfig, - method: &str, - params: &[(&str, String)], -) -> Result { - let query = params - .iter() - .map(|(key, value)| format!("{key}={}", urlencoding::encode(value))) - .collect::>() - .join("&"); - let response = client - .get(format!("{}/{}?{}", config.api_url, method, query)) - .bearer_auth(&config.bot_token) - .send() - .await - .map_err(|error| ApiError::Internal(format!("Slack API request failed: {error}")))?; - let status = response.status(); - let value = response - .json::() - .await - .map_err(|error| ApiError::Internal(format!("Slack API response was not JSON: {error}")))?; - if !status.is_success() || value.get("ok") != Some(&Value::Bool(true)) { - let slack_error = value - .get("error") - .and_then(Value::as_str) - .unwrap_or("unknown_error"); - return Err(ApiError::BadRequest(format!( - "Slack {method} failed: {slack_error}" - ))); - } - Ok(value) -} - -fn authorize_slack_proxy(headers: &HeaderMap) -> Result { +fn authorize_slack_file_proxy(headers: &HeaderMap) -> Result { let token = bearer_token(headers)?; verify_console_jwt(token) } fn ensure_upload_channel_allowed( - claims: &SlackProxyJwtClaims, + claims: &SlackFileProxyClaims, channel_id: &str, ) -> Result<(), ApiError> { ensure_channel_allowed( @@ -567,7 +425,7 @@ fn ensure_upload_channel_allowed( } fn ensure_download_channel_allowed( - claims: &SlackProxyJwtClaims, + claims: &SlackFileProxyClaims, channel_id: &str, ) -> Result<(), ApiError> { ensure_channel_allowed( @@ -577,24 +435,6 @@ fn ensure_download_channel_allowed( ) } -fn ensure_search_channels_allowed( - claims: &SlackProxyJwtClaims, - channel_ids: &[String], -) -> Result<(), ApiError> { - if channel_ids.iter().all(|channel_id| { - claims - .slack - .search_channels - .iter() - .any(|allowed| allowed == channel_id) - }) { - return Ok(()); - } - Err(ApiError::Forbidden( - "JWT is not authorized to search this Slack channel".to_owned(), - )) -} - fn ensure_channel_allowed( allowed_channels: &[String], channel_id: &str, @@ -606,85 +446,6 @@ fn ensure_channel_allowed( Err(ApiError::Forbidden(message.to_owned())) } -fn slack_search_requested_channels(channels: &str) -> Result, ApiError> { - let mut deduped = BTreeSet::new(); - for channel in channels.split(',') { - let Some(channel_id) = normalize_slack_search_channel_id(channel) else { - return Err(ApiError::BadRequest( - "invalid Slack search channel".to_owned(), - )); - }; - deduped.insert(channel_id); - } - if deduped.is_empty() { - return Err(ApiError::BadRequest( - "at least one Slack search channel is required".to_owned(), - )); - } - Ok(deduped.into_iter().collect()) -} - -fn normalize_slack_search_channel_id(channel: &str) -> Option { - let mut value = channel.trim(); - if value.is_empty() { - return None; - } - if let Some(inner) = value - .strip_prefix("<#") - .and_then(|value| value.strip_suffix('>')) - { - value = inner.split_once('|').map_or(inner, |(id, _)| id); - } - let value = value.to_ascii_uppercase(); - validate_slack_channel_id(&value).ok()?; - Some(value) -} - -fn normalize_slack_search_filter_value(value: &str) -> Option { - let value = value.trim(); - if value.is_empty() || value.chars().any(char::is_whitespace) { - return None; - } - Some(value.to_owned()) -} - -fn strip_slack_search_in_operators(query: &str) -> String { - query - .split_whitespace() - .filter(|term| !is_slack_search_in_operator(term)) - .collect::>() - .join(" ") -} - -fn is_slack_search_in_operator(term: &str) -> bool { - term.trim().to_ascii_lowercase().starts_with("in:") -} - -fn slack_search_matches(value: &Value) -> Vec { - value - .get("messages") - .and_then(|messages| messages.get("matches")) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() -} - -fn slack_search_match_in_channel(slack_match: &Value, channel_id: &str) -> bool { - slack_match - .get("channel") - .and_then(|channel| channel.get("id")) - .and_then(Value::as_str) - == Some(channel_id) -} - -fn sort_slack_search_matches(matches: &mut [Value]) { - matches.sort_by(|left, right| { - let left_ts = left.get("ts").and_then(Value::as_str).unwrap_or_default(); - let right_ts = right.get("ts").and_then(Value::as_str).unwrap_or_default(); - right_ts.cmp(left_ts) - }); -} - fn slack_file_in_channel(file: &Value, channel_id: &str) -> bool { slack_file_channel_ids(file).contains(channel_id) } @@ -833,12 +594,11 @@ mod tests { "exp": 4_102_444_800i64, "slack": { "upload_channels": ["C123456789"], - "download_channels": ["C987654321"], - "search_channels": ["G123456789"] + "download_channels": ["C987654321"] } }), ); - let claims = crate::api_jwt::verify_hs256_jwt::( + let claims = crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -847,7 +607,6 @@ mod tests { .unwrap(); ensure_upload_channel_allowed(&claims, "C123456789").unwrap(); ensure_download_channel_allowed(&claims, "C987654321").unwrap(); - ensure_search_channels_allowed(&claims, &["G123456789".to_owned()]).unwrap(); assert!(matches!( ensure_upload_channel_allowed(&claims, "C987654321").unwrap_err(), ApiError::Forbidden(_) @@ -856,72 +615,6 @@ mod tests { ensure_download_channel_allowed(&claims, "C123456789").unwrap_err(), ApiError::Forbidden(_) )); - assert!(matches!( - ensure_search_channels_allowed(&claims, &["C123456789".to_owned()]).unwrap_err(), - ApiError::Forbidden(_) - )); - } - - #[test] - fn search_channels_are_normalized_and_authorized() { - let claims = SlackProxyJwtClaims { - slack: SlackProxyClaims { - upload_channels: vec![], - download_channels: vec![], - search_channels: vec![ - "C123456789".to_owned(), - "D111111111".to_owned(), - "G987654321".to_owned(), - ], - }, - }; - let channels = - slack_search_requested_channels("c123456789,D111111111,<#G987654321|eng-oncall>") - .unwrap(); - assert_eq!(channels, vec!["C123456789", "D111111111", "G987654321"]); - ensure_search_channels_allowed(&claims, &channels).unwrap(); - assert!(matches!( - slack_search_requested_channels("eng-oncall").unwrap_err(), - ApiError::BadRequest(_) - )); - } - - #[test] - fn search_query_strips_user_supplied_in_operators() { - assert_eq!( - strip_slack_search_in_operators("deploy in:#general in:<#C123456789|eng-oncall>"), - "deploy" - ); - assert_eq!( - strip_slack_search_in_operators( - "within:limits deploy IN:random in: in:not/a/channel after:2026-01-01" - ), - "within:limits deploy after:2026-01-01" - ); - } - - #[test] - fn slack_search_match_filter_keeps_only_requested_channel() { - let requested = json!({ - "channel": {"id": "C123456789", "name": "general"}, - "text": "deploy" - }); - let other = json!({ - "channel": {"id": "C987654321", "name": "random"}, - "text": "deploy" - }); - - assert!(slack_search_match_in_channel(&requested, "C123456789")); - assert!(!slack_search_match_in_channel(&other, "C123456789")); - } - - #[test] - fn slack_search_filter_values_allow_non_ascii_names() { - assert_eq!( - normalize_slack_search_filter_value("チーム-通知").as_deref(), - Some("チーム-通知") - ); - assert!(normalize_slack_search_filter_value("team alerts").is_none()); } #[test] @@ -941,7 +634,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"other-secret", "centaur-api", @@ -969,7 +662,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -997,7 +690,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -1024,7 +717,7 @@ mod tests { } }), ); - let claims = crate::api_jwt::verify_hs256_jwt::( + let claims = crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -1049,7 +742,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", @@ -1113,7 +806,7 @@ mod tests { }), ); assert!(matches!( - crate::api_jwt::verify_hs256_jwt::( + crate::api_jwt::verify_hs256_jwt::( &token, b"secret", "centaur-api", diff --git a/services/console/lib/api_server/jwt.rb b/services/console/lib/api_server/jwt.rb index 2c2e1ea1b..6ba4ab528 100644 --- a/services/console/lib/api_server/jwt.rb +++ b/services/console/lib/api_server/jwt.rb @@ -18,11 +18,6 @@ def encode_for_principal(principal, now: Time.current) issued_at = window_start_for(principal, now.to_i) expires_at = issued_at + DEFAULT_TTL_SECONDS - slack_claims = { - "upload_channels" => [ channel_id ], - "download_channels" => [ channel_id ], - "search_channels" => [ channel_id ] - } CentaurJwt::Hs256.encode( { "iss" => issuer, @@ -30,7 +25,10 @@ def encode_for_principal(principal, now: Time.current) "aud" => audience, "iat" => issued_at, "exp" => expires_at, - "slack" => slack_claims + "slack" => { + "upload_channels" => [ channel_id ], + "download_channels" => [ channel_id ] + } }, signing_secret: signing_secret ) diff --git a/services/console/test/models/principal_test.rb b/services/console/test/models/principal_test.rb index 55148881e..57de568fa 100644 --- a/services/console/test/models/principal_test.rb +++ b/services/console/test/models/principal_test.rb @@ -114,11 +114,7 @@ def default_attrs(overrides = {}) "CENTAUR_API_SERVER_PROXY_HOSTS" => nil ) do principal = principals(:acme_channel) - principal.update!( - labels: { - Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" - } - ) + principal.update!(labels: { Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" }) config = principal.effective_config(redact_secrets: false) entry = config.fetch("secrets").find do |secret| @@ -136,7 +132,6 @@ def default_attrs(overrides = {}) assert_equal principal.oid, claims.fetch("sub") assert_equal [ "C0123456789" ], claims.dig("slack", "upload_channels") assert_equal [ "C0123456789" ], claims.dig("slack", "download_channels") - assert_equal [ "C0123456789" ], claims.dig("slack", "search_channels") assert_equal 1.hour.to_i, claims.fetch("exp") - claims.fetch("iat") assert_equal ApiServer::Jwt.rotation_offset(principal), claims.fetch("iat") % ApiServer::Jwt::DEFAULT_WINDOW_SECONDS diff --git a/tools/productivity/slack/cli.py b/tools/productivity/slack/cli.py index e3f3380ab..aa76b4f93 100644 --- a/tools/productivity/slack/cli.py +++ b/tools/productivity/slack/cli.py @@ -48,34 +48,6 @@ def _channel_arg_is_id(channel: str) -> bool: return bool(_SLACK_CHANNEL_ID_RE.fullmatch(value.upper())) -def _print_search_results(query: str, results: list[dict], full: bool) -> None: - if not results: - console.print("[yellow]No messages found.[/]") - raise typer.Exit() - - if full: - for i, msg in enumerate(results, 1): - console.print(f"\n[bold cyan]#{msg['channel']}[/] | [green]{msg['user']}[/]") - console.print(msg["text"]) - console.print(f"[dim]{msg['permalink']}[/]") - if i < len(results): - console.print("---") - return - - table = Table(title=f"Slack: '{query}' ({len(results)} results)") - table.add_column("Channel", style="cyan", max_width=15) - table.add_column("User", style="green", max_width=15) - table.add_column("Message", style="white", max_width=80) - - for msg in results: - text = msg["text"][:80].replace("\n", " ") - if len(msg["text"]) > 80: - text += "..." - table.add_row(f"#{msg['channel']}", msg["user"], text) - - console.print(table) - - @app.command() def send( channel: str = typer.Argument(..., help="Channel name, channel ID, or Slack user ID"), @@ -165,29 +137,31 @@ def search( from_user=from_user, messages_per_channel=depth, ) - _print_search_results(query, results, full) + if not results: + console.print("[yellow]No messages found.[/]") + raise typer.Exit() -@app.command("search-proxy") -def search_proxy( - query: str = typer.Argument(..., help="Text to search for"), - limit: int = typer.Option(20, "--limit", "-n", help="Max results"), - full: bool = typer.Option(False, "--full", "-f", help="Show full message text"), - from_user: str = typer.Option(None, "--from", help="Filter by username"), -): - """Search messages through the Centaur Slack search proxy.""" - from .client import search_messages_proxy + if full: + for i, msg in enumerate(results, 1): + console.print(f"\n[bold cyan]#{msg['channel']}[/] | [green]{msg['user']}[/]") + console.print(msg["text"]) + console.print(f"[dim]{msg['permalink']}[/]") + if i < len(results): + console.print("---") + else: + table = Table(title=f"Slack: '{query}' ({len(results)} results)") + table.add_column("Channel", style="cyan", max_width=15) + table.add_column("User", style="green", max_width=15) + table.add_column("Message", style="white", max_width=80) - try: - results = search_messages_proxy( - query, - max_results=limit, - from_user=from_user, - ) - except (RuntimeError, ValueError) as e: - stderr_console.print(f"[red]Error: {e}[/]") - raise typer.Exit(1) from e - _print_search_results(query, results, full) + for msg in results: + text = msg["text"][:80].replace("\n", " ") + if len(msg["text"]) > 80: + text += "..." + table.add_row(f"#{msg['channel']}", msg["user"], text) + + console.print(table) @app.command() diff --git a/tools/productivity/slack/client.py b/tools/productivity/slack/client.py index 7d312d678..3d30145d3 100644 --- a/tools/productivity/slack/client.py +++ b/tools/productivity/slack/client.py @@ -6,14 +6,13 @@ import os import re import time -import urllib.error import urllib.request from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import UTC, datetime from pathlib import Path from typing import Any, ClassVar -from urllib.parse import urlencode, urlparse +from urllib.parse import urlparse import structlog from slack_sdk import WebClient @@ -700,11 +699,6 @@ def search_messages( local_query, local_channels, local_from_user = self._extract_local_search_filters( query, channels, from_user ) - search_query = query - if from_user: - search_query += f" {self._slack_search_from_filter(from_user)}" - search_query = " ".join(search_query.split()) - if local_channels: return self._search_messages_local( local_query, @@ -714,6 +708,11 @@ def search_messages( messages_per_channel, ) + # Build the search query with modifiers + search_query = query + if from_user: + search_query += f" from:@{from_user.lstrip('@')}" + try: return self._search_messages_native(search_query, max_results) except (SlackApiError, RuntimeError, SlackRateLimitError): @@ -722,28 +721,6 @@ def search_messages( local_query, max_results, local_channels, local_from_user, messages_per_channel ) - def search_messages_proxy( - self, - query: str, - max_results: int = 20, - from_user: str | None = None, - ) -> list[dict]: - """Search messages through the Centaur Slack search proxy. - - Defaults to the current Slack thread's channel. Unlike ``search_messages``, - this method does not fall back to Slack native search or local history - scanning. - """ - search_query = query - if from_user: - search_query += f" {self._slack_search_from_filter(from_user)}" - search_query = " ".join(search_query.split()) - return self._search_messages_proxy( - search_query, - max_results, - self._current_slack_channel(), - ) - def _search_messages_native( self, query: str, @@ -760,65 +737,6 @@ def _search_messages_native( if not response.get("ok"): raise RuntimeError(response.get("error", "search.messages failed")) - return self._search_results_from_response(response) - - def _slack_search_from_filter(self, from_user: str) -> str: - user = self._clean_user_ref(from_user) - if self._looks_like_user_id(user): - return f"from:<@{user.upper()}>" - return f"from:{user.lstrip('@')}" - - def _search_messages_proxy( - self, - query: str, - max_results: int, - channel_id: str, - ) -> list[dict]: - """Search through the Centaur API Slack proxy with channel-scoped JWT auth.""" - base_url = self._search_proxy_base_url() - if not base_url: - raise RuntimeError("CENTAUR_API_URL is not configured") - params = urlencode( - { - "query": query, - "channels": channel_id, - "count": max_results, - } - ) - request = urllib.request.Request( - f"{base_url}/api/slack/search?{params}", - headers={"Accept": "application/json"}, - ) - try: - with urllib.request.urlopen(request, timeout=self._api_timeout_seconds()) as response: - payload = response.read() - except urllib.error.HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace").strip() - message = f"Slack search proxy failed with status {exc.code}" - if detail: - message = f"{message}: {detail}" - raise RuntimeError(message) from exc - except urllib.error.URLError as exc: - raise RuntimeError(f"Slack search proxy request failed: {exc}") from exc - - data = json.loads(payload.decode("utf-8")) - if not data.get("ok"): - raise RuntimeError(data.get("error", "Slack search proxy failed")) - return self._search_results_from_response(data) - - def _search_proxy_base_url(self) -> str | None: - value = str(secret("CENTAUR_API_URL", default="") or "").strip() - return value.rstrip("/") or None - - def _current_slack_channel(self) -> str: - from centaur_sdk.tool_sdk import current_slack_thread - - channel_id = current_slack_thread().get("channel_id") - if not channel_id: - raise RuntimeError("Slack search proxy requires a current Slack channel") - return channel_id - - def _search_results_from_response(self, response: dict) -> list[dict]: matches = response.get("messages", {}).get("matches", []) user_cache = self._get_user_cache() @@ -879,13 +797,9 @@ def from_repl(match: re.Match) -> str: query, ) - deduped_channels = self._dedupe_channel_refs(local_channels) - return " ".join(query.split()), deduped_channels or None, local_from_user - - def _dedupe_channel_refs(self, channels: list[str]) -> list[str]: deduped_channels = [] seen = set() - for channel in channels: + for channel in local_channels: normalized = self._clean_channel_ref(channel) if not normalized: continue @@ -894,7 +808,8 @@ def _dedupe_channel_refs(self, channels: list[str]) -> list[str]: continue seen.add(key) deduped_channels.append(normalized) - return deduped_channels + + return " ".join(query.split()), deduped_channels or None, local_from_user def _channel_refs_for_search(self, channels: list[str]) -> list[dict]: """Resolve channel filters without listing channels when IDs are provided.""" @@ -2140,10 +2055,6 @@ def search_messages(*args, **kwargs): return _client().search_messages(*args, **kwargs) -def search_messages_proxy(*args, **kwargs): - return _client().search_messages_proxy(*args, **kwargs) - - def get_channel_history_page(*args, **kwargs): return _client().get_channel_history_page(*args, **kwargs) diff --git a/tools/productivity/slack/tests/test_cli.py b/tools/productivity/slack/tests/test_cli.py index e68d8a38d..f36464047 100644 --- a/tools/productivity/slack/tests/test_cli.py +++ b/tools/productivity/slack/tests/test_cli.py @@ -2,9 +2,10 @@ import types from pathlib import Path -from slack.cli import _channel_arg_is_id, app from typer.testing import CliRunner +from slack.cli import _channel_arg_is_id, app + def test_channel_arg_is_id_accepts_channel_id_forms() -> None: assert _channel_arg_is_id("C0AJ07U8Z1N") @@ -77,38 +78,3 @@ def test_upload_rejects_channel_name(monkeypatch, tmp_path: Path) -> None: assert result.exit_code == 1 assert "must be a Slack conversation ID" in result.output - - -def test_search_proxy_command_calls_proxy_search(monkeypatch) -> None: - calls = [] - - def fake_search_messages_proxy(*args, **kwargs): - calls.append((args, kwargs)) - return [ - { - "channel": "eng-oncall", - "user": "alice", - "text": "deploy complete", - "permalink": "https://slack.example/archives/C123/p1", - } - ] - - fake_client = types.SimpleNamespace(search_messages_proxy=fake_search_messages_proxy) - monkeypatch.setitem(sys.modules, "slack.client", fake_client) - - result = CliRunner().invoke( - app, - ["search-proxy", "deploy", "--limit", "3", "--from", "alice"], - ) - - assert result.exit_code == 0 - assert calls == [ - ( - ("deploy",), - { - "max_results": 3, - "from_user": "alice", - }, - ) - ] - assert "deploy complete" in result.output diff --git a/tools/productivity/slack/tests/test_client.py b/tools/productivity/slack/tests/test_client.py index ab2ad6ef0..63c14fb80 100644 --- a/tools/productivity/slack/tests/test_client.py +++ b/tools/productivity/slack/tests/test_client.py @@ -1,5 +1,4 @@ import email.message -import io import json import pytest @@ -887,143 +886,6 @@ def test_native_search_uses_dedicated_search_client() -> None: assert fake_bot_client.api_calls == [] -def test_search_messages_proxy_uses_api_proxy_for_channel_scoped_search( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import urllib.request - - import centaur_sdk.tool_sdk - - client, fake_web_client = _make_client() - client._get_user_cache = lambda: {"U1": "alice"} # type: ignore[method-assign] - monkeypatch.setenv("CENTAUR_API_URL", "http://api") - monkeypatch.setattr( - centaur_sdk.tool_sdk, - "current_slack_thread", - lambda: {"channel_id": "C777", "thread_ts": "123.456"}, - ) - - def fake_urlopen(req, *args, **kwargs): - assert req.full_url == ( - "http://api/api/slack/search?query=deploy+from%3A%3C%40UGZCSQTPE%3E+" - "in%3A%23other&channels=C777&count=5" - ) - body = json.dumps( - { - "ok": True, - "messages": { - "matches": [ - { - "user": "U1", - "text": "deploy complete", - "ts": "200.000000", - "permalink": "https://slack.com/archives/C777/p200000000", - "channel": {"id": "C777", "name": "paradigm-pulse"}, - } - ] - }, - } - ).encode() - return _FakeHTTPResponse(body, "application/json") - - monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) - - result = client.search_messages_proxy( - "deploy from:<@UGZCSQTPE> in:#other", - max_results=5, - ) - - assert result[0]["text"] == "deploy complete" - assert fake_web_client.api_calls == [] - assert fake_web_client.history_calls == [] - - -def test_search_messages_proxy_defaults_to_current_slack_channel( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import urllib.request - - import centaur_sdk.tool_sdk - - client, _ = _make_client() - client._get_user_cache = lambda: {"U1": "alice"} # type: ignore[method-assign] - monkeypatch.setenv("CENTAUR_API_URL", "http://api") - monkeypatch.setattr( - centaur_sdk.tool_sdk, - "current_slack_thread", - lambda: {"channel_id": "C777", "thread_ts": "123.456"}, - ) - - def fake_urlopen(req, *args, **kwargs): - assert req.full_url == "http://api/api/slack/search?query=deploy&channels=C777&count=5" - body = json.dumps({"ok": True, "messages": {"matches": []}}).encode() - return _FakeHTTPResponse(body, "application/json") - - monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) - - assert client.search_messages_proxy("deploy", max_results=5) == [] - - -def test_search_messages_proxy_fails_without_current_slack_channel( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import centaur_sdk.tool_sdk - - client, _ = _make_client() - monkeypatch.setattr( - centaur_sdk.tool_sdk, - "current_slack_thread", - lambda: (_ for _ in ()).throw(RuntimeError("not a Slack thread")), - ) - - with pytest.raises(RuntimeError, match="not a Slack thread"): - client.search_messages_proxy("deploy") - - -def test_search_messages_proxy_failure_does_not_fall_back_to_local_scan( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import urllib.error - import urllib.request - - import centaur_sdk.tool_sdk - - client, fake_web_client = _make_client() - client._get_user_cache = lambda: {"U1": "alice"} # type: ignore[method-assign] - monkeypatch.setenv("CENTAUR_API_URL", "http://api") - monkeypatch.setattr( - centaur_sdk.tool_sdk, - "current_slack_thread", - lambda: {"channel_id": "C777", "thread_ts": "123.456"}, - ) - fake_web_client.history_pages = [ - {"messages": [{"user": "U1", "text": "deploy fallback", "ts": "300.000000"}]} - ] - - def fake_urlopen(req, *args, **kwargs): - raise urllib.error.HTTPError( - req.full_url, - 403, - "Forbidden", - hdrs=None, - fp=io.BytesIO(b'{"error":"forbidden"}'), - ) - - monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) - - with pytest.raises(RuntimeError, match="Slack search proxy failed with status 403"): - client.search_messages_proxy("deploy", max_results=5) - - assert fake_web_client.history_calls == [] - - -def test_slack_search_from_filter_uses_documented_forms() -> None: - client, _ = _make_client() - - assert client._slack_search_from_filter("<@UGZCSQTPE>") == "from:<@UGZCSQTPE>" - assert client._slack_search_from_filter("@deploybot") == "from:deploybot" - - def test_sync_channel_history_uses_watermark_lookback() -> None: client, _ = _make_client() captured: dict = {} From d096e33ba6849d5e82b98ecc99d8a9b44088da01 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 8 Jul 2026 21:12:19 -0600 Subject: [PATCH 114/198] feat: index private Slack channels behind flag (#986) * feat: index private Slack channels behind flag * fix: keep Slack user directory readonly visibility * test: derive private Slack RLS fixture * chore: bump chart version --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/apirs.yaml | 1 + contrib/chart/values.schema.json | 1 + contrib/chart/values.yaml | 1 + docs/pages/operate/slack-etl.mdx | 21 ++++--- docs/pages/reference/configuration.mdx | 1 + docs/public/md/operate/slack-etl.md | 21 ++++--- docs/public/md/reference/configuration.md | 1 + .../0038_slack_private_channels.sql | 59 +++++++++++++++++++ .../tests/etl_context_rls.rs | 52 ++++++++++++---- workflows/slack/retention.py | 2 +- workflows/slack/shared.py | 21 +++++-- workflows/slack/sync.py | 34 +++++++---- .../slack/tests/test_shared_attachments.py | 45 +++++++++++++- workflows/slack/tests/test_sync_cold_start.py | 27 ++++++++- 15 files changed, 238 insertions(+), 51 deletions(-) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0038_slack_private_channels.sql diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 1f61329f2..02f89b109 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.94 +version: 0.1.95 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 260ae79f1..12a05ddf3 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -75,6 +75,7 @@ (dict "name" "SLACK_SYNC_INTERVAL_SECONDS" "value" (dig "slack" "syncIntervalSeconds" 3600 $apiRsEtl)) (dict "name" "SLACK_SYNC_BACKFILL_LOOKBACK_DAYS" "value" (dig "slack" "syncBackfillLookbackDays" 30 $apiRsEtl)) (dict "name" "SLACK_SYNC_THREAD_LOOKBACK_DAYS" "value" (dig "slack" "syncThreadLookbackDays" 3 $apiRsEtl)) + (dict "name" "SLACK_SYNC_INDEX_PRIVATE_CHANNELS" "value" (dig "slack" "indexPrivateChannels" false $apiRsEtl)) (dict "name" "SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS" "value" (dig "slack" "excludedChannelPatterns" "" $apiRsEtl)) (dict "name" "SLACK_ETL_ATTACHMENTS_ENABLED" "value" (dig "slack" "attachments" "enabled" true $apiRsEtl)) (dict "name" "SLACK_ETL_ATTACHMENT_MAX_BYTES" "value" (dig "slack" "attachments" "maxBytes" 10485760 $apiRsEtl)) diff --git a/contrib/chart/values.schema.json b/contrib/chart/values.schema.json index df8c81705..ca1906e1b 100644 --- a/contrib/chart/values.schema.json +++ b/contrib/chart/values.schema.json @@ -277,6 +277,7 @@ "syncIntervalSeconds": { "type": "integer" }, "syncBackfillLookbackDays": { "type": "integer" }, "syncThreadLookbackDays": { "type": "integer" }, + "indexPrivateChannels": { "type": "boolean" }, "excludedChannelPatterns": { "type": "string" }, "attachments": { "type": "object", diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index 9883a07ed..01112045b 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -371,6 +371,7 @@ apiRs: syncIntervalSeconds: 3600 syncBackfillLookbackDays: 30 syncThreadLookbackDays: 3 + indexPrivateChannels: false excludedChannelPatterns: "" attachments: enabled: true diff --git a/docs/pages/operate/slack-etl.mdx b/docs/pages/operate/slack-etl.mdx index df047dcc5..587a17a7b 100644 --- a/docs/pages/operate/slack-etl.mdx +++ b/docs/pages/operate/slack-etl.mdx @@ -12,7 +12,7 @@ token, channel scope, exclusion patterns, and data boundary they want agents to use. ::: -Slack ETL keeps an indexed, queryable copy of public Slack history in Postgres +Slack ETL keeps an indexed, queryable copy of Slack channel history in Postgres for agent context and operator workflows. It runs as scheduled Centaur workflows: one workflow keeps recent channel history fresh, one drains deferred historical backfill work, and one turns synced messages into company context @@ -27,7 +27,7 @@ token and writes durable rows into Postgres. | Workflow | Default cadence | Role | |----------|-----------------|------| -| `slack_sync` | 1 hour | Lists public channels, refreshes users, syncs recent root messages, advances per-channel checkpoints, and enqueues backfill jobs. | +| `slack_sync` | 1 hour | Lists channels, refreshes users, syncs recent root messages, advances per-channel checkpoints, and enqueues backfill jobs. | | `slack_backfill` | 10 minutes | Claims queued backfill jobs and drains Slack cursors without slowing the incremental sync. | | `company_context_documents` | 4 hours | Projects changed Slack rows into `company_context_documents` for retrieval. | @@ -46,15 +46,17 @@ The token must be able to call: | Slack API | Used for | |-----------|----------| -| `conversations.list` | Discover public channels. | +| `conversations.list` | Discover public channels, and private channels when explicitly enabled. | | `conversations.history` | Read channel root messages. | | `conversations.replies` | Refresh thread replies. | | `users.list` | Resolve Slack user metadata for documents. | | `files:read` / file URL access | Download message attachment bytes from `files.slack.com`. | -Slack ETL currently syncs public channels visible to the configured ETL user -token. It does not sync private channels, DMs, or Slackbot-only live thread -events. +Slack ETL syncs public channels visible to the configured ETL user token. +Set `SLACK_SYNC_INDEX_PRIVATE_CHANNELS=true` to also sync private channels +visible to that token. It does not sync DMs or Slackbot-only live thread events. +Private channel rows are protected by RLS: `centaur_readonly` sees public +channel data and the channel in `centaur.slack_channel_id`. ## Enable the schedules @@ -80,12 +82,13 @@ apiRs: | `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `5` | Maximum Slack history pages drained before a job is requeued. | | `SLACK_SYNC_BACKFILL_LOOKBACK_DAYS` | `30` | Historical window seeded for first-time channel backfills. | | `SLACK_SYNC_THREAD_LOOKBACK_DAYS` | `3` | Recent thread window eligible for reply refresh. | +| `SLACK_SYNC_INDEX_PRIVATE_CHANNELS` | `false` | Includes private channels visible to the ETL token in Slack sync and backfill. | | `SLACK_ETL_ATTACHMENTS_ENABLED` | `true` | Download Slack message attachment bytes into Postgres. Metadata rows are still written when downloads are disabled. | | `SLACK_ETL_ATTACHMENT_MAX_BYTES` | `10485760` | Per-file byte cap for Slack attachment downloads. Oversized files keep metadata with `skipped_too_large` status. | | `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` | empty | Comma-separated channel-name globs to skip, without needing the leading `#`. | | `SLACK_RETENTION_ENABLED` | `true` | Allows the `slack_retention` schedule to run when at least one Slack retention TTL is positive. | | `SLACK_RETENTION_INTERVAL_MINUTES` | `60` | How often to prune Slack retention-managed rows. | -| `SLACK_ETL_RETENTION_DAYS` | `0` | Deletes public Slack ETL messages, derived Slack documents, and terminal ETL run/job rows older than this many days. `0` disables public ETL retention. | +| `SLACK_ETL_RETENTION_DAYS` | `0` | Deletes Slack ETL messages, derived Slack documents, and terminal ETL run/job rows older than this many days. `0` disables ETL retention. | | `SLACK_DM_RETENTION_DAYS` | `0` | Deletes Slack DM messages, stale empty DM conversations, and terminal DM run/job rows older than this many days. `0` disables DM retention. | | `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `true` | Enables projection from Slack sync rows into company context documents. | | `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `14400` | How often to project changed Slack rows into documents. | @@ -103,7 +106,7 @@ Slack ETL writes normalized Slack data into dedicated tables: | Table | Contents | |-------|----------| -| `slack_sync_channels` | Public channels visible to the ETL token and whether they are currently syncable. | +| `slack_sync_channels` | Channels visible to the ETL token, channel privacy, and whether they are currently syncable. | | `slack_sync_users` | Slack user display metadata used when rendering documents. | | `slack_sync_runs` | One row per incremental or backfill workflow run, with counts and channel outcomes. | | `slack_sync_messages` | Root messages and replies keyed by `(channel_id, message_ts)`. | @@ -255,7 +258,7 @@ setting alerts. |---------|---------------| | Schedules are missing | Confirm `WORKFLOW_DIRS` includes `/app/workflows` and the API restarted after the workflow files were deployed. | | Schedules exist but are disabled | Confirm Helm values set `apiRs.etl.slack.enabled=true` and the API pod was restarted. | -| `slack_sync` skips with `no_public_channels` | Confirm the ETL user token can see the expected public channels. | +| `slack_sync` skips with `no_channels` | Confirm the ETL user token can see the expected public channels, or enable private channel sync when only private channels are in scope. | | Channels are all skipped | Check `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` for broad globs. | | Checkpoints show `missing_scope` or `not_allowed_token_type` | Add the missing Slack OAuth scope or use the expected user-token class. | | Backfill jobs keep failing | Inspect `slack_sync_backfill_jobs.last_error` and the corresponding `slack_sync_runs` row. | diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index 110c95ef0..ed982e783 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -230,6 +230,7 @@ Slack ETL workflows: | `SLACK_ETL_ENABLED` | `apiRs.etl.slack.enabled`. | Master switch for Slack sync/backfill/context schedules. | | `SLACK_SYNC_INTERVAL_SECONDS`, `SLACK_BACKFILL_INTERVAL_SECONDS`, `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `apiRs.etl.slack.syncIntervalSeconds`, `apiRs.etl.slack.backfill.intervalSeconds`, `apiRs.etl.companyContextDocuments.intervalSeconds`. | Slack ETL schedule intervals. | | `SLACK_SYNC_BACKFILL_LOOKBACK_DAYS`, `SLACK_SYNC_THREAD_LOOKBACK_DAYS` | `apiRs.etl.slack.syncBackfillLookbackDays`, `apiRs.etl.slack.syncThreadLookbackDays`. | Slack history/thread lookback windows. | +| `SLACK_SYNC_INDEX_PRIVATE_CHANNELS` | `apiRs.etl.slack.indexPrivateChannels`. | Includes private channels visible to the ETL token. | | `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` | `apiRs.etl.slack.excludedChannelPatterns`. | Comma-separated channel-name globs to skip. | | `SLACK_BACKFILL_ENABLED`, `SLACK_BACKFILL_CHANNEL_BATCH_LIMIT`, `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `apiRs.etl.slack.backfill.*`. | Backfill enablement and batch sizing. | | `SLACK_RETENTION_ENABLED`, `SLACK_RETENTION_INTERVAL_MINUTES`, `SLACK_ETL_RETENTION_DAYS`, `SLACK_DM_RETENTION_DAYS` | `apiRs.etl.slack.retention.*`. | Slack retention enablement, cadence, and separate public ETL/DM TTLs. | diff --git a/docs/public/md/operate/slack-etl.md b/docs/public/md/operate/slack-etl.md index b4ffbc4f4..c95e11f69 100644 --- a/docs/public/md/operate/slack-etl.md +++ b/docs/public/md/operate/slack-etl.md @@ -12,7 +12,7 @@ token, channel scope, exclusion patterns, and data boundary they want agents to use. ::: -Slack ETL keeps an indexed, queryable copy of public Slack history in Postgres +Slack ETL keeps an indexed, queryable copy of Slack channel history in Postgres for agent context and operator workflows. It runs as scheduled Centaur workflows: one workflow keeps recent channel history fresh, one drains deferred historical backfill work, and one turns synced messages into company context @@ -27,7 +27,7 @@ token and writes durable rows into Postgres. | Workflow | Default cadence | Role | |----------|-----------------|------| -| `slack_sync` | 1 hour | Lists public channels, refreshes users, syncs recent root messages, advances per-channel checkpoints, and enqueues backfill jobs. | +| `slack_sync` | 1 hour | Lists channels, refreshes users, syncs recent root messages, advances per-channel checkpoints, and enqueues backfill jobs. | | `slack_backfill` | 10 minutes | Claims queued backfill jobs and drains Slack cursors without slowing the incremental sync. | | `company_context_documents` | 4 hours | Projects changed Slack rows into `company_context_documents` for retrieval. | @@ -46,15 +46,17 @@ The token must be able to call: | Slack API | Used for | |-----------|----------| -| `conversations.list` | Discover public channels. | +| `conversations.list` | Discover public channels, and private channels when explicitly enabled. | | `conversations.history` | Read channel root messages. | | `conversations.replies` | Refresh thread replies. | | `users.list` | Resolve Slack user metadata for documents. | | `files:read` / file URL access | Download message attachment bytes from `files.slack.com`. | -Slack ETL currently syncs public channels visible to the configured ETL user -token. It does not sync private channels, DMs, or Slackbot-only live thread -events. +Slack ETL syncs public channels visible to the configured ETL user token. +Set `SLACK_SYNC_INDEX_PRIVATE_CHANNELS=true` to also sync private channels +visible to that token. It does not sync DMs or Slackbot-only live thread events. +Private channel rows are protected by RLS: `centaur_readonly` sees public +channel data and the channel in `centaur.slack_channel_id`. ## Enable the schedules @@ -80,12 +82,13 @@ apiRs: | `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `5` | Maximum Slack history pages drained before a job is requeued. | | `SLACK_SYNC_BACKFILL_LOOKBACK_DAYS` | `30` | Historical window seeded for first-time channel backfills. | | `SLACK_SYNC_THREAD_LOOKBACK_DAYS` | `3` | Recent thread window eligible for reply refresh. | +| `SLACK_SYNC_INDEX_PRIVATE_CHANNELS` | `false` | Includes private channels visible to the ETL token in Slack sync and backfill. | | `SLACK_ETL_ATTACHMENTS_ENABLED` | `true` | Download Slack message attachment bytes into Postgres. Metadata rows are still written when downloads are disabled. | | `SLACK_ETL_ATTACHMENT_MAX_BYTES` | `10485760` | Per-file byte cap for Slack attachment downloads. Oversized files keep metadata with `skipped_too_large` status. | | `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` | empty | Comma-separated channel-name globs to skip, without needing the leading `#`. | | `SLACK_RETENTION_ENABLED` | `true` | Allows the `slack_retention` schedule to run when at least one Slack retention TTL is positive. | | `SLACK_RETENTION_INTERVAL_MINUTES` | `60` | How often to prune Slack retention-managed rows. | -| `SLACK_ETL_RETENTION_DAYS` | `0` | Deletes public Slack ETL messages, derived Slack documents, and terminal ETL run/job rows older than this many days. `0` disables public ETL retention. | +| `SLACK_ETL_RETENTION_DAYS` | `0` | Deletes Slack ETL messages, derived Slack documents, and terminal ETL run/job rows older than this many days. `0` disables ETL retention. | | `SLACK_DM_RETENTION_DAYS` | `0` | Deletes Slack DM messages, stale empty DM conversations, and terminal DM run/job rows older than this many days. `0` disables DM retention. | | `COMPANY_CONTEXT_DOCUMENTS_ENABLED` | `true` | Enables projection from Slack sync rows into company context documents. | | `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `14400` | How often to project changed Slack rows into documents. | @@ -103,7 +106,7 @@ Slack ETL writes normalized Slack data into dedicated tables: | Table | Contents | |-------|----------| -| `slack_sync_channels` | Public channels visible to the ETL token and whether they are currently syncable. | +| `slack_sync_channels` | Channels visible to the ETL token, channel privacy, and whether they are currently syncable. | | `slack_sync_users` | Slack user display metadata used when rendering documents. | | `slack_sync_runs` | One row per incremental or backfill workflow run, with counts and channel outcomes. | | `slack_sync_messages` | Root messages and replies keyed by `(channel_id, message_ts)`. | @@ -255,7 +258,7 @@ setting alerts. |---------|---------------| | Schedules are missing | Confirm `WORKFLOW_DIRS` includes `/app/workflows` and the API restarted after the workflow files were deployed. | | Schedules exist but are disabled | Confirm Helm values set `apiRs.etl.slack.enabled=true` and the API pod was restarted. | -| `slack_sync` skips with `no_public_channels` | Confirm the ETL user token can see the expected public channels. | +| `slack_sync` skips with `no_channels` | Confirm the ETL user token can see the expected public channels, or enable private channel sync when only private channels are in scope. | | Channels are all skipped | Check `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` for broad globs. | | Checkpoints show `missing_scope` or `not_allowed_token_type` | Add the missing Slack OAuth scope or use the expected user-token class. | | Backfill jobs keep failing | Inspect `slack_sync_backfill_jobs.last_error` and the corresponding `slack_sync_runs` row. | diff --git a/docs/public/md/reference/configuration.md b/docs/public/md/reference/configuration.md index 6d24cc8f9..8c7f978ba 100644 --- a/docs/public/md/reference/configuration.md +++ b/docs/public/md/reference/configuration.md @@ -233,6 +233,7 @@ Slack ETL workflows: | `SLACK_ETL_ENABLED` | `apiRs.etl.slack.enabled`. | Master switch for Slack sync/backfill/context schedules. | | `SLACK_SYNC_INTERVAL_SECONDS`, `SLACK_BACKFILL_INTERVAL_SECONDS`, `COMPANY_CONTEXT_DOCUMENTS_INTERVAL_SECONDS` | `apiRs.etl.slack.syncIntervalSeconds`, `apiRs.etl.slack.backfill.intervalSeconds`, `apiRs.etl.companyContextDocuments.intervalSeconds`. | Slack ETL schedule intervals. | | `SLACK_SYNC_BACKFILL_LOOKBACK_DAYS`, `SLACK_SYNC_THREAD_LOOKBACK_DAYS` | `apiRs.etl.slack.syncBackfillLookbackDays`, `apiRs.etl.slack.syncThreadLookbackDays`. | Slack history/thread lookback windows. | +| `SLACK_SYNC_INDEX_PRIVATE_CHANNELS` | `apiRs.etl.slack.indexPrivateChannels`. | Includes private channels visible to the ETL token. | | `SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS` | `apiRs.etl.slack.excludedChannelPatterns`. | Comma-separated channel-name globs to skip. | | `SLACK_BACKFILL_ENABLED`, `SLACK_BACKFILL_CHANNEL_BATCH_LIMIT`, `SLACK_BACKFILL_CHANNEL_PAGES_PER_JOB` | `apiRs.etl.slack.backfill.*`. | Backfill enablement and batch sizing. | | `SLACK_RETENTION_ENABLED`, `SLACK_RETENTION_INTERVAL_MINUTES`, `SLACK_ETL_RETENTION_DAYS`, `SLACK_DM_RETENTION_DAYS` | `apiRs.etl.slack.retention.*`. | Slack retention enablement, cadence, and separate public ETL/DM TTLs. | diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0038_slack_private_channels.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0038_slack_private_channels.sql new file mode 100644 index 000000000..602e38848 --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0038_slack_private_channels.sql @@ -0,0 +1,59 @@ +alter table slack_sync_channels + add column if not exists is_private boolean not null default false; + +create index if not exists idx_slack_sync_channels_private + on slack_sync_channels (is_private, channel_id); + +drop policy if exists centaur_readonly_slack_sync_channels_select + on slack_sync_channels; +create policy centaur_readonly_slack_sync_channels_select + on slack_sync_channels + for select + to centaur_readonly + using ( + not is_private + or channel_id = centaur_current_slack_channel_id() + ); + +drop policy if exists centaur_readonly_slack_sync_message_attachments_select + on slack_sync_message_attachments; +create policy centaur_readonly_slack_sync_message_attachments_select + on slack_sync_message_attachments + for select + to centaur_readonly + using ( + exists ( + select 1 + from slack_sync_channels channels + where channels.channel_id = slack_sync_message_attachments.channel_id + ) + ); + +drop policy if exists centaur_readonly_slack_sync_messages_select + on slack_sync_messages; +create policy centaur_readonly_slack_sync_messages_select + on slack_sync_messages + for select + to centaur_readonly + using ( + exists ( + select 1 + from slack_sync_channels channels + where channels.channel_id = slack_sync_messages.channel_id + ) + ); + +drop policy if exists centaur_readonly_company_context_documents_select + on company_context_documents; +create policy centaur_readonly_company_context_documents_select + on company_context_documents + for select + to centaur_readonly + using ( + source <> 'slack' + or exists ( + select 1 + from slack_sync_channels channels + where channels.channel_id = metadata ->> 'channel_id' + ) + ); diff --git a/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs b/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs index 391519ae1..0e0d2d29f 100644 --- a/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs +++ b/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs @@ -18,6 +18,8 @@ const DROP_SLACK_CONTEXT_ADMIN_CHANNELS_SQL: &str = include_str!("../migrations/0022_drop_slack_context_rls_admin_channels.sql"); const CENTAUR_READONLY_RLS_POLICIES_SQL: &str = include_str!("../migrations/0023_centaur_readonly_rls_policies.sql"); +const SLACK_PRIVATE_CHANNELS_SQL: &str = + include_str!("../migrations/0038_slack_private_channels.sql"); const RLS_TABLES: &[&str] = &[ "slack_sync_channels", @@ -84,6 +86,7 @@ async fn run_rls_assertions(conn: &mut PgConnection, schema: &str) -> Result<(), execute_migration(conn, ETL_CONTEXT_RLS_SQL).await?; execute_migration(conn, DROP_SLACK_CONTEXT_ADMIN_CHANNELS_SQL).await?; execute_migration(conn, CENTAUR_READONLY_RLS_POLICIES_SQL).await?; + execute_migration(conn, SLACK_PRIVATE_CHANNELS_SQL).await?; grant_schema_usage(conn, schema).await?; assert_rls_enabled(conn).await?; @@ -173,7 +176,11 @@ async fn run_rls_assertions(conn: &mut PgConnection, schema: &str) -> Result<(), ); let readonly_role = visible_rows(conn, schema, "centaur_readonly", None).await?; - assert_eq!(readonly_role, all_visible_rows()); + assert_eq!(readonly_role, public_visible_rows()); + + let readonly_private_channel = + visible_rows(conn, schema, "centaur_readonly", Some("G_PRIVATE")).await?; + assert_eq!(readonly_private_channel, public_and_private_visible_rows()); Ok(()) } @@ -260,6 +267,7 @@ async fn create_minimal_etl_tables(conn: &mut PgConnection) -> Result<(), sqlx:: create table slack_sync_messages ( channel_id text not null references slack_sync_channels(channel_id) on delete cascade, message_ts text not null, + user_id text not null default '', text text not null default '', primary key (channel_id, message_ts) ); @@ -527,28 +535,34 @@ async fn assert_legacy_admin_state_is_removed(conn: &mut PgConnection) -> Result async fn insert_fixture_rows(conn: &mut PgConnection) -> Result<(), sqlx::Error> { sqlx::raw_sql( r#" - insert into slack_sync_channels (channel_id, channel_name) values - ('C_ALPHA', 'alpha'), - ('C_BETA', 'beta'), - ('C_ADMIN', 'admin'); + insert into slack_sync_channels (channel_id, channel_name, is_private) values + ('C_ALPHA', 'alpha', false), + ('C_BETA', 'beta', false), + ('C_ADMIN', 'admin', false), + ('G_PRIVATE', 'private', true); insert into slack_sync_users (user_id, user_name) values ('U_ALPHA', 'alpha user'), - ('U_BETA', 'beta user'); + ('U_BETA', 'beta user'), + ('U_PRIVATE', 'private user'); - insert into slack_sync_messages (channel_id, message_ts, text) values - ('C_ALPHA', '1000.000001', 'alpha channel message'), - ('C_BETA', '1000.000002', 'beta channel message'); + insert into slack_sync_messages (channel_id, message_ts, user_id, text) values + ('C_ALPHA', '1000.000001', 'U_ALPHA', 'alpha channel message'), + ('C_BETA', '1000.000002', 'U_BETA', 'beta channel message'), + ('G_PRIVATE', '1000.000003', 'U_PRIVATE', 'private channel message'); insert into slack_sync_message_attachments (channel_id, message_ts, slack_file_id, name) values ('C_ALPHA', '1000.000001', 'F_ALPHA', 'alpha.pdf'), - ('C_BETA', '1000.000002', 'F_BETA', 'beta.pdf'); + ('C_BETA', '1000.000002', 'F_BETA', 'beta.pdf'), + ('G_PRIVATE', '1000.000003', 'F_PRIVATE', 'private.pdf'); insert into company_context_documents (document_id, source, source_type, metadata) values ('doc_slack_alpha', 'slack', 'slack_thread', '{"channel_id": "C_ALPHA"}'), ('doc_slack_beta', 'slack', 'slack_thread', '{"channel_id": "C_BETA"}'), + ('doc_slack_private', 'slack', 'slack_thread', '{"channel_id": "G_PRIVATE"}'), + ('doc_slack_unknown_channel', 'slack', 'slack_thread', '{}'), ('doc_gdrive', 'google_drive', 'google_doc', '{}'), ('doc_gcal', 'google_calendar', 'calendar_event', '{}'), ('doc_linear', 'linear', 'linear_issue', '{}'); @@ -675,14 +689,18 @@ fn empty_visible_rows() -> VisibleRows { } } -fn all_visible_rows() -> VisibleRows { +fn public_visible_rows() -> VisibleRows { VisibleRows { slack_channels: vec![ "C_ADMIN".to_owned(), "C_ALPHA".to_owned(), "C_BETA".to_owned(), ], - slack_users: vec!["U_ALPHA".to_owned(), "U_BETA".to_owned()], + slack_users: vec![ + "U_ALPHA".to_owned(), + "U_BETA".to_owned(), + "U_PRIVATE".to_owned(), + ], slack_messages: vec![ "C_ALPHA:1000.000001".to_owned(), "C_BETA:1000.000002".to_owned(), @@ -712,3 +730,13 @@ fn all_visible_rows() -> VisibleRows { linear_checkpoints: 1, } } + +fn public_and_private_visible_rows() -> VisibleRows { + let mut rows = public_visible_rows(); + rows.slack_channels.push("G_PRIVATE".to_owned()); + rows.slack_messages.push("G_PRIVATE:1000.000003".to_owned()); + rows.slack_attachments + .push("G_PRIVATE:1000.000003:F_PRIVATE".to_owned()); + rows.context_docs.push("doc_slack_private".to_owned()); + rows +} diff --git a/workflows/slack/retention.py b/workflows/slack/retention.py index 0e655e4ff..83fb92a3a 100644 --- a/workflows/slack/retention.py +++ b/workflows/slack/retention.py @@ -81,7 +81,7 @@ async def _count_or_delete( async def prune_slack_etl(pool, *, retention_days: int, dry_run: bool = False) -> dict[str, int]: - """Delete public Slack ETL rows older than the configured retention window.""" + """Delete Slack ETL rows older than the configured retention window.""" if retention_days <= 0: return { "company_context_documents": 0, diff --git a/workflows/slack/shared.py b/workflows/slack/shared.py index dc0c4a823..63159c963 100644 --- a/workflows/slack/shared.py +++ b/workflows/slack/shared.py @@ -80,7 +80,10 @@ class SlackSyncClient(Protocol): def _etl_access_mode(self) -> str: ... def _list_etl_channels( - self, limit: int = 200, force_refresh: bool = False + self, + limit: int = 200, + force_refresh: bool = False, + include_private_channels: bool = False, ) -> list[dict]: ... def _list_etl_users(self, limit: int = 200) -> list[dict]: ... @@ -1169,16 +1172,25 @@ def _list_etl_channels( self, limit: int = 500, force_refresh: bool = False, + include_private_channels: bool | None = None, ) -> list[dict]: channels = [] cursor = None + include_private = ( + env_flag_enabled("SLACK_SYNC_INDEX_PRIVATE_CHANNELS", default=False) + if include_private_channels is None + else include_private_channels + ) + conversation_types = ( + "public_channel,private_channel" if include_private else "public_channel" + ) while len(channels) < limit: try: response = self._retry_on_ratelimit( self._client.conversations_list, method_key="etl.conversations.list", - types="public_channel", + types=conversation_types, limit=min(limit - len(channels), self._MAX_PAGE_SIZE), cursor=cursor, exclude_archived=True, @@ -1192,7 +1204,8 @@ def _list_etl_channels( ) for channel in response.get("channels", []): - if channel.get("is_private", False): + is_private = bool(channel.get("is_private", False)) + if is_private and not include_private: continue channels.append( { @@ -1203,7 +1216,7 @@ def _list_etl_channels( "topic": channel.get("topic", {}).get("value", ""), "member_count": channel.get("num_members", 0), "is_archived": channel.get("is_archived", False), - "is_private": channel.get("is_private", False), + "is_private": is_private, "is_member": channel.get("is_member", False), } ) diff --git a/workflows/slack/sync.py b/workflows/slack/sync.py index efbe626e0..2a1471ab4 100644 --- a/workflows/slack/sync.py +++ b/workflows/slack/sync.py @@ -1,4 +1,4 @@ -"""Workflow: sync recent public Slack channel history into Postgres.""" +"""Workflow: sync recent Slack channel history into Postgres.""" from __future__ import annotations @@ -57,6 +57,7 @@ DEFAULT_THREAD_REPLY_PAGE_LIMIT = 200 DEFAULT_SYNC_INTERVAL_SECONDS = 3_600 EXCLUDED_CHANNELS_ENV = "SLACK_ETL_EXCLUDED_CHANNEL_PATTERNS" +INDEX_PRIVATE_CHANNELS_ENV = "SLACK_SYNC_INDEX_PRIVATE_CHANNELS" def _env_flag_enabled(name: str, default: bool = False) -> bool: @@ -210,7 +211,7 @@ def _max_slack_ts(*values: Any) -> str | None: async def _upsert_channels(pool, channels: list[dict[str, Any]]) -> None: - """Refresh public Slack sync channel rows and mark absent channels out of scope.""" + """Refresh Slack sync channel rows and mark absent channels out of scope.""" async with pool.acquire() as conn: async with conn.transaction(): await conn.execute( @@ -222,12 +223,13 @@ async def _upsert_channels(pool, channels: list[dict[str, Any]]) -> None: continue await conn.execute( "INSERT INTO slack_sync_channels (" - "channel_id, channel_name, is_archived, is_syncable, topic, purpose, " - "member_count, raw_payload, last_seen_at, updated_at" - ") VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7::jsonb, NOW(), NOW()) " + "channel_id, channel_name, is_archived, is_private, is_syncable, " + "topic, purpose, member_count, raw_payload, last_seen_at, updated_at" + ") VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8::jsonb, NOW(), NOW()) " "ON CONFLICT (channel_id) DO UPDATE SET " "channel_name = EXCLUDED.channel_name, " "is_archived = EXCLUDED.is_archived, " + "is_private = EXCLUDED.is_private, " "is_syncable = TRUE, " "topic = EXCLUDED.topic, " "purpose = EXCLUDED.purpose, " @@ -238,6 +240,7 @@ async def _upsert_channels(pool, channels: list[dict[str, Any]]) -> None: channel_id, str(channel.get("name") or ""), bool(channel.get("is_archived")), + bool(channel.get("is_private")), str(channel.get("topic") or ""), str(channel.get("purpose") or ""), int(channel.get("member_count") or 0), @@ -349,7 +352,7 @@ async def _update_checkpoint_failure( async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: - """Sync public Slack channels visible through the configured ETL user token.""" + """Sync Slack channels visible through the configured ETL user token.""" started_at = time.monotonic() mode = "incremental" record_slack_retention_run(WORKFLOW_NAME, "started", mode) @@ -378,8 +381,13 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: limit = positive_int(inp.limit, DEFAULT_CHANNEL_PAGE_LIMIT) client = _client() access_mode = client._etl_access_mode() + include_private_channels = _env_flag_enabled(INDEX_PRIVATE_CHANNELS_ENV) try: - public_channels = client._list_etl_channels(limit=10_000, force_refresh=True) + channels = client._list_etl_channels( + limit=10_000, + force_refresh=True, + include_private_channels=include_private_channels, + ) record_slack_retention_api_request("list_channels", "success") except Exception as exc: reason = failure_reason(str(exc)) @@ -391,10 +399,10 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: WORKFLOW_NAME, dt.datetime.now(dt.timezone.utc).timestamp() ) raise - record_etl_items_seen("slack", "channel", "channel", len(public_channels)) + record_etl_items_seen("slack", "channel", "channel", len(channels)) exclusion_patterns = _channel_exclusion_patterns(os.getenv(EXCLUDED_CHANNELS_ENV)) channels_to_sync, excluded_channels = _filter_excluded_channels( - public_channels, + channels, exclusion_patterns, ) if excluded_channels: @@ -407,11 +415,12 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: await _upsert_channels(ctx._pool, channels_to_sync) record_etl_items_upserted("slack", "channel", "channel", len(channels_to_sync)) - if not public_channels: - reason = "no_public_channels" + if not channels: + reason = "no_channels" ctx.log( - "slack_sync_skipped_no_public_channels", + "slack_sync_skipped_no_channels", access_mode=access_mode, + include_private_channels=include_private_channels, reason=reason, ) await emit_slack_checkpoint_metrics(ctx._pool) @@ -472,6 +481,7 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: metadata={ **inp.metadata, "slack_access_mode": access_mode, + "index_private_channels": include_private_channels, "users_upserted": users_upserted, "excluded_channel_patterns": exclusion_patterns, }, diff --git a/workflows/slack/tests/test_shared_attachments.py b/workflows/slack/tests/test_shared_attachments.py index 5f4141537..4f61dede0 100644 --- a/workflows/slack/tests/test_shared_attachments.py +++ b/workflows/slack/tests/test_shared_attachments.py @@ -183,13 +183,15 @@ def fake_api_call(): def test_list_etl_channels_preserves_slack_created_timestamp(): client = object.__new__(shared.SlackEtlClient) client._workflow_name = "slack_sync" + retry_calls = [] def fake_conversations_list(**_kwargs): raise AssertionError("wrapped Slack client call should not be used directly") client._client = types.SimpleNamespace(conversations_list=fake_conversations_list) - def fake_retry(_func, **_kwargs): + def fake_retry(_func, **kwargs): + retry_calls.append(kwargs) return { "channels": [ { @@ -217,6 +219,7 @@ def fake_retry(_func, **_kwargs): channels = client._list_etl_channels() + assert retry_calls[0]["types"] == "public_channel" assert channels == [ { "id": "C123", @@ -232,6 +235,46 @@ def fake_retry(_func, **_kwargs): ] +def test_list_etl_channels_can_include_private_channels(): + client = object.__new__(shared.SlackEtlClient) + client._workflow_name = "slack_sync" + retry_calls = [] + + def fake_conversations_list(**_kwargs): + raise AssertionError("wrapped Slack client call should not be used directly") + + client._client = types.SimpleNamespace(conversations_list=fake_conversations_list) + + def fake_retry(_func, **kwargs): + retry_calls.append(kwargs) + return { + "channels": [ + { + "id": "C123", + "name": "eng-infra", + "is_archived": False, + "is_private": False, + }, + { + "id": "G123", + "name": "private-room", + "is_archived": False, + "is_private": True, + "is_member": True, + }, + ], + "response_metadata": {}, + } + + client._retry_on_ratelimit = fake_retry + + channels = client._list_etl_channels(include_private_channels=True) + + assert retry_calls[0]["types"] == "public_channel,private_channel" + assert [channel["id"] for channel in channels] == ["C123", "G123"] + assert channels[1]["is_private"] is True + + def test_serialize_message_downloads_slack_file_bytes(monkeypatch): monkeypatch.setenv("SLACK_ETL_ATTACHMENTS_ENABLED", "true") monkeypatch.setenv("SLACK_ETL_ATTACHMENT_MAX_BYTES", "100") diff --git a/workflows/slack/tests/test_sync_cold_start.py b/workflows/slack/tests/test_sync_cold_start.py index 2b367a18c..5264edbeb 100644 --- a/workflows/slack/tests/test_sync_cold_start.py +++ b/workflows/slack/tests/test_sync_cold_start.py @@ -74,12 +74,14 @@ def log(self, name: str, **fields): class FakeClient: def __init__(self, *, cursor: str | None = None) -> None: self.history_calls: list[dict] = [] + self.channel_calls: list[dict] = [] self.cursor = cursor def _etl_access_mode(self): return "test" - def _list_etl_channels(self, *_args, **_kwargs): + def _list_etl_channels(self, *_args, **kwargs): + self.channel_calls.append(kwargs) return [{"id": "C123", "name": "cold-start"}] def _list_etl_users(self, *_args, **_kwargs): @@ -118,6 +120,7 @@ def _patch_handler_io(monkeypatch, sync, *, checkpoint=None, client=None): "checkpoint_success": [], "enqueued": [], "finish": [], + "run_start": [], "widened": [], } fake_client = client or FakeClient() @@ -140,6 +143,9 @@ async def fake_enqueue_backfill_job(_pool, **kwargs): async def fake_record_run_finish(_pool, **kwargs): calls["finish"].append(kwargs) + async def fake_record_run_start(_pool, **kwargs): + calls["run_start"].append(kwargs) + async def fake_widen_channel_bootstrap_job(_pool, **kwargs): calls["widened"].append(kwargs) return False @@ -154,7 +160,7 @@ async def fake_widen_channel_bootstrap_job(_pool, **kwargs): monkeypatch.setattr(sync, "_update_checkpoint_failure", _noop) monkeypatch.setattr(sync, "enqueue_backfill_job", fake_enqueue_backfill_job) monkeypatch.setattr(sync, "emit_slack_checkpoint_metrics", _noop) - monkeypatch.setattr(sync, "record_run_start", _noop) + monkeypatch.setattr(sync, "record_run_start", fake_record_run_start) monkeypatch.setattr(sync, "record_run_finish", fake_record_run_finish) monkeypatch.setattr( sync, @@ -175,7 +181,9 @@ def test_cold_start_channel_uses_full_lookback_window(monkeypatch): result = asyncio.run(sync.handler(sync.Input(), FakeContext())) assert result["status"] == "completed" + assert client.channel_calls[0]["include_private_channels"] is False assert client.history_calls[0]["oldest"] == "days:30" + assert calls["run_start"][0]["metadata"]["index_private_channels"] is False assert calls["checkpoint_success"] == [ { "channel_id": "C123", @@ -217,3 +225,18 @@ def test_watermarked_channel_keeps_incremental_overlap(monkeypatch): "priority": 150, } ] + + +def test_private_channel_flag_is_passed_to_discovery(monkeypatch): + monkeypatch.setenv("SLACK_ETL_ENABLED", "true") + monkeypatch.setenv("SLACK_SYNC_INDEX_PRIVATE_CHANNELS", "true") + sync = _load_sync() + client, calls = _patch_handler_io(monkeypatch, sync) + + monkeypatch.setattr(sync, "_ts_now_minus_days", lambda days: f"days:{days}") + + result = asyncio.run(sync.handler(sync.Input(), FakeContext())) + + assert result["status"] == "completed" + assert client.channel_calls[0]["include_private_channels"] is True + assert calls["run_start"][0]["metadata"]["index_private_channels"] is True From 7436d31e5318e11213578c424aec7849936f6797 Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:41:58 +0300 Subject: [PATCH 115/198] granola: add MCP backend with REST fallback (#994) * fix(slack): resolve user IDs and @usernames to DM channels in read paths _resolve_channel only matched channel IDs and bot channel names, so get_channel_history, get_thread_replies, and other read methods could not read the bot's DMs even though send_message/send_dm could open them. Resolve U.../<@U...> and @username references to the one-on-one DM conversation via conversations.open, reusing _open_dm_channel. * granola: add MCP backend with REST fallback The Centaur console OAuth flow mints user-scoped Granola MCP tokens (mcp.granola.ai), but the tool only spoke the Enterprise REST API (public-api.granola.ai, workspace API key). Add a GranolaMcpClient speaking Streamable HTTP JSON-RPC with the same method surface, normalized to REST note shapes. _client() tries MCP first (cheap get_account_info probe) and falls back to REST; GRANOLA_BACKEND=mcp|rest overrides. The proxy injects the OAuth Bearer for mcp.granola.ai from the console grant, so the tool never handles the token; GRANOLA_MCP_TOKEN supports local dev. New CLI commands: whoami, query (natural-language Q&A with citations), folders. --- tools/productivity/granola/cli.py | 57 +++++- tools/productivity/granola/client.py | 231 +++++++++++++++++++++- tools/productivity/granola/pyproject.toml | 3 + 3 files changed, 278 insertions(+), 13 deletions(-) diff --git a/tools/productivity/granola/cli.py b/tools/productivity/granola/cli.py index d0a49d007..8e455bd4f 100644 --- a/tools/productivity/granola/cli.py +++ b/tools/productivity/granola/cli.py @@ -57,9 +57,9 @@ def list_notes( after: str | None = typer.Option(None, "--after", help="Created after (ISO date)"), ): """List recent meeting notes across the workspace.""" - from .client import GranolaClient + from .client import _client - client = GranolaClient() + client = _client() notes = client.list_all_notes(limit=limit, created_after=after) if not notes: @@ -92,9 +92,9 @@ def get_note( transcript: bool = typer.Option(False, "--transcript", "-t", help="Include transcript"), ): """Get a specific meeting note by ID.""" - from .client import GranolaClient + from .client import _client - client = GranolaClient() + client = _client() note = client.get_note(note_id, include_transcript=transcript) title = note.get("title") or "Untitled" @@ -134,9 +134,9 @@ def get_transcript( note_id: str = typer.Argument(..., help="Note ID"), ): """Get the transcript for a meeting note.""" - from .client import GranolaClient + from .client import _client - client = GranolaClient() + client = _client() utterances = client.get_transcript(note_id) if not utterances: @@ -155,9 +155,9 @@ def search_notes( limit: int = typer.Option(20, "--limit", "-n", help="Max results"), ): """Search meeting notes by title.""" - from .client import GranolaClient + from .client import _client - client = GranolaClient() + client = _client() notes = client.list_all_notes(limit=100) query_lower = query.lower() @@ -184,5 +184,46 @@ def search_notes( console.print(table) +@app.command("whoami") +def whoami(): + """Show the connected Granola account (MCP backend only).""" + from .client import GranolaMcpClient + + client = GranolaMcpClient() + try: + info = client.get_account_info() + finally: + client.close() + print(json.dumps(info, indent=2, ensure_ascii=False)) + + +@app.command("query") +def query_meetings( + query: str = typer.Argument(..., help="Natural-language question about your meetings"), +): + """Ask Granola a question about your meetings (MCP backend only).""" + from .client import GranolaMcpClient + + client = GranolaMcpClient() + try: + answer = client.query(query) + finally: + client.close() + console.print(Markdown(answer)) + + +@app.command("folders") +def list_folders(): + """List meeting folders (MCP backend only).""" + from .client import GranolaMcpClient + + client = GranolaMcpClient() + try: + text = client.list_folders() + finally: + client.close() + print(text) + + if __name__ == "__main__": app() diff --git a/tools/productivity/granola/client.py b/tools/productivity/granola/client.py index aef4e1d46..77a9af549 100644 --- a/tools/productivity/granola/client.py +++ b/tools/productivity/granola/client.py @@ -1,15 +1,27 @@ -"""Granola Enterprise API client. +"""Granola client with two backends behind one interface. -Uses the official public API: https://docs.granola.ai -Provides workspace-wide access to meeting notes and transcripts. +- MCP backend (preferred): https://mcp.granola.ai/mcp, authenticated with a + user-scoped OAuth token minted by the Centaur console consent flow. The + sandbox proxy injects the Bearer token for the mcp.granola.ai host, so the + tool never handles the credential. Sees only the connected user's meetings. +- REST backend (fallback): the official Enterprise public API + (https://docs.granola.ai) at public-api.granola.ai, authenticated with a + workspace API key (GRANOLA_API_KEY). Workspace-wide access. + +`_client()` tries MCP first and falls back to REST. Override with +GRANOLA_BACKEND=mcp|rest. """ +import json +import re +from datetime import datetime from typing import Any import httpx from centaur_sdk import secret API_BASE = "https://public-api.granola.ai" +MCP_URL = "https://mcp.granola.ai/mcp" class GranolaClient: @@ -31,6 +43,9 @@ def __init__(self, api_key: str | None = None): timeout=30.0, ) + def close(self) -> None: + self._client.close() + def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: """Make authenticated GET request.""" response = self._client.get(path, params=params) @@ -111,5 +126,211 @@ def search_notes(self, query: str, limit: int = 50) -> list[dict[str, Any]]: all_notes = self.list_all_notes(limit=200) return [n for n in all_notes if query_lower in (n.get("title") or "").lower()][:limit] -def _client() -> GranolaClient: - return GranolaClient() + +# Matches one ... block in MCP meetings_data output. +_MEETING_RE = re.compile( + r'' + r"(?P.*?)", + re.DOTALL, +) +_PARTICIPANTS_RE = re.compile(r"(.*?)", re.DOTALL) +_SUMMARY_RE = re.compile(r"(.*?)", re.DOTALL) +# "Zygimantas (note creator) from Tempo " -> name + email +_PARTICIPANT_RE = re.compile(r"(?P[^,<]+?)\s*<(?P[^>]+)>") + + +def _parse_meeting_date(raw: str) -> str: + """Convert MCP dates like 'Jul 8, 2026 5:30 PM GMT+2' to ISO, best effort.""" + m = re.match(r"(\w+ \d+, \d+ \d+:\d+ [AP]M) GMT(?P[+-]\d+)?", raw) + if not m: + return raw + try: + dt = datetime.strptime(m.group(1), "%b %d, %Y %I:%M %p") + off = m.group("off") + return dt.isoformat() + (f"{int(off):+03d}:00" if off else "") + except ValueError: + return raw + + +def _parse_meetings(text: str) -> list[dict[str, Any]]: + """Parse MCP meetings_data text into REST-shaped note dicts.""" + notes = [] + for m in _MEETING_RE.finditer(text): + body = m.group("body") + attendees = [] + pm = _PARTICIPANTS_RE.search(body) + if pm: + for p in _PARTICIPANT_RE.finditer(pm.group(1)): + attendees.append({"name": p.group("name").strip(), "email": p.group("email")}) + owner = next( + (a for a in attendees if "(note creator)" in a["name"]), + attendees[0] if attendees else {}, + ) + if owner: + owner = {**owner, "name": owner["name"].replace("(note creator)", "").split(" from ")[0].strip()} + sm = _SUMMARY_RE.search(body) + notes.append( + { + "id": m.group("id"), + "title": m.group("title"), + "created_at": _parse_meeting_date(m.group("date")), + "owner": owner, + "attendees": attendees, + "summary_markdown": sm.group(1).strip() if sm else None, + } + ) + return notes + + +class GranolaMcpClient: + """Client for the Granola MCP server (user-scoped meeting access). + + Speaks Streamable HTTP JSON-RPC. The server is stateless (no session id). + In the Centaur sandbox the proxy injects the OAuth Bearer token for + mcp.granola.ai; outside it, set GRANOLA_MCP_TOKEN. + """ + + def __init__(self, token: str | None = None): + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + } + token = token or secret("GRANOLA_MCP_TOKEN", "") + if token: + headers["Authorization"] = f"Bearer {token}" + self._client = httpx.Client(headers=headers, timeout=30.0) + self._rpc_id = 0 + + def close(self) -> None: + self._client.close() + + def _rpc(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + self._rpc_id += 1 + response = self._client.post( + MCP_URL, + json={"jsonrpc": "2.0", "id": self._rpc_id, "method": method, "params": params or {}}, + ) + response.raise_for_status() + body = response.text + if response.headers.get("content-type", "").startswith("text/event-stream"): + payloads = [line[6:] for line in body.splitlines() if line.startswith("data: ")] + if not payloads: + raise RuntimeError(f"empty MCP event stream for {method}") + msg = json.loads(payloads[-1]) + else: + msg = json.loads(body) + if "error" in msg: + raise RuntimeError(f"MCP error from {method}: {msg['error']}") + return msg["result"] + + def _call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> str: + result = self._rpc("tools/call", {"name": name, "arguments": arguments or {}}) + if result.get("isError"): + raise RuntimeError(f"granola MCP tool {name} failed: {result}") + return "\n".join(c.get("text", "") for c in result.get("content", []) if c.get("type") == "text") + + def get_account_info(self) -> dict[str, Any]: + """Email and active workspace of the connected Granola account.""" + return json.loads(self._call_tool("get_account_info")) + + def list_folders(self) -> str: + """List meeting folders (raw text: id, title, description, note count).""" + return self._call_tool("list_meeting_folders") + + def query(self, query: str, document_ids: list[str] | None = None) -> str: + """Ask Granola a natural-language question about your meetings.""" + args: dict[str, Any] = {"query": query} + if document_ids: + args["document_ids"] = document_ids + return self._call_tool("query_granola_meetings", args) + + def list_notes( + self, + page_size: int = 30, + cursor: str | None = None, + created_before: str | None = None, + created_after: str | None = None, + updated_after: str | None = None, + ) -> dict[str, Any]: + """List the connected user's meetings, REST-shaped. + + Returns {notes: [...], hasMore: False, cursor: None} (MCP has no + pagination). Date filters map onto the MCP custom time range. + """ + args: dict[str, Any] + if created_after or created_before: + args = { + "time_range": "custom", + "custom_start": (created_after or "2000-01-01")[:10], + "custom_end": (created_before or datetime.now().strftime("%Y-%m-%d"))[:10], + } + else: + args = {"time_range": "last_30_days"} + text = self._call_tool("list_meetings", args) + return {"notes": _parse_meetings(text)[:page_size], "hasMore": False, "cursor": None} + + def list_all_notes( + self, + limit: int = 50, + created_after: str | None = None, + updated_after: str | None = None, + ) -> list[dict[str, Any]]: + """List meetings up to limit. Without a date filter, covers the last year.""" + created_after = created_after or updated_after + if not created_after: + now = datetime.now() + created_after = now.replace(year=now.year - 1).strftime("%Y-%m-%d") + result = self.list_notes(page_size=limit, created_after=created_after) + return result["notes"][:limit] + + def get_note(self, note_id: str, include_transcript: bool = False) -> dict[str, Any]: + """Fetch a single meeting by UUID, REST-shaped (title, owner, attendees, + summary_markdown, optionally transcript).""" + text = self._call_tool("get_meetings", {"meeting_ids": [note_id]}) + notes = _parse_meetings(text) + if not notes: + raise RuntimeError(f"meeting {note_id} not found") + note = notes[0] + if include_transcript: + note["transcript"] = self.get_transcript(note_id) + return note + + def get_transcript(self, note_id: str) -> list[dict[str, Any]]: + """Fetch the transcript for a meeting. Returns a list of utterances + (single block if the MCP text is not line-structured).""" + text = self._call_tool("get_meeting_transcript", {"meeting_id": note_id}) + if not text.strip(): + return [] + return [{"speaker": {"source": "transcript"}, "text": text.strip()}] + + def search_notes(self, query: str, limit: int = 50) -> list[dict[str, Any]]: + """Search meetings by title keyword. Case-insensitive substring match.""" + query_lower = query.lower() + all_notes = self.list_all_notes(limit=200) + return [n for n in all_notes if query_lower in (n.get("title") or "").lower()][:limit] + + +def _client() -> GranolaMcpClient | GranolaClient: + """Pick a backend: MCP first, REST fallback. GRANOLA_BACKEND=mcp|rest overrides.""" + backend = secret("GRANOLA_BACKEND", "").lower() + if backend == "rest": + return GranolaClient() + if backend == "mcp": + return GranolaMcpClient() + + mcp = GranolaMcpClient() + try: + mcp.get_account_info() + return mcp + except Exception as mcp_error: + mcp.close() + try: + return GranolaClient() + except Exception as rest_error: + raise RuntimeError( + "No working Granola backend.\n" + f"MCP ({MCP_URL}): {mcp_error}\n" + f"REST ({API_BASE}): {rest_error}\n" + "Connect Granola via the Centaur console OAuth flow (MCP), " + "or set GRANOLA_API_KEY (Enterprise REST API)." + ) from mcp_error diff --git a/tools/productivity/granola/pyproject.toml b/tools/productivity/granola/pyproject.toml index 80c0deb09..14382002b 100644 --- a/tools/productivity/granola/pyproject.toml +++ b/tools/productivity/granola/pyproject.toml @@ -25,6 +25,9 @@ build-backend = "hatchling.build" [tool.centaur] module = "client.py" +# MCP backend: the Bearer token for mcp.granola.ai is injected by the proxy +# from the console OAuth consent flow grant, not declared here. +hosts = ["mcp.granola.ai"] secrets = [ {type = "http", name = "GRANOLA_API_KEY", mode = "inject", inject_header = "Authorization", inject_formatter = "Bearer {{ .Value }}", hosts = ["public-api.granola.ai"]}, ] From fc80234d05485f4df271922e23b85d110039be85 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Thu, 9 Jul 2026 11:24:53 -0600 Subject: [PATCH 116/198] feat: proxy Slack history and files (#1001) --- .../crates/centaur-api-server/src/lib.rs | 8 +- .../centaur-api-server/src/slack_proxy.rs | 220 +++++++++++++- services/console/lib/api_server/jwt.rb | 3 +- .../console/test/models/principal_test.rb | 1 + tools/productivity/slack/cli.py | 161 +++++++++- tools/productivity/slack/client.py | 287 ++++++++++++++++++ tools/productivity/slack/tests/test_cli.py | 73 ++++- tools/productivity/slack/tests/test_client.py | 186 +++++++++++- 8 files changed, 932 insertions(+), 7 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/lib.rs b/services/api-rs/crates/centaur-api-server/src/lib.rs index 5c2d6841e..c6bbac371 100644 --- a/services/api-rs/crates/centaur-api-server/src/lib.rs +++ b/services/api-rs/crates/centaur-api-server/src/lib.rs @@ -128,7 +128,8 @@ mod tests { "exp": 4_102_444_800i64, "slack": { "upload_channels": ["C123456789"], - "download_channels": ["C987654321"] + "download_channels": ["C987654321"], + "history_channels": ["C111111111"] } }), &EncodingKey::from_secret(b"test-secret"), @@ -165,6 +166,11 @@ mod tests { .and_then(Value::as_str), Some("C987654321") ); + assert_eq!( + body.pointer("/slack_client_jwt/claims/slack/history_channels/0") + .and_then(Value::as_str), + Some("C111111111") + ); } #[tokio::test] diff --git a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs index 4abefbe46..748473ece 100644 --- a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs +++ b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs @@ -43,6 +43,10 @@ pub(crate) fn slack_proxy_router() -> Router { "/api/slack/files/{file_id}/download", get(download_slack_file), ) + .route( + "/api/slack/channels/{channel_id}/history", + get(get_slack_channel_history), + ) } #[derive(Debug, Deserialize)] @@ -68,6 +72,22 @@ struct SlackFileDownloadQuery { channel_id: String, } +#[derive(Debug, Deserialize)] +struct SlackChannelHistoryQuery { + #[serde(default)] + latest: Option, + #[serde(default)] + oldest: Option, + #[serde(default)] + inclusive: Option, + #[serde(default)] + include_all_metadata: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + cursor: Option, +} + #[derive(Debug, Deserialize)] struct SlackFileProxyClaims { slack: SlackProxyClaims, @@ -79,6 +99,8 @@ struct SlackProxyClaims { upload_channels: Vec, #[serde(default)] download_channels: Vec, + #[serde(default)] + history_channels: Vec, } #[derive(Debug, Serialize)] @@ -220,6 +242,21 @@ async fn download_slack_file( Ok(response) } +async fn get_slack_channel_history( + headers: HeaderMap, + Path(channel_id): Path, + Query(query): Query, +) -> Result, ApiError> { + let claims = authorize_slack_file_proxy(&headers)?; + ensure_history_channel_allowed(&claims, &channel_id)?; + validate_slack_channel_id(&channel_id)?; + validate_slack_channel_history_query(&query)?; + + let config = slack_proxy_config()?; + let value = slack_channel_history(http_client(), config, &channel_id, &query).await?; + Ok(Json(value)) +} + fn upstream_body_is_unexpected_html( upstream_content_type: Option<&str>, file_mimetype: Option<&str>, @@ -378,6 +415,51 @@ async fn slack_file_info( }) } +async fn slack_channel_history( + client: &reqwest::Client, + config: &SlackFileProxyConfig, + channel_id: &str, + query: &SlackChannelHistoryQuery, +) -> Result { + let form = slack_channel_history_form(channel_id, query); + slack_api_post_form(client, config, "conversations.history", &form).await +} + +fn slack_channel_history_form( + channel_id: &str, + query: &SlackChannelHistoryQuery, +) -> Vec<(&'static str, String)> { + let mut form = vec![ + ("channel", channel_id.to_owned()), + ("latest", query.latest.clone().unwrap_or_default()), + ("oldest", query.oldest.clone().unwrap_or_default()), + ( + "inclusive", + query + .inclusive + .map(|value| value.to_string()) + .unwrap_or_default(), + ), + ( + "include_all_metadata", + query + .include_all_metadata + .map(|value| value.to_string()) + .unwrap_or_default(), + ), + ( + "limit", + query + .limit + .map(|value| value.to_string()) + .unwrap_or_default(), + ), + ("cursor", query.cursor.clone().unwrap_or_default()), + ]; + form.retain(|(_, value)| !value.is_empty()); + form +} + async fn slack_api_post_form( client: &reqwest::Client, config: &SlackFileProxyConfig, @@ -435,6 +517,17 @@ fn ensure_download_channel_allowed( ) } +fn ensure_history_channel_allowed( + claims: &SlackFileProxyClaims, + channel_id: &str, +) -> Result<(), ApiError> { + ensure_channel_allowed( + &claims.slack.history_channels, + channel_id, + "JWT is not authorized to read history from this Slack channel", + ) +} + fn ensure_channel_allowed( allowed_channels: &[String], channel_id: &str, @@ -525,6 +618,26 @@ fn validate_slack_file_id(file_id: &str) -> Result<(), ApiError> { Err(ApiError::BadRequest("invalid Slack file ID".to_owned())) } +fn validate_slack_channel_history_query(query: &SlackChannelHistoryQuery) -> Result<(), ApiError> { + if let Some(latest) = query.latest.as_deref() { + validate_slack_timestamp(latest)?; + } + if let Some(oldest) = query.oldest.as_deref() { + validate_slack_timestamp(oldest)?; + } + if let Some(limit) = query.limit + && !(1..=999).contains(&limit) + { + return Err(ApiError::BadRequest( + "Slack history limit must be between 1 and 999".to_owned(), + )); + } + if let Some(cursor) = query.cursor.as_deref() { + validate_slack_cursor(cursor)?; + } + Ok(()) +} + fn validate_slack_thread_ts(thread_ts: &str) -> Result<(), ApiError> { let Some((seconds, micros)) = thread_ts.split_once('.') else { return Err(ApiError::BadRequest("invalid Slack thread_ts".to_owned())); @@ -539,6 +652,30 @@ fn validate_slack_thread_ts(thread_ts: &str) -> Result<(), ApiError> { Err(ApiError::BadRequest("invalid Slack thread_ts".to_owned())) } +fn validate_slack_timestamp(timestamp: &str) -> Result<(), ApiError> { + if !timestamp.is_empty() + && timestamp + .split_once('.') + .map(|(seconds, micros)| { + !seconds.is_empty() + && !micros.is_empty() + && seconds.bytes().all(|byte| byte.is_ascii_digit()) + && micros.bytes().all(|byte| byte.is_ascii_digit()) + }) + .unwrap_or_else(|| timestamp.bytes().all(|byte| byte.is_ascii_digit())) + { + return Ok(()); + } + Err(ApiError::BadRequest("invalid Slack timestamp".to_owned())) +} + +fn validate_slack_cursor(cursor: &str) -> Result<(), ApiError> { + if cursor.is_empty() || cursor.len() > 4096 || cursor.chars().any(|ch| ch.is_ascii_control()) { + return Err(ApiError::BadRequest("invalid Slack cursor".to_owned())); + } + Ok(()) +} + fn validate_filename(filename: &str) -> Result<(), ApiError> { let filename = filename.trim(); if filename.is_empty() || filename.contains('/') || filename.contains('\\') { @@ -594,7 +731,8 @@ mod tests { "exp": 4_102_444_800i64, "slack": { "upload_channels": ["C123456789"], - "download_channels": ["C987654321"] + "download_channels": ["C987654321"], + "history_channels": ["C111111111"] } }), ); @@ -607,6 +745,7 @@ mod tests { .unwrap(); ensure_upload_channel_allowed(&claims, "C123456789").unwrap(); ensure_download_channel_allowed(&claims, "C987654321").unwrap(); + ensure_history_channel_allowed(&claims, "C111111111").unwrap(); assert!(matches!( ensure_upload_channel_allowed(&claims, "C987654321").unwrap_err(), ApiError::Forbidden(_) @@ -615,6 +754,10 @@ mod tests { ensure_download_channel_allowed(&claims, "C123456789").unwrap_err(), ApiError::Forbidden(_) )); + assert!(matches!( + ensure_history_channel_allowed(&claims, "C123456789").unwrap_err(), + ApiError::Forbidden(_) + )); } #[test] @@ -869,4 +1012,79 @@ mod tests { ] ); } + + #[test] + fn validates_slack_channel_history_query() { + validate_slack_channel_history_query(&SlackChannelHistoryQuery { + latest: Some("1700000000.000002".to_owned()), + oldest: Some("0".to_owned()), + inclusive: Some(true), + include_all_metadata: Some(true), + limit: Some(999), + cursor: Some("next_cursor".to_owned()), + }) + .unwrap(); + + assert!(matches!( + validate_slack_channel_history_query(&SlackChannelHistoryQuery { + latest: None, + oldest: None, + inclusive: None, + include_all_metadata: None, + limit: Some(1000), + cursor: None, + }) + .unwrap_err(), + ApiError::BadRequest(_) + )); + assert!(matches!( + validate_slack_channel_history_query(&SlackChannelHistoryQuery { + latest: Some("not-a-ts".to_owned()), + oldest: None, + inclusive: None, + include_all_metadata: None, + limit: None, + cursor: None, + }) + .unwrap_err(), + ApiError::BadRequest(_) + )); + assert!(matches!( + validate_slack_channel_history_query(&SlackChannelHistoryQuery { + latest: None, + oldest: None, + inclusive: None, + include_all_metadata: None, + limit: None, + cursor: Some("bad\ncursor".to_owned()), + }) + .unwrap_err(), + ApiError::BadRequest(_) + )); + } + + #[test] + fn channel_history_form_omits_empty_query_params() { + let form = slack_channel_history_form( + "C123456789", + &SlackChannelHistoryQuery { + latest: Some("1700000000.000002".to_owned()), + oldest: None, + inclusive: Some(false), + include_all_metadata: Some(true), + limit: Some(15), + cursor: None, + }, + ); + assert_eq!( + form, + vec![ + ("channel", "C123456789".to_owned()), + ("latest", "1700000000.000002".to_owned()), + ("inclusive", "false".to_owned()), + ("include_all_metadata", "true".to_owned()), + ("limit", "15".to_owned()), + ] + ); + } } diff --git a/services/console/lib/api_server/jwt.rb b/services/console/lib/api_server/jwt.rb index 6ba4ab528..98e2628d8 100644 --- a/services/console/lib/api_server/jwt.rb +++ b/services/console/lib/api_server/jwt.rb @@ -27,7 +27,8 @@ def encode_for_principal(principal, now: Time.current) "exp" => expires_at, "slack" => { "upload_channels" => [ channel_id ], - "download_channels" => [ channel_id ] + "download_channels" => [ channel_id ], + "history_channels" => [ channel_id ] } }, signing_secret: signing_secret diff --git a/services/console/test/models/principal_test.rb b/services/console/test/models/principal_test.rb index 57de568fa..867a12f7e 100644 --- a/services/console/test/models/principal_test.rb +++ b/services/console/test/models/principal_test.rb @@ -132,6 +132,7 @@ def default_attrs(overrides = {}) assert_equal principal.oid, claims.fetch("sub") assert_equal [ "C0123456789" ], claims.dig("slack", "upload_channels") assert_equal [ "C0123456789" ], claims.dig("slack", "download_channels") + assert_equal [ "C0123456789" ], claims.dig("slack", "history_channels") assert_equal 1.hour.to_i, claims.fetch("exp") - claims.fetch("iat") assert_equal ApiServer::Jwt.rotation_offset(principal), claims.fetch("iat") % ApiServer::Jwt::DEFAULT_WINDOW_SECONDS diff --git a/tools/productivity/slack/cli.py b/tools/productivity/slack/cli.py index aa76b4f93..4153c0656 100644 --- a/tools/productivity/slack/cli.py +++ b/tools/productivity/slack/cli.py @@ -215,7 +215,7 @@ def channel( ) except (RuntimeError, ValueError) as e: stderr_console.print(f"[red]Error: {e}[/]") - raise typer.Exit(1) + raise typer.Exit(1) from e messages = page["messages"] @@ -246,6 +246,74 @@ def channel( console.print(f"[green]{msg['user']}[/]{thread_info}: {text}") +@app.command("channel-proxy") +def channel_proxy( + channel_id: str = typer.Argument(..., help="Slack channel ID, e.g. C1234567890"), + limit: int = typer.Option(50, "--limit", "-n", help="Max messages"), + cursor: str = typer.Option(None, "--cursor", help="Slack pagination cursor for the next page"), + oldest: str = typer.Option(None, "--oldest", help="Oldest Slack timestamp boundary"), + latest: str = typer.Option(None, "--latest", help="Latest Slack timestamp boundary"), + inclusive: bool | None = typer.Option( + None, + "--inclusive/--exclusive", + help="Include messages exactly on the oldest/latest boundary", + ), + include_all_metadata: bool | None = typer.Option( + None, + "--include-all-metadata/--metadata-default", + help="Ask Slack to return all message metadata", + ), + full: bool = typer.Option(False, "--full", "-f", help="Show full message text"), + json_output: bool = typer.Option(False, "--json", help="Output raw proxy response as JSON"), +): + """Get channel history through the Centaur API server proxy.""" + import sys + + from .client import get_channel_history_proxy + + try: + page = get_channel_history_proxy( + channel_id, + cursor=cursor, + include_all_metadata=include_all_metadata, + inclusive=inclusive, + latest=latest, + limit=limit, + oldest=oldest, + ) + except (RuntimeError, ValueError) as e: + stderr_console.print(f"[red]Error: {e}[/]") + raise typer.Exit(1) from e + + if json_output: + print(json.dumps(page, indent=2, ensure_ascii=False), file=sys.stdout) + raise typer.Exit() + + messages = page.get("messages", []) + if not messages: + console.print("[yellow]No messages found.[/]") + raise typer.Exit() + + header = f"[bold]#{channel_id}[/] - {len(messages)} messages" + if page.get("has_more"): + header += " [dim](more available)[/]" + console.print(f"{header}\n") + + next_cursor = page.get("response_metadata", {}).get("next_cursor") + if next_cursor: + console.print(f"[dim]next_cursor={next_cursor}[/]\n") + + for msg in messages: + user = msg.get("user") or msg.get("bot_id") or msg.get("username") or "unknown" + text = str(msg.get("text") or "") + if not full: + text = text[:120].replace("\n", " ") + if len(str(msg.get("text") or "")) > 120: + text += "..." + thread_info = f" [dim]({msg['reply_count']} replies)[/]" if msg.get("reply_count") else "" + console.print(f"[green]{user}[/]{thread_info}: {text}") + + @app.command() def thread( permalink: str = typer.Argument(..., help="Slack permalink or 'channel_id:timestamp'"), @@ -572,6 +640,62 @@ def upload( raise typer.Exit(1) +@app.command("upload-proxy") +def upload_proxy( + channel_id: str = typer.Argument( + ..., help="Slack channel/conversation ID to upload into, e.g. C123 or D123" + ), + files: list[str] = typer.Argument(..., help="File path(s) to upload"), # noqa: B008 + comment: str = typer.Option(None, "--comment", "-c", help="Comment to post with files"), + thread: str = typer.Option(None, "--thread", "-t", help="Slack thread timestamp to reply to"), + content_type: str = typer.Option( + None, "--content-type", help="Content-Type to send for all files" + ), + alt_text: str = typer.Option(None, "--alt-text", help="Alt text for all files"), + snippet_type: str = typer.Option(None, "--snippet-type", help="Slack snippet type"), +): + """Upload file(s) through the Centaur API server Slack proxy.""" + import base64 + import mimetypes + from pathlib import Path + + from .client import upload_file_proxy + + if not _channel_arg_is_id(channel_id): + console.print( + "[red]Error: upload-proxy channel must be a Slack conversation ID like C123 or D123[/]" + ) + raise typer.Exit(1) + + first_upload_path = files[0] if files else None + for file_path in files: + path = Path(file_path) + if not path.exists(): + console.print(f"[red]File not found: {file_path}[/]") + raise typer.Exit(1) + + effective_content_type = content_type or mimetypes.guess_type(path.name)[0] + try: + result = upload_file_proxy( + channel_id=channel_id, + content_base64=base64.b64encode(path.read_bytes()).decode(), + filename=path.name, + title=path.name, + initial_comment=comment if file_path == first_upload_path else None, + thread_ts=thread, + content_type=effective_content_type, + alt_txt=alt_text, + snippet_type=snippet_type, + ) + console.print(f"[green]✓ Uploaded {path.name}[/]") + console.print( + f"[dim]{result.get('file_id') or result.get('file', {}).get('id', '')}[/]" + ) + except (RuntimeError, ValueError) as e: + console.print(f"[red]Error uploading {path.name}: {e}[/]") + raise typer.Exit(1) from e + + @app.command() def questions( channel: str = typer.Argument(..., help="Channel name (without #)"), @@ -910,6 +1034,41 @@ def files( console.print(f" [dim]{f['url_private']}[/]") +@app.command("download-proxy") +def download_proxy( + file_id: str = typer.Argument(..., help="Slack file ID, e.g. F1234567890"), + channel_id: str = typer.Argument( + ..., help="Slack channel/conversation ID that the file is shared in" + ), + output: str = typer.Option(".", "--output", "-o", help="Output directory for downloads"), + json_output: bool = typer.Option(False, "--json", help="Print metadata as JSON"), +): + """Download a Slack file through the Centaur API server Slack proxy.""" + import base64 + import sys + from pathlib import Path + + from .client import download_file_proxy + + try: + result = download_file_proxy(file_id=file_id, channel_id=channel_id) + except (RuntimeError, ValueError) as e: + console.print(f"[red]Error downloading Slack file: {e}[/]") + raise typer.Exit(1) from e + + if json_output: + metadata = {key: value for key, value in result.items() if key != "content_base64"} + print(json.dumps(metadata, indent=2, ensure_ascii=False), file=sys.stdout) + raise typer.Exit() + + output_dir = Path(output) + output_dir.mkdir(parents=True, exist_ok=True) + out_path = output_dir / result["filename"] + out_path.write_bytes(base64.b64decode(result["content_base64"])) + console.print(f"[green]✓ Downloaded {result['filename']}[/] ({result['size_bytes']} bytes)") + console.print(f"[dim]{out_path.absolute()}[/]") + + # === Feedback Commands === diff --git a/tools/productivity/slack/client.py b/tools/productivity/slack/client.py index 3d30145d3..0d045a42e 100644 --- a/tools/productivity/slack/client.py +++ b/tools/productivity/slack/client.py @@ -1,11 +1,14 @@ """Slack API client for bot-token Slack tool operations.""" import base64 +import binascii import json import mimetypes import os import re import time +import urllib.error +import urllib.parse import urllib.request from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed @@ -84,6 +87,7 @@ class SlackClient: _CHANNEL_CACHE_TTL = 300 # 5 minutes _USER_CACHE_TTL = 600 # 10 minutes _MAX_PAGE_SIZE = 200 + _MAX_SLACK_HISTORY_PROXY_PAGE_SIZE = 999 _DEFAULT_THREAD_REPLY_LIMIT = 50 _DEFAULT_DUMP_MESSAGE_LIMIT = 100 _DEFAULT_DUMP_THREAD_LIMIT = 25 @@ -92,6 +96,7 @@ class SlackClient: _DATE_ONLY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") _NUMERIC_TS_RE = re.compile(r"^\d+(?:\.\d+)?$") _CHANNEL_ID_RE = re.compile(r"^[CGD][A-Z0-9]+$") + _FILE_ID_RE = re.compile(r"^F[A-Z0-9]+$") _USER_ID_RE = re.compile(r"^[UW][A-Z0-9]+$") _AUTH_ERROR_CODES: ClassVar[frozenset[str]] = frozenset( { @@ -301,6 +306,119 @@ def _normalize_ts(self, value: str | int | float | None) -> str | None: parsed = parsed.replace(tzinfo=UTC) return self._format_ts(parsed.timestamp()) + def _centaur_api_url(self) -> str: + """Return the Centaur API base URL available inside agent sandboxes.""" + return secret("CENTAUR_API_URL", "http://api:8000").rstrip("/") + + def _centaur_api_headers(self) -> dict[str, str]: + """Return headers for API-server calls. + + In sandboxes, iron-proxy injects the principal-scoped Authorization + header for the API host. A local bearer can be supplied for tests or + manual CLI use. + """ + headers = {"Accept": "application/json"} + bearer = secret("CENTAUR_API_BEARER_TOKEN", "").strip() + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + return headers + + def _centaur_api_query_value(self, value: Any) -> str: + """Format query values for axum/serde query extraction.""" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + def _centaur_api_url_for(self, path: str, params: dict[str, Any]) -> str: + """Build a Centaur API URL with query parameters.""" + query = urllib.parse.urlencode( + { + key: self._centaur_api_query_value(value) + for key, value in params.items() + if value is not None + } + ) + url = f"{self._centaur_api_url()}{path}" + if query: + url = f"{url}?{query}" + return url + + def _centaur_api_get_json(self, path: str, params: dict[str, Any]) -> dict[str, Any]: + """GET a Centaur API JSON endpoint.""" + url = self._centaur_api_url_for(path, params) + request = urllib.request.Request(url, headers=self._centaur_api_headers(), method="GET") + try: + with urllib.request.urlopen(request, timeout=self._api_timeout_seconds()) as response: + raw = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + detail = raw + try: + body = json.loads(raw) + detail = body.get("message") or body.get("detail") or raw + except json.JSONDecodeError: + pass + raise RuntimeError(f"Centaur API error {exc.code} on {path}: {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Centaur API request failed on {path}: {exc.reason}") from exc + return json.loads(raw) if raw else {} + + def _centaur_api_post_bytes_json( + self, + path: str, + params: dict[str, Any], + body: bytes, + content_type: str = "application/octet-stream", + ) -> dict[str, Any]: + """POST bytes to a Centaur API endpoint and parse a JSON response.""" + url = self._centaur_api_url_for(path, params) + headers = self._centaur_api_headers() + headers["Content-Type"] = content_type + request = urllib.request.Request(url, data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(request, timeout=self._api_timeout_seconds()) as response: + raw = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + detail = raw + try: + error_body = json.loads(raw) + detail = error_body.get("message") or error_body.get("detail") or raw + except json.JSONDecodeError: + pass + raise RuntimeError(f"Centaur API error {exc.code} on {path}: {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Centaur API request failed on {path}: {exc.reason}") from exc + return json.loads(raw) if raw else {} + + def _centaur_api_get_bytes( + self, + path: str, + params: dict[str, Any], + max_bytes: int, + ) -> tuple[bytes, dict[str, str]]: + """GET bytes from a Centaur API endpoint.""" + url = self._centaur_api_url_for(path, params) + request = urllib.request.Request(url, headers=self._centaur_api_headers(), method="GET") + try: + with urllib.request.urlopen(request, timeout=self._api_timeout_seconds()) as response: + body = response.read(max_bytes + 1) + headers = {key.lower(): value for key, value in response.headers.items()} + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + detail = raw + try: + error_body = json.loads(raw) + detail = error_body.get("message") or error_body.get("detail") or raw + except json.JSONDecodeError: + pass + raise RuntimeError(f"Centaur API error {exc.code} on {path}: {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Centaur API request failed on {path}: {exc.reason}") from exc + if len(body) > max_bytes: + raise ValueError(f"Slack file exceeds the {max_bytes}-byte download limit") + return body, headers + def _message_permalink(self, channel_id: str, ts: str) -> str: """Build a Slack permalink from channel and timestamp.""" return f"https://slack.com/archives/{channel_id}/p{ts.replace('.', '')}" @@ -318,6 +436,34 @@ def _resolve_channel_name(self, channel: str, channel_id: str) -> str: return item["name"] return channel_id + def _normalize_explicit_channel_id(self, channel_id: str) -> str: + """Normalize and validate an explicit Slack conversation ID.""" + normalized_channel_id = self._clean_channel_ref(channel_id).upper() + if len(normalized_channel_id) < 9 or not self._looks_like_channel_id( + normalized_channel_id + ): + raise ValueError("channel_id must be a Slack conversation ID like C123456789") + return normalized_channel_id + + def _normalize_file_id(self, file_id: str) -> str: + """Normalize and validate a Slack file ID.""" + normalized_file_id = str(file_id).strip().upper() + if len(normalized_file_id) < 9 or not self._FILE_ID_RE.fullmatch(normalized_file_id): + raise ValueError("file_id must be a Slack file ID like F123456789") + return normalized_file_id + + def _content_disposition_filename(self, value: str | None) -> str | None: + """Extract a simple filename value from Content-Disposition.""" + if not value: + return None + match = re.search(r'filename="([^"]+)"', value) + if match: + return match.group(1) + match = re.search(r"filename=([^;]+)", value) + if match: + return match.group(1).strip() + return None + def _serialize_message( self, msg: dict[str, Any], @@ -987,6 +1133,53 @@ def fetch_page(next_cursor: str | None, batch_limit: int) -> dict[str, Any]: "order": "desc", } + def get_channel_history_proxy( + self, + channel_id: str, + cursor: str | None = None, + include_all_metadata: bool | None = None, + inclusive: bool | None = None, + latest: str | int | float | None = None, + limit: int | None = None, + oldest: str | int | float | None = None, + ) -> dict[str, Any]: + """Fetch Slack channel history through the Centaur API server proxy. + + This maps to Slack's documented `conversations.history` arguments, + except `token` is intentionally omitted because the API server supplies + Slack credentials. `channel_id` must be an explicit Slack conversation + ID authorized by the principal's `slack.history_channels` claim. + """ + if secret("CENTAUR_SANDBOX_API_SERVER_ENABLED", "true").strip().lower() == "false": + raise RuntimeError( + "Slack channel history proxy requires the API server sandbox capability, " + "but it is disabled for this principal." + ) + + normalized_channel_id = self._clean_channel_ref(channel_id).upper() + if len(normalized_channel_id) < 9 or not self._looks_like_channel_id(normalized_channel_id): + raise ValueError("channel_id must be a Slack conversation ID like C123456789") + + params: dict[str, Any] = { + "cursor": cursor, + "include_all_metadata": include_all_metadata, + "inclusive": inclusive, + "latest": self._normalize_ts(latest), + "limit": None, + "oldest": self._normalize_ts(oldest), + } + if limit is not None: + requested_limit = int(limit) + if not 1 <= requested_limit <= self._MAX_SLACK_HISTORY_PROXY_PAGE_SIZE: + raise ValueError("limit must be between 1 and 999") + params["limit"] = requested_limit + + channel_path = urllib.parse.quote(normalized_channel_id, safe="") + return self._centaur_api_get_json( + f"/api/slack/channels/{channel_path}/history", + params, + ) + def get_channel_history( self, channel: str, @@ -1682,6 +1875,88 @@ def upload_file( resolved_channel=resolved_channel, ) + def upload_file_proxy( + self, + channel_id: str, + content_base64: str, + filename: str, + thread_ts: str | None = None, + title: str | None = None, + initial_comment: str | None = None, + content_type: str | None = None, + alt_txt: str | None = None, + snippet_type: str | None = None, + ) -> dict[str, Any]: + """Upload a file through the Centaur API server Slack proxy. + + This maps to `/api/slack/files/upload`. The caller supplies file bytes + as base64, and the API server handles Slack credentials and channel + authorization through the principal-scoped JWT. + """ + if secret("CENTAUR_SANDBOX_API_SERVER_ENABLED", "true").strip().lower() == "false": + raise RuntimeError( + "Slack file upload proxy requires the API server sandbox capability, " + "but it is disabled for this principal." + ) + normalized_channel_id = self._normalize_explicit_channel_id(channel_id) + effective_filename = str(filename).strip() + if not effective_filename: + raise ValueError("filename is required") + try: + body = base64.b64decode(content_base64, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError("content_base64 must be valid base64") from exc + if not body: + raise ValueError("content_base64 must not be empty") + + params = { + "channel_id": normalized_channel_id, + "filename": effective_filename, + "thread_ts": self._normalize_ts(thread_ts), + "title": title, + "initial_comment": initial_comment, + "content_type": content_type, + "alt_txt": alt_txt, + "snippet_type": snippet_type, + } + return self._centaur_api_post_bytes_json( + "/api/slack/files/upload", + params, + body, + content_type or "application/octet-stream", + ) + + def download_file_proxy(self, file_id: str, channel_id: str) -> dict[str, Any]: + """Download a Slack file through the Centaur API server Slack proxy. + + Returns base64-encoded file bytes plus filename, content type, and size. + """ + if secret("CENTAUR_SANDBOX_API_SERVER_ENABLED", "true").strip().lower() == "false": + raise RuntimeError( + "Slack file download proxy requires the API server sandbox capability, " + "but it is disabled for this principal." + ) + normalized_file_id = self._normalize_file_id(file_id) + normalized_channel_id = self._normalize_explicit_channel_id(channel_id) + body, headers = self._centaur_api_get_bytes( + f"/api/slack/files/{urllib.parse.quote(normalized_file_id, safe='')}/download", + {"channel_id": normalized_channel_id}, + self._MAX_DOWNLOAD_BYTES, + ) + filename = ( + self._content_disposition_filename(headers.get("content-disposition")) + or normalized_file_id + ) + content_type = headers.get("content-type") or "application/octet-stream" + return { + "file_id": normalized_file_id, + "channel_id": normalized_channel_id, + "filename": filename, + "content_type": content_type, + "size_bytes": len(body), + "content_base64": base64.b64encode(body).decode(), + } + def list_usergroups(self) -> list[dict]: """List all user groups in the workspace.""" try: @@ -2059,6 +2334,10 @@ def get_channel_history_page(*args, **kwargs): return _client().get_channel_history_page(*args, **kwargs) +def get_channel_history_proxy(*args, **kwargs): + return _client().get_channel_history_proxy(*args, **kwargs) + + def get_channel_history(*args, **kwargs): return _client().get_channel_history(*args, **kwargs) @@ -2107,6 +2386,14 @@ def upload_file(*args, **kwargs): return _client().upload_file(*args, **kwargs) +def upload_file_proxy(*args, **kwargs): + return _client().upload_file_proxy(*args, **kwargs) + + +def download_file_proxy(*args, **kwargs): + return _client().download_file_proxy(*args, **kwargs) + + def list_usergroups(*args, **kwargs): return _client().list_usergroups(*args, **kwargs) diff --git a/tools/productivity/slack/tests/test_cli.py b/tools/productivity/slack/tests/test_cli.py index f36464047..00252a204 100644 --- a/tools/productivity/slack/tests/test_cli.py +++ b/tools/productivity/slack/tests/test_cli.py @@ -1,10 +1,10 @@ +import base64 import sys import types from pathlib import Path -from typer.testing import CliRunner - from slack.cli import _channel_arg_is_id, app +from typer.testing import CliRunner def test_channel_arg_is_id_accepts_channel_id_forms() -> None: @@ -78,3 +78,72 @@ def test_upload_rejects_channel_name(monkeypatch, tmp_path: Path) -> None: assert result.exit_code == 1 assert "must be a Slack conversation ID" in result.output + + +def test_upload_proxy_calls_proxy_client(monkeypatch, tmp_path: Path) -> None: + upload = tmp_path / "chart.png" + upload.write_bytes(b"png") + calls = [] + + def fake_upload_file_proxy(**kwargs): + calls.append(kwargs) + return {"file_id": "F1234567890"} + + fake_client = types.SimpleNamespace(upload_file_proxy=fake_upload_file_proxy) + monkeypatch.setitem(sys.modules, "slack.client", fake_client) + + result = CliRunner().invoke( + app, + [ + "upload-proxy", + "C1234567890", + str(upload), + "--thread", + "1780000000.000000", + "--comment", + "chart", + "--content-type", + "image/png", + "--alt-text", + "chart alt", + ], + ) + + assert result.exit_code == 0 + assert calls == [ + { + "channel_id": "C1234567890", + "content_base64": "cG5n", + "filename": "chart.png", + "title": "chart.png", + "initial_comment": "chart", + "thread_ts": "1780000000.000000", + "content_type": "image/png", + "alt_txt": "chart alt", + "snippet_type": None, + } + ] + + +def test_download_proxy_writes_file(monkeypatch, tmp_path: Path) -> None: + calls = [] + + def fake_download_file_proxy(**kwargs): + calls.append(kwargs) + return { + "filename": "report.pdf", + "content_base64": base64.b64encode(b"%PDF").decode(), + "size_bytes": 4, + } + + fake_client = types.SimpleNamespace(download_file_proxy=fake_download_file_proxy) + monkeypatch.setitem(sys.modules, "slack.client", fake_client) + + result = CliRunner().invoke( + app, + ["download-proxy", "F1234567890", "C1234567890", "--output", str(tmp_path)], + ) + + assert result.exit_code == 0 + assert calls == [{"file_id": "F1234567890", "channel_id": "C1234567890"}] + assert (tmp_path / "report.pdf").read_bytes() == b"%PDF" diff --git a/tools/productivity/slack/tests/test_client.py b/tools/productivity/slack/tests/test_client.py index 63c14fb80..53abfaa0b 100644 --- a/tools/productivity/slack/tests/test_client.py +++ b/tools/productivity/slack/tests/test_client.py @@ -1,3 +1,4 @@ +import base64 import email.message import json @@ -410,6 +411,184 @@ def fail_history(**kwargs): client.get_channel_history_page("paradigm-pulse") +def test_get_channel_history_proxy_calls_centaur_api( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import urllib.parse + import urllib.request + + client, _ = _make_client() + request_info: dict[str, str | None] = {} + + def fake_urlopen(req, *args, **kwargs): + request_info["url"] = req.full_url + request_info["authorization"] = req.get_header("Authorization") + body = json.dumps( + { + "ok": True, + "messages": [{"type": "message", "ts": "1700000000.000001"}], + "has_more": False, + } + ).encode() + return _FakeHTTPResponse(body, "application/json") + + monkeypatch.setenv("CENTAUR_API_URL", "http://api.internal:8080") + monkeypatch.setenv("CENTAUR_API_BEARER_TOKEN", "test-jwt") + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + result = client.get_channel_history_proxy( + "<#C123456789|general>", + cursor="next", + include_all_metadata=True, + inclusive=False, + latest="1700000000.000002", + limit=999, + oldest=0, + ) + + assert result["ok"] is True + assert request_info["authorization"] == "Bearer test-jwt" + parsed = urllib.parse.urlparse(request_info["url"]) + assert parsed.scheme == "http" + assert parsed.netloc == "api.internal:8080" + assert parsed.path == "/api/slack/channels/C123456789/history" + query = urllib.parse.parse_qs(parsed.query) + assert query == { + "cursor": ["next"], + "include_all_metadata": ["true"], + "inclusive": ["false"], + "latest": ["1700000000.000002"], + "limit": ["999"], + "oldest": ["0.000000"], + } + + +def test_get_channel_history_proxy_validates_inputs() -> None: + client, _ = _make_client() + + with pytest.raises(ValueError, match="channel_id"): + client.get_channel_history_proxy("general") + + with pytest.raises(ValueError, match="between 1 and 999"): + client.get_channel_history_proxy("C123456789", limit=1000) + + +def test_upload_file_proxy_posts_file_bytes_to_centaur_api( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import urllib.parse + import urllib.request + + client, _ = _make_client() + request_info: dict[str, object] = {} + + def fake_urlopen(req, *args, **kwargs): + request_info["url"] = req.full_url + request_info["headers"] = {key.lower(): value for key, value in req.header_items()} + request_info["data"] = req.data + body = json.dumps( + { + "ok": True, + "file_id": "F123456789", + "channel_id": "C123456789", + "file": {"id": "F123456789"}, + } + ).encode() + return _FakeHTTPResponse(body, "application/json") + + monkeypatch.setenv("CENTAUR_API_URL", "http://api.internal:8080") + monkeypatch.setenv("CENTAUR_API_BEARER_TOKEN", "test-jwt") + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + result = client.upload_file_proxy( + channel_id="C123456789", + content_base64=base64.b64encode(b"hello").decode(), + filename="hello.txt", + thread_ts="1700000000.000001", + title="Hello", + initial_comment="uploaded", + content_type="text/plain", + alt_txt="hello file", + snippet_type="text", + ) + + assert result["file_id"] == "F123456789" + assert request_info["data"] == b"hello" + headers = request_info["headers"] + assert isinstance(headers, dict) + assert headers["authorization"] == "Bearer test-jwt" + assert headers["content-type"] == "text/plain" + parsed = urllib.parse.urlparse(request_info["url"]) + assert parsed.path == "/api/slack/files/upload" + assert urllib.parse.parse_qs(parsed.query) == { + "channel_id": ["C123456789"], + "filename": ["hello.txt"], + "thread_ts": ["1700000000.000001"], + "title": ["Hello"], + "initial_comment": ["uploaded"], + "content_type": ["text/plain"], + "alt_txt": ["hello file"], + "snippet_type": ["text"], + } + + +def test_download_file_proxy_returns_base64_file( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import urllib.parse + import urllib.request + + client, _ = _make_client() + request_info: dict[str, str | None] = {} + + def fake_urlopen(req, *args, **kwargs): + request_info["url"] = req.full_url + request_info["authorization"] = req.get_header("Authorization") + return _FakeHTTPResponse( + b"%PDF", + "application/pdf", + {"Content-Disposition": 'attachment; filename="report.pdf"'}, + ) + + monkeypatch.setenv("CENTAUR_API_URL", "http://api.internal:8080") + monkeypatch.setenv("CENTAUR_API_BEARER_TOKEN", "test-jwt") + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + result = client.download_file_proxy(file_id="F123456789", channel_id="C123456789") + + assert result == { + "file_id": "F123456789", + "channel_id": "C123456789", + "filename": "report.pdf", + "content_type": "application/pdf", + "size_bytes": 4, + "content_base64": base64.b64encode(b"%PDF").decode(), + } + assert request_info["authorization"] == "Bearer test-jwt" + parsed = urllib.parse.urlparse(request_info["url"]) + assert parsed.path == "/api/slack/files/F123456789/download" + assert urllib.parse.parse_qs(parsed.query) == {"channel_id": ["C123456789"]} + + +def test_file_proxy_methods_validate_inputs() -> None: + client, _ = _make_client() + + with pytest.raises(ValueError, match="filename"): + client.upload_file_proxy( + channel_id="C123456789", + content_base64=base64.b64encode(b"hello").decode(), + filename=" ", + ) + with pytest.raises(ValueError, match="valid base64"): + client.upload_file_proxy( + channel_id="C123456789", + content_base64="not base64", + filename="hello.txt", + ) + with pytest.raises(ValueError, match="file_id"): + client.download_file_proxy(file_id="bad", channel_id="C123456789") + + def test_search_messages_with_channel_ids_scans_history_without_listing() -> None: client, fake_web_client = _make_client() client._get_user_cache = lambda: {"UGZCSQTPE": "matt", "U1": "alice"} # type: ignore[method-assign] @@ -790,9 +969,12 @@ def test_upload_file_requires_a_content_source() -> None: class _FakeHTTPResponse: """Minimal stand-in for urllib's HTTPResponse context manager.""" - def __init__(self, body: bytes, content_type: str) -> None: + def __init__( + self, body: bytes, content_type: str, headers: dict[str, str] | None = None + ) -> None: self._body = body self._content_type = content_type + self._headers = headers or {} def __enter__(self) -> "_FakeHTTPResponse": return self @@ -807,6 +989,8 @@ def read(self, _amt: int = -1) -> bytes: def headers(self) -> "email.message.Message": msg = email.message.Message() msg["Content-Type"] = self._content_type + for key, value in self._headers.items(): + msg[key] = value return msg From 503aa4cd66d2a9bd91f60d5eeb868641f37790ee Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Thu, 9 Jul 2026 11:28:32 -0600 Subject: [PATCH 117/198] fix: proxy sandbox api traffic (#1002) * fix: proxy sandbox api traffic * test: cover iron-proxy api capability labels --- .../src/iron_proxy.rs | 85 ++++++++++++++++--- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs index 60221dfd3..c19f646ce 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs @@ -1096,13 +1096,7 @@ pub(crate) fn apply_proxy_env(spec: &mut SandboxSpec, resolved: &ResolvedIronPro // collector; routing them through iron-proxy fails (plain-HTTP forwards // are rejected), so the endpoint host always bypasses the proxy. no_proxy_extra.extend(otlp_endpoint_hosts(spec)); - let api_host = env_value(spec, "CENTAUR_API_URL").and_then(host_from_url); - for (name, value) in proxy_env( - &resolved.proxy_host, - resolved.proxy_port, - api_host.as_deref(), - &no_proxy_extra, - ) { + for (name, value) in proxy_env(&resolved.proxy_host, resolved.proxy_port, &no_proxy_extra) { set_env(spec, &name, &value); } // Operator-granted replace placeholders: the sandbox sends the proxy_value @@ -1530,11 +1524,10 @@ fn control_plane_egress_target( fn proxy_env( proxy_host: &str, proxy_port: u16, - api_host: Option<&str>, no_proxy_extra: &[String], ) -> BTreeMap { let proxy_url = format!("http://{proxy_host}:{proxy_port}"); - let no_proxy = no_proxy_value(proxy_host, api_host, no_proxy_extra); + let no_proxy = no_proxy_value(proxy_host, no_proxy_extra); BTreeMap::from([ ("FIREWALL_HOST".to_owned(), proxy_host.to_owned()), ("FIREWALL_PROXY_PORT".to_owned(), proxy_port.to_string()), @@ -1564,19 +1557,15 @@ fn proxy_env( ]) } -fn no_proxy_value(proxy_host: &str, api_host: Option<&str>, extra_values: &[String]) -> String { +fn no_proxy_value(proxy_host: &str, extra_values: &[String]) -> String { let mut hosts = BTreeSet::::from([ "localhost".to_owned(), "127.0.0.1".to_owned(), "::1".to_owned(), proxy_host.to_owned(), - "api".to_owned(), "victoriametrics".to_owned(), "victorialogs".to_owned(), ]); - if let Some(api_host) = api_host.filter(|value| !value.is_empty()) { - hosts.insert(api_host.to_owned()); - } for value in extra_values { hosts.extend( value @@ -2073,6 +2062,48 @@ mod tests { assert!(!iron_proxy_labels(&id, false).contains_key(API_SERVER_ENABLED_LABEL)); } + #[test] + fn iron_proxy_resources_carry_api_server_capability_label() { + let id = SandboxId::new("asbx-test"); + let iron_proxy = IronProxyConfig::new("proxy:test", "ca-cert", "ca-key"); + let resolved = resolved(); + let sync = ProxySyncEnv { + proxy_id: "iprx_test".to_owned(), + control_url: "http://console:3000".to_owned(), + token: "proxy-token".to_owned(), + }; + + let pod = build_iron_proxy_pod(&id, &iron_proxy, &resolved, &sync); + assert_eq!( + pod.metadata + .labels + .as_ref() + .and_then(|labels| labels.get(API_SERVER_ENABLED_LABEL)) + .map(String::as_str), + Some("true") + ); + + let service = build_iron_proxy_service(&id, &resolved); + assert_eq!( + service + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(API_SERVER_ENABLED_LABEL)) + .map(String::as_str), + Some("true") + ); + assert_eq!( + service + .spec + .as_ref() + .and_then(|spec| spec.selector.as_ref()) + .and_then(|selector| selector.get(API_SERVER_ENABLED_LABEL)) + .map(String::as_str), + Some("true") + ); + } + #[test] fn sandbox_egress_policy_does_not_inline_otlp_collector_rule() { let id = SandboxId::new("asbx-test"); @@ -2549,6 +2580,32 @@ mod tests { assert_eq!(ack, ProxyAck::ManagementUnavailable); } + #[test] + fn apply_proxy_env_does_not_add_api_host_to_no_proxy() { + let mut spec = SandboxSpec::new("centaur-agent:latest") + .env("CENTAUR_API_URL", "http://api:8080") + .env("NO_PROXY", "custom.internal"); + + apply_proxy_env(&mut spec, &resolved()); + + for name in ["NO_PROXY", "no_proxy"] { + let value = spec + .env + .iter() + .find(|env| env.name == name) + .map(|env| env.value.clone()) + .unwrap(); + assert!( + !value.split(',').any(|host| host == "api"), + "{name} should not contain the API host: {value}" + ); + assert!( + value.split(',').any(|host| host == "custom.internal"), + "{name} should preserve explicit NO_PROXY extras: {value}" + ); + } + } + #[test] fn proxy_fallback_delay_subtracts_elapsed_probe_time() { assert_eq!( From f701ba68584d63e93e901a0eeeab20514bd0d9c1 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:33:29 +0300 Subject: [PATCH 118/198] chore: change codex default model to gpt-5.6-sol (#1005) --- harness/codex/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harness/codex/config.toml b/harness/codex/config.toml index 8ed79d7d3..ef621948c 100644 --- a/harness/codex/config.toml +++ b/harness/codex/config.toml @@ -1,4 +1,4 @@ -model = "gpt-5.5" +model = "gpt-5.6-sol" model_reasoning_effort = "low" personality = "pragmatic" model_verbosity = "low" From 402dd63277ae72be8ffdcd0b5dd0a765d573e4f7 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Thu, 9 Jul 2026 12:05:59 -0700 Subject: [PATCH 119/198] Add Granola sync workflow (#987) feat: add granola sync workflow --- services/api-rs/Dockerfile | 3 +- .../migrations/0040_granola_sync_tables.sql | 277 +++++++ tools/productivity/company_context/client.py | 260 ++++++ .../company_context/tests/test_client.py | 121 +++ workflows/granola_sync.py | 757 ++++++++++++++++++ workflows/tests/test_granola_sync.py | 219 +++++ 6 files changed, 1636 insertions(+), 1 deletion(-) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0040_granola_sync_tables.sql create mode 100644 workflows/granola_sync.py create mode 100644 workflows/tests/test_granola_sync.py diff --git a/services/api-rs/Dockerfile b/services/api-rs/Dockerfile index 9e2950116..830d563e2 100644 --- a/services/api-rs/Dockerfile +++ b/services/api-rs/Dockerfile @@ -29,9 +29,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends ca-certificates curl python3 python3-pip COPY --from=builder /usr/local/bin/centaur-api-server /usr/local/bin/centaur-api-server WORKDIR /app +COPY centaur_sdk/ /app/centaur_sdk/ COPY services/workflow-python/ /app/workflow-python/ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ - pip3 install --break-system-packages --no-compile /app/workflow-python \ + pip3 install --break-system-packages --no-compile /app/centaur_sdk /app/workflow-python \ && python3 -c "import boto3, botocore" \ && rm -rf /usr/share/doc /usr/share/man /usr/share/info # api-rs discovers tool secret metadata from pyproject.toml at startup so it can diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0040_granola_sync_tables.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0040_granola_sync_tables.sql new file mode 100644 index 000000000..20284fba3 --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0040_granola_sync_tables.sql @@ -0,0 +1,277 @@ +create extension if not exists pg_search; + +create table if not exists granola_sync_runs ( + run_id text primary key, + workflow_run_id text, + mode text not null default 'incremental', + status text not null, + scopes_requested jsonb not null default '[]'::jsonb, + scopes_synced jsonb not null default '[]'::jsonb, + scopes_failed jsonb not null default '[]'::jsonb, + notes_seen integer not null default 0, + notes_upserted integer not null default 0, + transcripts_seen integer not null default 0, + transcripts_upserted integer not null default 0, + started_at timestamptz not null default now(), + finished_at timestamptz, + error_text text not null default '', + metadata jsonb not null default '{}'::jsonb +); + +create index if not exists idx_granola_sync_runs_started + on granola_sync_runs (started_at desc); + +create table if not exists granola_sync_notes ( + note_id text primary key, + title text not null default '', + owner_id text not null default '', + owner_email text not null default '', + owner_name text not null default '', + attendees jsonb not null default '[]'::jsonb, + access_emails text[] not null default array[]::text[], + calendar_event jsonb not null default '{}'::jsonb, + summary_markdown text not null default '', + summary_text text not null default '', + transcript_text text not null default '', + transcript_payload jsonb not null default '[]'::jsonb, + url text not null default '', + content_text text not null default '', + content_hash text not null default '', + source_created_at timestamptz, + source_updated_at timestamptz, + raw_payload jsonb not null default '{}'::jsonb, + source_run_id text references granola_sync_runs(run_id) on delete set null, + first_seen_at timestamptz not null default now(), + last_seen_at timestamptz not null default now(), + last_error text not null default '', + updated_at timestamptz not null default now() +); + +create index if not exists idx_granola_sync_notes_source_updated + on granola_sync_notes (source_updated_at desc); + +create index if not exists idx_granola_sync_notes_owner + on granola_sync_notes (owner_email, source_created_at desc); + +create index if not exists idx_granola_sync_notes_access_emails + on granola_sync_notes using gin (access_emails); + +create index if not exists idx_granola_sync_notes_text + on granola_sync_notes + using gin (to_tsvector('english', coalesce(content_text, ''))); + +create table if not exists granola_context_documents ( + document_id text primary key, + note_id text not null references granola_sync_notes(note_id) on delete cascade, + title text not null default '', + body text not null default '', + url text not null default '', + owner_id text not null default '', + owner_email text not null default '', + owner_name text not null default '', + access_emails text[] not null default array[]::text[], + attendee_labels text[] not null default array[]::text[], + occurred_at timestamptz, + source_updated_at timestamptz, + content_hash text not null default '', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (note_id), + check (document_id <> ''), + check (note_id <> '') +); + +create index if not exists idx_granola_context_documents_note_time + on granola_context_documents (note_id, occurred_at desc); + +create index if not exists idx_granola_context_documents_owner_time + on granola_context_documents (owner_email, occurred_at desc); + +create index if not exists idx_granola_context_documents_access_emails + on granola_context_documents using gin (access_emails); + +create index if not exists idx_granola_context_documents_metadata + on granola_context_documents using gin (metadata); + +drop index if exists idx_granola_context_documents_bm25; + +create index idx_granola_context_documents_bm25 + on granola_context_documents + using bm25 ( + document_id, + note_id, + title, + body, + url, + owner_id, + owner_email, + owner_name, + occurred_at, + source_updated_at, + metadata + ) + with ( + key_field = 'document_id', + text_fields = '{ + "document_id": { + "tokenizer": {"type": "keyword"} + }, + "note_id": { + "tokenizer": {"type": "keyword"} + }, + "owner_id": { + "tokenizer": {"type": "keyword"} + }, + "owner_email": { + "tokenizer": {"type": "keyword"} + } + }' + ); + +create table if not exists granola_sync_checkpoints ( + scope_id text primary key, + watermark_time timestamptz, + last_run_id text references granola_sync_runs(run_id) on delete set null, + last_success_at timestamptz, + last_error text not null default '', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +do $$ +declare + role_name text; +begin + foreach role_name in array array[ + 'centaur_slack_reader', + 'centaur_slack_admin', + 'centaur_readonly' + ] loop + if exists (select 1 from pg_roles where rolname = role_name) then + execute format( + 'grant select on %s to %I', + 'granola_sync_runs, granola_sync_notes, granola_context_documents, granola_sync_checkpoints', + role_name + ); + end if; + end loop; +end $$; + +alter table granola_sync_runs enable row level security; +alter table granola_sync_notes enable row level security; +alter table granola_context_documents enable row level security; +alter table granola_sync_checkpoints enable row level security; + +create or replace function centaur_current_slack_user_email() +returns text +language sql +stable +security definer +set search_path = public +as $$ + select coalesce( + lower(nullif(current_setting('centaur.user_email', true), '')), + ( + select lower(nullif(coalesce( + users.raw_payload #>> '{profile,email}', + users.raw_payload ->> 'email' + ), '')) + from slack_sync_users users + where users.team_id = centaur_current_slack_team_id() + and users.user_id = centaur_current_slack_user_id() + limit 1 + ) + ) +$$; + +create or replace function centaur_granola_current_user_can_read( + p_access_emails text[] +) +returns boolean +language sql +stable +as $$ + select coalesce( + centaur_current_slack_user_email() = any(coalesce(p_access_emails, array[]::text[])), + false + ) +$$; + +do $$ +declare + role_name text; +begin + foreach role_name in array array[ + 'centaur_slack_reader', + 'centaur_slack_admin', + 'centaur_readonly' + ] loop + if exists (select 1 from pg_roles where rolname = role_name) then + execute format( + 'grant execute on function centaur_current_slack_user_email() to %I', + role_name + ); + execute format( + 'grant execute on function centaur_granola_current_user_can_read(text[]) to %I', + role_name + ); + end if; + end loop; +end $$; + +drop policy if exists centaur_granola_runs_admin_select on granola_sync_runs; +drop policy if exists centaur_granola_runs_reader_select on granola_sync_runs; +create policy centaur_granola_runs_reader_select + on granola_sync_runs for select to centaur_slack_reader + using (false); +drop policy if exists centaur_readonly_granola_sync_runs_select on granola_sync_runs; +create policy centaur_readonly_granola_sync_runs_select + on granola_sync_runs for select to centaur_readonly using (false); + +drop policy if exists centaur_granola_notes_admin_select on granola_sync_notes; +drop policy if exists centaur_granola_notes_reader_select on granola_sync_notes; +create policy centaur_granola_notes_reader_select + on granola_sync_notes for select to centaur_slack_reader + using (centaur_granola_current_user_can_read(access_emails)); +drop policy if exists centaur_readonly_granola_sync_notes_select on granola_sync_notes; +create policy centaur_readonly_granola_sync_notes_select + on granola_sync_notes for select to centaur_readonly using (false); + +drop policy if exists centaur_granola_context_documents_admin_select + on granola_context_documents; +drop policy if exists centaur_granola_context_documents_reader_select + on granola_context_documents; +create policy centaur_granola_context_documents_reader_select + on granola_context_documents for select to centaur_slack_reader + using (centaur_granola_current_user_can_read(access_emails)); +drop policy if exists centaur_readonly_granola_context_documents_select + on granola_context_documents; +create policy centaur_readonly_granola_context_documents_select + on granola_context_documents for select to centaur_readonly using (false); + +drop policy if exists centaur_granola_checkpoints_admin_select + on granola_sync_checkpoints; +drop policy if exists centaur_granola_checkpoints_reader_select + on granola_sync_checkpoints; +create policy centaur_granola_checkpoints_reader_select + on granola_sync_checkpoints for select to centaur_slack_reader + using (false); +drop policy if exists centaur_readonly_granola_sync_checkpoints_select + on granola_sync_checkpoints; +create policy centaur_readonly_granola_sync_checkpoints_select + on granola_sync_checkpoints for select to centaur_readonly using (false); + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'centaur_slack_admin') then + create policy centaur_granola_runs_admin_select + on granola_sync_runs for select to centaur_slack_admin using (true); + create policy centaur_granola_notes_admin_select + on granola_sync_notes for select to centaur_slack_admin using (true); + create policy centaur_granola_context_documents_admin_select + on granola_context_documents for select to centaur_slack_admin using (true); + create policy centaur_granola_checkpoints_admin_select + on granola_sync_checkpoints for select to centaur_slack_admin using (true); + end if; +end $$; diff --git a/tools/productivity/company_context/client.py b/tools/productivity/company_context/client.py index f4ed6aebc..340932abf 100644 --- a/tools/productivity/company_context/client.py +++ b/tools/productivity/company_context/client.py @@ -28,6 +28,8 @@ DEFAULT_PREVIEW_CHARS = 280 MAX_RELATED_CHILDREN = 25 SLACK_DM_SOURCE = "slack_dm" +GRANOLA_SOURCE = "granola" +GRANOLA_SOURCE_TYPE = "granola_note" DOCS_SOURCE = "docs" LEGACY_GOOGLE_DRIVE_SOURCE = "google_drive" GOOGLE_DOCS_SOURCE_TYPE = "google_doc" @@ -421,6 +423,38 @@ def _google_doc_summary(row: Any) -> dict[str, Any]: } +def _granola_doc_summary(row: Any) -> dict[str, Any]: + """Return the common result shape for user-visible Granola notes.""" + metadata = _as_dict(_row_value(row, "metadata", {})) + metadata.update( + { + "note_id": str(_row_value(row, "note_id", "")), + "owner_id": str(_row_value(row, "owner_id", "")), + "owner_email": str(_row_value(row, "owner_email", "")), + "attendee_labels": list(_row_value(row, "attendee_labels", []) or []), + } + ) + return { + "document_id": str(_row_value(row, "document_id", "")), + "source": GRANOLA_SOURCE, + "source_type": GRANOLA_SOURCE_TYPE, + "source_document_id": str(_row_value(row, "note_id", "")), + "source_chunk_id": "", + "parent_document_id": None, + "title": str(_row_value(row, "title", "")), + "url": str(_row_value(row, "url", "")), + "author_name": str( + _row_value(row, "owner_name", "") + or _row_value(row, "owner_email", "") + or _row_value(row, "owner_id", "") + ), + "access_scope": "granola_note", + "occurred_at": _isoformat(_row_value(row, "occurred_at")), + "source_updated_at": _isoformat(_row_value(row, "source_updated_at")), + "metadata": metadata, + } + + def _dm_document_summary(row: Any) -> dict[str, Any]: """Return the common metadata we expose for Slack DM context records.""" metadata = _as_dict(_row_value(row, "metadata", {})) @@ -491,6 +525,14 @@ def _include_slack_dms_source(source: str | None, source_type: str | None) -> bo ) +def _include_granola_source(source: str | None, source_type: str | None) -> bool: + return (source is None or source == GRANOLA_SOURCE) and source_type in ( + None, + GRANOLA_SOURCE, + GRANOLA_SOURCE_TYPE, + ) + + def _company_context_filters_for_source( source: str | None, source_type: str | None, @@ -534,6 +576,7 @@ async def _search_async( search_terms = [query, *terms] results = [] google_docs_error = None + granola_error = None company_source, company_source_type = _company_context_filters_for_source( source, source_type, @@ -620,6 +663,29 @@ async def _search_async( except asyncpg.UndefinedTableError as exc: google_docs_error = str(exc) + if _include_granola_source(source, source_type): + try: + granola_rows = await self._search_granola_async( + conn, + search_terms=search_terms, + term_count=len(terms), + limit=limit, + occurred_after=occurred_after, + occurred_before=occurred_before, + ) + for row in granola_rows: + result = _granola_doc_summary(row) + result["score"] = float(_row_value(row, "score", 0.0) or 0.0) + result["preview"] = _body_preview( + str(_row_value(row, "body", "") or ""), + query=query, + ) + result["lane"] = "indexed" + result["result_type"] = GRANOLA_SOURCE_TYPE + results.append(result) + except asyncpg.UndefinedTableError as exc: + granola_error = str(exc) + results.sort( key=lambda item: ( float(item.get("score") or 0.0), @@ -651,6 +717,8 @@ async def _search_async( } if google_docs_error: response["google_docs_error"] = google_docs_error + if granola_error: + response["granola_error"] = granola_error return response finally: await conn.close() @@ -702,6 +770,54 @@ async def _search_google_docs_async( limit, ) + async def _search_granola_async( + self, + conn: asyncpg.Connection, + *, + search_terms: list[str], + term_count: int, + limit: int, + occurred_after: datetime | None, + occurred_before: datetime | None, + ) -> list[Any]: + occurred_after_param = len(search_terms) + 1 + occurred_before_param = len(search_terms) + 2 + limit_param = len(search_terms) + 3 + return await conn.fetch( + f""" + SELECT + document_id, + note_id, + title, + body, + url, + owner_id, + owner_email, + owner_name, + access_emails, + attendee_labels, + occurred_at, + source_updated_at, + metadata, + paradedb.score(document_id) AS score + FROM granola_context_documents + WHERE {_search_where_clause(term_count)} + AND (${occurred_after_param}::timestamptz IS NULL + OR occurred_at >= ${occurred_after_param}) + AND (${occurred_before_param}::timestamptz IS NULL + OR occurred_at < ${occurred_before_param}) + ORDER BY paradedb.score(document_id) DESC, + occurred_at DESC NULLS LAST, + source_updated_at DESC NULLS LAST, + document_id ASC + LIMIT ${limit_param} + """, + *search_terms, + occurred_after, + occurred_before, + limit, + ) + async def _latest_date_for_connection( self, conn: asyncpg.Connection, @@ -773,6 +889,31 @@ async def _latest_google_docs_for_connection( "latest_occurred_at": _isoformat(row["latest_occurred_at"]), } + async def _latest_granola_for_connection( + self, + conn: asyncpg.Connection, + *, + source: str | None, + source_type: str | None, + ) -> dict[str, Any]: + if not _include_granola_source(source, source_type): + return self._empty_latest_date_result(source=source, source_type=source_type) + row = await conn.fetchrow( + """ + SELECT + MAX(COALESCE(source_updated_at, occurred_at)) AS latest_date, + MAX(source_updated_at) AS latest_source_updated_at, + MAX(occurred_at) AS latest_occurred_at, + COUNT(*)::bigint AS document_count + FROM granola_context_documents + """ + ) + return self._latest_date_result_from_row( + row, + source=source, + source_type=source_type, + ) + async def _latest_slack_dms_for_connection( self, conn: asyncpg.Connection, @@ -880,6 +1021,7 @@ def _merge_latest_dates( indexed: dict[str, Any], google_docs: dict[str, Any], slack_dms: dict[str, Any] | None = None, + granola: dict[str, Any] | None = None, ) -> dict[str, Any]: def latest(values: list[str | None]) -> str | None: present = [value for value in values if value] @@ -888,6 +1030,8 @@ def latest(values: list[str | None]) -> str | None: latest_results = [indexed, google_docs] if slack_dms is not None: latest_results.append(slack_dms) + if granola is not None: + latest_results.append(granola) return { "status": "ok", @@ -1167,6 +1311,7 @@ async def _list_documents_async( try: results = [] google_docs_error = None + granola_error = None company_source, company_source_type = _company_context_filters_for_source( source, source_type, @@ -1227,6 +1372,23 @@ async def _list_documents_async( results.append(result) except asyncpg.UndefinedTableError as exc: google_docs_error = str(exc) + if _include_granola_source(source, source_type): + try: + granola_rows = await self._list_granola_async( + conn, + limit=limit, + occurred_after=occurred_after, + occurred_before=occurred_before, + ) + for row in granola_rows: + result = _granola_doc_summary(row) + result["preview"] = _body_preview( + str(_row_value(row, "body", "") or ""), + query="", + ) + results.append(result) + except asyncpg.UndefinedTableError as exc: + granola_error = str(exc) results.sort( key=lambda item: ( str(item.get("occurred_at") or ""), @@ -1246,6 +1408,8 @@ async def _list_documents_async( } if google_docs_error: response["google_docs_error"] = google_docs_error + if granola_error: + response["granola_error"] = granola_error return response finally: await conn.close() @@ -1286,6 +1450,42 @@ async def _list_google_docs_async( limit, ) + async def _list_granola_async( + self, + conn: asyncpg.Connection, + *, + limit: int, + occurred_after: datetime | None, + occurred_before: datetime | None, + ) -> list[Any]: + return await conn.fetch( + """ + SELECT + document_id, + note_id, + title, + body, + url, + owner_id, + owner_email, + owner_name, + access_emails, + attendee_labels, + occurred_at, + source_updated_at, + metadata + FROM granola_context_documents + WHERE ($1::timestamptz IS NULL OR occurred_at >= $1) + AND ($2::timestamptz IS NULL OR occurred_at < $2) + ORDER BY occurred_at DESC NULLS LAST, source_updated_at DESC NULLS LAST, + document_id ASC + LIMIT $3 + """, + occurred_after, + occurred_before, + limit, + ) + def list_documents( self, limit: int = DEFAULT_SEARCH_LIMIT, @@ -1343,12 +1543,20 @@ async def _latest_date_async( source=source, source_type=source_type, ) + granola = self._empty_latest_date_result(source=source, source_type=source_type) + with suppress(asyncpg.UndefinedTableError): + granola = await self._latest_granola_for_connection( + conn, + source=source, + source_type=source_type, + ) return self._merge_latest_dates( source=source, source_type=source_type, indexed=indexed, google_docs=google_docs, slack_dms=slack_dms, + granola=granola, ) finally: await conn.close() @@ -1468,6 +1676,16 @@ async def _read_document_async( google_doc = None if google_doc is not None: return google_doc + try: + granola_doc = await self._read_granola_doc_async( + conn, + document_id, + max_chars, + ) + except asyncpg.UndefinedTableError: + granola_doc = None + if granola_doc is not None: + return granola_doc return { "status": "error", "error": f"document not found: {document_id}", @@ -1536,6 +1754,48 @@ async def _read_google_doc_async( "content": content, } + async def _read_granola_doc_async( + self, + conn: asyncpg.Connection, + document_id: str, + max_chars: int | None, + ) -> dict[str, Any] | None: + row = await conn.fetchrow( + """ + SELECT + document_id, + note_id, + title, + body, + url, + owner_id, + owner_email, + owner_name, + access_emails, + attendee_labels, + occurred_at, + source_updated_at, + metadata + FROM granola_context_documents + WHERE document_id = $1 + """, + document_id, + ) + if not row: + return None + + body = str(row["body"] or "") + content = body if max_chars is None else body[:max_chars] + truncated = max_chars is not None and len(body) > max_chars + return { + "status": "ok", + **_granola_doc_summary(row), + "chars": len(content), + "total_chars": len(body), + "truncated": truncated, + "content": content, + } + def read_document( self, document_id: str, diff --git a/tools/productivity/company_context/tests/test_client.py b/tools/productivity/company_context/tests/test_client.py index e5d490a7a..4bbc9e30c 100644 --- a/tools/productivity/company_context/tests/test_client.py +++ b/tools/productivity/company_context/tests/test_client.py @@ -252,6 +252,7 @@ async def fake_connect(*args, **kwargs): pushed_lines = [] monkeypatch.setattr(company_context_client.asyncpg, "connect", fake_connect) monkeypatch.setattr(company_context_client, "_include_google_docs_source", lambda *_args: False) + monkeypatch.setattr(company_context_client, "_include_granola_source", lambda *_args: False) monkeypatch.setattr( company_context_client, "_push_company_context_lookup_metric_lines", @@ -574,6 +575,78 @@ async def fake_connect(*args, **kwargs): assert fake.closed is True +def test_search_granola_source_queries_private_note_projection(monkeypatch): + occurred_at = dt.datetime(2026, 7, 1, 10, 0, tzinfo=dt.UTC) + source_updated_at = dt.datetime(2026, 7, 1, 10, 30, tzinfo=dt.UTC) + fake = _FakeConnection( + fetch_rows=[ + [], + [ + { + "document_id": "granola:note:not_123", + "note_id": "not_123", + "title": "Launch review", + "body": "We agreed to ship.", + "url": "https://app.granola.ai/notes/not_123", + "owner_id": "usr_1", + "owner_email": "alice@example.com", + "owner_name": "Alice", + "access_emails": ["alice@example.com", "bob@example.com"], + "attendee_labels": ["Bob "], + "occurred_at": occurred_at, + "source_updated_at": source_updated_at, + "metadata": {"note_id": "not_123"}, + "score": 3.0, + } + ], + ] + ) + + async def fake_connect(*args, **kwargs): + return fake + + monkeypatch.setattr(company_context_client.asyncpg, "connect", fake_connect) + + result = CompanyContextClient("postgresql://example").search( + "launch review", + source="granola", + occurred_after="2026-07-01", + occurred_before="2026-07-02", + ) + + assert result["status"] == "ok" + assert result["count"] == 1 + assert result["results"][0]["source"] == "granola" + assert result["results"][0]["source_type"] == "granola_note" + assert result["results"][0]["source_document_id"] == "not_123" + assert result["results"][0]["author_name"] == "Alice" + legacy_query, legacy_args = fake.fetch_calls[0] + granola_query, granola_args = fake.fetch_calls[1] + assert "FROM company_context_documents" in legacy_query + assert legacy_args == ( + "launch review", + "launch", + "review", + "granola", + None, + dt.datetime(2026, 7, 1, tzinfo=dt.UTC), + dt.datetime(2026, 7, 2, tzinfo=dt.UTC), + 10, + ) + assert "FROM granola_context_documents" in granola_query + assert "occurred_at >= $4" in granola_query + assert "occurred_at < $5" in granola_query + assert granola_args == ( + "launch review", + "launch", + "review", + dt.datetime(2026, 7, 1, tzinfo=dt.UTC), + dt.datetime(2026, 7, 2, tzinfo=dt.UTC), + 10, + ) + assert fake.closed is True + + def test_search_rejects_invalid_occurred_at_filter(): result = CompanyContextClient("postgresql://example").search( "planning", @@ -1156,6 +1229,54 @@ async def fake_connect(*args, **kwargs): assert fake.closed is True +def test_read_document_falls_back_to_granola_note_projection(monkeypatch): + body = "Granola note content" + fake = _FakeConnection( + fetchrow_rows=[ + None, + None, + { + "document_id": "granola:note:not_123", + "note_id": "not_123", + "title": "Launch review", + "body": body, + "url": "https://app.granola.ai/notes/not_123", + "owner_id": "usr_1", + "owner_email": "alice@example.com", + "owner_name": "Alice", + "access_emails": ["alice@example.com", "bob@example.com"], + "attendee_labels": ["Bob "], + "occurred_at": dt.datetime(2026, 7, 1, 10, 0, tzinfo=dt.UTC), + "source_updated_at": dt.datetime(2026, 7, 1, 10, 30, tzinfo=dt.UTC), + "metadata": {"note_id": "not_123"}, + }, + ] + ) + + async def fake_connect(*args, **kwargs): + return fake + + monkeypatch.setattr(company_context_client.asyncpg, "connect", fake_connect) + + result = CompanyContextClient("postgresql://example").read_document( + "granola:note:not_123", + max_chars=7, + ) + + assert result["status"] == "ok" + assert result["source"] == "granola" + assert result["source_type"] == "granola_note" + assert result["source_document_id"] == "not_123" + assert result["author_name"] == "Alice" + assert result["content"] == "Granola" + assert result["chars"] == 7 + assert result["total_chars"] == len(body) + assert result["truncated"] is True + assert len(fake.fetchrow_calls) == 3 + assert "FROM granola_context_documents" in fake.fetchrow_calls[2][0] + assert fake.closed is True + + def test_read_document_reports_missing_document(monkeypatch): fake = _FakeConnection(row=None) diff --git a/workflows/granola_sync.py b/workflows/granola_sync.py new file mode 100644 index 000000000..0fca41c17 --- /dev/null +++ b/workflows/granola_sync.py @@ -0,0 +1,757 @@ +"""Workflow: sync Granola notes and transcripts into Postgres.""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import os +from dataclasses import dataclass, field +from typing import Any, Protocol + +from api.runtime_control import canonical_json +from workflows.etl_metrics import ( + record_etl_items_failed, + record_etl_items_seen, + record_etl_items_upserted, + set_etl_active_scopes, + set_etl_failed_scopes, + set_etl_scope_sync_freshness_seconds, +) +from api.workflow_engine import WorkflowContext +from workflows.slack.shared import env_flag_enabled, positive_int + +WORKFLOW_NAME = "granola_sync" +DEFAULT_SYNC_INTERVAL_SECONDS = 4 * 60 * 60 +DEFAULT_PAGE_SIZE = 30 +DEFAULT_WATERMARK_OVERLAP_SECONDS = 5 * 60 +WORKSPACE_SCOPE = "workspace" + + +SCHEDULE = { + "schedule_id": "granola_sync", + "interval_seconds": positive_int( + os.getenv("GRANOLA_SYNC_INTERVAL_SECONDS"), + DEFAULT_SYNC_INTERVAL_SECONDS, + ), + "enabled": env_flag_enabled("GRANOLA_ETL_ENABLED", default=False), + "no_delivery": True, +} + + +@dataclass +class Input: + """Runtime options for a manual Granola sync workflow run.""" + + since: str | None = None + limit: int = DEFAULT_PAGE_SIZE + max_notes: int | None = None + include_transcripts: bool = True + watermark_overlap_seconds: int = DEFAULT_WATERMARK_OVERLAP_SECONDS + metadata: dict[str, Any] = field(default_factory=dict) + + +class GranolaSyncClient(Protocol): + """Small adapter protocol used by the Granola ETL workflow.""" + + async def list_notes( + self, + page_size: int = DEFAULT_PAGE_SIZE, + cursor: str | None = None, + created_before: str | None = None, + created_after: str | None = None, + updated_after: str | None = None, + ) -> dict[str, Any]: ... + + async def get_note( + self, note_id: str, include_transcript: bool = False + ) -> dict[str, Any]: ... + + +class GranolaToolClient: + """Granola client backed by the workflow tool bridge.""" + + def __init__(self, ctx: WorkflowContext) -> None: + self._ctx = ctx + + async def list_notes( + self, + page_size: int = DEFAULT_PAGE_SIZE, + cursor: str | None = None, + created_before: str | None = None, + created_after: str | None = None, + updated_after: str | None = None, + ) -> dict[str, Any]: + result = await self._ctx.call_tool( + "granola", + "list_notes", + { + "page_size": page_size, + "cursor": cursor, + "created_before": created_before, + "created_after": created_after, + "updated_after": updated_after, + }, + ) + return result if isinstance(result, dict) else {} + + async def get_note( + self, note_id: str, include_transcript: bool = False + ) -> dict[str, Any]: + result = await self._ctx.call_tool( + "granola", + "get_note", + {"note_id": note_id, "include_transcript": include_transcript}, + ) + return result if isinstance(result, dict) else {} + + +def _client(ctx: WorkflowContext) -> GranolaSyncClient: + return GranolaToolClient(ctx) + + +def _parse_datetime(value: str | None) -> dt.datetime | None: + if not value: + return None + try: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + + +def _source_datetime(payload: dict[str, Any], *keys: str) -> dt.datetime | None: + for key in keys: + parsed = _parse_datetime(str(payload.get(key) or "")) + if parsed is not None: + return parsed + return None + + +def _rfc3339(value: dt.datetime) -> str: + return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z") + + +def _text_value(value: Any) -> str: + return str(value or "") + + +def _note_url(note: dict[str, Any]) -> str: + return _text_value(note.get("url") or note.get("permalink") or note.get("web_url")) + + +def _normalized_email(value: Any) -> str: + return str(value or "").strip().lower() + + +def _access_emails(owner: dict[str, Any], attendees: list[Any]) -> list[str]: + emails: list[str] = [] + + def add(value: Any) -> None: + email = _normalized_email(value) + if email and email not in emails: + emails.append(email) + + add(owner.get("email")) + for attendee in attendees: + if isinstance(attendee, dict): + add(attendee.get("email")) + return emails + + +def _json_object(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _json_array(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _format_time(value: dt.datetime | None) -> str: + if not value: + return "unknown time" + return value.astimezone(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def _named_entry(value: Any) -> str: + if isinstance(value, dict): + name = _text_value(value.get("name") or value.get("display_name")).strip() + email = _text_value(value.get("email")).strip() + if name and email: + return f"{name} <{email}>" + return name or email + return _text_value(value).strip() + + +def _named_entries(value: Any) -> list[str]: + labels: list[str] = [] + for entry in _json_array(value): + label = _named_entry(entry) + if label and label not in labels: + labels.append(label) + return labels + + +def _content_hash(*parts: Any) -> str: + return hashlib.sha256(canonical_json(parts).encode("utf-8")).hexdigest() + + +def _workflow_run_id_to_sync_run_id(workflow_run_id: str) -> str: + safe_run_id = "".join(char if char.isalnum() else "_" for char in workflow_run_id) + return f"granola_sync_{safe_run_id}" + + +def _scope_ref(scope_id: str, reason: str | None = None) -> dict[str, str]: + result = {"scope_id": scope_id} + if reason: + result["reason"] = reason + return result + + +def _failure_reason(error: str) -> str: + lowered = error.lower() + if "rate" in lowered or "429" in lowered: + return "rate_limited" + if ( + "401" in lowered + or "403" in lowered + or "auth" in lowered + or "permission" in lowered + ): + return "permission_error" + if "database" in lowered or "postgres" in lowered: + return "write_error" + return "api_error" + + +def _transcript_text(transcript: Any) -> str: + lines: list[str] = [] + for utterance in _json_array(transcript): + if not isinstance(utterance, dict): + continue + speaker = _json_object(utterance.get("speaker")) + speaker_name = ( + _text_value(speaker.get("name")) + or _text_value(speaker.get("email")) + or _text_value(speaker.get("source")) + or "Unknown" + ) + text = _text_value(utterance.get("text")).strip() + if text: + lines.append(f"{speaker_name}: {text}") + return "\n".join(lines) + + +def _granola_context_document( + *, + note: dict[str, Any], + note_id: str, + title: str, + owner: dict[str, Any], + attendees: list[Any], + access_emails: list[str], + calendar_event: dict[str, Any], + transcript: list[Any], + transcript_text: str, + summary_markdown: str, + summary_text: str, + source_created_at: dt.datetime | None, + source_updated_at: dt.datetime | None, +) -> dict[str, Any]: + owner_id = _text_value(owner.get("id") or owner.get("user_id")) + owner_email = _text_value(owner.get("email")) + owner_name = _text_value(owner.get("name") or owner.get("display_name")) + owner_label = _named_entry(owner) + attendee_labels = _named_entries(attendees) + document_title = title.strip() or "Untitled Granola note" + url = _note_url(note) + summary = summary_markdown.strip() or summary_text.strip() + + lines = [ + f"# {document_title}", + "", + "- Source: Granola", + f"- Created: {_format_time(source_created_at)}", + f"- Updated: {_format_time(source_updated_at)}", + ] + if owner_label: + lines.append(f"- Owner: {owner_label}") + if attendee_labels: + lines.append(f"- Attendees: {', '.join(attendee_labels)}") + if url: + lines.append(f"- URL: {url}") + if summary: + lines.extend(["", "## Summary", summary]) + if transcript_text.strip(): + lines.extend(["", "## Transcript", transcript_text.strip()]) + + body = "\n".join(lines).strip() + metadata = { + "source": "granola", + "note_id": note_id, + "owner_id": owner_id, + "owner_email": owner_email, + "owner_name": owner_name, + "access_emails": access_emails, + "attendees": attendees, + "attendee_labels": attendee_labels, + "calendar_event": calendar_event, + "transcript_payload": transcript, + "has_summary": bool(summary), + "has_transcript": bool(transcript_text.strip()), + "raw_payload": note, + } + return { + "document_id": f"granola:note:{note_id}", + "note_id": note_id, + "title": document_title, + "body": body, + "url": url, + "owner_id": owner_id, + "owner_email": owner_email, + "owner_name": owner_name, + "access_emails": access_emails, + "attendee_labels": attendee_labels, + "occurred_at": source_created_at or source_updated_at, + "source_updated_at": source_updated_at, + "content_hash": _content_hash(document_title, body, url, metadata), + "metadata": metadata, + } + + +async def _load_checkpoint(pool, scope_id: str) -> dict[str, Any] | None: + row = await pool.fetchrow( + "SELECT watermark_time, last_error FROM granola_sync_checkpoints " + "WHERE scope_id = $1", + scope_id, + ) + return dict(row) if row else None + + +async def _update_checkpoint_success( + pool, + *, + scope_id: str, + watermark_time: dt.datetime | None, + run_id: str, +) -> None: + await pool.execute( + "INSERT INTO granola_sync_checkpoints (" + "scope_id, watermark_time, last_run_id, last_success_at, last_error, updated_at" + ") VALUES ($1, $2, $3, NOW(), '', NOW()) " + "ON CONFLICT (scope_id) DO UPDATE SET " + "watermark_time = COALESCE(EXCLUDED.watermark_time, " + "granola_sync_checkpoints.watermark_time), " + "last_run_id = EXCLUDED.last_run_id, " + "last_success_at = NOW(), " + "last_error = '', " + "updated_at = NOW()", + scope_id, + watermark_time, + run_id, + ) + + +async def _update_checkpoint_failure( + pool, + *, + scope_id: str, + run_id: str, + error: str, +) -> None: + await pool.execute( + "INSERT INTO granola_sync_checkpoints (" + "scope_id, last_run_id, last_error, updated_at" + ") VALUES ($1, $2, $3, NOW()) " + "ON CONFLICT (scope_id) DO UPDATE SET " + "last_run_id = EXCLUDED.last_run_id, " + "last_error = EXCLUDED.last_error, " + "updated_at = NOW()", + scope_id, + run_id, + error, + ) + + +async def _emit_checkpoint_metrics(pool) -> None: + """Publish Granola workspace checkpoint health for the ETL overview.""" + row = await pool.fetchrow( + "SELECT COUNT(*) AS active_scopes, " + "COUNT(*) FILTER (WHERE last_error <> '') AS failed_scopes, " + "COALESCE(" + " EXTRACT(EPOCH FROM NOW() - MIN(last_success_at) " + " FILTER (WHERE last_success_at IS NOT NULL)" + " ), " + " 0" + ") AS freshness_seconds " + "FROM granola_sync_checkpoints" + ) + set_etl_active_scopes("granola", int(row["active_scopes"] or 0) if row else 0) + set_etl_failed_scopes("granola", int(row["failed_scopes"] or 0) if row else 0) + set_etl_scope_sync_freshness_seconds( + "granola", + float(row["freshness_seconds"] or 0.0) if row else 0.0, + ) + + +async def _record_run_start( + pool, + *, + run_id: str, + workflow_run_id: str, + scopes_requested: list[dict[str, str]], + metadata: dict[str, Any], +) -> None: + await pool.execute( + "INSERT INTO granola_sync_runs (" + "run_id, workflow_run_id, mode, status, scopes_requested, metadata" + ") VALUES ($1, $2, 'incremental', 'running', $3::jsonb, $4::jsonb) " + "ON CONFLICT (run_id) DO UPDATE SET " + "workflow_run_id = EXCLUDED.workflow_run_id, " + "status = 'running', " + "scopes_requested = EXCLUDED.scopes_requested, " + "scopes_synced = '[]'::jsonb, " + "scopes_failed = '[]'::jsonb, " + "notes_seen = 0, " + "notes_upserted = 0, " + "transcripts_seen = 0, " + "transcripts_upserted = 0, " + "finished_at = NULL, " + "error_text = '', " + "metadata = EXCLUDED.metadata", + run_id, + workflow_run_id, + canonical_json(scopes_requested), + canonical_json(metadata), + ) + + +async def _record_run_finish( + pool, + *, + run_id: str, + status: str, + scopes_synced: list[dict[str, str]], + scopes_failed: list[dict[str, str]], + counts: dict[str, int], + error_text: str = "", +) -> None: + await pool.execute( + "UPDATE granola_sync_runs SET " + "status = $2, scopes_synced = $3::jsonb, scopes_failed = $4::jsonb, " + "notes_seen = $5, notes_upserted = $6, transcripts_seen = $7, " + "transcripts_upserted = $8, finished_at = NOW(), error_text = $9 " + "WHERE run_id = $1", + run_id, + status, + canonical_json(scopes_synced), + canonical_json(scopes_failed), + counts.get("notes_seen", 0), + counts.get("notes_upserted", 0), + counts.get("transcripts_seen", 0), + counts.get("transcripts_upserted", 0), + error_text, + ) + + +async def _upsert_context_document(pool, document: dict[str, Any]) -> None: + await pool.execute( + "INSERT INTO granola_context_documents (" + "document_id, note_id, title, body, url, owner_id, owner_email, owner_name, " + "access_emails, attendee_labels, occurred_at, source_updated_at, content_hash, " + "metadata, updated_at" + ") VALUES (" + "$1, $2, $3, $4, $5, $6, $7, $8, $9::text[], $10::text[], $11, $12, $13, " + "$14::jsonb, NOW()" + ") ON CONFLICT (document_id) DO UPDATE SET " + "note_id = EXCLUDED.note_id, " + "title = EXCLUDED.title, " + "body = EXCLUDED.body, " + "url = EXCLUDED.url, " + "owner_id = EXCLUDED.owner_id, " + "owner_email = EXCLUDED.owner_email, " + "owner_name = EXCLUDED.owner_name, " + "access_emails = EXCLUDED.access_emails, " + "attendee_labels = EXCLUDED.attendee_labels, " + "occurred_at = EXCLUDED.occurred_at, " + "source_updated_at = EXCLUDED.source_updated_at, " + "content_hash = EXCLUDED.content_hash, " + "metadata = EXCLUDED.metadata, " + "updated_at = NOW()", + document["document_id"], + document["note_id"], + document["title"], + document["body"], + document["url"], + document["owner_id"], + document["owner_email"], + document["owner_name"], + document["access_emails"], + document["attendee_labels"], + document["occurred_at"], + document["source_updated_at"], + document["content_hash"], + canonical_json(document["metadata"]), + ) + + +async def _upsert_note( + pool, + *, + note: dict[str, Any], + run_id: str, +) -> tuple[dt.datetime | None, bool]: + note_id = _text_value(note.get("id") or note.get("note_id")) + owner = _json_object(note.get("owner")) + attendees = _json_array(note.get("attendees")) + access_emails = _access_emails(owner, attendees) + calendar_event = _json_object(note.get("calendar_event")) + transcript = _json_array(note.get("transcript")) + transcript_text = _transcript_text(transcript) + summary_markdown = _text_value(note.get("summary_markdown")) + summary_text = _text_value(note.get("summary_text")) + title = _text_value(note.get("title")) + content_text = "\n".join( + part + for part in (title, summary_markdown, summary_text, transcript_text) + if part.strip() + ) + source_created_at = _source_datetime(note, "created_at", "createdAt") + source_updated_at = ( + _source_datetime(note, "updated_at", "updatedAt") or source_created_at + ) + context_document = _granola_context_document( + note=note, + note_id=note_id, + title=title, + owner=owner, + attendees=attendees, + access_emails=access_emails, + calendar_event=calendar_event, + transcript=transcript, + transcript_text=transcript_text, + summary_markdown=summary_markdown, + summary_text=summary_text, + source_created_at=source_created_at, + source_updated_at=source_updated_at, + ) + await pool.execute( + "INSERT INTO granola_sync_notes (" + "note_id, title, owner_id, owner_email, owner_name, attendees, access_emails, " + "calendar_event, summary_markdown, summary_text, transcript_text, transcript_payload, " + "url, content_text, content_hash, source_created_at, source_updated_at, raw_payload, " + "source_run_id, last_seen_at, last_error, updated_at" + ") VALUES (" + "$1, $2, $3, $4, $5, $6::jsonb, $7::text[], $8::jsonb, $9, $10, " + "$11, $12::jsonb, $13, $14, $15, $16, $17, $18::jsonb, $19, NOW(), '', NOW()" + ") ON CONFLICT (note_id) DO UPDATE SET " + "title = EXCLUDED.title, " + "owner_id = EXCLUDED.owner_id, " + "owner_email = EXCLUDED.owner_email, " + "owner_name = EXCLUDED.owner_name, " + "attendees = EXCLUDED.attendees, " + "access_emails = EXCLUDED.access_emails, " + "calendar_event = EXCLUDED.calendar_event, " + "summary_markdown = EXCLUDED.summary_markdown, " + "summary_text = EXCLUDED.summary_text, " + "transcript_text = EXCLUDED.transcript_text, " + "transcript_payload = EXCLUDED.transcript_payload, " + "url = EXCLUDED.url, " + "content_text = EXCLUDED.content_text, " + "content_hash = EXCLUDED.content_hash, " + "source_created_at = EXCLUDED.source_created_at, " + "source_updated_at = EXCLUDED.source_updated_at, " + "raw_payload = EXCLUDED.raw_payload, " + "source_run_id = EXCLUDED.source_run_id, " + "last_seen_at = NOW(), " + "last_error = '', " + "updated_at = NOW()", + note_id, + title, + _text_value(owner.get("id") or owner.get("user_id")), + _text_value(owner.get("email")), + _text_value(owner.get("name") or owner.get("display_name")), + canonical_json(attendees), + access_emails, + canonical_json(calendar_event), + summary_markdown, + summary_text, + transcript_text, + canonical_json(transcript), + _text_value(note.get("url") or note.get("permalink")), + content_text, + _content_hash(content_text), + source_created_at, + source_updated_at, + canonical_json(note), + run_id, + ) + await _upsert_context_document(pool, context_document) + return source_updated_at, bool(transcript) + + +async def _sync_workspace( + *, + client: GranolaSyncClient, + pool, + page_size: int, + updated_after: dt.datetime | None, + max_notes: int | None, + include_transcripts: bool, + run_id: str, +) -> tuple[int, int, int, int, dt.datetime | None]: + seen = 0 + upserted = 0 + transcripts_seen = 0 + transcripts_upserted = 0 + watermark: dt.datetime | None = None + cursor: str | None = None + updated_after_arg = _rfc3339(updated_after) if updated_after else None + + while True: + page = await client.list_notes( + page_size=page_size, + cursor=cursor, + updated_after=updated_after_arg, + ) + notes = [ + note + for note in page.get("notes", []) or [] + if isinstance(note, dict) and (note.get("id") or note.get("note_id")) + ] + if max_notes is not None: + notes = notes[: max(max_notes - seen, 0)] + seen += len(notes) + record_etl_items_seen("granola", WORKSPACE_SCOPE, "note", len(notes)) + + for note_ref in notes: + note_id = _text_value(note_ref.get("id") or note_ref.get("note_id")) + note = ( + await client.get_note(note_id, include_transcript=include_transcripts) + if note_id + else note_ref + ) + if not isinstance(note, dict): + note = note_ref + note.setdefault("id", note_id) + source_updated_at, has_transcript = await _upsert_note( + pool, note=note, run_id=run_id + ) + upserted += 1 + record_etl_items_upserted("granola", WORKSPACE_SCOPE, "note", 1) + if include_transcripts: + transcripts_seen += 1 + if has_transcript: + transcripts_upserted += 1 + record_etl_items_upserted( + "granola", WORKSPACE_SCOPE, "transcript", 1 + ) + if source_updated_at and ( + watermark is None or source_updated_at > watermark + ): + watermark = source_updated_at + + if max_notes is not None and seen >= max_notes: + break + cursor = ( + _text_value(page.get("cursor") or page.get("next_cursor")).strip() or None + ) + if not page.get("hasMore") or not cursor: + break + + return seen, upserted, transcripts_seen, transcripts_upserted, watermark + + +async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: + """Sync changed Granola notes into raw sync tables.""" + if not env_flag_enabled("GRANOLA_ETL_ENABLED", default=False): + ctx.log("granola_sync_skipped_disabled") + return {"status": "skipped", "reason": "granola_etl_disabled"} + + page_size = min(positive_int(inp.limit, DEFAULT_PAGE_SIZE), DEFAULT_PAGE_SIZE) + overlap_seconds = max(int(inp.watermark_overlap_seconds), 0) + run_id = _workflow_run_id_to_sync_run_id(ctx.run_id) + scopes_requested = [_scope_ref(WORKSPACE_SCOPE)] + + await _record_run_start( + ctx._pool, + run_id=run_id, + workflow_run_id=ctx.run_id, + scopes_requested=scopes_requested, + metadata={ + **inp.metadata, + "page_size": page_size, + "max_notes": inp.max_notes, + "include_transcripts": inp.include_transcripts, + }, + ) + + client = _client(ctx) + explicit_since = _parse_datetime(inp.since) + checkpoint = await _load_checkpoint(ctx._pool, WORKSPACE_SCOPE) + watermark = explicit_since + if watermark is None and checkpoint and checkpoint.get("watermark_time"): + watermark = checkpoint["watermark_time"].astimezone(dt.timezone.utc) + if watermark is not None: + watermark = watermark - dt.timedelta(seconds=overlap_seconds) + + synced: list[dict[str, str]] = [] + failed: list[dict[str, str]] = [] + counts = { + "notes_seen": 0, + "notes_upserted": 0, + "transcripts_seen": 0, + "transcripts_upserted": 0, + } + try: + ( + counts["notes_seen"], + counts["notes_upserted"], + counts["transcripts_seen"], + counts["transcripts_upserted"], + successful_watermark, + ) = await _sync_workspace( + client=client, + pool=ctx._pool, + page_size=page_size, + updated_after=watermark, + max_notes=inp.max_notes, + include_transcripts=inp.include_transcripts, + run_id=run_id, + ) + await _update_checkpoint_success( + ctx._pool, + scope_id=WORKSPACE_SCOPE, + watermark_time=successful_watermark, + run_id=run_id, + ) + synced.append(_scope_ref(WORKSPACE_SCOPE)) + except Exception as exc: + error = str(exc) + failed.append(_scope_ref(WORKSPACE_SCOPE, error)) + record_etl_items_failed( + "granola", WORKSPACE_SCOPE, "scope", _failure_reason(error) + ) + await _update_checkpoint_failure( + ctx._pool, + scope_id=WORKSPACE_SCOPE, + run_id=run_id, + error=error, + ) + ctx.log("granola_sync_scope_failed", scope_id=WORKSPACE_SCOPE, error=error) + + status = "completed" if not failed else "failed" + error_text = "" if not failed else "Granola workspace sync failed" + await _record_run_finish( + ctx._pool, + run_id=run_id, + status=status, + scopes_synced=synced, + scopes_failed=failed, + counts=counts, + error_text=error_text, + ) + await _emit_checkpoint_metrics(ctx._pool) + + return {"status": status, "run_id": run_id, **counts} diff --git a/workflows/tests/test_granola_sync.py b/workflows/tests/test_granola_sync.py new file mode 100644 index 000000000..1a7373227 --- /dev/null +++ b/workflows/tests/test_granola_sync.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import asyncio +import datetime as dt +import importlib +import json +import sys +import types +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + + +def _install_workflow_stubs() -> None: + api_module = sys.modules.get("api") or types.ModuleType("api") + runtime_control = sys.modules.get("api.runtime_control") or types.ModuleType( + "api.runtime_control" + ) + runtime_control.canonical_json = lambda value: json.dumps(value, sort_keys=True) + + etl_metrics = types.ModuleType("workflows.etl_metrics") + for name in ( + "record_etl_items_failed", + "record_etl_items_seen", + "record_etl_items_upserted", + "set_etl_active_scopes", + "set_etl_failed_scopes", + "set_etl_scope_sync_freshness_seconds", + ): + setattr(etl_metrics, name, lambda *_args, **_kwargs: None) + + workflow_engine = types.ModuleType("api.workflow_engine") + workflow_engine.WorkflowContext = object + + slack_shared = types.ModuleType("workflows.slack.shared") + slack_shared.env_flag_enabled = lambda _name, default=True: default + slack_shared.positive_int = lambda value, default: ( + int(value) if value is not None and int(value) > 0 else default + ) + + api_module.runtime_control = runtime_control + api_module.workflow_engine = workflow_engine + sys.modules.setdefault("api", api_module) + sys.modules["api.runtime_control"] = runtime_control + sys.modules["api.workflow_engine"] = workflow_engine + sys.modules["workflows.etl_metrics"] = etl_metrics + sys.modules["workflows.slack.shared"] = slack_shared + + +def _load(name: str): + _install_workflow_stubs() + return importlib.import_module(name) + + +def test_granola_transcript_text_uses_speaker_identity(): + granola = _load("workflows.granola_sync") + + text = granola._transcript_text( + [ + {"speaker": {"name": "Alice"}, "text": "Hello"}, + {"speaker": {"email": "bob@example.com"}, "text": "Ship it"}, + {"speaker": {}, "text": ""}, + ] + ) + + assert text == "Alice: Hello\nbob@example.com: Ship it" + + +def test_granola_access_emails_include_owner_and_attendees_once(): + granola = _load("workflows.granola_sync") + + emails = granola._access_emails( + {"email": "Alice@Example.com "}, + [ + {"email": "bob@example.com"}, + {"email": "alice@example.com"}, + {"name": "No Email"}, + ], + ) + + assert emails == ["alice@example.com", "bob@example.com"] + + +def test_granola_context_document_is_user_scoped_to_owner_and_attendees(): + granola = _load("workflows.granola_sync") + note = { + "id": "not_123", + "title": "Launch review", + "web_url": "https://app.granola.ai/notes/not_123", + "owner": {"id": "usr_1", "name": "Alice", "email": "Alice@Example.com"}, + "attendees": [ + {"name": "Bob", "email": "bob@example.com"}, + {"name": "Alice", "email": "alice@example.com"}, + ], + "summary_markdown": "We agreed to ship.", + "transcript": [ + {"speaker": {"name": "Alice"}, "text": "Let's ship."}, + ], + "created_at": "2026-07-01T10:00:00Z", + "updated_at": "2026-07-01T10:30:00Z", + } + owner = granola._json_object(note["owner"]) + attendees = granola._json_array(note["attendees"]) + transcript = granola._json_array(note["transcript"]) + access_emails = granola._access_emails(owner, attendees) + + document = granola._granola_context_document( + note=note, + note_id="not_123", + title="Launch review", + owner=owner, + attendees=attendees, + access_emails=access_emails, + calendar_event={}, + transcript=transcript, + transcript_text=granola._transcript_text(transcript), + summary_markdown="We agreed to ship.", + summary_text="", + source_created_at=dt.datetime(2026, 7, 1, 10, tzinfo=dt.UTC), + source_updated_at=dt.datetime(2026, 7, 1, 10, 30, tzinfo=dt.UTC), + ) + + assert document["document_id"] == "granola:note:not_123" + assert document["note_id"] == "not_123" + assert document["url"] == "https://app.granola.ai/notes/not_123" + assert document["access_emails"] == ["alice@example.com", "bob@example.com"] + assert document["attendee_labels"] == [ + "Bob ", + "Alice ", + ] + assert "## Summary\nWe agreed to ship." in document["body"] + assert "## Transcript\nAlice: Let's ship." in document["body"] + assert document["metadata"]["access_emails"] == [ + "alice@example.com", + "bob@example.com", + ] + assert document["metadata"]["has_transcript"] is True + + +def test_granola_checkpoint_metrics_use_workspace_checkpoint_health(monkeypatch): + granola = _load("workflows.granola_sync") + calls: dict[str, list[tuple]] = { + "active": [], + "failed": [], + "freshness": [], + } + monkeypatch.setattr( + granola, + "set_etl_active_scopes", + lambda *args: calls["active"].append(args), + ) + monkeypatch.setattr( + granola, + "set_etl_failed_scopes", + lambda *args: calls["failed"].append(args), + ) + monkeypatch.setattr( + granola, + "set_etl_scope_sync_freshness_seconds", + lambda *args: calls["freshness"].append(args), + ) + + class FakePool: + def __init__(self) -> None: + self.fetchrow_calls: list[tuple[str, tuple]] = [] + + async def fetchrow(self, query, *args): + self.fetchrow_calls.append((query, args)) + return { + "active_scopes": 1, + "failed_scopes": 1, + "freshness_seconds": 123.5, + } + + pool = FakePool() + asyncio.run(granola._emit_checkpoint_metrics(pool)) + + assert len(pool.fetchrow_calls) == 1 + assert "FROM granola_sync_checkpoints" in pool.fetchrow_calls[0][0] + assert calls == { + "active": [("granola", 1)], + "failed": [("granola", 1)], + "freshness": [("granola", 123.5)], + } + + +def test_granola_sync_emits_checkpoint_metrics_after_a_failed_attempt(monkeypatch): + granola = _load("workflows.granola_sync") + monkeypatch.setattr(granola, "env_flag_enabled", lambda *_args, **_kwargs: True) + monkeypatch.setattr(granola, "_client", lambda _ctx: object()) + + async def noop(*_args, **_kwargs): + return None + + async def fail_sync(*_args, **_kwargs): + raise RuntimeError("Granola API unavailable") + + emitted: list[object] = [] + + async def record_metrics(pool): + emitted.append(pool) + + monkeypatch.setattr(granola, "_record_run_start", noop) + monkeypatch.setattr(granola, "_load_checkpoint", noop) + monkeypatch.setattr(granola, "_sync_workspace", fail_sync) + monkeypatch.setattr(granola, "_update_checkpoint_failure", noop) + monkeypatch.setattr(granola, "_record_run_finish", noop) + monkeypatch.setattr(granola, "_emit_checkpoint_metrics", record_metrics) + + pool = object() + context = types.SimpleNamespace( + run_id="run-123", _pool=pool, log=lambda *_args, **_kwargs: None + ) + + result = asyncio.run(granola.handler(granola.Input(), context)) + + assert result["status"] == "failed" + assert emitted == [pool] From 4c0b8e75e6ede4ff85e0a80ff3c14df0d33e4210 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Thu, 9 Jul 2026 12:39:06 -0700 Subject: [PATCH 120/198] feat: add Attio sync workflow Adds scheduled Attio meeting/transcript ingest and projects synced meetings into company context documents. --- .../migrations/0041_attio_sync_tables.sql | 131 ++++ workflows/attio_sync.py | 733 ++++++++++++++++++ workflows/company_context_documents.py | 164 ++++ workflows/tests/test_attio_sync.py | 97 +++ ...t_company_context_documents_attachments.py | 40 + 5 files changed, 1165 insertions(+) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0041_attio_sync_tables.sql create mode 100644 workflows/attio_sync.py create mode 100644 workflows/tests/test_attio_sync.py diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0041_attio_sync_tables.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0041_attio_sync_tables.sql new file mode 100644 index 000000000..442a374fa --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0041_attio_sync_tables.sql @@ -0,0 +1,131 @@ +create table if not exists attio_sync_runs ( + run_id text primary key, + workflow_run_id text, + mode text not null default 'incremental', + status text not null, + scopes_requested jsonb not null default '[]'::jsonb, + scopes_synced jsonb not null default '[]'::jsonb, + scopes_failed jsonb not null default '[]'::jsonb, + meetings_seen integer not null default 0, + meetings_upserted integer not null default 0, + call_recordings_seen integer not null default 0, + transcripts_upserted integer not null default 0, + started_at timestamptz not null default now(), + finished_at timestamptz, + error_text text not null default '', + metadata jsonb not null default '{}'::jsonb +); + +create index if not exists idx_attio_sync_runs_started + on attio_sync_runs (started_at desc); + +create table if not exists attio_sync_meetings ( + meeting_id text primary key, + title text not null default '', + description text not null default '', + url text not null default '', + linked_records jsonb not null default '[]'::jsonb, + participants jsonb not null default '[]'::jsonb, + organizer_id text not null default '', + organizer_name text not null default '', + organizer_email text not null default '', + call_recording_ids jsonb not null default '[]'::jsonb, + transcript_text text not null default '', + transcript_payload jsonb not null default '[]'::jsonb, + content_text text not null default '', + content_hash text not null default '', + started_at timestamptz, + ended_at timestamptz, + source_created_at timestamptz, + source_updated_at timestamptz, + raw_payload jsonb not null default '{}'::jsonb, + source_run_id text references attio_sync_runs(run_id) on delete set null, + first_seen_at timestamptz not null default now(), + last_seen_at timestamptz not null default now(), + last_error text not null default '', + updated_at timestamptz not null default now() +); + +create index if not exists idx_attio_sync_meetings_source_updated + on attio_sync_meetings (source_updated_at desc); + +create index if not exists idx_attio_sync_meetings_time + on attio_sync_meetings (started_at desc); + +create index if not exists idx_attio_sync_meetings_text + on attio_sync_meetings + using gin (to_tsvector('english', coalesce(content_text, ''))); + +create table if not exists attio_sync_checkpoints ( + scope_id text primary key, + watermark_time timestamptz, + last_run_id text references attio_sync_runs(run_id) on delete set null, + last_success_at timestamptz, + last_error text not null default '', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +do $$ +declare + role_name text; +begin + foreach role_name in array array[ + 'centaur_slack_reader', + 'centaur_slack_admin', + 'centaur_readonly' + ] loop + if exists (select 1 from pg_roles where rolname = role_name) then + execute format( + 'grant select on %s to %I', + 'attio_sync_runs, attio_sync_meetings, attio_sync_checkpoints', + role_name + ); + end if; + end loop; +end $$; + +alter table attio_sync_runs enable row level security; +alter table attio_sync_meetings enable row level security; +alter table attio_sync_checkpoints enable row level security; + +drop policy if exists centaur_attio_runs_admin_select on attio_sync_runs; +drop policy if exists centaur_attio_runs_reader_select on attio_sync_runs; +create policy centaur_attio_runs_reader_select + on attio_sync_runs for select to centaur_slack_reader + using (false); +drop policy if exists centaur_readonly_attio_sync_runs_select on attio_sync_runs; +create policy centaur_readonly_attio_sync_runs_select + on attio_sync_runs for select to centaur_readonly using (true); + +drop policy if exists centaur_attio_meetings_admin_select on attio_sync_meetings; +drop policy if exists centaur_attio_meetings_reader_select on attio_sync_meetings; +create policy centaur_attio_meetings_reader_select + on attio_sync_meetings for select to centaur_slack_reader + using (false); +drop policy if exists centaur_readonly_attio_sync_meetings_select + on attio_sync_meetings; +create policy centaur_readonly_attio_sync_meetings_select + on attio_sync_meetings for select to centaur_readonly using (true); + +drop policy if exists centaur_attio_checkpoints_admin_select on attio_sync_checkpoints; +drop policy if exists centaur_attio_checkpoints_reader_select on attio_sync_checkpoints; +create policy centaur_attio_checkpoints_reader_select + on attio_sync_checkpoints for select to centaur_slack_reader + using (false); +drop policy if exists centaur_readonly_attio_sync_checkpoints_select + on attio_sync_checkpoints; +create policy centaur_readonly_attio_sync_checkpoints_select + on attio_sync_checkpoints for select to centaur_readonly using (true); + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'centaur_slack_admin') then + create policy centaur_attio_runs_admin_select + on attio_sync_runs for select to centaur_slack_admin using (true); + create policy centaur_attio_meetings_admin_select + on attio_sync_meetings for select to centaur_slack_admin using (true); + create policy centaur_attio_checkpoints_admin_select + on attio_sync_checkpoints for select to centaur_slack_admin using (true); + end if; +end $$; diff --git a/workflows/attio_sync.py b/workflows/attio_sync.py new file mode 100644 index 000000000..8783dbc10 --- /dev/null +++ b/workflows/attio_sync.py @@ -0,0 +1,733 @@ +"""Workflow: sync Attio meetings and call transcripts into Postgres.""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import os +from dataclasses import dataclass, field +from typing import Any, Protocol + +from api.runtime_control import canonical_json +from workflows.etl_metrics import ( + record_etl_items_failed, + record_etl_items_seen, + record_etl_items_upserted, +) +from api.workflow_engine import WorkflowContext +from workflows.slack.shared import env_flag_enabled, positive_int + +WORKFLOW_NAME = "attio_sync" +DEFAULT_SYNC_INTERVAL_SECONDS = 4 * 60 * 60 +DEFAULT_PAGE_SIZE = 50 +DEFAULT_WATERMARK_OVERLAP_SECONDS = 5 * 60 +MEETINGS_SCOPE = "meetings" + + +SCHEDULE = { + "schedule_id": "attio_sync", + "interval_seconds": positive_int( + os.getenv("ATTIO_SYNC_INTERVAL_SECONDS"), + DEFAULT_SYNC_INTERVAL_SECONDS, + ), + "enabled": env_flag_enabled("ATTIO_ETL_ENABLED", default=False), + "no_delivery": True, +} + + +@dataclass +class Input: + """Runtime options for a manual Attio sync workflow run.""" + + since: str | None = None + limit: int = DEFAULT_PAGE_SIZE + max_meetings: int | None = None + include_transcripts: bool = True + watermark_overlap_seconds: int = DEFAULT_WATERMARK_OVERLAP_SECONDS + metadata: dict[str, Any] = field(default_factory=dict) + + +class AttioSyncClient(Protocol): + """Small adapter protocol used by the Attio ETL workflow.""" + + async def list_meetings( + self, + limit: int = DEFAULT_PAGE_SIZE, + cursor: str | None = None, + linked_object: str | None = None, + linked_record_id: str | None = None, + participants: list[str] | str | None = None, + sort: str | None = None, + ends_from: str | None = None, + starts_before: str | None = None, + timezone: str | None = None, + ) -> dict[str, Any]: ... + + async def get_meeting(self, meeting_id: str) -> dict[str, Any]: ... + + async def list_call_recordings( + self, + meeting_id: str, + limit: int = DEFAULT_PAGE_SIZE, + cursor: str | None = None, + ) -> dict[str, Any]: ... + + async def get_call_transcript( + self, + meeting_id: str, + call_recording_id: str, + cursor: str | None = None, + ) -> dict[str, Any]: ... + + +class AttioToolClient: + """Attio client backed by the workflow tool bridge.""" + + def __init__(self, ctx: WorkflowContext) -> None: + self._ctx = ctx + + async def list_meetings( + self, + limit: int = DEFAULT_PAGE_SIZE, + cursor: str | None = None, + linked_object: str | None = None, + linked_record_id: str | None = None, + participants: list[str] | str | None = None, + sort: str | None = None, + ends_from: str | None = None, + starts_before: str | None = None, + timezone: str | None = None, + ) -> dict[str, Any]: + result = await self._ctx.call_tool( + "attio", + "list_meetings", + { + "limit": limit, + "cursor": cursor, + "linked_object": linked_object, + "linked_record_id": linked_record_id, + "participants": participants, + "sort": sort, + "ends_from": ends_from, + "starts_before": starts_before, + "timezone": timezone, + }, + ) + return result if isinstance(result, dict) else {} + + async def get_meeting(self, meeting_id: str) -> dict[str, Any]: + result = await self._ctx.call_tool( + "attio", + "get_meeting", + {"meeting_id": meeting_id}, + ) + return result if isinstance(result, dict) else {} + + async def list_call_recordings( + self, + meeting_id: str, + limit: int = DEFAULT_PAGE_SIZE, + cursor: str | None = None, + ) -> dict[str, Any]: + result = await self._ctx.call_tool( + "attio", + "list_call_recordings", + {"meeting_id": meeting_id, "limit": limit, "cursor": cursor}, + ) + return result if isinstance(result, dict) else {} + + async def get_call_transcript( + self, + meeting_id: str, + call_recording_id: str, + cursor: str | None = None, + ) -> dict[str, Any]: + result = await self._ctx.call_tool( + "attio", + "get_call_transcript", + { + "meeting_id": meeting_id, + "call_recording_id": call_recording_id, + "cursor": cursor, + }, + ) + return result if isinstance(result, dict) else {} + + +def _client(ctx: WorkflowContext) -> AttioSyncClient: + return AttioToolClient(ctx) + + +def _parse_datetime(value: str | None) -> dt.datetime | None: + if not value: + return None + try: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + + +def _source_datetime(payload: dict[str, Any], *keys: str) -> dt.datetime | None: + for key in keys: + parsed = _parse_datetime(str(payload.get(key) or "")) + if parsed is not None: + return parsed + return None + + +def _rfc3339(value: dt.datetime) -> str: + return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z") + + +def _text_value(value: Any) -> str: + return str(value or "") + + +def _json_object(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _json_array(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _content_hash(*parts: Any) -> str: + return hashlib.sha256(canonical_json(parts).encode("utf-8")).hexdigest() + + +def _workflow_run_id_to_sync_run_id(workflow_run_id: str) -> str: + safe_run_id = "".join(char if char.isalnum() else "_" for char in workflow_run_id) + return f"attio_sync_{safe_run_id}" + + +def _scope_ref(scope_id: str, reason: str | None = None) -> dict[str, str]: + result = {"scope_id": scope_id} + if reason: + result["reason"] = reason + return result + + +def _failure_reason(error: str) -> str: + lowered = error.lower() + if "rate" in lowered or "429" in lowered: + return "rate_limited" + if ( + "401" in lowered + or "403" in lowered + or "auth" in lowered + or "permission" in lowered + ): + return "permission_error" + if "database" in lowered or "postgres" in lowered: + return "write_error" + return "api_error" + + +def _attio_id(value: Any, *keys: str) -> str: + if isinstance(value, dict): + for key in keys: + if value.get(key): + return _text_value(value.get(key)) + for nested in ("id", "data"): + if isinstance(value.get(nested), dict): + found = _attio_id(value[nested], *keys) + if found: + return found + return _text_value(value) + + +def _page_items(page: dict[str, Any]) -> list[dict[str, Any]]: + data = page.get("data") + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + if isinstance(data, dict) and isinstance(data.get("data"), list): + return [item for item in data["data"] if isinstance(item, dict)] + for key in ("meetings", "call_recordings", "transcript", "items"): + value = page.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + +def _next_cursor(page: dict[str, Any]) -> str | None: + pagination = ( + page.get("pagination") if isinstance(page.get("pagination"), dict) else {} + ) + meta = page.get("meta") if isinstance(page.get("meta"), dict) else {} + cursor = ( + page.get("next_cursor") + or page.get("cursor") + or pagination.get("next_cursor") + or pagination.get("nextCursor") + or meta.get("next_cursor") + or meta.get("nextCursor") + ) + return _text_value(cursor).strip() or None + + +def _person(value: Any) -> tuple[str, str, str]: + obj = _json_object(value) + person_id = _attio_id(obj.get("id") or obj, "workspace_member_id", "user_id", "id") + email = _text_value(obj.get("email") or obj.get("email_address")) + name = _text_value(obj.get("name") or obj.get("display_name") or email) + return person_id, name, email + + +def _transcript_text(transcript: Any) -> str: + lines: list[str] = [] + for item in _json_array(transcript): + if not isinstance(item, dict): + continue + speaker = _json_object(item.get("speaker") or item.get("participant")) + speaker_name = ( + _text_value(speaker.get("name") or speaker.get("display_name")) + or _text_value(item.get("speaker_name")) + or "Unknown" + ) + text = _text_value( + item.get("text") or item.get("content") or item.get("transcript") + ).strip() + if text: + lines.append(f"{speaker_name}: {text}") + return "\n".join(lines) + + +async def _load_checkpoint(pool, scope_id: str) -> dict[str, Any] | None: + row = await pool.fetchrow( + "SELECT watermark_time, last_error FROM attio_sync_checkpoints " + "WHERE scope_id = $1", + scope_id, + ) + return dict(row) if row else None + + +async def _update_checkpoint_success( + pool, + *, + scope_id: str, + watermark_time: dt.datetime | None, + run_id: str, +) -> None: + await pool.execute( + "INSERT INTO attio_sync_checkpoints (" + "scope_id, watermark_time, last_run_id, last_success_at, last_error, updated_at" + ") VALUES ($1, $2, $3, NOW(), '', NOW()) " + "ON CONFLICT (scope_id) DO UPDATE SET " + "watermark_time = COALESCE(EXCLUDED.watermark_time, " + "attio_sync_checkpoints.watermark_time), " + "last_run_id = EXCLUDED.last_run_id, " + "last_success_at = NOW(), " + "last_error = '', " + "updated_at = NOW()", + scope_id, + watermark_time, + run_id, + ) + + +async def _update_checkpoint_failure( + pool, + *, + scope_id: str, + run_id: str, + error: str, +) -> None: + await pool.execute( + "INSERT INTO attio_sync_checkpoints (" + "scope_id, last_run_id, last_error, updated_at" + ") VALUES ($1, $2, $3, NOW()) " + "ON CONFLICT (scope_id) DO UPDATE SET " + "last_run_id = EXCLUDED.last_run_id, " + "last_error = EXCLUDED.last_error, " + "updated_at = NOW()", + scope_id, + run_id, + error, + ) + + +async def _record_run_start( + pool, + *, + run_id: str, + workflow_run_id: str, + scopes_requested: list[dict[str, str]], + metadata: dict[str, Any], +) -> None: + await pool.execute( + "INSERT INTO attio_sync_runs (" + "run_id, workflow_run_id, mode, status, scopes_requested, metadata" + ") VALUES ($1, $2, 'incremental', 'running', $3::jsonb, $4::jsonb) " + "ON CONFLICT (run_id) DO UPDATE SET " + "workflow_run_id = EXCLUDED.workflow_run_id, " + "status = 'running', " + "scopes_requested = EXCLUDED.scopes_requested, " + "scopes_synced = '[]'::jsonb, " + "scopes_failed = '[]'::jsonb, " + "meetings_seen = 0, " + "meetings_upserted = 0, " + "call_recordings_seen = 0, " + "transcripts_upserted = 0, " + "finished_at = NULL, " + "error_text = '', " + "metadata = EXCLUDED.metadata", + run_id, + workflow_run_id, + canonical_json(scopes_requested), + canonical_json(metadata), + ) + + +async def _record_run_finish( + pool, + *, + run_id: str, + status: str, + scopes_synced: list[dict[str, str]], + scopes_failed: list[dict[str, str]], + counts: dict[str, int], + error_text: str = "", +) -> None: + await pool.execute( + "UPDATE attio_sync_runs SET " + "status = $2, scopes_synced = $3::jsonb, scopes_failed = $4::jsonb, " + "meetings_seen = $5, meetings_upserted = $6, call_recordings_seen = $7, " + "transcripts_upserted = $8, finished_at = NOW(), error_text = $9 " + "WHERE run_id = $1", + run_id, + status, + canonical_json(scopes_synced), + canonical_json(scopes_failed), + counts.get("meetings_seen", 0), + counts.get("meetings_upserted", 0), + counts.get("call_recordings_seen", 0), + counts.get("transcripts_upserted", 0), + error_text, + ) + + +def _recording_id(recording: dict[str, Any]) -> str: + return _attio_id( + recording.get("id") or recording, "call_recording_id", "recording_id", "id" + ) + + +def _meeting_id(meeting: dict[str, Any]) -> str: + return _attio_id(meeting.get("id") or meeting, "meeting_id", "id") + + +async def _load_call_recordings( + client: AttioSyncClient, + *, + meeting_id: str, + page_size: int, +) -> list[dict[str, Any]]: + recordings: list[dict[str, Any]] = [] + cursor: str | None = None + while True: + page = await client.list_call_recordings( + meeting_id, + limit=page_size, + cursor=cursor, + ) + items = _page_items(page) + recordings.extend(items) + cursor = _next_cursor(page) + if not cursor: + break + return recordings + + +async def _load_transcript( + client: AttioSyncClient, + *, + meeting_id: str, + recording_id: str, +) -> list[dict[str, Any]]: + transcript: list[dict[str, Any]] = [] + cursor: str | None = None + while True: + page = await client.get_call_transcript( + meeting_id, + recording_id, + cursor=cursor, + ) + transcript.extend(_page_items(page)) + cursor = _next_cursor(page) + if not cursor: + break + return transcript + + +async def _upsert_meeting( + pool, + *, + meeting: dict[str, Any], + call_recordings: list[dict[str, Any]], + transcript_payload: list[dict[str, Any]], + run_id: str, +) -> dt.datetime | None: + meeting_id = _meeting_id(meeting) + title = _text_value( + meeting.get("title") or meeting.get("name") or meeting.get("summary") + ) + description = _text_value(meeting.get("description") or meeting.get("body")) + linked_records = _json_array(meeting.get("linked_records")) + participants = _json_array(meeting.get("participants") or meeting.get("attendees")) + organizer_id, organizer_name, organizer_email = _person( + meeting.get("organizer") or meeting.get("created_by") or {} + ) + transcript_text = _transcript_text(transcript_payload) + started_at = _source_datetime(meeting, "started_at", "starts_at", "start_time") + ended_at = _source_datetime(meeting, "ended_at", "ends_at", "end_time") + source_created_at = _source_datetime(meeting, "created_at", "createdAt") + source_updated_at = ( + _source_datetime(meeting, "updated_at", "updatedAt", "modified_at") + or ended_at + or started_at + or source_created_at + ) + call_recording_ids = [ + recording_id + for recording in call_recordings + if (recording_id := _recording_id(recording)) + ] + content_text = "\n".join( + part for part in (title, description, transcript_text) if part.strip() + ) + await pool.execute( + "INSERT INTO attio_sync_meetings (" + "meeting_id, title, description, url, linked_records, participants, " + "organizer_id, organizer_name, organizer_email, call_recording_ids, " + "transcript_text, transcript_payload, content_text, content_hash, " + "started_at, ended_at, source_created_at, source_updated_at, raw_payload, " + "source_run_id, last_seen_at, last_error, updated_at" + ") VALUES (" + "$1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10::jsonb, " + "$11, $12::jsonb, $13, $14, $15, $16, $17, $18, $19::jsonb, $20, " + "NOW(), '', NOW()" + ") ON CONFLICT (meeting_id) DO UPDATE SET " + "title = EXCLUDED.title, " + "description = EXCLUDED.description, " + "url = EXCLUDED.url, " + "linked_records = EXCLUDED.linked_records, " + "participants = EXCLUDED.participants, " + "organizer_id = EXCLUDED.organizer_id, " + "organizer_name = EXCLUDED.organizer_name, " + "organizer_email = EXCLUDED.organizer_email, " + "call_recording_ids = EXCLUDED.call_recording_ids, " + "transcript_text = EXCLUDED.transcript_text, " + "transcript_payload = EXCLUDED.transcript_payload, " + "content_text = EXCLUDED.content_text, " + "content_hash = EXCLUDED.content_hash, " + "started_at = EXCLUDED.started_at, " + "ended_at = EXCLUDED.ended_at, " + "source_created_at = EXCLUDED.source_created_at, " + "source_updated_at = EXCLUDED.source_updated_at, " + "raw_payload = EXCLUDED.raw_payload, " + "source_run_id = EXCLUDED.source_run_id, " + "last_seen_at = NOW(), " + "last_error = '', " + "updated_at = NOW()", + meeting_id, + title, + description, + _text_value(meeting.get("url") or meeting.get("web_url")), + canonical_json(linked_records), + canonical_json(participants), + organizer_id, + organizer_name, + organizer_email, + canonical_json(call_recording_ids), + transcript_text, + canonical_json(transcript_payload), + content_text, + _content_hash(content_text), + started_at, + ended_at, + source_created_at, + source_updated_at, + canonical_json(meeting), + run_id, + ) + return source_updated_at + + +async def _sync_meetings( + *, + client: AttioSyncClient, + pool, + page_size: int, + updated_after: dt.datetime | None, + max_meetings: int | None, + include_transcripts: bool, + run_id: str, +) -> tuple[int, int, int, int, dt.datetime | None]: + seen = 0 + upserted = 0 + recordings_seen = 0 + transcripts_upserted = 0 + watermark: dt.datetime | None = None + cursor: str | None = None + ends_from = _rfc3339(updated_after) if updated_after else None + + while True: + page = await client.list_meetings( + limit=page_size, + cursor=cursor, + ends_from=ends_from, + sort="start_asc", + ) + meetings = [meeting for meeting in _page_items(page) if _meeting_id(meeting)] + if max_meetings is not None: + meetings = meetings[: max(max_meetings - seen, 0)] + seen += len(meetings) + record_etl_items_seen("attio", MEETINGS_SCOPE, "meeting", len(meetings)) + + for meeting_ref in meetings: + meeting_id = _meeting_id(meeting_ref) + meeting = await client.get_meeting(meeting_id) + if not isinstance(meeting, dict) or not meeting: + meeting = meeting_ref + meeting.setdefault("id", {"meeting_id": meeting_id}) + call_recordings: list[dict[str, Any]] = [] + transcript_payload: list[dict[str, Any]] = [] + if include_transcripts: + call_recordings = await _load_call_recordings( + client, + meeting_id=meeting_id, + page_size=page_size, + ) + recordings_seen += len(call_recordings) + for recording in call_recordings: + recording_id = _recording_id(recording) + if not recording_id: + continue + transcript_payload.extend( + await _load_transcript( + client, + meeting_id=meeting_id, + recording_id=recording_id, + ) + ) + if transcript_payload: + transcripts_upserted += 1 + record_etl_items_upserted("attio", MEETINGS_SCOPE, "transcript", 1) + + source_updated_at = await _upsert_meeting( + pool, + meeting=meeting, + call_recordings=call_recordings, + transcript_payload=transcript_payload, + run_id=run_id, + ) + upserted += 1 + record_etl_items_upserted("attio", MEETINGS_SCOPE, "meeting", 1) + if source_updated_at and ( + watermark is None or source_updated_at > watermark + ): + watermark = source_updated_at + + if max_meetings is not None and seen >= max_meetings: + break + cursor = _next_cursor(page) + if not cursor: + break + + return seen, upserted, recordings_seen, transcripts_upserted, watermark + + +async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: + """Sync changed Attio meetings into raw sync tables.""" + if not env_flag_enabled("ATTIO_ETL_ENABLED", default=False): + ctx.log("attio_sync_skipped_disabled") + return {"status": "skipped", "reason": "attio_etl_disabled"} + + page_size = positive_int(inp.limit, DEFAULT_PAGE_SIZE) + overlap_seconds = max(int(inp.watermark_overlap_seconds), 0) + run_id = _workflow_run_id_to_sync_run_id(ctx.run_id) + scopes_requested = [_scope_ref(MEETINGS_SCOPE)] + + await _record_run_start( + ctx._pool, + run_id=run_id, + workflow_run_id=ctx.run_id, + scopes_requested=scopes_requested, + metadata={ + **inp.metadata, + "page_size": page_size, + "max_meetings": inp.max_meetings, + "include_transcripts": inp.include_transcripts, + }, + ) + + client = _client(ctx) + explicit_since = _parse_datetime(inp.since) + checkpoint = await _load_checkpoint(ctx._pool, MEETINGS_SCOPE) + watermark = explicit_since + if watermark is None and checkpoint and checkpoint.get("watermark_time"): + watermark = checkpoint["watermark_time"].astimezone(dt.timezone.utc) + if watermark is not None: + watermark = watermark - dt.timedelta(seconds=overlap_seconds) + + synced: list[dict[str, str]] = [] + failed: list[dict[str, str]] = [] + counts = { + "meetings_seen": 0, + "meetings_upserted": 0, + "call_recordings_seen": 0, + "transcripts_upserted": 0, + } + try: + ( + counts["meetings_seen"], + counts["meetings_upserted"], + counts["call_recordings_seen"], + counts["transcripts_upserted"], + successful_watermark, + ) = await _sync_meetings( + client=client, + pool=ctx._pool, + page_size=page_size, + updated_after=watermark, + max_meetings=inp.max_meetings, + include_transcripts=inp.include_transcripts, + run_id=run_id, + ) + await _update_checkpoint_success( + ctx._pool, + scope_id=MEETINGS_SCOPE, + watermark_time=successful_watermark, + run_id=run_id, + ) + synced.append(_scope_ref(MEETINGS_SCOPE)) + except Exception as exc: + error = str(exc) + failed.append(_scope_ref(MEETINGS_SCOPE, error)) + record_etl_items_failed( + "attio", MEETINGS_SCOPE, "scope", _failure_reason(error) + ) + await _update_checkpoint_failure( + ctx._pool, + scope_id=MEETINGS_SCOPE, + run_id=run_id, + error=error, + ) + ctx.log("attio_sync_scope_failed", scope_id=MEETINGS_SCOPE, error=error) + + status = "completed" if not failed else "failed" + error_text = "" if not failed else "Attio meetings sync failed" + await _record_run_finish( + ctx._pool, + run_id=run_id, + status=status, + scopes_synced=synced, + scopes_failed=failed, + counts=counts, + error_text=error_text, + ) + + return {"status": status, "run_id": run_id, **counts} diff --git a/workflows/company_context_documents.py b/workflows/company_context_documents.py index f09d1c8aa..c9c785eae 100644 --- a/workflows/company_context_documents.py +++ b/workflows/company_context_documents.py @@ -36,12 +36,14 @@ "google_drive": ("google_doc",), "google_calendar": ("calendar_event",), "linear": ("linear_issue",), + "attio": ("attio_meeting",), } COMPANY_CONTEXT_DOCUMENT_ACTIONS = ("inserted", "updated", "deleted", "noop") ETL_CHECKPOINT_TABLES = { "google_drive": "google_drive_sync_checkpoints", "google_calendar": "google_calendar_sync_checkpoints", "linear": "linear_sync_checkpoints", + "attio": "attio_sync_checkpoints", } @@ -83,6 +85,7 @@ def _env_flag_enabled(name: str, default: bool = False) -> bool: or _env_flag_enabled("GOOGLE_DRIVE_ETL_ENABLED") or _env_flag_enabled("GOOGLE_CALENDAR_ETL_ENABLED") or _env_flag_enabled("LINEAR_ETL_ENABLED") + or _env_flag_enabled("ATTIO_ETL_ENABLED") ) and _env_flag_enabled("COMPANY_CONTEXT_DOCUMENTS_ENABLED", default=True) ), @@ -222,6 +225,7 @@ def _source_enabled() -> bool: or _env_flag_enabled("GOOGLE_DRIVE_ETL_ENABLED") or _env_flag_enabled("GOOGLE_CALENDAR_ETL_ENABLED") or _env_flag_enabled("LINEAR_ETL_ENABLED") + or _env_flag_enabled("ATTIO_ETL_ENABLED") ) @@ -589,6 +593,43 @@ async def _load_changed_linear_issues( } +async def _load_changed_attio_meetings( + pool, + since: dt.datetime | None, + until: dt.datetime | None = None, +) -> dict[str, Any]: + """Find Attio meetings whose synced content changed.""" + where_sql, args = _updated_at_where( + "updated_at", + since, + until, + base_clauses=("last_error = ''",), + ) + + rows = await pool.fetch( + "SELECT meeting_id, title, description, url, linked_records, participants, " + "organizer_id, organizer_name, organizer_email, call_recording_ids, " + "transcript_text, transcript_payload, content_text, content_hash, started_at, " + "ended_at, source_created_at, source_updated_at, raw_payload, updated_at " + f"FROM attio_sync_meetings {where_sql} " + "ORDER BY source_updated_at NULLS LAST, started_at NULLS LAST, meeting_id", + *args, + ) + stats = await pool.fetchrow( + f"SELECT COUNT(*) AS changed_meetings, MAX(updated_at) AS max_updated_at " + f"FROM attio_sync_meetings {where_sql}", + *args, + ) + max_updated_at = stats["max_updated_at"] if stats else None + if isinstance(max_updated_at, dt.datetime): + max_updated_at = max_updated_at.astimezone(dt.timezone.utc) + return { + "meetings": list(rows), + "changed_meetings": int(stats["changed_meetings"] or 0) if stats else 0, + "max_updated_at": max_updated_at, + } + + async def _load_linear_issue_comments(pool, issue_id: str) -> list[Any]: """Load comments to embed in one Linear issue context document.""" return list( @@ -1269,6 +1310,94 @@ def _linear_issue_document(row: Any, comments: list[Any]) -> dict[str, Any] | No } +def _named_entries(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + names: list[str] = [] + for entry in value: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or entry.get("email") or "").strip() + if name: + names.append(name) + return names + + +def _attio_meeting_document(row: Any) -> dict[str, Any] | None: + """Render one synced Attio meeting into a context document.""" + meeting_id = str(row["meeting_id"] or "").strip() + if not meeting_id: + return None + + title = str(row["title"] or "Untitled Attio meeting").strip() + description = str(row["description"] or "").strip() + transcript = str(row["transcript_text"] or "").strip() + url = str(row["url"] or "").strip() + participants = _jsonb_value(row, "participants", []) + participant_names = _named_entries(participants) + linked_records = _jsonb_value(row, "linked_records", []) + call_recording_ids = _jsonb_value(row, "call_recording_ids", []) + raw_payload = _jsonb_value(row, "raw_payload", {}) + started_at = row["started_at"] + ended_at = row["ended_at"] + source_created_at = row["source_created_at"] + source_updated_at = row["source_updated_at"] or row["updated_at"] + organizer_name = str(row["organizer_name"] or row["organizer_email"] or "").strip() + + lines = [ + f"# {title}", + "", + "- Source: Attio", + ] + if organizer_name: + lines.append(f"- Organizer: {organizer_name}") + if participant_names: + lines.append(f"- Participants: {', '.join(participant_names)}") + if started_at: + lines.append(f"- Started: {_format_time(started_at)}") + if ended_at: + lines.append(f"- Ended: {_format_time(ended_at)}") + if url: + lines.append(f"- URL: {url}") + if description: + lines.extend(["", "---", "", "## Description", "", description]) + if transcript: + lines.extend(["", "## Transcript", "", transcript]) + body = "\n".join(lines).strip() + metadata = { + "meeting_id": meeting_id, + "linked_records": linked_records if isinstance(linked_records, list) else [], + "participants": participants if isinstance(participants, list) else [], + "organizer_id": str(row["organizer_id"] or ""), + "organizer_name": str(row["organizer_name"] or ""), + "organizer_email": str(row["organizer_email"] or ""), + "call_recording_ids": ( + call_recording_ids if isinstance(call_recording_ids, list) else [] + ), + "has_description": bool(description), + "has_transcript": bool(transcript), + "raw_payload": raw_payload if isinstance(raw_payload, dict) else {}, + } + return { + "document_id": f"attio:meeting:{meeting_id}", + "source": "attio", + "source_type": "attio_meeting", + "source_document_id": meeting_id, + "source_chunk_id": "", + "parent_document_id": None, + "title": title, + "body": body, + "url": url, + "author_id": str(row["organizer_id"] or ""), + "author_name": organizer_name, + "access_scope": "company", + "occurred_at": started_at or source_created_at or source_updated_at, + "source_updated_at": source_updated_at, + "content_hash": _content_hash(title, body, url, metadata), + "metadata": metadata, + } + + def _calendar_event_document_id(row: Any) -> str: calendar_id = str(row["calendar_id"] or "") event_id = str(row["event_id"] or "") @@ -1371,6 +1500,7 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: google_drive_enabled = _env_flag_enabled("GOOGLE_DRIVE_ETL_ENABLED") google_calendar_enabled = _env_flag_enabled("GOOGLE_CALENDAR_ETL_ENABLED") linear_enabled = _env_flag_enabled("LINEAR_ETL_ENABLED") + attio_enabled = _env_flag_enabled("ATTIO_ETL_ENABLED") enabled_sources = [ source for source, enabled in ( @@ -1378,6 +1508,7 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: ("google_drive", google_drive_enabled), ("google_calendar", google_calendar_enabled), ("linear", linear_enabled), + ("attio", attio_enabled), ) if enabled ] @@ -1420,6 +1551,15 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: linear_changed = await _load_changed_linear_issues( ctx._pool, since, batch_until ) + attio_changed = { + "meetings": [], + "changed_meetings": 0, + "max_updated_at": None, + } + if attio_enabled: + attio_changed = await _load_changed_attio_meetings( + ctx._pool, since, batch_until + ) documents_upserted = 0 documents_deleted = 0 @@ -1578,6 +1718,24 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: if action in {"inserted", "updated"}: documents_upserted += 1 + for row in attio_changed["meetings"]: + document = _attio_meeting_document(row) + if document is None: + continue + observe_company_context_document_size( + "attio", + str(document["source_type"]), + len(str(document["body"] or "")), + ) + action = await _upsert_document(ctx._pool, document) + record_company_context_documents_changed( + "attio", + str(document["source_type"]), + action, + ) + if action in {"inserted", "updated"}: + documents_upserted += 1 + if batch_until is not None: watermark = batch_until else: @@ -1588,6 +1746,7 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: drive_changed["max_updated_at"], calendar_changed["max_updated_at"], linear_changed["max_updated_at"], + attio_changed["max_updated_at"], last_watermark, ) if value is not None @@ -1606,6 +1765,9 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: "linear": watermark if batch_until is not None else linear_changed["max_updated_at"] or last_watermark, + "attio": watermark + if batch_until is not None + else attio_changed["max_updated_at"] or last_watermark, } remaining_lag_seconds = ( max((now - watermark).total_seconds(), 0.0) if watermark is not None else None @@ -1620,12 +1782,14 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: "changed_drive_files": drive_changed["changed_files"], "changed_calendar_events": calendar_changed["changed_events"], "changed_linear_issues": linear_changed["changed_issues"], + "changed_attio_meetings": attio_changed["changed_meetings"], "channel_day_documents": len(changed["channel_days"]), "thread_candidates": len(changed["threads"]), "slack_attachment_documents": len(changed["attachments"]), "drive_documents": len(drive_changed["files"]), "calendar_event_documents": len(calendar_changed["events"]), "linear_issue_documents": len(linear_changed["issues"]), + "attio_meeting_documents": len(attio_changed["meetings"]), "documents_upserted": documents_upserted, "documents_deleted": documents_deleted, "since": since.isoformat() if since else None, diff --git a/workflows/tests/test_attio_sync.py b/workflows/tests/test_attio_sync.py new file mode 100644 index 000000000..0b025c683 --- /dev/null +++ b/workflows/tests/test_attio_sync.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import asyncio +import importlib +import json +import sys +import types + + +def _install_workflow_stubs() -> None: + api_module = sys.modules.get("api") or types.ModuleType("api") + runtime_control = sys.modules.get("api.runtime_control") or types.ModuleType( + "api.runtime_control" + ) + runtime_control.canonical_json = lambda value: json.dumps(value, sort_keys=True) + + etl_metrics = types.ModuleType("workflows.etl_metrics") + for name in ( + "record_etl_items_failed", + "record_etl_items_seen", + "record_etl_items_upserted", + ): + setattr(etl_metrics, name, lambda *_args, **_kwargs: None) + + workflow_engine = types.ModuleType("api.workflow_engine") + workflow_engine.WorkflowContext = object + + slack_shared = types.ModuleType("workflows.slack.shared") + slack_shared.env_flag_enabled = lambda _name, default=True: default + slack_shared.positive_int = lambda value, default: ( + int(value) if value is not None and int(value) > 0 else default + ) + + api_module.runtime_control = runtime_control + api_module.workflow_engine = workflow_engine + sys.modules.setdefault("api", api_module) + sys.modules["api.runtime_control"] = runtime_control + sys.modules["api.workflow_engine"] = workflow_engine + sys.modules["workflows.etl_metrics"] = etl_metrics + sys.modules["workflows.slack.shared"] = slack_shared + + +def _load(name: str): + _install_workflow_stubs() + return importlib.import_module(name) + + +def test_attio_page_helpers_accept_common_cursor_shapes(): + attio = _load("workflows.attio_sync") + + assert attio._page_items({"data": [{"id": 1}, "skip"]}) == [{"id": 1}] + assert attio._page_items({"data": {"data": [{"id": 2}]}}) == [{"id": 2}] + assert attio._page_items({"meetings": [{"id": 3}]}) == [{"id": 3}] + assert attio._next_cursor({"pagination": {"next_cursor": "cur_1"}}) == "cur_1" + assert attio._next_cursor({"meta": {"nextCursor": "cur_2"}}) == "cur_2" + + +def test_attio_transcript_text_uses_speaker_or_participant(): + attio = _load("workflows.attio_sync") + + text = attio._transcript_text( + [ + {"speaker": {"name": "Dana"}, "text": "Budget approved"}, + {"participant": {"display_name": "Eli"}, "content": "Sending next steps"}, + {"speaker_name": "Fran", "transcript": "Thanks"}, + ] + ) + + assert text == "Dana: Budget approved\nEli: Sending next steps\nFran: Thanks" + + +def test_attio_sync_uses_supported_meeting_sort(): + attio = _load("workflows.attio_sync") + + class FakeAttioClient: + def __init__(self) -> None: + self.sort = None + + async def list_meetings(self, **kwargs): + self.sort = kwargs.get("sort") + return {"data": []} + + client = FakeAttioClient() + + asyncio.run( + attio._sync_meetings( + client=client, + pool=None, + page_size=50, + updated_after=None, + max_meetings=None, + include_transcripts=False, + run_id="run_1", + ) + ) + + assert client.sort == "start_asc" diff --git a/workflows/tests/test_company_context_documents_attachments.py b/workflows/tests/test_company_context_documents_attachments.py index c8784b793..185e093a2 100644 --- a/workflows/tests/test_company_context_documents_attachments.py +++ b/workflows/tests/test_company_context_documents_attachments.py @@ -299,3 +299,43 @@ def test_slack_attachment_document_indexes_metadata_without_private_url(): assert "files-pri" not in document["body"] assert "url_private" not in document["metadata"] assert document["metadata"]["message_permalink"].endswith("p1770000000000100") + + +def test_attio_meeting_document_indexes_description_and_transcript(): + row = { + "meeting_id": "mtg_123", + "title": "Acme renewal call", + "description": "Customer renewal discussion.", + "url": "https://app.attio.com/meetings/mtg_123", + "linked_records": [{"target_object": "companies", "target_record_id": "rec_1"}], + "participants": [{"name": "Dana"}, {"email": "buyer@example.com"}], + "organizer_id": "mem_1", + "organizer_name": "Eli", + "organizer_email": "eli@example.com", + "call_recording_ids": ["rec_1"], + "transcript_text": "Dana: Budget approved\nEli: Next step is legal", + "transcript_payload": [{"text": "Budget approved"}], + "content_text": "", + "content_hash": "", + "started_at": dt.datetime(2026, 6, 21, 16, 0, tzinfo=dt.UTC), + "ended_at": dt.datetime(2026, 6, 21, 16, 30, tzinfo=dt.UTC), + "source_created_at": dt.datetime(2026, 6, 21, 15, 59, tzinfo=dt.UTC), + "source_updated_at": dt.datetime(2026, 6, 21, 16, 31, tzinfo=dt.UTC), + "raw_payload": {"id": {"meeting_id": "mtg_123"}}, + "updated_at": dt.datetime(2026, 6, 21, 16, 32, tzinfo=dt.UTC), + } + + document = projection._attio_meeting_document(row) + + assert document is not None + assert document["document_id"] == "attio:meeting:mtg_123" + assert document["source"] == "attio" + assert document["source_type"] == "attio_meeting" + assert document["title"] == "Acme renewal call" + assert "- Organizer: Eli" in document["body"] + assert "- Participants: Dana, buyer@example.com" in document["body"] + assert "Customer renewal discussion." in document["body"] + assert "Dana: Budget approved" in document["body"] + assert document["author_id"] == "mem_1" + assert document["metadata"]["has_transcript"] is True + assert document["metadata"]["meeting_id"] == "mtg_123" From 5066dc52f9a2c3d5b20d7fcd9684d8ccdf38f0c2 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Thu, 9 Jul 2026 12:43:12 -0700 Subject: [PATCH 121/198] fix: sync slack labels for mcp principals --- .../app/controllers/oauth/flows_controller.rb | 9 +++++ .../principal_credential_reconciliation.rb | 33 +++++++++++++++++-- services/console/lib/oauth/providers/slack.rb | 8 ++++- .../oauth/flows_controller_test.rb | 2 ++ .../test/lib/oauth/providers/slack_test.rb | 6 +++- ...rincipal_credential_reconciliation_test.rb | 13 ++++++++ 6 files changed, 66 insertions(+), 5 deletions(-) diff --git a/services/console/app/controllers/oauth/flows_controller.rb b/services/console/app/controllers/oauth/flows_controller.rb index f8554b24b..fc45497a8 100644 --- a/services/console/app/controllers/oauth/flows_controller.rb +++ b/services/console/app/controllers/oauth/flows_controller.rb @@ -183,6 +183,7 @@ def upsert_credential(state, result, identity) provider_email: identity[:email], # Store exactly what the IdP granted, so the refresh POST re-requests it. scopes: granted_scopes(result, state), + labels: credential_labels(credential, identity), refresh_token: result.refresh_token, access_token: result.access_token, expires_at: now + expires_in, @@ -201,6 +202,14 @@ def granted_scopes(result, state) @provider.parse_granted_scopes(result.scope) end + def credential_labels(credential, identity) + labels = credential.labels || {} + return labels unless @app.provider == Oauth::Providers::Slack::KEY + return labels if identity[:team_id].blank? + + labels.merge("slack_team_id" => identity[:team_id]) + end + def identity_display_name(identity) identity[:name].presence || identity[:email].presence || identity[:subject] end diff --git a/services/console/app/services/principal_credential_reconciliation.rb b/services/console/app/services/principal_credential_reconciliation.rb index f16ec4bf9..1a9334f5d 100644 --- a/services/console/app/services/principal_credential_reconciliation.rb +++ b/services/console/app/services/principal_credential_reconciliation.rb @@ -108,9 +108,10 @@ def apply_entry(entry) end def sync_principal_provider_labels(principal, credentials) - # Console-user principals never match by label, so stamping provider - # identity labels on them would only create stale, unused inputs. - return if console_user_principal?(principal) + if console_user_principal?(principal) + sync_console_user_slack_labels(principal, credentials) + return + end google_credentials = credentials.select do |credential| credential.oauth_app&.provider == GOOGLE_PROVIDER @@ -277,11 +278,37 @@ def slack_team_matches?(principal, credential) principal_team = normalize_key(principal.labels&.[](SLACK_TEAM_LABEL)) credential_team = normalize_key(credential.labels&.[](SLACK_TEAM_LABEL)) || normalize_key(credential.oauth_app&.labels&.[](SLACK_TEAM_LABEL)) + return true if console_user_principal?(principal) && principal_team.blank? return true if principal_team.blank? && credential_team.blank? principal_team.present? && principal_team == credential_team end + def sync_console_user_slack_labels(principal, credentials) + slack_credentials = credentials.select do |credential| + credential.oauth_app&.provider == SLACK_PROVIDER + end + return if slack_credentials.empty? + + slack_user_id = unique_present_value(slack_credentials.map(&:provider_subject)) + slack_team_id = unique_present_value(slack_credentials.map { |credential| slack_team_for(credential) }) + return unless slack_user_id && slack_team_id + + labels = principal.labels || {} + updates = { + "slack_user_id" => slack_user_id, + SLACK_TEAM_LABEL => slack_team_id + } + return if updates.all? { |key, value| labels[key] == value } + + principal.update!(labels: labels.merge(updates)) + end + + def slack_team_for(credential) + credential.labels&.[](SLACK_TEAM_LABEL).presence || + credential.oauth_app&.labels&.[](SLACK_TEAM_LABEL).presence + end + def console_user_principal?(principal) (principal.labels || {})["kind"] == CONSOLE_USER_KIND end diff --git a/services/console/lib/oauth/providers/slack.rb b/services/console/lib/oauth/providers/slack.rb index 439cd69dc..affd9aea9 100644 --- a/services/console/lib/oauth/providers/slack.rb +++ b/services/console/lib/oauth/providers/slack.rb @@ -36,7 +36,8 @@ def identity_from(result, client_id:) return { subject: user_id, email: result.response.dig("authed_user", "email"), - name: slack_user_name(result.response) + name: slack_user_name(result.response), + team_id: slack_team_id(result.response) } end @@ -51,6 +52,11 @@ def slack_user_name(response) response.dig("authed_user", "name").presence || response.dig("authed_user", "user").presence end + + def slack_team_id(response) + response.dig("team", "id").presence || + response.dig("authed_user", "team_id").presence + end end end end diff --git a/services/console/test/controllers/oauth/flows_controller_test.rb b/services/console/test/controllers/oauth/flows_controller_test.rb index 423fe379b..ec30bd5ab 100644 --- a/services/console/test/controllers/oauth/flows_controller_test.rb +++ b/services/console/test/controllers/oauth/flows_controller_test.rb @@ -62,6 +62,7 @@ def slack_token_body(sub: "U0R7MFMJM", scope: "chat:write", id_token_value: nil, ok: true, access_token: "xoxe.xoxb-1-bot", refresh_token: "xoxe-1-bot-refresh", expires_in: 43_200, token_type: "bot", scope: "commands", id_token: id_token_value, + team: { id: "TACME", name: "Acme" }, authed_user: { id: sub, user: "grace", @@ -295,6 +296,7 @@ def start_flow(slug: "google", **params) assert_equal %w[chat:write], cred.scopes assert_equal "xoxe.xoxp-1-user", cred.access_token assert_equal "xoxe-1-refresh", cred.refresh_token + assert_equal "TACME", cred.labels["slack_team_id"] assert_equal [ "slack.com" ], cred.static_secret.rules.map(&:host) assert_equal "Slack – grace token", cred.static_secret.name end diff --git a/services/console/test/lib/oauth/providers/slack_test.rb b/services/console/test/lib/oauth/providers/slack_test.rb index af544ecd2..63210fc39 100644 --- a/services/console/test/lib/oauth/providers/slack_test.rb +++ b/services/console/test/lib/oauth/providers/slack_test.rb @@ -31,11 +31,15 @@ def valid_claims(**overrides) result = result_with( claims: valid_claims, id_token: nil, - response: { "authed_user" => { "id" => "U12345" } } + response: { + "team" => { "id" => "T12345" }, + "authed_user" => { "id" => "U12345" } + } ) identity = strategy.identity_from(result, client_id: CLIENT_ID) assert_equal "U12345", identity[:subject] + assert_equal "T12345", identity[:team_id] assert_nil identity[:email] assert_nil identity[:name] end diff --git a/services/console/test/services/principal_credential_reconciliation_test.rb b/services/console/test/services/principal_credential_reconciliation_test.rb index fd8e83923..832a14ead 100644 --- a/services/console/test/services/principal_credential_reconciliation_test.rb +++ b/services/console/test/services/principal_credential_reconciliation_test.rb @@ -177,6 +177,19 @@ class PrincipalCredentialReconciliationTest < ActiveSupport::TestCase end end + test "console user principal syncs Slack labels from a matched admin credential" do + app = oauth_apps(:acme_slack) + app.update!(labels: app.labels.merge("slack_team_id" => "TACME")) + credential = create_credential(app, "U-MEMBER", "member@acme.example") + secret = wrap(credential) + + principal = create_console_user_principal(users(:member_user), foreign_id: "console-user-slack") + + assert principal.grants.exists?(static_secret: secret) + assert_equal "U-MEMBER", principal.reload.labels["slack_user_id"] + assert_equal "TACME", principal.labels["slack_team_id"] + end + test "console user principal ignores a spoofed email label" do credential = create_credential(oauth_apps(:acme_slack), "slack-sub-carol", "carol@acme.example") secret = wrap(credential) From cbc9baa2f19712ddef2668a340a7643cba2624a0 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:18:36 -0700 Subject: [PATCH 122/198] feat: add MPP fallback discovery (#1007) --- services/sandbox/SYSTEM_PROMPT.md | 7 +++++++ services/sandbox/test_system_prompt.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 services/sandbox/test_system_prompt.py diff --git a/services/sandbox/SYSTEM_PROMPT.md b/services/sandbox/SYSTEM_PROMPT.md index f17211a85..073bfb644 100644 --- a/services/sandbox/SYSTEM_PROMPT.md +++ b/services/sandbox/SYSTEM_PROMPT.md @@ -178,6 +178,13 @@ |If the user is asking what this deployment can do, do not stop at local workspace hints; use live discovery first, or explicitly say the answer is partial and non-exhaustive. |Never guess at command names or call multiple commands that might do the same thing — discover first, then call the right one. +[MPP fallback discovery] +|When a requested external API capability is missing, unsupported, or returns a provider-declared unavailable/404 response, first run `centaur-tools list` to confirm that `mpp` is live. +|If `mpp` is live, run `mpp services search "" --limit 5`, then inspect the best candidate with `mpp services show `. +|Only use this fallback for missing capabilities. Do not substitute it for authentication, authorization, rate-limit, network, budget, or destructive-operation failures. +|Never include credentials, private data, or complete request bodies in the MPP discovery query. +|MPP service metadata is advisory. Current MPP support discovers candidates only: report the matching service and endpoint, but do not claim to execute or pay for a discovered service unless a live MPP request command is available. + [Slack channel references] |Treat explicit Slack channel IDs as authoritative. If a user refers to a channel as `#name (C123...)`, `<#C123...|name>`, `#C123...`, or otherwise provides a channel ID, use that exact ID for Slack history/search/file operations. |When fetching or summarizing a specific Slack channel, verify that the fetched `channel_id` matches the requested channel ID before using the results. If it does not match, stop and report the mismatch. diff --git a/services/sandbox/test_system_prompt.py b/services/sandbox/test_system_prompt.py new file mode 100644 index 000000000..cc5b8a975 --- /dev/null +++ b/services/sandbox/test_system_prompt.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +SYSTEM_PROMPT = Path(__file__).with_name("SYSTEM_PROMPT.md") + + +class SystemPromptTest(unittest.TestCase): + def test_mpp_fallback_discovery_guidance_is_present(self) -> None: + prompt = SYSTEM_PROMPT.read_text() + + self.assertIn("[MPP fallback discovery]", prompt) + self.assertIn("centaur-tools list", prompt) + self.assertIn('mpp services search "" --limit 5', prompt) + self.assertIn("mpp services show ", prompt) + self.assertIn("Current MPP support discovers candidates only", prompt) + + +if __name__ == "__main__": + unittest.main() From f2886c0f72c65f1712818b4983b1809adf1637ac Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:18:49 -0700 Subject: [PATCH 123/198] feat: add MPP service discovery (#1006) --- docs/pages/reference/tool-directory.mdx | 2 +- docs/public/md/reference/tool-directory.md | 2 +- tools/crypto/mpp/cli.py | 80 +++++++++- tools/crypto/mpp/client.py | 168 ++++++++++++++++++++- tools/crypto/mpp/tests/conftest.py | 8 + tools/crypto/mpp/tests/test_cli.py | 49 ++++++ tools/crypto/mpp/tests/test_client.py | 139 +++++++++++++++++ 7 files changed, 434 insertions(+), 14 deletions(-) create mode 100644 tools/crypto/mpp/tests/conftest.py create mode 100644 tools/crypto/mpp/tests/test_cli.py create mode 100644 tools/crypto/mpp/tests/test_client.py diff --git a/docs/pages/reference/tool-directory.mdx b/docs/pages/reference/tool-directory.mdx index 191c44df5..17322fa7f 100644 --- a/docs/pages/reference/tool-directory.mdx +++ b/docs/pages/reference/tool-directory.mdx @@ -136,7 +136,7 @@ These tools ship in the base repo because many Centaur users need onchain or mar | `kalshi` | Prediction market events, markets, trades, and candlesticks | None | | `karma` | DAO delegate reputation, activity, scores, and governance analytics | None | | `messari` | Crypto asset prices, metrics, profiles, markets, news, and timeseries | `MESSARI_API_KEY` | -| `mpp` | Paid market-data and web-search requests through Machine Payments Protocol | None | +| `mpp` | Paid MPP requests | None | | `nansen` | Wallet labels, smart-money activity, token flows, holders, and PnL | `NANSEN_API_KEY` | | `polymarket` | Prediction market events, markets, prices, books, and trades | None | | `snapshot` | Offchain governance spaces, proposals, votes, and voting power | `SNAPSHOT_API_KEY` | diff --git a/docs/public/md/reference/tool-directory.md b/docs/public/md/reference/tool-directory.md index 6449b471b..b5c044f2f 100644 --- a/docs/public/md/reference/tool-directory.md +++ b/docs/public/md/reference/tool-directory.md @@ -138,7 +138,7 @@ These tools ship in the base repo because many Centaur users need onchain or mar | `kalshi` | Prediction market events, markets, trades, and candlesticks | None | | `karma` | DAO delegate reputation, activity, scores, and governance analytics | None | | `messari` | Crypto asset prices, metrics, profiles, markets, news, and timeseries | `MESSARI_API_KEY` | -| `mpp` | Paid market-data and web-search requests through Machine Payments Protocol | None | +| `mpp` | Paid MPP requests | None | | `nansen` | Wallet labels, smart-money activity, token flows, holders, and PnL | `NANSEN_API_KEY` | | `polymarket` | Prediction market events, markets, prices, books, and trades | None | | `snapshot` | Offchain governance spaces, proposals, votes, and voting power | `SNAPSHOT_API_KEY` | diff --git a/tools/crypto/mpp/cli.py b/tools/crypto/mpp/cli.py index 8783c9c04..bda668445 100644 --- a/tools/crypto/mpp/cli.py +++ b/tools/crypto/mpp/cli.py @@ -1,16 +1,31 @@ """CLI for Market data via MPP (Machine Payments Protocol).""" +import json +from collections.abc import Callable + +import typer from dotenv import load_dotenv load_dotenv() -import json -import typer - app = typer.Typer( name="mpp", - help="Market data via MPP (Machine Payments Protocol) — token prices, web search, on-chain data, trending tokens, wallet balances, and Dune SQL queries. Paid per-query with Tempo stablecoins.", + help="Market data and live service discovery via MPP (Machine Payments Protocol).", ) +services_app = typer.Typer(help="Discover public MPP services without making a payment.") +app.add_typer(services_app, name="services") + + +def _print_json(payload: object) -> None: + typer.echo(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) + + +def _run_discovery(operation: Callable[[], object]) -> None: + try: + _print_json(operation()) + except (RuntimeError, ValueError) as exc: + _print_json({"error": str(exc)}) + raise typer.Exit(1) from exc @app.callback() @@ -29,13 +44,66 @@ def health(): payload = {"ok": True, "tool": "mpp", "error": None, "details": details} except Exception as exc: payload = {"ok": False, "tool": "mpp", "error": str(exc), "details": {}} - print(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) + _print_json(payload) raise typer.Exit(1) from exc finally: close = getattr(client, "close", None) if callable(close): close() - print(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) + _print_json(payload) + + +@services_app.command("list") +def list_services( + query: str | None = typer.Option( + None, "--query", "-q", help="Text to match in catalog metadata" + ), + category: str | None = typer.Option(None, help="Exact service category"), + tag: str | None = typer.Option(None, help="Exact service tag"), + limit: int = typer.Option(20, min=1, max=100, help="Maximum services to return"), +) -> None: + """List public MPP services with optional catalog filters.""" + from .client import _client + + client = _client() + _run_discovery( + lambda: { + "services": client.list_services(query=query, category=category, tag=tag, limit=limit), + "filters": {"query": query, "category": category, "tag": tag, "limit": limit}, + } + ) + + +@services_app.command("search") +def search_services( + query: str = typer.Argument(..., help="Text to match in catalog metadata"), + category: str | None = typer.Option(None, help="Exact service category"), + tag: str | None = typer.Option(None, help="Exact service tag"), + limit: int = typer.Option(20, min=1, max=100, help="Maximum services to return"), +) -> None: + """Search public MPP services by id, name, description, category, or tag.""" + from .client import _client + + client = _client() + _run_discovery( + lambda: { + "services": client.search_services( + query=query, category=category, tag=tag, limit=limit + ), + "filters": {"query": query, "category": category, "tag": tag, "limit": limit}, + } + ) + + +@services_app.command("show") +def show_service( + service: str = typer.Argument(..., help="Exact service id or unambiguous service name"), +) -> None: + """Show one complete public MPP service record.""" + from .client import _client + + client = _client() + _run_discovery(lambda: client.get_service(service)) if __name__ == "__main__": diff --git a/tools/crypto/mpp/client.py b/tools/crypto/mpp/client.py index 7501ecf4f..3bdfe210a 100644 --- a/tools/crypto/mpp/client.py +++ b/tools/crypto/mpp/client.py @@ -9,12 +9,22 @@ from __future__ import annotations import time -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any import httpx + from centaur_sdk.tool_sdk import secret +MPP_SERVICE_CATALOG_URL = "https://mpp.dev/api/services" +MPP_SERVICE_CATALOG_TIMEOUT_SECONDS = 15 +DEFAULT_SERVICE_LIMIT = 20 +MAX_SERVICE_LIMIT = 100 + + +class MppCatalogError(RuntimeError): + """Raised when the public MPP service catalog cannot be used safely.""" + # --- Token name normalization --- TOKEN_NAME_MAP: dict[str, str] = { @@ -69,8 +79,154 @@ def __init__(self) -> None: self._private_key: str | None = None self._daily_spend = 0.0 self._daily_cap = 10.0 - self._last_reset = datetime.now(timezone.utc).strftime("%Y-%m-%d") + self._last_reset = datetime.now(UTC).strftime("%Y-%m-%d") self._coingecko_id_cache: dict[str, str] = {} + self._catalog_http: httpx.Client | None = None + + def _fetch_service_catalog(self) -> list[dict[str, Any]]: + """Fetch and validate the public MPP service catalog without using a wallet.""" + client = self._catalog_http + owns_client = client is None + if client is None: + client = httpx.Client(timeout=MPP_SERVICE_CATALOG_TIMEOUT_SECONDS) + + try: + try: + response = client.get(MPP_SERVICE_CATALOG_URL) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise MppCatalogError( + f"MPP service catalog returned HTTP {exc.response.status_code}" + ) from exc + except httpx.HTTPError as exc: + raise MppCatalogError("could not fetch MPP service catalog") from exc + + try: + payload = response.json() + except ValueError as exc: + raise MppCatalogError("MPP service catalog returned invalid JSON") from exc + + services = payload.get("services") if isinstance(payload, dict) else None + if not isinstance(services, list) or not all( + isinstance(service, dict) for service in services + ): + raise MppCatalogError("MPP service catalog has an invalid services list") + return services + finally: + if owns_client: + client.close() + + @staticmethod + def _validate_service_limit(limit: int) -> None: + if not 1 <= limit <= MAX_SERVICE_LIMIT: + raise ValueError(f"limit must be between 1 and {MAX_SERVICE_LIMIT}") + + @staticmethod + def _matches_service( + service: dict[str, Any], query: str | None, category: str | None, tag: str | None + ) -> bool: + if category is not None: + categories = service.get("categories") or [] + if not any(str(value).casefold() == category.casefold() for value in categories): + return False + + if tag is not None: + tags = service.get("tags") or [] + if not any(str(value).casefold() == tag.casefold() for value in tags): + return False + + if query is None: + return True + + haystack = [ + service.get("id"), + service.get("name"), + service.get("description"), + *(service.get("categories") or []), + *(service.get("tags") or []), + ] + normalized_query = query.casefold() + return any( + normalized_query in str(value).casefold() for value in haystack if value is not None + ) + + @staticmethod + def _service_summary(service: dict[str, Any]) -> dict[str, Any]: + endpoints = service.get("endpoints") or [] + paid_endpoints = service.get("paidEndpoints") + if not isinstance(paid_endpoints, int): + paid_endpoints = sum( + isinstance(endpoint, dict) and endpoint.get("payment") is not None + for endpoint in endpoints + ) + return { + "id": service.get("id", ""), + "name": service.get("name", ""), + "description": service.get("description", ""), + "service_url": service.get("serviceUrl") or service.get("url") or "", + "categories": service.get("categories") or [], + "tags": service.get("tags") or [], + "status": service.get("status", ""), + "paid_endpoints": paid_endpoints, + } + + def list_services( + self, + query: str | None = None, + category: str | None = None, + tag: str | None = None, + limit: int = DEFAULT_SERVICE_LIMIT, + ) -> list[dict[str, Any]]: + """List public MPP services, optionally filtered by catalog metadata.""" + self._validate_service_limit(limit) + normalized_query = query.strip() if query is not None else None + normalized_category = category.strip() if category is not None else None + normalized_tag = tag.strip() if tag is not None else None + if normalized_query == "": + normalized_query = None + if normalized_category == "": + normalized_category = None + if normalized_tag == "": + normalized_tag = None + + return [ + self._service_summary(service) + for service in self._fetch_service_catalog() + if self._matches_service(service, normalized_query, normalized_category, normalized_tag) + ][:limit] + + def search_services( + self, + query: str, + category: str | None = None, + tag: str | None = None, + limit: int = DEFAULT_SERVICE_LIMIT, + ) -> list[dict[str, Any]]: + """Search public MPP services by text and optional exact catalog filters.""" + return self.list_services(query=query, category=category, tag=tag, limit=limit) + + def get_service(self, service: str) -> dict[str, Any]: + """Return one complete public MPP service record by id or unambiguous name.""" + identifier = service.strip() + if not identifier: + raise ValueError("service id or name is required") + + services = self._fetch_service_catalog() + exact_ids = [item for item in services if item.get("id") == identifier] + if exact_ids: + return exact_ids[0] + + name_matches = [ + item + for item in services + if isinstance(item.get("name"), str) + and item["name"].casefold() == identifier.casefold() + ] + if len(name_matches) == 1: + return name_matches[0] + if len(name_matches) > 1: + raise ValueError(f"MPP service name {identifier!r} is ambiguous; use its id") + raise ValueError(f"MPP service {identifier!r} was not found") def _get_private_key(self) -> str: """Lazy-load MPP_PRIVATE_KEY from secrets on first use.""" @@ -84,7 +240,7 @@ def _get_private_key(self) -> str: return self._private_key def _check_budget(self, needed: float = 0) -> bool: - today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + today = datetime.now(UTC).strftime("%Y-%m-%d") if today != self._last_reset: self._daily_spend = 0.0 self._last_reset = today @@ -287,9 +443,9 @@ def get_price_history(self, token_name: str, days: int = 30) -> list[dict]: return [ { "date": ( - datetime.fromtimestamp(p[0] / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M") + datetime.fromtimestamp(p[0] / 1000, tz=UTC).strftime("%Y-%m-%d %H:%M") if days <= 7 - else datetime.fromtimestamp(p[0] / 1000, tz=timezone.utc).strftime("%Y-%m-%d") + else datetime.fromtimestamp(p[0] / 1000, tz=UTC).strftime("%Y-%m-%d") ), "price": p[1], } @@ -330,7 +486,7 @@ def get_ohlc(self, token_name: str, days: int = 30) -> list[dict]: return [] return [ { - "date": datetime.fromtimestamp(c[0] / 1000, tz=timezone.utc).strftime("%Y-%m-%d"), + "date": datetime.fromtimestamp(c[0] / 1000, tz=UTC).strftime("%Y-%m-%d"), "open": c[1], "high": c[2], "low": c[3], diff --git a/tools/crypto/mpp/tests/conftest.py b/tools/crypto/mpp/tests/conftest.py new file mode 100644 index 000000000..7e8029425 --- /dev/null +++ b/tools/crypto/mpp/tests/conftest.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(ROOT / "tools" / "crypto")) +sys.path.insert(0, str(ROOT)) diff --git a/tools/crypto/mpp/tests/test_cli.py b/tools/crypto/mpp/tests/test_cli.py new file mode 100644 index 000000000..185ea2daf --- /dev/null +++ b/tools/crypto/mpp/tests/test_cli.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json + +from mpp import cli +from typer.testing import CliRunner + + +class FakeClient: + def list_services(self, **kwargs): + assert kwargs == {"query": None, "category": "search", "tag": None, "limit": 5} + return [{"id": "exa"}] + + def search_services(self, **kwargs): + assert kwargs == {"query": "image", "category": None, "tag": None, "limit": 20} + return [{"id": "fal"}] + + def get_service(self, service: str): + assert service == "fal" + return {"id": "fal", "endpoints": [{"payment": {"intent": "charge"}}]} + + +def test_service_commands_emit_json(monkeypatch) -> None: + monkeypatch.setattr("mpp.client._client", lambda: FakeClient()) + runner = CliRunner() + + listed = runner.invoke(cli.app, ["services", "list", "--category", "search", "--limit", "5"]) + searched = runner.invoke(cli.app, ["services", "search", "image"]) + shown = runner.invoke(cli.app, ["services", "show", "fal"]) + + assert listed.exit_code == 0, listed.output + assert json.loads(listed.output)["services"] == [{"id": "exa"}] + assert searched.exit_code == 0, searched.output + assert json.loads(searched.output)["services"] == [{"id": "fal"}] + assert shown.exit_code == 0, shown.output + assert json.loads(shown.output)["endpoints"][0]["payment"] == {"intent": "charge"} + + +def test_service_commands_return_a_json_error(monkeypatch) -> None: + class FailingClient: + def get_service(self, service: str): + raise ValueError(f"MPP service {service!r} was not found") + + monkeypatch.setattr("mpp.client._client", lambda: FailingClient()) + + result = CliRunner().invoke(cli.app, ["services", "show", "missing"]) + + assert result.exit_code == 1 + assert json.loads(result.output) == {"error": "MPP service 'missing' was not found"} diff --git a/tools/crypto/mpp/tests/test_client.py b/tools/crypto/mpp/tests/test_client.py new file mode 100644 index 000000000..a3e5bd12e --- /dev/null +++ b/tools/crypto/mpp/tests/test_client.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import httpx +import pytest +from mpp import client as client_module +from mpp.client import MppCatalogError, MppClient + +CATALOG = { + "services": [ + { + "id": "fal", + "name": "Fal AI", + "description": "Image generation models", + "serviceUrl": "https://fal.mpp.example", + "categories": ["AI", "media"], + "tags": ["image", "generation"], + "status": "active", + "paidEndpoints": 2, + "endpoints": [ + { + "method": "POST", + "path": "/generate", + "payment": {"intent": "charge", "method": "tempo", "amount": "100"}, + } + ], + }, + { + "id": "exa", + "name": "Exa", + "description": "Web search API", + "url": "https://exa.example", + "categories": ["search"], + "tags": ["web", "research"], + "status": "active", + "endpoints": [{"method": "POST", "path": "/search", "payment": {"intent": "charge"}}], + }, + { + "id": "exa-archive", + "name": "Exa", + "description": "Archived Exa service", + "serviceUrl": "https://archive.example", + "categories": ["search"], + "tags": ["archive"], + "status": "inactive", + "endpoints": [], + }, + ] +} + + +def make_client(handler) -> MppClient: + client = MppClient() + client._catalog_http = httpx.Client(transport=httpx.MockTransport(handler)) + return client + + +def test_list_services_filters_summarizes_and_never_loads_a_private_key(monkeypatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "https://mpp.dev/api/services" + return httpx.Response(200, json=CATALOG) + + monkeypatch.setattr( + client_module, + "secret", + lambda _: pytest.fail("service discovery must not load MPP_PRIVATE_KEY"), + ) + client = make_client(handler) + + assert client.list_services(query="image", category="ai", tag="generation") == [ + { + "id": "fal", + "name": "Fal AI", + "description": "Image generation models", + "service_url": "https://fal.mpp.example", + "categories": ["AI", "media"], + "tags": ["image", "generation"], + "status": "active", + "paid_endpoints": 2, + } + ] + + +def test_search_matches_all_supported_metadata_and_honors_limit() -> None: + client = make_client(lambda _: httpx.Response(200, json=CATALOG)) + + assert [service["id"] for service in client.search_services("web", limit=1)] == ["exa"] + assert [service["id"] for service in client.list_services(query="media")] == ["fal"] + assert [service["id"] for service in client.list_services(query="exa")] == [ + "exa", + "exa-archive", + ] + + +def test_get_service_returns_raw_endpoint_and_payment_metadata() -> None: + client = make_client(lambda _: httpx.Response(200, json=CATALOG)) + + assert client.get_service("fal") == CATALOG["services"][0] + + +def test_get_service_resolves_an_unambiguous_name_and_rejects_ambiguous_or_unknown_names() -> None: + single = {"services": [CATALOG["services"][0], CATALOG["services"][1]]} + client = make_client(lambda _: httpx.Response(200, json=single)) + assert client.get_service("fal ai")["id"] == "fal" + + ambiguous = make_client(lambda _: httpx.Response(200, json=CATALOG)) + with pytest.raises(ValueError, match="ambiguous"): + ambiguous.get_service("Exa") + + with pytest.raises(ValueError, match="was not found"): + client.get_service("missing") + + +@pytest.mark.parametrize("payload", [{}, {"services": {}}, {"services": ["not-a-service"]}]) +def test_catalog_rejects_invalid_shapes(payload) -> None: + client = make_client(lambda _: httpx.Response(200, json=payload)) + + with pytest.raises(MppCatalogError, match="invalid services list"): + client.list_services() + + +def test_catalog_errors_are_concise() -> None: + def http_error(request: httpx.Request) -> httpx.Response: + return httpx.Response(503, request=request) + + client = make_client(http_error) + with pytest.raises(MppCatalogError, match="HTTP 503"): + client.list_services() + + malformed = make_client(lambda _: httpx.Response(200, content=b"not-json")) + with pytest.raises(MppCatalogError, match="invalid JSON"): + malformed.list_services() + + +@pytest.mark.parametrize("limit", [0, 101]) +def test_list_services_rejects_unsafe_limits(limit: int) -> None: + client = MppClient() + + with pytest.raises(ValueError, match="between 1 and 100"): + client.list_services(limit=limit) From ba485ad05691a7332e16258ac312d4df8fb8b5bf Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Thu, 9 Jul 2026 13:40:40 -0700 Subject: [PATCH 124/198] fix: label MCP principals from Slack SSO (#1010) * fix: sync slack labels for mcp principals * fix: label MCP principals from Slack SSO --- .../app/controllers/mcp/oauth_controller.rb | 18 +++++++++- services/console/app/models/user.rb | 14 ++++++-- services/console/app/models/user_identity.rb | 2 ++ ...09160000_add_team_id_to_user_identities.rb | 5 +++ services/console/db/schema.rb | 3 +- services/console/lib/login/providers/slack.rb | 5 ++- .../controllers/mcp/oauth_controller_test.rb | 29 ++++++++++++++++ .../test/lib/login_slack_provider_test.rb | 34 +++++++++++++++++++ services/console/test/models/user_test.rb | 10 ++++++ 9 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 services/console/db/migrate/20260709160000_add_team_id_to_user_identities.rb create mode 100644 services/console/test/lib/login_slack_provider_test.rb diff --git a/services/console/app/controllers/mcp/oauth_controller.rb b/services/console/app/controllers/mcp/oauth_controller.rb index c140ed227..ed2d95082 100644 --- a/services/console/app/controllers/mcp/oauth_controller.rb +++ b/services/console/app/controllers/mcp/oauth_controller.rb @@ -457,7 +457,7 @@ def principal_for_current_user "kind" => "console_user", "console-user-id" => current_user.oid, "email" => current_user.email - ) + ).merge(slack_identity_labels_for(current_user)) principal.save! assign_user_mcp_role(principal) if newly_created principal @@ -484,6 +484,22 @@ def assign_user_mcp_role(principal) principal.principal_roles.find_or_create_by!(role: role) end + # Slack's OIDC id_token is the authenticated source of the user's native + # Slack identity. Refuse an ambiguous account rather than guessing which + # workspace should determine company-context RLS. + def slack_identity_labels_for(user) + identities = user.user_identities.where(provider: UserIdentity::SLACK_PROVIDER).order(:id) + identities = identities.filter_map do |identity| + next if identity.subject.blank? || identity.team_id.blank? + + [ identity.subject, identity.team_id ] + end.uniq + return {} unless identities.one? + + slack_user_id, slack_team_id = identities.first + { "slack_user_id" => slack_user_id, "slack_team_id" => slack_team_id } + end + def principal_foreign_id(email) normalized = email.to_s.downcase.strip safe = normalized.gsub(/[^A-Za-z0-9\-._~]/, "-").gsub(/-+/, "-").first(48) diff --git a/services/console/app/models/user.rb b/services/console/app/models/user.rb index 1c5760b6e..82dd0aa57 100644 --- a/services/console/app/models/user.rb +++ b/services/console/app/models/user.rb @@ -49,15 +49,14 @@ def self.link_or_provision(provider:, identity:) transaction do user = if (existing = UserIdentity.find_by(provider: provider, subject: identity[:subject])) - existing.update!(email: identity[:email], email_verified: identity[:email_verified]) + existing.update!(identity_attributes(provider:, identity:)) existing.user.tap do |u| u.update!(name: identity[:name]) if identity[:name].present? && u.name.blank? end else (linkable_user(identity) || create!(provisioned_attributes(identity))).tap do |u| u.user_identities.create!( - provider: provider, subject: identity[:subject], - email: identity[:email], email_verified: identity[:email_verified] + identity_attributes(provider:, identity:).merge(provider:, subject: identity[:subject]) ) end end @@ -74,6 +73,15 @@ def self.linkable_user(identity) end private_class_method :linkable_user + def self.identity_attributes(provider:, identity:) + attributes = { email: identity[:email], email_verified: identity[:email_verified] } + if provider == UserIdentity::SLACK_PROVIDER && identity[:team_id].present? + attributes[:team_id] = identity[:team_id] + end + attributes + end + private_class_method :identity_attributes + # Attributes for a brand-new SSO user: everyone is provisioned active -- the # console is only reachable on the internal network, so a completed SSO login # is sufficient and there is no admin-approval queue. Admin additionally diff --git a/services/console/app/models/user_identity.rb b/services/console/app/models/user_identity.rb index 624f139ed..2eff28faf 100644 --- a/services/console/app/models/user_identity.rb +++ b/services/console/app/models/user_identity.rb @@ -8,8 +8,10 @@ class UserIdentity < ApplicationRecord belongs_to :user PROVIDERS = %w[google slack].freeze + SLACK_PROVIDER = "slack".freeze normalizes :email, with: ->(e) { e.to_s.strip.downcase.presence } + normalizes :team_id, with: ->(id) { id.to_s.strip.presence } validates :provider, presence: true, inclusion: { in: PROVIDERS } validates :subject, presence: true, uniqueness: { scope: :provider } diff --git a/services/console/db/migrate/20260709160000_add_team_id_to_user_identities.rb b/services/console/db/migrate/20260709160000_add_team_id_to_user_identities.rb new file mode 100644 index 000000000..d2383afd3 --- /dev/null +++ b/services/console/db/migrate/20260709160000_add_team_id_to_user_identities.rb @@ -0,0 +1,5 @@ +class AddTeamIdToUserIdentities < ActiveRecord::Migration[8.1] + def change + add_column :user_identities, :team_id, :string + end +end diff --git a/services/console/db/schema.rb b/services/console/db/schema.rb index 9e971a4e6..1f8a40c5f 100644 --- a/services/console/db/schema.rb +++ b/services/console/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_07_190000) do +ActiveRecord::Schema[8.1].define(version: 2026_07_09_160000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -419,6 +419,7 @@ t.boolean "email_verified", default: false, null: false t.string "provider", null: false t.string "subject", null: false + t.string "team_id" t.datetime "updated_at", null: false t.bigint "user_id", null: false t.index ["provider", "subject"], name: "index_user_identities_on_provider_and_subject", unique: true diff --git a/services/console/lib/login/providers/slack.rb b/services/console/lib/login/providers/slack.rb index 2e49cec8f..5b5632178 100644 --- a/services/console/lib/login/providers/slack.rb +++ b/services/console/lib/login/providers/slack.rb @@ -5,6 +5,7 @@ module Providers # endpoint returns an id_token carrying the account identity. class Slack KEY = "slack" + TEAM_ID_CLAIM = "https://slack.com/team_id".freeze AUTHORIZATION_ENDPOINT = "https://slack.com/openid/connect/authorize" TOKEN_ENDPOINT = "https://slack.com/api/openid.connect.token" SCOPES = %w[openid email profile].freeze @@ -17,7 +18,9 @@ def scopes = SCOPES def extra_authorization_params = {} def identity_from(result, client_id:) - Login::IdToken.identity(result.id_token, client_id: client_id, valid_issuers: VALID_ISSUERS) + identity = Login::IdToken.identity(result.id_token, client_id: client_id, valid_issuers: VALID_ISSUERS) + claims = Login::IdToken.decode_claims(result.id_token) + identity.merge(team_id: claims[TEAM_ID_CLAIM].to_s.strip.presence) end end end diff --git a/services/console/test/controllers/mcp/oauth_controller_test.rb b/services/console/test/controllers/mcp/oauth_controller_test.rb index a4101710b..d991b8a26 100644 --- a/services/console/test/controllers/mcp/oauth_controller_test.rb +++ b/services/console/test/controllers/mcp/oauth_controller_test.rb @@ -170,6 +170,35 @@ class OauthControllerTest < ActionDispatch::IntegrationTest assert_includes principal.roles, role end + test "authorization approval labels a principal from one Slack SSO identity" do + @operator.user_identities.create!( + provider: "slack", subject: "U123", team_id: "T123", email: @operator.email, email_verified: true + ) + client = create_client + + code = authorize_code(client) + + principal = McpOauthAuthorizationCode.find_usable(code).principal + assert_equal "U123", principal.labels["slack_user_id"] + assert_equal "T123", principal.labels["slack_team_id"] + end + + test "authorization approval leaves Slack labels unset for ambiguous Slack SSO identities" do + @operator.user_identities.create!( + provider: "slack", subject: "U123", team_id: "T123", email: @operator.email, email_verified: true + ) + @operator.user_identities.create!( + provider: "slack", subject: "U456", team_id: "T456", email: @operator.email, email_verified: true + ) + client = create_client + + code = authorize_code(client) + + principal = McpOauthAuthorizationCode.find_usable(code).principal + assert_nil principal.labels["slack_user_id"] + assert_nil principal.labels["slack_team_id"] + end + test "authorization approval reuses an existing user-mcp role" do existing = Role.create!( namespace: "default", diff --git a/services/console/test/lib/login_slack_provider_test.rb b/services/console/test/lib/login_slack_provider_test.rb new file mode 100644 index 000000000..e1404f80f --- /dev/null +++ b/services/console/test/lib/login_slack_provider_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +module Login + module Providers + class SlackTest < ActiveSupport::TestCase + CLIENT_ID = "slack-login-client-id".freeze + + def result(claims) + payload = Base64.urlsafe_encode64(claims.to_json, padding: false) + Broker::AuthorizationCodeClient::Result.new( + access_token: "AT", refresh_token: nil, expires_in: 3600, + scope: "openid email profile", id_token: "h.#{payload}.s", response: {} + ) + end + + test "extracts the workspace id from Slack's verified OIDC identity" do + identity = Slack.new.identity_from( + result( + "aud" => CLIENT_ID, + "iss" => "https://slack.com", + "sub" => "U123", + "email" => "ada@tempo.xyz", + "email_verified" => true, + "https://slack.com/team_id" => "T123" + ), + client_id: CLIENT_ID + ) + + assert_equal "U123", identity[:subject] + assert_equal "T123", identity[:team_id] + end + end + end +end diff --git a/services/console/test/models/user_test.rb b/services/console/test/models/user_test.rb index d767e3758..e71a52961 100644 --- a/services/console/test/models/user_test.rb +++ b/services/console/test/models/user_test.rb @@ -138,6 +138,16 @@ def identity(overrides = {}) assert_equal target, user end + test "link_or_provision preserves the Slack workspace id" do + user = User.link_or_provision( + provider: "slack", + identity: identity(subject: "slack-user", email: "slack-user@example.com", team_id: " T123 ") + ) + + slack_identity = user.user_identities.find_by!(provider: "slack") + assert_equal "T123", slack_identity.team_id + end + test "link_or_provision will not let an unverified email adopt an existing account" do target = users(:globex_admin) assert_raises(ActiveRecord::RecordInvalid) do From 78f9ba16296e527d63b678d7f319fb1a0a19b27c Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Thu, 9 Jul 2026 14:52:50 -0600 Subject: [PATCH 125/198] fix: default slack file and thread tools to api routes (#1009) * fix: default slack file and thread tools to api routes * test: assert slack etl uses direct slack routes --- .../centaur-api-server/src/slack_proxy.rs | 69 ++++++ services/sandbox/SYSTEM_PROMPT.md | 9 +- tools/productivity/slack/cli.py | 225 ++++++++++++++---- tools/productivity/slack/client.py | 46 ++++ tools/productivity/slack/tests/test_cli.py | 115 ++++++++- tools/productivity/slack/tests/test_client.py | 64 +++++ workflows/slack/shared.py | 4 +- .../slack/tests/test_shared_attachments.py | 140 +++++++++++ 8 files changed, 607 insertions(+), 65 deletions(-) diff --git a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs index 748473ece..09721a1d7 100644 --- a/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs +++ b/services/api-rs/crates/centaur-api-server/src/slack_proxy.rs @@ -47,6 +47,10 @@ pub(crate) fn slack_proxy_router() -> Router { "/api/slack/channels/{channel_id}/history", get(get_slack_channel_history), ) + .route( + "/api/slack/channels/{channel_id}/threads/{thread_ts}/replies", + get(get_slack_thread_replies), + ) } #[derive(Debug, Deserialize)] @@ -257,6 +261,23 @@ async fn get_slack_channel_history( Ok(Json(value)) } +async fn get_slack_thread_replies( + headers: HeaderMap, + Path((channel_id, thread_ts)): Path<(String, String)>, + Query(query): Query, +) -> Result, ApiError> { + let claims = authorize_slack_file_proxy(&headers)?; + ensure_history_channel_allowed(&claims, &channel_id)?; + validate_slack_channel_id(&channel_id)?; + validate_slack_thread_ts(&thread_ts)?; + validate_slack_channel_history_query(&query)?; + + let config = slack_proxy_config()?; + let value = + slack_thread_replies(http_client(), config, &channel_id, &thread_ts, &query).await?; + Ok(Json(value)) +} + fn upstream_body_is_unexpected_html( upstream_content_type: Option<&str>, file_mimetype: Option<&str>, @@ -425,6 +446,17 @@ async fn slack_channel_history( slack_api_post_form(client, config, "conversations.history", &form).await } +async fn slack_thread_replies( + client: &reqwest::Client, + config: &SlackFileProxyConfig, + channel_id: &str, + thread_ts: &str, + query: &SlackChannelHistoryQuery, +) -> Result { + let form = slack_thread_replies_form(channel_id, thread_ts, query); + slack_api_post_form(client, config, "conversations.replies", &form).await +} + fn slack_channel_history_form( channel_id: &str, query: &SlackChannelHistoryQuery, @@ -460,6 +492,16 @@ fn slack_channel_history_form( form } +fn slack_thread_replies_form( + channel_id: &str, + thread_ts: &str, + query: &SlackChannelHistoryQuery, +) -> Vec<(&'static str, String)> { + let mut form = slack_channel_history_form(channel_id, query); + form.push(("ts", thread_ts.to_owned())); + form +} + async fn slack_api_post_form( client: &reqwest::Client, config: &SlackFileProxyConfig, @@ -1087,4 +1129,31 @@ mod tests { ] ); } + + #[test] + fn thread_replies_form_includes_thread_ts() { + let form = slack_thread_replies_form( + "C123456789", + "1700000000.000001", + &SlackChannelHistoryQuery { + latest: None, + oldest: Some("0".to_owned()), + inclusive: Some(true), + include_all_metadata: None, + limit: Some(25), + cursor: Some("next".to_owned()), + }, + ); + assert_eq!( + form, + vec![ + ("channel", "C123456789".to_owned()), + ("oldest", "0".to_owned()), + ("inclusive", "true".to_owned()), + ("limit", "25".to_owned()), + ("cursor", "next".to_owned()), + ("ts", "1700000000.000001".to_owned()), + ] + ); + } } diff --git a/services/sandbox/SYSTEM_PROMPT.md b/services/sandbox/SYSTEM_PROMPT.md index 073bfb644..06d5d1dc3 100644 --- a/services/sandbox/SYSTEM_PROMPT.md +++ b/services/sandbox/SYSTEM_PROMPT.md @@ -152,7 +152,7 @@ | [Common Tool CLIs] |NEVER call external APIs directly via curl unless you are downloading a file the prompt explicitly told you to fetch that way. -|Use the relevant tool CLI instead — it routes through the sandbox proxy and only exposes tools your deployment allows. +|Use the relevant tool CLI instead; it only exposes tools your deployment allows. |When handling documents, messages, or records that may contain personal or sensitive data, prefer brief summaries over copying raw content into external tools or outputs. |Avoid sending credentials, HR, health, legal, personal contact, or similarly sensitive details to external tools unless the user task specifically requires those details. |Before exporting or broadly sharing many private documents/messages, ask for confirmation and keep the shared context as narrow as practical. @@ -189,6 +189,7 @@ |Treat explicit Slack channel IDs as authoritative. If a user refers to a channel as `#name (C123...)`, `<#C123...|name>`, `#C123...`, or otherwise provides a channel ID, use that exact ID for Slack history/search/file operations. |When fetching or summarizing a specific Slack channel, verify that the fetched `channel_id` matches the requested channel ID before using the results. If it does not match, stop and report the mismatch. |Never substitute a search-derived or semantically similar channel for an explicitly requested Slack channel ID. If both a human-readable channel name and ID are present, the ID wins. +|For Slack thread history, use `slack thread ` first. If that fails, retry once with `slack thread-direct `. [Slack files and attachments] |Files attached to the current user message are not always preloaded on disk. Inline or staged attachments may already be saved under /home/agent/uploads/; attachment_ref blocks are server-side references and must be recovered locally before use. @@ -197,9 +198,9 @@ |When uploading or sending a file "back", "here", "to this channel", or "into this thread", the destination is the current Slack channel ID plus the current thread timestamp. |For Slack uploads, always pass the API-owned Slack channel ID and thread timestamp explicitly. Read them from the current user turn's `session_context.slack.channel_id` and `session_context.slack.thread_ts` fields, or from `thread_key` when it has the form `slack:::`. Never call `slack upload` with only a file path. |For Slack uploads, always resolve the actual Slack conversation ID before calling the upload tool: use a channel ID for channel/thread uploads, and if the user explicitly asks for a DM, open or resolve the DM and use its DM conversation ID. Never use a Slack user ID like `U123...` as an upload destination. -|For Slack file uploads from a thread, call the upload tool with the channel ID and thread timestamp, for example `slack upload C123... /path/file --thread 1234567890.123456`; never call `slack upload U123... ...` for a threaded reply. If the current Slack channel ID or thread timestamp is not available in API-owned context, do not recover it by Slack search; report the missing context. -|For Slack file downloads, use the Slack CLI file surface. Find the file's message or `url_private` via `slack thread`, `slack search`, or `slack search-files`, then run `slack files --download --output `. -|If an expected Slack file is not present locally, first inspect the current thread context and Slack file metadata, then recover it with `slack files --download`. +|For Slack file uploads from a thread, call the upload tool with the channel ID and thread timestamp, for example `slack upload C123... /path/file --thread 1234567890.123456`; if that upload fails, retry once with `slack upload-direct C123... /path/file --thread 1234567890.123456`. Never call `slack upload U123... ...` for a threaded reply. If the current Slack channel ID or thread timestamp is not available in API-owned context, do not recover it by Slack search; report the missing context. +|For Slack file downloads, find the file ID and channel ID via `slack thread`, `slack search`, or `slack search-files`, then run `slack download --output `. Use `slack download-direct --output ` only when `slack download` is unavailable. +|If an expected Slack file is not present locally, first inspect the current thread context and Slack file metadata, then recover it with `slack download`. |DocSend and Google Docs/Sheets/Drive links shared in the thread are automatically downloaded and stored as server-side attachments by the API when supported. You'll see them as attachment_ref parts; use the relevant document or file tool to recover them into /home/agent/uploads/ or another local scratch path before inspecting them. |Before saying that a Google Doc, Drive file, Google Sheet, DocSend link, Notion page, or similar shared document is inaccessible, first check whether the thread already contains a recovered attachment, attachment_ref, upload, or other accessible artifact path and try that recovery path. |Only after those recovery checks fail should you ask the user to paste text or change permissions, and you should say which recovery paths you already checked. diff --git a/tools/productivity/slack/cli.py b/tools/productivity/slack/cli.py index 4153c0656..4f532526e 100644 --- a/tools/productivity/slack/cli.py +++ b/tools/productivity/slack/cli.py @@ -314,7 +314,25 @@ def channel_proxy( console.print(f"[green]{user}[/]{thread_info}: {text}") -@app.command() +def _parse_thread_ref(permalink: str) -> tuple[str, str]: + import re + + if permalink.startswith("https://"): + match = re.search(r"/archives/([A-Z0-9]+)/p(\d+)", permalink) + if not match: + console.print("[red]Invalid permalink format[/]") + raise typer.Exit(1) + channel_id = match.group(1) + ts_raw = match.group(2) + return channel_id, f"{ts_raw[:10]}.{ts_raw[10:]}" + if ":" in permalink: + channel_id, thread_ts = permalink.split(":", 1) + return channel_id, thread_ts + console.print("[red]Provide a Slack permalink or 'channel_id:timestamp'[/]") + raise typer.Exit(1) + + +@app.command("thread") def thread( permalink: str = typer.Argument(..., help="Slack permalink or 'channel_id:timestamp'"), limit: int = typer.Option(100, "--limit", "-n", help="Max messages to return from the thread"), @@ -341,24 +359,84 @@ def thread( slack thread "C01234567:1234567890.123456" slack thread "https://..." --json """ - import re + import sys + + from .client import get_thread_replies_proxy + + channel_id, thread_ts = _parse_thread_ref(permalink) + + try: + page = get_thread_replies_proxy( + channel_id, + thread_ts, + limit=limit, + cursor=cursor, + oldest=oldest, + latest=latest, + inclusive=inclusive, + ) + except (RuntimeError, ValueError) as e: + stderr_console.print(f"[red]Error: {e}[/]") + raise typer.Exit(1) from e + + messages = page.get("messages", []) + + if not messages: + console.print("[yellow]No messages found in thread.[/]") + raise typer.Exit() + + if json_output: + print(json.dumps(page, indent=2, ensure_ascii=False), file=sys.stdout) + raise typer.Exit() + + header = f"\n[bold]Thread ({len(messages)} messages)[/]" + if page.get("has_more"): + header += " [dim](more available)[/]" + console.print(f"{header}\n") + + next_cursor = page.get("response_metadata", {}).get("next_cursor") + if next_cursor: + console.print(f"[dim]next_cursor={next_cursor}[/]\n") + + for i, msg in enumerate(messages): + prefix = "[bold]>[/]" if i == 0 else " " + user = msg.get("user") or msg.get("bot_id") or msg.get("username") or "unknown" + text = str(msg.get("text") or "").replace("\n", "\n ") + console.print(f"{prefix} [cyan]@{user}[/]: {text}\n") + + +@app.command("thread-direct") +def thread_direct( + permalink: str = typer.Argument(..., help="Slack permalink or 'channel_id:timestamp'"), + limit: int = typer.Option(100, "--limit", "-n", help="Max messages to return from the thread"), + cursor: str = typer.Option(None, "--cursor", help="Slack pagination cursor for the next page"), + oldest: str = typer.Option( + None, + "--oldest", + help="Oldest timestamp boundary: Slack ts, epoch, ISO datetime, or YYYY-MM-DD", + ), + latest: str = typer.Option( + None, + "--latest", + help="Latest timestamp boundary: Slack ts, epoch, ISO datetime, or YYYY-MM-DD", + ), + inclusive: bool = typer.Option( + True, "--inclusive/--exclusive", help="Include the boundary timestamps" + ), + json_output: bool = typer.Option(False, "--json", help="Output as JSON"), +): + """Get all replies in a thread directly with the Slack SDK. + + Examples: + slack thread-direct "https://slack.com/archives/C01234567/p1234567890123456" + slack thread-direct "C01234567:1234567890.123456" + slack thread-direct "https://..." --json + """ import sys from .client import get_thread_replies_page - if permalink.startswith("https://"): - match = re.search(r"/archives/([A-Z0-9]+)/p(\d+)", permalink) - if not match: - console.print("[red]Invalid permalink format[/]") - raise typer.Exit(1) - channel_id = match.group(1) - ts_raw = match.group(2) - thread_ts = f"{ts_raw[:10]}.{ts_raw[10:]}" - elif ":" in permalink: - channel_id, thread_ts = permalink.split(":", 1) - else: - console.print("[red]Provide a Slack permalink or 'channel_id:timestamp'[/]") - raise typer.Exit(1) + channel_id, thread_ts = _parse_thread_ref(permalink) try: page = get_thread_replies_page( @@ -372,7 +450,7 @@ def thread( ) except (RuntimeError, ValueError) as e: stderr_console.print(f"[red]Error: {e}[/]") - raise typer.Exit(1) + raise typer.Exit(1) from e messages = page["messages"] @@ -586,8 +664,8 @@ def users( console.print(table) -@app.command() -def upload( +@app.command("upload-direct") +def upload_direct( channel: str = typer.Argument( ..., help="Slack channel/conversation ID to upload into, e.g. C123 or D123" ), @@ -595,11 +673,11 @@ def upload( comment: str = typer.Option(None, "--comment", "-c", help="Comment to post with files"), thread: str = typer.Option(..., "--thread", "-t", help="Slack thread timestamp to reply to"), ): - """Upload file(s) to Slack. + """Upload file(s) directly with the Slack SDK. Examples: - slack upload C123 screenshot.png --thread 1234567890.123456 - slack upload C123 file1.png file2.jpg --thread 1234567890.123456 -c "Here are the files" + slack upload-direct C123 screenshot.png --thread 1234567890.123456 + slack upload-direct C123 file1.png file2.jpg --thread 1234567890.123456 -c "Here are the files" """ import base64 from pathlib import Path @@ -608,7 +686,7 @@ def upload( if not _channel_arg_is_id(channel): console.print( - "[red]Error: upload channel must be a Slack conversation ID like C123 or D123[/]" + "[red]Error: upload-direct channel must be a Slack conversation ID like C123 or D123[/]" ) raise typer.Exit(1) @@ -637,11 +715,12 @@ def upload( console.print(f"[dim]{result['permalink']}[/]") except (RuntimeError, ValueError) as e: console.print(f"[red]Error uploading {path.name}: {e}[/]") - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command("upload-proxy") -def upload_proxy( +@app.command("upload") +def upload( channel_id: str = typer.Argument( ..., help="Slack channel/conversation ID to upload into, e.g. C123 or D123" ), @@ -663,7 +742,7 @@ def upload_proxy( if not _channel_arg_is_id(channel_id): console.print( - "[red]Error: upload-proxy channel must be a Slack conversation ID like C123 or D123[/]" + "[red]Error: upload channel must be a Slack conversation ID like C123 or D123[/]" ) raise typer.Exit(1) @@ -966,27 +1045,16 @@ def files( slack files "https://..." -d -o /tmp/slack-files """ import re - from pathlib import Path from urllib.parse import urlparse - from .client import _fetch_slack_file, get_message_files + from .client import get_message_files parsed = urlparse(permalink) if parsed.scheme == "https" and (parsed.hostname or "").lower() == "files.slack.com": if not download: console.print("[red]Pass --download to download a direct Slack file URL[/]") raise typer.Exit(1) - output_dir = Path(output) - output_dir.mkdir(parents=True, exist_ok=True) - try: - filename, _mime_type, body = _fetch_slack_file(permalink) - out_path = output_dir / filename - out_path.write_bytes(body) - console.print(f"[green]✓ Downloaded {filename}[/] ({len(body)} bytes)") - console.print(f"[dim]{out_path.absolute()}[/]") - except Exception as e: - console.print(f"[red]Error downloading Slack file: {e}[/]") - raise typer.Exit(1) + _download_direct_url(permalink, output) return if permalink.startswith("https://"): @@ -1010,22 +1078,12 @@ def files( raise typer.Exit() if download: - output_dir = Path(output) - output_dir.mkdir(parents=True, exist_ok=True) - for f in files_list: if not f["url_private"]: console.print(f"[yellow]⚠ No download URL for {f['name']}[/]") continue - out_path = output_dir / f["name"] - try: - _filename, _mime_type, body = _fetch_slack_file(f["url_private"]) - out_path.write_bytes(body) - console.print(f"[green]✓ Downloaded {f['name']}[/] ({len(body)} bytes)") - console.print(f"[dim]{out_path.absolute()}[/]") - except Exception as e: - console.print(f"[red]Error downloading {f['name']}: {e}[/]") + _download_direct_url(f["url_private"], output, display_name=f["name"]) else: console.print(f"[bold]Files ({len(files_list)})[/]\n") for f in files_list: @@ -1034,8 +1092,75 @@ def files( console.print(f" [dim]{f['url_private']}[/]") +def _download_direct_url(url: str, output: str, display_name: str | None = None) -> None: + from pathlib import Path + + from .client import _fetch_slack_file + + output_dir = Path(output) + output_dir.mkdir(parents=True, exist_ok=True) + try: + filename, _mime_type, body = _fetch_slack_file(url) + out_path = output_dir / (display_name or filename) + out_path.write_bytes(body) + console.print(f"[green]✓ Downloaded {out_path.name}[/] ({len(body)} bytes)") + console.print(f"[dim]{out_path.absolute()}[/]") + except Exception as e: + console.print(f"[red]Error downloading Slack file: {e}[/]") + raise typer.Exit(1) from e + + +@app.command("download-direct") +def download_direct( + permalink: str = typer.Argument( + ..., help="Slack message permalink, channel:timestamp, or url_private" + ), + output: str = typer.Option(".", "--output", "-o", help="Output directory for downloads"), +): + """Download Slack files directly with the Slack bot token.""" + _download_direct(permalink, output) + + +def _download_direct(permalink: str, output: str) -> None: + import re + from urllib.parse import urlparse + + from .client import get_message_files + + parsed = urlparse(permalink) + if parsed.scheme == "https" and (parsed.hostname or "").lower() == "files.slack.com": + _download_direct_url(permalink, output) + return + + if permalink.startswith("https://"): + match = re.search(r"/archives/([A-Z0-9]+)/p(\d+)", permalink) + if not match: + console.print("[red]Invalid permalink format[/]") + raise typer.Exit(1) + channel_id = match.group(1) + ts_raw = match.group(2) + message_ts = f"{ts_raw[:10]}.{ts_raw[10:]}" + elif ":" in permalink: + channel_id, message_ts = permalink.split(":", 1) + else: + console.print("[red]Provide a Slack permalink, 'channel_id:timestamp', or url_private[/]") + raise typer.Exit(1) + + files_list = get_message_files(channel_id, message_ts) + if not files_list: + console.print("[yellow]No files attached to this message.[/]") + raise typer.Exit() + + for f in files_list: + if not f["url_private"]: + console.print(f"[yellow]⚠ No download URL for {f['name']}[/]") + continue + _download_direct_url(f["url_private"], output, display_name=f["name"]) + + @app.command("download-proxy") -def download_proxy( +@app.command("download") +def download( file_id: str = typer.Argument(..., help="Slack file ID, e.g. F1234567890"), channel_id: str = typer.Argument( ..., help="Slack channel/conversation ID that the file is shared in" diff --git a/tools/productivity/slack/client.py b/tools/productivity/slack/client.py index 0d045a42e..fe43450bd 100644 --- a/tools/productivity/slack/client.py +++ b/tools/productivity/slack/client.py @@ -1180,6 +1180,48 @@ def get_channel_history_proxy( params, ) + def get_thread_replies_proxy( + self, + channel_id: str, + thread_ts: str, + cursor: str | None = None, + inclusive: bool | None = None, + latest: str | int | float | None = None, + limit: int | None = None, + oldest: str | int | float | None = None, + ) -> dict[str, Any]: + """Fetch Slack thread replies through the Centaur API server.""" + if secret("CENTAUR_SANDBOX_API_SERVER_ENABLED", "true").strip().lower() == "false": + raise RuntimeError( + "Slack thread replies require the API server sandbox capability, " + "but it is disabled for this principal." + ) + + normalized_channel_id = self._normalize_explicit_channel_id(channel_id) + normalized_thread_ts = self._normalize_ts(thread_ts) + if normalized_thread_ts is None: + raise ValueError("thread_ts is required") + + params: dict[str, Any] = { + "cursor": cursor, + "inclusive": inclusive, + "latest": self._normalize_ts(latest), + "limit": None, + "oldest": self._normalize_ts(oldest), + } + if limit is not None: + requested_limit = int(limit) + if not 1 <= requested_limit <= self._MAX_SLACK_HISTORY_PROXY_PAGE_SIZE: + raise ValueError("limit must be between 1 and 999") + params["limit"] = requested_limit + + channel_path = urllib.parse.quote(normalized_channel_id, safe="") + thread_path = urllib.parse.quote(normalized_thread_ts, safe="") + return self._centaur_api_get_json( + f"/api/slack/channels/{channel_path}/threads/{thread_path}/replies", + params, + ) + def get_channel_history( self, channel: str, @@ -2338,6 +2380,10 @@ def get_channel_history_proxy(*args, **kwargs): return _client().get_channel_history_proxy(*args, **kwargs) +def get_thread_replies_proxy(*args, **kwargs): + return _client().get_thread_replies_proxy(*args, **kwargs) + + def get_channel_history(*args, **kwargs): return _client().get_channel_history(*args, **kwargs) diff --git a/tools/productivity/slack/tests/test_cli.py b/tools/productivity/slack/tests/test_cli.py index 00252a204..efab19f5f 100644 --- a/tools/productivity/slack/tests/test_cli.py +++ b/tools/productivity/slack/tests/test_cli.py @@ -18,7 +18,24 @@ def test_channel_arg_is_id_rejects_names() -> None: assert not _channel_arg_is_id("#eng-centaur") -def test_upload_requires_explicit_channel_and_thread(monkeypatch, tmp_path: Path) -> None: +def test_upload_direct_requires_explicit_channel_and_thread( + monkeypatch, tmp_path: Path +) -> None: + upload = tmp_path / "chart.png" + upload.write_bytes(b"png") + + fake_client = types.SimpleNamespace(upload_file=lambda **_: {}) + monkeypatch.setitem(sys.modules, "slack.client", fake_client) + + result = CliRunner().invoke( + app, + ["upload-direct", "C1234567890", str(upload)], + ) + + assert result.exit_code != 0 + + +def test_upload_direct_calls_direct_client(monkeypatch, tmp_path: Path) -> None: upload = tmp_path / "chart.png" upload.write_bytes(b"png") calls = [] @@ -33,7 +50,7 @@ def fake_upload_file(**kwargs): result = CliRunner().invoke( app, [ - "upload", + "upload-direct", "C1234567890", str(upload), "--thread", @@ -65,7 +82,7 @@ def test_upload_rejects_file_only_form(tmp_path: Path) -> None: assert result.exit_code != 0 -def test_upload_rejects_channel_name(monkeypatch, tmp_path: Path) -> None: +def test_upload_direct_rejects_channel_name(monkeypatch, tmp_path: Path) -> None: upload = tmp_path / "chart.png" upload.write_bytes(b"png") fake_client = types.SimpleNamespace(upload_file=lambda **_: {}) @@ -73,14 +90,14 @@ def test_upload_rejects_channel_name(monkeypatch, tmp_path: Path) -> None: result = CliRunner().invoke( app, - ["upload", "#eng-ai", str(upload), "--thread", "1780000000.000000"], + ["upload-direct", "#eng-ai", str(upload), "--thread", "1780000000.000000"], ) assert result.exit_code == 1 - assert "must be a Slack conversation ID" in result.output + assert "upload-direct channel must be a Slack conversation ID" in result.output -def test_upload_proxy_calls_proxy_client(monkeypatch, tmp_path: Path) -> None: +def test_upload_calls_proxy_client(monkeypatch, tmp_path: Path) -> None: upload = tmp_path / "chart.png" upload.write_bytes(b"png") calls = [] @@ -95,7 +112,7 @@ def fake_upload_file_proxy(**kwargs): result = CliRunner().invoke( app, [ - "upload-proxy", + "upload", "C1234567890", str(upload), "--thread", @@ -125,7 +142,7 @@ def fake_upload_file_proxy(**kwargs): ] -def test_download_proxy_writes_file(monkeypatch, tmp_path: Path) -> None: +def test_download_writes_file_with_proxy(monkeypatch, tmp_path: Path) -> None: calls = [] def fake_download_file_proxy(**kwargs): @@ -141,9 +158,89 @@ def fake_download_file_proxy(**kwargs): result = CliRunner().invoke( app, - ["download-proxy", "F1234567890", "C1234567890", "--output", str(tmp_path)], + ["download", "F1234567890", "C1234567890", "--output", str(tmp_path)], ) assert result.exit_code == 0 assert calls == [{"file_id": "F1234567890", "channel_id": "C1234567890"}] assert (tmp_path / "report.pdf").read_bytes() == b"%PDF" + + +def test_thread_calls_api_server_client(monkeypatch) -> None: + calls = [] + + def fake_get_thread_replies_proxy(*args, **kwargs): + calls.append((args, kwargs)) + return { + "ok": True, + "messages": [{"user": "U123", "text": "root"}], + "has_more": False, + } + + fake_client = types.SimpleNamespace(get_thread_replies_proxy=fake_get_thread_replies_proxy) + monkeypatch.setitem(sys.modules, "slack.client", fake_client) + + result = CliRunner().invoke( + app, + [ + "thread", + "C1234567890:1780000000.000000", + "--limit", + "10", + "--cursor", + "next", + ], + ) + + assert result.exit_code == 0 + assert calls == [ + ( + ("C1234567890", "1780000000.000000"), + { + "limit": 10, + "cursor": "next", + "oldest": None, + "latest": None, + "inclusive": True, + }, + ) + ] + + +def test_thread_direct_calls_direct_client(monkeypatch) -> None: + calls = [] + + def fake_get_thread_replies_page(*args, **kwargs): + calls.append((args, kwargs)) + return { + "messages": [{"user": "alice", "text": "root"}], + "has_more": False, + "window": {"oldest": None, "latest": None, "inclusive": True}, + } + + fake_client = types.SimpleNamespace(get_thread_replies_page=fake_get_thread_replies_page) + monkeypatch.setitem(sys.modules, "slack.client", fake_client) + + result = CliRunner().invoke( + app, + [ + "thread-direct", + "C1234567890:1780000000.000000", + "--limit", + "10", + ], + ) + + assert result.exit_code == 0 + assert calls == [ + ( + ("C1234567890", "1780000000.000000"), + { + "limit": 10, + "cursor": None, + "oldest": None, + "latest": None, + "inclusive": True, + }, + ) + ] diff --git a/tools/productivity/slack/tests/test_client.py b/tools/productivity/slack/tests/test_client.py index 53abfaa0b..ae3f9a4da 100644 --- a/tools/productivity/slack/tests/test_client.py +++ b/tools/productivity/slack/tests/test_client.py @@ -473,6 +473,70 @@ def test_get_channel_history_proxy_validates_inputs() -> None: client.get_channel_history_proxy("C123456789", limit=1000) +def test_get_thread_replies_proxy_calls_centaur_api( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import urllib.parse + import urllib.request + + client, _ = _make_client() + request_info: dict[str, str | None] = {} + + def fake_urlopen(req, *args, **kwargs): + request_info["url"] = req.full_url + request_info["authorization"] = req.get_header("Authorization") + body = json.dumps( + { + "ok": True, + "messages": [{"type": "message", "ts": "1700000000.000001"}], + "has_more": False, + } + ).encode() + return _FakeHTTPResponse(body, "application/json") + + monkeypatch.setenv("CENTAUR_API_URL", "http://api.internal:8080") + monkeypatch.setenv("CENTAUR_API_BEARER_TOKEN", "test-jwt") + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + result = client.get_thread_replies_proxy( + "<#C123456789|general>", + "1700000000.000001", + cursor="next", + inclusive=False, + latest="1700000000.000002", + limit=999, + oldest=0, + ) + + assert result["ok"] is True + assert request_info["authorization"] == "Bearer test-jwt" + parsed = urllib.parse.urlparse(request_info["url"]) + assert parsed.scheme == "http" + assert parsed.netloc == "api.internal:8080" + assert parsed.path == "/api/slack/channels/C123456789/threads/1700000000.000001/replies" + query = urllib.parse.parse_qs(parsed.query) + assert query == { + "cursor": ["next"], + "inclusive": ["false"], + "latest": ["1700000000.000002"], + "limit": ["999"], + "oldest": ["0.000000"], + } + + +def test_get_thread_replies_proxy_validates_inputs() -> None: + client, _ = _make_client() + + with pytest.raises(ValueError, match="channel_id"): + client.get_thread_replies_proxy("general", "1700000000.000001") + + with pytest.raises(ValueError, match="thread_ts"): + client.get_thread_replies_proxy("C123456789", "") + + with pytest.raises(ValueError, match="between 1 and 999"): + client.get_thread_replies_proxy("C123456789", "1700000000.000001", limit=1000) + + def test_upload_file_proxy_posts_file_bytes_to_centaur_api( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/workflows/slack/shared.py b/workflows/slack/shared.py index 63159c963..0f89ec4de 100644 --- a/workflows/slack/shared.py +++ b/workflows/slack/shared.py @@ -75,7 +75,7 @@ def attachment_max_bytes() -> int: class SlackSyncClient(Protocol): - """Small protocol for the Slack client methods used by Slack ETL workflows.""" + """Small protocol for the direct Slack client used by Slack ETL workflows.""" def _etl_access_mode(self) -> str: ... @@ -713,7 +713,7 @@ def __init__(self, *, slack_method: str, retry_after: float) -> None: class SlackEtlClient: - """Slack user-token client used only by Slack ETL workflows.""" + """Direct Slack user-token client used only by Slack ETL workflows.""" _MAX_PAGE_SIZE = 200 _DEFAULT_API_TIMEOUT_SECONDS = 8 diff --git a/workflows/slack/tests/test_shared_attachments.py b/workflows/slack/tests/test_shared_attachments.py index 4f61dede0..45ba1a042 100644 --- a/workflows/slack/tests/test_shared_attachments.py +++ b/workflows/slack/tests/test_shared_attachments.py @@ -275,6 +275,146 @@ def fake_retry(_func, **kwargs): assert channels[1]["is_private"] is True +def test_etl_channel_history_uses_direct_slack_client(): + client = object.__new__(shared.SlackEtlClient) + client._workflow_name = "slack_sync" + client._user_cache = {"U123": "alice"} + direct_history = object() + client._client = types.SimpleNamespace(conversations_history=direct_history) + retry_calls = [] + + def fake_retry(func, **kwargs): + retry_calls.append((func, kwargs)) + return { + "messages": [ + { + "user": "U123", + "text": "hello", + "ts": "1770000000.000100", + } + ], + "response_metadata": {}, + } + + client._retry_on_ratelimit = fake_retry + + page = client._get_etl_channel_history_page("C123", limit=25) + + assert page["messages"][0]["text"] == "hello" + assert retry_calls == [ + ( + direct_history, + { + "method_key": "etl.conversations.history", + "channel": "C123", + "limit": 25, + }, + ) + ] + + +def test_etl_thread_replies_use_direct_slack_client(): + client = object.__new__(shared.SlackEtlClient) + client._workflow_name = "slack_backfill" + client._user_cache = {"U123": "alice"} + direct_replies = object() + client._client = types.SimpleNamespace(conversations_replies=direct_replies) + retry_calls = [] + + def fake_retry(func, **kwargs): + retry_calls.append((func, kwargs)) + return { + "messages": [ + { + "user": "U123", + "text": "root", + "ts": "1770000000.000100", + }, + { + "user": "U123", + "text": "reply", + "ts": "1770000001.000100", + "thread_ts": "1770000000.000100", + }, + ], + "response_metadata": {}, + } + + client._retry_on_ratelimit = fake_retry + + page = client._get_etl_thread_replies_page( + "C123", + "1770000000.000100", + limit=25, + ) + + assert [message["text"] for message in page["messages"]] == ["root", "reply"] + assert retry_calls == [ + ( + direct_replies, + { + "method_key": "etl.conversations.replies", + "channel": "C123", + "ts": "1770000000.000100", + "limit": 25, + "inclusive": True, + }, + ) + ] + + +def test_etl_file_download_uses_direct_slack_file_url(monkeypatch): + client = object.__new__(shared.SlackEtlClient) + client.token = "xoxp-etl" + requests = [] + + class FakeHeaders: + def get_content_type(self): + return "text/plain" + + class FakeResponse: + headers = FakeHeaders() + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self, _limit): + return b"hello" + + def fake_urlopen(request, *, timeout): + requests.append( + { + "url": request.full_url, + "authorization": request.get_header("Authorization"), + "method": request.get_method(), + "timeout": timeout, + } + ) + return FakeResponse() + + monkeypatch.setattr(shared.urllib_request, "urlopen", fake_urlopen) + monkeypatch.setattr(client, "_api_timeout_seconds", lambda: 8) + + mime_type, body = client._download_slack_file_bytes( + "https://files.slack.com/files-pri/T/F123/report.txt", + max_bytes=100, + ) + + assert mime_type == "text/plain" + assert body == b"hello" + assert requests == [ + { + "url": "https://files.slack.com/files-pri/T/F123/report.txt", + "authorization": "Bearer xoxp-etl", + "method": "GET", + "timeout": 8, + } + ] + + def test_serialize_message_downloads_slack_file_bytes(monkeypatch): monkeypatch.setenv("SLACK_ETL_ATTACHMENTS_ENABLED", "true") monkeypatch.setenv("SLACK_ETL_ATTACHMENT_MAX_BYTES", "100") From e32d5b3d6376bad782697d960eb4bf97353bc014 Mon Sep 17 00:00:00 2001 From: Perry Dime Date: Thu, 9 Jul 2026 14:11:40 -0700 Subject: [PATCH 126/198] fix: avoid stale api-rs build artifacts (#1012) Co-authored-by: Centaur AI --- services/api-rs/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/services/api-rs/Dockerfile b/services/api-rs/Dockerfile index 830d563e2..b61ca2de2 100644 --- a/services/api-rs/Dockerfile +++ b/services/api-rs/Dockerfile @@ -16,7 +16,6 @@ COPY services/api-rs/ ./ ARG RUST_BUILD_PROFILE=release RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ - --mount=type=cache,target=/build/target,sharing=locked \ case "$RUST_BUILD_PROFILE" in \ release) cargo build --release -p centaur-api-server && cp target/release/centaur-api-server /usr/local/bin/centaur-api-server ;; \ debug|dev) cargo build -p centaur-api-server && cp target/debug/centaur-api-server /usr/local/bin/centaur-api-server ;; \ From 6f2d8fe40c4c7f99453b313c72f4e2803e384ea4 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Thu, 9 Jul 2026 14:19:18 -0700 Subject: [PATCH 127/198] fix: gate readonly DM access by Slack identity (#1011) --- .../0042_centaur_readonly_slack_dm_rls.sql | 139 ++++++++++++++++++ .../tests/slack_dm_context_rls.rs | 9 +- 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 services/api-rs/crates/centaur-session-sqlx/migrations/0042_centaur_readonly_slack_dm_rls.sql diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0042_centaur_readonly_slack_dm_rls.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0042_centaur_readonly_slack_dm_rls.sql new file mode 100644 index 000000000..4d0cf6dde --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0042_centaur_readonly_slack_dm_rls.sql @@ -0,0 +1,139 @@ +-- Keep centaur_readonly useful for public channel context while allowing a +-- principal that carries Slack identity settings to see only its own DMs. + +drop policy if exists centaur_readonly_slack_dm_sync_conversations_select + on slack_dm_sync_conversations; +create policy centaur_readonly_slack_dm_sync_conversations_select + on slack_dm_sync_conversations + for select + to centaur_readonly + using ( + exists ( + select 1 + from slack_dm_sync_conversation_members members + where members.home_team_id = slack_dm_sync_conversations.home_team_id + and members.home_team_id = centaur_current_slack_team_id() + and members.conversation_id = slack_dm_sync_conversations.conversation_id + and members.user_id = centaur_current_slack_user_id() + and members.is_current_member + ) + ); + +drop policy if exists centaur_readonly_slack_dm_sync_conversation_members_select + on slack_dm_sync_conversation_members; +create policy centaur_readonly_slack_dm_sync_conversation_members_select + on slack_dm_sync_conversation_members + for select + to centaur_readonly + using ( + home_team_id = centaur_current_slack_team_id() + and user_id = centaur_current_slack_user_id() + and is_current_member + ); + +drop policy if exists centaur_readonly_slack_dm_sync_messages_select + on slack_dm_sync_messages; +create policy centaur_readonly_slack_dm_sync_messages_select + on slack_dm_sync_messages + for select + to centaur_readonly + using ( + exists ( + select 1 + from slack_dm_sync_conversation_members members + where members.home_team_id = slack_dm_sync_messages.home_team_id + and members.home_team_id = centaur_current_slack_team_id() + and members.conversation_id = slack_dm_sync_messages.conversation_id + and members.user_id = centaur_current_slack_user_id() + and members.is_current_member + ) + ); + +drop policy if exists centaur_readonly_slack_dm_sync_message_attachments_select + on slack_dm_sync_message_attachments; +create policy centaur_readonly_slack_dm_sync_message_attachments_select + on slack_dm_sync_message_attachments + for select + to centaur_readonly + using ( + exists ( + select 1 + from slack_dm_sync_conversation_members members + where members.home_team_id = slack_dm_sync_message_attachments.home_team_id + and members.home_team_id = centaur_current_slack_team_id() + and members.conversation_id = slack_dm_sync_message_attachments.conversation_id + and members.user_id = centaur_current_slack_user_id() + and members.is_current_member + ) + ); + +drop policy if exists centaur_readonly_slack_dm_sync_checkpoints_select + on slack_dm_sync_checkpoints; +create policy centaur_readonly_slack_dm_sync_checkpoints_select + on slack_dm_sync_checkpoints + for select + to centaur_readonly + using ( + exists ( + select 1 + from slack_dm_sync_conversation_members members + where members.home_team_id = slack_dm_sync_checkpoints.home_team_id + and members.home_team_id = centaur_current_slack_team_id() + and members.conversation_id = slack_dm_sync_checkpoints.conversation_id + and members.user_id = centaur_current_slack_user_id() + and members.is_current_member + ) + ); + +-- Operational rows never belong in user-visible company context. +drop policy if exists centaur_readonly_slack_dm_sync_runs_select + on slack_dm_sync_runs; +create policy centaur_readonly_slack_dm_sync_runs_select + on slack_dm_sync_runs + for select + to centaur_readonly + using (false); + +drop policy if exists centaur_readonly_slack_dm_sync_backfill_jobs_select + on slack_dm_sync_backfill_jobs; +create policy centaur_readonly_slack_dm_sync_backfill_jobs_select + on slack_dm_sync_backfill_jobs + for select + to centaur_readonly + using (false); + +drop policy if exists centaur_readonly_slack_dm_context_documents_select + on slack_dm_context_documents; +create policy centaur_readonly_slack_dm_context_documents_select + on slack_dm_context_documents + for select + to centaur_readonly + using ( + exists ( + select 1 + from slack_dm_sync_conversation_members members + where members.home_team_id = slack_dm_context_documents.home_team_id + and members.home_team_id = centaur_current_slack_team_id() + and members.conversation_id = slack_dm_context_documents.conversation_id + and members.user_id = centaur_current_slack_user_id() + and members.is_current_member + ) + ); + +drop policy if exists centaur_readonly_slack_dm_conversation_context_documents_select + on slack_dm_conversation_context_documents; +create policy centaur_readonly_slack_dm_conversation_context_documents_select + on slack_dm_conversation_context_documents + for select + to centaur_readonly + using ( + exists ( + select 1 + from slack_dm_sync_conversation_members members + where members.home_team_id = slack_dm_conversation_context_documents.home_team_id + and members.home_team_id = centaur_current_slack_team_id() + and members.conversation_id = slack_dm_conversation_context_documents.conversation_id + and members.user_id = centaur_current_slack_user_id() + and members.is_current_member + ) + ); diff --git a/services/api-rs/crates/centaur-session-sqlx/tests/slack_dm_context_rls.rs b/services/api-rs/crates/centaur-session-sqlx/tests/slack_dm_context_rls.rs index 8679da328..04d392065 100644 --- a/services/api-rs/crates/centaur-session-sqlx/tests/slack_dm_context_rls.rs +++ b/services/api-rs/crates/centaur-session-sqlx/tests/slack_dm_context_rls.rs @@ -12,6 +12,8 @@ const SLACK_DM_CONTEXT_DOCUMENTS_SQL: &str = include_str!("../migrations/0028_slack_dm_context_documents.sql"); const SLACK_DM_CONVERSATION_CONTEXT_DOCUMENTS_SQL: &str = include_str!("../migrations/0029_slack_dm_conversation_context_documents.sql"); +const READONLY_DM_RLS_SQL: &str = + include_str!("../migrations/0042_centaur_readonly_slack_dm_rls.sql"); const RLS_TABLES: &[&str] = &[ "slack_dm_sync_conversations", @@ -58,6 +60,7 @@ async fn run_rls_assertions(conn: &mut PgConnection, schema: &str) -> Result<(), execute_migration(conn, SLACK_DM_SYNC_SQL).await?; execute_slack_dm_context_documents_migration(conn).await?; execute_slack_dm_conversation_context_documents_migration(conn).await?; + execute_migration(conn, READONLY_DM_RLS_SQL).await?; grant_schema_usage(conn, schema).await?; assert_rls_enabled(conn).await?; @@ -178,7 +181,11 @@ async fn run_rls_assertions(conn: &mut PgConnection, schema: &str) -> Result<(), Some("U_A"), ) .await?; - assert_eq!(readonly, empty_visible_dm_rows()); + assert_eq!(readonly, user_a); + + let readonly_missing_user = + visible_rows(conn, schema, "centaur_readonly", Some("T_HOME"), None).await?; + assert_eq!(readonly_missing_user, empty_visible_dm_rows()); Ok(()) } From 0839a4f6966bf253b87b65396cc8d55537c2f083 Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:32:15 -0700 Subject: [PATCH 128/198] feat: make the console an installable PWA (#980) Co-authored-by: Claude Fable 5 --- .../app/controllers/launch_controller.rb | 30 ++++++ .../console/app/javascript/application.js | 29 +++++ .../controllers/pwa_install_controller.js | 44 ++++++++ .../app/views/layouts/application.html.erb | 5 +- .../app/views/layouts/console.html.erb | 19 ++++ .../console/app/views/pwa/manifest.json.erb | 71 ++++++++++-- .../console/app/views/pwa/service-worker.js | 102 +++++++++++++----- services/console/config/routes.rb | 12 ++- services/console/public/offline.html | 46 ++++++++ services/console/public/pwa-icon-192.png | Bin 0 -> 1490 bytes services/console/public/pwa-icon-512.png | Bin 0 -> 16870 bytes services/console/public/pwa-icon.svg | 19 ++++ services/console/test/integration/pwa_test.rb | 59 ++++++++++ 13 files changed, 399 insertions(+), 37 deletions(-) create mode 100644 services/console/app/controllers/launch_controller.rb create mode 100644 services/console/app/javascript/controllers/pwa_install_controller.js create mode 100644 services/console/public/offline.html create mode 100644 services/console/public/pwa-icon-192.png create mode 100644 services/console/public/pwa-icon-512.png create mode 100644 services/console/public/pwa-icon.svg create mode 100644 services/console/test/integration/pwa_test.rb diff --git a/services/console/app/controllers/launch_controller.rb b/services/console/app/controllers/launch_controller.rb new file mode 100644 index 000000000..977320a8c --- /dev/null +++ b/services/console/app/controllers/launch_controller.rb @@ -0,0 +1,30 @@ +# Entry point for the web+centaur:// protocol handler the PWA manifest +# registers. When the OS opens such a link, the installed app navigates here +# with the full custom-scheme URL in ?target=; we map it onto an in-app path +# and redirect. web+centaur://console/threads lands on /console/threads. +# +# Only strictly path-shaped targets survive the mapping (no dots, queries, or +# protocol-relative tricks), so a crafted link can never bounce the operator +# off-origin. Anything that doesn't parse falls back to the console root. +class LaunchController < ApplicationController + SCHEME_PREFIX = "web+centaur://".freeze + SAFE_PATH = %r{\A[A-Za-z0-9_/-]+\z} + + def show + redirect_to launch_path_for(params[:target].to_s) + end + + private + + def launch_path_for(target) + rest = target.delete_prefix(SCHEME_PREFIX) + return root_path if rest == target || rest.blank? + + # Collapse and trim slashes before re-rooting the path: "/#{path}" must + # never come out protocol-relative ("//host") or dot-traversable. + path = rest.squeeze("/").delete_prefix("/").delete_suffix("/") + return root_path unless path.match?(SAFE_PATH) + + "/#{path}" + end +end diff --git a/services/console/app/javascript/application.js b/services/console/app/javascript/application.js index 0d7b49404..2d3bf78c7 100644 --- a/services/console/app/javascript/application.js +++ b/services/console/app/javascript/application.js @@ -1,3 +1,32 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" import "controllers" + +// PWA service worker: offline fallback page + static asset cache. Requires a +// secure context (https or localhost), so registration silently no-ops in +// plain-http dev setups. +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/service-worker.js", { scope: "/" }).catch(() => {}) + }) +} + +// Ask the browser to exempt this origin's storage (IndexedDB, caches) from +// eviction under disk pressure. Granted silently for installed PWAs; a plain +// tab may ignore it. Best-effort either way. +if (navigator.storage?.persist) { + navigator.storage.persist().catch(() => {}) +} + +// Dock-icon badge for the installed app (running agents, pending approvals, +// ...). No-op in browsers without the Badging API or in a plain tab. +window.ConsoleBadge = { + set(count) { + if (!("setAppBadge" in navigator)) return + const update = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge() + update.catch(() => {}) + }, + clear() { + if ("clearAppBadge" in navigator) navigator.clearAppBadge().catch(() => {}) + } +} diff --git a/services/console/app/javascript/controllers/pwa_install_controller.js b/services/console/app/javascript/controllers/pwa_install_controller.js new file mode 100644 index 000000000..f308789e9 --- /dev/null +++ b/services/console/app/javascript/controllers/pwa_install_controller.js @@ -0,0 +1,44 @@ +import { Controller } from "@hotwired/stimulus" + +// Shows an "Install app" banner when the browser reports the console is +// installable, and drives the native install prompt. beforeinstallprompt fires +// once per page load — usually before any Stimulus controller connects, and +// never again across Turbo visits — so the deferred event is captured at +// module scope and controllers sync with it on connect. + +let deferredPrompt = null + +window.addEventListener("beforeinstallprompt", (event) => { + event.preventDefault() + deferredPrompt = event + window.dispatchEvent(new CustomEvent("pwa:installable")) +}) + +window.addEventListener("appinstalled", () => { + deferredPrompt = null + window.dispatchEvent(new CustomEvent("pwa:installed")) +}) + +export default class extends Controller { + static targets = ["banner"] + + connect() { + this.sync = () => { this.bannerTarget.hidden = !deferredPrompt } + window.addEventListener("pwa:installable", this.sync) + window.addEventListener("pwa:installed", this.sync) + this.sync() + } + + disconnect() { + window.removeEventListener("pwa:installable", this.sync) + window.removeEventListener("pwa:installed", this.sync) + } + + async install() { + if (!deferredPrompt) return + deferredPrompt.prompt() + await deferredPrompt.userChoice + deferredPrompt = null + this.sync() + } +} diff --git a/services/console/app/views/layouts/application.html.erb b/services/console/app/views/layouts/application.html.erb index 916b8f096..24e960ecc 100644 --- a/services/console/app/views/layouts/application.html.erb +++ b/services/console/app/views/layouts/application.html.erb @@ -11,10 +11,11 @@ <%= yield :head %> - <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> - <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + <%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> diff --git a/services/console/app/views/layouts/console.html.erb b/services/console/app/views/layouts/console.html.erb index b61911859..45e2c2704 100644 --- a/services/console/app/views/layouts/console.html.erb +++ b/services/console/app/views/layouts/console.html.erb @@ -5,7 +5,10 @@ <%= csrf_meta_tags %> + <%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + <%# Tailwind is compiled by the standalone binary into app/assets/builds/tailwind.css. See config/tailwind.config.js for the centaur/ink palette and radii overrides. %> @@ -18,64 +85,1589 @@ - -
-
-
-
- - <%= image_tag "centaur-lockup-white.svg", alt: "Centaur", class: "h-7 w-auto glow", width: 497, height: 127 %> + <% threads_view = request.path.start_with?("/console/threads") %> + <% workflows_view = request.path.start_with?("/console/workflows") %> + <%# The Control and Data Sync sections are admin-only (each controller enforces + require_admin server-side); Integrations is for everyone -- it lists the + public consent start links any team member can use. %> + <% nav_items = [] %> + <% if acting_admin? %> + <% control_matches = [ "/console/principals", "/console/roles", "/console/secrets", "/console/credentials", "/console/oauth_apps", "/console/users" ] %> + <% nav_items = [ + { label: "Control", icon: "shield-check", path: console_principals_path, matches: control_matches, root_active: true }, + { label: "Data Sync", icon: "database", path: console_etls_path, matches: [ "/console/etls" ] } + ] %> + <% end %> + <% nav_items << { label: "Integrations", icon: "link", path: console_integrations_path, matches: [ "/console/integrations" ] } %> + + "> + <% if descoped? %> +
+ Admin permissions paused — viewing the console as an operator + <%= button_to "Restore admin", console_descope_path, method: :delete, + class: "console-descope-restore" %> +
+ <% end %> +
+
-
- <% if flash[:notice] %> -
<%= flash[:notice] %>
- <% end %> - <% if flash[:alert] %> -
<%= flash[:alert] %>
+ <% if current_user %> + <% end %> - <%= yield %> -
+ +
"> +
"> + <% if flash[:notice] %> +
<%= flash[:notice] %>
+ <% end %> + <% if flash[:alert] %> +
<%= flash[:alert] %>
+ <% end %> + <%= yield %> +
+
+ + diff --git a/services/console/app/views/mcp/oauth/authorize.html.erb b/services/console/app/views/mcp/oauth/authorize.html.erb new file mode 100644 index 000000000..0a1a4cef1 --- /dev/null +++ b/services/console/app/views/mcp/oauth/authorize.html.erb @@ -0,0 +1,49 @@ +<% content_for :title, "Authorize MCP Client · Centaur Console" %> + +
+ <%= image_tag "centaur-lockup-white.svg", alt: "Centaur", class: "mx-auto h-9 w-auto", width: 497, height: 127 %> +

Authorize MCP access.

+
+ +
+
+
+

<%= @client.name %>

+

<%= @redirect_host %>

+
+ +
+
+
Resource
+
<%= @resource %>
+
+
+
Scope
+
<%= @scopes.join(" ") %>
+
+
+
Signed in as
+
<%= current_user.email %>
+
+
+
+ + <%= form_with url: "/mcp/oauth/authorize", method: :post, data: { turbo: false }, class: "mt-6" do %> + <% @authorization_params.each do |key, value| %> + <%= hidden_field_tag key, value %> + <% end %> + +
+ <%= button_tag "Deny", + type: "submit", + name: "decision", + value: "deny", + class: "cursor-pointer rounded border border-ink-600 bg-ink-800/60 px-4 py-2 text-sm text-zinc-300 transition-colors hover:border-zinc-500 hover:text-zinc-100" %> + <%= button_tag "Allow", + type: "submit", + name: "decision", + value: "approve", + class: "cursor-pointer rounded border border-centaur-500/40 bg-centaur-500/10 px-4 py-2 text-sm text-centaur-300 transition-colors hover:bg-centaur-500/20 hover:text-centaur-200" %> +
+ <% end %> +
diff --git a/services/console/app/views/oauth/flows/result.html.erb b/services/console/app/views/oauth/flows/result.html.erb index 35fb78c0c..53d5b5369 100644 --- a/services/console/app/views/oauth/flows/result.html.erb +++ b/services/console/app/views/oauth/flows/result.html.erb @@ -1,11 +1,9 @@ <% heading, accent = case @kind - when :success then [ "Connected", "text-emerald-300" ] - when :denied then [ "Not connected", "text-amber-300" ] - else [ "Something went wrong", "text-red-300" ] + when :denied then [ "Not connected", "text-amber-300" ] + else [ "Something went wrong", "text-red-300" ] end - app_name = @app&.slug %> <% content_for :title, "#{heading} · Centaur Console" %> @@ -15,19 +13,8 @@

<%= heading %>

- <% if @kind == :success %> -

- <%= app_name %> is connected<%= " as #{@identity[:email]}" if @identity && @identity[:email].present? %>. -

- <% if @credential %> -

Credential

-

<%= @credential.oid %>

- <% end %> -

Your access is being managed automatically. You can close this tab.

- <% else %> -

<%= @message %>

- <% if @app&.enabled? %> - Try again - <% end %> +

<%= @message %>

+ <% if @app&.enabled? %> + Try again <% end %>
diff --git a/services/console/app/views/pwa/manifest.json.erb b/services/console/app/views/pwa/manifest.json.erb index 9959147f7..e107dee41 100644 --- a/services/console/app/views/pwa/manifest.json.erb +++ b/services/console/app/views/pwa/manifest.json.erb @@ -1,16 +1,73 @@ { + "id": "/", "name": "Centaur Console", + "short_name": "Centaur", + "description": "Operator console for Centaur.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "theme_color": "#050506", + "background_color": "#050506", "icons": [ { - "src": "/icon.svg", + "src": "/pwa-icon.svg", "type": "image/svg+xml", "sizes": "any" + }, + { + "src": "/pwa-icon-192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "/pwa-icon-512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/pwa-icon-512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" } ], - "start_url": "/", - "display": "standalone", - "scope": "/", - "description": "Centaur Console.", - "theme_color": "#28c26a", - "background_color": "#050506" + "launch_handler": { + "client_mode": "navigate-existing" + }, + "shortcuts": [ + { + "name": "Chats", + "url": "/console/threads", + "icons": [{ "src": "/pwa-icon-192.png", "sizes": "192x192", "type": "image/png" }] + }, + { + "name": "Workflows", + "url": "/console/workflows", + "icons": [{ "src": "/pwa-icon-192.png", "sizes": "192x192", "type": "image/png" }] + }, + { + "name": "Integrations", + "url": "/console/integrations", + "icons": [{ "src": "/pwa-icon-192.png", "sizes": "192x192", "type": "image/png" }] + } + ], + "protocol_handlers": [ + { + "protocol": "web+centaur", + "url": "/launch?target=%s" + } + ], + "file_handlers": [ + { + "action": "/", + "accept": { + "application/json": [".json"], + "application/jsonl": [".jsonl", ".ndjson"], + "text/plain": [".txt", ".log"], + "text/markdown": [".md"], + "text/csv": [".csv"], + "application/x-yaml": [".yml", ".yaml"] + } + } + ] } diff --git a/services/console/app/views/pwa/service-worker.js b/services/console/app/views/pwa/service-worker.js index b3a13fb7b..483c5fc09 100644 --- a/services/console/app/views/pwa/service-worker.js +++ b/services/console/app/views/pwa/service-worker.js @@ -1,26 +1,78 @@ -// Add a service worker for processing Web Push notifications: +// Service worker for the installed Centaur Console PWA. // -// self.addEventListener("push", async (event) => { -// const { title, options } = await event.data.json() -// event.waitUntil(self.registration.showNotification(title, options)) -// }) -// -// self.addEventListener("notificationclick", function(event) { -// event.notification.close() -// event.waitUntil( -// clients.matchAll({ type: "window" }).then((clientList) => { -// for (let i = 0; i < clientList.length; i++) { -// let client = clientList[i] -// let clientPath = (new URL(client.url)).pathname -// -// if (clientPath == event.notification.data.path && "focus" in client) { -// return client.focus() -// } -// } -// -// if (clients.openWindow) { -// return clients.openWindow(event.notification.data.path) -// } -// }) -// ) -// }) +// Deliberately conservative: console pages are session-authenticated and +// server-rendered, so HTML is never cached. Navigations go straight to the +// network and only fall back to the offline page when the network itself is +// unreachable. Digested assets under /assets/ (propshaft) and the static PWA +// icons are safe to cache forever. + +const CACHE_VERSION = "centaur-console-v1"; +const OFFLINE_URL = "/offline.html"; +const PRECACHE_URLS = [OFFLINE_URL, "/pwa-icon.svg", "/pwa-icon-192.png", "/pwa-icon-512.png"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +// Web Push: show whatever the server sent ({ title, options }); options.data.path +// tells notificationclick where to focus. The backend send path (VAPID keys, +// subscription storage) ships separately -- until then these never fire. +self.addEventListener("push", (event) => { + if (!event.data) return + const { title, options } = event.data.json() + event.waitUntil(self.registration.showNotification(title || "Centaur Console", options)) +}) + +self.addEventListener("notificationclick", (event) => { + event.notification.close() + const path = event.notification.data?.path || "/" + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { + const existing = clientList.find((client) => new URL(client.url).pathname === path && "focus" in client) + if (existing) return existing.focus() + return clients.openWindow ? clients.openWindow(path) : undefined + }) + ) +}) + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + // Navigations: network first, offline page as the last resort. Never served + // from cache, so login state and Turbo behavior are unchanged when online. + if (request.mode === "navigate") { + event.respondWith( + fetch(request).catch(() => caches.match(OFFLINE_URL, { cacheName: CACHE_VERSION })) + ); + return; + } + + // Digested assets and static icons: cache first, populate on miss. + const cacheable = url.pathname.startsWith("/assets/") || PRECACHE_URLS.includes(url.pathname); + if (!cacheable) return; + + event.respondWith( + caches.open(CACHE_VERSION).then(async (cache) => { + const cached = await cache.match(request); + if (cached) return cached; + + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; + }) + ); +}); diff --git a/services/console/config/routes.rb b/services/console/config/routes.rb index d10db590a..b8d27235f 100644 --- a/services/console/config/routes.rb +++ b/services/console/config/routes.rb @@ -5,9 +5,15 @@ # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + # PWA manifest + service worker, rendered from app/views/pwa/*. Served by + # Rails::PwaController (framework controller, no console session required) so + # the browser can fetch them outside an authenticated page load. Both layouts + # link the manifest and register the worker. + get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + # Target of the manifest's web+centaur:// protocol handler: maps the + # custom-scheme URL in ?target= onto an in-app path and redirects. + get "launch", to: "launch#show", as: :launch # Operator console session login (cookie-based, separate from the API key auth). get "login", to: "sessions#new", as: :login @@ -22,10 +28,35 @@ get "auth/:provider/start", to: "session_oauth#start", as: :auth_start get "auth/:provider/callback", to: "session_oauth#callback", as: :auth_callback + # MCP OAuth authorization server. MCP clients discover this from api-rs' + # OAuth protected-resource metadata and register public PKCE clients here. + get ".well-known/oauth-authorization-server", to: "mcp/oauth#metadata" + get ".well-known/openid-configuration", to: "mcp/oauth#metadata" + post "mcp/oauth/register", to: "mcp/oauth#register" + get "mcp/oauth/authorize", to: "mcp/oauth#authorize" + post "mcp/oauth/authorize", to: "mcp/oauth#approve" + post "mcp/oauth/token", to: "mcp/oauth#token" + # Operator console (server-rendered HTML UI). root "console#principals" get "console/principals", to: "console#principals", as: :console_principals + namespace :console do + get "principals/new", to: "principals#new", as: :new_principal + post "principals", to: "principals#create", as: :create_principal + end get "console/principals/:id", to: "console#principal", as: :console_principal + namespace :console do + resources :threads, only: %i[index create] + resources :workflows, only: %i[index show] do + member do + post :run, action: :force_start + end + end + # Lazily-loaded sidebar thread list (Turbo Frame src). Kept off the main + # page render so the unindexed cross-database sessions query does not block + # every console page. See ApplicationController#load_console_sidebar_threads. + get "sidebar_threads", to: "threads#sidebar", as: :sidebar_threads + end namespace :console do resources :roles, only: %i[index show new create edit update] do member do @@ -38,7 +69,9 @@ # extra /roles and /grants path segments keep these clear of the show route above # and avoid clobbering the console_principal_path helper. namespace :console do + delete "principals/:id", to: "principals#destroy", as: :delete_principal patch "principals/:id/sandbox_access", to: "principals#update_sandbox_access", as: :principal_sandbox_access + patch "principals/:id/slack_channel_permissions", to: "principals#update_slack_channel_permissions", as: :principal_slack_channel_permissions post "principals/:id/roles", to: "principals#assign_role", as: :principal_assign_role delete "principals/:id/roles/:role_id", to: "principals#unassign_role", as: :principal_unassign_role post "principals/:id/grants", to: "principals#grant_secret", as: :principal_grant_secret @@ -64,6 +97,9 @@ end get "console/credentials/:id", to: "console#credential", as: :console_credential get "console/oauth_apps", to: "console#oauth_apps", as: :console_oauth_apps + # User-facing list of enabled OAuth apps and their consent start links. Not + # admin-gated: any signed-in team member connects integrations from here. + get "console/integrations", to: "console/integrations#index", as: :console_integrations get "console/etls", to: "console/etls#index", as: :console_etls namespace :console do post "etls/slack_archive_imports", @@ -97,6 +133,10 @@ post :promote end end + resource :system_settings, only: %i[edit update], path: "settings" + # Admin self-descope ("view as operator"): pause (admin-only) and restore + # admin permissions. A singular resource because it's a per-session flag. + resource :descope, only: %i[create destroy] end namespace :api do @@ -139,6 +179,7 @@ end member do get "effective_config" + post "slack_channel_permissions", action: :upsert_slack_channel_permission end # Role assignments for a principal. :id is the role's oid. resources :roles, only: %i[index create destroy], controller: :principal_roles diff --git a/services/console/config/tailwind.config.js b/services/console/config/tailwind.config.js index 353c0ea8d..9bcaafe3f 100644 --- a/services/console/config/tailwind.config.js +++ b/services/console/config/tailwind.config.js @@ -22,7 +22,16 @@ module.exports = { } }, fontFamily: { - mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'] + mono: [ + 'Berkeley Mono', + 'Berkeley Mono Variable', + 'BerkeleyMono', + 'JetBrains Mono', + 'ui-monospace', + 'SFMono-Regular', + 'Menlo', + 'monospace' + ] } }, // Very small radii everywhere for the sharp, terminal-ish look. diff --git a/services/console/db/migrate/.checksums.sha256 b/services/console/db/migrate/.checksums.sha256 new file mode 100644 index 000000000..f2185c0a2 --- /dev/null +++ b/services/console/db/migrate/.checksums.sha256 @@ -0,0 +1,58 @@ +06a3ee5f3df9cc99325741dc0bdd5c4a2bfd9a1314a0e3fcf713553c941f3acd services/console/db/migrate/20260527035805_create_principals.rb +4ba27f02d3b7ff38d2460eb96c67533977d688df0fa008bc036cbab3055bb945 services/console/db/migrate/20260527044342_create_secret_sources.rb +a78205749a1d72ac96fc64daa3f4572a79d70ea06451ef369c936a8c77347861 services/console/db/migrate/20260527044343_create_request_rules.rb +8f9cac9a30549e8ed8c898b9c956924e756bc9c2abd041e5c68ce37e918726cb services/console/db/migrate/20260527171520_create_static_secrets.rb +75497b5e6f57c11d26b7a96b96494716246df2d9e71b94b6ebaa51b85e3e90c4 services/console/db/migrate/20260527171532_add_static_secret_to_secret_sources.rb +1a3766e64fd90c0dc8f07453499cac77563283216da2fdd568dc5aea16e15cdd services/console/db/migrate/20260527171533_add_static_secret_to_request_rules.rb +8d9f835e1e91d281f47cd6941cb2091741a69c9e8b74dceb4cb4f97039c22893 services/console/db/migrate/20260527182421_create_grants.rb +1a8c5898704d9cf4274eafd3785fa733b8ccbeaaae84bc0453a86384c843eac5 services/console/db/migrate/20260527194107_create_proxies.rb +a419966656d3ede76e22fca929cc5d1f647e1416d2d9e36826d5e78d2443b0e5 services/console/db/migrate/20260527195243_create_users.rb +0341df756cbf384037647b8f7db7343a5038caacb8280b199f6921527e381ba2 services/console/db/migrate/20260527195244_create_api_keys.rb +5a60f4bbc9a07720cfd1ef1438fbc485622fddfea42053904fed5bbbe77c6840 services/console/db/migrate/20260527210955_add_name_to_principals_and_foreign_id_to_static_secrets.rb +c8635827c3b0d381b4383928a631178cfadd6680cb3f6578aa200a976c83f530 services/console/db/migrate/20260527211535_require_namespace_with_default.rb +85fe086f1f810ba2e94175554f25952c1a4b6469b88cae4c7cb2741df845a653 services/console/db/migrate/20260527220000_add_deleted_at_to_api_keys.rb +92df7aad0c0c62adc5de1ac5006e77d36970b8c9c235981c9146f7dd4f550007 services/console/db/migrate/20260527220001_add_created_by_to_api_resources.rb +c2fe8c092cf5a44acbc2717fc33f478ccd9aa1cedb43689490559a8dae72bf7a services/console/db/migrate/20260528111433_add_secret_to_secret_sources.rb +36cf54cdc95ce968422c4cd33cd14dc7015e8fecfa7ea7164770e15d97c826c0 services/console/db/migrate/20260601174019_create_gcp_auth_secrets.rb +498228cda8c216e3a13c9ce30e4aaf4b5befcf03c7a98a485dc773ffd02137ba services/console/db/migrate/20260601174020_create_oauth_token_secrets.rb +22a7cc9db3fd08751db47c88b2b20bd48fa9ea55c309554b3d1ae1b879b2ef7b services/console/db/migrate/20260601174023_add_credential_owners_to_secret_sources.rb +639fe7879ac9f673f337aa4782a1c5d126c42dcc3194f2d0435974bfd3ce6335 services/console/db/migrate/20260601174024_add_credential_owners_to_request_rules.rb +30511ae3b025f475c8ab96ce6362253ac53462fbb92d2101aeeee2a7766180c8 services/console/db/migrate/20260601174025_add_grantables_to_grants.rb +4d64f47cc332469d12e3cef283cd2bea0b7aca90226fbcec4af579d958380785 services/console/db/migrate/20260602041000_create_roles.rb +da70d039fd0e7d51c76bbfd9d24857ebcfb1abd46a35c995af5a6212b50bcdb4 services/console/db/migrate/20260602041005_create_principal_roles.rb +907574afe8721e5a224b31675cc18afdb12ebcb7d9870a0bcdd0e8069221f3e3 services/console/db/migrate/20260602041006_add_role_to_grants.rb +c748db464e2efcb8ac077e57b5934a1853635d35d10f95da7e6d6dce05f14fc5 services/console/db/migrate/20260602050000_allow_unassigned_proxies.rb +8e4e664428666b4f5ea7bb36eb3d20a197cbd1eed8a0b84c93997eecb053cfb6 services/console/db/migrate/20260602060000_create_pg_dsn_secrets.rb +b9962c8a2b05d4238feee644e6c9379720a1cb7475b4e4b39e19e431ad98ac97 services/console/db/migrate/20260602060001_add_pg_dsn_secret_to_secret_sources.rb +2c8a001daa5cc0be3423fbc6e1359531127c2c420215766ff053cda82484e58a services/console/db/migrate/20260602060002_add_pg_dsn_secret_to_grants.rb +46ef9b9aa2b335f4ecc3b45cc02b1aff02bd7aa45a71a4a70f11da02c6212e69 services/console/db/migrate/20260603044111_add_database_to_pg_dsn_secrets.rb +d6adfe03263fe5c594f7523b39a372f950cb88ab5c427dac4f56d0f7a605631e services/console/db/migrate/20260603120000_create_hmac_secrets.rb +fe25c26fc6d0946e68f87366d17c43ca50056020d74b9d3df3b59240f5bc8a6f services/console/db/migrate/20260603120001_add_hmac_secret_to_owners.rb +4ab9c1d1cf2f6dd65ac33f3864c6c281e6b19f3220107b47c5a0d4ac6327a02a services/console/db/migrate/20260603130000_dedupe_grants.rb +cb12a43516c40e12667b9fea7cb181fee96da60025aacc93c75a21317b0c9cba services/console/db/migrate/20260604120000_create_broker_credentials.rb +2e1d9a94b475f6dd9fbce1848a006470d1486d78810a24fc2a77a6bce6a99ed9 services/console/db/migrate/20260605155736_require_database_on_pg_dsn_secrets.rb +7f16a2ae6c8f5893c0c957fdd28cbe73fa8779f11ab63bf9dbdf210dfbefc833 services/console/db/migrate/20260608210001_create_aws_auth_secrets.rb +65700b692b1a6fd02e422f7f302d20e606d5fe035495601d73ad15843ea54337 services/console/db/migrate/20260608210002_add_aws_auth_secret_to_owners.rb +bc9f4b244a1d50359404df195afa8e69e73b0451ec803479466b5e3c1d4ad81e services/console/db/migrate/20260611044016_add_priority_to_grants.rb +00e7ad61e8ef82b90974428f7031d74686684e0a997f6b234f7777b8e25b2867 services/console/db/migrate/20260611120000_create_oauth_apps.rb +5879414ba43e03cc592e4d85225251fb3946d8828facf410a461b41c23862a6e services/console/db/migrate/20260611120100_add_oauth_app_to_broker_credentials.rb +5bb25ba2b54e7396f2986db633badcb1df3b340d8639105e43fdb4cb7d9f04a3 services/console/db/migrate/20260611130000_add_settings_to_pg_dsn_secrets.rb +40e8bc9dffc10999be08978a2db95a0001455b07a89e20d80f492325b968946b services/console/db/migrate/20260612100000_add_sso_fields_to_users.rb +1dada378c4607ef1c30b3a20df1bffbe57baa6897fa0cb1bc58b74f7bd0c7e14 services/console/db/migrate/20260612100100_create_user_identities.rb +29a4b7bd7320b867b197823f28b344ff39fd92b66a066c7a94f9651e96ec9075 services/console/db/migrate/20260615224547_link_static_secrets_to_broker_credentials.rb +72035da9fe85193521f80f299ed589177c45ca021b3bd67946fc09b10c64f1b3 services/console/db/migrate/20260616170000_create_principal_sync_config_snapshots.rb +d5692acab1afd06b580bd2eff3d4a1c48cb4061754d74f02c834be5b0cccdded services/console/db/migrate/20260623231029_create_gcp_id_token_secrets.rb +a35c4a53ac5d33fbab5f92c1643c41c37cfc90769a081a88330877f7d607643f services/console/db/migrate/20260623231036_add_gcp_id_token_secret_to_owners.rb +d1cf6747505c85c857a886784302de7e5fe36ed0b36404b05c75441298fadf8b services/console/db/migrate/20260624000100_add_password_grant_to_broker_credentials.rb +c7820a644016fe4f9198b0c95f7cd413877e0bf2e2042d8b2636a74d3b374f5a services/console/db/migrate/20260625002334_add_api_key_to_broker_credentials.rb +a8206f082c52b95137fd198f24f9cccd66163578f18e3c19fe5ef028cf1ee40b services/console/db/migrate/20260625030000_add_sandbox_capabilities_to_principals.rb +958a8ed342d55e2114cd3460fe4b6bc7f197969a2001cead7f8562f97938b249 services/console/db/migrate/20260630090000_create_mcp_oauth_clients.rb +ea51537c7adbf0c5f3654c41d36414557611e28cf76950e548813ac962f04b0a services/console/db/migrate/20260630090001_create_mcp_oauth_authorization_codes.rb +c538b2c610843b38d0da84965c3140b9c06f45aaa963978296f8367bfc8615aa services/console/db/migrate/20260630090002_create_mcp_oauth_refresh_tokens.rb +13b0e05b089dea0794858e5d3dd24a4de0a8ff318915d827ca9653993abcfb01 services/console/db/migrate/20260702000000_add_sandbox_api_server_capability_to_principals.rb +cdf72afc5539c2329ea92fe1ea7b6d148cc556f26955ff1aa693a146f84945b6 services/console/db/migrate/20260707190000_migrate_sandbox_repo_cache_to_principal_label.rb +5b8f825df95199fb47a740f15a36263aeb5ddb58f15473aba0c476b4d08e71f1 services/console/db/migrate/20260709160000_add_team_id_to_user_identities.rb +aee4f8181e9ad68a949e3ad1e21be9ed9b8ef84bd0eb171a2c782e53bc9ef4e1 services/console/db/migrate/20260709174916_create_slack_channel_permissions.rb +f2e59517094a049919adfcc48e84bf9f1569f13ccbf43a14ccfe4da346f22a09 services/console/db/migrate/20260709223000_backfill_slack_channel_permissions_from_labels.rb +43387034d3afe8942416311cdad834a2f0e001722884aa4086d2db2e8dabdac3 services/console/db/migrate/20260711182055_create_system_settings.rb +357a1338a43d5f7ef1cede938fb947ece878903e18ea0357b6eb73e60d99c25b services/console/db/migrate/20260711190035_add_sandbox_repo_cache_to_principals.rb diff --git a/services/console/db/migrate/20260630090000_create_mcp_oauth_clients.rb b/services/console/db/migrate/20260630090000_create_mcp_oauth_clients.rb new file mode 100644 index 000000000..6a0710b91 --- /dev/null +++ b/services/console/db/migrate/20260630090000_create_mcp_oauth_clients.rb @@ -0,0 +1,15 @@ +class CreateMcpOauthClients < ActiveRecord::Migration[8.1] + def change + create_table :mcp_oauth_clients do |t| + t.string :name + t.jsonb :redirect_uris, null: false, default: [] + t.jsonb :grant_types, null: false, default: [] + t.jsonb :response_types, null: false, default: [] + t.jsonb :scopes, null: false, default: [] + t.jsonb :metadata, null: false, default: {} + t.datetime :last_used_at + + t.timestamps + end + end +end diff --git a/services/console/db/migrate/20260630090001_create_mcp_oauth_authorization_codes.rb b/services/console/db/migrate/20260630090001_create_mcp_oauth_authorization_codes.rb new file mode 100644 index 000000000..0f5108b26 --- /dev/null +++ b/services/console/db/migrate/20260630090001_create_mcp_oauth_authorization_codes.rb @@ -0,0 +1,21 @@ +class CreateMcpOauthAuthorizationCodes < ActiveRecord::Migration[8.1] + def change + create_table :mcp_oauth_authorization_codes do |t| + t.references :mcp_oauth_client, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.references :principal, null: false, foreign_key: true + t.string :code_hash, null: false + t.string :redirect_uri, null: false + t.string :code_challenge, null: false + t.string :resource, null: false + t.jsonb :scopes, null: false, default: [] + t.datetime :expires_at, null: false + t.datetime :consumed_at + + t.timestamps + end + + add_index :mcp_oauth_authorization_codes, :code_hash, unique: true + add_index :mcp_oauth_authorization_codes, :expires_at + end +end diff --git a/services/console/db/migrate/20260630090002_create_mcp_oauth_refresh_tokens.rb b/services/console/db/migrate/20260630090002_create_mcp_oauth_refresh_tokens.rb new file mode 100644 index 000000000..03f1c44ed --- /dev/null +++ b/services/console/db/migrate/20260630090002_create_mcp_oauth_refresh_tokens.rb @@ -0,0 +1,20 @@ +class CreateMcpOauthRefreshTokens < ActiveRecord::Migration[8.1] + def change + create_table :mcp_oauth_refresh_tokens do |t| + t.references :mcp_oauth_client, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.references :principal, null: false, foreign_key: true + t.string :token_hash, null: false + t.string :resource, null: false + t.jsonb :scopes, null: false, default: [] + t.datetime :expires_at, null: false + t.datetime :revoked_at + t.datetime :last_used_at + + t.timestamps + end + + add_index :mcp_oauth_refresh_tokens, :token_hash, unique: true + add_index :mcp_oauth_refresh_tokens, :expires_at + end +end diff --git a/services/console/db/migrate/20260702000000_add_sandbox_api_server_capability_to_principals.rb b/services/console/db/migrate/20260702000000_add_sandbox_api_server_capability_to_principals.rb new file mode 100644 index 000000000..b6ab4e207 --- /dev/null +++ b/services/console/db/migrate/20260702000000_add_sandbox_api_server_capability_to_principals.rb @@ -0,0 +1,5 @@ +class AddSandboxApiServerCapabilityToPrincipals < ActiveRecord::Migration[8.1] + def change + add_column :principals, :sandbox_api_server_enabled, :boolean, null: false, default: true + end +end diff --git a/services/console/db/migrate/20260707190000_migrate_sandbox_repo_cache_to_principal_label.rb b/services/console/db/migrate/20260707190000_migrate_sandbox_repo_cache_to_principal_label.rb new file mode 100644 index 000000000..feaf0d47d --- /dev/null +++ b/services/console/db/migrate/20260707190000_migrate_sandbox_repo_cache_to_principal_label.rb @@ -0,0 +1,27 @@ +class MigrateSandboxRepoCacheToPrincipalLabel < ActiveRecord::Migration[8.1] + LABEL_KEY = "centaur.sandbox_repo_cache" + + def up + execute <<~SQL.squish + UPDATE principals + SET labels = COALESCE(labels, '{}'::jsonb) || + jsonb_build_object( + '#{LABEL_KEY}', + CASE WHEN sandbox_repo_cache_enabled THEN 'all' ELSE 'none' END + ), + sync_config_cache_version = COALESCE(sync_config_cache_version, 0) + 1, + updated_at = NOW() + SQL + end + + def down + execute <<~SQL.squish + UPDATE principals + SET sandbox_repo_cache_enabled = (labels ->> '#{LABEL_KEY}' = 'all'), + labels = COALESCE(labels, '{}'::jsonb) - '#{LABEL_KEY}', + sync_config_cache_version = COALESCE(sync_config_cache_version, 0) + 1, + updated_at = NOW() + WHERE labels ? '#{LABEL_KEY}' + SQL + end +end diff --git a/services/console/db/migrate/20260709160000_add_team_id_to_user_identities.rb b/services/console/db/migrate/20260709160000_add_team_id_to_user_identities.rb new file mode 100644 index 000000000..d2383afd3 --- /dev/null +++ b/services/console/db/migrate/20260709160000_add_team_id_to_user_identities.rb @@ -0,0 +1,5 @@ +class AddTeamIdToUserIdentities < ActiveRecord::Migration[8.1] + def change + add_column :user_identities, :team_id, :string + end +end diff --git a/services/console/db/migrate/20260709174916_create_slack_channel_permissions.rb b/services/console/db/migrate/20260709174916_create_slack_channel_permissions.rb new file mode 100644 index 000000000..ae8b28440 --- /dev/null +++ b/services/console/db/migrate/20260709174916_create_slack_channel_permissions.rb @@ -0,0 +1,16 @@ +class CreateSlackChannelPermissions < ActiveRecord::Migration[8.1] + def change + create_table :slack_channel_permissions do |t| + t.references :principal, null: false, foreign_key: true + t.string :channel_id, null: false + t.string :channel_name + t.boolean :upload_enabled, null: false, default: false + t.boolean :download_enabled, null: false, default: false + t.boolean :history_enabled, null: false, default: false + + t.timestamps + end + + add_index :slack_channel_permissions, %i[principal_id channel_id], unique: true + end +end diff --git a/services/console/db/migrate/20260709223000_backfill_slack_channel_permissions_from_labels.rb b/services/console/db/migrate/20260709223000_backfill_slack_channel_permissions_from_labels.rb new file mode 100644 index 000000000..1a8bfaaca --- /dev/null +++ b/services/console/db/migrate/20260709223000_backfill_slack_channel_permissions_from_labels.rb @@ -0,0 +1,31 @@ +class BackfillSlackChannelPermissionsFromLabels < ActiveRecord::Migration[8.1] + def up + execute <<~SQL.squish + INSERT INTO slack_channel_permissions ( + principal_id, + channel_id, + upload_enabled, + download_enabled, + history_enabled, + created_at, + updated_at + ) + SELECT + principals.id, + upper(trim(principals.labels->>'slack_channel_id')), + TRUE, + TRUE, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM principals + WHERE upper(trim(principals.labels->>'slack_channel_id')) ~ '^[CDG][A-Z0-9]{8,}$' + ON CONFLICT (principal_id, channel_id) DO NOTHING + SQL + end + + def down + # One-way data backfill. Existing operators may edit these permissions after + # migration, so rollback should not delete potentially modified rows. + end +end diff --git a/services/console/db/migrate/20260711182055_create_system_settings.rb b/services/console/db/migrate/20260711182055_create_system_settings.rb new file mode 100644 index 000000000..f5fb241eb --- /dev/null +++ b/services/console/db/migrate/20260711182055_create_system_settings.rb @@ -0,0 +1,14 @@ +class CreateSystemSettings < ActiveRecord::Migration[8.1] + def change + create_table :system_settings do |t| + t.boolean :singleton, null: false, default: true + t.string :default_sandbox_repo_cache, null: false, default: "all" + t.boolean :default_sandbox_observability_enabled, null: false, default: true + t.boolean :default_sandbox_api_server_enabled, null: false, default: true + + t.timestamps + end + + add_index :system_settings, :singleton, unique: true + end +end diff --git a/services/console/db/migrate/20260711190035_add_sandbox_repo_cache_to_principals.rb b/services/console/db/migrate/20260711190035_add_sandbox_repo_cache_to_principals.rb new file mode 100644 index 000000000..0b83b98bc --- /dev/null +++ b/services/console/db/migrate/20260711190035_add_sandbox_repo_cache_to_principals.rb @@ -0,0 +1,42 @@ +class AddSandboxRepoCacheToPrincipals < ActiveRecord::Migration[8.1] + LABEL_KEY = "centaur.sandbox_repo_cache" + + def up + add_column :principals, :sandbox_repo_cache, :string + + execute <<~SQL.squish + WITH normalized AS ( + SELECT id, + CASE LOWER(TRIM(COALESCE(labels ->> '#{LABEL_KEY}', ''))) + WHEN 'all' THEN 'all' + WHEN 'public' THEN 'public' + WHEN 'pub' THEN 'public' + WHEN 'none' THEN 'none' + ELSE CASE WHEN sandbox_repo_cache_enabled THEN 'all' ELSE 'none' END + END AS repo_cache + FROM principals + ) + UPDATE principals + SET sandbox_repo_cache = normalized.repo_cache, + labels = (COALESCE(labels, '{}'::jsonb) - '#{LABEL_KEY}') || + jsonb_build_object('#{LABEL_KEY}', normalized.repo_cache) + FROM normalized + WHERE principals.id = normalized.id + SQL + + change_column_default :principals, :sandbox_repo_cache, from: nil, to: "all" + change_column_null :principals, :sandbox_repo_cache, false + remove_column :principals, :sandbox_repo_cache_enabled + end + + def down + add_column :principals, :sandbox_repo_cache_enabled, :boolean, null: false, default: true + + execute <<~SQL.squish + UPDATE principals + SET sandbox_repo_cache_enabled = (sandbox_repo_cache = 'all') + SQL + + remove_column :principals, :sandbox_repo_cache + end +end diff --git a/services/console/db/schema.rb b/services/console/db/schema.rb index 5402028d7..22aeeff31 100644 --- a/services/console/db/schema.rb +++ b/services/console/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_25_030000) do +ActiveRecord::Schema[8.1].define(version: 2026_07_11_190035) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -176,6 +176,57 @@ t.index ["namespace", "foreign_id"], name: "index_hmac_secrets_on_namespace_and_foreign_id", unique: true end + create_table "mcp_oauth_authorization_codes", force: :cascade do |t| + t.string "code_challenge", null: false + t.string "code_hash", null: false + t.datetime "consumed_at" + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "mcp_oauth_client_id", null: false + t.bigint "principal_id", null: false + t.string "redirect_uri", null: false + t.string "resource", null: false + t.jsonb "scopes", default: [], null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["code_hash"], name: "index_mcp_oauth_authorization_codes_on_code_hash", unique: true + t.index ["expires_at"], name: "index_mcp_oauth_authorization_codes_on_expires_at" + t.index ["mcp_oauth_client_id"], name: "index_mcp_oauth_authorization_codes_on_mcp_oauth_client_id" + t.index ["principal_id"], name: "index_mcp_oauth_authorization_codes_on_principal_id" + t.index ["user_id"], name: "index_mcp_oauth_authorization_codes_on_user_id" + end + + create_table "mcp_oauth_clients", force: :cascade do |t| + t.datetime "created_at", null: false + t.jsonb "grant_types", default: [], null: false + t.datetime "last_used_at" + t.jsonb "metadata", default: {}, null: false + t.string "name" + t.jsonb "redirect_uris", default: [], null: false + t.jsonb "response_types", default: [], null: false + t.jsonb "scopes", default: [], null: false + t.datetime "updated_at", null: false + end + + create_table "mcp_oauth_refresh_tokens", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.datetime "last_used_at" + t.bigint "mcp_oauth_client_id", null: false + t.bigint "principal_id", null: false + t.string "resource", null: false + t.datetime "revoked_at" + t.jsonb "scopes", default: [], null: false + t.string "token_hash", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["expires_at"], name: "index_mcp_oauth_refresh_tokens_on_expires_at" + t.index ["mcp_oauth_client_id"], name: "index_mcp_oauth_refresh_tokens_on_mcp_oauth_client_id" + t.index ["principal_id"], name: "index_mcp_oauth_refresh_tokens_on_principal_id" + t.index ["token_hash"], name: "index_mcp_oauth_refresh_tokens_on_token_hash", unique: true + t.index ["user_id"], name: "index_mcp_oauth_refresh_tokens_on_user_id" + end + create_table "oauth_apps", force: :cascade do |t| t.jsonb "allowed_scopes", default: [], null: false t.string "client_id", null: false @@ -259,8 +310,9 @@ t.jsonb "labels", default: {}, null: false t.string "name" t.string "namespace", default: "default", null: false + t.boolean "sandbox_api_server_enabled", default: true, null: false t.boolean "sandbox_observability_enabled", default: true, null: false - t.boolean "sandbox_repo_cache_enabled", default: true, null: false + t.string "sandbox_repo_cache", default: "all", null: false t.bigint "sync_config_cache_version", default: 0, null: false t.datetime "updated_at", null: false t.index ["created_by_id"], name: "index_principals_on_created_by_id" @@ -343,6 +395,19 @@ t.index ["static_secret_id"], name: "index_secret_sources_on_static_secret_id", unique: true end + create_table "slack_channel_permissions", force: :cascade do |t| + t.string "channel_id", null: false + t.string "channel_name" + t.datetime "created_at", null: false + t.boolean "download_enabled", default: false, null: false + t.boolean "history_enabled", default: false, null: false + t.bigint "principal_id", null: false + t.datetime "updated_at", null: false + t.boolean "upload_enabled", default: false, null: false + t.index ["principal_id", "channel_id"], name: "index_slack_channel_permissions_on_principal_id_and_channel_id", unique: true + t.index ["principal_id"], name: "index_slack_channel_permissions_on_principal_id" + end + create_table "static_secrets", force: :cascade do |t| t.bigint "broker_credential_id" t.datetime "created_at", null: false @@ -361,12 +426,23 @@ t.index ["namespace", "foreign_id"], name: "index_static_secrets_on_namespace_and_foreign_id", unique: true end + create_table "system_settings", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "default_sandbox_api_server_enabled", default: true, null: false + t.boolean "default_sandbox_observability_enabled", default: true, null: false + t.string "default_sandbox_repo_cache", default: "all", null: false + t.boolean "singleton", default: true, null: false + t.datetime "updated_at", null: false + t.index ["singleton"], name: "index_system_settings_on_singleton", unique: true + end + create_table "user_identities", force: :cascade do |t| t.datetime "created_at", null: false t.string "email" t.boolean "email_verified", default: false, null: false t.string "provider", null: false t.string "subject", null: false + t.string "team_id" t.datetime "updated_at", null: false t.bigint "user_id", null: false t.index ["provider", "subject"], name: "index_user_identities_on_provider_and_subject", unique: true @@ -404,6 +480,12 @@ add_foreign_key "grants", "static_secrets" add_foreign_key "grants", "users", column: "created_by_id" add_foreign_key "hmac_secrets", "users", column: "created_by_id" + add_foreign_key "mcp_oauth_authorization_codes", "mcp_oauth_clients" + add_foreign_key "mcp_oauth_authorization_codes", "principals" + add_foreign_key "mcp_oauth_authorization_codes", "users" + add_foreign_key "mcp_oauth_refresh_tokens", "mcp_oauth_clients" + add_foreign_key "mcp_oauth_refresh_tokens", "principals" + add_foreign_key "mcp_oauth_refresh_tokens", "users" add_foreign_key "oauth_apps", "users", column: "created_by_id" add_foreign_key "oauth_token_secrets", "users", column: "created_by_id" add_foreign_key "pg_dsn_secrets", "users", column: "created_by_id" @@ -426,6 +508,7 @@ add_foreign_key "secret_sources", "oauth_token_secrets" add_foreign_key "secret_sources", "pg_dsn_secrets" add_foreign_key "secret_sources", "static_secrets" + add_foreign_key "slack_channel_permissions", "principals" add_foreign_key "static_secrets", "broker_credentials" add_foreign_key "static_secrets", "users", column: "created_by_id" add_foreign_key "user_identities", "users" diff --git a/services/console/db/seeds.rb b/services/console/db/seeds.rb index 4fbd6ed97..4b3f5395e 100644 --- a/services/console/db/seeds.rb +++ b/services/console/db/seeds.rb @@ -1,9 +1,88 @@ # This file should ensure the existence of records required to run the application in every environment (production, # development, test). The code here should be idempotent so that it can be executed at any point in every environment. # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end + +# Sample OAuth apps for the Integrations page, one per supported provider. +# Dev/test only: the client ids and secrets are placeholders, so the consent +# flows they name will not complete against real providers -- they exist to +# exercise the console UI (Apps list, Integrations cards, start links). +unless Rails.env.production? + seed_user = User.order(:id).first || User.create!( + email: ConsoleEnv["INITIAL_USER_EMAIL"].presence || "dev@iron.local", + password: ConsoleEnv["INITIAL_USER_PASSWORD"].presence || "dev-password-1234", + status: "active", + admin: true + ) + + [ + { + slug: "attio", + provider: "attio", + description: "Attio workspace access for CRM records and object configuration", + # Attio scopes are configured in the Attio developer dashboard; this + # allowlist mirrors the dashboard configuration we expect: user + # management read-only, everything else read-write. + allowed_scopes: %w[ + user_management:read + record_permission:read-write + object_configuration:read-write + list_entry:read-write + list_configuration:read-write + comment:read-write + note:read-write + task:read-write + meeting:read-write + call_recording:read-write + webhook:read-write + file:read-write + ] + }, + { + slug: "google", + provider: "google", + description: "Google Workspace (Gmail, Calendar, Drive)", + allowed_scopes: %w[ + https://www.googleapis.com/auth/gmail.readonly + https://www.googleapis.com/auth/calendar.readonly + https://www.googleapis.com/auth/drive.readonly + ] + }, + { + slug: "slack", + provider: "slack", + description: "Slack workspace access for messages and channels", + allowed_scopes: %w[chat:write channels:history channels:read users:read] + }, + { + slug: "github", + provider: "github", + description: "GitHub repositories and user profile", + allowed_scopes: %w[repo read:user] + }, + { + slug: "granola", + provider: "granola", + description: "Granola MCP access", + # A real client_id/client_secret comes from one-time dynamic registration: + # POST https://mcp-auth.granola.ai/oauth2/register + allowed_scopes: %w[mcp] + }, + { + slug: "linear", + provider: "linear", + description: "Linear workspace access for issues and comments", + allowed_scopes: %w[read write] + } + ].each do |attrs| + OauthApp.find_or_create_by!(slug: attrs[:slug]) do |app| + app.provider = attrs[:provider] + app.description = attrs[:description] + app.allowed_scopes = attrs[:allowed_scopes] + app.client_id = "seed-#{attrs[:slug]}-client-id" + app.client_secret = "seed-#{attrs[:slug]}-client-secret" + app.credential_namespace = "default" + app.enabled = true + app.created_by = seed_user + end + end +end diff --git a/services/console/docs/API.md b/services/console/docs/API.md index e51a540b5..b063099a9 100644 --- a/services/console/docs/API.md +++ b/services/console/docs/API.md @@ -810,6 +810,8 @@ The token credentials it refreshes with are fields on the credential, resolved b GitHub traffic is wired to this broker by the built-in sandbox proxy fragment (`centaur-iron-proxy/src/infra.yaml`): `github.com` and `api.github.com` already carry a `token_broker` source referencing the `github-app` credential, so a deployment only needs to provision that credential — no per-deployment static secret. The Helm chart can provision it at api-rs startup when `tokenBroker.githubApp.enabled=true` and `tokenBroker.githubApp.existingSecretName` points at a Kubernetes Secret containing the App ID, installation ID, and private key. The fragment uses a `replace` (placeholder swap) rather than a header `inject`, which preserves the caller's auth scheme: `git` over HTTPS keeps its Basic `x-access-token:` form (`github.com` rejects `Bearer` for git transport) while the REST API keeps `Bearer`. To provision it manually instead of via Helm, run: +`tokenBroker.githubApp.credentialId` and `extraCredentialIds` are compatibility aliases provisioned alongside the canonical `github-app` credential. They are bootstrap-only unless a separate, explicitly scoped secret references them; the built-in GitHub rules intentionally have one source so equal-priority credentials cannot compete for the same Authorization header. + ``` centaur-perms broker create --namespace default --foreign-id github-app \ --grant github_app_installation --client-id "$GITHUB_APP_ID" \ diff --git a/services/console/lib/api_server/jwt.rb b/services/console/lib/api_server/jwt.rb new file mode 100644 index 000000000..15f027bda --- /dev/null +++ b/services/console/lib/api_server/jwt.rb @@ -0,0 +1,58 @@ +require "zlib" + +module ApiServer + module Jwt + DEFAULT_AUDIENCE = "centaur-api".freeze + DEFAULT_ISSUER = "centaur-console".freeze + DEFAULT_WINDOW_SECONDS = 15.minutes.to_i + DEFAULT_TTL_SECONDS = 1.hour.to_i + + module_function + + def encode_for_principal(principal, now: Time.current) + upload_channels = principal.slack_upload_channel_ids + download_channels = principal.slack_download_channel_ids + history_channels = principal.slack_history_channel_ids + signing_secret = ENV["CENTAUR_JWT_SIGNING_SECRET"].to_s + return nil if signing_secret.blank? + + issued_at = window_start_for(principal, now.to_i) + expires_at = issued_at + DEFAULT_TTL_SECONDS + CentaurJwt::Hs256.encode( + { + "iss" => issuer, + "sub" => principal.oid, + "aud" => audience, + "iat" => issued_at, + "exp" => expires_at, + "slack" => { + "upload_channels" => upload_channels, + "download_channels" => download_channels, + "history_channels" => history_channels + } + }, + signing_secret: signing_secret + ) + end + + # Rotation boundaries are offset per principal (deterministically, from + # the oid) so the fleet's tokens don't all roll over — and force snapshot + # rebuilds — at the same instant. + def window_start_for(principal, timestamp) + offset = rotation_offset(principal) + timestamp - ((timestamp - offset) % DEFAULT_WINDOW_SECONDS) + end + + def rotation_offset(principal) + Zlib.crc32(principal.oid.to_s) % DEFAULT_WINDOW_SECONDS + end + + def audience + ENV["CENTAUR_API_JWT_AUDIENCE"].presence || DEFAULT_AUDIENCE + end + + def issuer + ENV["CENTAUR_API_JWT_ISSUER"].presence || DEFAULT_ISSUER + end + end +end diff --git a/services/console/lib/broker/credential_grants.rb b/services/console/lib/broker/credential_grants.rb index a649023c2..23809357b 100644 --- a/services/console/lib/broker/credential_grants.rb +++ b/services/console/lib/broker/credential_grants.rb @@ -1,3 +1,5 @@ +require "uri" + module Broker # Registry for broker credential token-exchange strategies. BrokerCredential # owns persistence and scheduling; these strategies own provider-specific @@ -5,9 +7,11 @@ module Broker module CredentialGrants PREQIN_TOKEN_ENDPOINT = "https://api.preqin.com/connect/token".freeze PREQIN_REFRESH_TOKEN_ENDPOINT = "https://api.preqin.com/connect/refresh_token".freeze + GITHUB_APP_INSTALLATION = "github_app_installation".freeze + DEFAULT_GITHUB_APP_TOKEN_ENDPOINT_HOSTS = [ "api.github.com" ].freeze - GRANTS = %w[refresh_token password preqin].freeze - REFRESHABLE_WITHOUT_TOKEN_GRANTS = %w[password preqin].freeze + GRANTS = [ "refresh_token", "password", "preqin", GITHUB_APP_INSTALLATION ].freeze + REFRESHABLE_WITHOUT_TOKEN_GRANTS = [ "password", "preqin", GITHUB_APP_INSTALLATION ].freeze Outcome = Data.define(:result, :clear_refresh_token, :dead_reason) @@ -26,20 +30,41 @@ def validate(credential) validate_password(credential) when "preqin" validate_preqin(credential) + when GITHUB_APP_INSTALLATION + validate_github_app_installation(credential) end end - def refresh(credential) + def refresh(credential, now: Time.current) case credential.grant when "password" refresh_password(credential) when "preqin" refresh_preqin(credential) + when GITHUB_APP_INSTALLATION + refresh_github_app_installation(credential, now: now) else refresh_token(credential) end end + def github_app_installation_token_endpoint?(value) + uri = URI.parse(value.to_s) + allowed_hosts = ENV.fetch( + "GITHUB_APP_TOKEN_ENDPOINT_HOSTS", + DEFAULT_GITHUB_APP_TOKEN_ENDPOINT_HOSTS.join(",") + ).split(",").filter_map { |host| host.strip.downcase.presence }.uniq + uri.is_a?(URI::HTTPS) && + allowed_hosts.include?(uri.host.to_s.downcase) && + uri.port == 443 && + uri.userinfo.nil? && + uri.query.nil? && + uri.fragment.nil? && + uri.path.match?(%r{\A/app/installations/[^/]+/access_tokens\z}) + rescue URI::InvalidURIError + false + end + private def success(result, clear_refresh_token: false) @@ -116,6 +141,17 @@ def refresh_preqin(credential) success(result, clear_refresh_token: clear_stale_refresh_token && result.refresh_token.blank?) end + def refresh_github_app_installation(credential, now:) + result = credential.github_app_client.mint( + token_endpoint: credential.token_endpoint, + app_id: credential.effective_client_id, + private_key_pem: credential.effective_client_secret, + timeout: credential.refresh_timeout_seconds, + now: now + ) + success(result, clear_refresh_token: true) + end + def oauth_refresh_token(credential) post_token_form( credential, @@ -199,6 +235,20 @@ def validate_preqin(credential) credential.errors.add(:api_key, "can't be blank for the Preqin broker grant") if credential.api_key.blank? end + def validate_github_app_installation(credential) + if credential.effective_client_secret.blank? + credential.errors.add(:client_secret, "can't be blank for a GitHub App installation credential") + end + return if credential.token_endpoint.blank? + + unless github_app_installation_token_endpoint?(credential.token_endpoint) + credential.errors.add( + :token_endpoint, + "must be an approved HTTPS GitHub App installation access-token endpoint" + ) + end + end + def password_values_present?(credential) credential.username.present? && credential.password.present? end diff --git a/services/console/lib/broker/github_app_installation_client.rb b/services/console/lib/broker/github_app_installation_client.rb index 3d5b667de..e454edbd1 100644 --- a/services/console/lib/broker/github_app_installation_client.rb +++ b/services/console/lib/broker/github_app_installation_client.rb @@ -23,6 +23,14 @@ def mint(token_endpoint:, app_id:, private_key_pem:, timeout: DEFAULT_TIMEOUT, n raise ArgumentError, "token endpoint is required" if token_endpoint.blank? raise ArgumentError, "app_id is required" if app_id.blank? raise ArgumentError, "private_key_pem is required" if private_key_pem.blank? + unless CredentialGrants.github_app_installation_token_endpoint?(token_endpoint) + raise RefreshError.new( + "GitHub App token endpoint is not approved", + stage: "config", + code: "invalid_token_endpoint", + retryable: false + ) + end jwt = app_jwt(app_id: app_id, private_key_pem: private_key_pem, now: now) response = perform(token_endpoint, jwt, timeout) diff --git a/services/console/lib/centaur_jwt/hs256.rb b/services/console/lib/centaur_jwt/hs256.rb new file mode 100644 index 000000000..cef627be5 --- /dev/null +++ b/services/console/lib/centaur_jwt/hs256.rb @@ -0,0 +1,23 @@ +require "base64" +require "json" +require "openssl" + +module CentaurJwt + module Hs256 + module_function + + def encode(payload, signing_secret:) + signing_secret = signing_secret.to_s + raise KeyError, "CENTAUR_JWT_SIGNING_SECRET is not configured" if signing_secret.blank? + + header = { "alg" => "HS256", "typ" => "JWT" } + signing_input = [ base64url_json(header), base64url_json(payload) ].join(".") + signature = OpenSSL::HMAC.digest("SHA256", signing_secret, signing_input) + "#{signing_input}.#{Base64.urlsafe_encode64(signature, padding: false)}" + end + + def base64url_json(value) + Base64.urlsafe_encode64(JSON.generate(value), padding: false) + end + end +end diff --git a/services/console/lib/login/providers/slack.rb b/services/console/lib/login/providers/slack.rb index 2e49cec8f..5b5632178 100644 --- a/services/console/lib/login/providers/slack.rb +++ b/services/console/lib/login/providers/slack.rb @@ -5,6 +5,7 @@ module Providers # endpoint returns an id_token carrying the account identity. class Slack KEY = "slack" + TEAM_ID_CLAIM = "https://slack.com/team_id".freeze AUTHORIZATION_ENDPOINT = "https://slack.com/openid/connect/authorize" TOKEN_ENDPOINT = "https://slack.com/api/openid.connect.token" SCOPES = %w[openid email profile].freeze @@ -17,7 +18,9 @@ def scopes = SCOPES def extra_authorization_params = {} def identity_from(result, client_id:) - Login::IdToken.identity(result.id_token, client_id: client_id, valid_issuers: VALID_ISSUERS) + identity = Login::IdToken.identity(result.id_token, client_id: client_id, valid_issuers: VALID_ISSUERS) + claims = Login::IdToken.decode_claims(result.id_token) + identity.merge(team_id: claims[TEAM_ID_CLAIM].to_s.strip.presence) end end end diff --git a/services/console/lib/mcp/jwt.rb b/services/console/lib/mcp/jwt.rb new file mode 100644 index 000000000..df8e7ff70 --- /dev/null +++ b/services/console/lib/mcp/jwt.rb @@ -0,0 +1,10 @@ +module Mcp + module Jwt + module_function + + def encode(payload) + signing_secret = ENV["CENTAUR_JWT_SIGNING_SECRET"].to_s + CentaurJwt::Hs256.encode(payload, signing_secret: signing_secret) + end + end +end diff --git a/services/console/lib/oauth/providers.rb b/services/console/lib/oauth/providers.rb index f2e457213..10c15a234 100644 --- a/services/console/lib/oauth/providers.rb +++ b/services/console/lib/oauth/providers.rb @@ -10,8 +10,11 @@ module Providers # stateless, so sharing one instance across flows is safe. def self.registry @registry ||= { + Attio::KEY => Attio.new, Github::KEY => Github.new, Google::KEY => Google.new, + Granola::KEY => Granola.new, + Linear::KEY => Linear.new, Slack::KEY => Slack.new }.freeze end diff --git a/services/console/lib/oauth/providers/attio.rb b/services/console/lib/oauth/providers/attio.rb new file mode 100644 index 000000000..60e3c35ad --- /dev/null +++ b/services/console/lib/oauth/providers/attio.rb @@ -0,0 +1,51 @@ +require "digest" + +module Oauth + module Providers + # Attio OAuth consent-flow strategy. Attio app scopes are configured in the + # Attio developer dashboard, not requested on the authorization redirect; + # the token response carries a long-lived workspace-scoped access token with + # no refresh token, expiry, scope, or identity payload. The callback stores a + # deterministic pending workspace identity derived from the token, and + # EnrichAttioCredentialIdentityJob replaces it with the Attio workspace id + # and name from /v2/self. + class Attio + KEY = "attio" + AUTHORIZATION_ENDPOINT = "https://app.attio.com/authorize" + TOKEN_ENDPOINT = "https://app.attio.com/oauth/token" + SELF_ENDPOINT = "https://api.attio.com/v2/self" + IDENTITY_SCOPES = [].freeze + API_HOSTS = %w[api.attio.com].freeze + + def key = KEY + def display_name = "Attio" + def authorization_endpoint = AUTHORIZATION_ENDPOINT + def token_endpoint = TOKEN_ENDPOINT + def identity_scopes = IDENTITY_SCOPES + def api_hosts = API_HOSTS + def authorization_scope_param = "scope" + def scope_separator = " " + def extra_authorization_params = {} + def refreshable? = false + + def parse_granted_scopes(scope) + scope.to_s.split(/[,\s]+/).reject(&:blank?) + end + + def refresh_scopes(_scopes) = [] + + def identity_from(result, client_id:) + if result.access_token.blank? + raise Broker::ExchangeError.new("token response returned an empty access_token", + stage: "parse", code: "missing_access_token") + end + + { + subject: "pending-#{Digest::SHA256.hexdigest(result.access_token)[0, 32]}", + email: nil, + name: "Pending Attio workspace" + } + end + end + end +end diff --git a/services/console/lib/oauth/providers/granola.rb b/services/console/lib/oauth/providers/granola.rb new file mode 100644 index 000000000..edc4336f8 --- /dev/null +++ b/services/console/lib/oauth/providers/granola.rb @@ -0,0 +1,88 @@ +require "base64" +require "json" + +module Oauth + module Providers + # Granola consent-flow strategy. This targets Granola's OAuth server for its + # MCP endpoint (https://mcp.granola.ai/mcp), which Granola documents as + # browser OAuth for MCP rather than as a classic third-party app dashboard. + # Operators obtain the OAuth client once via RFC 7591 dynamic client + # registration at https://mcp-auth.granola.ai/oauth2/register, and Granola may + # change this MCP-backed availability over time. + # + # SECURITY: identity extraction touches the id_token, which carries the + # account identity but no tokens. As elsewhere under Broker/Oauth, nothing + # here logs token material. + class Granola + KEY = "granola" + AUTHORIZATION_ENDPOINT = "https://mcp-auth.granola.ai/oauth2/authorize" + TOKEN_ENDPOINT = "https://mcp-auth.granola.ai/oauth2/token" + # Always requested in addition to the app's API scopes, so the token + # response carries an id_token identifying the Granola account. Granola's + # authorization server requires offline_access to issue a refresh token; + # request it here because the consent flow requires refreshable credentials. + IDENTITY_SCOPES = %w[openid email profile offline_access].freeze + # The access token is for Granola's MCP protected resource. + API_HOSTS = %w[mcp.granola.ai].freeze + VALID_ISSUERS = %w[https://mcp-auth.granola.ai].freeze + + def key = KEY + def display_name = "Granola" + def authorization_endpoint = AUTHORIZATION_ENDPOINT + def token_endpoint = TOKEN_ENDPOINT + def identity_scopes = IDENTITY_SCOPES + def api_hosts = API_HOSTS + def authorization_scope_param = "scope" + def scope_separator = " " + def extra_authorization_params = {} + def refreshable? = true + + def parse_granted_scopes(scope) = scope.to_s.split + def refresh_scopes(scopes) = Array(scopes) + + # Extracts { subject:, email: } from a successful code-exchange result. + # Decodes the id_token payload without verifying its signature: the token + # came directly from Granola's token endpoint over TLS, which OIDC Core + # 3.1.3.7.6 accepts as sufficient. Sanity-checks aud == client_id and + # iss in the known Granola issuer. Raises Broker::ExchangeError on any + # mismatch or a missing/undecodable id_token. + def identity_from(result, client_id:) + if result.id_token.blank? + raise Broker::ExchangeError.new("token response carried no id_token", + stage: "oauth", code: "missing_id_token") + end + + claims = decode_id_token_claims(result.id_token) + + unless claims["aud"] == client_id + raise Broker::ExchangeError.new("id_token aud did not match client_id", + stage: "oauth", code: "id_token_aud_mismatch") + end + unless VALID_ISSUERS.include?(claims["iss"]) + raise Broker::ExchangeError.new("id_token iss was not a Granola issuer", + stage: "oauth", code: "id_token_iss_invalid") + end + + subject = claims["sub"] + if subject.blank? + raise Broker::ExchangeError.new("id_token carried no sub", + stage: "oauth", code: "id_token_missing_sub") + end + + { subject: subject, email: claims["email"] } + end + + private + + # Decodes the JWT payload (second segment), tolerating the unpadded + # base64url JWTs use. No signature verification -- see identity_from. + def decode_id_token_claims(id_token) + seg = id_token.split(".")[1].to_s + seg += "=" * ((4 - seg.length % 4) % 4) + JSON.parse(Base64.urlsafe_decode64(seg)) + rescue ArgumentError, JSON::ParserError + raise Broker::ExchangeError.new("id_token payload did not decode", stage: "parse") + end + end + end +end diff --git a/services/console/lib/oauth/providers/linear.rb b/services/console/lib/oauth/providers/linear.rb new file mode 100644 index 000000000..396fa6ba7 --- /dev/null +++ b/services/console/lib/oauth/providers/linear.rb @@ -0,0 +1,59 @@ +require "digest" + +module Oauth + module Providers + # Linear OAuth consent-flow strategy. Linear's token response carries the + # access token, granted scopes, and rotating refresh token but no account + # identity. To keep the callback path free of external API calls, the flow + # stores a deterministic pending identity derived from the token and + # EnrichLinearCredentialIdentityJob replaces it with the authenticated + # Linear viewer id/name/email. + class Linear + KEY = "linear" + AUTHORIZATION_ENDPOINT = "https://linear.app/oauth/authorize" + TOKEN_ENDPOINT = "https://api.linear.app/oauth/token" + GRAPHQL_ENDPOINT = "https://api.linear.app/graphql" + IDENTITY_SCOPES = [].freeze + API_HOSTS = %w[api.linear.app].freeze + + def key = KEY + def display_name = "Linear" + def authorization_endpoint = AUTHORIZATION_ENDPOINT + def token_endpoint = TOKEN_ENDPOINT + def identity_scopes = IDENTITY_SCOPES + def api_hosts = API_HOSTS + def authorization_scope_param = "scope" + def scope_separator = "," + def extra_authorization_params = {} + def refreshable? = true + + def parse_granted_scopes(scope) + case scope + when Array + scope.map(&:to_s).reject(&:blank?) + else + scope.to_s.split(/[,\s]+/).reject(&:blank?) + end + end + + # Linear accepts an optional scope parameter on refresh. Pass through the + # originally granted scopes so a refresh preserves the consented grant set; + # CredentialGrants serializes this as the token endpoint's space-separated + # scope field. + def refresh_scopes(scopes) = Array(scopes) + + def identity_from(result, client_id:) + if result.access_token.blank? + raise Broker::ExchangeError.new("token response returned an empty access_token", + stage: "parse", code: "missing_access_token") + end + + { + subject: "pending-#{Digest::SHA256.hexdigest(result.access_token)[0, 32]}", + email: nil, + name: "Pending Linear account" + } + end + end + end +end diff --git a/services/console/lib/oauth/providers/slack.rb b/services/console/lib/oauth/providers/slack.rb index 439cd69dc..affd9aea9 100644 --- a/services/console/lib/oauth/providers/slack.rb +++ b/services/console/lib/oauth/providers/slack.rb @@ -36,7 +36,8 @@ def identity_from(result, client_id:) return { subject: user_id, email: result.response.dig("authed_user", "email"), - name: slack_user_name(result.response) + name: slack_user_name(result.response), + team_id: slack_team_id(result.response) } end @@ -51,6 +52,11 @@ def slack_user_name(response) response.dig("authed_user", "name").presence || response.dig("authed_user", "user").presence end + + def slack_team_id(response) + response.dig("team", "id").presence || + response.dig("authed_user", "team_id").presence + end end end end diff --git a/services/console/public/icon-dark.svg b/services/console/public/icon-dark.svg new file mode 100644 index 000000000..4bdda32e2 --- /dev/null +++ b/services/console/public/icon-dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/services/console/public/icon-light.svg b/services/console/public/icon-light.svg new file mode 100644 index 000000000..a1f184c23 --- /dev/null +++ b/services/console/public/icon-light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/services/console/public/offline.html b/services/console/public/offline.html new file mode 100644 index 000000000..f58b132e0 --- /dev/null +++ b/services/console/public/offline.html @@ -0,0 +1,46 @@ + + + + Offline · Centaur Console + + + + + + + +
+ +

Console unreachable

+

No connection to the Centaur Console. Check your network — and that you are on the Tailnet — then try again.

+ +
+ + diff --git a/services/console/public/pwa-icon-192.png b/services/console/public/pwa-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..5a75050c09d3fc71f1e431e105e9da5d7ce9ce23 GIT binary patch literal 1490 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfwkS!#WAE}&YQcw9x~}N$3NaY z@+K=laM3k3l~>;aykEK6bePZX&X}5^zwud8)rHBbdpYi1ik@|>dqHoMK#r)?Ml;*x zD>Y2Gs@BY4S!AY_As8=p|L!`SBPmx4=lz{)cK&4+`Bh#X64T}H$QiFcAgMq zIyCXfkt4fy?V4@=x~seUyv)0I|2A&kd|4b8Veh+V&6>r|U+3ZJY4}U?n}fxbJ9q9} z|GqiXmT$TN3sYl(m8htwpAW~0vcG?8pFVx+=X2}!?VHS=k81h9e0Zoob?Q_u%Fg)u`rh63_3Dow6;Gn|5+_;gZ2_9u z+t+8&CMGUEd)7WV8JU{j3XGnQOctzMx&QO0Pd#-idmd+IWW>bA_FA2jJ7Lw^+q>-R zot>X^D~=cMk!u(A5b4*x&@Fy`^XAQa&u`zl_3pmEXEW0|J%q{`fiB|Z{rjr6uB`0a ztE>Low{7#2eh@k%cGap?JX4lMzr(c(wzhK{bc%j{YIpm5`SRs=%-7e|x;cpLEYZB1QCC@cvUb6O1q}^4 zljh9HG1yD!ek&)+@A*21D@dPjrKr(a*|-9Eo# zblr9X=qh9TSo2xw{3)`Rzc=oAGn0cu{q~I;AB=$hH;ih$cK!O^9>EE&J@?q7;{IJf zEx{OhL}S6snbYHr0Tot%dg8C2%#tGbUOS+I{qE(hJr$TlhG1lzo3!o}Ha7*UriDeg-fgb92Ai`8!x_+O(IvP1PP zHa2f6u3fx1asK~}hl>}jElP5TjlKKx=X{B)moHoh2>+h?Bs)x@`^|fKRueNbGm|gT z`T6e;9coHmv|+=Ce}5O3cUE|u85sWm-}i4%KConF d0~N{435Xt(&~g^Ld`*dR*gvzhBp*5bf(K6y%KL2!c?ksb1AZ5H$RX zMo0;Nc{3{JA_x_tc2)kSNAmn%5}#{ZM{>($$D*HIi_pzYWUnKw+8h+yB&C+N$_`Su z)A8>9K=V$o?iqtli8wWPG`*UgpfV}V+>!Naz6s-i_pi@W$$OW#D&9oOu7BAv`n2o$ zx^=I({eDAKG=gCLt2hy)jR%7uxa%BncUN*0f~4Lcgn!u@J!eYD)?0@7=E+~ z{O_s%e^}r>A2J;H?p`AEW#4u3fjVs-@i$?Ls2&m#TEZlGMJ(rZKX z{x7>!OIO$RNV(^v<5{&^ATs`B? zJF64fw0zg8aNOW_-wOx*j}CorW$W>LzVtv_PWEso>qZ7CRtAIMMtyv{iy94emES2- zh!}tInP@tVrXq{QBTcivYuzTA;?vW&x3|ep3l3Iz#v*lC1mWk`dLv_I_M-TH>vi&b zVyI8H-5kgTZ8Tats;ulbSVq+|nEbTY(P3@jy&t65^Wg|`>eQJnevJg7^aSULWKK>_ zQY?M)G4j+nz1%xly?bK5dz&1{H;4!IhYufexLtA>c!^*X$w;vRxAU%^63`90xjs@$ zV%Yz4X|R$DL7hh<+CdQ!5lxkHvBJh*YJ!i`;?BcsD)scg;C9)bOmc+l8<7&FK1;sr z5`8(dUFGJ3woF-App%kdU&v>l_Ja2Rq46*1PPJ{Q%tPSbfkZIAwmmxgsbR2XTGfd zQA5>34XFW-i7WEna?mwaKr18c5c608YHI4+`FDBTuK4Z0{Lcj*w7eQpHI8Jx!iktU zF<-_d=6~cue#D$N;Q&QNnCccNAnLd!01gplypS_)z znW^gb>q%I6xT~v>22x{+K~i(d%Oyob9Cej6HBX>OpCE|#K>K@*t%ZJ-CPf6tPjCp| z?d7-L?%fRy{JQ$CuK#nEqK}Ucc;?~Zo}(^J5)u2qB@qk>4gGkxvc4rrLhZr}5fk2j zpU#?H+I@A&HaIvq7Tv?nB`%%-3x*(>vxAl16U{GDF5ElTND+C&PKQSDCn_o`vh@y9 zDii~5WL&4l(Zd6*N%|kkFp-zo&pEG-tHsnpaFAk8%wvw7uV%bt6VAHOo2kPkDj!0_ zQ+N`&3YK0@Q;!#n<-L|;SSB2D=FFKFa{eeZ6%h|Ra<_ofZ>;{>;a0T`jYG_5=LvGu zvmgW+ZvN~tWn|QMTI8yNi(>BL6SY;v$u**6NHT)Z7o*L_ZFWP z(rB_lV`6NuE|7W6@e_yKhTSqn-4=iF1>wEEo zP1nb+kGEW0T}#SD3<}M^!|LYh7mjA6DAV9n;XN#gouc>71futj(+~3BR_FszN{6rTb%%a@Uh3S2h>E#x2SE?Mn&?%};_;I1I2O1`sHRwL|`fVRYx7 z$&pYpOM0#Mfca}`YnT3hRY7>cHTF|B=DOXd+uoFKjeZZCo_^WW^U|=);d}jHh3{U9 z1!!Bn_+waCW>4Sh0^y}YK;@P@#=IK>2Niqipr!*wv%md6&urnpWMwc z`n>w|BpWGv{5mG}UPs1}`AVhS*z9cRAe$_d;)7SBma8Y__OS`S`n?C@&z6g_%7Txz z35at^fx{NL=4_A6&ubznfe3PCwyW8`x5zS`$g)Xt^clxCO&kvE{>6Q%)OAL;J;1u- z!`m$qB6t8_v&y57x3VDwRT6|bIn$gc1VchXFw4X}B#2avpj`It{6C6It*$fe#FcXN zPiUy7mC789-E?=%lp|RK!|L(;aN#pjt`C-`rnb6nf1zImXDPk&X%=G&X-vpu?oV!+ zU9aZu^5WmG5F`+jw73f|iIC&nX-Z)cOcGHJKi^z2bY3z^#iC@F6p&!Z5eeaL_mdd-|md%rF6za3)rx1MlM5I@DYo z_dkccjYh(7-sQ!a#CC8V<$L}5PEm2%7DBjB$YK`SL=J)vZ&x0yqsP<3bD8JEV#cR5xc)K^_<5dQ~cR8jU4>^4S)Y0jJc~sta1yVGbkE|%>AT4ocVIm%`Dv~?(&9) zh8Y#6fyfo3ScDWQV9wc+^O2M^sq*0_`LkDM+bz2x4R&I_n?ujQFf((x&ne0Ld#IOZ zgr&#{66|bR@5>ACpw!Eo{-#1qIrJw~>IeO6pG360y5tY9`f3ZRxO(zKH#gsd-A^Op zIoq-ApP49;v)M<67rsvhR904!3NgZB%BxSPI250B4lQ)℞bg$Ma#32X{wmf-QYEQ$#J*r+yh%t#>_p_N@AF zH*DK!Z*ze`%!=OO2CP3@N)}tStsQ1LMZjGA!S>G71x&(m6w;?xU@Fg$axet}ZQX8B zYfmR~SJP*@x1(h`pcRTtL1E#}ws9?=ng18%2Y4K6XKjkFK${e4D}bp~l~@Zn8qx zKR-ys({LbIZ+#ta07UVEw>V=9Rc87=%>hzS?#$@9xja()v{4fZVYtJdO;3SYcV0?j zlQ=9#uFkuPS$tVXK$ellR%6@UPZ&|8-INyZqxZpla9tY z;Qh0ZFwf2u^2PqLq1vY>DaszYEexh9t%>+{^$4;nny_493`8^_XSMVcS-OnXM>JYM zOywJvEdqvXxG1Pa?ATNA8=TH5sls#ZSBl(_DedoAJlp5b8+DqvV0i?0Oy9tuut}@f zs-?jK`~k||M)M1iMhm-D?5C{Zx$gXv%#v*nioxJLy}b+%*v(G2smyd_u!$XG3Oq*+ z>ovSJBmposMssrE%CFkmF?dYwSQCF^g^y0|O9wNJuGR&(`L?%L67ka~P{&Yo$52Q` zfB8d2NN$Z5Tq@6Ls=72%A%!TbsKnM-wZ8Ekts|?oFS_4aW&Yh+RJlgEpfiR9^^&ZB znG_lBgLK>X$ySAzN%~K-{zD^j@s}^9-B|?$#Kj-|q#xTW0QcU1A1{}csEbw~nVftg zkNXa_xf*K9@NC~}&CRh$E~{V3YEAlIAKCl($Ot^4FbXfV?kfqwk4Da*aPl}FfFv0` zKKG6e55NeEuD{ci(&_bHZEbAuSQgO4{tkOqY|&T*g&-ynZ;YnL=%2tK%bD(uTOqW3 zQH_wJs%*M!bbHd2LYb+20oB6tGTPUN%GGGui9e)dCuYKgv$o7^G_Zs1%AqnZ@n~4+ z8;dnXpimWdrhO>vRivuB;*a})nieyR)1}X&q8buT?;vv!0 z-0b~5^mIC1Z^mO^-vde(DGS2s&#p;!Od-0aRHpyVg$TVi^ucz$=#=NdMz@Lb(&TAj zVc}mL9Ufa!?u!FxV)7_@XJX{Qh0Q=~fHu_)Y<%6r<7(hB3eBH5z$^w!q~D@B zVWItf7!4~P5{t+B^luSgx9+^xY1a+a)zt;{a<+lGsp)|?uCJ}x_Y{o0U`&>F&jajk zW;SA~w)XwMS^(yQnigUt<-KVKo_&&yk~gcb)Lz>5&(9P;MzxO~K%&05&1o8a@q#7t zI|x4_t*WYOZxVT;f)JR`5p247sWpu{mAcHPR+U)f$6 zTj=|AlAN6LJW3hCe}bzm_E1L&+~AF|+21DX_;8KuR-E93(?|_*%q)b&#+=0VTB{s| z1Vq$Oni5U^JL}HO6*m9ONcZ@~KcM01kFQV8vj<8(s&<0>8i zp0f3FqfsC5%!tzhq%r%x63h3;Vr(E#itPC49=F^r=rxV8Io#h-_CA7=nLuHyDhL_* zT9UZR)vHm0*Wbt_e%QJ%R|ZL&bpAlVlP9M7s1&dottud+L1Ge;B>g6nPd2B|GDZo~ z3CESsNkTXcl&>f!?d;}VvXrrii;O&(^|))LqoU)h`x1c9So--g^Iyrm7+uJ@L}((S z-ipi~@(q9gpud}&o9QzC{zua8i%}kxUZ1P|4xpq$^1Yp{7olnDfx+7iR!l$;r&j<@ zd4HJq`t{#*HCi{zB+qbI3`^_@X5amiT%kRI{zSQ{nD`A zV*=g#AkuNs1}b;jV)AF7%XaGzxFqZi3=DEjs-J6Gxni(36zI{hqvFF1wb&2bw;E%3 zNVy?JCgkWB);ut=#=-~Pxi@^`o52sjiTNsgq4)!PzE>WRk&*H49aq-l_uYU{Rn4re za4gK}XQrK}eqCcHgDeS2=Q5BS-mF&=j#YMvc7~6tuWnPP&v%=g;t|NQm@SKxOVc-@I`OTPLw)NsN34w>Yhj{G#x|UwE!mw zlCjOd^GVRx>(iE<;1yC2D1Wm{RzLZD6?GMAH2z&dZEdoJNydXzt?S)WTP*&OZhKRF zc6ceZXzs$uh)$L+PfQ4&8sS+YdlnkX`8}Ns2lzeu=1JBDAG%;aSdi=l5fkC@q5+XC zlj^E*u@gb2fhTGyCV#yK-Nc~Qo}5ld(lT`2t2^T=%a*uV4Mtb<)rFR>?AvM|mz|i& zxv9Ybc2G5A^1UrsO43kxb?h;R5VO;dIQpwVD6qbNUuBj0Gy*sM>T@|fF8agdy&x^Ot}~S(KEBoEqnY=$%IfN_iZ-<5-v9f>O-;|- z5tDS$nnr*N5J*-vIBV5KC5`~VwY7&>GQwg`QZP$uZ-3Vh2nc`&yj}9;fSL&<JQmcG_KI3( zpj!Rh%G%mgN#vYSGWhg?D5}!{D89a9$*a}lRK&N+>QF*(`vuR>OH+ZJ)_O_hh~?S zv>2nM-5nAzkjU?eu*&)U{CLIA?i3S~20QHl*$KL55k%x?4ih$5<~K_1`-9dGpFg(* z?QCM+C<}15zzbcFNjNU9kH+#vx-U^06E(vBw?nvnBb)*P)SAx<#t&6aF7<6grE2K_p@E`N{1TC9shf9 zsH5Y?%pe;(1rVK_^z?MF5QBr5_ht?pk4EfNsp-q5-{)|D1rFrpeO7;$WLpS>Oy54( z-5{Z0yi)P4wWwcVx6SVD$9#z>9T9%l#1@$a*TgtlSK=wAXlF zo;RzAjip7sWa15w2R+SeeLk@+W4k-w%NxruX3Fc1-_O2`AywUHT8*3kf2_hYmMd_meE~e`suZ>_}BQFk^K7VMDs|k zf2Kk4A41}Dqeo$F;GOfs&WLnn>JXwrfw4HxrS4e@cPn%H!`<~6P+s%&Id(5f(V9^r zx}e|X8CUyNf`(94<@baw;@!JW3WV%^-No(>WY`z3f0W8g9X z5<^d)J!}@Kmhss>-*P*W`SR%OY!ms`eB$d<1t7GLJk=Gm`pJ%Tja)ay9W})AX#q^I zv$F%G3;(9&mSM@*^z`)9l;Ovl0=l}8!5}SSqE2ujxTEd8rRv@RJjluB=H{J7MV>S5 zJbW3eva+)2+;KR<6X0~2S(>9%X?ZoFKefb`t@3o^IEcGmR+y&d@&~VpKQB2d9XV+ zCm(psxPpFm{O0qzawvVk$F9i8ypi=)7nLU?1*0MSoEiy7%v+9*j{aa%GTQoBRR}|8 zC;m@%y`nKh>e&B0P@-7B-|l3q+z~S^Eu>Tv8ZKdBhlZ%P8?#-J&&6k0(KV#VA@Jo? zQXqh>RiLN@Ksw7c{+e)g7jWC_=r>uPTaahHG-AbN+YrVKXtW}EmQY=EE!^f~5=_amCIm9Zay!2vRR9qwNa zfeWTTdB{Te@Rl09;uJ6pp$=urAX!0f1sQ=T}x% z)}}Dr7QF!f_DX(!=9K4j_uT!yx}<;CdV! z9FWRRP|Es@H&E*$d!J7W-hAfv&Az|%oKBhtQ0<0`fc=@J|IBHaY^<%#b!Kr{tJjA= z36+Ut`wNs?rd_M;d__wq6;}v+)WGOfD?7XM}{m)O_hM>kR4pqyAOifI<9qeABEyMCeV*d8IUag6#rz977T$@z`S&kKZH z`EF9}XWXfx&m$-!V>ck__^yjc$qW-!Ki(MIV?r;!r@*)T&Guk(Xl0g>X7Q2uf zyfkQV2jMRqj)~v}HuCUr|1%Vj(A{BB_yLV3OFnD|nU{3fgcR8+WNd~mo$5IjDOVYn z$#75ur>ouO=*T)kL`4TUsyy#mH)G?&E|QQu#pD0_`T1RT9;Z6Y0{_-}Ew!;!eZ-6N z=RhnHu6S=kdFtuuiCRrdOC#{+->tsi_;!nZ?i9Yu(^2jXMk=+B+lk($Usd zgU}$c=y&kqrJ`x%p$A#wU)mUQs(Gr+1kpyL!u zcTOEavm@h$(EU;3^E3D&DH(UC+Ak2lInPH+MV0#Y6>fq>*DBA1I%$cF?6MrVnA>J5 z?nAlCMB0C~fRX%XC%!yd7zKU5sr=!Xas=b0D>BDIzK|zhqwa^s;v4!!s&gVB}Xtw7X zO7J}Qdj9Mg{drSFP1L%bdo-rdGBrVh1h1ph+|*;0d`<-$G=*hG%PKc;~yA>?RuakBaL~U^68M5@<&3?!Do~^GQE2H4rG2F8$t0zq! zs}db@AnCTfMLdlQpb~?Tc+{#6cWuwSC{4e{kIEv-&Lw0o*9fL&xmRzb-R~M1F{{<^ za+iGFB57@&#V?*$r9iP81N&BUfEve$spYj(r0gJsA&0?8R6}3itMSeK9haDN({BnG zc0A~-WQ2SXB5D~Uhj23=q@-a3_Uo=xF33-~VAwt5$UsVFkET({)}X}YSO3x&=;&9_ zlAS`JL@6Lg3W#dmEd-K(Qu$MA%}o zRd6m^^v=jg0mk)k-xIA$4DU2g903p`io2*)8AqkewtSfx1$8qN(Ul+icD#`b;d#W( z|E)$HtGwqv=vN5^OkLI%18o5;XRitp!#4g&6y0V>_N)x)DdvkJ05-VfUjq;#)o5}t zIJjZ^04X~=JMO=S635k{-}_U+5al#hPoanA#2qOR?xaZG>(0$zSEG#d38D@*`|s3J zfE>F1*L`TI?~|C2P%>6x-A(pIc>DXtJGA^LE;dAS zk=UNMZm)}qin44@m}toksI`=*1FLNt9QzngjSIdYh4wgl^8#63Gu&FwdfTrIm1pIl zm}mZ72LK|P>gp8_$Go7uLk2A(+?g16wCzMAV>7nJa&h4D6Q+v}1LGYMpaRmoF#aw3)~H+nghfTk_);4?2p=p`B}elw)Z_Sn7v6h1 z}VoIo_^oVAriuZ6xYs4Ja0 z2C>-VaKA=BpXzbs{Dk0zOCSI@;cpY6(V|u*R!1dP5T* zPH}8YHET}D{c!!Yx{>IY)@Gykm-RMhI047j`N`gC;m?RWLh zS;GE)_o)$N53%rH^aJ>4kO?WHxUqhF+hc_7GHBXOVZc~TD!o2E_-#J#259b!>x`z) za4Dt}Ms_kY`QNpG-YEYO@Z&NF)je`{QDv?(MZ?3UXa@+~(b?;!Zwd!Nm_3%}b#zhO zu4jA2?StkYn6M}+EDS{F;RCTF#;3uHRt;N6hOAJ6mG~kZ?(#O3Agvfhq zk(Pz!8Xgsg^H`bf(kPM?s5}C8smhyE{!Lx+R`y{&OiO?~$g#Gw{H=j>_}A;N>3P7J z0YQrOg@5Nu%HR>p0+q>PJbtk9FPS6`Tod4ULy^&E8Ue?pls*950PE()$Lj=HbH=#3 z7|yLQ89x>f*-XOwoee4h%|;DaOk++@>sx^Pm`TDyY|>~eJXVT8^p{NX?_LS^I*J5+ zR9C7BdS9L%9`ggAZ+xL&HcKh>Ao}%xvP}`Yp9hdGoJ|h&Cr$zUw3LS1qP2dh zodGl+f=euTUf^&@oL0uLe*N$ys|;0px$b*)5J{X}<_z8{5bI)9 z8-k%3ds$lg3=G5ke3>FAd@ertH==EZ86F!GvuM!fICu6Y|I(7O7ta zOft4H{1|q3ojkA5s=5pOE%CfrT2wtSP8p|R_;Yir|GCXcgd@q_gZJYW8$kbz z?wgk$w6O~>{suTy>bM08#CZYS`+4Y!GD$eZaH(7uH!A?Q0v@fl_cna7zo1BW);|xN z;>9_`#kvq$&`<6k5s_@(Z+R8JeFK&sU0kDlK4kpwwEu_#zjZHUK!YU_Cn_C za|~X$^uCrvfU#Vyl{}ir{p`g&QK$^Q|BNS$A#NP_mZf%cX=!H0I(17c*F#i~%RC=K z8D=${cbt0wqp9l6%geGecpF!Gxrv|qUH*`Z#xOcMy1KW- z6mky`2k3$lK4w?=$x--V9F8UZnn0!8*`njtKW7hP`HoA`2uFA|^6xahzO}ryBe6jJR;%4h64TK_Mhg=+-Sr*$0v6nKv={ zxC-);8j2~y6r!M@pdTN2aT;h$jkmwM`DntMfqMBO$_MK7JMEYtXUgGIRw8Whxz+;@B4@d7-dPQ7Qiuseieh38W zO#90Xm7Lq0JIpY}*_~&c{skCRk$+DZ4Uc-~N6Yc{vt(SjZ-Gxi6iH4u-*ELmvhV9N z4&IIQ1pF3bi#cyrmpRl%{4RmUH~g1^HV*A% z^-PzRW@iN*;pHk~HVH9Qk-2ik;PT@VtQ{G@@b$F} zl$2yom`(M)r<+#QLMPO*G>P;Gj5PY~Kg3w8s4ub6(eb8V$R<9?A{8`VAId$~?Z_TM zWf<1I>p2Y6q@=KrdhihV8l&-n9ki@y5@`^TyA@6kA4VVC@ti1l>M&AyRAMcwzJ6#& zQ{=uRo6cgcf+%;#JLma$^64zJ6g@xQ4&~gUiE|y~RO0JttGJCr3MO&UrD8cT^Exe9 zQmzFmCZwj-V>q_BxHxk&^Jp1CrItc@OxhB6(b4rf0^UnXh#2yXLvSpk&$^R47Vkrh zi7nQBtbc`&BYI1_pmLD@1QrrqLemEQ8Na;+iJ17LBqo8t_>Vzbhfv{eJ@7n)$*O_B z#KvCVPZx3_xg@yQ*k?4_SUhF!k=HpQFe~&i^jKF`S9uLUbn%0!&UEC@9*oOfHC?Lq z*>RRAdy|d}3JMap?+d+h;wi~Yhl}e@T02mZSZEKAEI?1@I*bP5u7U}GD;k&DF*Fhy zoiK_89UiRnJ=z!;Q33)QRS!*RP*FK83wCIFwGM8Q+%8)Z%#{z^tzjy@9|mNG)(Lcg z=w-xXcisBO^Ve~i9u9r$dlExFiq7{c{h{+6yiUk@_sKe;!>U`~5d98XwQ5OMiE-m$ zjgsHggKL-^hgh+A5<5(+swo1VSYdRTkH_l>WW%mJ+$L1RV@aNi?eEW8Cuh<` zk-IbT6S}xHOiKgTRVzjRs|AD{m@q?du1~hyN1K9Ifg|@6-;Z=o^2*80Ewk^p0)eB* zTy6v!VaEk-6(&v%4-f0UXI5nZ+G4>H`lsxBOas!k?)26`j2e~tSB)hMvK>DMM}RLA zE9=7*81^RQVV@n~27<1Jw%vL0m2213)oFV6z;JT>{Fxpr=IJNI%?2}dKrUd5-2}&! zmd+gvpPrwGNd||J1)ZZ2a-f_6jjG1?>cbyCw1(%YtPazV=BV_4s}Kz4)lOHXhL;Th zusZ?9Y2ch5FH+R;=K#^s8g)-vA03byqcy%^eWS#z8FI{5+V_mHJBD z-b(F2_dPJ2Gy5`UIY0K0u6 zsPtJSwPsjfnWIR>3bfK563aNAM-Q`PpO}YX2@w<7m|MVu+?VLeSQR^sQm~GL71n>h zL@2SI$a?yY{VFkjuyT)75~k+6t9ECz0X;T+9X$lUhoDGsV!Zt;Su_%%0foHy2%2OK zG$B_gI@dLnZ`IGrrA-Ds4m`JZ@=gcbukSH20b^(uIlH-SY`t)%dsl&C_n8V%HrOXk zwKt@m^M51f&q~8NZDF8Ii^iD%xL)ecXKN(v6L4^-1mRve#-jDjYm1sU2ZkjN8bfO= zKIi1rMId$0>3=?-C|j-vv6g%IBpP>vl5*2!>KDfjlqIMp(rSQfC}Xq$YYKhc;#*<) zQmTiVXicdGRi=i}^9#qxks{Y~?^fO!97FY(DmcSbun?+A3FMsgEY>`tl6jh_%rUl? zKx)v3rZg{A=6kE51as4hf9)xAlru4r6AdtGj)I{AYG!6lDbrpqMCS*NkDD8O={e!r zizSXjer@c%*A=WP{Q>b`8@iex;dob+B5A`O22eo4Q)8=pj+C56Hs-?0(VBeG`AYn# zmEf*hP@)8R-Y&oM$tFaqauJWJOs|?W#o%8+ zceJT|?8U~R^IvA|g4A_{%CGq)PvI~%HkNlZf%hI7XGn@vHF#VgojU;0>$)hl+qi}e zx|4KvgFyc4hQ{dn#++qnbol`fZX2W|cx56aeYN0HeI0c{2}PGo7Q*@+dIJ2)e3 zbhQYOQpR$94%Vp9Txs)MoxtI_+2au$m$e_yAGf(C#-R4_^BanhNj z0K<2eavlD9)iB9{P!AJ0m~KrFz`hkbQ7e&05ow>*$)gdMTPi8Gb6xG<(FDd7Y1IwrpX9BWrLf0zI#q2 zNH^gy9RgjD zG)ot<4t;wY_u6>!=wQizKHmt8Cwtfp^Rmgw$-vsG)>pvkIZ#woIHV0B+#nLU3nG1m zhfbk+@Sj{IBcf(W>D!bP6g4I@v_T@%*gUD zk6x^Zn%Pr(zc03NL6naa-)k^Q&Bxx_3};I%nRCA8JUsL)Z;#&?tklY$vji{eP}>_ z5y`&X;WpQXbk3xh$8r=PW#y|^sUDC($fSX0_UX7Cz}mFFZkTpxXnJ`3-HdbqXiH}0 z-+)ftxEu83ZnBK0I<$;e@5+-C!bYhxK5CL22f(|^V@07;W&JW~TOQ1E^z`*HK8O;f zz#sC$WC85I*@3KsVGKfs!!oJybKQaq=)Lvoxw*Nsvs`(3c>;VC(@%RWQ$I!^LCF&F z7w**qGlh4A?k*sLM{|E7u+0STb)z>jH>XK#%&!)v?gG93ClEQFD`vi#nVaUVs5l`3 zwKfxI%=Xbc6g z@zr7QRM}WHwCmED1{^TndwSD~%HY@k zs-W@zT}3!o27)sxMJ`_D%R@ zms*gODl31S7SM$u)6eEwAJ8rGgfEfeo*L9c7wTE%p7r*!nZ*n@F;3k-*@PU~TrTw4 zSp`>t*{s(Q2w{>Re+Krne3yy^KRex3r5eq-F>A2R=+Z)rhd!A`yxTo%y98Vv zhIMl@BVFhIbQR@?p7Lfu67Mlg!}-e3nJ0#|1zUQ5rLsY3$^3J?!S;9B_q4wIO>vD~ zUGeOvyR3|E{Glu$IRcHdB}LBUKsp#lIQ7{b;ySH61)js8XN2uGq{_3>u*QB;s2SF5 zsU5azd3EbU_!t0ZvUP&Zbt-W^L{CriZ2Gi8+tPiZFIXrp9btEaxz_Qilpn9=gdPr) z6QTx<%jTPg;irKrU&Brn*yC}HPQ8Y^z=nstP;Ao_w!!&OcO7@Sgf>kOG7$Xe3uqB$ z)fA#M=U09s9|+5J6H%3P#Ogq&n=^V~K*;5GLC`mb(!Y;)318iye*k;OWIXq`3wze& zjg5?iRzIqexghi?92{q~vQ0{qT${@u{S!z?KD=REFjmrhLEe`Xp(rjM6GnvZR&YOh z2wLR1hEY=t@(PCxKVJ^U8Wdac910l=ItZ3S$%mFC``=npq!R+>`Enyc1)}l?jNQab z6jTO#H5UGUl`x-uqsybcFo;*!9N@QYCJAStOgE3q%0Hz- z2B<$VN3{w@^-!Q-D(-ie?@CHA4OQkz6rM!~g};A#8gwyOOX`hi`9K-fAB5QsVe)loshoA!LA$nJR3~CsDt!yn!fx#OoRS-&ccOIvcqP= zGqki~j)OjOa@BzIX%PvS{#f0v;;Rqa zCSl{$b)>0QRQr0ou(2Tb)q%yg;Gdoz%ig)<-XDE8H3vTN0d*13P0#Ep-uT=Fsuj#q z!H(-HKz2%@r!KUuf^rFx6c-ZAfk;ja4^uIE$#<&2GTbU(c9$7Ccbb(|FGv4^4jW;= z$DiDO)jgQ?`)uE@H?kn@I$eJ62PIk?z+^Iwcd87JoVm+gF|(7U|DZr4=lb-B;Kzdk zGrHB|ByZsI@h@Mhj#ALKrAX4T3VXTTgS6iqe^#S!YI>Ugqc60MKi96(yvX!N={*#l4N#({M8Gu*hW7e03B0Wyia60I5DM6X= zsM^tG*GU@IEuhANLBEa;?*-?#;M3YGlTXN?X2@K^>`q@^_krMNyZ6L9`AU zG~2Wc0$yjaZhlI*-%6Hs`HjU)ZQ7RPyXw*~&38{;VSQ90Df+&nR*Jg&2h)IXah9 zG)|-_!mfIL6zc!}hWf8DtvCch*%JP}fdAdH|Nq_shmhKkqa)i9MgjQc7{TmB$i$5F V+RUbJ7kq-KDP6ysr(pW{{{R_O=9T~e literal 0 HcmV?d00001 diff --git a/services/console/public/pwa-icon.svg b/services/console/public/pwa-icon.svg new file mode 100644 index 000000000..1af2c6d62 --- /dev/null +++ b/services/console/public/pwa-icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/services/console/test/controllers/api/v1/principals_controller_test.rb b/services/console/test/controllers/api/v1/principals_controller_test.rb index ac934bb37..e9d3f91d3 100644 --- a/services/console/test/controllers/api/v1/principals_controller_test.rb +++ b/services/console/test/controllers/api/v1/principals_controller_test.rb @@ -41,9 +41,18 @@ def json_body assert_equal principal.oid, data["id"] assert_equal "acme", data["namespace"] assert_equal "C0123456789", data["foreign_id"] - assert_equal({ "kind" => "slack_channel", "team" => "platform" }, data["labels"]) + assert_equal( + { + "kind" => "slack_channel", + "team" => "platform", + Principal::SANDBOX_REPO_CACHE_LABEL => "all" + }, + data["labels"] + ) + assert_equal "all", data["sandbox_repo_cache"] assert_equal true, data["sandbox_repo_cache_enabled"] assert_equal true, data["sandbox_observability_enabled"] + assert_equal true, data["sandbox_api_server_enabled"] end test "GET returns 404 for an unknown oid" do @@ -63,7 +72,16 @@ def json_body data: { namespace: "acme", foreign_id: "U-new-id", - labels: { "kind" => "user", "team" => "platform" } + labels: { "kind" => "user", "team" => "platform" }, + slack_channel_permissions: [ + { + channel_id: "C0123456789", + channel_name: "general", + upload_enabled: true, + download_enabled: false, + history_enabled: true + } + ] } } @@ -76,9 +94,114 @@ def json_body assert_match(/\Aprn_/, data["id"]) assert_equal "acme", data["namespace"] assert_equal "U-new-id", data["foreign_id"] - assert_equal({ "kind" => "user", "team" => "platform" }, data["labels"]) + assert_equal( + { + "kind" => "user", + "team" => "platform", + Principal::SANDBOX_REPO_CACHE_LABEL => "all" + }, + data["labels"] + ) + assert_equal( + [ + { + "channel_id" => "C0123456789", + "channel_name" => "general", + "upload_enabled" => true, + "download_enabled" => false, + "history_enabled" => true + } + ], + data["slack_channel_permissions"] + ) + assert_equal "all", data["sandbox_repo_cache"] assert_equal true, data["sandbox_repo_cache_enabled"] assert_equal true, data["sandbox_observability_enabled"] + assert_equal true, data["sandbox_api_server_enabled"] + end + + test "POST applies system sandbox defaults when omitted" do + system_settings(:default).update!( + default_sandbox_repo_cache: "public", + default_sandbox_observability_enabled: false, + default_sandbox_api_server_enabled: false + ) + body = { + data: { + namespace: "acme", + foreign_id: "U-defaulted" + } + } + + post api_v1_principals_url, params: body.to_json, headers: auth_headers + assert_response :created + + data = json_body.fetch("data") + assert_equal "public", data["sandbox_repo_cache"] + assert_equal false, data["sandbox_observability_enabled"] + assert_equal false, data["sandbox_api_server_enabled"] + end + + test "POST keeps explicit sandbox capabilities over system defaults" do + system_settings(:default).update!( + default_sandbox_repo_cache: "none", + default_sandbox_observability_enabled: false, + default_sandbox_api_server_enabled: false + ) + body = { + data: { + namespace: "acme", + foreign_id: "U-explicit-capabilities", + sandbox_repo_cache: "all", + sandbox_observability_enabled: true, + sandbox_api_server_enabled: true + } + } + + post api_v1_principals_url, params: body.to_json, headers: auth_headers + assert_response :created + + data = json_body.fetch("data") + assert_equal "all", data["sandbox_repo_cache"] + assert_equal true, data["sandbox_observability_enabled"] + assert_equal true, data["sandbox_api_server_enabled"] + end + + test "POST overwrites explicit repo-cache label with system default" do + system_settings(:default).update!(default_sandbox_repo_cache: "all") + body = { + data: { + namespace: "acme", + foreign_id: "U-explicit-repo-cache-label", + labels: { Principal::SANDBOX_REPO_CACHE_LABEL => "none" } + } + } + + post api_v1_principals_url, params: body.to_json, headers: auth_headers + assert_response :created + + data = json_body.fetch("data") + assert_equal "all", data["sandbox_repo_cache"] + assert_equal({ Principal::SANDBOX_REPO_CACHE_LABEL => "all" }, data["labels"]) + end + + test "POST uses repo-cache param over conflicting label" do + system_settings(:default).update!(default_sandbox_repo_cache: "all") + body = { + data: { + namespace: "acme", + foreign_id: "U-repo-cache-param-wins", + sandbox_repo_cache: "public", + labels: { Principal::SANDBOX_REPO_CACHE_LABEL => "none" } + } + } + + post api_v1_principals_url, params: body.to_json, headers: auth_headers + assert_response :created + + data = json_body.fetch("data") + assert_equal "public", data["sandbox_repo_cache"] + assert_equal({ Principal::SANDBOX_REPO_CACHE_LABEL => "public" }, data["labels"]) end test "POST creates a Principal with only a human-readable name" do @@ -98,8 +221,9 @@ def json_body test "PUT updates the human-readable name" do principal = principals(:acme_channel) principal.update!( - sandbox_repo_cache_enabled: false, - sandbox_observability_enabled: false + sandbox_repo_cache: "none", + sandbox_observability_enabled: false, + sandbox_api_server_enabled: false ) body = { data: { name: "Acme Slack channel" } } @@ -108,16 +232,18 @@ def json_body principal.reload assert_equal "Acme Slack channel", principal.name - assert_equal false, principal.sandbox_repo_cache_enabled + assert_equal "none", principal.sandbox_repo_cache assert_equal false, principal.sandbox_observability_enabled + assert_equal false, principal.sandbox_api_server_enabled end test "PUT updates sandbox access flags" do principal = principals(:acme_channel) body = { data: { - sandbox_repo_cache_enabled: false, - sandbox_observability_enabled: false + sandbox_repo_cache: "public", + sandbox_observability_enabled: false, + sandbox_api_server_enabled: false } } @@ -125,12 +251,15 @@ def json_body assert_response :ok principal.reload - assert_equal false, principal.sandbox_repo_cache_enabled + assert_equal "public", principal.sandbox_repo_cache assert_equal false, principal.sandbox_observability_enabled + assert_equal false, principal.sandbox_api_server_enabled data = json_body.fetch("data") + assert_equal "public", data["sandbox_repo_cache"] assert_equal false, data["sandbox_repo_cache_enabled"] assert_equal false, data["sandbox_observability_enabled"] + assert_equal false, data["sandbox_api_server_enabled"] end test "POST returns 422 when (namespace, foreign_id) already exists" do @@ -160,7 +289,264 @@ def json_body assert_response :ok principal.reload - assert_equal({ "kind" => "slack_channel", "team" => "ops" }, principal.labels) + assert_equal( + { + "kind" => "slack_channel", + "team" => "ops", + Principal::SANDBOX_REPO_CACHE_LABEL => "all" + }, + principal.labels + ) + end + + test "PUT overwrites explicit repo-cache label" do + principal = principals(:acme_channel) + body = { + data: { + labels: { + "kind" => "slack_channel", + Principal::SANDBOX_REPO_CACHE_LABEL => "none" + } + } + } + + put api_v1_principal_url(id: principal.oid), params: body.to_json, headers: auth_headers + assert_response :ok + assert_equal "all", principal.reload.sandbox_repo_cache + assert_equal "all", principal.labels[Principal::SANDBOX_REPO_CACHE_LABEL] + end + + test "PUT replaces Slack channel permission rows" do + principal = principals(:acme_channel) + SlackChannelPermission.create!( + principal: principal, + channel_id: "C1111111111", + upload_enabled: true + ) + body = { + data: { + slack_channel_permissions: [ + { + channel_id: "C0123456789", + upload_enabled: true, + download_enabled: true, + history_enabled: false + }, + { + channel_id: "G9876543210", + upload_enabled: false, + download_enabled: false, + history_enabled: true + } + ] + } + } + + put api_v1_principal_url(id: principal.oid), params: body.to_json, headers: auth_headers + assert_response :ok + + assert_equal( + [ + { + "channel_id" => "C0123456789", + "channel_name" => nil, + "upload_enabled" => true, + "download_enabled" => true, + "history_enabled" => false + }, + { + "channel_id" => "G9876543210", + "channel_name" => nil, + "upload_enabled" => false, + "download_enabled" => false, + "history_enabled" => true + } + ], + principal.reload.slack_channel_permissions_payload + ) + end + + test "PUT rejects a single Slack channel permission object" do + principal = principals(:acme_channel) + body = { + data: { + slack_channel_permissions: { + channel_id: "C0123456789", + upload_enabled: true, + download_enabled: false, + history_enabled: true + } + } + } + + put api_v1_principal_url(id: principal.oid), params: body.to_json, headers: auth_headers + assert_response :unprocessable_content + assert_equal "slack_channel_permissions must be an array", json_body.dig("error", "message") + end + + test "PUT rejects malformed Slack channel permission rows" do + principal = principals(:acme_channel) + body = { data: { slack_channel_permissions: [ "not-an-object" ] } } + + put api_v1_principal_url(id: principal.oid), params: body.to_json, headers: auth_headers + assert_response :unprocessable_content + assert_equal "slack_channel_permissions rows must be objects", json_body.dig("error", "message") + end + + test "PUT can clear Slack channel permission rows" do + principal = principals(:acme_channel) + principal.update!(labels: { Principal::SLACK_CHANNEL_ID_LABEL => "C0123456789" }) + SlackChannelPermission.create!( + principal: principal, + channel_id: "C0123456789", + upload_enabled: true, + download_enabled: true, + history_enabled: true + ) + body = { data: { slack_channel_permissions: [] } } + + put api_v1_principal_url(id: principal.oid), params: body.to_json, headers: auth_headers + assert_response :ok + + assert_empty principal.reload.slack_channel_permissions + assert_equal [], json_body.dig("data", "slack_channel_permissions") + end + + test "POST upserts one Slack channel permission without replacing other rows" do + principal = principals(:acme_channel) + SlackChannelPermission.create!( + principal: principal, + channel_id: "G9876543210", + upload_enabled: true, + download_enabled: false, + history_enabled: false + ) + body = { + data: { + channel_id: "C0123456789", + channel_name: "general", + upload_enabled: true, + download_enabled: true, + history_enabled: true + } + } + + post "/api/v1/principals/#{principal.oid}/slack_channel_permissions", + params: body.to_json, + headers: auth_headers + assert_response :created + + assert_equal( + [ "C0123456789", "G9876543210" ], + principal.reload.slack_channel_permissions.ordered.pluck(:channel_id) + ) + assert_equal "general", json_body.dig("data", "channel_name") + end + + test "POST updates an existing Slack channel permission with normalized channel id" do + principal = principals(:acme_channel) + SlackChannelPermission.create!( + principal: principal, + channel_id: "C0123456789", + channel_name: "general", + upload_enabled: true, + download_enabled: false, + history_enabled: false + ) + body = { + data: { + channel_id: " c0123456789 ", + channel_name: "general", + upload_enabled: false, + download_enabled: true, + history_enabled: true + } + } + + assert_no_difference -> { principal.slack_channel_permissions.count } do + post "/api/v1/principals/#{principal.oid}/slack_channel_permissions", + params: body.to_json, + headers: auth_headers + end + assert_response :ok + + permission = principal.reload.slack_channel_permissions.sole + assert_equal "C0123456789", permission.channel_id + assert_not permission.upload_enabled + assert_predicate permission, :download_enabled + assert_predicate permission, :history_enabled + end + + test "POST retries after concurrent Slack channel permission create wins" do + principal = principals(:acme_channel) + body = { + data: { + channel_id: "C0123456789", + channel_name: "new-name", + upload_enabled: false, + download_enabled: true, + history_enabled: false + } + } + calls = 0 + original = Api::V1::PrincipalsController.instance_method(:save_slack_channel_permission!) + + Api::V1::PrincipalsController.define_method(:save_slack_channel_permission!) do |target_principal, attrs| + calls += 1 + if calls == 1 + target_principal.slack_channel_permissions.create!( + channel_id: attrs[:channel_id], + channel_name: "winner", + upload_enabled: true, + download_enabled: false, + history_enabled: true + ) + raise ActiveRecord::RecordNotUnique, "duplicate key value violates unique constraint" + end + + original.bind_call(self, target_principal, attrs) + end + Api::V1::PrincipalsController.send(:private, :save_slack_channel_permission!) + + assert_difference -> { principal.slack_channel_permissions.count } => 1 do + post "/api/v1/principals/#{principal.oid}/slack_channel_permissions", + params: body.to_json, + headers: auth_headers + end + assert_response :ok + + permission = principal.reload.slack_channel_permissions.sole + assert_equal "C0123456789", permission.channel_id + assert_equal "new-name", permission.channel_name + assert_not permission.upload_enabled + assert_predicate permission, :download_enabled + assert_not permission.history_enabled + assert_equal 1, calls + ensure + Api::V1::PrincipalsController.define_method(:save_slack_channel_permission!, original) + Api::V1::PrincipalsController.send(:private, :save_slack_channel_permission!) + end + + test "POST upserts one Slack DM permission" do + principal = principals(:acme_user_bob) + body = { + data: { + channel_id: "D0123456789", + channel_name: "U0123456789" + } + } + + post "/api/v1/principals/#{principal.oid}/slack_channel_permissions", + params: body.to_json, + headers: auth_headers + assert_response :created + + permission = principal.reload.slack_channel_permissions.sole + assert_equal "D0123456789", permission.channel_id + assert_equal "U0123456789", permission.channel_name + assert_predicate permission, :upload_enabled + assert_predicate permission, :download_enabled + assert_predicate permission, :history_enabled end test "PUT ignores attempts to change immutable namespace and foreign_id" do @@ -192,6 +578,11 @@ def json_body end test "PUT upserts a new principal by foreign_id" do + system_settings(:default).update!( + default_sandbox_repo_cache: "public", + default_sandbox_observability_enabled: false, + default_sandbox_api_server_enabled: false + ) body = { data: { namespace: "acme", name: "Upserted" } } assert_difference -> { Principal.count } => 1 do put api_v1_principal_url(id: "U-upsert"), params: body.to_json, headers: auth_headers @@ -202,6 +593,9 @@ def json_body assert_equal "acme", data["namespace"] assert_equal "U-upsert", data["foreign_id"] assert_equal "Upserted", data["name"] + assert_equal "public", data["sandbox_repo_cache"] + assert_equal false, data["sandbox_observability_enabled"] + assert_equal false, data["sandbox_api_server_enabled"] end test "PUT by foreign_id updates an existing principal without creating" do @@ -246,6 +640,16 @@ def json_body assert_equal %w[U-alice U-bob].sort, foreign_ids.sort end + test "GET index filters by sandbox repo-cache label" do + get api_v1_principals_url, + params: { namespace: "acme", labels: { Principal::SANDBOX_REPO_CACHE_LABEL => "all" } }, + headers: auth_headers + assert_response :ok + + foreign_ids = json_body.fetch("data").map { |p| p["foreign_id"] } + assert_equal %w[C0123456789 U-alice U-bob].sort, foreign_ids.sort + end + test "GET index ANDs multiple label filters" do get api_v1_principals_url, params: { namespace: "acme", labels: { kind: "user", team: "platform" } }, @@ -362,7 +766,6 @@ def grant_sources_to_acme_channel data = json_body.fetch("data") assert_equal principal.oid, data["id"] - assert_equal "120s", data.dig("proxy", "upstream_response_header_timeout") assert_equal 2, data.fetch("secrets").length assert_kind_of Array, data.fetch("transforms") assert_kind_of Array, data.fetch("postgres") diff --git a/services/console/test/controllers/api/v1/proxy_sync_controller_test.rb b/services/console/test/controllers/api/v1/proxy_sync_controller_test.rb index b82e0b536..8c8e741d7 100644 --- a/services/console/test/controllers/api/v1/proxy_sync_controller_test.rb +++ b/services/console/test/controllers/api/v1/proxy_sync_controller_test.rb @@ -43,11 +43,10 @@ def json_body body = json_body assert_match(/\Asha256:[0-9a-f]{64}\z/, body.fetch("config_hash")) - assert_equal "120s", body.dig("proxy", "upstream_response_header_timeout") secrets = body.fetch("secrets") assert_equal 2, secrets.length - # Unsupported top-level fields stay absent so the proxy no-ops on them. + # Omitted top-level fields stay absent so the proxy no-ops on them. refute body.key?("rules") refute body.key?("mcp") refute body.key?("ingest_token") @@ -69,22 +68,6 @@ def json_body refute_includes raw, "s3cr3t-db-pass" end - test "sync overlays managed proxy settings onto cached snapshots" do - legacy_payload = Principal::EMPTY_CONFIG.deep_dup - legacy_payload.delete("proxy") - - PrincipalSyncConfigSnapshot.create!( - principal: @proxy.principal, - principal_cache_version: @proxy.principal.sync_config_cache_version, - payload: legacy_payload - ) - - post api_v1_proxy_sync_url, params: {}.to_json, headers: auth_headers - assert_response :ok - - assert_equal "120s", json_body.dig("proxy", "upstream_response_header_timeout") - end - test "secret changes bump principal cache version and build a new snapshot" do post api_v1_proxy_sync_url, params: {}.to_json, headers: auth_headers assert_response :ok @@ -211,6 +194,7 @@ def json_body post api_v1_proxy_sync_url, params: { config_hash: "sha256:#{'0' * 64}" }.to_json, headers: auth_headers end + # codeql[rb/clear-text-storage-sensitive-data] Response is a test-only placeholder config. assert_response :ok transform = json_body.fetch("transforms").find { |t| t["name"] == "gcp_id_token" } assert_equal secret.audience, transform.dig("config", "audience") @@ -321,6 +305,63 @@ def json_body assert_equal "PROD_API_KEY", bumped.last end + test "infra role delivers the built-in GitHub broker replacement to sandbox principals" do + admin = users(:acme_admin) + credential = BrokerCredential.create!( + namespace: "acme", + foreign_id: "github-app", + name: "GitHub App installation token", + grant: BrokerCredential::GITHUB_APP_INSTALLATION, + token_endpoint: "https://api.github.com/app/installations/42/access_tokens", + client_id: "12345", + client_secret: "private-key", + access_token: "ghs-live-installation-token", + expires_at: 1.hour.from_now, + last_refresh: Time.current, + created_by: admin + ) + secret = StaticSecret.new( + namespace: "acme", + foreign_id: "infra-github-app", + name: "github-app", + replace_config: { + "proxy_value" => "GITHUB_TOKEN", + "match_headers" => [ "Authorization" ] + }, + labels: { "managed-by" => "centaur" }, + created_by: admin + ) + secret.build_source( + source_type: "token_broker", + config: { + "credential_id" => credential.foreign_id, + "credential_namespace" => credential.namespace + } + ) + secret.rules.build(host: "github.com", position: 0) + secret.rules.build(host: "api.github.com", position: 1) + secret.save! + # codeql[rb/clear-text-storage-sensitive-data] Fake token data stays inside the encrypted test database. + Grant.create!(role: roles(:acme_infra), static_secret: secret, created_by: admin) + + assert_equal secret, secret.source.static_secret + assert_includes @proxy.principal.roles, roles(:acme_infra) + assert_includes @proxy.principal.granted_static_secrets, secret + + post api_v1_proxy_sync_url, params: {}.to_json, headers: auth_headers + assert_response :ok + + entry = json_body.fetch("secrets").find do |candidate| + candidate.dig("replace", "proxy_value") == "GITHUB_TOKEN" + end + refute_nil entry + assert_equal "ghs-live-installation-token", entry.dig("source", "value") + assert_equal "control_plane", entry.dig("source", "type") + assert_equal [ "Authorization" ], entry.dig("replace", "match_headers") + # codeql[rb/clear-text-storage-sensitive-data] This assertion verifies host rules, not secret persistence. + assert_equal [ "github.com", "api.github.com" ], entry.fetch("rules").map { |rule| rule.fetch("host") } + end + test "an unassigned proxy syncs an empty config with unassigned status" do unassigned_token = "iprx_#{'c' * 64}" post api_v1_proxy_sync_url, params: {}.to_json, headers: auth_headers(unassigned_token) @@ -329,7 +370,6 @@ def json_body body = json_body assert_equal "unassigned", body.fetch("status") assert_nil body.fetch("principal_id") - assert_equal "120s", body.dig("proxy", "upstream_response_header_timeout") assert_empty body.fetch("secrets") assert_empty body.fetch("transforms") end diff --git a/services/console/test/controllers/console/descopes_controller_test.rb b/services/console/test/controllers/console/descopes_controller_test.rb new file mode 100644 index 000000000..35ef36154 --- /dev/null +++ b/services/console/test/controllers/console/descopes_controller_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +module Console + # Covers admin self-descope ("view as operator"): who can start it, that admin + # gates and admin chrome disappear while descoped, how it's restored, and the + # self-healing session cleanup when the user is no longer an admin. + class DescopesControllerTest < ActionDispatch::IntegrationTest + def sign_in(user) + post login_url, params: { email: user.email, password: "password123456" } + end + + test "a non-admin cannot descope" do + sign_in users(:member_user) + post console_descope_url + assert_redirected_to console_threads_path + assert_nil flash[:alert] + end + + test "a descoped admin loses admin pages and chrome, and sees the banner" do + sign_in users(:acme_admin) + post console_descope_url + assert_redirected_to console_threads_path + + get console_users_url + assert_redirected_to console_threads_path + assert_nil flash[:alert] + + get console_threads_url + assert_response :ok + assert_select ".console-descope-banner", /Admin permissions paused/ + assert_select ".console-nav-link", text: "Control", count: 0 + assert_select "form[action=?]", console_descope_path do + assert_select "button", text: /Restore admin/ + end + end + + test "restore brings back admin permissions" do + sign_in users(:acme_admin) + post console_descope_url + + delete console_descope_url + assert_redirected_to console_principals_path + + get console_users_url + assert_response :ok + assert_select ".console-descope-banner", count: 0 + end + + test "descope ends automatically when the user is no longer an admin" do + admin = users(:acme_admin) + sign_in admin + post console_descope_url + + admin.update!(admin: false) + get console_threads_url + assert_response :ok + assert_select ".console-descope-banner", count: 0 + end + + test "restore is a no-op redirect when not descoped" do + sign_in users(:acme_admin) + delete console_descope_url + assert_redirected_to console_principals_path + end + + test "the account menu offers descope only to acting admins" do + sign_in users(:acme_admin) + get console_threads_url + assert_select ".console-signout-label", text: "View as operator" + + sign_in users(:member_user) + get console_threads_url + assert_select ".console-signout-label", text: "View as operator", count: 0 + end + end +end diff --git a/services/console/test/controllers/console/etls_controller_test.rb b/services/console/test/controllers/console/etls_controller_test.rb index 885a77d42..8fdb3108e 100644 --- a/services/console/test/controllers/console/etls_controller_test.rb +++ b/services/console/test/controllers/console/etls_controller_test.rb @@ -60,7 +60,22 @@ def delete_slack_archive_import(import_id) assert_redirected_to login_path end - test "renders Slack archive imports on the ETLs page" do + test "an active non-admin is redirected away from the Data Sync page" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + get console_etls_url + assert_redirected_to console_threads_path + assert_nil flash[:alert] + # The gate fires before the action, so the api client is never touched. + assert_empty @client.calls + + post console_slack_archive_imports_url, params: { filename: "export.zip" } + assert_redirected_to console_threads_path + assert_empty @client.calls + end + + test "renders Slack archive imports on the Data Sync page" do @client.imports = [ { "import_id" => "sai_uploaded", @@ -85,8 +100,8 @@ def delete_slack_archive_import(import_id) get console_etls_url assert_response :ok - assert_select "h1", text: "ETLs" - assert_select "nav a[href=?]", console_etls_path, text: "ETLs" + assert_select "h1", text: "Data Sync" + assert_select "nav a[href=?]", console_etls_path, text: "Data Sync" assert_select "td", text: /export\.zip/ assert_select "th", text: "Workspace", count: 0 assert_select "span", text: "uploaded" diff --git a/services/console/test/controllers/console/integrations_controller_test.rb b/services/console/test/controllers/console/integrations_controller_test.rb new file mode 100644 index 000000000..d20b96b0c --- /dev/null +++ b/services/console/test/controllers/console/integrations_controller_test.rb @@ -0,0 +1,104 @@ +require "test_helper" + +class Console::IntegrationsControllerTest < ActionDispatch::IntegrationTest + test "redirects to login when not signed in" do + get console_integrations_url + assert_redirected_to login_path + end + + test "a non-admin sees enabled apps with their start links, logos, and no disabled apps" do + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + get console_integrations_url + assert_response :ok + + # Enabled apps show up with their consent start links. + %w[google slack github].each do |slug| + assert_select "a[href=?]", "http://www.example.com/oauth/#{slug}/start" + end + # Disabled apps are hidden. + assert_no_match "google-disabled", response.body + + # Known providers render a brand logo (inline SVG). + assert_select "svg path[fill='#4285F4']" # Google + assert_select "svg path[fill='#E01E5A']" # Slack + end + + test "an app already connected under the user's email shows Reconnect and its status" do + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + credential = BrokerCredential.create!( + oauth_app: oauth_apps(:acme_google), + namespace: "acme", + foreign_id: "google-google-member-sub", + name: "Google – Member", + token_endpoint: "https://oauth2.googleapis.com/token", + client_id: "google-client-id", + provider_subject: "member-sub", + provider_email: users(:member_user).email, + external_user_key: "member-key" + ) + + get console_integrations_url + assert_response :ok + assert_select "a.btn-secondary[href=?]", "http://www.example.com/oauth/google/start", text: "Reconnect" + assert_match "Connected", response.body + # The other apps are still unconnected. + assert_select "a.btn-primary[href=?]", "http://www.example.com/oauth/slack/start", text: "Connect" + + # A dead credential asks the user to reconnect rather than claiming success. + credential.update!(dead: true, dead_reason: "invalid_grant") + get console_integrations_url + assert_match "Needs reconnecting", response.body + end + + test "a credential the user minted shows connected even when the provider email differs" do + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + BrokerCredential.create!( + oauth_app: oauth_apps(:acme_google), + namespace: "acme", + foreign_id: "google-google-personal-sub", + name: "Google – Personal", + token_endpoint: "https://oauth2.googleapis.com/token", + client_id: "google-client-id", + provider_subject: "personal-sub", + provider_email: "personal@gmail.example", + external_user_key: "personal-key", + created_by: users(:member_user) + ) + + get console_integrations_url + assert_response :ok + assert_select "a.btn-secondary[href=?]", "http://www.example.com/oauth/google/start", text: "Reconnect" + end + + test "a credential minted for someone else's email does not mark the app connected" do + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + BrokerCredential.create!( + oauth_app: oauth_apps(:acme_google), + namespace: "acme", + foreign_id: "google-google-other-sub", + name: "Google – Other", + token_endpoint: "https://oauth2.googleapis.com/token", + client_id: "google-client-id", + provider_subject: "other-sub", + provider_email: users(:acme_admin).email, + external_user_key: "other-key" + ) + + get console_integrations_url + assert_response :ok + assert_select "a.btn-primary[href=?]", "http://www.example.com/oauth/google/start", text: "Connect" + assert_no_match "Reconnect", response.body + end + + test "an admin sees the same page" do + post login_url, params: { email: users(:acme_admin).email, password: "password123456" } + + get console_integrations_url + assert_response :ok + assert_select "a[href=?]", "http://www.example.com/oauth/google/start" + end +end diff --git a/services/console/test/controllers/console/principals_controller_test.rb b/services/console/test/controllers/console/principals_controller_test.rb index f4c6f8dbe..47f9b39f9 100644 --- a/services/console/test/controllers/console/principals_controller_test.rb +++ b/services/console/test/controllers/console/principals_controller_test.rb @@ -17,20 +17,202 @@ class PrincipalsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to login_path end - test "update_sandbox_access toggles repo cache and observability access" do + test "new renders the create form" do + get console_new_principal_url + assert_response :ok + assert_select "form[action=?][method=?]", console_create_principal_path, "post" do + assert_select "input[name='principal[namespace]'][value=default]" + assert_select "input[name='principal[foreign_id]']" + assert_select "input[name='principal[name]']" + assert_select "button", "Add label" + assert_select "input[type=submit][value='Add Principal']" + end + end + + test "create persists a principal and redirects to its detail page" do + system_settings(:default).update!( + default_sandbox_repo_cache: "public", + default_sandbox_observability_enabled: false, + default_sandbox_api_server_enabled: false + ) + + assert_difference -> { Principal.count }, 1 do + post console_create_principal_url, + params: { + principal: { namespace: "acme", foreign_id: "C-new-console", name: "New console principal" }, + labels: { + "0" => { key: "kind", value: "slack_channel" }, + "1" => { key: "team", value: "platform" } + } + } + end + + principal = Principal.find_by!(namespace: "acme", foreign_id: "C-new-console") + assert_redirected_to console_principal_path(principal.oid) + assert_equal "Principal created.", flash[:notice] + assert_equal "New console principal", principal.name + assert_equal( + { + "kind" => "slack_channel", + "team" => "platform", + Principal::SANDBOX_REPO_CACHE_LABEL => "public" + }, + principal.labels + ) + assert_equal "public", principal.sandbox_repo_cache + assert_equal false, principal.sandbox_observability_enabled + assert_equal false, principal.sandbox_api_server_enabled + assert_equal @operator, principal.created_by + end + + test "create re-renders validation errors" do + existing = principals(:acme_channel) + + assert_no_difference -> { Principal.count } do + post console_create_principal_url, + params: { + principal: { namespace: existing.namespace, foreign_id: existing.foreign_id, name: "Duplicate" } + } + end + + assert_response :unprocessable_entity + assert_select ".alert-error", text: /Principal could not be saved/ + assert_select ".field-error", text: /has already been taken/ + end + + test "update_sandbox_access toggles sandbox capabilities" do principal = principals(:acme_user_bob) patch console_principal_sandbox_access_url(principal.oid), params: { - sandbox_repo_cache_enabled: "0", - sandbox_observability_enabled: "0" + sandbox_repo_cache: "public", + sandbox_observability_enabled: "0", + sandbox_api_server_enabled: "0" } assert_redirected_to console_principal_path(principal.oid) assert_equal "Updated sandbox access.", flash[:notice] principal.reload - assert_equal false, principal.sandbox_repo_cache_enabled + assert_equal "public", principal.sandbox_repo_cache + assert_equal "public", principal.labels[Principal::SANDBOX_REPO_CACHE_LABEL] assert_equal false, principal.sandbox_observability_enabled + assert_equal false, principal.sandbox_api_server_enabled + end + + test "update_slack_channel_permissions stores selected Slack channel permissions" do + principal = principals(:acme_user_bob) + + patch console_principal_slack_channel_permissions_url(principal.oid), + params: { + principal: { + slack_channel_permissions_attributes: { + "0" => { + channel_id: "C0123456789", + upload_enabled: "1", + download_enabled: "0", + history_enabled: "1" + }, + "1" => { + channel_id: "G9876543210", + upload_enabled: "0", + download_enabled: "1", + history_enabled: "0" + } + } + } + } + + assert_redirected_to console_principal_path(principal.oid) + assert_equal( + [ + { + "channel_id" => "C0123456789", + "channel_name" => nil, + "upload_enabled" => true, + "download_enabled" => false, + "history_enabled" => true + }, + { + "channel_id" => "G9876543210", + "channel_name" => nil, + "upload_enabled" => false, + "download_enabled" => true, + "history_enabled" => false + } + ], + principal.reload.slack_channel_permissions_payload + ) + end + + test "update_slack_channel_permissions clears stale channel names when changing channels" do + principal = principals(:acme_user_bob) + permission = SlackChannelPermission.create!( + principal: principal, + channel_id: "C0123456789", + channel_name: "old-channel", + upload_enabled: true, + download_enabled: true, + history_enabled: true + ) + + patch console_principal_slack_channel_permissions_url(principal.oid), + params: { + principal: { + slack_channel_permissions_attributes: { + "0" => { + id: permission.id, + channel_id: "G9876543210", + channel_name: "", + upload_enabled: "1", + download_enabled: "1", + history_enabled: "1" + } + } + } + } + + assert_redirected_to console_principal_path(principal.oid) + permission.reload + assert_equal "G9876543210", permission.channel_id + assert_nil permission.channel_name + end + + test "destroy deletes the principal and dependent access records" do + principal = principals(:acme_channel) + proxy = proxies(:acme_proxy) + client = McpOauthClient.create!(redirect_uris: [ "http://localhost/callback" ]) + McpOauthAuthorizationCode.create!( + mcp_oauth_client: client, + user: users(:acme_admin), + principal: principal, + redirect_uri: "http://localhost/callback", + code_challenge: "challenge", + resource: "https://api.example.test", + scopes: %w[mcp:tools] + ) + McpOauthRefreshToken.create!( + mcp_oauth_client: client, + user: users(:acme_admin), + principal: principal, + resource: "https://api.example.test", + scopes: %w[mcp:tools] + ) + + assert_difference -> { Principal.count }, -1 do + assert_difference -> { Grant.where(principal: principal).count }, -3 do + assert_difference -> { PrincipalRole.where(principal: principal).count }, -1 do + assert_difference -> { McpOauthAuthorizationCode.where(principal: principal).count }, -1 do + assert_difference -> { McpOauthRefreshToken.where(principal: principal).count }, -1 do + delete console_delete_principal_url(principal.oid) + end + end + end + end + end + + assert_redirected_to console_principals_path + assert_equal "Deleted principal #{principal.foreign_id}.", flash[:notice] + assert_nil proxy.reload.principal end test "assign_role attaches the role and redirects with a notice" do diff --git a/services/console/test/controllers/console/system_settings_controller_test.rb b/services/console/test/controllers/console/system_settings_controller_test.rb new file mode 100644 index 000000000..ee5583c48 --- /dev/null +++ b/services/console/test/controllers/console/system_settings_controller_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +module Console + class SystemSettingsControllerTest < ActionDispatch::IntegrationTest + def sign_in(user) + post login_url, params: { email: user.email, password: "password123456" } + end + + test "redirects to login when signed out" do + get edit_console_system_settings_url + assert_redirected_to login_path + end + + test "non-admin users cannot edit settings" do + sign_in users(:member_user) + get edit_console_system_settings_url + assert_redirected_to console_threads_path + end + + test "admin can edit system settings" do + sign_in users(:acme_admin) + + get edit_console_system_settings_url + assert_response :ok + + assert_select ".console-control-tab-active", text: "Settings" + assert_select "select[name='system_setting[default_sandbox_repo_cache]']" + assert_select "input[name='system_setting[default_sandbox_observability_enabled]']" + assert_select "input[name='system_setting[default_sandbox_api_server_enabled]']" + end + + test "admin updates default sandbox capabilities" do + sign_in users(:acme_admin) + + patch console_system_settings_url, + params: { + system_setting: { + default_sandbox_repo_cache: "public", + default_sandbox_observability_enabled: "0", + default_sandbox_api_server_enabled: "0" + } + } + + assert_redirected_to edit_console_system_settings_path + assert_equal "System settings updated.", flash[:notice] + settings = system_settings(:default).reload + assert_equal "public", settings.default_sandbox_repo_cache + assert_equal false, settings.default_sandbox_observability_enabled + assert_equal false, settings.default_sandbox_api_server_enabled + end + end +end diff --git a/services/console/test/controllers/console/threads_controller_test.rb b/services/console/test/controllers/console/threads_controller_test.rb new file mode 100644 index 000000000..540c6b65c --- /dev/null +++ b/services/console/test/controllers/console/threads_controller_test.rb @@ -0,0 +1,1305 @@ +require "test_helper" +require "tmpdir" + +class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest + TranscriptMessage = Struct.new(:role, :parts_array, :metadata_hash, :created_at, keyword_init: true) + TranscriptSession = Struct.new(:metadata_hash, :harness_type, :title, keyword_init: true) + ModelSession = Struct.new(:thread_key, :metadata_hash, :harness_type, keyword_init: true) + ModelExecution = Struct.new(:metadata, keyword_init: true) + TranscriptEvent = Struct.new(:event_type, :payload_hash, :created_at, keyword_init: true) + SelectedSession = Struct.new(:thread_key, keyword_init: true) + + setup do + @operator = users(:acme_admin) + post login_url, params: { email: @operator.email, password: "password123456" } + end + + test "an admin sees the Control and Data Sync nav items" do + with_recent_first_error do + get console_threads_url + end + + assert_response :ok + assert_select ".console-nav-link", text: "Control" + assert_select ".console-nav-link", text: "Data Sync" + end + + test "a non-admin sees only the Integrations nav item, not Control or Data Sync" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + with_recent_first_error do + get console_threads_url + end + + assert_response :ok + assert_select ".console-nav-link", count: 1, text: /Integrations/ + assert_select ".console-thread-group-title", text: /Chats/ + end + + test "threads page does not render composer when session database is unavailable" do + with_recent_first_error do + get console_threads_url + end + + assert_response :ok + assert_select "input[name=q]", count: 0 + assert_select ".console-main-thread-frame aside", count: 0 + # No chat selected: like the not-found state, the page renders only the + # centered empty state — no detail header. + assert_select ".console-thread-detail-header", count: 0 + assert_select "a[aria-label=?]", "New chat", count: 0 + assert_select "span[aria-label=?]", "New chat disabled", count: 0 + assert_select "textarea[name=prompt]", count: 0 + assert_select "select[name=harness_type]", count: 0 + assert_select "form[action=?]", console_threads_path, count: 0 + assert_select "body", text: /No chats yet/ + assert_select "body", text: /Chat database is unavailable/ + end + + test "blank prompt is blocked by read only mode" do + post console_threads_url, params: { prompt: " " } + + assert_redirected_to console_threads_path + assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + end + + test "threads page hides composer controls" do + with_recent_first_error do + get console_threads_url + end + + assert_response :ok + assert_select "textarea[name=prompt]", count: 0 + assert_select "form[action=?]", console_threads_path, count: 0 + assert_select "body", text: /Read-only snapshot/, count: 0 + assert_select "span[aria-label=?]", "New chat disabled", count: 0 + assert_select "a[aria-label=?]", "New chat", count: 0 + end + + test "posts are blocked without calling the session api" do + post console_threads_url, params: { prompt: "Do not run this." } + + assert_redirected_to console_threads_path + assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + end + + test "plain threads page redirects to first visible thread" do + skip_unless_session_table + + thread_key = "console:auto-select-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + + get console_threads_url + + assert_redirected_to console_threads_path(thread: thread_key) + end + + test "direct selected thread renders chat not found when the current user did not start it" do + skip_unless_session_table + + thread_key = "slack:C0DIRECT:#{SecureRandom.hex(6)}" + insert_slack_session( + thread_key, + slack_user_id: "U_OTHER", + slack_user_name: "someone-else" + ) + + # @operator has no Slack OAuth credential matching U_OTHER, so this thread is + # outside their owner scope. A direct ?thread= link must render a 404 chat + # not found state instead of surfacing it or falling back to another chat. + get console_threads_url(thread: thread_key) + + assert_response :not_found + assert_select "body", text: /Chat not found/ + # The not-found rendering carries no page header and no explainer copy — + # just the centered "Chat not found" state. + assert_select ".console-thread-detail-header", count: 0 + assert_select "body", text: /may not exist/, count: 0 + assert_select "[data-thread-panel]", count: 0 + assert_select ".console-thread-list a.console-thread-link-active[href=?]", + console_threads_path(thread: thread_key), + count: 0 + end + + test "direct link to a nonexistent thread renders chat not found" do + skip_unless_session_table + + # Even with an owned chat present, a bogus key must 404 rather than fall + # back to the first visible chat. + insert_console_session("console:owned-#{SecureRandom.hex(6)}") + + get console_threads_url(thread: "console:missing-#{SecureRandom.hex(6)}") + + assert_response :not_found + assert_select "body", text: /Chat not found/ + end + + test "slack assistant-role messages from the current Slack user render as user authored" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [ "u123" ] } + controller.instance_variable_set( + :@selected_session, + TranscriptSession.new( + metadata_hash: { + "slack_user_id" => "U123", + "slack_display_name" => "Goksu Toprak", + "slack_user_name" => "goksu" + } + ) + ) + message = TranscriptMessage.new( + role: "assistant", + parts_array: [ { "type" => "text", "text" => "Root Slack bot post" } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "U123", + "slack_display_name" => "U123" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "assistant", item[:role] + assert_equal "Goksu Toprak", item[:label] + assert_equal :end, item[:align] + assert_equal "Root Slack bot post", item[:text] + end + + test "slack message text resolves mentions from bot identity and selected actor metadata" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [ "u123" ] } + controller.instance_variable_set( + :@selected_session, + TranscriptSession.new( + metadata_hash: { + "slack_user_id" => "U123", + "slack_display_name" => "Goksu Toprak", + "slack_user_name" => "goksu" + } + ) + ) + message = TranscriptMessage.new( + role: "user", + parts_array: [ + { + "type" => "text", + "text" => "@UBOT Are you working? Also loop in <@U123>." + } + ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "is_mention" => true, + "slack_user_id" => "U123", + "slack_display_name" => "Goksu Toprak", + "slack_user_name" => "goksu" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + controller.instance_variable_set(:@selected_messages, [ message ]) + controller.instance_variable_set(:@selected_events, []) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "@ai Are you working? Also loop in @goksu.", item[:text] + end + + test "slack mention resolution prefers synced user names when available" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [] } + controller.define_singleton_method(:slack_user_display_labels_from_database) do |_user_ids| + { "u456" => "@alice" } + end + message = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "cc @U456" } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "U123" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + controller.instance_variable_set(:@selected_session, TranscriptSession.new(metadata_hash: {})) + controller.instance_variable_set(:@selected_messages, [ message ]) + controller.instance_variable_set(:@selected_events, []) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "cc @alice", item[:text] + end + + test "slack messages from other actors keep their author label" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [ "u123" ] } + controller.instance_variable_set( + :@selected_session, + TranscriptSession.new(metadata_hash: { "slack_user_id" => "U123" }) + ) + message = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "Another person replied" } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "U456", + "slack_display_name" => "Alice" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "Alice", item[:label] + assert_equal :start, item[:align] + end + + test "slack messages from selected thread owner still show author when not current Slack user" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [ "u999" ] } + controller.define_singleton_method(:slack_mention_labels_by_id) { { "u123" => "@goksu" } } + controller.instance_variable_set( + :@selected_session, + TranscriptSession.new( + metadata_hash: { + "slack_user_id" => "U123", + "slack_display_name" => "Goksu Toprak", + "slack_user_name" => "goksu" + } + ) + ) + message = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "Owner message in a direct linked thread" } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "U123", + "slack_display_name" => "U123" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + + item = controller.send(:transcript_item_for_message, message) + + assert_equal "@goksu", item[:label] + assert_equal :start, item[:align] + end + + test "slack bot messages use configured bot username as author label" do + controller = Console::ThreadsController.new + controller.define_singleton_method(:current_slack_user_ids) { [] } + mention = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "@UBOT Please check this." } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "is_mention" => true, + "slack_user_id" => "U123" + }, + created_at: Time.zone.parse("2026-06-26 17:15:58 UTC") + ) + bot_message = TranscriptMessage.new( + role: "user", + parts_array: [ { "type" => "text", "text" => "Working on it." } ], + metadata_hash: { + "source" => "slackbotv2", + "platform" => "slack", + "slack_user_id" => "UBOT", + "slack_display_name" => "UBOT" + }, + created_at: Time.zone.parse("2026-06-26 17:16:58 UTC") + ) + controller.instance_variable_set(:@selected_session, TranscriptSession.new(metadata_hash: {})) + controller.instance_variable_set(:@selected_messages, [ mention, bot_message ]) + controller.instance_variable_set(:@selected_events, []) + + item = controller.send(:transcript_item_for_message, bot_message) + + assert_equal "@ai", item[:label] + assert_equal :start, item[:align] + end + + test "terminal execution events render as bot output" do + controller = Console::ThreadsController.new + event = TranscriptEvent.new( + event_type: "session.execution_completed", + payload_hash: { "result_text" => "The issue is real for @U123." }, + created_at: Time.zone.parse("2026-06-26 17:16:44 UTC") + ) + controller.define_singleton_method(:slack_user_display_labels_from_database) do |_user_ids| + { "u123" => "@goksu" } + end + controller.instance_variable_set(:@selected_session, TranscriptSession.new(metadata_hash: {})) + controller.instance_variable_set(:@selected_messages, []) + controller.instance_variable_set(:@selected_events, [ event ]) + + item = controller.send(:transcript_item_for_event, event) + + assert_equal "assistant", item[:role] + assert_equal "@ai", item[:label] + assert_equal :start, item[:align] + assert_equal "The issue is real for @goksu.", item[:text] + end + + test "generated thread title strips slack mentions and clips to assistant title length" do + controller = Console::ThreadsController.new + title = controller.send( + :generated_thread_title, + "@U0ANX3AM5RR Approach truth-seeking to max and let me know if this is actually " \ + "a legit issue with extra context that should not fit" + ) + + assert_not_includes title, "@U0ANX3AM5RR" + assert title.start_with?("Approach truth-seeking") + assert_operator title.length, :<=, 80 + assert title.end_with?("...") + end + + test "thread title prefers the stored generated title over metadata" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "summary" => { "title" => "metadata title" } }, + harness_type: "codex", + title: "Fix worker memory leak" + ) + + assert_equal "Fix worker memory leak", controller.send(:thread_title, session) + end + + test "thread title ignores a blank stored title" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "subject" => "Fallback subject" }, + harness_type: "codex", + title: " " + ) + + assert_equal "Fallback subject", controller.send(:thread_title, session) + end + + test "thread title prefers stored summary metadata when present" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "summary" => { "title" => "Investigate rollout failure" } }, + harness_type: "codex" + ) + + assert_equal "Investigate rollout failure", controller.send(:thread_title, session) + end + + test "thread title tolerates a plain string summary without raising" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "summary" => "a plain string" }, + harness_type: "codex" + ) + + assert_nothing_raised do + assert_equal "a plain string", controller.send(:thread_title, session) + end + end + + test "thread title tolerates a string thread metadata without raising" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "thread" => "x", "subject" => "Fallback subject" }, + harness_type: "codex" + ) + + assert_nothing_raised do + assert_equal "Fallback subject", controller.send(:thread_title, session) + end + end + + test "thread source and harness labels are display cased" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "platform" => "slack" }, + harness_type: "codex" + ) + + assert_equal "Slack", controller.send(:thread_source_label, session) + assert_equal "slack", controller.send(:thread_source_icon, session) + assert_equal "Codex", controller.send(:thread_harness_label, session) + end + + test "thread model label prefers the latest execution's recorded model override" do + controller = Console::ThreadsController.new + session = ModelSession.new( + thread_key: "slack:C1:1", + metadata_hash: {}, + harness_type: "claudecode" + ) + execution = ModelExecution.new(metadata: { "model" => "claude-sonnet-4-6" }) + controller.instance_variable_set(:@latest_executions, { "slack:C1:1" => execution }) + + assert_equal "CLAUDE-SONNET-4-6", controller.send(:thread_model_label, session) + end + + test "thread model label reads session metadata before the harness default" do + controller = Console::ThreadsController.new + session = TranscriptSession.new( + metadata_hash: { "model" => "claude-fable-5" }, + harness_type: "claudecode" + ) + + assert_equal "CLAUDE-FABLE-5", controller.send(:thread_model_label, session) + end + + test "thread model label falls back to the deployment's model env override" do + controller = Console::ThreadsController.new + + with_env("CLAUDE_MODEL" => "claude-fable-5", "CODEX_MODEL" => "gpt-6") do + assert_equal "CLAUDE-FABLE-5", controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "claudecode") + ) + assert_equal "GPT-6", controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "codex") + ) + end + end + + test "thread model label falls back to the models pinned in the harness config files" do + controller = Console::ThreadsController.new + + Dir.mktmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "claude")) + FileUtils.mkdir_p(File.join(dir, "codex")) + File.write(File.join(dir, "claude", "settings.json"), { model: "claude-baked-1" }.to_json) + File.write(File.join(dir, "codex", "config.toml"), <<~TOML) + model = "gpt-baked-1" + model_reasoning_effort = "low" + TOML + + with_env("CLAUDE_MODEL" => nil, "CODEX_MODEL" => nil, "CENTAUR_HARNESS_CONFIG_DIR" => dir) do + assert_equal "CLAUDE-BAKED-1", controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "claudecode") + ) + assert_equal "GPT-BAKED-1", controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "codex") + ) + end + end + end + + test "thread model label is nil for harnesses without a fixed default" do + controller = Console::ThreadsController.new + + assert_nil controller.send( + :thread_model_label, + TranscriptSession.new(metadata_hash: {}, harness_type: "amp") + ) + end + + test "visible thread scope matches Slack threads owned by the current user's Slack OAuth record" do + app = oauth_apps(:acme_slack) + app.update!(client_secret: "slack-secret", labels: { "slack_team_id" => "T123" }) + create_slack_oauth_credential( + app, + subject: "UOWNER", + email: @operator.email, + labels: { "slack_team_id" => "T123" } + ) + controller = threads_controller_for(@operator) + + sql = controller.send(:visible_thread_scope).to_sql + + assert_includes sql, "thread_key LIKE 'slack:%'" + assert_includes sql, "metadata ->> 'slack_user_id'" + assert_includes sql, "uowner" + assert_includes sql, "split_part(thread_key, ':', 2)" + assert_includes sql, "t123" + end + + test "visible thread scope keeps current user's console threads without Slack OAuth" do + controller = threads_controller_for(@operator) + sql = controller.send(:visible_thread_scope).to_sql + + assert_includes sql, "thread_key LIKE 'console:%'" + assert_includes sql, @operator.email + refute_includes sql, "slack_user_id" + end + + test "visible thread scope matches Slack threads by user id when the credential has no team" do + app = oauth_apps(:acme_slack) + app.update!(client_secret: "slack-secret", labels: {}) + create_slack_oauth_credential( + app, + subject: "UOWNER", + email: @operator.email, + labels: {} + ) + controller = threads_controller_for(@operator) + + sql = controller.send(:visible_thread_scope).to_sql + + # slackbotv2 threads carry no team (slack:CHANNEL:TS keys, no slack_team_id), + # so a team-less credential still matches on slack_user_id alone; team scoping + # is added only when the credential exposes a team. + assert_includes sql, "thread_key LIKE 'slack:%'" + assert_includes sql, "metadata ->> 'slack_user_id'" + assert_includes sql, "uowner" + refute_includes sql, "split_part(thread_key, ':', 2)" + end + + test "visible thread scope matches Slack threads via the SSO identity without a broker credential" do + UserIdentity.create!( + user: @operator, + provider: "slack", + subject: "USSOONLY", + email: @operator.email, + email_verified: true + ) + controller = threads_controller_for(@operator) + + sql = controller.send(:visible_thread_scope).to_sql + + # The Slack OIDC subject is the workspace user id, so signing in with + # Slack is enough to own the threads slackbotv2 attributed to that id — + # no broker credential required. + assert_includes sql, "thread_key LIKE 'slack:%'" + assert_includes sql, "metadata ->> 'slack_user_id'" + assert_includes sql, "ussoonly" + refute_includes sql, "split_part(thread_key, ':', 2)" + end + + test "visible thread scope dedupes an SSO identity that matches its broker credential" do + app = oauth_apps(:acme_slack) + app.update!(client_secret: "slack-secret", labels: {}) + create_slack_oauth_credential(app, subject: "UOWNER", email: @operator.email, labels: {}) + UserIdentity.create!( + user: @operator, + provider: "slack", + subject: "UOWNER", + email: @operator.email, + email_verified: true + ) + controller = threads_controller_for(@operator) + + owners = controller.send(:slack_thread_owners_for_current_user) + + assert_equal [ "UOWNER" ], owners.map { |owner| owner.user_id.upcase } + end + + test "sidebar thread scope matches Slack threads via the SSO identity without a broker credential" do + UserIdentity.create!( + user: @operator, + provider: "slack", + subject: "USSOONLY", + email: @operator.email, + email_verified: true + ) + controller = threads_controller_for(@operator) + + sql = controller.send(:console_sidebar_visible_thread_scope).to_sql + + assert_includes sql, "thread_key LIKE 'slack:%'" + assert_includes sql, "metadata ->> 'slack_user_id'" + assert_includes sql, "ussoonly" + end + + test "selected session resolves a directly linked thread only within the owner scope" do + controller = Console::ThreadsController.new + owned_thread = SelectedSession.new(thread_key: "slack:C123:1782339173.755169") + scoped_relation = Object.new + scoped_relation.define_singleton_method(:where) do |thread_key:| + thread_key == owned_thread.thread_key ? [ owned_thread ] : [] + end + controller.instance_variable_set(:@starting_new_thread, false) + controller.instance_variable_set(:@sessions, []) + + # An owned key outside the base window is recovered through the scope. + controller.instance_variable_set(:@selected_thread_key, owned_thread.thread_key) + assert_equal owned_thread, controller.send(:selected_session, scoped_relation, []) + + # A key the scope does not own has no unscoped fallback, so it stays hidden. + controller.instance_variable_set(:@selected_thread_key, "slack:C999:1782339173.999999") + assert_nil controller.send(:selected_session, scoped_relation, []) + end + + test "starting a thread is blocked without calling the session api" do + post console_threads_url, params: { prompt: "Reply with PONG.", harness_type: "amp" } + + assert_redirected_to console_threads_path + assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + end + + test "posting to an existing thread is blocked without calling the session api" do + post console_threads_url, + params: { + prompt: "Continue from here.", + thread_key: "console:existing", + harness_type: "codex" + } + + assert_redirected_to console_threads_path(thread: "console:existing") + assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + end + + # Fix 6: the sidebar thread list is loaded lazily via a Turbo Frame so the + # cross-database sessions query never runs during the primary page render. + test "console pages defer the sidebar thread list to a lazy turbo frame" do + # A non-thread page must not run the sessions query during its render: if it + # did, load_console_sidebar_threads would be invoked. Track invocations and + # assert none happen while rendering the primary page. + original = ApplicationController.instance_method(:load_console_sidebar_threads) + Thread.current[:sidebar_loaded] = false + ApplicationController.send(:define_method, :load_console_sidebar_threads) do + Thread.current[:sidebar_loaded] = true + original.bind(self).call + end + + begin + get console_principals_url + + assert_response :ok + assert_not Thread.current[:sidebar_loaded], + "primary page render must not load the sidebar thread list" + assert_select "turbo-frame#console_sidebar_threads[src=?]", console_sidebar_threads_path + assert_select "turbo-frame#console_sidebar_threads[loading=?]", "lazy" + ensure + ApplicationController.send(:define_method, :load_console_sidebar_threads, original) + Thread.current[:sidebar_loaded] = nil + end + end + + test "sidebar action renders the empty thread list when the session DB is unavailable" do + with_recent_first_error do + get console_sidebar_threads_url + end + + assert_response :ok + assert_select "turbo-frame#console_sidebar_threads" + assert_select ".console-thread-empty", text: /No recent chats/ + end + + # Fix 5: selected_messages must return the NEWEST MESSAGE_LIMIT messages, in + # oldest-first display order. A previous ascending order + limit returned the + # oldest N and dropped the newest for long threads. + test "selected_messages query fetches newest messages first with a limit" do + # Building the SQL type-casts against the session_messages schema, which + # only exists where the api-rs session tables are present. + skip_unless_session_table + + relation = CentaurSessionMessage + .where(thread_key: "console:ordering") + .order(created_at: :desc, message_id: :desc) + .limit(Console::ThreadsController::MESSAGE_LIMIT) + sql = relation.to_sql + + assert_match(/ORDER BY.*created_at.*DESC.*message_id.*DESC/i, sql) + assert_match(/LIMIT #{Console::ThreadsController::MESSAGE_LIMIT}\b/, sql) + end + + test "selected_messages returns newest messages in ascending display order" do + skip_unless_session_table + + thread_key = "console:transcript-order" + insert_console_session(thread_key) + + limit = Console::ThreadsController::MESSAGE_LIMIT + total = limit + 5 + total.times do |i| + insert_session_message(thread_key, index: i) + end + + controller = Console::ThreadsController.new + controller.instance_variable_set(:@selected_session, SelectedSession.new(thread_key: thread_key)) + + messages = controller.send(:selected_messages) + + assert_equal limit, messages.size + indices = messages.map { |m| m.message_id.split("-").last.to_i } + # Oldest-first display order over the newest `limit` messages: the earliest + # (index 0..4) are dropped, and what remains is ascending. + assert_equal (total - limit...total).to_a, indices + assert_equal indices, indices.sort + end + + OutputLineEvent = Struct.new(:payload, :created_at, :execution_id, :event_id, keyword_init: true) + + test "thinking transcript item is extracted from a completed reasoning output line" do + controller = Console::ThreadsController.new + line = { + method: "item/completed", + params: { + item: { + type: "reasoning", + content: [ "First I will check the schema.", "Then write the query." ] + } + } + }.to_json + event = OutputLineEvent.new(payload: line, created_at: Time.zone.parse("2026-06-26 17:15:58 UTC")) + + item = controller.send(:thinking_transcript_item, event) + + assert_equal "thinking", item[:role] + assert_equal "Thinking", item[:label] + assert_equal :thinking, item[:source] + assert_equal :start, item[:align] + assert_equal "First I will check the schema.\nThen write the query.", item[:text] + assert_equal event.created_at, item[:created_at] + end + + test "thinking extraction accepts dot-form types and summary-only reasoning" do + controller = Console::ThreadsController.new + line = { + type: "item.completed", + item: { type: "reasoning", summary: [ { text: "Condensed thought." } ] } + }.to_json + event = OutputLineEvent.new(payload: line, created_at: Time.zone.now) + + item = controller.send(:thinking_transcript_item, event) + + assert_equal "Condensed thought.", item[:text] + end + + test "thinking extraction formats completed command execution output lines" do + controller = Console::ThreadsController.new + line = { + method: "item/completed", + params: { + item: { + id: "cmd-1", + type: "commandExecution", + command: "pnpm test", + status: "completed", + aggregatedOutput: "ok\n", + exitCode: 0 + } + } + }.to_json + event = OutputLineEvent.new(payload: line, created_at: Time.zone.parse("2026-06-26 17:15:58 UTC")) + + item = controller.send(:thinking_transcript_item, event) + + assert_equal "thinking", item[:role] + assert_equal "Ran 1 command", item[:label] + assert_equal :thinking, item[:source] + assert_equal "command", item[:trace_kind] + assert_equal 1, item[:commands].length + assert_equal "pnpm test", item[:commands].first[:command] + assert_equal "ok\n", item[:commands].first[:output] + assert_equal 0, item[:commands].first[:exit_code] + assert_not item[:commands].first[:failed] + assert_includes item[:text], "Status: completed" + assert_includes item[:text], "Exit code: 0" + assert_includes item[:text], "```sh\npnpm test\n```" + assert_includes item[:text], "Output:" + assert_includes item[:text], "```text\nok\n```" + end + + test "compact trace grouping combines adjacent command executions for one run" do + controller = Console::ThreadsController.new + now = Time.zone.now + first = { + role: "thinking", + label: "Ran 1 command", + text: "$ pnpm test", + trace_kind: "command", + commands: [ { command: "pnpm test", output: "ok\n", exit_code: 0, status: "completed", failed: false } ], + execution_id: "exe-1", + created_at: now, + source: :thinking + } + second = { + role: "thinking", + label: "Ran 1 command", + text: "$ curl bad", + trace_kind: "command", + commands: [ { command: "curl bad", output: "failed\n", exit_code: 22, status: "completed", failed: true } ], + execution_id: "exe-1", + created_at: now + 1.second, + source: :thinking + } + thought = { + role: "thinking", + label: "Thinking", + text: "Need one more check.", + trace_kind: "thinking", + created_at: now + 2.seconds, + source: :thinking + } + + grouped = controller.send(:compact_trace_items, [ first, second, thought ]) + + assert_equal 2, grouped.length + assert_equal "commands", grouped.first[:trace_kind] + assert_equal "Ran 2 commands", grouped.first[:label] + assert_equal "1 failed", grouped.first[:failed_label] + assert_equal [ "pnpm test", "curl bad" ], grouped.first[:commands].map { |command| command[:command] } + assert_equal thought, grouped.second + end + + test "activity summaries attach to the latest trace item at or before their source line" do + controller = Console::ThreadsController.new + items = [ + { event_id: 10, text: "first" }, + { event_id: 20, text: "second" } + ] + summaries = [ + TranscriptEvent.new(event_type: "session.activity_summary", payload_hash: { "summary" => "I found the bug", "source_event_id" => 9 }), + TranscriptEvent.new(event_type: "session.activity_summary", payload_hash: { "summary" => "I'm reading the schema", "source_event_id" => 11 }), + TranscriptEvent.new(event_type: "session.activity_summary", payload_hash: { "summary" => "I'm writing the query", "source_event_id" => 15 }), + TranscriptEvent.new(event_type: "session.activity_summary", payload_hash: { "summary" => "", "source_event_id" => 21 }) + ] + controller.define_singleton_method(:selected_activity_summaries) { summaries } + + controller.send(:apply_activity_summaries, items) + + # The newest summary in an item's window wins; blank summaries and + # summaries preceding every trace item are dropped. + assert_equal "I'm writing the query", items[0][:summary] + assert_nil items[1][:summary] + end + + test "thinking extraction formats claude stream-json tool calls" do + controller = Console::ThreadsController.new + line = { + type: "assistant", + message: { + content: [ + { type: "tool_use", id: "toolu_1", name: "websearch", input: { query: "centaur" } } + ] + } + }.to_json + event = OutputLineEvent.new(payload: line, created_at: Time.zone.now) + + item = controller.send(:thinking_transcript_item, event) + + assert_equal "Tool call", item[:label] + assert_includes item[:text], "Use websearch" + assert_includes item[:text], '"query": "centaur"' + end + + test "thinking extraction ignores partial and unrelated output lines" do + controller = Console::ThreadsController.new + now = Time.zone.now + + delta = { method: "item/reasoning/textDelta", params: { delta: "partial" } }.to_json + started_tool = { + method: "item/started", + params: { item: { type: "commandExecution", command: "pnpm test" } } + }.to_json + non_json = "plain stdout noise mentioning reasoning" + non_string = { "result" => "reasoning" } + + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: delta, created_at: now)) + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: started_tool, created_at: now)) + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: non_json, created_at: now)) + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: non_string, created_at: now)) + end + + test "thinking transcript item is extracted from a claude stream-json assistant line" do + controller = Console::ThreadsController.new + line = { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "The schema mismatch explains the failure.", signature: "sig" }, + { type: "text", text: "Here is the fix." } + ] + } + }.to_json + event = OutputLineEvent.new(payload: line, created_at: Time.zone.parse("2026-06-26 17:15:58 UTC")) + + item = controller.send(:thinking_transcript_item, event) + + assert_equal "thinking", item[:role] + assert_equal :thinking, item[:source] + assert_equal "The schema mismatch explains the failure.", item[:text] + assert_equal event.created_at, item[:created_at] + end + + test "thinking extraction joins multiple claude thinking blocks and skips thinking-free assistant lines" do + controller = Console::ThreadsController.new + now = Time.zone.now + + multi = { + type: "assistant", + message: { + content: [ + { type: "thinking", thinking: "First thought." }, + { type: "thinking", thinking: "Second thought." } + ] + } + }.to_json + text_only = { + type: "assistant", + message: { content: [ { type: "text", text: "No thinking here." } ] } + }.to_json + stream_event = { + type: "stream_event", + event: { delta: { type: "thinking_delta", thinking: "partial" } } + }.to_json + + item = controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: multi, created_at: now)) + assert_equal "First thought.\nSecond thought.", item[:text] + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: text_only, created_at: now)) + assert_nil controller.send(:thinking_transcript_item, OutputLineEvent.new(payload: stream_event, created_at: now)) + end + + test "requested thread keys are deduped, stripped, and capped at the panel limit" do + controller = Console::ThreadsController.new + controller.params = ActionController::Parameters.new( + thread: " a , b,a,, c ,d,e " + ) + + assert_equal %w[a b c d], controller.send(:requested_thread_keys) + end + + test "thinking trace renders as a collapsed disclosure in the transcript" do + skip_unless_session_table + + thread_key = "console:thinking-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + insert_session_message(thread_key, index: 0) + insert_reasoning_event(thread_key, text: "I should compare the two schemas before answering.") + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "details.console-thinking summary", text: /Thinking/ + assert_select "details.console-thinking", text: /compare the two schemas/ + end + + test "tool trace renders as a collapsed disclosure in the transcript" do + skip_unless_session_table + + thread_key = "console:tool-trace-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + insert_session_message(thread_key, index: 0) + insert_command_trace_event(thread_key, command: "pnpm test", output: "ok\n") + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "details.console-thinking summary", text: /Ran 1 command/ + assert_select ".console-thinking-command-row", text: /pnpm test/ + assert_select ".console-thinking-command-full", text: /\$ pnpm test/ + assert_select ".console-thinking-command-result", text: /ok/ + assert_select ".console-thinking-command-meta", count: 0 + assert_select "details.console-thinking", text: /Status:/, count: 0 + assert_select "details.console-thinking", text: /pnpm test/ + assert_select "details.console-thinking", text: /ok/ + end + + test "thinking preview shows the activity summary covering its block" do + skip_unless_session_table + + thread_key = "console:activity-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + insert_session_message(thread_key, index: 0) + source_event_id = insert_reasoning_event(thread_key, text: "I should compare the two schemas before answering.") + insert_activity_summary_event( + thread_key, + summary: "I'm comparing the two schemas", + source_event_id: source_event_id + ) + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "details.console-thinking .console-thinking-preview", + text: /I'm comparing the two schemas/ + # The full thinking text stays available in the disclosure body. + assert_select "details.console-thinking", text: /compare the two schemas before answering/ + end + + test "command trace group shows the activity summary as its collapsed preview" do + skip_unless_session_table + + thread_key = "console:activity-cmd-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + insert_session_message(thread_key, index: 0) + source_event_id = insert_command_trace_event(thread_key, command: "pnpm test", output: "ok\n") + insert_activity_summary_event( + thread_key, + summary: "I'm running the test suite", + source_event_id: source_event_id + ) + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "details.console-thinking summary", text: /Ran 1 command/ + assert_select "details.console-thinking .console-thinking-preview", + text: /I'm running the test suite/ + end + + test "split view renders owned panes as panels and drops unowned keys" do + skip_unless_session_table + + primary_key = "console:panel-a-#{SecureRandom.hex(6)}" + pane_key = "console:panel-b-#{SecureRandom.hex(6)}" + unowned_key = "slack:C0PANEL:#{SecureRandom.hex(6)}" + insert_console_session(primary_key) + insert_console_session(pane_key) + insert_slack_session(unowned_key, slack_user_id: "U_OTHER", slack_user_name: "someone-else") + + get console_threads_url(thread: [ primary_key, pane_key, unowned_key ].join(",")) + + assert_response :ok + assert_select "[data-thread-panel]", count: 2 + assert_select "[data-thread-panel=?]", primary_key + assert_select "[data-thread-panel=?]", pane_key + assert_select "[data-thread-panel=?]", unowned_key, count: 0 + # Each panel exposes a close control back to the remaining threads. + assert_select "[data-thread-panel] a[aria-label='Close panel']", count: 2 + end + + test "split view caps the grid at four panels" do + skip_unless_session_table + + keys = Array.new(5) { |i| "console:panel-cap-#{i}-#{SecureRandom.hex(4)}" } + keys.each { |key| insert_console_session(key) } + + get console_threads_url(thread: keys.join(",")) + + assert_response :ok + assert_select "[data-thread-panel]", count: Console::ThreadsController::PANEL_LIMIT + end + + test "single thread view does not render the split grid" do + skip_unless_session_table + + thread_key = "console:solo-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "[data-thread-panel]", count: 0 + # column-reverse scroll container opens the thread at its newest message. + assert_select "#thread-transcript-scroll.console-transcript-scroll" + end + + test "sidebar thread links carry the cmd-click split view hook" do + skip_unless_session_table + + thread_key = "console:sidebar-split-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + + get console_sidebar_threads_url + + assert_response :ok + # The layout's Cmd/Ctrl-click handler targets this attribute to add the + # thread to the split-view grid. + assert_select "a[data-console-thread-link][href=?]", + console_threads_path(thread: thread_key) + end + + # The sidebar list loads out of band via a lazy Turbo Frame, so the page must + # forward the current thread selection on the frame src for the active + # highlight to render. + test "threads page forwards the thread selection to the sidebar frame src" do + skip_unless_session_table + + thread_key = "console:sidebar-active-#{SecureRandom.hex(8)}" + insert_console_session(thread_key) + + get console_threads_url(thread: thread_key) + + assert_response :ok + assert_select "turbo-frame#console_sidebar_threads[src=?]", + console_sidebar_threads_path(thread: thread_key) + end + + test "sidebar highlights every open thread of a split view" do + skip_unless_session_table + + keys = Array.new(2) { |i| "console:sidebar-open-#{i}-#{SecureRandom.hex(4)}" } + keys.each { |key| insert_console_session(key) } + + get console_sidebar_threads_url(thread: keys.join(",")) + + assert_response :ok + # Open threads carry their 1-based pane number in grid order; no filled + # pill on thread rows. + assert_select "a.console-thread-link-open[data-console-pane-index='1'][href=?]", + console_threads_path(thread: keys.first) + assert_select "a.console-thread-link-open[data-console-pane-index='2'][href=?]", + console_threads_path(thread: keys.last) + assert_select "a.console-thread-link-active", count: 0 + end + + test "split view close control drops one thread and keeps the rest open" do + skip_unless_session_table + + keys = Array.new(3) { |i| "console:panel-close-#{i}-#{SecureRandom.hex(4)}" } + keys.each { |key| insert_console_session(key) } + + get console_threads_url(thread: keys.join(",")) + + assert_response :ok + # Closing the middle panel keeps the primary and the last pane. + assert_select "[data-thread-panel=?] a[aria-label='Close panel'][href=?]", + keys[1], + console_threads_path(thread: [ keys[0], keys[2] ].join(",")) + # Closing the primary panel promotes the next thread to primary. + assert_select "[data-thread-panel=?] a[aria-label='Close panel'][href=?]", + keys[0], + console_threads_path(thread: [ keys[1], keys[2] ].join(",")) + end + + private + + # Sets each env var for the block (nil deletes) and restores the previous + # values afterwards. + def with_env(overrides) + previous = overrides.keys.index_with { |name| ENV[name] } + overrides.each { |name, value| value.nil? ? ENV.delete(name) : ENV[name] = value } + yield + ensure + previous.each { |name, value| value.nil? ? ENV.delete(name) : ENV[name] = value } + end + + def with_recent_first_error + singleton = class << CentaurSession; self; end + original = CentaurSession.method(:recent_first) + singleton.define_method(:recent_first) { raise ActiveRecord::ConnectionNotEstablished } + yield + ensure + singleton.define_method(:recent_first, original) + end + + def threads_controller_for(user) + Console::ThreadsController.new.tap do |controller| + controller.define_singleton_method(:current_user) { user } + end + end + + def create_slack_oauth_credential(app, subject:, email:, labels: {}) + BrokerCredential.create!( + namespace: app.credential_namespace, + oauth_app: app, + provider_subject: subject, + provider_email: email, + labels: labels, + token_endpoint: app.provider_strategy.token_endpoint, + refresh_token: "refresh-#{subject}", + access_token: "access-#{subject}", + expires_at: 1.hour.from_now, + last_refresh: Time.current, + external_user_key: "user-#{subject}" + ) + end + + def insert_console_session(thread_key) + connection = CentaurSession.connection + metadata = { platform: "console", actor_email: @operator.email }.to_json + insert_session(thread_key, metadata) + end + + def skip_unless_session_table + skip("api-rs session tables are unavailable") unless CentaurSession.connection.data_source_exists?("sessions") + end + + def insert_slack_session(thread_key, slack_user_id:, slack_user_name:) + metadata = { + source: "slackbotv2", + platform: "slack", + thread_id: thread_key, + slack_user_id: slack_user_id, + slack_user_name: slack_user_name + }.to_json + insert_session(thread_key, metadata) + end + + def insert_session_message(thread_key, index:) + connection = CentaurSession.connection + parts = [ { type: "text", text: "message #{index}" } ].to_json + connection.execute(<<~SQL.squish) + insert into session_messages (message_id, thread_key, role, parts, metadata, created_at) + values ( + #{connection.quote("#{thread_key}-msg-#{index}")}, + #{connection.quote(thread_key)}, + 'user', + #{connection.quote(parts)}::jsonb, + '{}'::jsonb, + now() + (#{index} * interval '1 second') + ) + SQL + end + + # Mirrors how api-rs persists harness stdout: the payload column is a + # JSON-encoded *string* holding one protocol notification line. + def insert_reasoning_event(thread_key, text:) + insert_output_line_event( + thread_key, + method: "item/completed", + params: { item: { type: "reasoning", content: [ text ] } } + ) + end + + def insert_command_trace_event(thread_key, command:, output:) + insert_output_line_event( + thread_key, + method: "item/completed", + params: { + item: { + type: "commandExecution", + command: command, + status: "completed", + aggregatedOutput: output, + exitCode: 0 + } + } + ) + end + + def insert_output_line_event(thread_key, method:, params:) + connection = CentaurSession.connection + line = { method: method, params: params }.to_json + connection.select_value(<<~SQL.squish).to_i + insert into session_events (thread_key, event_type, payload, created_at) + values ( + #{connection.quote(thread_key)}, + 'session.output.line', + #{connection.quote(line.to_json)}::jsonb, + now() + ) + returning event_id + SQL + end + + # Mirrors api-rs's activity-summary worker: the payload is a JSON object + # whose source_event_id points at the output line that triggered it. + def insert_activity_summary_event(thread_key, summary:, source_event_id:) + connection = CentaurSession.connection + payload = { summary: summary, source_event_id: source_event_id }.to_json + connection.execute(<<~SQL.squish) + insert into session_events (thread_key, event_type, payload, created_at) + values ( + #{connection.quote(thread_key)}, + 'session.activity_summary', + #{connection.quote(payload)}::jsonb, + now() + ) + SQL + end + + def insert_session(thread_key, metadata) + connection = CentaurSession.connection + connection.execute(<<~SQL.squish) + insert into sessions (thread_key, harness_type, status, metadata, created_at, updated_at) + values ( + #{connection.quote(thread_key)}, + 'codex', + 'active', + #{connection.quote(metadata)}::jsonb, + now() + interval '1 day', + now() + interval '1 day' + ) + SQL + end +end diff --git a/services/console/test/controllers/console/users_controller_test.rb b/services/console/test/controllers/console/users_controller_test.rb index d4bcfc681..e09fa2bc3 100644 --- a/services/console/test/controllers/console/users_controller_test.rb +++ b/services/console/test/controllers/console/users_controller_test.rb @@ -16,8 +16,8 @@ def sign_in(user) test "an active non-admin is forbidden" do sign_in users(:member_user) get console_users_url - assert_redirected_to root_path - assert_equal "That page is restricted to admins.", flash[:alert] + assert_redirected_to console_threads_path + assert_nil flash[:alert] end test "an admin sees the index with pending users listed" do @@ -25,6 +25,16 @@ def sign_in(user) get console_users_url assert_response :ok assert_select "td", /pending@acme.example/ + assert_select ".console-nav-link", text: "Control" + assert_select ".console-nav-link", text: "Apps", count: 0 + assert_select ".console-nav-link", text: "Users", count: 0 + assert_select ".console-control-tab", text: "Apps" + assert_select ".console-control-tab-active", text: "Users" + assert_select "button[data-console-theme-toggle]", text: "Light mode" + assert_select "link[data-console-favicon][href=?]", "/icon-dark.svg" + assert_includes response.body, "/icon-light.svg" + assert_includes response.body, "prefers-color-scheme: light" + assert_includes response.body, "centaur-console-theme-source" end test "the index shows IdP chips for linked identities and a password chip otherwise" do @@ -53,6 +63,30 @@ def sign_in(user) assert target.reload.disabled? end + test "disable revokes outstanding MCP OAuth refresh tokens" do + sign_in users(:acme_admin) + target = users(:member_user) + refresh = McpOauthRefreshToken.create!( + mcp_oauth_client: McpOauthClient.create!( + name: "Amp", + redirect_uris: [ "http://127.0.0.1:49152/callback" ], + grant_types: McpOauthClient::DEFAULT_GRANT_TYPES, + response_types: McpOauthClient::DEFAULT_RESPONSE_TYPES, + scopes: McpOauthClient::DEFAULT_SCOPES + ), + user: target, + principal: principals(:acme_channel), + resource: "http://localhost:3000/mcp", + scopes: [ "mcp:tools" ] + ) + + post disable_console_user_url(target.oid) + + assert_redirected_to console_users_path + assert target.reload.disabled? + assert refresh.reload.revoked_at.present? + end + test "an admin cannot disable their own account" do admin = users(:acme_admin) sign_in admin @@ -76,7 +110,7 @@ def sign_in(user) sign_in users(:member_user) target = users(:pending_user) post approve_console_user_url(target.oid) - assert_redirected_to root_path + assert_redirected_to console_threads_path assert target.reload.pending? end end diff --git a/services/console/test/controllers/console/workflows_controller_test.rb b/services/console/test/controllers/console/workflows_controller_test.rb new file mode 100644 index 000000000..071f1b4bb --- /dev/null +++ b/services/console/test/controllers/console/workflows_controller_test.rb @@ -0,0 +1,447 @@ +require "test_helper" + +class Console::WorkflowsControllerTest < ActionDispatch::IntegrationTest + FakeWorkflowRun = Struct.new( + :workflow_name, + :workflow_name_label, + :task_name, + :display_status, + :queue_name, + :queue_label, + :attempts, + :max_attempts, + :started_or_created_at, + :created_at, + :terminal_at, + :recency_at, + :run_id, + :task_id, + :harness_type, + :queue_run_count, + keyword_init: true + ) do + def workflow_name_label + self[:workflow_name_label].presence || workflow_name.presence || task_name.presence || "unknown workflow" + end + + def workflow_key + workflow_name.presence || task_name.presence + end + + def recency_at + self[:recency_at] || terminal_at || started_or_created_at + end + end + + # Stands in for CentaurApiClient: schedules/run details for show-page + # enrichment, plus a capture of force-started runs. + class FakeApiClient + attr_reader :created_runs + + def initialize(schedules: [], run_details: {}, create_result: nil, create_error: nil) + @schedules = schedules + @run_details = run_details + @create_result = create_result || { "ok" => true, "run_id" => "run-new", "created" => true } + @create_error = create_error + @created_runs = [] + end + + def list_workflow_schedules + { "ok" => true, "schedules" => @schedules } + end + + def get_workflow_run(run_id) + detail = @run_details[run_id] + raise CentaurApiClient::Error, "run not found" unless detail + + { "ok" => true, "run" => detail } + end + + def create_workflow_run(workflow_name:, input: nil) + raise CentaurApiClient::Error, @create_error if @create_error + + @created_runs << { workflow_name: workflow_name, input: input } + @create_result + end + end + + setup do + @original_client_factory = Console::WorkflowsController.client_factory + with_api_client(FakeApiClient.new) + @operator = users(:acme_admin) + post login_url, params: { email: @operator.email, password: "password123456" } + end + + teardown do + Console::WorkflowsController.client_factory = @original_client_factory + end + + test "an admin sees one row per workflow" do + run = fake_run(workflow_name: "slack_sync", display_status: "running") + + with_workflow_index(runs: [ run ]) do + get console_workflows_url + end + + assert_response :ok + assert_select "h1", count: 0 + assert_select ".console-thread-group-title-active", text: /Workflows/ + assert_select "a[href=?]", console_workflow_path("slack_sync"), text: /slack_sync/ + assert_select "span", text: "running" + assert_select "a[href=?]", console_workflows_path + assert response.body.index('href="/console/workflows"') < response.body.index('href="/console/threads"') + end + + test "a workflow with runs in several queues lists each queue on its own line" do + run = fake_run(workflow_name: "slack_backfill", queue_name: "centaur_workflows_etl_backfill", queue_label: "etl backfill") + queue_runs = [ + fake_run(workflow_name: "slack_backfill", queue_name: "centaur_workflows_etl_backfill", queue_label: "etl backfill", queue_run_count: 7), + fake_run(workflow_name: "slack_backfill", queue_name: "centaur_workflows_slack_live", queue_label: "slack live", display_status: "running", queue_run_count: 2) + ] + + with_workflow_index(runs: [ run ], queue_breakdown: { "slack_backfill" => queue_runs }) do + get console_workflows_url + end + + assert_response :ok + assert_select "tbody tr", count: 1 + assert_match "etl backfill", response.body + assert_match "slack live", response.body + assert_match "├", response.body + assert_match "└", response.body + assert_match "7 runs", response.body + end + + test "the workflow index does not show run ids" do + run = fake_run(workflow_name: "slack_sync") + + with_workflow_index(runs: [ run ]) do + get console_workflows_url + end + + assert_response :ok + assert_no_match run.run_id, response.body + assert_no_match run.task_id, response.body + end + + test "the workflow index is paginated" do + runs = 3.times.map { |i| fake_run(workflow_name: "wf_#{i}") } + + with_workflow_index(runs: runs, workflow_count: 120) do + get console_workflows_url, params: { page: 2 } + end + + assert_response :ok + assert_match "120 workflows", response.body + assert_match "page 2 of 3", response.body + assert_select "a", text: "Previous" + assert_select "a", text: "Next" + end + + test "a non-admin is redirected away from the workflow dashboard" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + get console_workflows_url + + assert_redirected_to console_threads_path + assert_nil flash[:alert] + end + + test "a non-admin does not see the workflows tab" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + get console_threads_url + + assert_response :ok + assert_select ".console-nav-link", text: "Control", count: 0 + assert_select ".console-nav-link", text: "Data Sync", count: 0 + assert_select ".console-thread-group-title", text: /Chats/ + assert_select ".console-thread-group-title", text: /Workflows/, count: 0 + end + + test "workflow show page lists core metadata and historical runs" do + run = fake_run(workflow_name: "slack_sync", display_status: "completed", harness_type: "codex") + + with_workflow_history("slack_sync", runs: [ run ]) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "h1.page-title", text: /slack_sync/ + assert_select "dt", text: "Engine" + assert_select "dd", text: "Codex" + assert_select "h2", "Historical Runs" + assert_select "tbody tr", count: 1 + assert_select "form[action=?]", run_console_workflow_path("slack_sync") + end + + test "workflow show page renders status filter tabs with counts" do + run = fake_run(workflow_name: "slack_sync", display_status: "completed") + + with_workflow_history( + "slack_sync", + runs: [ run ], + status_counts: { "completed" => 9, "failed" => 1 } + ) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "a.chip", text: /all\s*10/ + assert_select "a.chip", text: /completed\s*9/ + assert_select "a.chip", text: /failed\s*1/ + assert_select "dd", text: /10 runs/ + end + + test "workflow show page marks the active status tab and passes the filter through" do + run = fake_run(workflow_name: "slack_sync", display_status: "failed") + seen = {} + + with_workflow_history( + "slack_sync", + runs: [ run ], + status_counts: { "completed" => 9, "failed" => 1 }, + capture: seen + ) do + get console_workflow_url("slack_sync"), params: { status: "failed" } + end + + assert_response :ok + assert_equal "failed", seen[:status] + assert_select "a.chip-on", text: /failed\s*1/ + end + + test "workflow show page renders queue tabs when several queues exist" do + run = fake_run(workflow_name: "slack_sync") + + with_workflow_history( + "slack_sync", + runs: [ run ], + queue_names: %w[centaur_workflows_etl centaur_workflows_slack_live] + ) do + get console_workflow_url("slack_sync"), params: { queue: "centaur_workflows_slack_live" } + end + + assert_response :ok + assert_select "a.chip", text: "etl" + assert_select "a.chip-on", text: "slack live" + end + + test "workflow show page paginates historical runs" do + runs = 2.times.map { |i| fake_run(workflow_name: "slack_sync", run_id: "run-#{i}") } + + with_workflow_history("slack_sync", runs: runs, run_count: 130) do + get console_workflow_url("slack_sync"), params: { page: 2 } + end + + assert_response :ok + assert_match "130 runs", response.body + assert_match "page 2 of 3", response.body + end + + test "workflow show page shows the schedule and source link when registered" do + run = fake_run(workflow_name: "slack_sync", harness_type: "codex") + with_api_client(FakeApiClient.new(schedules: [ slack_sync_schedule ])) + + with_workflow_history("slack_sync", runs: [ run ]) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "dt", text: "Schedule" + assert_select "dd", text: /cron \*\/5 \* \* \* \* · America\/Los_Angeles/ + assert_select "a[href=?]", + "https://github.com/paradigmxyz/centaur/blob/main/workflows/slack/sync.py", + text: /workflows\/slack\/sync\.py/ + end + + test "workflow show page links overlay-repo workflow sources to the overlay repo" do + run = fake_run(workflow_name: "consensus_ci_triage") + schedule = slack_sync_schedule.merge( + "workflow_name" => "consensus_ci_triage", + "source_path" => "centaur-tempo/workflows/consensus_ci_triage.py" + ) + with_api_client(FakeApiClient.new(schedules: [ schedule ])) + + with_workflow_history("consensus_ci_triage", runs: [ run ]) do + get console_workflow_url("consensus_ci_triage") + end + + assert_response :ok + assert_select "a[href=?]", + "https://github.com/tempoxyz/centaur-tempo/blob/main/workflows/consensus_ci_triage.py" + end + + test "workflow show page surfaces the latest run's input and failure for debugging" do + run = fake_run(workflow_name: "slack_sync", display_status: "failed") + with_api_client( + FakeApiClient.new( + run_details: { + run.run_id => { + "run_id" => run.run_id, + "input" => { "mode" => "full" }, + "failure" => { "error" => "boom exploded" } + } + } + ) + ) + + with_workflow_history("slack_sync", runs: [ run ]) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "h2", text: "Debugging" + assert_select "dt", text: "Input" + assert_select "dt", text: "Failure" + assert_match "boom exploded", response.body + end + + test "workflow show page renders without api enrichment when the api is down" do + run = fake_run(workflow_name: "slack_sync") + with_api_client(FakeApiClient.new(run_details: {})) + + with_workflow_history("slack_sync", runs: [ run ]) do + get console_workflow_url("slack_sync") + end + + assert_response :ok + assert_select "h2", text: "Debugging", count: 0 + assert_select "dt", text: "Schedule", count: 0 + end + + test "force starting a workflow queues a run with the schedule input" do + client = FakeApiClient.new(schedules: [ slack_sync_schedule ]) + with_api_client(client) + + post run_console_workflow_url("slack_sync") + + assert_redirected_to console_workflow_path("slack_sync") + assert_match(/Run queued \(run-new\)/, flash[:notice]) + assert_equal [ { workflow_name: "slack_sync", input: { "mode" => "incremental" } } ], client.created_runs + end + + test "force starting a workflow surfaces api errors" do + with_api_client(FakeApiClient.new(create_error: "workflow runtime is not enabled")) + + post run_console_workflow_url("slack_sync") + + assert_redirected_to console_workflow_path("slack_sync") + assert_match(/workflow runtime is not enabled/, flash[:alert]) + end + + test "a non-admin cannot force start a workflow" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + client = FakeApiClient.new + with_api_client(client) + + post run_console_workflow_url("slack_sync") + + assert_redirected_to console_threads_path + assert_empty client.created_runs + end + + test "workflow show page returns not found for unknown workflow" do + with_workflow_history("missing") do + get console_workflow_url("missing") + end + + assert_response :not_found + assert_select "body", text: /No workflow runs found for missing/ + end + + test "workflows page handles unavailable workflow database" do + with_centaur_workflow_run_methods(available?: -> { false }) do + get console_workflows_url + end + + assert_response :ok + assert_select "body", text: /Workflow database is unavailable/ + assert_select "body", text: /No workflow runs available/ + end + + private + + def with_api_client(client) + Console::WorkflowsController.client_factory = -> { client } + end + + def slack_sync_schedule + { + "schedule_id" => "slack_sync", + "workflow_name" => "slack_sync", + "source_path" => "workflows/slack/sync.py", + "kind" => { "type" => "cron", "cron" => "*/5 * * * *" }, + "timezone" => "America/Los_Angeles", + "input" => { "mode" => "incremental" }, + "enabled" => true, + "no_delivery" => false + } + end + + def fake_run(attrs = {}) + now = Time.zone.parse("2026-07-06 12:00:00 UTC") + FakeWorkflowRun.new({ + workflow_name: "echo", + workflow_name_label: nil, + task_name: "centaur_workflow", + display_status: "completed", + queue_name: "centaur_workflows", + queue_label: "default", + attempts: 1, + max_attempts: 3, + started_or_created_at: now, + created_at: now, + terminal_at: now + 2.minutes, + recency_at: nil, + run_id: "00000000-0000-0000-0000-000000000001", + task_id: "00000000-0000-0000-0000-000000000002", + harness_type: nil, + queue_run_count: 1 + }.merge(attrs)) + end + + def with_workflow_index(runs:, queue_breakdown: {}, workflow_count: nil) + with_centaur_workflow_run_methods( + available?: -> { true }, + workflow_count: -> { workflow_count || runs.size }, + latest_per_workflow: ->(limit:, offset: 0) { runs }, + latest_per_queue: ->(keys) { queue_breakdown } + ) do + yield + end + end + + def with_workflow_history(workflow_name, runs: [], status_counts: nil, queue_names: [], run_count: nil, capture: nil) + status_counts ||= runs.group_by(&:display_status).transform_values(&:size) + with_centaur_workflow_run_methods( + available?: -> { true }, + for_workflow: ->(name, limit:, offset: 0, status: nil, queue: nil) { + capture&.merge!(status: status, queue: queue, offset: offset) + name == workflow_name && limit.positive? ? runs : [] + }, + status_counts: ->(name) { name == workflow_name ? status_counts : {} }, + queue_names: ->(name) { name == workflow_name ? queue_names : [] }, + run_count: ->(name, status: nil, queue: nil) { run_count || runs.size } + ) do + yield + end + end + + def with_centaur_workflow_run_methods(overrides) + originals = overrides.keys.to_h { |name| [ name, CentaurWorkflowRun.method(name) ] } + + overrides.each do |name, implementation| + CentaurWorkflowRun.define_singleton_method(name, &implementation) + end + + yield + ensure + originals&.each do |name, original| + CentaurWorkflowRun.define_singleton_method(name, original) + end + end +end diff --git a/services/console/test/controllers/console_controller_test.rb b/services/console/test/controllers/console_controller_test.rb index 97640f2db..dbc20434a 100644 --- a/services/console/test/controllers/console_controller_test.rb +++ b/services/console/test/controllers/console_controller_test.rb @@ -12,6 +12,28 @@ class ConsoleControllerTest < ActionDispatch::IntegrationTest assert_redirected_to login_path end + test "an active non-admin is redirected away from every Control page" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + [ root_url, console_principals_url, console_roles_url, console_secrets_url, + console_credentials_url, console_oauth_apps_url ].each do |url| + get url + assert_redirected_to console_threads_path + assert_nil flash[:alert] + end + end + + test "a non-admin cannot mutate through the Control form controllers" do + delete logout_url + post login_url, params: { email: users(:member_user).email, password: "password123456" } + + assert_no_difference -> { Role.count } do + post console_roles_url, params: { role: { foreign_id: "sneaky", namespace: "default" } } + end + assert_redirected_to console_threads_path + end + test "secrets table shows backend labels (not refs) and links to detail" do secret = static_secrets(:acme_prod_api_key) get console_secrets_url @@ -121,6 +143,40 @@ class ConsoleControllerTest < ActionDispatch::IntegrationTest assert_select "div", text: /#{Regexp.escape(principal.oid)}.*#{Regexp.escape(principal.namespace)}/ end + test "principals table links to add principal" do + get console_principals_url + assert_response :ok + assert_select "a[href=?]", console_new_principal_path, text: "Add Principal" + end + + test "principal detail page offers delete" do + principal = principals(:acme_channel) + get console_principal_url(principal.oid) + assert_response :ok + assert_select "form[action=?][method=?]", console_delete_principal_path(principal.oid), "post" do + assert_select "input[name=_method][value=delete]" + assert_select "button[type=submit]", "Delete" + end + end + + test "principal detail page renders DM permissions as API-managed rows" do + principal = principals(:acme_user_bob) + SlackChannelPermission.create!( + principal: principal, + channel_id: "D0123456789", + channel_name: "U0123456789", + upload_enabled: true, + download_enabled: false, + history_enabled: true + ) + + get console_principal_url(principal.oid) + assert_response :ok + + assert_select "td", text: /DM U0123456789/ + assert_select "td", text: "API-managed" + end + test "credentials table combines id, shows status, and links to detail" do credential = broker_credentials(:acme_managed_gmail) get console_credentials_url diff --git a/services/console/test/controllers/mcp/oauth_controller_test.rb b/services/console/test/controllers/mcp/oauth_controller_test.rb new file mode 100644 index 000000000..16e773d5b --- /dev/null +++ b/services/console/test/controllers/mcp/oauth_controller_test.rb @@ -0,0 +1,339 @@ +require "test_helper" +require "base64" +require "digest" +require "uri" + +module Mcp + class OauthControllerTest < ActionDispatch::IntegrationTest + setup do + @operator = users(:acme_admin) + @saved_env = { + "CENTAUR_JWT_SIGNING_SECRET" => ENV["CENTAUR_JWT_SIGNING_SECRET"], + "CENTAUR_MCP_PUBLIC_URL" => ENV["CENTAUR_MCP_PUBLIC_URL"], + "CENTAUR_CONSOLE_PUBLIC_URL" => ENV["CENTAUR_CONSOLE_PUBLIC_URL"] + } + ENV["CENTAUR_JWT_SIGNING_SECRET"] = "test-secret" + ENV["CENTAUR_MCP_PUBLIC_URL"] = "http://localhost:3000/mcp" + ENV["CENTAUR_CONSOLE_PUBLIC_URL"] = "http://www.example.com" + end + + teardown do + @saved_env.each do |key, value| + if value.nil? + ENV.delete(key) + else + ENV[key] = value + end + end + end + + test "metadata advertises MCP OAuth endpoints" do + get "/.well-known/oauth-authorization-server" + + assert_response :ok + body = JSON.parse(response.body) + assert_equal "http://www.example.com", body.fetch("issuer") + assert_equal "http://www.example.com/mcp/oauth/authorize", body.fetch("authorization_endpoint") + assert_equal "http://www.example.com/mcp/oauth/token", body.fetch("token_endpoint") + assert_equal "http://www.example.com/mcp/oauth/register", body.fetch("registration_endpoint") + assert_includes body.fetch("code_challenge_methods_supported"), "S256" + end + + test "dynamic client registration creates a public PKCE client" do + assert_difference -> { McpOauthClient.count }, 1 do + post "/mcp/oauth/register", + params: { + client_name: "Amp", + redirect_uris: [ "http://127.0.0.1:49152/callback" ], + scope: "mcp:tools" + }, + as: :json + end + + assert_response :created + body = JSON.parse(response.body) + assert_match(/\Amoc_/, body.fetch("client_id")) + assert_equal "none", body.fetch("token_endpoint_auth_method") + assert_equal "mcp:tools", body.fetch("scope") + end + + test "dynamic client registration rejects non-loopback redirect URIs" do + assert_no_difference -> { McpOauthClient.count } do + post "/mcp/oauth/register", + params: { + client_name: "Attacker", + redirect_uris: [ "https://evil.example/callback" ], + scope: "mcp:tools" + }, + as: :json + end + + assert_response :bad_request + assert_equal "invalid_client_metadata", JSON.parse(response.body).fetch("error") + end + + test "authorize rejects non-loopback redirect URIs even when already stored" do + client = create_client + client.update_column(:redirect_uris, [ "https://evil.example/callback" ]) + post login_url, params: { email: @operator.email, password: "password123456" } + + assert_no_difference -> { McpOauthAuthorizationCode.count } do + get "/mcp/oauth/authorize", + params: authorize_params(client).merge(redirect_uri: "https://evil.example/callback") + end + + assert_response :bad_request + assert_includes response.body, "redirect_uri is not registered" + end + + test "authorize redirects signed-out users through login and preserves the request" do + client = create_client + get "/mcp/oauth/authorize", params: authorize_params(client) + + assert_redirected_to login_path + + post login_url, params: { email: @operator.email, password: "password123456" } + assert_match %r{\Ahttp://www\.example\.com/mcp/oauth/authorize\?}, response.location + end + + test "authorize accepts dynamic loopback redirect ports" do + client = create_client(redirect_uris: [ "http://localhost/callback" ]) + approval_params = authorize_params(client).merge( + redirect_uri: "http://localhost:49153/callback" + ) + post login_url, params: { email: @operator.email, password: "password123456" } + + assert_no_difference -> { McpOauthAuthorizationCode.count } do + get "/mcp/oauth/authorize", params: approval_params + end + + assert_response :ok + assert_select "form[action=?]", "/mcp/oauth/authorize" + + post "/mcp/oauth/authorize", params: approval_params.merge(decision: "approve") + assert_response :redirect + redirect = URI.parse(response.location) + assert_equal "localhost", redirect.host + assert_equal 49153, redirect.port + assert Rack::Utils.parse_nested_query(redirect.query).key?("code") + end + + test "authorization approval denial redirects without issuing a code" do + client = create_client + post login_url, params: { email: @operator.email, password: "password123456" } + + assert_no_difference -> { McpOauthAuthorizationCode.count } do + post "/mcp/oauth/authorize", params: authorize_params(client).merge(decision: "deny") + end + + assert_response :redirect + redirect = URI.parse(response.location) + query = Rack::Utils.parse_nested_query(redirect.query) + assert_equal "access_denied", query.fetch("error") + assert_equal "state-test", query.fetch("state") + end + + test "authorization code exchange returns a JWT access token for the console principal" do + client = create_client + code = authorize_code(client) + stored_code = McpOauthAuthorizationCode.find_usable(code) + assert_equal @operator, stored_code.user + assert_equal "http://localhost:3000/mcp", stored_code.resource + assert_match(/\Aprn_/, stored_code.principal.oid) + + exchange_authorization_code(client, code) + + assert_response :ok + body = JSON.parse(response.body) + assert_equal "Bearer", body.fetch("token_type") + assert_equal "mcp:tools", body.fetch("scope") + assert_match(/\Amcprt_/, body.fetch("refresh_token")) + + jwt_payload = decode_jwt_payload(body.fetch("access_token")) + assert_equal "http://www.example.com", jwt_payload.fetch("iss") + assert_equal "http://localhost:3000/mcp", jwt_payload.fetch("aud") + assert_equal stored_code.principal.oid, jwt_payload.fetch("principal_id") + assert_equal @operator.email, jwt_payload.fetch("email") + assert_equal "mcp:tools", jwt_payload.fetch("scope") + end + + test "authorization approval seeds new console principals with the user-mcp role" do + client = create_client + + code = authorize_code(client) + + principal = McpOauthAuthorizationCode.find_usable(code).principal + role = Role.find_by(namespace: principal.namespace, foreign_id: "user-mcp") + assert role, "expected the user-mcp role to be created" + assert_equal "User MCP", role.name + assert_equal "centaur", role.labels["managed-by"] + assert_includes principal.roles, role + end + + test "authorization approval labels a principal from one Slack SSO identity" do + @operator.user_identities.create!( + provider: "slack", subject: "U123", team_id: "T123", email: @operator.email, email_verified: true + ) + client = create_client + + code = authorize_code(client) + + principal = McpOauthAuthorizationCode.find_usable(code).principal + assert_equal "U123", principal.labels["slack_user_id"] + assert_equal "T123", principal.labels["slack_team_id"] + end + + test "authorization approval leaves Slack labels unset for ambiguous Slack SSO identities" do + @operator.user_identities.create!( + provider: "slack", subject: "U123", team_id: "T123", email: @operator.email, email_verified: true + ) + @operator.user_identities.create!( + provider: "slack", subject: "U456", team_id: "T456", email: @operator.email, email_verified: true + ) + client = create_client + + code = authorize_code(client) + + principal = McpOauthAuthorizationCode.find_usable(code).principal + assert_nil principal.labels["slack_user_id"] + assert_nil principal.labels["slack_team_id"] + end + + test "authorization approval reuses an existing user-mcp role" do + existing = Role.create!( + namespace: "default", + foreign_id: "user-mcp", + name: "Custom user role", + created_by: @operator + ) + client = create_client + + assert_no_difference -> { Role.count } do + code = authorize_code(client) + principal = McpOauthAuthorizationCode.find_usable(code).principal + assert_includes principal.roles, existing + end + end + + test "authorization approval does not restore a removed user-mcp role on existing principals" do + client = create_client + code = authorize_code(client) + principal = McpOauthAuthorizationCode.find_usable(code).principal + principal.principal_roles.destroy_all + + post "/mcp/oauth/authorize", params: authorize_params(client).merge(decision: "approve") + + assert_response :redirect + assert_empty principal.reload.roles + end + + test "authorization code exchange rejects users disabled after consent" do + client = create_client + code = authorize_code(client) + stored_code = McpOauthAuthorizationCode.find_usable(code) + @operator.update!(status: :disabled) + + assert_no_difference -> { McpOauthRefreshToken.count } do + exchange_authorization_code(client, code) + end + + assert_response :bad_request + assert_equal "invalid_grant", JSON.parse(response.body).fetch("error") + assert stored_code.reload.consumed_at.present? + end + + test "refresh token exchange rejects inactive users and revokes their tokens" do + client = create_client + code = authorize_code(client) + exchange_authorization_code(client, code) + refresh_token = JSON.parse(response.body).fetch("refresh_token") + issued = McpOauthRefreshToken.find_usable(refresh_token) + extra = McpOauthRefreshToken.create!( + mcp_oauth_client: client, + user: @operator, + principal: issued.principal, + resource: issued.resource, + scopes: issued.scopes + ) + @operator.update_column(:status, "disabled") + + post "/mcp/oauth/token", + params: { + grant_type: "refresh_token", + client_id: client.public_client_id, + refresh_token: refresh_token + } + + assert_response :bad_request + assert_equal "invalid_grant", JSON.parse(response.body).fetch("error") + assert issued.reload.revoked_at.present? + assert extra.reload.revoked_at.present? + assert_equal 0, @operator.mcp_oauth_refresh_tokens.usable.count + end + + private + + def create_client(redirect_uris: [ redirect_uri ]) + McpOauthClient.create!( + name: "Amp", + redirect_uris: redirect_uris, + grant_types: McpOauthClient::DEFAULT_GRANT_TYPES, + response_types: McpOauthClient::DEFAULT_RESPONSE_TYPES, + scopes: McpOauthClient::DEFAULT_SCOPES + ) + end + + def authorize_params(client) + { + response_type: "code", + client_id: client.public_client_id, + redirect_uri: redirect_uri, + scope: "mcp:tools", + state: "state-test", + resource: "http://localhost:3000/mcp", + code_challenge: code_challenge, + code_challenge_method: "S256" + } + end + + def authorize_code(client) + approval_params = authorize_params(client) + post login_url, params: { email: @operator.email, password: "password123456" } + + assert_no_difference -> { McpOauthAuthorizationCode.count } do + get "/mcp/oauth/authorize", params: approval_params + end + assert_response :ok + assert_select "form[action=?]", "/mcp/oauth/authorize" + + post "/mcp/oauth/authorize", params: approval_params.merge(decision: "approve") + assert_response :redirect + redirect = URI.parse(response.location) + Rack::Utils.parse_nested_query(redirect.query).fetch("code") + end + + def exchange_authorization_code(client, code) + post "/mcp/oauth/token", + params: { + grant_type: "authorization_code", + client_id: client.public_client_id, + code: code, + redirect_uri: redirect_uri, + code_verifier: code_verifier + } + end + + def redirect_uri = "http://127.0.0.1:49152/callback" + + def code_verifier = "test-code-verifier" + + def code_challenge + Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + end + + def decode_jwt_payload(token) + _header, payload, _signature = token.split(".") + JSON.parse(Base64.urlsafe_decode64(payload)) + end + end +end From e0056df1fd4db11d80f7f7604ab2ccee095082f0 Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:58:31 -0400 Subject: [PATCH 156/198] feat: synchronize Centaur with audited upstream baseline (10/16) Audited synchronization to paradigmxyz/centaur@3c6e84d97bcac83947d38bd0fc4a6b07b8a83ce6. The split commits are a GitHub signing transport; their aggregate tree is the reviewed integration. --- .../oauth/flows_controller_test.rb | 172 +++++- .../session_oauth_controller_test.rb | 10 +- .../controllers/sessions_controller_test.rb | 7 + services/console/test/fixtures/oauth_apps.yml | 24 + services/console/test/fixtures/principals.yml | 5 + .../fixtures/slack_channel_permissions.yml | 2 + .../console/test/fixtures/system_settings.yml | 5 + .../test/helpers/application_helper_test.rb | 119 ++++ .../helpers/console/principals_helper_test.rb | 25 + services/console/test/integration/pwa_test.rb | 60 ++ ...rich_attio_credential_identity_job_test.rb | 88 +++ ...ich_github_credential_identity_job_test.rb | 49 +- ...ich_linear_credential_identity_job_test.rb | 84 +++ .../github_app_installation_client_test.rb | 20 + .../test/lib/centaur_jwt/hs256_test.rb | 19 + .../test/lib/login_slack_provider_test.rb | 34 ++ .../test/lib/oauth/providers/attio_test.rb | 53 ++ .../test/lib/oauth/providers/granola_test.rb | 87 +++ .../test/lib/oauth/providers/linear_test.rb | 53 ++ .../test/lib/oauth/providers/slack_test.rb | 6 +- .../test/models/broker_credential_test.rb | 24 + .../models/centaur_session_record_test.rb | 123 ++++ .../test/models/centaur_workflow_run_test.rb | 172 ++++++ .../test/models/mcp_oauth_client_test.rb | 28 + .../principal_sync_config_snapshot_test.rb | 184 ++++++ .../console/test/models/principal_test.rb | 313 +++++++++- services/console/test/models/proxy_test.rb | 3 +- .../models/slack_channel_permission_test.rb | 115 ++++ .../test/models/system_setting_test.rb | 32 + services/console/test/models/user_test.rb | 39 +- .../test/services/centaur_api_client_test.rb | 105 ++++ .../google_docs/sync_credential_test.rb | 6 + ...rincipal_credential_reconciliation_test.rb | 130 +++- .../services/slack_channel_catalog_test.rb | 104 ++++ services/discordbot/AGENTS.md | 44 ++ services/discordbot/src/discord-narrator.ts | 18 + services/discordbot/src/index.ts | 20 +- services/discordbot/src/session-api.ts | 11 +- .../discordbot/test/chat-sdk-emulate.test.ts | 123 +++- services/githubbot/AGENTS.md | 44 ++ services/githubbot/Dockerfile | 28 + services/githubbot/README.md | 169 ++++++ services/githubbot/package.json | 31 + services/githubbot/src/authorization.ts | 62 ++ services/githubbot/src/body-mention.ts | 211 +++++++ services/githubbot/src/comment-bot.ts | 208 +++++++ services/githubbot/src/context.ts | 101 ++++ services/githubbot/src/index.ts | 570 ++++++++++++++++++ services/githubbot/src/issue-manager.ts | 303 ++++++++++ services/githubbot/src/issue-prompt.ts | 28 + 50 files changed, 4175 insertions(+), 96 deletions(-) create mode 100644 services/console/test/fixtures/slack_channel_permissions.yml create mode 100644 services/console/test/fixtures/system_settings.yml create mode 100644 services/console/test/helpers/console/principals_helper_test.rb create mode 100644 services/console/test/integration/pwa_test.rb create mode 100644 services/console/test/jobs/oauth/enrich_attio_credential_identity_job_test.rb create mode 100644 services/console/test/jobs/oauth/enrich_linear_credential_identity_job_test.rb create mode 100644 services/console/test/lib/centaur_jwt/hs256_test.rb create mode 100644 services/console/test/lib/login_slack_provider_test.rb create mode 100644 services/console/test/lib/oauth/providers/attio_test.rb create mode 100644 services/console/test/lib/oauth/providers/granola_test.rb create mode 100644 services/console/test/lib/oauth/providers/linear_test.rb create mode 100644 services/console/test/models/centaur_session_record_test.rb create mode 100644 services/console/test/models/centaur_workflow_run_test.rb create mode 100644 services/console/test/models/mcp_oauth_client_test.rb create mode 100644 services/console/test/models/principal_sync_config_snapshot_test.rb create mode 100644 services/console/test/models/slack_channel_permission_test.rb create mode 100644 services/console/test/models/system_setting_test.rb create mode 100644 services/console/test/services/slack_channel_catalog_test.rb create mode 100644 services/discordbot/AGENTS.md create mode 100644 services/githubbot/AGENTS.md create mode 100644 services/githubbot/Dockerfile create mode 100644 services/githubbot/README.md create mode 100644 services/githubbot/package.json create mode 100644 services/githubbot/src/authorization.ts create mode 100644 services/githubbot/src/body-mention.ts create mode 100644 services/githubbot/src/comment-bot.ts create mode 100644 services/githubbot/src/context.ts create mode 100644 services/githubbot/src/index.ts create mode 100644 services/githubbot/src/issue-manager.ts create mode 100644 services/githubbot/src/issue-prompt.ts diff --git a/services/console/test/controllers/oauth/flows_controller_test.rb b/services/console/test/controllers/oauth/flows_controller_test.rb index 3fd4e2f1a..ec30bd5ab 100644 --- a/services/console/test/controllers/oauth/flows_controller_test.rb +++ b/services/console/test/controllers/oauth/flows_controller_test.rb @@ -12,12 +12,16 @@ class FlowsControllerTest < ActionDispatch::IntegrationTest CLIENT_ID = "acme-google-client-id".freeze SLACK_CLIENT_ID = "acme-slack-client-id".freeze GITHUB_CLIENT_ID = "acme-github-client-id".freeze + ATTIO_CLIENT_ID = "acme-attio-client-id".freeze + LINEAR_CLIENT_ID = "acme-linear-client-id".freeze setup do @app = oauth_apps(:acme_google) # slug "google" @app.update!(client_secret: "app-secret") oauth_apps(:acme_slack).update!(client_secret: "slack-secret") oauth_apps(:acme_github).update!(client_secret: "github-secret") + oauth_apps(:acme_attio).update!(client_secret: "attio-secret") + oauth_apps(:acme_linear).update!(client_secret: "linear-secret") clear_enqueued_jobs end @@ -58,6 +62,7 @@ def slack_token_body(sub: "U0R7MFMJM", scope: "chat:write", id_token_value: nil, ok: true, access_token: "xoxe.xoxb-1-bot", refresh_token: "xoxe-1-bot-refresh", expires_in: 43_200, token_type: "bot", scope: "commands", id_token: id_token_value, + team: { id: "TACME", name: "Acme" }, authed_user: { id: sub, user: "grace", @@ -78,6 +83,23 @@ def github_token_body(scope: "repo,read:user", **overrides) }.merge(overrides).to_json end + def attio_token_body(**overrides) + { + access_token: "attio-user-token", + token_type: "Bearer" + }.merge(overrides).to_json + end + + def linear_token_body(scope: "read write", **overrides) + { + access_token: "lin-user-token", + refresh_token: "lin-refresh-token", + token_type: "Bearer", + expires_in: 86_399, + scope: scope + }.merge(overrides).to_json + end + def sign_in(user) post login_url, params: { email: user.email, password: "password123456" } end @@ -93,6 +115,23 @@ def start_flow(slug: "google", **params) # --- start ---------------------------------------------------------------- + test "start redirects to Attio with dashboard-configured scopes" do + get oauth_start_url(slug: "attio") + assert_response :redirect + uri = URI.parse(response.location) + assert_equal "app.attio.com", uri.host + assert_equal "/authorize", uri.path + q = URI.decode_www_form(uri.query).to_h + assert_equal ATTIO_CLIENT_ID, q["client_id"] + assert_equal "http://www.example.com/oauth/attio/callback", q["redirect_uri"] + assert_equal "code", q["response_type"] + assert_equal "S256", q["code_challenge_method"] + assert q["code_challenge"].present? + # The Attio developer dashboard owns the effective scopes; the generic + # flow still sends the sample app allowlist as a harmless scope param. + assert_equal "record_permission:read object_configuration:read", q["scope"] + end + test "start redirects to Google with the right params and sets the flow cookie" do get oauth_start_url(slug: "google") assert_response :redirect @@ -153,6 +192,24 @@ def start_flow(slug: "google", **params) assert_includes scopes, "read:user" end + test "start redirects to Linear with comma separated scopes" do + get oauth_start_url(slug: "linear") + assert_response :redirect + uri = URI.parse(response.location) + assert_equal "linear.app", uri.host + assert_equal "/oauth/authorize", uri.path + q = URI.decode_www_form(uri.query).to_h + assert_equal LINEAR_CLIENT_ID, q["client_id"] + assert_equal "http://www.example.com/oauth/linear/callback", q["redirect_uri"] + assert_equal "code", q["response_type"] + assert_equal "S256", q["code_challenge_method"] + assert_nil q["user_scope"] + assert_nil q["prompt"] + scopes = q["scope"].split(",") + assert_includes scopes, "read" + assert_includes scopes, "write" + end + test "start works without any session" do get oauth_start_url(slug: "google") assert_response :redirect @@ -195,16 +252,15 @@ def start_flow(slug: "google", **params) # --- callback ------------------------------------------------------------- - test "callback happy path mints a live credential and renders a success page" do + test "callback happy path mints a live credential and redirects to the Integrations page" do state = start_flow stub_exchange(status: 200, body: token_body) assert_difference -> { BrokerCredential.count } => 1 do get oauth_callback_url(slug: "google"), params: { state: state, code: "auth-code" } end - assert_response :ok - assert_match "Connected", response.body - assert_match "user@example.com", response.body + assert_redirected_to console_integrations_path + assert_equal "google connected as user@example.com.", flash[:notice] cred = BrokerCredential.find_by(oauth_app: @app, provider_subject: "google-sub-1") assert_equal "acme", cred.namespace @@ -218,7 +274,6 @@ def start_flow(slug: "google", **params) assert_equal "RT", cred.refresh_token assert cred.next_attempt_at.present? assert_nil cred.created_by - assert_includes response.body, cred.oid end test "callback happy path supports Slack user tokens" do @@ -228,8 +283,8 @@ def start_flow(slug: "google", **params) assert_difference -> { BrokerCredential.count } => 1 do get oauth_callback_url(slug: "slack"), params: { state: state, code: "auth-code" } end - assert_response :ok - assert_match "Connected", response.body + assert_redirected_to console_integrations_path + assert_match(/\Aslack connected/, flash[:notice]) app = oauth_apps(:acme_slack) cred = BrokerCredential.find_by(oauth_app: app, provider_subject: "U0R7MFMJM") @@ -241,10 +296,40 @@ def start_flow(slug: "google", **params) assert_equal %w[chat:write], cred.scopes assert_equal "xoxe.xoxp-1-user", cred.access_token assert_equal "xoxe-1-refresh", cred.refresh_token + assert_equal "TACME", cred.labels["slack_team_id"] assert_equal [ "slack.com" ], cred.static_secret.rules.map(&:host) assert_equal "Slack – grace token", cred.static_secret.name end + test "callback happy path supports Attio workspace tokens" do + state = start_flow(slug: "attio", scopes: "record_permission:read") + stub_exchange(status: 200, body: attio_token_body) + + assert_enqueued_with(job: Oauth::EnrichAttioCredentialIdentityJob) do + assert_difference -> { BrokerCredential.count } => 1 do + get oauth_callback_url(slug: "attio"), params: { state: state, code: "auth-code" } + end + end + assert_redirected_to console_integrations_path + assert_match(/\Aattio connected/, flash[:notice]) + + app = oauth_apps(:acme_attio) + cred = BrokerCredential.find_by(oauth_app: app) + assert_equal "acme", cred.namespace + assert_match(/\Aattio-attio-pending-[a-f0-9]{32}\z/, cred.foreign_id) + assert_match(/\Apending-[a-f0-9]{32}\z/, cred.provider_subject) + assert_equal "Attio – Pending Attio workspace", cred.name + assert_equal "https://app.attio.com/oauth/token", cred.token_endpoint + assert_nil cred.provider_email + assert_equal %w[record_permission:read], cred.scopes + assert_equal "attio-user-token", cred.access_token + assert_nil cred.refresh_token + assert_nil cred.next_attempt_at + assert_equal [ "api.attio.com" ], cred.static_secret.rules.map(&:host) + assert_equal "Attio – Pending Attio workspace token", cred.static_secret.name + refute_includes BrokerCredential.refreshable, cred + end + test "callback happy path supports GitHub OAuth app tokens" do state = start_flow(slug: "github", scopes: "repo read:user") stub_exchange(status: 200, body: github_token_body) @@ -254,8 +339,8 @@ def start_flow(slug: "google", **params) get oauth_callback_url(slug: "github"), params: { state: state, code: "auth-code" } end end - assert_response :ok - assert_match "Connected", response.body + assert_redirected_to console_integrations_path + assert_match(/\Agithub connected/, flash[:notice]) app = oauth_apps(:acme_github) cred = BrokerCredential.find_by(oauth_app: app) @@ -274,6 +359,34 @@ def start_flow(slug: "google", **params) refute_includes BrokerCredential.refreshable, cred end + test "callback happy path supports Linear OAuth app tokens" do + state = start_flow(slug: "linear", scopes: "read write") + stub_exchange(status: 200, body: linear_token_body) + + assert_enqueued_with(job: Oauth::EnrichLinearCredentialIdentityJob) do + assert_difference -> { BrokerCredential.count } => 1 do + get oauth_callback_url(slug: "linear"), params: { state: state, code: "auth-code" } + end + end + assert_redirected_to console_integrations_path + assert_match(/\Alinear connected/, flash[:notice]) + + app = oauth_apps(:acme_linear) + cred = BrokerCredential.find_by(oauth_app: app) + assert_equal "acme", cred.namespace + assert_match(/\Alinear-linear-pending-[a-f0-9]{32}\z/, cred.foreign_id) + assert_match(/\Apending-[a-f0-9]{32}\z/, cred.provider_subject) + assert_equal "Linear – Pending Linear account", cred.name + assert_equal "https://api.linear.app/oauth/token", cred.token_endpoint + assert_nil cred.provider_email + assert_equal %w[read write], cred.scopes + assert_equal "lin-user-token", cred.access_token + assert_equal "lin-refresh-token", cred.refresh_token + assert cred.next_attempt_at.present? + assert_equal [ "api.linear.app" ], cred.static_secret.rules.map(&:host) + assert_equal "Linear – Pending Linear account token", cred.static_secret.name + end + test "callback wraps the minted credential in a grantable static secret" do state = start_flow stub_exchange(status: 200, body: token_body) @@ -313,6 +426,43 @@ def start_flow(slug: "google", **params) assert_equal "operator-renamed", secret.reload.name end + test "callback records the signed-in user on the credential and keeps the original owner on re-consent" do + user = users(:member_user) + sign_in user + state = start_flow + stub_exchange(status: 200, body: token_body) + get oauth_callback_url(slug: "google"), params: { state: state, code: "auth-code" } + + cred = BrokerCredential.find_by(oauth_app: @app, provider_subject: "google-sub-1") + assert_equal user, cred.created_by + + # Someone else re-consenting for the same provider account does not steal + # the credential. + sign_in users(:acme_admin) + state = start_flow + stub_exchange(status: 200, body: token_body) + get oauth_callback_url(slug: "google"), params: { state: state, code: "auth-code" } + assert_equal user, cred.reload.created_by + end + + test "a Slack consent with no email in the token response still shows connected on Integrations" do + user = users(:member_user) + sign_in user + state = start_flow(slug: "slack", scopes: "chat:write") + stub_exchange(status: 200, body: slack_token_body) + get oauth_callback_url(slug: "slack"), params: { state: state, code: "auth-code" } + assert_redirected_to console_integrations_path + + # Slack's token response carries no email (enrichment fills it in later), + # so the connected state must come from the created_by link. + cred = BrokerCredential.find_by(oauth_app: oauth_apps(:acme_slack), provider_subject: "U0R7MFMJM") + assert_nil cred.provider_email + assert_equal user, cred.created_by + + get console_integrations_url + assert_select "a.btn-secondary[href=?]", "http://www.example.com/oauth/slack/start", text: "Reconnect" + end + test "callback works with a disabled console session" do user = users(:member_user) sign_in user @@ -324,8 +474,8 @@ def start_flow(slug: "google", **params) get oauth_callback_url(slug: "google"), params: { state: state, code: "auth-code" } end - assert_response :ok - assert_match "Connected", response.body + assert_redirected_to console_integrations_path + assert_match(/connected/, flash[:notice]) assert_equal user.id, session[:user_id] end diff --git a/services/console/test/controllers/session_oauth_controller_test.rb b/services/console/test/controllers/session_oauth_controller_test.rb index 5381d6ce6..7ba73379c 100644 --- a/services/console/test/controllers/session_oauth_controller_test.rb +++ b/services/console/test/controllers/session_oauth_controller_test.rb @@ -101,13 +101,13 @@ def run_callback(sub:, email:, provider: "google", **token_overrides) # --- callback: provisioning ------------------------------------------------ - test "callback provisions a pending user for a non-bootstrap email and signs them in" do + test "callback provisions an active user for a non-bootstrap email and lands on the console" do assert_difference -> { User.count }, 1 do run_callback(sub: "new-sub", email: "newcomer@example.com") end - assert_redirected_to pending_path + assert_redirected_to console_threads_path user = User.find_by(email: "newcomer@example.com") - assert user.pending? + assert user.active? assert_not user.admin? assert_equal "Test User", user.name assert_equal user.id, session[:user_id] @@ -144,12 +144,12 @@ def run_callback(sub:, email:, provider: "google", **token_overrides) assert_nil session[:user_id] end - test "callback creates a pending user for an unverified, unrecognized email" do + test "callback creates an active user for an unverified, unrecognized email" do assert_difference -> { User.count }, 1 do run_callback(sub: "unv-sub", email: "stranger@example.com", email_verified: false) end user = User.find_by(email: "stranger@example.com") - assert user.pending? + assert user.active? assert_not user.user_identities.first.email_verified end diff --git a/services/console/test/controllers/sessions_controller_test.rb b/services/console/test/controllers/sessions_controller_test.rb index d49e1faf7..f2edd6699 100644 --- a/services/console/test/controllers/sessions_controller_test.rb +++ b/services/console/test/controllers/sessions_controller_test.rb @@ -15,6 +15,13 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_equal @operator.id, session[:user_id] end + test "a non-admin lands on the threads view after login" do + member = users(:member_user) + post login_url, params: { email: member.email, password: "password123456" } + assert_redirected_to console_threads_path + assert_equal member.id, session[:user_id] + end + test "email match is case-insensitive" do post login_url, params: { email: @operator.email.upcase, password: "password123456" } assert_equal @operator.id, session[:user_id] diff --git a/services/console/test/fixtures/oauth_apps.yml b/services/console/test/fixtures/oauth_apps.yml index f7dd50cbf..079bdd591 100644 --- a/services/console/test/fixtures/oauth_apps.yml +++ b/services/console/test/fixtures/oauth_apps.yml @@ -1,6 +1,18 @@ # client_secret is encrypted and is set in test setup via the model, not here -- # encrypt_fixtures is off (same as broker_credentials.yml). client_id is not # encrypted, so it lives here. +acme_attio: + slug: attio + description: Acme Attio integration + provider: attio + client_id: acme-attio-client-id + allowed_scopes: + - record_permission:read + - object_configuration:read + credential_namespace: acme + enabled: true + created_by: acme_admin + acme_google: slug: google description: Acme Google integration @@ -49,3 +61,15 @@ acme_github: credential_namespace: acme enabled: true created_by: acme_admin + +acme_linear: + slug: linear + description: Acme Linear integration + provider: linear + client_id: acme-linear-client-id + allowed_scopes: + - read + - write + credential_namespace: acme + enabled: true + created_by: acme_admin diff --git a/services/console/test/fixtures/principals.yml b/services/console/test/fixtures/principals.yml index 91e23366f..7341b4fdc 100644 --- a/services/console/test/fixtures/principals.yml +++ b/services/console/test/fixtures/principals.yml @@ -4,6 +4,7 @@ acme_channel: labels: kind: slack_channel team: platform + centaur.sandbox_repo_cache: all created_by: acme_admin globex_user: @@ -11,6 +12,7 @@ globex_user: foreign_id: U987654321 labels: kind: user + centaur.sandbox_repo_cache: all created_by: globex_admin acme_user_alice: @@ -19,6 +21,7 @@ acme_user_alice: labels: kind: user team: platform + centaur.sandbox_repo_cache: all created_by: acme_admin acme_user_bob: @@ -27,6 +30,7 @@ acme_user_bob: labels: kind: user team: ops + centaur.sandbox_repo_cache: all created_by: acme_admin globex_user_overlap: @@ -35,4 +39,5 @@ globex_user_overlap: labels: kind: user team: platform + centaur.sandbox_repo_cache: all created_by: globex_admin diff --git a/services/console/test/fixtures/slack_channel_permissions.yml b/services/console/test/fixtures/slack_channel_permissions.yml new file mode 100644 index 000000000..06ce246cd --- /dev/null +++ b/services/console/test/fixtures/slack_channel_permissions.yml @@ -0,0 +1,2 @@ +# Empty by default. Tests create Slack channel permission rows explicitly so legacy +# slack_channel_id label fallback remains covered by existing principal fixtures. diff --git a/services/console/test/fixtures/system_settings.yml b/services/console/test/fixtures/system_settings.yml new file mode 100644 index 000000000..f86e37652 --- /dev/null +++ b/services/console/test/fixtures/system_settings.yml @@ -0,0 +1,5 @@ +default: + singleton: true + default_sandbox_repo_cache: all + default_sandbox_observability_enabled: true + default_sandbox_api_server_enabled: true diff --git a/services/console/test/helpers/application_helper_test.rb b/services/console/test/helpers/application_helper_test.rb index a04d35d3b..f7e00d46e 100644 --- a/services/console/test/helpers/application_helper_test.rb +++ b/services/console/test/helpers/application_helper_test.rb @@ -1,4 +1,5 @@ require "test_helper" +require "timeout" class ApplicationHelperTest < ActionView::TestCase test "truncate_middle leaves short values unchanged" do @@ -33,10 +34,128 @@ class ApplicationHelperTest < ActionView::TestCase assert_select_in html, "time[data-localtime-relative-value=true]" end + test "local_time can request compact relative formatting" do + html = local_time(Time.utc(2026, 6, 4, 18, 30, 0), relative: true, format: :compact) + + assert_select_in html, "time[data-localtime-relative-value=true]" + assert_select_in html, "time[data-localtime-format-value=compact]" + end + test "local_time renders a placeholder for nil" do assert_select_in local_time(nil), "span", text: "—" end + test "console_markdown renders common github-flavored markdown" do + html = console_markdown(<<~MARKDOWN) + Yes, **partially legit**. + + Issue 1 is real on current `main`. + + - one + - two + + https://github.com/paradigmxyz/centaur/issues/792 + MARKDOWN + + assert_select_in html, "p", text: /Yes, partially legit/ + assert_select_in html, "strong", text: "partially legit" + assert_select_in html, "code", text: "main" + assert_select_in html, "ul li", count: 2 + assert_select_in html, "a.console-markdown-link[href='https://github.com/paradigmxyz/centaur/issues/792']", + text: "https://github.com/paradigmxyz/centaur/issues/792" + end + + test "console_markdown renders gfm tables with alignment" do + html = console_markdown(<<~MARKDOWN) + Before the table. + + | Name | Count | Status | + | :--- | ---: | :---: | + | `api-rs` | 12 | **ok** | + | console | 3 | pending | + + After the table. + MARKDOWN + + assert_select_in html, "table thead tr th", count: 3 + assert_select_in html, "table tbody tr", count: 2 + assert_select_in html, "th.text-right", text: "Count" + assert_select_in html, "th.text-center", text: "Status" + assert_select_in html, "td.text-right", text: "12" + assert_select_in html, "tbody code", text: "api-rs" + assert_select_in html, "tbody strong", text: "ok" + assert_select_in html, "p", text: "Before the table." + assert_select_in html, "p", text: "After the table." + end + + test "console_markdown pads and truncates ragged table rows to the header width" do + html = console_markdown(<<~MARKDOWN) + | a | b | + | --- | --- | + | only | + | one | two | three | + MARKDOWN + + assert_select_in html, "tbody tr", count: 2 + assert_select_in html, "tbody tr:first-child td", count: 2 + assert_select_in html, "tbody tr:last-child td", count: 2 + refute_includes html, "three" + end + + test "console_markdown escapes html inside table cells" do + html = console_markdown("| h |\n| --- |\n| |") + + refute_includes html, " **safe**") + + refute_includes html, " diff --git a/services/console/app/views/console/threads/_thinking_indicator.html.erb b/services/console/app/views/console/threads/_thinking_indicator.html.erb new file mode 100644 index 000000000..c06092fe5 --- /dev/null +++ b/services/console/app/views/console/threads/_thinking_indicator.html.erb @@ -0,0 +1,6 @@ +<%# Pulsing placeholder shown while a turn is executing and the reply has not + landed yet. The composer's optimistic-submit JS builds this same markup + client-side (see _composer.html.erb) — keep the two in sync. %> +
+
Thinking…
+
diff --git a/services/console/app/views/console/threads/_thread_panel.html.erb b/services/console/app/views/console/threads/_thread_panel.html.erb index c3116bc19..a78dfede9 100644 --- a/services/console/app/views/console/threads/_thread_panel.html.erb +++ b/services/console/app/views/console/threads/_thread_panel.html.erb @@ -1,10 +1,36 @@ <%# One panel of the split-view grid. panel: {session:, thread_key:, - transcript_items:}; panels: all panels, used to build the close link that - drops this panel and promotes the first remaining thread to primary. The - thread param carries all open keys comma-separated, primary first. %> + transcript_items:} — or {new_chat: true} for a composer pane; panels: all + panels, used to build the close link that drops this panel and promotes + the first remaining thread to primary. The thread param carries all open + keys comma-separated, primary first. %> <% session = panel[:session] %> <% remaining_keys = panels.map { |other| other[:thread_key] } - [ panel[:thread_key] ] %> <% close_path = console_threads_path(thread: remaining_keys.join(",")) %> +<% if panel[:new_chat] %> +
+
+
+ New chat +
+ + <%= console_icon("x-mark", classes: "size-4") %> + +
+ <%# Mirrors the thread-panel structure 1:1 — empty transcript scroll in + the middle, composer docked at the bottom — so a new-chat pane sizes + exactly like its conversation siblings. %> +
+
+
+
+ <%= render "console/threads/composer", mode: :new, compact: true %> +
+
+<% else %>
@@ -37,6 +63,11 @@
<%= render "console/threads/transcript", items: panel[:transcript_items] %> + <%= render "console/threads/thinking_indicator" if thread_execution_active?(session.thread_key) %>
+
+ <%= render "console/threads/composer", mode: :thread, session: session %> +
+<% end %> diff --git a/services/console/app/views/console/threads/index.html.erb b/services/console/app/views/console/threads/index.html.erb index e55fbb394..ea5b74894 100644 --- a/services/console/app/views/console/threads/index.html.erb +++ b/services/console/app/views/console/threads/index.html.erb @@ -51,31 +51,22 @@
<% end %> + <%# An empty selection IS the new-chat screen — both the explicit ?new=1 + entry point and the nothing-selected / no-chats-yet cases. An + unresolvable ?thread= still renders as not-found. %> + <% if (@starting_new_thread || @selected_session.nil?) && !@thread_not_found %> +
+
+

Start a new chat

+ <%= render "console/threads/composer", mode: :new %> +
+
+ <% else %>
<% if @selected_session %>
<%= render "console/threads/transcript", items: @selected_transcript_items %> -
- <% elsif @thread_not_found %> -
-
-
- <%= console_icon("message-square", classes: "size-5") %> -
-
Chat not found
-
-
- <% elsif @sessions.empty? %> -
-
-
- <%= console_icon("message-square", classes: "size-5") %> -
-
No chats yet
-

- Chats you start — from Slack or the Console — will show up here. -

-
+ <%= render "console/threads/thinking_indicator" if thread_execution_active?(@selected_session.thread_key) %>
<% else %>
@@ -83,11 +74,44 @@
<%= console_icon("message-square", classes: "size-5") %>
-
Select a chat from the sidebar
+
Chat not found
<% end %> + + <% if @selected_session %> +
+
+ <%= render "console/threads/composer", mode: :thread, session: @selected_session %> +
+
+ <% end %> + <% end %> <% end %> + + <% if @thread_panels.any? { |panel| thread_execution_active?(panel[:thread_key]) } %> + <%# The console has no event stream; while a turn is running in any open + pane, refresh the transcript every few seconds. Skipped whenever a + composer holds a draft so a reload never eats typed text. %> + + <% end %> diff --git a/services/console/app/views/layouts/console.html.erb b/services/console/app/views/layouts/console.html.erb index 39de88822..f985b2db9 100644 --- a/services/console/app/views/layouts/console.html.erb +++ b/services/console/app/views/layouts/console.html.erb @@ -240,6 +240,12 @@ font-weight: 500; } + /* Keep stacked group tabs (Workflows / New chat / Chats) on the same + 0.125rem rhythm as the nav rows above them. */ + .console-thread-group + .console-thread-group { + margin-top: 0.125rem; + } + .console-thread-group-title { display: flex; height: 2.35rem; @@ -818,6 +824,192 @@ opacity: 1; } + /* Chat composer — the full-page New-chat card and the dock pinned under + open transcripts share one box; the dock only adds placement. No + separator rule: the rounded box already reads as the boundary, and a + full-bleed hairline over the narrower transcript column looks off. */ + .console-composer-dock { + flex: 0 0 auto; + padding: 0.25rem 0 1rem; + } + + .console-composer-dock--panel { + padding: 0.6rem 0.75rem 0.75rem; + } + + .console-composer-box { + display: flex; + flex-direction: column; + gap: 0.4rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.035); + padding: 0.75rem 0.875rem 0.625rem; + transition: border-color 120ms ease; + } + + .console-composer-box:focus-within { + border-color: rgba(255, 255, 255, 0.22); + } + + .console-composer-input { + width: 100%; + resize: none; + border: 0; + background: transparent; + color: #f4f4f5; + font-size: 0.875rem; + line-height: 1.5; + outline: none; + } + + .console-composer-input::placeholder { + color: #71717a; + } + + .console-composer-controls { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; + } + + .console-composer-spacer { + flex: 1; + } + + /* Model picker: a pill trigger opening a popover that reuses the + account-menu panel/item classes, so both dropdowns share one design. */ + .console-composer-model { + position: relative; + min-width: 0; + } + + .console-composer-model-trigger { + display: flex; + align-items: center; + gap: 0.4rem; + max-width: 16rem; + border-radius: 999px; + background: transparent; + padding: 0.3rem 0.75rem; + color: #a1a1aa; + font-size: 0.8125rem; + font-weight: 500; + transition: background 120ms ease, color 120ms ease; + } + + .console-composer-model-trigger:hover, + .console-composer-model-trigger[aria-expanded="true"] { + background: rgba(255, 255, 255, 0.09); + color: #f4f4f5; + } + + .console-composer-model-chevron { + display: grid; + place-items: center; + transform: rotate(90deg); + transition: transform 120ms ease; + } + + .console-composer-model-trigger[aria-expanded="true"] .console-composer-model-chevron { + transform: rotate(-90deg); + } + + /* The picker sits just left of the send button, so the popover hangs + from its right edge. */ + .console-composer-model-menu { + top: calc(100% + 0.4rem); + right: 0; + bottom: auto; + left: auto; + width: 15rem; + } + + /* Root rows of the picker menu: setting name left, current value and a + chevron on the right (codex-app style). */ + .console-composer-menu-value { + margin-left: auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #71717a; + font-weight: 400; + } + + .console-composer-menu-chevron { + display: grid; + flex: 0 0 auto; + place-items: center; + color: #71717a; + } + + .console-composer-model-option-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + text-align: left; + } + + .console-composer-model-check { + display: grid; + place-items: center; + color: #3ace79; + } + + /* "Thinking…" placeholder while a turn is executing, painted both by + the server (_thinking_indicator) and the composer's optimistic JS. */ + .console-thinking-pending { + color: #8b8b94; + font-size: 0.875rem; + line-height: 1.5; + animation: console-thinking-pulse 1.6s ease-in-out infinite; + } + + @keyframes console-thinking-pulse { + 0%, 100% { opacity: 0.45; } + 50% { opacity: 1; } + } + + .console-composer-send { + display: grid; + height: 2rem; + width: 2rem; + flex: 0 0 auto; + place-items: center; + border-radius: 999px; + background: #28c26a; + color: #0b0b0d; + transition: background 120ms ease; + } + + .console-composer-send:hover { + background: #3ace79; + } + + /* Full-page New-chat composer. Sits a bit above vertical center (the + claude.ai-style resting height) and matches the transcript column + width so it reads as an empty chat, not a separate page. */ + .console-new-chat { + width: 100%; + max-width: 48rem; + margin-top: 28vh; + display: flex; + flex-direction: column; + gap: 1rem; + } + + .console-new-chat-title { + color: #f4f4f5; + font-size: 1.25rem; + font-weight: 600; + text-align: center; + } + + .console-control-tabs { display: flex; gap: 0.25rem; @@ -1256,6 +1448,57 @@ color: #8c9198; } + /* Composer: plain-CSS component classes, so the utility-class remaps + above never reach them — they need their own light overrides. */ + html[data-console-theme="light"] .console-composer-box { + border-color: rgba(15, 18, 20, 0.14); + background: #ffffff; + } + + html[data-console-theme="light"] .console-composer-box:focus-within { + border-color: rgba(15, 18, 20, 0.3); + } + + html[data-console-theme="light"] .console-composer-input { + color: #1c1f23; + } + + html[data-console-theme="light"] .console-composer-input::placeholder { + color: #8c9198; + } + + html[data-console-theme="light"] .console-composer-model-trigger { + background: transparent; + color: #565c63; + } + + html[data-console-theme="light"] .console-composer-model-trigger:hover, + html[data-console-theme="light"] .console-composer-model-trigger[aria-expanded="true"] { + background: rgba(15, 18, 20, 0.1); + color: #1c1f23; + } + + html[data-console-theme="light"] .console-composer-model-check { + color: #1f9d55; + } + + html[data-console-theme="light"] .console-composer-menu-value, + html[data-console-theme="light"] .console-composer-menu-chevron { + color: #8c9198; + } + + html[data-console-theme="light"] .console-thinking-pending { + color: #6f757c; + } + + html[data-console-theme="light"] .console-composer-send { + color: #ffffff; + } + + html[data-console-theme="light"] .console-new-chat-title { + color: #1c1f23; + } + @media (max-width: 760px) { .console-sidebar { width: 11rem; @@ -1349,6 +1592,22 @@ <% end %> + <%# New chat rides the thread-link machinery with the "new" sentinel + key: plain click opens the full-page composer, Cmd/Ctrl-click + adds a composer pane to the split view (and re-clicking an open + one closes it). %> + +
" @@ -1584,6 +1843,9 @@ : []; document.querySelectorAll("[data-console-thread-link]").forEach((link) => { + // The New chat tab participates in open/close clicks but is not a + // thread row: no open-dot or pane-number decoration. + if (link.dataset.consoleNewChat !== undefined) return; const key = new URL(link.href, window.location.origin).searchParams.get("thread"); const paneIndex = openKeys.indexOf(key); const open = paneIndex !== -1; @@ -1602,7 +1864,57 @@ }); }; - document.addEventListener("turbo:load", syncSidebarActiveThreads); + // A chat the turbo-permanent list has never seen (just started from + // the composer, or older than the list window) appears immediately as + // an optimistic row, then the frame refetches so the server's row, + // title, and ordering replace it. + const SIDEBAR_THREADS_PATH = "<%= console_sidebar_threads_path %>"; + + const ensureActiveThreadRow = () => { + const url = new URL(window.location.href); + if (!url.pathname.startsWith(THREADS_PATH)) return; + const openKeys = (url.searchParams.get("thread") || "").split(",").filter(Boolean); + const primary = openKeys[0]; + // "new" is the composer-pane sentinel, not a thread. + if (!primary || primary === "new") return; + + const frame = document.getElementById("console_sidebar_threads"); + if (!frame) return; + // Still on the lazy-load placeholder: the initial fetch carries the + // thread param and will include this chat on its own. + if (!frame.querySelector("[data-console-thread-link], .console-thread-empty")) return; + + const known = Array.from(frame.querySelectorAll("[data-console-thread-link]")).some((link) => + new URL(link.href, window.location.origin).searchParams.get("thread") === primary + ); + if (known) return; + + frame.querySelector(".console-thread-empty")?.remove(); + const title = document.querySelector(".console-thread-detail-header h1")?.textContent?.trim() || primary; + const link = document.createElement("a"); + link.href = `${THREADS_PATH}?thread=${encodeURIComponent(primary)}`; + link.className = "console-thread-link"; + link.setAttribute("data-console-thread-link", "true"); + link.title = title; + const titleSpan = document.createElement("span"); + titleSpan.className = "console-thread-title"; + titleSpan.textContent = title; + const timeSpan = document.createElement("span"); + timeSpan.className = "console-thread-time"; + timeSpan.textContent = "now"; + link.append(titleSpan, timeSpan); + frame.prepend(link); + syncSidebarActiveThreads(); + + const src = new URL(SIDEBAR_THREADS_PATH, window.location.origin); + src.searchParams.set("thread", openKeys.join(",")); + frame.setAttribute("src", src.toString()); + }; + + document.addEventListener("turbo:load", () => { + syncSidebarActiveThreads(); + ensureActiveThreadRow(); + }); document.addEventListener("turbo:frame-load", (event) => { if (event.target && event.target.id === "console_sidebar_threads") syncSidebarActiveThreads(); }); diff --git a/services/console/test/controllers/console/threads_controller_test.rb b/services/console/test/controllers/console/threads_controller_test.rb index 540c6b65c..61b19213d 100644 --- a/services/console/test/controllers/console/threads_controller_test.rb +++ b/services/console/test/controllers/console/threads_controller_test.rb @@ -36,53 +36,20 @@ class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest assert_select ".console-thread-group-title", text: /Chats/ end - test "threads page does not render composer when session database is unavailable" do + test "threads page falls back to the new chat screen when session database is unavailable" do with_recent_first_error do get console_threads_url end assert_response :ok - assert_select "input[name=q]", count: 0 - assert_select ".console-main-thread-frame aside", count: 0 - # No chat selected: like the not-found state, the page renders only the - # centered empty state — no detail header. + # No chat selected: the new-chat composer renders (posting goes through + # the API, not the sessions DB), alongside the unavailability note. assert_select ".console-thread-detail-header", count: 0 - assert_select "a[aria-label=?]", "New chat", count: 0 - assert_select "span[aria-label=?]", "New chat disabled", count: 0 - assert_select "textarea[name=prompt]", count: 0 - assert_select "select[name=harness_type]", count: 0 - assert_select "form[action=?]", console_threads_path, count: 0 - assert_select "body", text: /No chats yet/ + assert_select "a[aria-label=?]", "New chat", count: 1 + assert_select "textarea[name=prompt]", count: 1 assert_select "body", text: /Chat database is unavailable/ end - test "blank prompt is blocked by read only mode" do - post console_threads_url, params: { prompt: " " } - - assert_redirected_to console_threads_path - assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] - end - - test "threads page hides composer controls" do - with_recent_first_error do - get console_threads_url - end - - assert_response :ok - assert_select "textarea[name=prompt]", count: 0 - assert_select "form[action=?]", console_threads_path, count: 0 - assert_select "body", text: /Read-only snapshot/, count: 0 - assert_select "span[aria-label=?]", "New chat disabled", count: 0 - assert_select "a[aria-label=?]", "New chat", count: 0 - end - - test "posts are blocked without calling the session api" do - post console_threads_url, params: { prompt: "Do not run this." } - - assert_redirected_to console_threads_path - assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] - end - test "plain threads page redirects to first visible thread" do skip_unless_session_table @@ -625,23 +592,276 @@ class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest assert_nil controller.send(:selected_session, scoped_relation, []) end - test "starting a thread is blocked without calling the session api" do - post console_threads_url, params: { prompt: "Reply with PONG.", harness_type: "amp" } + test "renders the sidebar New chat link and the full-page composer" do + with_composer do + with_recent_first_error do + get console_threads_url(new: 1) + end + end + + assert_response :ok + assert_select "a[aria-label=?]", "New chat", count: 1 + assert_select "form[action=?]", console_threads_path do + assert_select "textarea[name=prompt]", count: 1 + # The model picker is a custom menu (account-dropdown style) posting + # through a hidden field, not a native select. + assert_select "input[type=hidden][name=model]", count: 1 + assert_select "[data-console-model-option][data-value=?]", "amp" + assert_select "select", count: 0 + end + end + + test "shows the new chat screen when nothing is selected" do + with_composer do + with_recent_first_error do + get console_threads_url + end + end + + assert_response :ok + assert_select "textarea[name=prompt]", count: 1 + assert_select "body", text: /No chats yet/, count: 0 + end + + test "an active execution renders a thinking indicator" do + skip_unless_session_table + insert_console_session("console:thinking-active") + insert_session_execution("console:thinking-active", status: "running") + + get console_threads_url(thread: "console:thinking-active") + + assert_response :ok + assert_select "[data-console-thinking-indicator]", count: 1 + end + + test "a completed execution renders no thinking indicator" do + skip_unless_session_table + insert_console_session("console:thinking-done") + insert_session_execution("console:thinking-done", status: "completed") + + get console_threads_url(thread: "console:thinking-done") + + assert_response :ok + assert_select "[data-console-thinking-indicator]", count: 0 + end + + test "a new sentinel pane opens a composer panel alongside a thread" do + skip_unless_session_table + insert_console_session("console:with-new-pane") + + with_composer do + get console_threads_url(thread: "console:with-new-pane,new") + end + + assert_response :ok + assert_select "[data-thread-panel]", count: 2 + assert_select "[data-thread-panel=new]", count: 1 + assert_select "[data-thread-panel=new] textarea[name=prompt]", count: 1 + assert_select "[data-thread-panel=new] [data-console-model-picker]", count: 1 + end + + test "the new sentinel alone renders the full-page new chat screen" do + with_composer do + with_recent_first_error do + get console_threads_url(thread: "new") + end + end + + assert_response :ok + assert_select "[data-thread-panel]", count: 0 + assert_select "textarea[name=prompt]", count: 1 + end + + test "starting a chat from a pane swaps the sentinel for the created thread" do + client = RecordingApiClient.new + with_composer(client: client) do + post console_threads_url, + params: { + prompt: "Reply with PONG.", + model: "gpt-5.5", + open_threads: "console:other,new" + } + end + + thread_key = client.calls[0].last[:thread_key] + assert_redirected_to console_threads_path(thread: "console:other,#{thread_key}") + end + + test "renders a follow-up composer on an open chat" do + skip_unless_session_table + insert_console_session("console:composer-open") + + with_composer do + get console_threads_url(thread: "console:composer-open") + end + + assert_response :ok + assert_select "form[action=?]", console_threads_path do + assert_select "input[type=hidden][name=thread_key][value=?]", "console:composer-open" + assert_select "textarea[name=prompt]", count: 1 + # Follow-ups stay on the chat's existing harness/model: no picker. + assert_select "[data-console-model-picker]", count: 0 + end + end + + test "starting a chat creates a session, appends the prompt, and executes it" do + client = RecordingApiClient.new + with_composer(client: client) do + post console_threads_url, + params: { prompt: "Reply with PONG.", model: "claude-opus-4-8" } + end + + assert_equal %i[create_session append_session_messages execute_session], client.calls.map(&:first) + + create = client.calls[0].last + assert create[:thread_key].start_with?("console:"), "expected a console:-namespaced thread key" + assert_equal "claudecode", create[:harness_type] + assert_equal "console", create[:metadata][:platform] + assert_equal "console", create[:metadata][:source] + assert_equal @operator.email, create[:metadata][:actor_email] + assert_equal "claude-opus-4-8", create[:metadata][:model] + + append = client.calls[1].last + assert_equal create[:thread_key], append[:thread_key] + message = append[:messages].first + assert_equal "user", message[:role] + assert_equal "Reply with PONG.", message[:parts].first[:text] + assert_equal @operator.email, message[:metadata][:user_email] + + execute = client.calls[2].last + assert_equal create[:thread_key], execute[:thread_key] + assert execute[:idempotency_key].present? + assert_equal "claude-opus-4-8", execute[:metadata][:model] + line = JSON.parse(execute[:input_lines].first) + assert_equal "user", line["type"] + assert_equal create[:thread_key], line["thread_key"] + assert_equal "claude-opus-4-8", line["model"] + assert_equal message[:client_message_id], line["client_user_message_id"] + assert_equal "Reply with PONG.", line.dig("message", "content", 0, "text") + + assert_redirected_to console_threads_path(thread: create[:thread_key]) + end + + test "picking Amp starts an amp chat and sends no model" do + client = RecordingApiClient.new + with_composer(client: client) do + post console_threads_url, params: { prompt: "Reply with PONG.", model: "amp" } + end + + create = client.calls[0].last + assert_equal "amp", create[:harness_type] + assert_not create[:metadata].key?(:model) + + execute = client.calls[2].last + assert_not execute[:metadata].key?(:model) + line = JSON.parse(execute[:input_lines].first) + assert_not line.key?("model") + end + + test "starting a chat with an unknown model is rejected" do + client = RecordingApiClient.new + with_composer(client: client) do + post console_threads_url, params: { prompt: "Reply with PONG.", model: "hal9000" } + end + + assert_empty client.calls + assert_redirected_to console_threads_path(new: 1) + assert_match(/Unknown model/, flash[:alert]) + end + + test "a gpt model pick starts a codex chat" do + client = RecordingApiClient.new + with_composer(client: client) do + post console_threads_url, params: { prompt: "Reply with PONG.", model: "gpt-5.5" } + end + + create = client.calls[0].last + assert_equal "codex", create[:harness_type] + assert_equal "gpt-5.5", create[:metadata][:model] + end + + test "a codex chat carries the picked reasoning effort" do + client = RecordingApiClient.new + with_composer(client: client) do + post console_threads_url, + params: { prompt: "Reply with PONG.", model: "gpt-5.6-sol", effort: "max" } + end + + execute = client.calls[2].last + assert_equal "max", execute[:metadata][:reasoning] + line = JSON.parse(execute[:input_lines].first) + assert_equal "max", line["reasoning"] + end + + test "an effort the model does not offer is dropped" do + client = RecordingApiClient.new + with_composer(client: client) do + # max is 5.6-only; claude models take no effort at all. + post console_threads_url, + params: { prompt: "Reply with PONG.", model: "gpt-5.5", effort: "max" } + post console_threads_url, + params: { prompt: "Reply with PONG.", model: "claude-opus-4-8", effort: "high" } + end + + [ 2, 5 ].each do |index| + execute = client.calls[index].last + assert_not execute[:metadata].key?(:reasoning) + assert_not JSON.parse(execute[:input_lines].first).key?("reasoning") + end + end + + test "a blank prompt asks for a message" do + client = RecordingApiClient.new + with_composer(client: client) do + post console_threads_url, params: { prompt: " " } + end + + assert_empty client.calls + assert_redirected_to console_threads_path(new: 1) + assert_equal "Type a message first.", flash[:alert] + end + + test "replying appends and executes on an owned chat without creating a session" do + skip_unless_session_table + insert_console_session("console:composer-reply") + + client = RecordingApiClient.new + with_composer(client: client) do + post console_threads_url, + params: { + prompt: "Continue from here.", + thread_key: "console:composer-reply", + open_threads: "console:composer-reply,console:other" + } + end + + assert_equal %i[append_session_messages execute_session], client.calls.map(&:first) + assert_equal "console:composer-reply", client.calls[0].last[:thread_key] + assert_redirected_to console_threads_path(thread: "console:composer-reply,console:other") + end + + test "replying into a chat outside the owner scope is rejected" do + skip_unless_session_table + + client = RecordingApiClient.new + with_composer(client: client) do + post console_threads_url, + params: { prompt: "Continue from here.", thread_key: "console:not-mine" } + end + assert_empty client.calls assert_redirected_to console_threads_path - assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + assert_equal "Chat not found.", flash[:alert] end - test "posting to an existing thread is blocked without calling the session api" do - post console_threads_url, - params: { - prompt: "Continue from here.", - thread_key: "console:existing", - harness_type: "codex" - } + test "a session api error surfaces as a flash alert" do + client = RecordingApiClient.new(error: CentaurApiClient::Error.new("boom")) + with_composer(client: client) do + post console_threads_url, params: { prompt: "Reply with PONG.", harness_type: "codex" } + end - assert_redirected_to console_threads_path(thread: "console:existing") - assert_equal "Chats are read-only while browsing a mirrored production snapshot.", flash[:alert] + assert_redirected_to console_threads_path(new: 1) + assert_match(/boom/, flash[:alert]) end # Fix 6: the sidebar thread list is loaded lazily via a Turbo Frame so the @@ -1153,6 +1373,39 @@ class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest private + # Fake CentaurApiClient recording every composer call; raises `error` from + # each method instead when given, to exercise the failure paths. + class RecordingApiClient + attr_reader :calls + + def initialize(error: nil) + @calls = [] + @error = error + end + + def create_session(**kwargs) = record(:create_session, kwargs) + def append_session_messages(**kwargs) = record(:append_session_messages, kwargs) + def execute_session(**kwargs) = record(:execute_session, kwargs) + + private + + def record(name, kwargs) + raise @error if @error + + @calls << [ name, kwargs ] + {} + end + end + + # Runs the block with the injected fake session client. + def with_composer(client: RecordingApiClient.new) + original_factory = Console::ThreadsController.client_factory + Console::ThreadsController.client_factory = -> { client } + yield client + ensure + Console::ThreadsController.client_factory = original_factory + end + # Sets each env var for the block (nil deletes) and restores the previous # values afterwards. def with_env(overrides) @@ -1215,6 +1468,21 @@ def insert_slack_session(thread_key, slack_user_id:, slack_user_name:) insert_session(thread_key, metadata) end + def insert_session_execution(thread_key, status:) + connection = CentaurSession.connection + connection.execute(<<~SQL.squish) + insert into session_executions (execution_id, thread_key, status, metadata, created_at, updated_at) + values ( + #{connection.quote("#{thread_key}-exec")}, + #{connection.quote(thread_key)}, + #{connection.quote(status)}, + '{}'::jsonb, + now(), + now() + ) + SQL + end + def insert_session_message(thread_key, index:) connection = CentaurSession.connection parts = [ { type: "text", text: "message #{index}" } ].to_json From 9683728409c29074597c228ba9b697f8d0ff6bff Mon Sep 17 00:00:00 2001 From: Goksu Toprak <19259594+goksu@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:46:03 -0700 Subject: [PATCH 187/198] fix(console): preserve optimistic chat messages (#1047) --- .../app/views/console/threads/_composer.html.erb | 7 ++++++- .../console/app/views/layouts/console.html.erb | 16 +++++++++++++--- .../console/threads_controller_test.rb | 7 +++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/services/console/app/views/console/threads/_composer.html.erb b/services/console/app/views/console/threads/_composer.html.erb index e41b89b6b..6caee67f9 100644 --- a/services/console/app/views/console/threads/_composer.html.erb +++ b/services/console/app/views/console/threads/_composer.html.erb @@ -145,7 +145,9 @@ if (container) { const column = document.createElement("div"); column.className = "flex w-full flex-col gap-6"; + column.setAttribute("data-console-optimistic-transcript", ""); column.append(userBubble(text), thinkingRow()); + container.classList.add("console-new-chat--optimistic"); container.querySelector(".console-new-chat-title")?.setAttribute("hidden", ""); container.insertBefore(column, form); form.hidden = true; @@ -158,7 +160,10 @@ if (!transcript) return; transcript.querySelector("[data-console-thinking-indicator]")?.remove(); transcript.append(userBubble(text), thinkingRow()); - input.value = ""; + // Turbo builds FormData after the submit event has bubbled. Clearing the + // textarea here would therefore send an empty prompt; wait until the + // browser has captured the form payload before clearing the composer. + form.addEventListener("formdata", () => { input.value = ""; }, { once: true }); }); document.addEventListener("keydown", (event) => { diff --git a/services/console/app/views/layouts/console.html.erb b/services/console/app/views/layouts/console.html.erb index f985b2db9..b8b61cc01 100644 --- a/services/console/app/views/layouts/console.html.erb +++ b/services/console/app/views/layouts/console.html.erb @@ -990,9 +990,11 @@ background: #3ace79; } - /* Full-page New-chat composer. Sits a bit above vertical center (the - claude.ai-style resting height) and matches the transcript column - width so it reads as an empty chat, not a separate page. */ + /* Full-page New-chat composer. Its resting state sits a bit above + vertical center (the claude.ai-style resting height) and matches the + transcript column width so it reads as an empty chat, not a separate + page. Once submitted, it fills the available transcript height and + bottom-aligns the optimistic turn like an opened conversation. */ .console-new-chat { width: 100%; max-width: 48rem; @@ -1002,6 +1004,14 @@ gap: 1rem; } + .console-new-chat--optimistic { + align-self: stretch; + max-width: 52rem; + margin-top: 0; + padding: 1.5rem 0; + justify-content: flex-end; + } + .console-new-chat-title { color: #f4f4f5; font-size: 1.25rem; diff --git a/services/console/test/controllers/console/threads_controller_test.rb b/services/console/test/controllers/console/threads_controller_test.rb index 61b19213d..14e58c8ba 100644 --- a/services/console/test/controllers/console/threads_controller_test.rb +++ b/services/console/test/controllers/console/threads_controller_test.rb @@ -609,6 +609,10 @@ class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest assert_select "[data-console-model-option][data-value=?]", "amp" assert_select "select", count: 0 end + # Submitting replaces the centered empty state with a full-height, + # bottom-aligned optimistic transcript while the request is in flight. + assert_includes response.body, 'container.classList.add("console-new-chat--optimistic")' + assert_includes response.body, ".console-new-chat--optimistic" end test "shows the new chat screen when nothing is selected" do @@ -702,6 +706,9 @@ class Console::ThreadsControllerTest < ActionDispatch::IntegrationTest # Follow-ups stay on the chat's existing harness/model: no picker. assert_select "[data-console-model-picker]", count: 0 end + # Optimistic rendering must not clear the textarea until Turbo has copied + # its value into FormData, or the controller receives a blank prompt. + assert_includes response.body, 'form.addEventListener("formdata"' end test "starting a chat creates a session, appends the prompt, and executes it" do From 0be6fa833c8d7f2e0c1ac4af622771b9dffb8507 Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:06:11 -0400 Subject: [PATCH 188/198] chore: align TipLink overlay with current upstream Drop upstream-redundant workflow session plumbing, retain the independent cleanup and capability boundaries, align MCP discovery with sandbox shims, and correct runtime access guidance. From 75a19343cc89f1c490ff1324e9617ad7387037ec Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:07:32 -0400 Subject: [PATCH 189/198] docs: remove retired implicit-session contract --- docs/pages/extend/workflows.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/pages/extend/workflows.mdx b/docs/pages/extend/workflows.mdx index 362cbc037..d5d6d46fd 100644 --- a/docs/pages/extend/workflows.mdx +++ b/docs/pages/extend/workflows.mdx @@ -70,7 +70,6 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: | `ctx.sleep(name, duration)` | Suspend and resume later. | | `ctx.sleep_until(name, when)` | Resume at a specific time. | | `ctx.wait_for_event(name, event_type, correlation_id)` | Wait for an external event. | -| `ctx.start_workflow(...)` | Start a child workflow and continue immediately. | | `ctx.wait_for_workflow(...)` | Wait for a child workflow to finish. | | `ctx.run_workflow(...)` | Start and wait in one call. | | `ctx.start_agent(...)` | Start an agent turn. | From a8654b41f1c41b391f8122cdbcf55cb269a03fd9 Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:07:49 -0400 Subject: [PATCH 190/198] fix: align MCP discovery with sandbox shims --- docs/public/md/extend/workflows.md | 1 - .../crates/centaur-api-server/src/mcp.rs | 46 ++++- .../centaur-api-server/src/tool_discovery.rs | 159 ++++++++++++++++-- 3 files changed, 190 insertions(+), 16 deletions(-) diff --git a/docs/public/md/extend/workflows.md b/docs/public/md/extend/workflows.md index 2cb8d6ff5..687cc66a4 100644 --- a/docs/public/md/extend/workflows.md +++ b/docs/public/md/extend/workflows.md @@ -70,7 +70,6 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: | `ctx.sleep(name, duration)` | Suspend and resume later. | | `ctx.sleep_until(name, when)` | Resume at a specific time. | | `ctx.wait_for_event(name, event_type, correlation_id)` | Wait for an external event. | -| `ctx.start_workflow(...)` | Start a child workflow and continue immediately. | | `ctx.wait_for_workflow(...)` | Wait for a child workflow to finish. | | `ctx.run_workflow(...)` | Start and wait in one call. | | `ctx.start_agent(...)` | Start an agent turn. | diff --git a/services/api-rs/crates/centaur-api-server/src/mcp.rs b/services/api-rs/crates/centaur-api-server/src/mcp.rs index 6df680ece..e47af033d 100644 --- a/services/api-rs/crates/centaur-api-server/src/mcp.rs +++ b/services/api-rs/crates/centaur-api-server/src/mcp.rs @@ -310,7 +310,9 @@ fn mcp_centaur_tool_catalog() -> Result, ApiError> { } .resolve_tool_dirs() .map_err(|error| ApiError::Internal(error.to_string()))?; - let tools = discover_tool_catalog(&dirs) + let tool_allowlist = effective_sandbox_env("TOOL_ALLOWLIST"); + let tool_blocklist = effective_sandbox_env("TOOL_BLOCKLIST"); + let tools = discover_tool_catalog(&dirs, tool_allowlist.as_deref(), tool_blocklist.as_deref()) .map_err(|error| ApiError::Internal(error.to_string()))? .tools; if !cfg!(test) { @@ -319,6 +321,27 @@ fn mcp_centaur_tool_catalog() -> Result, ApiError> { Ok(tools) } +fn effective_sandbox_env(name: &str) -> Option { + env::var("SESSION_SANDBOX_EXTRA_ENV") + .ok() + .and_then(|raw| sandbox_extra_env_value(&raw, name)) + .or_else(|| env::var(name).ok()) +} + +fn sandbox_extra_env_value(raw: &str, name: &str) -> Option { + let parsed = serde_json::from_str::(raw).ok()?; + let entry = parsed.as_array()?.iter().rev().find(|item| { + item.get("name") + .and_then(Value::as_str) + .is_some_and(|candidate| candidate.trim() == name) + })?; + Some(match entry.get("value") { + None | Some(Value::Null) => String::new(), + Some(Value::String(value)) => value.clone(), + Some(value) => value.to_string(), + }) +} + fn mcp_find_centaur_tool(name: &str) -> Result, ApiError> { Ok(mcp_centaur_tool_catalog()? .into_iter() @@ -857,6 +880,27 @@ mod mcp_tests { env::temp_dir().join(format!("{prefix}-{}-{suffix}", std::process::id())) } + #[test] + fn tool_filters_follow_effective_sandbox_extra_env() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("TOOL_ALLOWLIST", "api-only"), + ( + "SESSION_SANDBOX_EXTRA_ENV", + r#"[{"name":"TOOL_ALLOWLIST","value":"old"},{"name":"TOOL_ALLOWLIST","value":"sandbox"},{"name":"TOOL_BLOCKLIST","value":"blocked"}]"#, + ), + ]); + + assert_eq!( + effective_sandbox_env("TOOL_ALLOWLIST").as_deref(), + Some("sandbox") + ); + assert_eq!( + effective_sandbox_env("TOOL_BLOCKLIST").as_deref(), + Some("blocked") + ); + } + fn test_tool(project_dir: PathBuf) -> DiscoveredTool { DiscoveredTool { name: "demo".to_owned(), diff --git a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs index 86db62e06..5e5c6f8b0 100644 --- a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs +++ b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs @@ -173,21 +173,70 @@ pub fn discover_persona_registry( pub(crate) fn discover_tool_catalog( tool_dirs: &[PathBuf], + tool_allowlist: Option<&str>, + tool_blocklist: Option<&str>, ) -> Result { - let mut tools = Vec::new(); - for tool in collect_plugin_metadata(tool_dirs)?.tools { - for script_name in tool.script_names { - tools.push(DiscoveredTool { - name: script_name, - package: tool.package.clone(), - description: tool.description.clone(), - client_module: tool.client_module.clone(), - project_dir: tool.dir.clone(), - }); + let allowlist = parse_tool_name_filter(tool_allowlist); + let blocklist = parse_tool_name_filter(tool_blocklist); + let mut tools = BTreeMap::new(); + + // Match services/sandbox/install_tool_shims.py: scan TOOL_DIRS in order, + // filter by package-directory, project, or script name, and let the last + // package that declares a script name own that script. + for base_dir in tool_dirs { + if !base_dir.exists() { + continue; + } + for tool_dir in candidate_tool_dirs(base_dir)? { + let pyproject_path = tool_dir.join("pyproject.toml"); + let Some(LoadedPluginMeta::Tool(tool)) = + load_plugin_meta(base_dir, &tool_dir, &pyproject_path)? + else { + continue; + }; + let identifiers = tool_identifiers(&tool); + if !allowlist.is_empty() && identifiers.is_disjoint(&allowlist) { + continue; + } + if !identifiers.is_disjoint(&blocklist) { + continue; + } + for script_name in tool.script_names { + if blocklist.contains(&script_name) { + continue; + } + tools.insert( + script_name.clone(), + DiscoveredTool { + name: script_name, + package: tool.package.clone(), + description: tool.description.clone(), + client_module: tool.client_module.clone(), + project_dir: tool.dir.clone(), + }, + ); + } } } - tools.sort_by(|left, right| left.name.cmp(&right.name)); - Ok(DiscoveredToolCatalog { tools }) + Ok(DiscoveredToolCatalog { + tools: tools.into_values().collect(), + }) +} + +fn parse_tool_name_filter(value: Option<&str>) -> BTreeSet { + value + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .collect() +} + +fn tool_identifiers(tool: &LoadedToolMeta) -> BTreeSet { + let mut identifiers = BTreeSet::from([tool.name.clone(), tool.package.clone()]); + identifiers.extend(tool.script_names.iter().cloned()); + identifiers } fn split_tool_dirs(value: &str) -> Vec { @@ -565,8 +614,7 @@ fn load_tool_meta( names.sort(); names }) - .filter(|names| !names.is_empty()) - .unwrap_or_else(|| vec![name.clone()]); + .unwrap_or_default(); let default_hosts = string_array(tool_conf.get("hosts")); let labels = tool_labels(&name, &overlay_name_for_root(source_root)); let secrets = match parse_secret_list(tool_conf.get("secrets"), &default_hosts, &labels) @@ -1705,6 +1753,89 @@ mod tests { ); } + #[test] + fn tool_catalog_matches_sandbox_filters_and_script_precedence() { + let temp = temp_dir("api-rs-tool-catalog"); + let base = temp.join("base"); + let overlay = temp.join("overlay"); + write_tool( + &base.join("category").join("alpha-dir"), + r#" +[project] +name = "alpha-project" + +[project.scripts] +alpha = "alpha:main" +shared = "alpha:main" +"#, + ); + write_tool( + &base.join("category").join("beta-dir"), + r#" +[project] +name = "beta-project" + +[project.scripts] +beta = "beta:main" +"#, + ); + write_tool( + &base.join("category").join("blocked-dir"), + r#" +[project] +name = "blocked-project" + +[project.scripts] +blocked = "blocked:main" +safe-sibling = "blocked:main" +"#, + ); + write_tool( + &base.join("category").join("phantom"), + r#" +[project] +name = "phantom-project" +"#, + ); + write_tool( + &overlay.join("category").join("replacement"), + r#" +[project] +name = "overlay-project" + +[project.scripts] +overlay = "overlay:main" +shared = "overlay:main" +"#, + ); + + let catalog = discover_tool_catalog( + &[base.clone(), overlay.clone()], + Some("alpha-dir,beta-project,overlay,blocked-dir,phantom"), + Some("blocked"), + ) + .unwrap(); + + assert_eq!( + catalog + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["alpha", "beta", "overlay", "shared"] + ); + let shared = catalog + .tools + .iter() + .find(|tool| tool.name == "shared") + .expect("shared script"); + assert_eq!(shared.package, "overlay-project"); + assert_eq!(shared.project_dir, overlay.join("category/replacement")); + assert!(catalog.tools.iter().all(|tool| tool.name != "safe-sibling")); + + let _ = fs::remove_dir_all(temp); + } + #[test] fn postgres_listeners_retain_sandbox_env_name_and_database() { // api-rs bakes the sandbox PG DSNs from `sandbox_env`, so the listener From 5c53dc0726f82304d0cefb24d9a85a2d3adc75fe Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:07:52 -0400 Subject: [PATCH 191/198] refactor: use upstream workflow-owned sessions --- .../crates/centaur-session-runtime/src/lib.rs | 96 ++----------------- 1 file changed, 7 insertions(+), 89 deletions(-) diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 76dece26b..93f46db8e 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -2101,30 +2101,14 @@ impl SessionRuntime { .store .list_workflow_owned_sandboxes(workflow_run_id) .await?; - self.stop_workflow_owned_sessions(sessions, "run", workflow_run_id, reason) - .await - } - - /// Stop every implicit session created by any attempt of one durable task. - /// Run ids change across retries; the task id is the cancellation identity. - pub async fn stop_workflow_task_owned_sandboxes( - &self, - workflow_task_id: &str, - reason: &str, - ) -> Result { - let sessions = self - .store - .list_workflow_task_owned_sandboxes(workflow_task_id) - .await?; - self.stop_workflow_owned_sessions(sessions, "task", workflow_task_id, reason) + self.stop_workflow_owned_sessions(sessions, workflow_run_id, reason) .await } async fn stop_workflow_owned_sessions( &self, sessions: Vec, - scope_kind: &'static str, - scope_id: &str, + workflow_run_id: &str, reason: &str, ) -> Result { let mut report = WorkflowSandboxCleanupReport::default(); @@ -2132,7 +2116,7 @@ impl SessionRuntime { for session in sessions { let sandbox_id = session.sandbox_id; let thread_key = session.thread_key; - let release_id = format!("workflow:{scope_kind}:{scope_id}:{reason}"); + let release_id = format!("workflow:run:{workflow_run_id}:{reason}"); let outcome = match self .release_thread(&thread_key, Some(&release_id), sandbox_id.as_deref(), true) .await @@ -2145,8 +2129,7 @@ impl SessionRuntime { warn!( thread_key = %thread_key, sandbox_id = sandbox_id.as_deref(), - workflow_scope_kind = scope_kind, - workflow_scope_id = scope_id, + workflow_run_id, reason, %error, "failed to release workflow-owned session" @@ -2178,8 +2161,7 @@ impl SessionRuntime { warn!( thread_key = %thread_key, sandbox_id, - workflow_scope_kind = scope_kind, - workflow_scope_id = scope_id, + workflow_run_id, %error, "failed to mark workflow-owned warm sandbox failed" ); @@ -2194,8 +2176,7 @@ impl SessionRuntime { json!({ "thread_key": thread_key.as_str(), "sandbox_id": sandbox_id, - "workflow_run_id": (scope_kind == "run").then_some(scope_id), - "workflow_task_id": (scope_kind == "task").then_some(scope_id), + "workflow_run_id": workflow_run_id, "reason": reason, "missing": outcome.sandbox_missing, "cleared": outcome.session.sandbox_id.is_none(), @@ -2208,8 +2189,7 @@ impl SessionRuntime { warn!( thread_key = %thread_key, sandbox_id, - workflow_scope_kind = scope_kind, - workflow_scope_id = scope_id, + workflow_run_id, %error, "failed to append workflow sandbox cleanup event" ); @@ -10514,68 +10494,6 @@ mod adoption_tests { })); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn workflow_task_cleanup_covers_sessions_from_multiple_retry_attempts() { - let Some(store) = test_store().await else { - return; - }; - let _serial = TEST_LOCK.lock().await; - let workflow_task_id = format!("task-{}", uuid::Uuid::new_v4()); - let mut expected_sandboxes = Vec::new(); - let mut thread_keys = Vec::new(); - for attempt in ["first", "retry"] { - let thread_key = ThreadKey::parse(format!( - "test:wf-task-cleanup-{attempt}-{}", - uuid::Uuid::new_v4() - )) - .unwrap(); - let sandbox_id = format!("sbx-{attempt}-{}", uuid::Uuid::new_v4()); - store - .create_or_get_session( - &thread_key, - &HarnessType::Codex, - None, - json!({ - "source": "absurd_workflow", - "workflow_task_id": workflow_task_id, - "workflow_run_id": format!("run-{attempt}"), - "workflow_owned_thread": true, - }), - ) - .await - .expect("create attempt session"); - store - .update_sandbox_id(&thread_key, Some(&sandbox_id)) - .await - .expect("assign attempt sandbox"); - expected_sandboxes.push(sandbox_id); - thread_keys.push(thread_key); - } - - let backend = Arc::new(MockBackend::new(SandboxStatus::Running, Vec::new())); - let runtime = runtime_with(&store, backend.clone()); - let report = runtime - .stop_workflow_task_owned_sandboxes(&workflow_task_id, "workflow_cancelled") - .await - .expect("cleanup all task attempts"); - - let mut stopped = report.stopped; - stopped.sort(); - expected_sandboxes.sort(); - assert_eq!(stopped, expected_sandboxes); - for thread_key in thread_keys { - assert_eq!( - store.get_session(&thread_key).await.unwrap().sandbox_id, - None - ); - assert!(events(&store, &thread_key).await.iter().any(|event| { - event.event_type == "session.workflow_sandbox_stopped" - && event.payload["workflow_task_id"] == json!(workflow_task_id) - && event.payload["workflow_run_id"].is_null() - })); - } - } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn workflow_cleanup_preserves_explicit_unowned_thread_key() { let Some(store) = test_store().await else { From 4cc8fbe2e808d19a61a0a8e61080c766f102e9a0 Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:07:55 -0400 Subject: [PATCH 192/198] refactor: drop redundant workflow session cleanup --- .../crates/centaur-session-sqlx/src/lib.rs | 24 --- .../crates/centaur-workflows/src/lib.rs | 195 +----------------- 2 files changed, 11 insertions(+), 208 deletions(-) diff --git a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs index d20be271f..8af6280d0 100644 --- a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs +++ b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs @@ -1453,30 +1453,6 @@ impl PgSessionStore { rows.into_iter().map(TryInto::try_into).collect() } - /// List every implicit session owned by a durable workflow task across all - /// of its attempts. A retry gets a new run id but retains the task id, so - /// cancellation/reaping must use this scope to avoid leaking a suspended - /// session created by a newer attempt. - pub async fn list_workflow_task_owned_sandboxes( - &self, - workflow_task_id: &str, - ) -> Result, SessionStoreError> { - let rows = sqlx::query_as::<_, WorkflowOwnedSandboxRow>( - r#" - select thread_key, sandbox_id - from sessions - where metadata->>'workflow_owned_thread' = 'true' - and metadata->>'workflow_task_id' = $1 - order by thread_key - "#, - ) - .bind(workflow_task_id) - .fetch_all(&self.pool) - .await?; - - rows.into_iter().map(TryInto::try_into).collect() - } - pub async fn update_sandbox_id( &self, thread_key: &ThreadKey, diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index 67fb60a61..51be071b2 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -111,15 +111,6 @@ const LOCAL_WORKFLOW_HOST_DENIED_ENVS: &[&str] = &[ type HmacSha256 = Hmac; -fn workflow_owned_thread_key(run_id: &str, task_id: &str, workflow_name: &str) -> String { - format!( - "wf:{}:{}:agent:{}", - run_id.replace('-', ""), - task_id.replace('-', ""), - workflow_name - ) -} - #[derive(Debug, Deserialize, Serialize)] struct WorkflowTaskTokenClaims { version: u8, @@ -258,7 +249,6 @@ struct WorkflowRuntimeInner { slack_live_client: Client, etl_client: Client, etl_backfill_client: Client, - session_runtime: SessionRuntime, workers: StdMutex>>, metadata_reconciler: StdMutex>>, draining: AtomicBool, @@ -802,7 +792,6 @@ impl WorkflowRuntime { }, webhook_registry.clone(), schedule_registry.clone(), - session_runtime.clone(), interval, ) }); @@ -813,7 +802,6 @@ impl WorkflowRuntime { slack_live_client, etl_client, etl_backfill_client, - session_runtime, workers: StdMutex::new(Some(vec![ worker, slack_live_worker, @@ -997,17 +985,6 @@ impl WorkflowRuntime { ] { if let Some(run) = self.get_run_for_queue(queue_name, run_id).await? { client.cancel_task(&run.task_id, Some(queue_name)).await?; - let cleanup = self - .inner - .session_runtime - .stop_workflow_task_owned_sandboxes(&run.task_id, "workflow_cancelled") - .await?; - if !cleanup.failed.is_empty() { - return Err(WorkflowRuntimeError::Internal(format!( - "workflow {run_id} was cancelled but {} owned sandbox cleanup(s) failed", - cleanup.failed.len() - ))); - } return Ok(()); } } @@ -1925,7 +1902,6 @@ fn spawn_workflow_metadata_reconciler( workflow_clients: WorkflowQueueClients, webhook_registry: Arc>>, schedule_registry: Arc>>, - session_runtime: SessionRuntime, interval: Duration, ) -> JoinHandle<()> { tokio::spawn(async move { @@ -1959,13 +1935,7 @@ fn spawn_workflow_metadata_reconciler( warn!(%error, "failed to record workflow queue metrics"); } if let Err(error) = reaper - .reap( - &workflow_clients, - &schedule_client, - &session_runtime, - &metadata, - &schedules, - ) + .reap(&workflow_clients, &schedule_client, &metadata, &schedules) .await { warn!(%error, "failed to reap removed workflow tasks"); @@ -2193,7 +2163,6 @@ impl RemovedWorkflowReaper { &mut self, workflow_clients: &WorkflowQueueClients, schedule_client: &Client, - session_runtime: &SessionRuntime, metadata: &PythonWorkflowMetadata, schedules: &BTreeMap, ) -> Result<(), WorkflowRuntimeError> { @@ -2214,7 +2183,9 @@ impl RemovedWorkflowReaper { (WORKFLOW_ETL_QUEUE, &workflow_clients.etl), (WORKFLOW_ETL_BACKFILL_QUEUE, &workflow_clients.etl_backfill), ] { - for (task_id, name) in fetch_active_workflow_tasks(client, queue_name).await? { + for (task_id, name) in + fetch_active_named_tasks(client, queue_name, WORKFLOW_TASK, "workflow_name").await? + { active_runs.push((queue_name, task_id, name)); } } @@ -2241,15 +2212,6 @@ impl RemovedWorkflowReaper { if let Err(error) = client.cancel_task(task_id, Some(queue_name)).await { warn!(%error, queue_name, task_id, "failed to cancel run of removed workflow"); } else { - let cleanup = session_runtime - .stop_workflow_task_owned_sandboxes(task_id, "workflow_removed") - .await?; - if !cleanup.failed.is_empty() { - return Err(WorkflowRuntimeError::Internal(format!( - "removed workflow task {task_id} was cancelled but {} owned sandbox cleanup(s) failed", - cleanup.failed.len() - ))); - } info!(queue_name, task_id, "cancelled run of removed workflow"); } } @@ -2290,37 +2252,6 @@ impl RemovedWorkflowReaper { } } -/// Returns `(task_id, workflow_name)` for active workflow tasks. Cancellation -/// and implicit-session cleanup are task scoped so retries cannot race a stale -/// attempt snapshot. -async fn fetch_active_workflow_tasks( - client: &Client, - queue_name: &str, -) -> Result, WorkflowRuntimeError> { - let (task_table, _) = absurd_queue_tables(queue_name)?; - let rows = sqlx::query(&format!( - r#" - select - t.task_id::text as task_id, - t.params->>'workflow_name' as name - from {task_table} t - where t.task_name = $1 - and t.state not in {ABSURD_TERMINAL_TASK_STATES} - "#, - )) - .bind(WORKFLOW_TASK) - .fetch_all(client.pool()) - .await?; - Ok(rows - .into_iter() - .filter_map(|row| { - let task_id: String = row.try_get("task_id").ok()?; - let name: Option = row.try_get("name").ok()?; - Some((task_id, name?)) - }) - .collect()) -} - /// Returns `(task_id, name)` for every non-terminal task in the queue, where /// `name` is extracted from the task params (`workflow_name` for runs, /// `schedule_id` for schedule ticks). Tasks without the field are skipped. @@ -2729,9 +2660,10 @@ async fn run_centaur_workflow_inner( .step("agent_turn", || { let session_runtime = session_runtime.clone(); let harness_type = input.harness_type.clone(); + let thread_key = + format!("wf:{}:agent:agent_turn", ctx.task_id().replace('-', "")); let task_id = ctx.task_id().to_owned(); let run_id = ctx.run_id().to_owned(); - let thread_key = workflow_owned_thread_key(&run_id, &task_id, "agent_turn"); async move { let client_message_id = format!("absurd-workflow:{task_id}:native:user"); let metadata = json!({ @@ -3438,12 +3370,6 @@ async fn handle_python_context_request( Err(error) => Err(error.to_string()), } } - Some("ctx.start_workflow") => { - match start_python_child_workflow(message, ctx, input).await { - Ok(value) => Ok(value), - Err(error) => Err(error.to_string()), - } - } Some("ctx.call_tool") => match call_python_workflow_tool(message).await { Ok(value) => Ok(value), Err(error) => Err(error.to_string()), @@ -3472,99 +3398,6 @@ async fn handle_python_context_request( }) } -async fn start_python_child_workflow( - message: &Value, - ctx: &TaskContext, - parent: &WorkflowTaskInput, -) -> Result { - let workflow_name = message - .get("workflow_name") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - WorkflowRuntimeError::BadRequest("ctx.start_workflow requires workflow_name".to_owned()) - })?; - WorkflowEnablement::from_env()?.ensure_enabled(workflow_name)?; - - let target_queue = queue_name_for_class(workflow_queue_class(workflow_name)); - if target_queue != ctx.queue_name() { - return Err(WorkflowRuntimeError::BadRequest(format!( - "ctx.start_workflow cannot cross queues: parent queue {:?}, target queue {:?}", - ctx.queue_name(), - target_queue - ))); - } - - let harness_type = match message.get("harness_type") { - Some(Value::String(raw)) => HarnessType::from_str(raw).map_err(|_| { - WorkflowRuntimeError::BadRequest(format!( - "ctx.start_workflow has unsupported harness_type {raw:?}" - )) - })?, - Some(Value::Null) | None => parent.harness_type.clone(), - Some(_) => { - return Err(WorkflowRuntimeError::BadRequest( - "ctx.start_workflow harness_type must be a string".to_owned(), - )); - } - }; - let max_attempts = match message.get("max_attempts") { - Some(Value::Number(value)) => { - let value = value.as_i64().ok_or_else(|| { - WorkflowRuntimeError::BadRequest( - "ctx.start_workflow max_attempts must be an integer".to_owned(), - ) - })?; - let value = i32::try_from(value).map_err(|_| { - WorkflowRuntimeError::BadRequest( - "ctx.start_workflow max_attempts is out of range".to_owned(), - ) - })?; - if value < 1 { - return Err(WorkflowRuntimeError::BadRequest( - "ctx.start_workflow max_attempts must be at least 1".to_owned(), - )); - } - Some(value) - } - Some(Value::Null) | None => None, - Some(_) => { - return Err(WorkflowRuntimeError::BadRequest( - "ctx.start_workflow max_attempts must be an integer".to_owned(), - )); - } - }; - let idempotency_key = message - .get("idempotency_key") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); - let spawn = ctx - .spawn_child( - WORKFLOW_TASK, - WorkflowTaskInput { - workflow_name: workflow_name.to_owned(), - input: message.get("input").cloned().unwrap_or_else(|| json!({})), - harness_type, - }, - SpawnOptions { - max_attempts, - idempotency_key, - ..SpawnOptions::default() - }, - ) - .await?; - Ok(json!({ - "ok": true, - "run_id": spawn.run_id, - "task_id": spawn.task_id, - "status": "queued", - "created": spawn.created, - })) -} - fn parse_python_duration_seconds(message: &Value) -> Result { let seconds = message .get("duration_seconds") @@ -3622,7 +3455,11 @@ async fn run_python_agent_turn( .map(ToOwned::to_owned); let workflow_owned_thread = explicit_thread_key.is_none(); let thread_key = explicit_thread_key.unwrap_or_else(|| { - workflow_owned_thread_key(ctx.run_id(), ctx.task_id(), &input.workflow_name) + format!( + "wf:{}:agent:{}", + ctx.task_id().replace('-', ""), + input.workflow_name + ) }); let harness_type = parse_agent_harness(&args)?.unwrap_or_else(|| input.harness_type.clone()); let persona_id = args @@ -4241,16 +4078,6 @@ mod tests { } } - #[test] - fn workflow_owned_thread_keys_are_attempt_scoped() { - let first = workflow_owned_thread_key("run-1111", "task-fixed", "repair"); - let retry = workflow_owned_thread_key("run-2222", "task-fixed", "repair"); - - assert_ne!(first, retry); - assert!(first.contains("run1111:taskfixed")); - assert!(retry.contains("run2222:taskfixed")); - } - #[test] fn parse_worker_concurrency_uses_override_or_default() { // Override wins. From 7ac9e922a8178eb7c3eb74237d31327b4d34f59a Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:07:57 -0400 Subject: [PATCH 193/198] docs: scope runtime access and workflow guidance --- services/sandbox/SYSTEM_PROMPT.md | 7 ++-- services/sandbox/test_system_prompt.py | 12 +++++++ .../workflow-python/api/workflow_engine.py | 22 ------------ .../tests/test_workflow_host.py | 35 ------------------- 4 files changed, 16 insertions(+), 60 deletions(-) diff --git a/services/sandbox/SYSTEM_PROMPT.md b/services/sandbox/SYSTEM_PROMPT.md index 13bcb0915..98bcccad0 100644 --- a/services/sandbox/SYSTEM_PROMPT.md +++ b/services/sandbox/SYSTEM_PROMPT.md @@ -128,9 +128,9 @@ |Prefer one batched lookup round with the most likely sources over broad sequential discovery. If a tool contract is already shown in this prompt, a live skill, or recent ` --help` output, use that contract directly. | |[Observability — logs + execution data] -|You have full access to Centaur's internal observability via tool CLIs such as `vlogs`. -|If a user says a workflow, alert, or channel post never populated, or asks you to check the code for issues, investigate runtime evidence before proposing redesigns or simplifications: read the relevant code paths, check workflow status, and inspect the relevant `vlogs` queries plus any other observability tools first. -|If a user reports an internal tool integration or auth failure, inspect runtime evidence before suggesting secret or permission rewiring: check live tool behavior and `vlogs` evidence to confirm whether secrets resolved and what request failed, then compare the tool's code path with a known-good integration before recommending secret or permission changes. +|Observability access is deployment- and principal-scoped. Do not assume ordinary sandbox principals can use `vlogs`, `vmetrics`, or `centaur-investigator`; confirm availability with `centaur-tools list`. +|If a user says a workflow, alert, or channel post never populated, or asks you to check the code for issues, investigate the runtime evidence available to your principal before proposing redesigns or simplifications: read the relevant code paths, check workflow status, and inspect relevant observability queries when those tools are available. +|If a user reports an internal tool integration or auth failure, check live tool behavior and available runtime evidence before suggesting secret or permission rewiring. If operator-only evidence is required, state that boundary instead of claiming access. | |Logs (VictoriaLogs via `vlogs`): | centaur-tools call vlogs errors '{"start":"1h"}' → errors across all services @@ -210,6 +210,7 @@ |For Slack file uploads from a thread, call the upload tool with the channel ID and thread timestamp, for example `slack upload C123... /path/file --thread 1234567890.123456`; if that upload fails, retry once with `slack upload-direct C123... /path/file --thread 1234567890.123456`. Never call `slack upload U123... ...` for a threaded reply. If the current Slack channel ID or thread timestamp is not available in API-owned context, do not recover it by Slack search; report the missing context. |For Slack file downloads, find the file ID and channel ID via `slack thread`, `slack search`, or `slack search-files `, then run `slack download --output `. Use `slack download-direct --output ` only when `slack download` is unavailable. |If an expected Slack file is not present locally, first inspect the current thread context and Slack file metadata, then recover it with `slack download`. +|To attach a Slack file to a Linear issue, download it to a local path with `slack download`, then call the Linear tool's `upload_file` method with that local path. Do not pass Slack attachment handles or private URLs to Linear. |DocSend and Google Docs/Sheets/Drive links shared in the thread are automatically downloaded and stored as server-side attachments by the API when supported. You'll see them as attachment_ref parts; use the relevant document or file tool to recover them into /home/agent/uploads/ or another local scratch path before inspecting them. |Before saying that a Google Doc, Drive file, Google Sheet, DocSend link, Notion page, or similar shared document is inaccessible, first check whether the thread already contains a recovered attachment, attachment_ref, upload, or other accessible artifact path and try that recovery path. |Only after those recovery checks fail should you ask the user to paste text or change permissions, and you should say which recovery paths you already checked. diff --git a/services/sandbox/test_system_prompt.py b/services/sandbox/test_system_prompt.py index f8b85779a..d166610f1 100644 --- a/services/sandbox/test_system_prompt.py +++ b/services/sandbox/test_system_prompt.py @@ -22,11 +22,23 @@ def test_runtime_discovery_and_vlogs_examples_match_available_surfaces(self) -> self.assertNotIn("[Active deployment]", prompt) self.assertIn("$CENTAUR_HARNESS_TYPE", prompt) + self.assertNotIn("You have full access to Centaur's internal observability", prompt) + self.assertIn("Observability access is deployment- and principal-scoped", prompt) + self.assertIn("Do not assume ordinary sandbox principals can use", prompt) self.assertIn("centaur-tools call vlogs thread_logs", prompt) self.assertIn("centaur-tools call vlogs thread_trace", prompt) self.assertNotIn("| vlogs thread_logs", prompt) self.assertNotIn("| vlogs thread_trace", prompt) + def test_slack_to_linear_attachments_use_a_local_file(self) -> None: + prompt = SYSTEM_PROMPT.read_text() + + self.assertIn("attach a Slack file to a Linear issue", prompt) + self.assertIn("local path with `slack download`", prompt) + self.assertIn("Linear tool's `upload_file` method", prompt) + self.assertNotIn("attachment_id", prompt) + self.assertNotIn("attachment_url", prompt) + def test_model_and_harness_switching_answer_guidance_is_present(self) -> None: prompt = SYSTEM_PROMPT.read_text() diff --git a/services/workflow-python/api/workflow_engine.py b/services/workflow-python/api/workflow_engine.py index c59177e02..389a6e399 100644 --- a/services/workflow-python/api/workflow_engine.py +++ b/services/workflow-python/api/workflow_engine.py @@ -115,28 +115,6 @@ async def start_agent(self, *args: Any, text: str | None = None, **kwargs: Any) async def call_tool(self, tool: str, method: str, args: dict[str, Any] | None = None) -> Any: return await WorkflowToolManager(self._rpc).call_tool_raw(tool, method, args or {}) - async def start_workflow( - self, - workflow_name: str, - input: Any = None, - *, - idempotency_key: str | None = None, - harness_type: str | None = None, - max_attempts: int | None = None, - ) -> Any: - request: dict[str, Any] = { - "type": "ctx.start_workflow", - "workflow_name": workflow_name, - "input": {} if input is None else input, - } - if idempotency_key is not None: - request["idempotency_key"] = idempotency_key - if harness_type is not None: - request["harness_type"] = harness_type - if max_attempts is not None: - request["max_attempts"] = max_attempts - return await self._rpc.request(request) - async def post_to_slack(self, channel: str, text: str, **kwargs: Any) -> Any: return await self._rpc.request( { diff --git a/services/workflow-python/tests/test_workflow_host.py b/services/workflow-python/tests/test_workflow_host.py index 653f222b2..71f26727b 100644 --- a/services/workflow-python/tests/test_workflow_host.py +++ b/services/workflow-python/tests/test_workflow_host.py @@ -59,8 +59,6 @@ async def request(self, payload): } if message_type == "ctx.agent_turn": return payload["args"] - if message_type == "ctx.start_workflow": - return {"ok": True, "run_id": "child-run", "created": True} if message_type == "ctx.sleep": return {"slept": True} raise AssertionError(f"unexpected request {payload}") @@ -164,39 +162,6 @@ def test_run_agent_accepts_positional_step_name_with_text(self) -> None: self.assertEqual(result, {"name": "draft_summary", "text": "summarize this"}) - def test_start_workflow_uses_private_context_rpc(self) -> None: - host = load_workflow_host() - rpc = RequestRpc() - ctx = host.WorkflowContext( - rpc, - run_id="run-123", - task_id="task-456", - workflow_name="parent", - ) - - result = asyncio.run( - ctx.start_workflow( - "child", - {"value": 1}, - idempotency_key="parent:child:1", - max_attempts=2, - ) - ) - - self.assertEqual(result["run_id"], "child-run") - self.assertEqual( - rpc.requests, - [ - { - "type": "ctx.start_workflow", - "workflow_name": "child", - "input": {"value": 1}, - "idempotency_key": "parent:child:1", - "max_attempts": 2, - } - ], - ) - def test_create_pool_retries_transient_connection_failure(self) -> None: host = load_workflow_host() calls = [] From f3a4a4fd67e1633d786f133571babc373848e8e5 Mon Sep 17 00:00:00 2001 From: Akshaan Kakar Date: Mon, 13 Jul 2026 11:23:20 -0700 Subject: [PATCH 194/198] docs: fix company context latest-date guidance (#1049) --- .agents/skills/company-context/SKILL.md | 10 ++++++---- tools/productivity/company_context/cli.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.agents/skills/company-context/SKILL.md b/.agents/skills/company-context/SKILL.md index 6def4cb17..7a2100266 100644 --- a/.agents/skills/company-context/SKILL.md +++ b/.agents/skills/company-context/SKILL.md @@ -42,12 +42,14 @@ Use the source-specific tools that match the question. For broad cross-source qu 4. For time-sensitive or "latest" asks, check index freshness: ```bash -company_context latest-date --json -company_context latest-date --source slack --json -company_context latest-date --source docs --source-type google_doc --json -company_context latest-date --source linear --json +company_context latest-date +company_context latest-date --source slack +company_context latest-date --source docs --source-type google_doc +company_context latest-date --source linear ``` +`latest-date` always outputs JSON, so it does not accept a `--json` flag. + 5. If results are weak, broaden or target source filters: ```bash diff --git a/tools/productivity/company_context/cli.py b/tools/productivity/company_context/cli.py index 38496b71f..db56a8620 100644 --- a/tools/productivity/company_context/cli.py +++ b/tools/productivity/company_context/cli.py @@ -295,7 +295,7 @@ def latest_date( ), source_type: str | None = typer.Option(None, "--source-type", help="Filter by source type."), ) -> None: - """Show the latest indexed timestamp.""" + """Show the latest indexed timestamp as JSON.""" result = CompanyContextClient().latest_date(source=source, source_type=source_type) _require_ok(result) _print_json(result) From 3141cd47eeff05c1846ac7d7ae51356a6ceb67b3 Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:32:31 -0400 Subject: [PATCH 195/198] refactor: remove retired delivery receipt API --- docs/UPSTREAM_SYNC_20260711.md | 27 ++--- packages/api-client/src/client.ts | 28 ----- packages/api-client/src/index.ts | 2 - packages/api-client/test/client.test.ts | 29 ----- .../crates/centaur-api-server/src/lib.rs | 6 - .../crates/centaur-api-server/src/routes.rs | 107 +----------------- .../crates/centaur-api-server/src/types.rs | 15 --- 7 files changed, 13 insertions(+), 201 deletions(-) diff --git a/docs/UPSTREAM_SYNC_20260711.md b/docs/UPSTREAM_SYNC_20260711.md index de68c8215..be935ed1e 100644 --- a/docs/UPSTREAM_SYNC_20260711.md +++ b/docs/UPSTREAM_SYNC_20260711.md @@ -23,7 +23,6 @@ the tested tree exactly, and satisfy the fork's signature policy. | Ambient Slack channels | Configured root messages and replies execute without an explicit mention; messages outside the allowlist remain inert. | | Slack event dedupe | The patched Chat dependency dedupes by actionable bucket so a non-actionable `message` event cannot suppress a later `app_mention`. | | Durable terminal reconciliation | Slack compares streamed markdown with the durable terminal result and replaces divergent output. | -| Durable Slack delivery proof | After Slack confirms a primary, reconciled, fallback, or visible-error message, Slackbot records one idempotent `session.delivery_completed` event through a Slackbot-key-only route bound to the exact thread and execution. | | Generic HTTP secret scopes | Method/path scopes are retained through discovery, permission translation, and iron-control registration. | | Least-privilege Slack ETL token | The reviewed TipLink #68 intent is ported to the current `match_headers` manifest schema: `SLACK_ETL_TOKEN` can replace `Authorization` only for the four ETL Slack Web API paths over `GET`/`POST` and for `GET` downloads from `files.slack.com`. A real-manifest translation test locks the resulting iron-control rules. | | GitHub App installation tokens | The grant is registered in upstream `Broker::CredentialGrants`; the model delegates validation and refresh to that registry. Helm bootstraps the canonical credential before api-rs starts, and the built-in infra role grants a scheme-preserving `GITHUB_TOKEN` replacement for `github.com` and `api.github.com` to sandbox principals. | @@ -68,6 +67,10 @@ the tested tree exactly, and satisfy the fork's signature policy. layered onto the durable upstream pipeline. - Upstream's in-process Slack handoff retry replaces TipLink's older dedupe-key deletion/retry mechanics. +- TipLink's custom `session.delivery_completed` receipt path was used only by + the retired silent-thread trace/capture workflows. Upstream render + obligations, terminal reconciliation, and fallback delivery remain the + active reliability mechanisms. - Upstream stdout-owner leases, adoption, shutdown handoff, sandbox capacity, capability labels, and API routing are authoritative. Session release was redesigned around those ownership fences rather than replaying the old @@ -125,7 +128,7 @@ dropped. (6): `a8adb1f8 9baa30cf 3a886f7f 2dfabef2 f58f1154 6b0c3b09`. - Historical follow-up rather than active-baseline carry (1): `07bd5f08`. Its fallback-post retry was already absent from `ba2c01f5`; restoring it - requires a separate exactly-once delivery/receipt decision and test. + requires a separate exactly-once delivery decision and test. Known one-for-one upstream equivalents include `d6dcdb4d` / `0691b1aa`, `c59a82ae` / `cc0c4c0c`, `f5636a0f` / `f6664689`, `1882c8eb` / @@ -166,23 +169,15 @@ SQLx/Rails checksum manifests and CI guards are included in this branch. does not enter the package/deployment lanes. If a run creates only a subset of final tags, never delete or move them and never retry that head; supersede it with a new signed PR commit and repeat the complete gate. -2. Establish a zero-overlap delivery-writer boundary before the full runtime - sync: disable cloudflared ingress, drain active work, and scale every old - api-rs and Slackbot replica to zero. Do not allow old and receipt-writing - Slackbot replicas to serve concurrently; the silent-trace scanner uses the - earliest durable receipt as its rollout cutoff. -3. Deploy the complete api-rs and Slackbot revision with legacy network-policy +2. Deploy the complete api-rs and Slackbot revision with legacy network-policy access and `overlay.image` compatibility enabled, then restore ingress. -4. Run one controlled Slack turn. Confirm its primary or fallback message is - visible and verify exactly one `session.delivery_completed` event exists for - its `(thread_key, execution_id)` before enabling or accepting silent-trace - scanning. With no receipt, the scanner must report - `delivery_receipt_writer_not_activated` and start no captures. -5. Let workload-key reconciliation retire stale unclaimed warm sandboxes; +3. Run one controlled Slack turn and confirm its primary or fallback message + is visible through the retained render-obligation recovery path. +4. Let workload-key reconciliation retire stale unclaimed warm sandboxes; existing assigned sessions replace their sandbox on their next owned turn, while explicit cancellation still uses canonical release. -6. Verify new ready pods carry capability labels, repo-backed prompts, and the +5. Verify new ready pods carry capability labels, repo-backed prompts, and the expected workload key. -7. Drain all legacy sessions, then disable +6. Drain all legacy sessions, then disable `networkPolicy.legacyManagedByApiServerAccess` and eventually remove the image-overlay values from the Fineas deployment. diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index fa3f73f66..c7d795625 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -49,19 +49,6 @@ export interface ReleaseThreadResponse { execution_cancelled: boolean; } -export interface RecordSessionDeliveryOptions { - messageId?: string; - outcome: string; -} - -export interface RecordSessionDeliveryResponse { - ok: boolean; - created: boolean; - event_id: number; - execution_id: string; - thread_key: string; -} - export class CentaurClient { readonly http: AxiosInstance; @@ -134,21 +121,6 @@ export class CentaurClient { return data as ReleaseThreadResponse; } - async recordSessionDelivery( - threadKey: string, - executionId: string, - opts: RecordSessionDeliveryOptions, - ): Promise { - const { data } = await this.http.post( - `/api/session/${encodeURIComponent(threadKey)}/executions/${encodeURIComponent(executionId)}/delivery`, - { - message_id: opts.messageId, - outcome: opts.outcome, - }, - ); - return data as RecordSessionDeliveryResponse; - } - async sendWorkflowEvent(opts: { eventName: string; payload?: Record; diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index afd49b48b..447ee3559 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,8 +1,6 @@ export { ApiError } from "./types"; export { CentaurClient } from "./client"; export type { - RecordSessionDeliveryOptions, - RecordSessionDeliveryResponse, ReleaseThreadOptions, ReleaseThreadResponse, WorkflowRunOptions, diff --git a/packages/api-client/test/client.test.ts b/packages/api-client/test/client.test.ts index 6903cac10..30bc8cf32 100644 --- a/packages/api-client/test/client.test.ts +++ b/packages/api-client/test/client.test.ts @@ -119,33 +119,4 @@ describe("CentaurClient", () => { }, ); }); - - it("records a Slack delivery receipt against the exact session execution", async () => { - const client = new CentaurClient({ - apiUrl: "http://api.local", - apiKey: "slackbot-key", - }); - const postMock = vi.spyOn(client.http, "post").mockResolvedValue({ - data: { - ok: true, - created: true, - event_id: 42, - execution_id: "exec:123", - thread_key: "slack:T:C:1.2", - }, - }); - - await client.recordSessionDelivery("slack:T:C:1.2", "exec:123", { - messageId: "1780000000.000100", - outcome: "fallback", - }); - - expect(postMock).toHaveBeenCalledWith( - "/api/session/slack%3AT%3AC%3A1.2/executions/exec%3A123/delivery", - { - message_id: "1780000000.000100", - outcome: "fallback", - }, - ); - }); }); diff --git a/services/api-rs/crates/centaur-api-server/src/lib.rs b/services/api-rs/crates/centaur-api-server/src/lib.rs index 9d0eb71a6..52d8cebe8 100644 --- a/services/api-rs/crates/centaur-api-server/src/lib.rs +++ b/services/api-rs/crates/centaur-api-server/src/lib.rs @@ -239,12 +239,6 @@ mod tests { .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"input_lines":[]}"#)) .unwrap(), - Request::builder() - .method(Method::POST) - .uri("/api/session/slack%3AC123%3A123.456/executions/exe-1/delivery") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"outcome":"primary"}"#)) - .unwrap(), Request::builder() .method(Method::GET) .uri("/api/session/slack%3AC123%3A123.456/events") diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index 98604d8bc..47f5b98f2 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -62,9 +62,8 @@ use crate::{ AppendMessagesRequest, AppendMessagesResponse, CreateSessionRequest, CreateSessionResponse, EmitWorkflowEventRequest, EventsQuery, ExecuteSessionRequest, ExecuteSessionResponse, InterruptSessionExecutionRequest, InterruptSessionExecutionResponse, ListWorkflowRunsQuery, - OnHarnessConflict, RecordSessionDeliveryRequest, RecordSessionDeliveryResponse, - ReleaseThreadRequest, ReleaseThreadResponse, SessionContextResponse, SessionSseEvent, - SlackThreadContext, stream_error_sse, + OnHarnessConflict, ReleaseThreadRequest, ReleaseThreadResponse, SessionContextResponse, + SessionSseEvent, SlackThreadContext, stream_error_sse, }, }; @@ -245,10 +244,6 @@ pub fn build_router_with_app_state(state: AppState) -> Router { "/api/session/{thread_key}/interrupt", post(interrupt_session_execution), ) - .route( - "/api/session/{thread_key}/executions/{execution_id}/delivery", - post(record_session_delivery), - ) .route("/api/session/{thread_key}/release", post(release_thread)) .route("/api/session/{thread_key}/events", get(stream_events)) .route("/api/sandboxes/drain", post(drain_sandboxes)) @@ -620,31 +615,6 @@ async fn interrupt_session_execution( })) } -async fn record_session_delivery( - State(state): State, - _authorization: SlackDeliveryAuthorization, - Path((raw_thread_key, execution_id)): Path<(String, String)>, - Json(request): Json, -) -> Result, ApiError> { - let thread_key = ThreadKey::try_from(raw_thread_key)?; - let outcome = state - .runtime()? - .record_slack_delivery( - &thread_key, - &execution_id, - request.message_id.as_deref(), - &request.outcome, - ) - .await?; - Ok(Json(RecordSessionDeliveryResponse { - ok: true, - created: outcome.created, - event_id: outcome.event.event_id, - execution_id, - thread_key, - })) -} - async fn release_thread( State(state): State, SessionApiAuthorization(authorization): SessionApiAuthorization, @@ -2232,9 +2202,6 @@ impl FromRequestParts for WorkflowApiAuthorization { #[derive(Debug)] struct SessionApiAuthorization(WorkflowApiAuthorization); -#[derive(Debug)] -struct SlackDeliveryAuthorization; - impl WorkflowApiClaims { fn allows_channel(&self, channel_id: &str) -> bool { self.slack @@ -2351,38 +2318,6 @@ impl FromRequestParts for SessionApiAuthorization { } } -fn authorize_slack_delivery_headers( - headers: &HeaderMap, - configured_key: Option<&str>, -) -> Result<(), ApiError> { - // Parse authentication first so an anonymous request is always rejected - // as unauthorized, even when the service itself is misconfigured. - let presented = bearer_token(headers)?; - let expected = configured_key - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| ApiError::Internal("SLACKBOT_API_KEY is not configured".to_owned()))?; - if constant_time_eq(presented.as_bytes(), expected.as_bytes()) { - return Ok(()); - } - Err(ApiError::Unauthorized( - "invalid Slack delivery service token".to_owned(), - )) -} - -impl FromRequestParts for SlackDeliveryAuthorization { - type Rejection = ApiError; - - async fn from_request_parts( - parts: &mut Parts, - _state: &AppState, - ) -> Result { - let key = env::var("SLACKBOT_API_KEY").ok(); - authorize_slack_delivery_headers(&parts.headers, key.as_deref())?; - Ok(Self) - } -} - fn ensure_session_create_authorized( authorization: &WorkflowApiAuthorization, thread_key: &ThreadKey, @@ -3764,44 +3699,6 @@ mod workflow_api_tests { assert!(!claims_owns_session(&claims, Some("prn_other"))); assert!(!claims_owns_session(&claims, None)); } - - #[test] - fn slack_delivery_receipts_accept_only_the_dedicated_slackbot_bearer() { - let mut headers = HeaderMap::new(); - headers.insert("authorization", "Bearer slackbot-secret".parse().unwrap()); - authorize_slack_delivery_headers(&headers, Some("slackbot-secret")).unwrap(); - - for forged in [ - "Bearer control-secret", - "Bearer workflow-secret", - "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwcm5fZm9yZ2VkIn0.signature", - ] { - headers.insert("authorization", forged.parse().unwrap()); - assert!(matches!( - authorize_slack_delivery_headers(&headers, Some("slackbot-secret")), - Err(ApiError::Unauthorized(_)) - )); - } - - headers.remove("authorization"); - headers.insert("x-api-key", "slackbot-secret".parse().unwrap()); - assert!(matches!( - authorize_slack_delivery_headers(&headers, Some("slackbot-secret")), - Err(ApiError::Unauthorized(_)) - )); - - headers.remove("x-api-key"); - assert!(matches!( - authorize_slack_delivery_headers(&headers, None), - Err(ApiError::Unauthorized(_)) - )); - - headers.insert("authorization", "Bearer slackbot-secret".parse().unwrap()); - assert!(matches!( - authorize_slack_delivery_headers(&headers, None), - Err(ApiError::Internal(_)) - )); - } } #[cfg(test)] diff --git a/services/api-rs/crates/centaur-api-server/src/types.rs b/services/api-rs/crates/centaur-api-server/src/types.rs index 8a1216070..9f995403f 100644 --- a/services/api-rs/crates/centaur-api-server/src/types.rs +++ b/services/api-rs/crates/centaur-api-server/src/types.rs @@ -114,21 +114,6 @@ pub struct InterruptSessionExecutionResponse { pub thread_key: ThreadKey, } -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct RecordSessionDeliveryRequest { - pub message_id: Option, - pub outcome: String, -} - -#[derive(Clone, Debug, Serialize)] -pub struct RecordSessionDeliveryResponse { - pub ok: bool, - pub created: bool, - pub event_id: i64, - pub execution_id: String, - pub thread_key: ThreadKey, -} - #[derive(Clone, Debug, Deserialize)] pub struct EventsQuery { pub after_event_id: Option, From b1b366fb811c6ca5cbc82aaba1a00c98e3394bed Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:32:34 -0400 Subject: [PATCH 196/198] refactor: remove retired delivery receipt runtime --- .../crates/centaur-session-runtime/src/lib.rs | 84 +------------------ 1 file changed, 2 insertions(+), 82 deletions(-) diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 93f46db8e..88b656bdc 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -26,8 +26,8 @@ use centaur_session_core::{ SessionMessageInput, ThreadKey, }; use centaur_session_sqlx::{ - PgSessionStore, RecordExecutionDeliveryResult, ReleaseSessionResult, SandboxCapacityCandidate, - SessionEventListener, SessionStoreError, WorkflowOwnedSandbox, default_metadata, + PgSessionStore, ReleaseSessionResult, SandboxCapacityCandidate, SessionEventListener, + SessionStoreError, WorkflowOwnedSandbox, default_metadata, }; use centaur_telemetry::{ export_thread_trace_root_span, record_sandbox_warm_pool_claim, @@ -57,18 +57,6 @@ use title_generator::{ pub const SESSION_OUTPUT_LINE_EVENT: &str = "session.output.line"; pub const SESSION_FIRST_TOKEN_EVENT: &str = "session.first_token"; -pub const SESSION_DELIVERY_COMPLETED_EVENT: &str = "session.delivery_completed"; - -fn is_slack_message_id(value: &str) -> bool { - let Some((seconds, fraction)) = value.split_once('.') else { - return false; - }; - (10..=20).contains(&seconds.len()) - && fraction.len() == 6 - && seconds.bytes().all(|byte| byte.is_ascii_digit()) - && fraction.bytes().all(|byte| byte.is_ascii_digit()) -} - const EVENT_STREAM_SAFETY_POLL_INTERVAL: Duration = Duration::from_secs(30); const STEERING_STARTUP_RETRY_INTERVAL: Duration = Duration::from_millis(250); const STEERING_STARTUP_RETRY_TIMEOUT: Duration = Duration::from_secs(15); @@ -1774,66 +1762,6 @@ impl SessionRuntime { Ok(message_ids) } - /// Record proof that Slack confirmed a final answer was visible for this - /// exact execution. Replays return the original event without inserting a - /// duplicate receipt. - pub async fn record_slack_delivery( - &self, - thread_key: &ThreadKey, - execution_id: &str, - message_id: Option<&str>, - outcome: &str, - ) -> Result { - if !thread_key.as_str().starts_with("slack:") { - return Err(SessionRuntimeError::BadRequest( - "Slack delivery receipts require a Slack thread key".to_owned(), - )); - } - let execution_id = execution_id.trim(); - if execution_id.is_empty() || execution_id.len() > 128 { - return Err(SessionRuntimeError::BadRequest( - "execution_id must be between 1 and 128 characters".to_owned(), - )); - } - let outcome = outcome.trim(); - if outcome.is_empty() - || outcome.len() > 64 - || !outcome - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') - { - return Err(SessionRuntimeError::BadRequest( - "delivery outcome must contain only lowercase letters, digits, and underscores" - .to_owned(), - )); - } - let message_id = message_id.map(str::trim).filter(|value| !value.is_empty()); - if message_id.is_some_and(|value| !is_slack_message_id(value)) { - return Err(SessionRuntimeError::BadRequest( - "delivery message_id is invalid".to_owned(), - )); - } - - self.store - .record_execution_delivery( - thread_key, - execution_id, - SESSION_DELIVERY_COMPLETED_EVENT, - json!({ - "thread_key": thread_key.as_str(), - "execution_id": execution_id, - "message_id": message_id, - "outcome": outcome, - }), - ) - .await? - .ok_or_else(|| { - SessionRuntimeError::BadRequest( - "execution does not belong to the requested thread".to_owned(), - ) - }) - } - fn spawn_session_title_generation(&self, thread_key: &ThreadKey) { let Some(generator) = self.session_title_generator.clone() else { return; @@ -7268,14 +7196,6 @@ mod tests { use serde_json::json; use time::OffsetDateTime; - #[test] - fn slack_delivery_message_ids_accept_only_slack_timestamps() { - assert!(is_slack_message_id("1783737926.397459")); - assert!(!is_slack_message_id("secret answer content")); - assert!(!is_slack_message_id("1783737926")); - assert!(!is_slack_message_id("1783737926.39745x")); - } - #[test] fn sandbox_repo_cache_label_controls_access() { assert_eq!( From 0c2e52faf3e285ba38e9408fa6b7198d65d1fa22 Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:32:36 -0400 Subject: [PATCH 197/198] refactor: remove retired delivery receipt storage and writer --- .../crates/centaur-session-sqlx/src/lib.rs | 189 ------------------ services/slackbotv2/src/index.ts | 148 ++------------ services/slackbotv2/src/session-api.ts | 40 ---- services/slackbotv2/src/types.ts | 13 -- 4 files changed, 15 insertions(+), 375 deletions(-) diff --git a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs index 8af6280d0..04ac1f97f 100644 --- a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs +++ b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs @@ -38,14 +38,6 @@ pub struct ClaimExecutionResult { pub claimed: bool, } -#[derive(Clone, Debug)] -pub struct RecordExecutionDeliveryResult { - pub event: SessionEvent, - /// True only when this call inserted the durable receipt. Replays return - /// the original event with `created = false`. - pub created: bool, -} - /// An active execution whose stdout-owner lease was released by /// [`PgSessionStore::release_stdout_owned_executions`]. #[derive(Clone, Debug)] @@ -1091,90 +1083,6 @@ impl PgSessionStore { row.try_into() } - /// Persist one Slack delivery receipt for an exact session execution. - /// Locking the execution row serializes duplicate callbacks without a new - /// schema constraint and proves the execution belongs to `thread_key`. - pub async fn record_execution_delivery( - &self, - thread_key: &ThreadKey, - execution_id: &str, - event_type: &str, - payload: Value, - ) -> Result, SessionStoreError> { - let mut tx = self.pool.begin().await?; - // Match the canonical release transaction's session -> execution lock - // order. The event insert takes a session FK lock, so locking the - // execution first could deadlock with a concurrent release. - let session_exists = sqlx::query_scalar::<_, i32>( - "select 1 from sessions where thread_key = $1 for key share", - ) - .bind(thread_key.as_str()) - .fetch_optional(&mut *tx) - .await? - .is_some(); - if !session_exists { - tx.commit().await?; - return Ok(None); - } - let execution_exists = sqlx::query_scalar::<_, i32>( - r#" - select 1 - from session_executions - where thread_key = $1 and execution_id = $2 - for update - "#, - ) - .bind(thread_key.as_str()) - .bind(execution_id) - .fetch_optional(&mut *tx) - .await? - .is_some(); - if !execution_exists { - tx.commit().await?; - return Ok(None); - } - - if let Some(row) = sqlx::query_as::<_, SessionEventRow>( - r#" - select event_id, thread_key, execution_id, event_type, payload, created_at - from session_events - where execution_id = $1 and event_type = $2 - order by event_id - limit 1 - "#, - ) - .bind(execution_id) - .bind(event_type) - .fetch_optional(&mut *tx) - .await? - { - tx.commit().await?; - return Ok(Some(RecordExecutionDeliveryResult { - event: row.try_into()?, - created: false, - })); - } - - let row = sqlx::query_as::<_, SessionEventRow>( - r#" - insert into session_events (thread_key, execution_id, event_type, payload) - values ($1, $2, $3, $4) - returning event_id, thread_key, execution_id, event_type, payload, created_at - "#, - ) - .bind(thread_key.as_str()) - .bind(execution_id) - .bind(event_type) - .bind(payload) - .fetch_one(&mut *tx) - .await?; - tx.commit().await?; - Ok(Some(RecordExecutionDeliveryResult { - event: row.try_into()?, - created: true, - })) - } - pub async fn append_event_if_stdout_owner( &self, thread_key: &ThreadKey, @@ -2520,103 +2428,6 @@ mod tests { ); } - #[tokio::test] - async fn execution_delivery_receipt_is_thread_bound_and_idempotent() { - let Some(store) = test_store().await else { - return; - }; - let thread_key = ThreadKey::parse(format!("slack:CDELIVERY:{}", Uuid::new_v4())).unwrap(); - let other_thread = ThreadKey::parse(format!("slack:COTHER:{}", Uuid::new_v4())).unwrap(); - for thread in [&thread_key, &other_thread] { - store - .create_or_get_session(thread, &HarnessType::Codex, None, json!({})) - .await - .expect("create session"); - } - let execution_id = store - .create_execution(&thread_key, None, json!({})) - .await - .expect("create execution") - .execution - .execution_id; - - assert!( - store - .record_execution_delivery( - &other_thread, - &execution_id, - "session.delivery_completed", - json!({"outcome": "forged"}), - ) - .await - .expect("cross-thread record") - .is_none() - ); - - let first = store - .record_execution_delivery( - &thread_key, - &execution_id, - "session.delivery_completed", - json!({"outcome": "primary", "message_id": "1780000000.000100"}), - ) - .await - .expect("first receipt") - .expect("bound execution"); - assert!(first.created); - - let replay = store - .record_execution_delivery( - &thread_key, - &execution_id, - "session.delivery_completed", - json!({"outcome": "fallback", "message_id": "different"}), - ) - .await - .expect("replayed receipt") - .expect("bound execution"); - assert!(!replay.created); - assert_eq!(replay.event.event_id, first.event.event_id); - assert_eq!(replay.event.payload, first.event.payload); - - let count = sqlx::query_scalar::<_, i64>( - "select count(*) from session_events where execution_id = $1 and event_type = 'session.delivery_completed'", - ) - .bind(&execution_id) - .fetch_one(store.pool()) - .await - .expect("count receipts"); - assert_eq!(count, 1); - store - .complete_execution(&execution_id) - .await - .expect("complete first execution before concurrency case"); - - let concurrent_execution_id = store - .create_execution(&thread_key, None, json!({})) - .await - .expect("create concurrent execution") - .execution - .execution_id; - let left = store.record_execution_delivery( - &thread_key, - &concurrent_execution_id, - "session.delivery_completed", - json!({"outcome": "primary", "message_id": "1780000001.000100"}), - ); - let right = store.record_execution_delivery( - &thread_key, - &concurrent_execution_id, - "session.delivery_completed", - json!({"outcome": "primary", "message_id": "1780000001.000100"}), - ); - let (left, right) = tokio::join!(left, right); - let left = left.expect("left receipt").expect("left execution"); - let right = right.expect("right receipt").expect("right execution"); - assert_ne!(left.created, right.created); - assert_eq!(left.event.event_id, right.event.event_id); - } - #[tokio::test] async fn principal_bound_session_rejects_cross_principal_restart_before_mutation() { let Some(store) = test_store().await else { diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 77a08b129..26b05a053 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -34,7 +34,6 @@ import { interruptSessionExecution, isRetryableSessionApiError, openSessionEventStream, - recordSessionDelivery, serializeAttachment, serializeMessageLinks, serializeMessage, @@ -86,8 +85,6 @@ export type { SlackbotV2ExecuteSessionResponse, SlackbotV2Fetch, SlackbotV2Options, - SlackbotV2RecordDeliveryRequest, - SlackbotV2RecordDeliveryResponse, SlackbotV2SessionMessage, SlackbotV2SessionMessageRole } from './types' @@ -123,7 +120,6 @@ const RENDER_RECOVERY_THREAD_TIMEOUT_MS = 2 * 60 * 1000 const RENDER_RECOVERY_MAX_THREAD_FAILURES = 5 const RENDER_RETRY_INITIAL_DELAY_MS = 250 const RENDER_RETRY_MAX_DELAY_MS = 5_000 -const DELIVERY_RECEIPT_MAX_ATTEMPTS = 4 const ASSISTANT_STATUS_MAX_CHARS = 50 const SLACK_TASK_DETAILS_MAX_CHARS = 500 const SLACK_FALLBACK_TEXT_MAX_CHARS = 35_000 @@ -1055,7 +1051,7 @@ async function syncThreadMessageToSession( }) } try { - const errorNotice = await renderExecutionStream( + await renderExecutionStream( thread, streamError(error), serializedMessage, @@ -1063,15 +1059,6 @@ async function syncThreadMessageToSession( trace, assistantStatusVisible ) - if (errorNotice.delivered) { - await persistConfirmedDelivery( - input.options, - forwardInput, - 'forward_error_notice', - errorNotice.messageId, - trace - ) - } } catch (renderError) { // The error notice is best-effort; a Slack render failure here must not // mask the original forward failure. @@ -1130,52 +1117,6 @@ function scheduleExecutionRender( backgroundWaitUntil(promise) } -async function persistConfirmedDelivery( - options: SlackbotV2Options, - input: Pick, - outcome: string, - messageId: string | undefined, - trace?: SlackbotV2Trace -): Promise { - const executionId = input.executionId - if (!executionId) { - traceWarn(options, 'slackbotv2_delivery_receipt_missing_execution', trace, { outcome }) - return - } - for (let attempt = 0; attempt < DELIVERY_RECEIPT_MAX_ATTEMPTS; attempt += 1) { - try { - const receipt = await recordSessionDelivery(options, input.threadId, executionId, { - ...(messageId ? { message_id: messageId } : {}), - outcome - }) - traceLog(options, 'slackbotv2_delivery_receipt_recorded', trace, { - created: receipt.created, - event_id: receipt.event_id, - execution_id: executionId, - message_id: messageId, - outcome - }) - return - } catch (error) { - const retryable = isRetryableSessionApiError(error) - const exhausted = attempt + 1 >= DELIVERY_RECEIPT_MAX_ATTEMPTS - if (!retryable || exhausted) { - // Delivery already succeeded. Never turn a receipt failure into a - // rerender that could duplicate the user's Slack answer. - traceWarn(options, 'slackbotv2_delivery_receipt_failed', trace, { - attempt: attempt + 1, - error: errorMessage(error), - execution_id: executionId, - outcome, - retryable - }) - return - } - await sleep(renderRetryDelayMs(attempt)) - } - } -} - function setMessageText(message: SlackbotV2ApiMessage, text: string): void { const displayText = renderSlackDisplayText({ raw: message.raw, text }) message.text = text @@ -1221,7 +1162,6 @@ async function renderExecutionAttempt( rendered = true outcome = 'complete' let divergenceReconciled = false - let deliveryMessageId = streamResult.messageId if (streamResult.diverged && streamResult.messageId) { // The live answer stream diverged from the recomposed answer, so the delta // stream was frozen at the last clean prefix to avoid interleaving. Swap @@ -1243,22 +1183,12 @@ async function renderExecutionAttempt( if (reconciled) { divergenceReconciled = true fallbackLastEventId = reconciled.lastEventId - deliveryMessageId = reconciled.messageId ?? deliveryMessageId } } traceLog(options, 'slackbotv2_render_complete', trace, { answer_diverged: streamResult.diverged, divergence_reconciled: divergenceReconciled }) - if (streamResult.delivered) { - await persistConfirmedDelivery( - options, - input, - divergenceReconciled ? 'primary_reconciled' : 'primary', - deliveryMessageId, - trace - ) - } return 'complete' } catch (error) { // Check the Slack adapter's delivery annotation before retryability: @@ -1296,13 +1226,6 @@ async function renderExecutionAttempt( }, 'warn' ) - await persistConfirmedDelivery( - options, - input, - 'answer_visible', - slackStreamMessageId(error), - trace - ) return 'complete' } traceLog( @@ -1351,7 +1274,6 @@ async function renderExecutionAttempt( rendered = true outcome = 'fallback' fallbackLastEventId = fallback.lastEventId - await persistConfirmedDelivery(options, input, 'fallback', fallback.messageId, trace) return 'complete' } throw error @@ -1424,7 +1346,7 @@ async function renderFallbackFinalAnswer( source: { afterEventId: number; executionId?: string; threadId: string }, trace?: SlackbotV2Trace, replacement?: { replaceMessageId: string } -): Promise<{ lastEventId: number; messageId?: string } | null> { +): Promise<{ lastEventId: number } | null> { const startedAtMs = nowMs() let outcome = 'error' let lastEventId = source.afterEventId @@ -1472,13 +1394,10 @@ async function renderFallbackFinalAnswer( } const text = fallback.textOrDefault() const fallbackText = truncateSlackText(text, SLACK_FALLBACK_TEXT_MAX_CHARS, 'Slack final answer') - let messageId: string | undefined if (replacement) { await thread.adapter.editMessage(thread.id, replacement.replaceMessageId, fallbackText) - messageId = replacement.replaceMessageId } else { - const sent = await thread.post(fallbackText) - messageId = sent?.id + await thread.post(fallbackText) } traceLog(options, 'slackbotv2_render_fallback_complete', trace, { chars: text.length, @@ -1487,7 +1406,7 @@ async function renderFallbackFinalAnswer( phase_ms: elapsedMs(startedAtMs) }) outcome = 'complete' - return { lastEventId, messageId } + return { lastEventId } } catch (error) { outcome = 'error' traceLog( @@ -1796,22 +1715,13 @@ async function recoverRenderObligation( recordRenderAttempt('recovery', renderOutcome, renderStartedAtMs) return true } - const errorNotice = await renderRecoveredExecutionStream( + await renderRecoveredExecutionStream( thread, streamError(error), obligation.message, options, trace ) - if (errorNotice.delivered) { - await persistConfirmedDelivery( - options, - input, - 'recovery_stream_error', - errorNotice.messageId, - trace - ) - } await thread.setState({ activeExecution: false, lastEventId, @@ -1838,7 +1748,6 @@ async function recoverRenderObligation( rendered = true renderOutcome = 'complete' let divergenceReconciled = false - let deliveryMessageId = streamResult.messageId if (streamResult.diverged && streamResult.messageId) { // Same divergence reconcile as the live path: the answer stream was // frozen at the last clean prefix, so swap the streamed message for the @@ -1857,22 +1766,12 @@ async function recoverRenderObligation( if (reconciled) { divergenceReconciled = true lastEventId = Math.max(lastEventId, reconciled.lastEventId) - deliveryMessageId = reconciled.messageId ?? deliveryMessageId } } traceLog(options, 'slackbotv2_render_recovery_complete', trace, { answer_diverged: streamResult.diverged, divergence_reconciled: divergenceReconciled }) - if (streamResult.delivered) { - await persistConfirmedDelivery( - options, - input, - divergenceReconciled ? 'recovery_primary_reconciled' : 'recovery_primary', - deliveryMessageId, - trace - ) - } } catch (error) { const answerLost = slackAnswerLost(error) if (answerLost === false) { @@ -1883,13 +1782,6 @@ async function recoverRenderObligation( traceLog(options, 'slackbotv2_render_recovery_failed_answer_visible', trace, { error: errorMessage(error) }) - await persistConfirmedDelivery( - options, - input, - 'recovery_answer_visible', - slackStreamMessageId(error), - trace - ) } else { traceLog( options, @@ -1937,13 +1829,6 @@ async function recoverRenderObligation( rendered = true renderOutcome = 'fallback' lastEventId = Math.max(lastEventId, fallback.lastEventId) - await persistConfirmedDelivery( - options, - input, - 'recovery_fallback', - fallback.messageId, - trace - ) } } finally { const latest = (await thread.state) ?? {} @@ -2092,10 +1977,10 @@ async function renderExecutionStream( trace?: SlackbotV2Trace, assistantStatusVisible = false, consoleSessionBlock?: SlackContextBlock -): Promise<{ delivered: boolean; diverged: boolean; messageId?: string }> { +): Promise<{ diverged: boolean; messageId?: string }> { const promptText = slackMessagePromptText(message) if (isPlainTextOnlyRequest(promptText)) { - const messageId = await renderPlainTextExecutionStream( + await renderPlainTextExecutionStream( thread, stream, message, @@ -2103,7 +1988,7 @@ async function renderExecutionStream( trace, assistantStatusVisible ) - return { delivered: true, diverged: false, messageId } + return { diverged: false } } const titleStartedAtMs = nowMs() await setAssistantTitle(thread, titleFromMessage(promptText, options.userName), options, trace) @@ -2133,7 +2018,7 @@ async function renderExecutionStream( ) ) ) - if (!visibleStream) return { delivered: false, diverged: false } + if (!visibleStream) return { diverged: false } // Stream via the adapter (as renderRecoveredExecutionStream does) so the // posted message id is available for divergence reconciliation. For Slack // this matches thread.post(StreamingPlan): updateIntervalMs is a no-op @@ -2149,7 +2034,6 @@ async function renderExecutionStream( ...(consoleSessionBlock ? { stopBlocks: [consoleSessionBlock] } : {}) }) return { - delivered: true, diverged: capture.diverged || fallback.terminalResultMismatch(), messageId: sent?.id } @@ -2164,11 +2048,11 @@ async function renderRecoveredExecutionStream( message: SlackbotV2ApiMessage, options: SlackbotV2Options, trace?: SlackbotV2Trace -): Promise<{ delivered: boolean; diverged: boolean; messageId?: string }> { +): Promise<{ diverged: boolean; messageId?: string }> { const promptText = slackMessagePromptText(message) if (isPlainTextOnlyRequest(promptText)) { - const messageId = await renderPlainTextExecutionStream(thread, stream, message, options, trace) - return { delivered: true, diverged: false, messageId } + await renderPlainTextExecutionStream(thread, stream, message, options, trace) + return { diverged: false } } const titleStartedAtMs = nowMs() await setAssistantTitle(thread, titleFromMessage(promptText, options.userName), options, trace) @@ -2195,7 +2079,7 @@ async function renderRecoveredExecutionStream( ) ) ) - if (!visibleStream) return { delivered: false, diverged: false } + if (!visibleStream) return { diverged: false } const sent = await thread.adapter.stream!( thread.id, visibleStream, @@ -2206,7 +2090,6 @@ async function renderRecoveredExecutionStream( } ) return { - delivered: true, diverged: capture.diverged || fallback.terminalResultMismatch(), messageId: sent?.id } @@ -2222,7 +2105,7 @@ async function renderPlainTextExecutionStream( options: SlackbotV2Options, trace?: SlackbotV2Trace, assistantStatusVisible = false -): Promise { +): Promise { const fallback = new SlackRenderFallback() const titleStartedAtMs = nowMs() await setAssistantTitle( @@ -2258,8 +2141,7 @@ async function renderPlainTextExecutionStream( traceLog(options, 'slackbotv2_render_plain_text_final', trace, { chars: text.length }) - const sent = await thread.post(text) - return sent?.id + await thread.post(text) } finally { await setAssistantStatus(thread, '', options, trace) } diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index 696b91ff1..4fede84fb 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -15,8 +15,6 @@ import type { SlackbotV2Fetch, SlackbotV2InterruptSessionResponse, SlackbotV2Options, - SlackbotV2RecordDeliveryRequest, - SlackbotV2RecordDeliveryResponse, SlackbotV2RendererSource, SlackbotV2SessionMessage } from './types' @@ -573,35 +571,6 @@ export async function interruptSessionExecution( ) } -export async function recordSessionDelivery( - options: SlackbotV2Options, - threadId: string, - executionId: string, - receipt: SlackbotV2RecordDeliveryRequest -): Promise { - return recordSessionApiOperation( - 'record_delivery', - async () => { - const fetchFn = options.fetch ?? fetch - const response = await fetchWithTimeout( - fetchFn, - apiSessionDeliveryUrl(options.apiUrl, threadId, executionId), - { - method: 'POST', - headers: apiHeaders(options), - body: JSON.stringify(receipt) - }, - sessionApiTimeoutMs(options), - 'record session delivery' - ) - await ensureApiOk(response, 'record session delivery') - return (await response.json()) as SlackbotV2RecordDeliveryResponse - }, - sessionApiTimeoutMs(options), - 'record session delivery' - ) -} - const RESTART_CONTEXT_MAX_CHARS = 24_000 /** @@ -1350,15 +1319,6 @@ function apiSessionUrl( return new URL(path, ensureTrailingSlash(apiUrl)).toString() } -function apiSessionDeliveryUrl( - apiUrl: string, - threadId: string, - executionId: string -): string { - const path = `/api/session/${encodeURIComponent(threadId)}/executions/${encodeURIComponent(executionId)}/delivery` - return new URL(path, ensureTrailingSlash(apiUrl)).toString() -} - function ensureTrailingSlash(value: string): string { return value.endsWith('/') ? value : `${value}/` } diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index b908aa9f1..505fb0b29 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -98,19 +98,6 @@ export type SlackbotV2InterruptSessionResponse = { thread_key: string } -export type SlackbotV2RecordDeliveryRequest = { - message_id?: string - outcome: string -} - -export type SlackbotV2RecordDeliveryResponse = { - ok: boolean - created: boolean - event_id: number - execution_id: string - thread_key: string -} - export type SlackbotV2Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise export type SlackbotV2Options = { From 0fd1735da56d820a4ef12ef525f745f5136fabb1 Mon Sep 17 00:00:00 2001 From: "fineas-bot[bot]" <258147136+fineas-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:32:39 -0400 Subject: [PATCH 198/198] test: remove retired delivery receipt fixtures --- .../slackbotv2/test/chat-sdk-emulate.test.ts | 78 +------------------ services/slackbotv2/test/session-api.test.ts | 33 -------- 2 files changed, 1 insertion(+), 110 deletions(-) diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index b5b440224..9ed22dcb5 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -19,7 +19,6 @@ import { type SlackbotV2ApiMessage, type SlackbotV2CreateSessionRequest, type SlackbotV2ExecuteSessionRequest, - type SlackbotV2RecordDeliveryRequest, type SlackbotV2SessionMessage } from '../src/index' import { clearRequesterIdentityCacheForTests } from '../src/session-api' @@ -456,16 +455,6 @@ describe('slackbotv2', () => { threadKey(parent.ts) ]) expect(codexApi.executes).toHaveLength(2) - expect(codexApi.deliveries).toHaveLength(2) - expect(codexApi.deliveries.map(delivery => delivery.body.outcome)).toEqual([ - 'primary', - 'primary' - ]) - expect(codexApi.deliveries.map(delivery => delivery.executionId)).toEqual([ - 'exe-1', - 'exe-3' - ]) - expect(codexApi.deliveries.every(delivery => Boolean(delivery.body.message_id))).toBe(true) const firstAppend = codexApi.appends[0]! expect(firstAppend.threadKey).toBe(threadKey(parent.ts)) @@ -1869,14 +1858,6 @@ describe('slackbotv2', () => { expect(renderedText).toContain( 'Execution failed: Reconnecting... 2/5: unexpected status 502 Bad Gateway' ) - expect(codexApi.deliveries).toHaveLength(1) - expect(codexApi.deliveries[0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ outcome: 'primary' }), - executionId: 'exe-1', - threadKey: threadKey(parent.ts) - }) - ) }) it('renders successful completions with no final answer as visible Slack text', async () => { @@ -2165,14 +2146,6 @@ describe('slackbotv2', () => { expect(texts.filter(text => text.includes('TOO_LONG_FALLBACK_VISIBLE') )).toHaveLength(1) - expect(codexApi.deliveries).toHaveLength(1) - expect(codexApi.deliveries[0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ outcome: 'fallback' }), - executionId: 'exe-1', - threadKey: key - }) - ) const threadState = await sharedState.get>(`thread-state:${key}`) expect(threadState).toEqual( expect.objectContaining({ @@ -2405,8 +2378,6 @@ describe('slackbotv2', () => { text.includes(durableFinalAnswer) ) expect(visibleFinalReplies).toHaveLength(1) - expect(codexApi.deliveries).toHaveLength(1) - expect(codexApi.deliveries[0]?.body.outcome).toBe('fallback') const threadState = await sharedState.get>(`thread-state:${key}`) expect(threadState).toEqual( expect.objectContaining({ @@ -3899,17 +3870,9 @@ describe('slackbotv2', () => { }) ) expect(Number(recoveredThreadState?.lastEventId)).toBeGreaterThan(0) - expect(codexApi.deliveries).toHaveLength(1) - expect(codexApi.deliveries[0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ outcome: 'recovery_primary' }), - executionId: 'exe-recovery', - threadKey: key - }) - ) }) - it('records a receipt after a visible non-retryable recovery error', async () => { + it('renders a visible non-retryable recovery error', async () => { const sharedState = createMemoryState() await sharedState.connect() @@ -3946,14 +3909,6 @@ describe('slackbotv2', () => { }, 2000) expect(await threadText(parent.ts)).toContain('Execution failed') - expect(codexApi.deliveries).toHaveLength(1) - expect(codexApi.deliveries[0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ outcome: 'recovery_stream_error' }), - executionId: 'exe-recovery-error', - threadKey: key - }) - ) }) it('skips stale render obligations from Chat SDK state on startup', async () => { @@ -5037,17 +4992,12 @@ type MockSessionEvent = { threadKey: string } -type MockSessionDelivery = MockSessionRequest & { - executionId: string -} - type MockSessionApi = { appends: MockSessionRequest[] autoRespond: boolean close(): Promise closeStreams(): void creates: MockSessionRequest[] - deliveries: MockSessionDelivery[] emitOutputLine(threadKey: string, line: string, executionId?: string): void emitOutputLines(threadKey: string, lines: string[], executionId?: string): void emitSessionEvent(threadKey: string, event: string, data: unknown, executionId?: string): void @@ -5066,7 +5016,6 @@ type MockSessionApi = { async function startMockCodexApi(): Promise { const appends: MockSessionRequest[] = [] const creates: MockSessionRequest[] = [] - const deliveries: MockSessionDelivery[] = [] const eventRequests: MockSessionEventRequest[] = [] const events: MockSessionEvent[] = [] const executes: MockSessionRequest[] = [] @@ -5088,7 +5037,6 @@ async function startMockCodexApi(): Promise { void handleMockCodexRequest(req, res, { appends, creates, - deliveries, events, eventRequests, executes, @@ -5139,13 +5087,11 @@ async function startMockCodexApi(): Promise { const api: MockSessionApi = { appends, creates, - deliveries, eventRequests, executes, reset() { appends.length = 0 creates.length = 0 - deliveries.length = 0 eventRequests.length = 0 events.length = 0 executes.length = 0 @@ -5247,7 +5193,6 @@ async function handleMockCodexRequest( appends: MockSessionRequest[] autoRespond: boolean creates: MockSessionRequest[] - deliveries: MockSessionDelivery[] events: MockSessionEvent[] eventRequests: MockSessionEventRequest[] executeHold: Promise | null @@ -5267,27 +5212,6 @@ async function handleMockCodexRequest( } ): Promise { const url = new URL(req.url ?? '/', `http://127.0.0.1:${input.port}`) - const deliveryMatch = /^\/api\/session\/([^/]+)\/executions\/([^/]+)\/delivery$/.exec( - url.pathname - ) - if (deliveryMatch?.[1] && deliveryMatch[2]) { - const threadKey = decodeURIComponent(deliveryMatch[1]) - const executionId = decodeURIComponent(deliveryMatch[2]) - const request = await nodeRequestToWebRequest(req, url) - const body = (await request.json()) as SlackbotV2RecordDeliveryRequest - input.deliveries.push({ body, executionId, threadKey }) - await sendWebResponse( - res, - Response.json({ - created: input.deliveries.filter(delivery => delivery.executionId === executionId).length === 1, - event_id: 10_000 + input.deliveries.length, - execution_id: executionId, - ok: true, - thread_key: threadKey - }) - ) - return - } const match = /^\/api\/session\/([^/]+)(?:\/(messages|execute|events))?$/.exec(url.pathname) if (!match?.[1]) { await sendWebResponse(res, new Response('not found', { status: 404 })) diff --git a/services/slackbotv2/test/session-api.test.ts b/services/slackbotv2/test/session-api.test.ts index 979a4f994..769a1283b 100644 --- a/services/slackbotv2/test/session-api.test.ts +++ b/services/slackbotv2/test/session-api.test.ts @@ -7,7 +7,6 @@ import { harnessRestartPreamble, interruptSessionExecution, openSessionEventStream, - recordSessionDelivery, serializeAttachment, serializeMessage } from '../src/session-api' @@ -93,15 +92,6 @@ function fakeApi(responses: { createSession?: Array<{ body?: unknown; status: nu thread_key: 'slack:C1:1700000000.000100' }) } - if (url.endsWith('/delivery')) { - return Response.json({ - created: true, - event_id: 42, - execution_id: 'exec:1', - ok: true, - thread_key: 'slack:C1:1700000000.000100' - }) - } if (!url.endsWith('/messages') && createResponses.length > 0) { const next = createResponses.shift()! return Response.json(next.body ?? { ok: next.status < 400 }, { status: next.status }) @@ -266,29 +256,6 @@ describe('session interruption', () => { }) }) -describe('session delivery receipts', () => { - test('posts only receipt metadata to the exact encoded execution endpoint', async () => { - const { fetchFn, requests } = fakeApi() - - const response = await recordSessionDelivery( - options(fetchFn), - 'slack:C1:1700000000.000100', - 'exec:1', - { message_id: '1700000001.000200', outcome: 'fallback' } - ) - - expect(response).toMatchObject({ created: true, event_id: 42 }) - const delivery = requests.find(request => request.url.endsWith('/delivery')) - expect(delivery?.url).toBe( - 'http://api.test/api/session/slack%3AC1%3A1700000000.000100/executions/exec%3A1/delivery' - ) - expect(delivery?.body).toEqual({ - message_id: '1700000001.000200', - outcome: 'fallback' - }) - }) -}) - describe('Slack display text fallback', () => { test('serializeMessage extracts raw Slack blocks when adapter text is empty', async () => { const raw = {