stacktrace-cli 0.5.3__py3-none-any.whl → 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/analysis.py +33 -31
- stacktrace_cli/cloud/__init__.py +10 -0
- stacktrace_cli/cloud/setup.sh +17 -4
- stacktrace_cli/correlate/observed.py +62 -6
- stacktrace_cli/daemon/cli.py +93 -2
- stacktrace_cli/daemon/config.py +85 -5
- stacktrace_cli/daemon/jobs.py +48 -1
- stacktrace_cli/daemon/observer.py +31 -2
- stacktrace_cli/daemon/presentation.py +28 -0
- stacktrace_cli/daemon/runtime.py +25 -11
- stacktrace_cli/detector/deterministic.py +1859 -20
- stacktrace_cli/detector/render.py +1 -4
- stacktrace_cli/detector/rules.py +27 -4
- stacktrace_cli/detector/run.py +9 -1
- stacktrace_cli/monitor/reasoning.py +8 -6
- stacktrace_cli/monitor/server.py +4 -16
- stacktrace_cli/monitor/site/app.js +18 -291
- stacktrace_cli/monitor/site/index.html +0 -12
- stacktrace_cli/monitor/site/styles.css +0 -35
- stacktrace_cli/monitor/watch.py +139 -65
- stacktrace_cli/remote/identity.py +41 -12
- {stacktrace_cli-0.5.3.dist-info → stacktrace_cli-0.6.0.dist-info}/METADATA +3 -3
- {stacktrace_cli-0.5.3.dist-info → stacktrace_cli-0.6.0.dist-info}/RECORD +26 -26
- {stacktrace_cli-0.5.3.dist-info → stacktrace_cli-0.6.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.5.3.dist-info → stacktrace_cli-0.6.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
stacktrace_cli/analysis.py
CHANGED
|
@@ -348,32 +348,6 @@ def _acquire(
|
|
|
348
348
|
)
|
|
349
349
|
|
|
350
350
|
|
|
351
|
-
def _correlated(
|
|
352
|
-
*,
|
|
353
|
-
agent_kinds: tuple[str, ...],
|
|
354
|
-
window_start: datetime,
|
|
355
|
-
bom_paths: tuple[Path, ...],
|
|
356
|
-
project_map: tuple[str, ...],
|
|
357
|
-
root: Path | None,
|
|
358
|
-
session_ids: tuple[str, ...],
|
|
359
|
-
attach_advisories: Any,
|
|
360
|
-
build_all: Any = None,
|
|
361
|
-
) -> Acquired:
|
|
362
|
-
"""Everything up to the judging, shared by both entry points."""
|
|
363
|
-
return _acquire(
|
|
364
|
-
_collected(
|
|
365
|
-
agent_kinds=agent_kinds,
|
|
366
|
-
window_start=window_start,
|
|
367
|
-
root=root,
|
|
368
|
-
session_ids=session_ids,
|
|
369
|
-
),
|
|
370
|
-
bom_paths=bom_paths,
|
|
371
|
-
project_map=project_map,
|
|
372
|
-
attach_advisories=attach_advisories,
|
|
373
|
-
build_all=build_all,
|
|
374
|
-
)
|
|
375
|
-
|
|
376
|
-
|
|
377
351
|
def _reaches(detection: Any, window_start: datetime, window_end: datetime) -> bool:
|
|
378
352
|
"""Whether this finding's own span overlaps the window.
|
|
379
353
|
|
|
@@ -482,6 +456,7 @@ def analyse_progressively(
|
|
|
482
456
|
history: DriftHistory | None = None,
|
|
483
457
|
reasoning: bool = False,
|
|
484
458
|
budget: int = DEFAULT_BUDGET,
|
|
459
|
+
exclude_from_reasoning: frozenset[tuple[str, str]] = frozenset(),
|
|
485
460
|
) -> Iterator[Analysis]:
|
|
486
461
|
"""The same pipeline, delivered in instalments.
|
|
487
462
|
|
|
@@ -634,12 +609,20 @@ def analyse_progressively(
|
|
|
634
609
|
if not reasoning or not ordered:
|
|
635
610
|
return
|
|
636
611
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
612
|
+
excluded_count = 0
|
|
613
|
+
if exclude_from_reasoning:
|
|
614
|
+
kept = tuple(
|
|
615
|
+
s
|
|
616
|
+
for s in view.sessions
|
|
617
|
+
if (s.session.session_id, s.session.agent_kind) not in exclude_from_reasoning
|
|
618
|
+
)
|
|
619
|
+
excluded_count = len(view.sessions) - len(kept)
|
|
620
|
+
reasoning_view = replace(view, sessions=kept) if excluded_count else view
|
|
621
|
+
else:
|
|
622
|
+
reasoning_view = view
|
|
623
|
+
|
|
641
624
|
judged = run_detector(
|
|
642
|
-
|
|
625
|
+
reasoning_view,
|
|
643
626
|
reasoning=True,
|
|
644
627
|
budget=budget,
|
|
645
628
|
cache=cache,
|
|
@@ -647,4 +630,23 @@ def analyse_progressively(
|
|
|
647
630
|
jev_key=jev_key,
|
|
648
631
|
history=history,
|
|
649
632
|
)
|
|
633
|
+
|
|
634
|
+
if excluded_count:
|
|
635
|
+
extra_detections = tuple(
|
|
636
|
+
d
|
|
637
|
+
for d in accumulated.detections
|
|
638
|
+
if (d.session.session_id, d.session.agent_kind) in exclude_from_reasoning
|
|
639
|
+
)
|
|
640
|
+
extra_unknowns = tuple(
|
|
641
|
+
u
|
|
642
|
+
for u in accumulated.unknowns
|
|
643
|
+
if (u.session.session_id, u.session.agent_kind) in exclude_from_reasoning
|
|
644
|
+
)
|
|
645
|
+
judged = replace(
|
|
646
|
+
judged,
|
|
647
|
+
detections=judged.detections + extra_detections,
|
|
648
|
+
unknowns=judged.unknowns + extra_unknowns,
|
|
649
|
+
sessions=judged.sessions + excluded_count,
|
|
650
|
+
)
|
|
651
|
+
|
|
650
652
|
yield _stage(judged, tuple(ordered))
|
stacktrace_cli/cloud/__init__.py
CHANGED
|
@@ -12,6 +12,16 @@ CLI and runs this:
|
|
|
12
12
|
uv tool install stacktrace-cli
|
|
13
13
|
stacktrace-cloud-setup
|
|
14
14
|
|
|
15
|
+
Or, equivalently, one hosted line that wraps exactly those two steps (see
|
|
16
|
+
`stacktrace-site`'s `public/remote-install.sh`). Download before running rather
|
|
17
|
+
than piping directly into `sh`: without `pipefail` (the default in the `sh`
|
|
18
|
+
that a setup-script box runs), a failed `curl` still lets `sh` read an empty
|
|
19
|
+
script and exit 0, so a broken download would otherwise snapshot an
|
|
20
|
+
environment with neither the CLI nor the boot hook and report success:
|
|
21
|
+
|
|
22
|
+
curl -fsSL https://stacktrace.ai/remote-install.sh -o /tmp/remote-install.sh \
|
|
23
|
+
&& sh /tmp/remote-install.sh
|
|
24
|
+
|
|
15
25
|
Both variables matter: setup runs as root, and `uv tool install`'s root
|
|
16
26
|
defaults put the tool's environment under `/root/.local/share/uv/tools` and the
|
|
17
27
|
linked executable under `/root/.local/bin` — both inside `/root`, which a later
|
stacktrace_cli/cloud/setup.sh
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
# every home so it fires whichever user the session runs as.
|
|
11
11
|
# 2. The asset identity, in every home's ~/.config/stacktrace/asset-id, so
|
|
12
12
|
# `stacktrace remote sync` resolves it at the persisted rung (ADR-0065)
|
|
13
|
-
# instead of minting a fresh asset per session.
|
|
13
|
+
# instead of minting a fresh asset per session. Beside it,
|
|
14
|
+
# asset-display-prefix makes the fleet label `cloud (<id>)` rather than
|
|
15
|
+
# the image's hostname (`vm`), which is the same on every box. Label
|
|
16
|
+
# only: the key above is what identifies the asset.
|
|
14
17
|
#
|
|
15
18
|
# The telemetry install id is deliberately NOT seeded here. Seeding it would
|
|
16
19
|
# make `read_install_id()` non-None before any event runs, so the one-time
|
|
@@ -59,7 +62,7 @@ while [ $# -gt 0 ]; do
|
|
|
59
62
|
# the loop spins forever — a malformed invocation would hang setup.
|
|
60
63
|
--asset-id) [ $# -ge 2 ] || { echo "✗ --asset-id needs a value" >&2; exit 2; }; ASSET_ID="$2"; shift 2 ;;
|
|
61
64
|
--root) [ $# -ge 2 ] || { echo "✗ --root needs a value" >&2; exit 2; }; ROOT="$2"; shift 2 ;;
|
|
62
|
-
-h|--help) sed -n '2,
|
|
65
|
+
-h|--help) sed -n '2,52p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
63
66
|
*) echo "✗ unknown argument: $1" >&2; exit 2 ;;
|
|
64
67
|
esac
|
|
65
68
|
done
|
|
@@ -78,6 +81,7 @@ _uuid() {
|
|
|
78
81
|
fi
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
DISPLAY_PREFIX="cloud"
|
|
81
85
|
[ -n "$ASSET_ID" ] || ASSET_ID="cloud-$(_uuid)"
|
|
82
86
|
# remote/identity.py:_is_identifier rejects whitespace, non-printable
|
|
83
87
|
# characters, and anything over 255 characters. A value this accepts but the
|
|
@@ -146,8 +150,12 @@ fi
|
|
|
146
150
|
|
|
147
151
|
# Streams redirected because own_std_streams only re-points fd 1/2 on log
|
|
148
152
|
# rollover; without this the daemon inherits the hook's pipe to Claude Code.
|
|
153
|
+
# --no-reasoning is explicit, not just `run`'s own default (ADR-0081): this
|
|
154
|
+
# hook never provisions TYPESAFE_API_KEY itself, but a cloud VM's ambient
|
|
155
|
+
# environment is not something this script controls or can audit ahead of
|
|
156
|
+
# time, and this surface has nobody present to notice if that changed.
|
|
149
157
|
if ! stacktrace daemon status 2>/dev/null | grep -q '^running: yes'; then
|
|
150
|
-
setsid nohup stacktrace daemon run </dev/null >>"$STATE/daemon.log" 2>&1 &
|
|
158
|
+
setsid nohup stacktrace daemon run --no-reasoning </dev/null >>"$STATE/daemon.log" 2>&1 &
|
|
151
159
|
fi
|
|
152
160
|
exit 0
|
|
153
161
|
BOOTSCRIPT
|
|
@@ -176,6 +184,11 @@ for home in "${ROOT}"/root "${ROOT}"/home/*; do
|
|
|
176
184
|
fi
|
|
177
185
|
chmod 600 "$cfg/asset-id" 2>/dev/null || true
|
|
178
186
|
|
|
187
|
+
if ! printf '%s\n' "$DISPLAY_PREFIX" > "$cfg/asset-display-prefix" 2>/dev/null; then
|
|
188
|
+
echo "✗ cannot write $cfg/asset-display-prefix — not seeding $home" >&2; failed=1; continue
|
|
189
|
+
fi
|
|
190
|
+
chmod 600 "$cfg/asset-display-prefix" 2>/dev/null || true
|
|
191
|
+
|
|
179
192
|
# Register the SessionStart hook, merging so any existing settings survive.
|
|
180
193
|
# The hook is the point of seeding a home — a home whose registration fails
|
|
181
194
|
# (an unwritable path, or a settings.json that is valid JSON of an unexpected
|
|
@@ -248,7 +261,7 @@ PY
|
|
|
248
261
|
for d in "$home/.config" "$cfg" "$home/.claude"; do
|
|
249
262
|
[ -e "$d" ] && chown --reference="$home" "$d" 2>/dev/null || true
|
|
250
263
|
done
|
|
251
|
-
chown --reference="$home" "$cfg/asset-id" \
|
|
264
|
+
chown --reference="$home" "$cfg/asset-id" "$cfg/asset-display-prefix" \
|
|
252
265
|
"$home/.claude/settings.json" 2>/dev/null || true
|
|
253
266
|
|
|
254
267
|
echo "✓ seeded $home"
|
|
@@ -1381,9 +1381,13 @@ _SEPARATORS = ("&&", "||", ";", "|", "\n")
|
|
|
1381
1381
|
_WORD_START_BEFORE_COMMENT = frozenset(" \t\n;&|<>()")
|
|
1382
1382
|
|
|
1383
1383
|
|
|
1384
|
-
def _split_on_separators(command: str) -> list[str]:
|
|
1384
|
+
def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
|
|
1385
1385
|
"""The commands in a shell line, split only where the shell would split it.
|
|
1386
1386
|
|
|
1387
|
+
Returns ``(segment_text, preceding_operator)`` pairs. The first segment's
|
|
1388
|
+
operator is ``None``; every later segment carries the ``&&``, ``||``, ``;``,
|
|
1389
|
+
``|`` or ``\\n`` that separated it from the one before it.
|
|
1390
|
+
|
|
1387
1391
|
A control operator is an operator **only unquoted and unescaped** — that is
|
|
1388
1392
|
the shell's own grammar, and it is verified against `bash` itself in
|
|
1389
1393
|
`tests/test_shell_separators_match_the_shell.py` rather than asserted here.
|
|
@@ -1420,10 +1424,11 @@ def _split_on_separators(command: str) -> list[str]:
|
|
|
1420
1424
|
left in, `shlex` hands the caller a bare newline where the next word should
|
|
1421
1425
|
be, and `git \\<newline> push` loses its subcommand to it.
|
|
1422
1426
|
"""
|
|
1423
|
-
parts: list[str] = []
|
|
1427
|
+
parts: list[tuple[str, str | None]] = []
|
|
1424
1428
|
current: list[str] = []
|
|
1425
1429
|
index = 0
|
|
1426
1430
|
quote: str | None = None
|
|
1431
|
+
preceding_op: str | None = None
|
|
1427
1432
|
#: Whether a `#` here would open a comment. True at the start of the string
|
|
1428
1433
|
#: and after anything that ends a word; false after any character a word is
|
|
1429
1434
|
#: made of, quotes included -- `echo "a"#b` prints `a#b`.
|
|
@@ -1475,11 +1480,20 @@ def _split_on_separators(command: str) -> list[str]:
|
|
|
1475
1480
|
index += 1
|
|
1476
1481
|
at_word_start = char in _WORD_START_BEFORE_COMMENT
|
|
1477
1482
|
continue
|
|
1478
|
-
|
|
1483
|
+
segment_text = "".join(current)
|
|
1484
|
+
if segment_text.strip():
|
|
1485
|
+
parts.append((segment_text, preceding_op))
|
|
1486
|
+
preceding_op = separator
|
|
1487
|
+
else:
|
|
1488
|
+
# Two separators with nothing between them (e.g. "&&\n"):
|
|
1489
|
+
# keep the conditional operator over a plain terminator so
|
|
1490
|
+
# the next real segment inherits the right dependency.
|
|
1491
|
+
if separator in ("&&", "||") or preceding_op is None:
|
|
1492
|
+
preceding_op = separator
|
|
1479
1493
|
current = []
|
|
1480
1494
|
index += len(separator)
|
|
1481
1495
|
at_word_start = True
|
|
1482
|
-
parts.append("".join(current))
|
|
1496
|
+
parts.append(("".join(current), preceding_op))
|
|
1483
1497
|
return parts
|
|
1484
1498
|
|
|
1485
1499
|
|
|
@@ -1521,7 +1535,7 @@ def _segments_of(text: str, depth: int) -> tuple[tuple[str, tuple[str, ...]], ..
|
|
|
1521
1535
|
`command_segments` would run the masks a second time on their own output.
|
|
1522
1536
|
"""
|
|
1523
1537
|
found: list[tuple[str, tuple[str, ...]]] = []
|
|
1524
|
-
for part in _split_on_separators(text):
|
|
1538
|
+
for part, _op in _split_on_separators(text):
|
|
1525
1539
|
# Comments are gone: `_split_on_separators` discards one at the word
|
|
1526
1540
|
# start that opens it, which is the only place the shell begins one.
|
|
1527
1541
|
# This used to skip a segment *beginning* with `#`, which caught the
|
|
@@ -1567,6 +1581,48 @@ def _segments_of(text: str, depth: int) -> tuple[tuple[str, tuple[str, ...]], ..
|
|
|
1567
1581
|
return tuple(found)
|
|
1568
1582
|
|
|
1569
1583
|
|
|
1584
|
+
def command_segments_with_ops(
|
|
1585
|
+
command: object, depth: int = 0
|
|
1586
|
+
) -> tuple[tuple[str, tuple[str, ...], str | None], ...]:
|
|
1587
|
+
"""Like `command_segments`, but each entry also carries the shell operator
|
|
1588
|
+
that preceded it (``None`` for the first segment, then ``&&``, ``||``,
|
|
1589
|
+
``;``, ``|`` or ``\\n``).
|
|
1590
|
+
|
|
1591
|
+
Only `_resolve_rm_targets` in the deterministic detector needs the operator
|
|
1592
|
+
context — to decide whether an inline ``cd`` reliably changed the directory
|
|
1593
|
+
and whether an ``rm`` segment is unconditionally reachable. Every other
|
|
1594
|
+
caller uses the plain `command_segments`, which discards the operator.
|
|
1595
|
+
"""
|
|
1596
|
+
if not isinstance(command, str) or not command:
|
|
1597
|
+
return ()
|
|
1598
|
+
text = _readable_commands(command, depth)
|
|
1599
|
+
found: list[tuple[str, tuple[str, ...], str | None]] = []
|
|
1600
|
+
for part, preceding_op in _split_on_separators(text):
|
|
1601
|
+
part = part.strip()
|
|
1602
|
+
if not part:
|
|
1603
|
+
continue
|
|
1604
|
+
try:
|
|
1605
|
+
tokens = shlex.split(part)
|
|
1606
|
+
except ValueError:
|
|
1607
|
+
continue
|
|
1608
|
+
while tokens and (_ASSIGNMENT.match(tokens[0]) or tokens[0] in _NOT_A_BINARY):
|
|
1609
|
+
if tokens.pop(0) == "time":
|
|
1610
|
+
while tokens and tokens[0] in _TIME_OPTIONS:
|
|
1611
|
+
tokens.pop(0)
|
|
1612
|
+
if not tokens:
|
|
1613
|
+
continue
|
|
1614
|
+
head = tokens[0].rsplit("/", 1)[-1]
|
|
1615
|
+
if not head or all(char in _OPERATOR_CHARS for char in head):
|
|
1616
|
+
continue
|
|
1617
|
+
arguments = tuple(tokens[1:])
|
|
1618
|
+
found.append((head, arguments, preceding_op))
|
|
1619
|
+
if head in _SHELLS and depth < _MAX_SHELL_DEPTH:
|
|
1620
|
+
body = _shell_command_body(arguments)
|
|
1621
|
+
if body is not None:
|
|
1622
|
+
found.extend(command_segments_with_ops(body, depth + 1))
|
|
1623
|
+
return tuple(found)
|
|
1624
|
+
|
|
1625
|
+
|
|
1570
1626
|
def redirect_targets(command: object) -> tuple[str, ...]:
|
|
1571
1627
|
"""Files a shell command redirects its output into.
|
|
1572
1628
|
|
|
@@ -1604,7 +1660,7 @@ def redirect_targets(command: object) -> tuple[str, ...]:
|
|
|
1604
1660
|
# body, an arithmetic comparison, a `case` frame or the body of a function
|
|
1605
1661
|
# nothing called is not a redirection, and each was named as a file before
|
|
1606
1662
|
# its mask existed.
|
|
1607
|
-
for part in _split_on_separators(_readable_commands(command, 0)):
|
|
1663
|
+
for part, _op in _split_on_separators(_readable_commands(command, 0)):
|
|
1608
1664
|
part = part.strip()
|
|
1609
1665
|
if not part:
|
|
1610
1666
|
continue
|
stacktrace_cli/daemon/cli.py
CHANGED
|
@@ -14,7 +14,7 @@ from typing import TypeVar
|
|
|
14
14
|
|
|
15
15
|
import click
|
|
16
16
|
|
|
17
|
-
from .config import DaemonConfig
|
|
17
|
+
from .config import DaemonConfig, parse_reasoning
|
|
18
18
|
from .paths import RuntimePaths
|
|
19
19
|
from .presentation import render_finding
|
|
20
20
|
from .store import FindingStore, SessionKey
|
|
@@ -117,6 +117,24 @@ def _retry_while_starting(
|
|
|
117
117
|
help="How often to upload endpoint composition. 0 disables it."
|
|
118
118
|
" Defaults to STACKTRACE_BOM_INTERVAL, then 6h.",
|
|
119
119
|
)
|
|
120
|
+
@click.option(
|
|
121
|
+
"--reasoning/--no-reasoning",
|
|
122
|
+
# Declared `False`, not `None`: `test_the_reasoning_stage_is_off_by_
|
|
123
|
+
# default_everywhere_it_can_be_asked_for` reads this literal value off
|
|
124
|
+
# every command in the tree, on the theory that a default legible only
|
|
125
|
+
# after tracing what `DaemonConfig.resolve` does with `None` is exactly
|
|
126
|
+
# the kind of default that drifts unnoticed (ADR-0029). Whether the flag
|
|
127
|
+
# was actually typed is recovered below via `get_parameter_source`, the
|
|
128
|
+
# same "flag beats environment beats default" ladder every other option
|
|
129
|
+
# here gets from `resolve` seeing `None` -- Click just won't hand this one
|
|
130
|
+
# a `None` to pass through.
|
|
131
|
+
default=False,
|
|
132
|
+
hidden=True,
|
|
133
|
+
help="Analyse changed sessions with a reasoning model (stage three). Off"
|
|
134
|
+
" by default: pass --reasoning to opt in, and set TYPESAFE_API_KEY so"
|
|
135
|
+
" there is something to answer with. Defaults to STACKTRACE_REASONING,"
|
|
136
|
+
" then false.",
|
|
137
|
+
)
|
|
120
138
|
def run_command(
|
|
121
139
|
scan_interval: str | None,
|
|
122
140
|
scan_since: str | None,
|
|
@@ -124,10 +142,17 @@ def run_command(
|
|
|
124
142
|
fleet_interval: str | None,
|
|
125
143
|
webhook_interval: str | None,
|
|
126
144
|
bom_interval: str | None,
|
|
145
|
+
reasoning: bool,
|
|
127
146
|
) -> None:
|
|
128
147
|
"""Run the daemon in the foreground for a host supervisor."""
|
|
129
148
|
_, _, run_daemon = _runtime()
|
|
130
149
|
paths = RuntimePaths.from_environment()
|
|
150
|
+
ctx = click.get_current_context()
|
|
151
|
+
reasoning_flag = (
|
|
152
|
+
reasoning
|
|
153
|
+
if ctx.get_parameter_source("reasoning") is click.core.ParameterSource.COMMANDLINE
|
|
154
|
+
else None
|
|
155
|
+
)
|
|
131
156
|
try:
|
|
132
157
|
config = DaemonConfig.resolve(
|
|
133
158
|
scan_interval=scan_interval,
|
|
@@ -136,6 +161,7 @@ def run_command(
|
|
|
136
161
|
fleet_interval=fleet_interval,
|
|
137
162
|
webhook_interval=webhook_interval,
|
|
138
163
|
bom_interval=bom_interval,
|
|
164
|
+
reasoning=reasoning_flag,
|
|
139
165
|
)
|
|
140
166
|
_reject_unobservable_kinds(config)
|
|
141
167
|
except ValueError as error:
|
|
@@ -331,12 +357,77 @@ def _database_line(paths: RuntimePaths) -> str:
|
|
|
331
357
|
return f"{store.database_size()} bytes ({paths.state})"
|
|
332
358
|
|
|
333
359
|
|
|
360
|
+
_JEV_KEY_ENV = "TYPESAFE_API_KEY"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _ensure_jev_key() -> None:
|
|
364
|
+
"""Prompt for the jev key when it is not already in the environment.
|
|
365
|
+
|
|
366
|
+
Sets the key in ``os.environ`` so the forked daemon inherits it.
|
|
367
|
+
An empty response or a non-interactive terminal skips — reasoning
|
|
368
|
+
degrades gracefully when the analyzer reports unavailable.
|
|
369
|
+
"""
|
|
370
|
+
if os.environ.get(_JEV_KEY_ENV):
|
|
371
|
+
return
|
|
372
|
+
if not sys.stdin.isatty():
|
|
373
|
+
return
|
|
374
|
+
key = click.prompt(
|
|
375
|
+
"TYPESAFE_API_KEY for reasoning (Enter to skip)",
|
|
376
|
+
default="",
|
|
377
|
+
hide_input=True,
|
|
378
|
+
show_default=False,
|
|
379
|
+
)
|
|
380
|
+
if key:
|
|
381
|
+
os.environ[_JEV_KEY_ENV] = key
|
|
382
|
+
|
|
383
|
+
|
|
334
384
|
@daemon.command()
|
|
335
|
-
|
|
385
|
+
@click.option(
|
|
386
|
+
"--reasoning/--no-reasoning",
|
|
387
|
+
default=False,
|
|
388
|
+
hidden=True,
|
|
389
|
+
help="Analyse changed sessions with a reasoning model (stage three). Off"
|
|
390
|
+
" by default: pass --reasoning to opt in for this run, and set"
|
|
391
|
+
" TYPESAFE_API_KEY so there is something to answer with.",
|
|
392
|
+
)
|
|
393
|
+
def start(reasoning: bool) -> None:
|
|
336
394
|
"""Start one detached daemon when no host service manager is available."""
|
|
337
395
|
_runtime()
|
|
338
396
|
from .lifecycle import StartOutcome, start_daemon, transition_lock
|
|
339
397
|
|
|
398
|
+
# `start_daemon` forks/execs inheriting `os.environ` as it stands right
|
|
399
|
+
# here -- the same route `_ensure_jev_key` uses to hand the detached
|
|
400
|
+
# process a key it just prompted for, and now the only route
|
|
401
|
+
# `--reasoning`/`--no-reasoning` has to reach that process's own
|
|
402
|
+
# `DaemonConfig.resolve()` too, which reads `STACKTRACE_REASONING`. When
|
|
403
|
+
# the flag was not actually typed, the same "flag beats environment beats
|
|
404
|
+
# default" ladder `run_command` gets from `resolve()` applies here too:
|
|
405
|
+
# an operator who already exported `STACKTRACE_REASONING=true` before
|
|
406
|
+
# calling `start` (the documented way to opt in a detached daemon,
|
|
407
|
+
# config.py's own comment on `SETTINGS`) must not have that silently
|
|
408
|
+
# overwritten by this command's unrelated `False` click default.
|
|
409
|
+
ctx = click.get_current_context()
|
|
410
|
+
explicit = ctx.get_parameter_source("reasoning") is click.core.ParameterSource.COMMANDLINE
|
|
411
|
+
if explicit:
|
|
412
|
+
enabled = reasoning
|
|
413
|
+
else:
|
|
414
|
+
inherited = os.environ.get("STACKTRACE_REASONING")
|
|
415
|
+
try:
|
|
416
|
+
enabled = inherited is not None and parse_reasoning(inherited)
|
|
417
|
+
except ValueError as error:
|
|
418
|
+
raise click.ClickException(str(error)) from error
|
|
419
|
+
if enabled:
|
|
420
|
+
_ensure_jev_key()
|
|
421
|
+
os.environ["STACKTRACE_REASONING"] = "true"
|
|
422
|
+
else:
|
|
423
|
+
# An explicit --no-reasoning, or no opt-in at all: either way, do not
|
|
424
|
+
# let a key already present in the *caller's* environment (a shell
|
|
425
|
+
# profile, an exported var) reach the daemon and reason anyway --
|
|
426
|
+
# that is exactly the implicit activation this opt-in exists to
|
|
427
|
+
# prevent.
|
|
428
|
+
os.environ.pop(_JEV_KEY_ENV, None)
|
|
429
|
+
os.environ["STACKTRACE_REASONING"] = "false"
|
|
430
|
+
|
|
340
431
|
paths = RuntimePaths.from_environment()
|
|
341
432
|
click.echo("Starting Stacktrace daemon...", err=True)
|
|
342
433
|
try:
|
stacktrace_cli/daemon/config.py
CHANGED
|
@@ -14,6 +14,7 @@ import os
|
|
|
14
14
|
from collections.abc import Mapping, Sequence
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from datetime import timedelta
|
|
17
|
+
from decimal import Decimal, InvalidOperation
|
|
17
18
|
|
|
18
19
|
#: Every setting, as `(attribute, environment variable, default spelling)`.
|
|
19
20
|
#: One table so a flag, its environment variable and its default cannot drift
|
|
@@ -25,6 +26,10 @@ SETTINGS: tuple[tuple[str, str, str], ...] = (
|
|
|
25
26
|
("fleet_interval", "STACKTRACE_FLEET_INTERVAL", "5m"),
|
|
26
27
|
("webhook_interval", "STACKTRACE_WEBHOOK_INTERVAL", "1m"),
|
|
27
28
|
("bom_interval", "STACKTRACE_BOM_INTERVAL", "6h"),
|
|
29
|
+
# ADR-0081: opt-in only. A detached `start` inherits this the same way it
|
|
30
|
+
# already inherits TYPESAFE_API_KEY -- by setting the environment
|
|
31
|
+
# variable before forking, not by re-typing the flag.
|
|
32
|
+
("reasoning", "STACKTRACE_REASONING", "false"),
|
|
28
33
|
)
|
|
29
34
|
|
|
30
35
|
_UNITS: dict[str, int] = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
@@ -41,6 +46,20 @@ _UNITS: dict[str, int] = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
|
41
46
|
#: close to this limit.
|
|
42
47
|
_MAX_DURATION_SECONDS = 1e9
|
|
43
48
|
|
|
49
|
+
#: A duration this small is never a legitimate cadence — every real scan,
|
|
50
|
+
#: fleet, webhook, or BOM interval this repo suggests is measured in minutes
|
|
51
|
+
#: to hours, and `0` (disabled) is handled separately. Rejecting it here,
|
|
52
|
+
#: rather than only bounding it above, closes a class of review findings a
|
|
53
|
+
#: `Job` deep in the scheduler kept surfacing one instance of at a time: a
|
|
54
|
+
#: value small enough relative to a `monotonic()` reading in the millions of
|
|
55
|
+
#: seconds (a long-running host's uptime) makes `now - anchor` divided by
|
|
56
|
+
#: `interval` overflow to infinity, or round back to `anchor` on the float
|
|
57
|
+
#: grid, either way breaking arithmetic that assumes `interval` is a sane,
|
|
58
|
+
#: representable step. Validating the input at this boundary means that
|
|
59
|
+
#: arithmetic never has to defend itself against a value that should not have
|
|
60
|
+
#: reached it.
|
|
61
|
+
_MIN_POSITIVE_DURATION_SECONDS = 1.0
|
|
62
|
+
|
|
44
63
|
|
|
45
64
|
def parse_duration(value: str) -> float:
|
|
46
65
|
"""`5s`, `3d`, `6h`, or a bare count of seconds. `0` means disabled.
|
|
@@ -64,14 +83,52 @@ def parse_duration(value: str) -> float:
|
|
|
64
83
|
) from None
|
|
65
84
|
if not math.isfinite(amount) or amount < 0:
|
|
66
85
|
raise ValueError(f"duration must be a non-negative, finite number: {value!r}")
|
|
86
|
+
if amount == 0.0:
|
|
87
|
+
# `float()` underflows a positive-but-tiny literal to exactly `0.0`
|
|
88
|
+
# well before it would otherwise hit `_MIN_POSITIVE_DURATION_SECONDS`
|
|
89
|
+
# below (the smallest subnormal float is ~4.9e-324) -- silently
|
|
90
|
+
# accepting that as `scaled == 0` turns a mistyped positive interval
|
|
91
|
+
# into "disabled" rather than refusing it. `Decimal` parses the same
|
|
92
|
+
# literal exactly, with no binary-float rounding, telling a real zero
|
|
93
|
+
# apart from one that only looks like zero once converted -- except
|
|
94
|
+
# an exponent extreme enough to underflow `float()` (unlike `float`,
|
|
95
|
+
# which clamps) can still exceed `Decimal`'s own representable range
|
|
96
|
+
# and raise `InvalidOperation` rather than return a value to compare;
|
|
97
|
+
# that is exactly as not-a-literal-zero as any other reading here.
|
|
98
|
+
try:
|
|
99
|
+
is_literal_zero = Decimal(digits) == 0
|
|
100
|
+
except InvalidOperation:
|
|
101
|
+
is_literal_zero = False
|
|
102
|
+
if not is_literal_zero:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"duration is too small ({value!r}); the minimum is "
|
|
105
|
+
f"{_MIN_POSITIVE_DURATION_SECONDS:.0f} second, or 0 to disable"
|
|
106
|
+
)
|
|
67
107
|
scaled = amount * unit
|
|
68
108
|
if not math.isfinite(scaled) or scaled > _MAX_DURATION_SECONDS:
|
|
69
109
|
raise ValueError(
|
|
70
110
|
f"duration is too large ({value!r}); the limit is {_MAX_DURATION_SECONDS:.0f} seconds"
|
|
71
111
|
)
|
|
112
|
+
if 0 < scaled < _MIN_POSITIVE_DURATION_SECONDS:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"duration is too small ({value!r}); the minimum is "
|
|
115
|
+
f"{_MIN_POSITIVE_DURATION_SECONDS:.0f} second, or 0 to disable"
|
|
116
|
+
)
|
|
72
117
|
return scaled
|
|
73
118
|
|
|
74
119
|
|
|
120
|
+
def parse_reasoning(value: str) -> bool:
|
|
121
|
+
"""`true` or `false`, case-insensitively. Anything else -- `TRUE`, `1`, a
|
|
122
|
+
typo like `treu` -- is a mistyped opt-in and must fail loudly rather than
|
|
123
|
+
silently resolve to off, the same way a mistyped duration fails rather
|
|
124
|
+
than silently falling back to a default.
|
|
125
|
+
"""
|
|
126
|
+
normalized = value.strip().lower()
|
|
127
|
+
if normalized not in ("true", "false"):
|
|
128
|
+
raise ValueError(f"reasoning must be 'true' or 'false', not {value!r}")
|
|
129
|
+
return normalized == "true"
|
|
130
|
+
|
|
131
|
+
|
|
75
132
|
@dataclass(frozen=True)
|
|
76
133
|
class Setting:
|
|
77
134
|
"""One resolved value, the text it was resolved from, and where that came from."""
|
|
@@ -90,6 +147,7 @@ class DaemonConfig:
|
|
|
90
147
|
fleet_interval: Setting
|
|
91
148
|
webhook_interval: Setting
|
|
92
149
|
bom_interval: Setting
|
|
150
|
+
reasoning: Setting
|
|
93
151
|
|
|
94
152
|
@property
|
|
95
153
|
def scan_interval_seconds(self) -> float:
|
|
@@ -111,6 +169,10 @@ class DaemonConfig:
|
|
|
111
169
|
def bom_interval_seconds(self) -> float:
|
|
112
170
|
return parse_duration(self.bom_interval.value)
|
|
113
171
|
|
|
172
|
+
@property
|
|
173
|
+
def reasoning_enabled(self) -> bool:
|
|
174
|
+
return parse_reasoning(self.reasoning.value)
|
|
175
|
+
|
|
114
176
|
@property
|
|
115
177
|
def kinds(self) -> tuple[str, ...]:
|
|
116
178
|
"""The agent kinds to observe, in the order given, without repeats."""
|
|
@@ -125,9 +187,14 @@ class DaemonConfig:
|
|
|
125
187
|
|
|
126
188
|
@classmethod
|
|
127
189
|
def from_document(cls, document: Mapping[str, object]) -> DaemonConfig:
|
|
128
|
-
"""Read back what a running daemon wrote.
|
|
129
|
-
to the default, because a status surface that raises on a
|
|
130
|
-
tells the operator less than one that says what the default is
|
|
190
|
+
"""Read back what a running daemon wrote. A malformed entry falls back
|
|
191
|
+
to the default, because a status surface that raises on a corrupt file
|
|
192
|
+
tells the operator less than one that says what the default is -- but a
|
|
193
|
+
key missing from the document entirely is a different claim: the
|
|
194
|
+
incumbent daemon predates this setting and never had anything to
|
|
195
|
+
report, it did not choose the default. Reporting the default for that
|
|
196
|
+
case would let `daemon status` claim a value the incumbent, not-yet-
|
|
197
|
+
restarted process during a rolling upgrade may not be honoring."""
|
|
131
198
|
resolved: dict[str, Setting] = {}
|
|
132
199
|
for name, _variable, default in SETTINGS:
|
|
133
200
|
entry = document.get(name)
|
|
@@ -136,7 +203,12 @@ class DaemonConfig:
|
|
|
136
203
|
if isinstance(value, str) and isinstance(source, str):
|
|
137
204
|
resolved[name] = Setting(value, source)
|
|
138
205
|
continue
|
|
139
|
-
|
|
206
|
+
if name in document:
|
|
207
|
+
resolved[name] = Setting(default, "default")
|
|
208
|
+
else:
|
|
209
|
+
resolved[name] = Setting(
|
|
210
|
+
"unknown", "not reported by the running daemon (restart to report)"
|
|
211
|
+
)
|
|
140
212
|
return cls(**resolved)
|
|
141
213
|
|
|
142
214
|
@classmethod
|
|
@@ -149,6 +221,7 @@ class DaemonConfig:
|
|
|
149
221
|
fleet_interval: str | None = None,
|
|
150
222
|
webhook_interval: str | None = None,
|
|
151
223
|
bom_interval: str | None = None,
|
|
224
|
+
reasoning: bool | None = None,
|
|
152
225
|
environment: Mapping[str, str] | None = None,
|
|
153
226
|
) -> DaemonConfig:
|
|
154
227
|
"""Flag, then environment, then default — and validate here, not later.
|
|
@@ -166,13 +239,19 @@ class DaemonConfig:
|
|
|
166
239
|
"fleet_interval": fleet_interval,
|
|
167
240
|
"webhook_interval": webhook_interval,
|
|
168
241
|
"bom_interval": bom_interval,
|
|
242
|
+
"reasoning": None if reasoning is None else ("true" if reasoning else "false"),
|
|
169
243
|
}
|
|
170
244
|
resolved: dict[str, Setting] = {}
|
|
171
245
|
for name, variable, default in SETTINGS:
|
|
172
246
|
flag = flags[name]
|
|
173
247
|
if flag is not None:
|
|
174
248
|
resolved[name] = Setting(flag, "flag")
|
|
175
|
-
elif environ
|
|
249
|
+
elif variable in environ:
|
|
250
|
+
# Presence, not truthiness: `STACKTRACE_REASONING=""` (an
|
|
251
|
+
# unset-variable interpolation gone wrong, say) is the
|
|
252
|
+
# operator setting this to an empty string, not leaving it
|
|
253
|
+
# unset -- it must reach the same validation below as any
|
|
254
|
+
# other spelling, not quietly take the default.
|
|
176
255
|
resolved[name] = Setting(environ[variable], f"environment ({variable})")
|
|
177
256
|
else:
|
|
178
257
|
resolved[name] = Setting(default, "default")
|
|
@@ -185,6 +264,7 @@ class DaemonConfig:
|
|
|
185
264
|
config.fleet_interval_seconds,
|
|
186
265
|
config.webhook_interval_seconds,
|
|
187
266
|
config.bom_interval_seconds,
|
|
267
|
+
config.reasoning_enabled,
|
|
188
268
|
)
|
|
189
269
|
if not config.kinds:
|
|
190
270
|
raise ValueError("--agent-kind must name at least one agent kind")
|