Skip to content

Commit dcdb329

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16820-reference-helptext-three-locales
2 parents 403dee7 + ce8bfc9 commit dcdb329

19 files changed

Lines changed: 2168 additions & 151 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
Dispatcher domain routes stop claiming their lexical neighbours: `DomainRoute.match` now defaults to `'segment'`, and the project-membership skip list gained the same boundary.
6+
7+
Ten shipped routes — `/actions`, `/ai`, `/analytics`, `/automation`, `/data`, `/i18n`, `/meta`, `/notifications`, `/packages`, `/ui` — carried the implicit `'prefix'` default, a bare `path.startsWith(prefix)` with no segment boundary. So `/datax`, `/metaxyz`, `/uifoo`, `/aixx` and `/packagesomething` were each claimed by a domain that does not own them, and a package mounting one of those namespaces later would have been shadowed by a domain that never wanted it. `/auth` was the eleventh member of the family and was repaired on its own; this closes the rest at the seam rather than one route at a time, so the eleventh domain someone adds is boundary-correct without having to remember anything.
8+
9+
- **The default moved, the mode did not go away.** `match: 'segment'` (the prefix exactly, plus everything under `prefix + '/'`) is the default; `match: 'prefix'` still buys the bare `startsWith` claim for a route that asks for it in writing. One shape genuinely needs it and now declares it: a prefix ending in `'?'` (`/keys?`, `/mcp?`, `/mcp/skill?`), which reproduces the legacy branch's query-string form for adapters that pass the query through in the path. There is no `/` after that `'?'`, so a segment match cannot express it — those three routes match exactly what they always did.
10+
- **What each narrowed claim used to answer, measured per domain rather than assumed.** They were not uniform: `/actionsx`, `/aixx`, `/automationx`, `/metaxyz` and `/packagesomething` answered `401`; `/i18nxx` and `/notificationsx` answered `501`; `/analyticsx` and `/uifoo` fell through unhandled. `/data` was the worst and the reason per-domain measurement was owed — its handler reads the sub-path as an OBJECT NAME, so `GET /datax` answered a **success envelope for a fabricated object** and `GET /datax/foo` **threw** `Record foo not found in x`. Each of the ten now answers the dispatcher's `ROUTE_NOT_FOUND` envelope, which is what they should always have answered. No caller depended on any of these: nothing in the repo builds a dispatch path by concatenating a domain prefix without a separator, and no route-ledger row or SDK method addresses a shape of this kind.
11+
- **The membership skip list, which was the same predicate with a worse consequence.** `enforceProjectMembership` skipped the control plane with `skipPaths.some(p => path.startsWith(p))` and `'/auth'` in the list, so `/authentication/foo` was waved **past the membership check** rather than merely routed somewhere wrong. It was latent — nothing claims `/authentication/*`, so such a request 404s first — and it would have gone live the day any domain claimed a path of that shape. The skip list stops at `'/'`, `'?'` or end-of-string now; the `'?'` form is part of the boundary on purpose, so `/auth?redirect=…` keeps the exemption it has today and the control plane is not newly gated.
12+
13+
Every domain still claims itself and every path under it, `/auth/me/permissions` included; the registry header comment that described the old rough edges as deliberate no longer describes code that has them.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/metadata': patch
3+
---
4+
5+
fix(metadata): keep the original notification instant when migrating `sys_notification` to the event model (#16312)
6+
7+
`migrateSysNotificationToEvent` materializes each legacy inbox row into a
8+
`sys_inbox_message` and a `sys_notification_receipt`, back-dating both to the
9+
notification's own `created_at`. Both writes passed no options bag, so they
10+
relied on the audit binder's create-side `record.created_at ?? now` — the
11+
laundering #15964 removed, on the maintainer ruling of 2026-09-06. Without
12+
that accident, every migrated inbox row and receipt is stamped with the moment
13+
the migration RAN: a user's whole bell history collapses to "all arrived
14+
today".
15+
16+
The two writes now declare `{ context: { preserveAudit: true } }`, the explicit
17+
historical-import channel the same ruling deliberately kept (#3493; it is what
18+
REST import's `treatAsHistorical` sets). This is not a bypass of audit — it is
19+
the door audit left open for a historical import. No exported symbol, schema or
20+
config key moves.
21+
22+
**Release ordering.** `@objectstack/objectql`'s side of #15964 is itself still
23+
an unreleased changeset, so no published version of this migration has ever
24+
written the flattened timeline. Releasing the two together keeps it that way.
25+
26+
**If a deployment did run it from a build that has both halves**, the original
27+
timeline is recoverable rather than lost: the source `sys_notification` rows
28+
are rewritten in place, never deleted or archived, and `created_at` is not
29+
among the legacy columns the run clears — so the notification's own instant is
30+
still on the event row and reachable from both new rows through
31+
`notification_id`.
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env bash
2+
# Self-test for guard-process-kill.sh — run it after touching that hook:
3+
#
4+
# .claude/hooks/guard-process-kill.selftest.sh
5+
#
6+
# Feeds the hook the same JSON payload shape Claude Code delivers on PreToolUse and asserts
7+
# the block/allow verdict per command. Needs jq (to build payloads) and nothing else: no
8+
# install, no build, no network. Exit 0 = all cases hold.
9+
#
10+
# The harness is guard-shared-stash.selftest.sh's, one-for-one — same verdict(), expect(),
11+
# stderr_of(), says() and lacks() — because this guard is that guard's shape applied to the
12+
# other shared object. The case matrix is this guard's own, and it pins BOTH sides on
13+
# purpose: a guard for a class this wide is worth nothing if the PID-scoped teardown the
14+
# repo already prescribes comes back red.
15+
16+
set -uo pipefail
17+
18+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19+
hook="$here/guard-process-kill.sh"
20+
pass=0
21+
fail=0
22+
23+
command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to build payloads" >&2; exit 1; }
24+
25+
# verdict <command> [env assignments…] -> prints "block" or "allow"
26+
verdict() {
27+
local cmd="$1"; shift
28+
local payload out rc
29+
payload="$(jq -nc --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
30+
out="$(printf '%s' "$payload" | env "$@" "$hook" 2>/dev/null)"
31+
rc=$?
32+
case "$rc" in
33+
0) printf 'allow' ;;
34+
2) printf 'block' ;;
35+
*) printf 'exit%s' "$rc" ;;
36+
esac
37+
}
38+
39+
expect() { # expect <block|allow> <command> [env…]
40+
local want="$1" cmd="$2"; shift 2
41+
local got; got="$(verdict "$cmd" "$@")"
42+
if [ "$got" = "$want" ]; then
43+
pass=$((pass + 1)); printf ' ok %-5s %s\n' "$got" "$cmd"
44+
else
45+
fail=$((fail + 1)); printf ' FAIL want=%s got=%s %s\n' "$want" "$got" "$cmd"
46+
fi
47+
}
48+
49+
stderr_of() { # stderr_of <command> [env…] -> the refusal text an agent actually reads
50+
local cmd="$1"; shift
51+
local payload
52+
payload="$(jq -nc --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
53+
printf '%s' "$payload" | env "$@" "$hook" 2>&1 >/dev/null
54+
}
55+
56+
says() { # says <needle> <label> <command> [env…]
57+
local needle="$1" label="$2" subject="$3"; shift 3
58+
local out; out="$(stderr_of "$subject" "$@")"
59+
case "$out" in
60+
*"$needle"*) pass=$((pass + 1)); printf ' ok says %s\n' "$label" ;;
61+
*) fail=$((fail + 1)); printf ' FAIL missing "%s" %s\n' "$needle" "$label" ;;
62+
esac
63+
}
64+
65+
lacks() { # lacks <needle> <label> <command> [env…]
66+
local needle="$1" label="$2" subject="$3"; shift 3
67+
local out; out="$(stderr_of "$subject" "$@")"
68+
case "$out" in
69+
*"$needle"*) fail=$((fail + 1)); printf ' FAIL still says "%s" %s\n' "$needle" "$label" ;;
70+
*) pass=$((pass + 1)); printf ' ok lacks %s\n' "$label" ;;
71+
esac
72+
}
73+
74+
echo "== the specimen this rule was filed for (objectstack#16182 / PR #16120) =="
75+
expect block "pkill -f 'dispatch-gates.mjs --self-test'"
76+
expect block 'pkill -f dispatch-gates.mjs'
77+
78+
echo "== pkill selects by NAME with or without -f =="
79+
expect block 'pkill node'
80+
expect block 'pkill -9 vitest'
81+
expect block 'pkill -x esbuild'
82+
expect block 'pkill -u root node'
83+
expect block 'pkill --signal TERM node'
84+
expect block 'pkill -f "pnpm --filter @objectstack/spec test"'
85+
expect block '/usr/bin/pkill -f vitest'
86+
expect block 'OS_FOO=1 pkill -f vitest'
87+
# Bare `pkill` — its option loop completes with no PID-family selector, and it is the ONLY
88+
# input that reaches the pidscoped gate at the end of check_pkill. Without this row that gate
89+
# is unpinned: an ablation replacing it with an unconditional allow left this file green at
90+
# 68/0, which is how the hole was found rather than shipped.
91+
expect block 'pkill'
92+
93+
echo "== killall is the spelling a pkill-only rule would go silent on =="
94+
expect block 'killall node'
95+
expect block 'killall -9 node'
96+
expect block 'killall -r "vitest.*"'
97+
expect block 'killall'
98+
99+
echo "== reached through separators and command substitution =="
100+
expect block 'cd /home/user/objectstack && pkill -f vitest'
101+
expect block 'pnpm test; pkill -f vitest'
102+
expect block 'out=$(pkill -f vitest)'
103+
104+
echo "== a pgrep NAME PATTERN feeding a kill is a pkill spelled out longhand =="
105+
expect block 'pgrep -f dispatch-gates.mjs | xargs kill'
106+
expect block 'kill $(pgrep -f vitest)'
107+
expect block 'kill -9 $(pgrep node)'
108+
expect block 'for p in $(pgrep -f vitest); do kill "$p"; done'
109+
expect block 'pgrep -lf node | awk "{print \$1}" | xargs kill -9'
110+
111+
echo "== ps piped through grep into a kill is the hand-rolled spelling of the same thing =="
112+
expect block 'ps aux | grep vitest | awk "{print \$2}" | xargs kill'
113+
expect block 'ps -ef | grep node | xargs kill -9'
114+
115+
echo "== PID-SCOPED kills are the POSITIVE form and must stay allowed =="
116+
expect allow 'kill "$SERVER_PID"'
117+
expect allow 'kill -0 "$SERVER_PID"'
118+
expect allow 'kill -9 12345'
119+
expect allow 'kill -- -"$PGID"'
120+
expect allow 'kill %1'
121+
expect allow 'cmd & pid=$!; kill "$pid"'
122+
123+
echo "== the teardown AGENTS.md itself prescribes: the port is one YOU picked =="
124+
expect allow 'kill $(lsof -ti tcp:38421)'
125+
expect allow 'kill $(lsof -ti tcp:3000) 2>/dev/null'
126+
127+
echo "== pgrep -P / -s select by a handle the caller owns — live in two tracked scripts =="
128+
# scripts/publish-smoke.sh kill_tree() and scripts/gen-sdui-manifest.sh sdui_live_pids().
129+
# A rule that reddened these would be routed around within the hour.
130+
expect allow 'for child in $(pgrep -P "$pid"); do kill "$child"; done'
131+
expect allow 'pgrep -s "$leader" | xargs kill'
132+
expect allow 'kill $(pgrep -P 4242)'
133+
expect allow 'pgrep -P "${frontier// /,}"'
134+
135+
echo "== the same selectors on pkill, with NO pattern operand =="
136+
expect allow 'pkill -P "$pid"'
137+
expect allow 'pkill -s "$sid"'
138+
expect allow 'pkill -P4242'
139+
expect allow 'pkill --help'
140+
# …and adding a pattern to them is blocked again, which is what makes the pair meaningful.
141+
expect block 'pkill -P "$pid" node'
142+
expect block 'pkill -s "$sid" -f vitest'
143+
144+
echo "== reads are reads: nothing dies, so nothing is blocked =="
145+
expect allow 'pgrep -f vitest'
146+
expect allow 'pgrep -lf node'
147+
expect allow 'ps aux | grep node'
148+
expect allow 'ps -o sid= -p "$LEADER" | tr -d " "'
149+
expect allow 'ps aux | grep -c vitest'
150+
151+
echo "== unrelated commands are untouched =="
152+
expect allow 'pnpm --filter @objectstack/spec test'
153+
expect allow 'git status'
154+
expect allow 'node scripts/pm/dispatch-gates.mjs --self-test'
155+
expect allow 'rm -rf node_modules'
156+
157+
echo "== writing ABOUT the ban must not trip the ban (objectstack#4890) =="
158+
expect allow 'grep -n "pkill -f" AGENTS.md'
159+
expect allow 'git grep -n "killall"'
160+
expect allow 'grep -rn "pgrep -f x | xargs kill" .claude/'
161+
expect allow 'echo "never run pkill -f against a shared container"'
162+
163+
echo "== an UNQUOTED \\\" opens no quote, so the kill behind it is still seen (#11738) =="
164+
# Inherited from guard-shared-stash.sh's split_segments(): reading the escaped `\"` as
165+
# OPENING a region that never closes collapses the command into one harmless-headed segment
166+
# and lets the real kill ride through as an argument. The bare forms next door are the
167+
# controls that say the guard was reached at all.
168+
expect block 'echo \" ; pkill -f vitest'
169+
expect block 'echo \" && killall node'
170+
expect allow 'echo \" ; echo hello'
171+
expect allow 'echo \" ; git status'
172+
173+
echo "== an escaped \\\" INSIDE a double-quoted word does NOT close it (#10406 half) =="
174+
# The other direction of the same rule: reading it as CLOSING splits where bash would not
175+
# and turns the tail of a pure READ into a segment judged on its own head word.
176+
expect allow 'grep -rn "he said \"pkill -f vitest\" once" .claude/'
177+
expect block 'echo "he said \"x\"" && pkill -f vitest'
178+
179+
echo "== escape hatch =="
180+
expect allow 'pkill -f vitest' OS_ALLOW_PROCESS_KILL=1
181+
expect allow 'ps aux | grep node | xargs kill' OS_ALLOW_PROCESS_KILL=1
182+
183+
echo "== the refusal leads with the POSITIVE form, and names where the hatch works =="
184+
# A rule that only says "not like this" gets routed into another spelling of the same
185+
# mistake, so the positive form is pinned as text an agent actually reads — not merely as
186+
# an intention in the header. And the hatch sentence must name the environment THIS HOOK
187+
# reads: a VAR=1 command prefix cannot reach it, and an instruction that does not work is
188+
# an invitation to route around the guard (#15971, the same repair the stash guard took).
189+
says 'Kill only a PID you recorded' 'the positive form' 'pkill -f vitest'
190+
says 'hook itself runs in' 'the hatch names the environment this hook reads' 'pkill -f vitest'
191+
lacks 're-run with' 'the refusal does not print an unusable prefix remedy' 'pkill -f vitest'
192+
193+
echo "== payload with no command fails open =="
194+
if printf '%s' '{"tool_name":"Bash","tool_input":{}}' | "$hook" >/dev/null 2>&1; then
195+
pass=$((pass + 1)); printf ' ok allow (empty tool_input)\n'
196+
else
197+
fail=$((fail + 1)); printf ' FAIL empty tool_input should fail open\n'
198+
fi
199+
200+
echo "== jq-less fallback still parses the command =="
201+
nojq="$(mktemp -d)"
202+
for b in bash env cat sed head grep; do
203+
p="$(command -v "$b")" && ln -s "$p" "$nojq/$b"
204+
done
205+
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"pkill -f vitest"}}' \
206+
| PATH="$nojq" "$hook" >/dev/null 2>&1
207+
case "$?" in
208+
0) got_nojq=allow ;;
209+
2) got_nojq=block ;;
210+
*) got_nojq="exit$?" ;;
211+
esac
212+
if [ "$got_nojq" = block ]; then
213+
pass=$((pass + 1)); printf ' ok block (no jq on PATH)\n'
214+
else
215+
fail=$((fail + 1)); printf ' FAIL no-jq fallback got=%s\n' "$got_nojq"
216+
fi
217+
rm -rf "$nojq"
218+
219+
printf '\n%s passed, %s failed\n' "$pass" "$fail"
220+
[ "$fail" -eq 0 ]

0 commit comments

Comments
 (0)