Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 48 additions & 19 deletions config/constants/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,22 +98,13 @@ def host_home() -> Path:
return OPENSRE_HOME_DIR


def opensre_home() -> Path:
"""The organization's context root, or the host home when unbound.

The mount named by ``OPENSRE_CONTEXT_ROOT`` belongs to one organization, so
it is used only for a turn that names that organization. Anything unbound —
boot-time integration reads, the CLI — stays on the host root rather than
writing into a customer's volume. Chat transports bind an org scope and
therefore use the mount (or ``orgs/<id>/`` nest) when configured.
def _org_root(org_id: str) -> Path:
"""Root owned by one organization: the mount when this deployment owns it.

Without the mount, a bound org principal nests under
``~/.opensre/orgs/<org_id>/`` so several organizations can be exercised on
one machine.
The single implementation of the owner check, shared by :func:`opensre_home`
and :func:`deployment_home`. A second copy beside it would be a second
tenancy policy to keep in step.
"""
scope = current_scope()
if scope is None or scope.principal.kind != "org":
return OPENSRE_HOME_DIR
mounted_root = os.getenv(CONTEXT_ROOT_ENV, "").strip()
if mounted_root:
# The mount is chrooted to exactly one org. If this deployment declares
Expand All @@ -126,15 +117,52 @@ def opensre_home() -> Path:
f"{CONTEXT_ROOT_ENV} is set but no organization is configured for this "
"deployment; refusing to write a customer's data to an unidentified volume"
)
if scope.principal.id != silo_owner:
if org_id != silo_owner:
raise ContextRootOwnerMismatchError(
f"context root belongs to {silo_owner!r} but this turn is owned by "
f"{scope.principal.id!r}; refusing to cross organizations on a shared mount"
f"{org_id!r}; refusing to cross organizations on a shared mount"
)
return Path(mounted_root).expanduser()
_warn_unmounted_org_once(scope.principal.id)
org_id = _safe_segment(scope.principal.id, label="principal id")
return OPENSRE_HOME_DIR / ORGS_DIR_NAME / org_id
_warn_unmounted_org_once(org_id)
return OPENSRE_HOME_DIR / ORGS_DIR_NAME / _safe_segment(org_id, label="principal id")


def opensre_home() -> Path:
"""The organization's context root, or the host home when unbound.

The mount named by ``OPENSRE_CONTEXT_ROOT`` belongs to one organization, so
it is used only for a turn that names that organization. Anything unbound —
boot-time integration reads, the CLI — stays on the host root rather than
writing into a customer's volume. Chat transports bind an org scope and
therefore use the mount (or ``orgs/<id>/`` nest) when configured.

Without the mount, a bound org principal nests under
``~/.opensre/orgs/<org_id>/`` so several organizations can be exercised on
one machine.
"""
scope = current_scope()
if scope is None or scope.principal.kind != "org":
return OPENSRE_HOME_DIR
return _org_root(scope.principal.id)


def deployment_home() -> Path:
"""The organization this deployment serves, whether or not a scope is bound.

For an artifact two surfaces must share. A background investigation is
started in the shell, which binds no scope, and retrieved from a chat
transport, which binds the organization, so :func:`opensre_home` would put
them on different files. An unbound caller therefore resolves to the
configured organization; a machine naming none stays on the host root.

:func:`_org_root`'s owner check still applies, so this is not a way around
the mount's tenancy guarantee.
"""
scope = current_scope()
if scope is not None and scope.principal.kind == "org":
return _org_root(scope.principal.id)
configured = organization_id()
return _org_root(configured) if configured else OPENSRE_HOME_DIR


def session_home() -> Path:
Expand Down Expand Up @@ -206,6 +234,7 @@ def ensure_opensre_tmp_dir() -> Path:
"SYNTHETIC_SCENARIOS_DIR",
"ContextRootOwnerMismatchError",
"UnsafePathSegmentError",
"deployment_home",
"ensure_opensre_tmp_dir",
"get_memory_dir",
"get_store_path",
Expand Down
49 changes: 33 additions & 16 deletions docs/background-investigations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,33 @@ When background mode is enabled:

- new investigations run asynchronously
- the shell stays free for more questions and follow-ups
- completed RCAs are tracked in-session
- completed RCAs are kept, and can be looked up later from the shell or from chat
- completion notifications can be sent to email (via the [`smtp`](/smtp) integration), Telegram (via the [`telegram`](/messaging/telegram) integration), Rocket.Chat (via the [`rocketchat`](/messaging/rocketchat) integration), Buzz (via the [`buzz`](/messaging/buzz) integration), or any combination

<Note>
This first version is **session-local only**. If the REPL process exits,
in-flight background jobs stop with it.
In-flight jobs stop if the shell exits. Completed RCAs are kept, and you can look
them up later from the shell or from a chat channel.
</Note>

---

## Commands

| Command | What it does |
| --- | --- |
| `/background on` | Enable async investigation launches |
| `/background off` | Return to normal foreground execution |
| `/background status` | Show background mode, tracked job count, and active notify channels |
| `/background list` | List tracked jobs |
| `/background show <task_id>` | Show the RCA summary and the per-channel notification result for one job |
| `/background use <task_id>` | Promote a completed job into the active follow-up context |
| `/background notify list` | Show the channels a completed RCA is delivered to (default: **none**) |
| `/background notify set <channel[,channel...]>` | Set completion channels — `email`, `telegram`, `rocketchat`, `buzz`, or a comma-separated combination |
| Command | What it does | Chat |
| --- | --- | --- |
| `/background on` | Enable async investigation launches | shell only |
| `/background off` | Return to normal foreground execution | shell only |
| `/background status` | Show background mode, tracked job count, and active notify channels | yes |
| `/background list` | List tracked jobs | yes |
| `/background show <task_id>` | Show the RCA summary and the per-channel notification result for one job | yes |
| `/background use <task_id>` | Promote a completed job into the active follow-up context | shell only |
| `/background notify list` | Show the channels a completed RCA is delivered to (default: **none**) | yes |
| `/background notify set <channel[,channel...]>` | Set completion channels — `email`, `telegram`, `rocketchat`, `buzz`, or a comma-separated combination | shell only |

Commands marked **yes** work when you message the bot on Telegram, Slack or
Discord, so you can look up a finished RCA without going back to your terminal.
The completion notification carries the task id you need for
`/background show`.

---

Expand All @@ -52,6 +57,8 @@ in-flight background jobs stop with it.
/background notify list # confirm
```

Channels are remembered, so you only set them once.

4. Start an investigation:

```text
Expand Down Expand Up @@ -108,9 +115,19 @@ or delivery errors, the RCA still completes, stays in `/background list`, and ca

---

## Current v1 limits
## Current limits

- interactive shell only — there is no `opensre background …` CLI command
- jobs are not persisted across REPL restarts
- investigations can only be **started** from the interactive shell; chat can look
them up but not launch one, and there is no `opensre background …` CLI command
- in-flight jobs stop if the shell exits; only completed RCAs are kept
- `/background use` needs the shell, because the full investigation state it
promotes is not part of what gets kept
- completion does not automatically replace your active follow-up context
- you must run `/background use <task_id>` to promote a finished RCA

## Who can see a completed RCA

Completed RCAs belong to your organization, not to you personally: anyone who can
message the bot for the same organization can list them and read them. Keep that
in mind if an investigation touches something you would not post in a team
channel.
20 changes: 20 additions & 0 deletions gateway/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@
ensure_project_platform_package()


@pytest.fixture(autouse=True)
def _isolate_opensre_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Keep every gateway test off the developer's real ``~/.opensre``.

Patch the attribute, not ``OPENSRE_HOME``: that env var is read once at
import, while the root helpers re-read the attribute per call.

Disabling the keyring is not optional here. Redirecting the home makes
credential lookup miss ``integrations.json`` and fall through to the OS
keychain, which blocks on a GUI prompt, so the pair must move together the
way ``tests/conftest.py`` keeps them.
"""
from config.constants import paths
from config.secrets.os_keyring import reset_keyring_state

reset_keyring_state()
monkeypatch.setenv("OPENSRE_DISABLE_KEYRING", "1")
monkeypatch.setattr(paths, "OPENSRE_HOME_DIR", tmp_path / "opensre-home")


@pytest.fixture(autouse=True)
def _isolate_gateway_runtime_files(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Keep every gateway test off the developer's real ``~/.opensre/gateway``.
Expand Down
100 changes: 100 additions & 0 deletions gateway/tests/runtime/test_slash_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,30 @@ def test_gateway_background_write_forms_report_repl_only() -> None:
assert "uv run opensre" in sink.finalized


def test_gateway_background_use_is_refused_before_the_record_lookup() -> None:
task_id = _seed_record(task_id="bg-use-chat")

sink = _run_gateway_slash(f"/background use {task_id}")

assert sink.finalized is not None
assert "uv run opensre" in sink.finalized
assert "unknown background task" not in sink.finalized


def test_gateway_background_show_reaches_a_record_past_the_listing_bound() -> None:
# Oldest, then enough newer rows to push it out of any recent-N listing while
# staying inside the store's own bound, so only a full read finds it.
buried = _seed_record(task_id="bg-buried", root_cause="the oldest cause")
for index in range(60):
_seed_record(task_id=f"bg-bulk-{index:03d}", root_cause=f"cause {index}")

sink = _run_gateway_slash(f"/background show {buried}")

assert sink.finalized is not None
assert "the oldest cause" in sink.finalized
assert "unknown background task" not in sink.finalized


def test_gateway_onboard_slash_returns_headless_guidance(monkeypatch: pytest.MonkeyPatch) -> None:
"""Literal /onboard on SessionCore must not spawn a blocking interactive wizard."""
recorded: list[list[str]] = []
Expand Down Expand Up @@ -233,3 +257,79 @@ def _step() -> None:
assert "adapters" in calls
assert "runners" not in calls
reset_process_runtime_for_tests()


# Rich draws tables with these; nothing on the chat path converts them, so they
# reach Telegram as literal characters inside an 80-column hard-wrapped block.
_BOX_DRAWING = set("─│┃━╭╮╰╯┏┓┗┛┿┼┤├┬┴┌┐└┘╡╞═")


def _seed_record(**overrides: Any) -> str:
from platform.background_investigations.store import background_investigation_store
from platform.background_investigations.types import BackgroundInvestigationRecord

fields: dict[str, Any] = {
"task_id": "bg-chat-1",
"status": "completed",
"command": "/investigate checkout-latency",
"root_cause": "connection pool saturation on the payments replica",
"top_analysis": ("rds cpu spike at 14:02",),
"next_steps": ("raise the pool ceiling to 64",),
}
fields.update(overrides)
record = BackgroundInvestigationRecord(**fields)
background_investigation_store().save(record)
return record.task_id


def test_gateway_background_show_returns_the_rca_as_plain_text() -> None:
"""A completed RCA is retrievable from a chat transport.

Asserting the absence of box-drawing is the load-bearing half. The Rich table
renders fine into the captured console and would pass a content-only
assertion while arriving in Telegram as an 80-column hard-wrapped grid of
━ and │ that no sink converts.
"""
task_id = _seed_record()

sink = _run_gateway_slash(f"/background show {task_id}")

assert sink.finalized is not None
assert "connection pool saturation" in sink.finalized
assert "raise the pool ceiling" in sink.finalized
assert not _BOX_DRAWING & set(sink.finalized), sink.finalized


def test_gateway_background_show_does_not_leak_delivery_exception_detail() -> None:
task_id = _seed_record(
task_id="bg-chat-redact",
notification_results={
"email": "failed: SMTPRecipientsRefused",
"telegram": "sent",
"buzz": "missing buzz integration: Buzz is not configured.",
},
)

sink = _run_gateway_slash(f"/background show {task_id}")

assert sink.finalized is not None
assert "email:failed" in sink.finalized
assert "SMTPRecipientsRefused" not in sink.finalized
assert "telegram:sent" in sink.finalized
assert "missing buzz integration: Buzz is not configured." in sink.finalized


def test_gateway_background_list_is_plain_and_bounded() -> None:
"""One line per record, and the root cause is trimmed. An unbounded list of
twenty folded RCAs overruns the 4096-character message cap, and the transports
tail-truncate, so the closing hint would be the first thing lost."""
for index in range(3):
_seed_record(task_id=f"bg-many-{index}", root_cause="saturation " * 60)

sink = _run_gateway_slash("/background list")

assert sink.finalized is not None
assert "bg-many-2" in sink.finalized
assert not _BOX_DRAWING & set(sink.finalized), sink.finalized
assert len(sink.finalized) < 4096
assert "/background show" in sink.finalized
Loading