Skip to content

Commit 9a408e2

Browse files
Merge pull request #137 from CodeGraphContext/fix/slack-finite-timings
fix(slack): reject non-finite timing settings and approval waits
2 parents 1498c43 + b0a4b92 commit 9a408e2

5 files changed

Lines changed: 76 additions & 9 deletions

File tree

‎docs/cookbook/07-slack.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ Configuration is environment-only, read once at startup:
116116
| `GRAPHARC_SLACK_LIVE_INTERVAL` | `2.5` | seconds between two edits of the status message |
117117
| `GRAPHARC_SLACK_LIVE_URL` | unset | base URL of a `grapharc serve --live-root` the requester can reach; posts a "watch live" link |
118118

119+
The timeout and live-interval environment values must be finite and positive;
120+
fractions of a second are accepted. `NaN`, infinity and overflowing values
121+
such as `1e309` are startup errors naming the variable. A requester-supplied
122+
`--approval-timeout` must also be finite and positive and fit within the
123+
command's existing timeout ceiling.
124+
119125
The bot reads tokens from the process environment only. The model gateway's
120126
`.env` loader is deliberately not used here — even though it now reads the
121127
working directory alone rather than searching upward: a bot that a whole

‎docs/deep-dive.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ A stable system is not one that claims to have no edges — it is one whose edge
254254
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
255255
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.
256256

257-
**Verified this pass:** `pytest` → green, 2,252 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
257+
**Verified this pass:** `pytest` → green, 2,267 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
258258

259259
[ROADMAP.md](../ROADMAP.md) tracks what is built and what is not, item by item.
260260

‎grapharc/slack/command.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from __future__ import annotations
4141

4242
import importlib
43+
import math
4344
import shlex
4445
import tomllib
4546
import uuid
@@ -530,10 +531,10 @@ def parse_command(
530531
raise SlackCommandError(
531532
f"`--approval-timeout` wants a number of seconds, got {supplied!r}"
532533
) from None
533-
if asked <= 0 or asked > ceiling:
534+
if not math.isfinite(asked) or asked <= 0 or asked > ceiling:
534535
raise SlackCommandError(
535536
f"`--approval-timeout {supplied}` does not fit this command's "
536-
f"budget: the wait must be between 1 and {ceiling:.0f} seconds, "
537+
f"budget: the wait must be finite, positive and at most {ceiling:.0f} seconds, "
537538
"so that a run nobody answers ends by reporting a timeout "
538539
"rather than by being killed mid-wait"
539540
)

‎grapharc/slack/config.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from __future__ import annotations
1313

14+
import math
1415
import os
1516
from dataclasses import dataclass, field
1617
from pathlib import Path
@@ -86,8 +87,8 @@ def from_env(cls, environ: dict[str, str] | None = None) -> SlackBotConfig:
8687
raise SlackConfigError(
8788
f"GRAPHARC_SLACK_TIMEOUT must be a number of seconds, got {raw_timeout!r}"
8889
) from None
89-
if timeout <= 0:
90-
raise SlackConfigError("GRAPHARC_SLACK_TIMEOUT must be positive")
90+
if not math.isfinite(timeout) or timeout <= 0:
91+
raise SlackConfigError("GRAPHARC_SLACK_TIMEOUT must be positive and finite")
9192

9293
raw_work_timeout = env.get("GRAPHARC_SLACK_WORK_TIMEOUT", "1800")
9394
try:
@@ -97,8 +98,8 @@ def from_env(cls, environ: dict[str, str] | None = None) -> SlackBotConfig:
9798
"GRAPHARC_SLACK_WORK_TIMEOUT must be a number of seconds, "
9899
f"got {raw_work_timeout!r}"
99100
) from None
100-
if work_timeout <= 0:
101-
raise SlackConfigError("GRAPHARC_SLACK_WORK_TIMEOUT must be positive")
101+
if not math.isfinite(work_timeout) or work_timeout <= 0:
102+
raise SlackConfigError("GRAPHARC_SLACK_WORK_TIMEOUT must be positive and finite")
102103
# A work budget under the reader budget is almost certainly a typo, and
103104
# the failure it produces is confusing: `plan --go` would be killed
104105
# sooner than `metrics`. Take the larger rather than obeying literally.
@@ -112,8 +113,8 @@ def from_env(cls, environ: dict[str, str] | None = None) -> SlackBotConfig:
112113
"GRAPHARC_SLACK_LIVE_INTERVAL must be a number of seconds, "
113114
f"got {raw_interval!r}"
114115
) from None
115-
if live_interval <= 0:
116-
raise SlackConfigError("GRAPHARC_SLACK_LIVE_INTERVAL must be positive")
116+
if not math.isfinite(live_interval) or live_interval <= 0:
117+
raise SlackConfigError("GRAPHARC_SLACK_LIVE_INTERVAL must be positive and finite")
117118

118119
live_url_base = env.get("GRAPHARC_SLACK_LIVE_URL", "").rstrip("/") or None
119120
if live_url_base is not None and not live_url_base.startswith(("http://", "https://")):

‎tests/test_slack_finite_timings.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Non-finite time settings must not reach Slack's timers or command runner."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from grapharc.slack.command import SlackCommandError, parse_command
8+
from grapharc.slack.config import SlackBotConfig, SlackConfigError
9+
10+
11+
@pytest.mark.parametrize("value", ["nan", "inf", "-inf", "1e309"])
12+
@pytest.mark.parametrize(
13+
"key", ["GRAPHARC_SLACK_TIMEOUT", "GRAPHARC_SLACK_WORK_TIMEOUT", "GRAPHARC_SLACK_LIVE_INTERVAL"]
14+
)
15+
def test_environment_timing_values_must_be_finite(tmp_path, key, value):
16+
env = {
17+
"SLACK_BOT_TOKEN": "test-bot",
18+
"SLACK_APP_TOKEN": "test-app",
19+
"GRAPHARC_SLACK_WORKDIR": str(tmp_path),
20+
key: value,
21+
}
22+
23+
with pytest.raises(SlackConfigError, match=key):
24+
SlackBotConfig.from_env(env)
25+
26+
27+
@pytest.mark.parametrize("form", ["--approval-timeout nan", "--approval-timeout=NaN"])
28+
def test_a_nan_approval_wait_is_refused(tmp_path, form):
29+
with pytest.raises(SlackCommandError, match="does not fit this command's budget"):
30+
parse_command(
31+
f"plan goal --scripted --go {form}",
32+
workdir=tmp_path,
33+
timeout_seconds=60,
34+
work_timeout_seconds=180,
35+
)
36+
37+
38+
def test_finite_fractional_timings_remain_configurable(tmp_path):
39+
config = SlackBotConfig.from_env(
40+
{
41+
"SLACK_BOT_TOKEN": "test-bot",
42+
"SLACK_APP_TOKEN": "test-app",
43+
"GRAPHARC_SLACK_WORKDIR": str(tmp_path),
44+
"GRAPHARC_SLACK_TIMEOUT": "12.5",
45+
"GRAPHARC_SLACK_WORK_TIMEOUT": "25.5",
46+
"GRAPHARC_SLACK_LIVE_INTERVAL": "0.25",
47+
}
48+
)
49+
50+
assert config.timeout_seconds == 12.5
51+
assert config.work_timeout_seconds == 25.5
52+
assert config.live_interval_seconds == 0.25
53+
argv = parse_command(
54+
"plan goal --scripted --approval-timeout 1.5",
55+
workdir=tmp_path,
56+
timeout_seconds=config.timeout_seconds,
57+
work_timeout_seconds=config.work_timeout_seconds,
58+
)
59+
assert argv[argv.index("--approval-timeout") + 1] == "1.5"

0 commit comments

Comments
 (0)