crewforth 0.0.1 → 3.0.0-rc.1
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.
- package/CHANGELOG.md +3784 -0
- package/LICENSE +21 -0
- package/README.md +37 -7
- package/VERSION +1 -0
- package/adopt.sh +1670 -0
- package/bin/cli.js +497 -0
- package/kit/AGENT_TEMPLATE.md +92 -0
- package/kit/CLAUDE.md +228 -0
- package/kit/README.md +107 -0
- package/kit/agents/crew-backend-expert.md +95 -0
- package/kit/agents/crew-commit-agent.md +96 -0
- package/kit/agents/crew-database-expert.md +84 -0
- package/kit/agents/crew-devops-expert.md +82 -0
- package/kit/agents/crew-frontend-expert.md +85 -0
- package/kit/agents/crew-performance-expert.md +78 -0
- package/kit/agents/crew-planner.md +60 -0
- package/kit/agents/crew-privacy-agent.md +71 -0
- package/kit/agents/crew-review-agent.md +95 -0
- package/kit/agents/crew-security-expert.md +73 -0
- package/kit/agents/crew-session-manager.md +77 -0
- package/kit/agents/crew-test-expert.md +64 -0
- package/kit/eval/doctor.sh +465 -0
- package/kit/eval/gate-report.sh +137 -0
- package/kit/eval/golden-holdout.txt +53 -0
- package/kit/eval/golden-routing.txt +199 -0
- package/kit/eval/lib/crew-env.sh +14 -0
- package/kit/eval/lib/settings-json.awk +154 -0
- package/kit/eval/lib/star.sh +70 -0
- package/kit/eval/parser-conformance.sh +612 -0
- package/kit/eval/preflight.sh +186 -0
- package/kit/eval/routing-eval.sh +209 -0
- package/kit/eval/scan-skill.sh +162 -0
- package/kit/eval/smoke-test.sh +6693 -0
- package/kit/eval/update-guard.sh +81 -0
- package/kit/eval/utilization.sh +130 -0
- package/kit/hooks/board-sync.sh +106 -0
- package/kit/hooks/board.sh +962 -0
- package/kit/hooks/commit-msg +49 -0
- package/kit/hooks/context-usage.sh +353 -0
- package/kit/hooks/floor-blocklist.txt +124 -0
- package/kit/hooks/guard-bash.sh +1431 -0
- package/kit/hooks/guard-commit-scan.sh +511 -0
- package/kit/hooks/guard-write.sh +513 -0
- package/kit/hooks/pre-commit +577 -0
- package/kit/hooks/route-hint.sh +185 -0
- package/kit/hooks/secret-blocklist.txt +82 -0
- package/kit/hooks/session-guard.sh +149 -0
- package/kit/hooks/session-rehydrate.sh +41 -0
- package/kit/hooks/session-stats.sh +252 -0
- package/kit/hooks/session-update-check.sh +236 -0
- package/kit/hooks/skill-trust.sh +150 -0
- package/kit/hooks/trace-blocklist.txt +53 -0
- package/kit/settings.json +132 -0
- package/kit/skills/a11y/SKILL.md +44 -0
- package/kit/skills/adr/SKILL.md +74 -0
- package/kit/skills/api-design/SKILL.md +50 -0
- package/kit/skills/automode-policy/SKILL.md +91 -0
- package/kit/skills/automode-policy/references/policy.json +13 -0
- package/kit/skills/automode-policy/scripts/apply.sh +84 -0
- package/kit/skills/automode-policy/scripts/check.sh +102 -0
- package/kit/skills/backend-architecture/SKILL.md +103 -0
- package/kit/skills/brainstorm/SKILL.md +47 -0
- package/kit/skills/ci-pipeline/SKILL.md +38 -0
- package/kit/skills/commit-message/SKILL.md +82 -0
- package/kit/skills/commit-message/references/examples.md +8 -0
- package/kit/skills/confidence-check/SKILL.md +86 -0
- package/kit/skills/crew-board/SKILL.md +55 -0
- package/kit/skills/crew-brainstorm/SKILL.md +19 -0
- package/kit/skills/crew-code-review/SKILL.md +151 -0
- package/kit/skills/crew-code-review/references/history-search.md +19 -0
- package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
- package/kit/skills/crew-code-review/references/receiving.md +23 -0
- package/kit/skills/crew-doctor/SKILL.md +36 -0
- package/kit/skills/crew-gates/SKILL.md +25 -0
- package/kit/skills/crew-handoff/SKILL.md +14 -0
- package/kit/skills/crew-plan/SKILL.md +18 -0
- package/kit/skills/crew-review/SKILL.md +23 -0
- package/kit/skills/crew-ship/SKILL.md +19 -0
- package/kit/skills/crew-skill/SKILL.md +61 -0
- package/kit/skills/crew-studio/SKILL.md +129 -0
- package/kit/skills/crew-update/SKILL.md +43 -0
- package/kit/skills/db-migration/SKILL.md +120 -0
- package/kit/skills/db-migration/references/backup-restore.md +33 -0
- package/kit/skills/db-migration/references/raw-sql.md +30 -0
- package/kit/skills/db-migration/references/tool-matrix.md +35 -0
- package/kit/skills/dependency-audit/SKILL.md +61 -0
- package/kit/skills/dependency-upgrade/SKILL.md +82 -0
- package/kit/skills/deploy/SKILL.md +100 -0
- package/kit/skills/deploy/references/proxy-ssl.md +56 -0
- package/kit/skills/deploy/references/rollback.md +18 -0
- package/kit/skills/deploy/references/scripts.md +9 -0
- package/kit/skills/deploy/references/topology-a.md +128 -0
- package/kit/skills/deploy/references/topology-b.md +44 -0
- package/kit/skills/docs-writer/SKILL.md +53 -0
- package/kit/skills/docs-writer/references/coauthoring.md +34 -0
- package/kit/skills/eval-grader/SKILL.md +75 -0
- package/kit/skills/eval-grader/references/method.md +51 -0
- package/kit/skills/frontend/SKILL.md +56 -0
- package/kit/skills/frontend/references/verify-contract.md +41 -0
- package/kit/skills/frontend-design/SKILL.md +71 -0
- package/kit/skills/frontend-design/references/design-review.md +34 -0
- package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
- package/kit/skills/handoff/SKILL.md +71 -0
- package/kit/skills/handoff/references/cold-reader.md +51 -0
- package/kit/skills/i18n-integrity/SKILL.md +31 -0
- package/kit/skills/incident-runbook/SKILL.md +40 -0
- package/kit/skills/incident-runbook/references/postmortem.md +16 -0
- package/kit/skills/iterate/SKILL.md +40 -0
- package/kit/skills/mcp-builder/SKILL.md +54 -0
- package/kit/skills/mcp-builder/references/building.md +71 -0
- package/kit/skills/observability/SKILL.md +50 -0
- package/kit/skills/performance/SKILL.md +38 -0
- package/kit/skills/performance/references/bottlenecks.md +10 -0
- package/kit/skills/privacy-compliance/SKILL.md +75 -0
- package/kit/skills/red-team/SKILL.md +48 -0
- package/kit/skills/reflect/SKILL.md +57 -0
- package/kit/skills/release/SKILL.md +44 -0
- package/kit/skills/security-scan/SKILL.md +99 -0
- package/kit/skills/security-scan/references/ai-agents.md +107 -0
- package/kit/skills/security-scan/references/fronts.md +99 -0
- package/kit/skills/security-scan/references/prompting.md +23 -0
- package/kit/skills/security-scan/references/reporting.md +75 -0
- package/kit/skills/security-scan/references/verify.md +131 -0
- package/kit/skills/sonarqube-check/SKILL.md +108 -0
- package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
- package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
- package/kit/skills/spec-planning/SKILL.md +92 -0
- package/kit/skills/systematic-debugging/SKILL.md +57 -0
- package/kit/skills/systematic-debugging/references/techniques.md +44 -0
- package/kit/skills/teamboard/SKILL.md +88 -0
- package/kit/skills/teamboard/references/setup.md +37 -0
- package/kit/skills/testing/SKILL.md +49 -0
- package/kit/skills/testing/references/flaky-triage.md +51 -0
- package/kit/skills/threat-model/SKILL.md +57 -0
- package/kit/skills/threat-model/references/interview.md +51 -0
- package/kit/skills/threat-model/references/schema.md +42 -0
- package/kit/skills/token-budget/SKILL.md +51 -0
- package/kit/skills/trace-scan/SKILL.md +43 -0
- package/kit/skills/worktree/SKILL.md +40 -0
- package/kit/studio/README.md +266 -0
- package/kit/studio/ensure-node.sh +471 -0
- package/kit/studio/package.json +14 -0
- package/kit/studio/server/hooks/studio-gate.sh +169 -0
- package/kit/studio/server/index.js +902 -0
- package/kit/studio/server/lib/crew-env.js +11 -0
- package/kit/studio/server/lib/fleet.js +146 -0
- package/kit/studio/server/lib/graph.js +533 -0
- package/kit/studio/server/lib/instance.js +135 -0
- package/kit/studio/server/lib/kit-telemetry.js +182 -0
- package/kit/studio/server/lib/kit.js +158 -0
- package/kit/studio/server/lib/palette.js +118 -0
- package/kit/studio/server/lib/peers.js +72 -0
- package/kit/studio/server/lib/permissions.js +150 -0
- package/kit/studio/server/lib/projects.js +294 -0
- package/kit/studio/server/lib/roster.js +191 -0
- package/kit/studio/server/lib/session.js +309 -0
- package/kit/studio/server/lib/terminal.js +91 -0
- package/kit/studio/server/lib/transcript.js +100 -0
- package/kit/studio/web/app.js +1098 -0
- package/kit/studio/web/canvas.js +1179 -0
- package/kit/studio/web/chat.js +543 -0
- package/kit/studio/web/index.html +86 -0
- package/kit/studio/web/md.js +110 -0
- package/kit/studio/web/storage-migrate.js +23 -0
- package/kit/studio/web/style.css +1453 -0
- package/package.json +46 -8
- package/start.sh +748 -0
- package/bin/crewforth.js +0 -10
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ensure-node.sh — find a Node the panel can run on, and when asked, go and get one.
|
|
3
|
+
#
|
|
4
|
+
# Every gate in Crewforth is bash and needs nothing installed. The panel is the one
|
|
5
|
+
# component that needs a runtime, and a machine without Node used to get a dead end:
|
|
6
|
+
# "install Node 18+, then come back". This script is Crewforth doing that itself.
|
|
7
|
+
#
|
|
8
|
+
# ensure-node.sh print a usable node's path on stdout; exit 1 if there is none
|
|
9
|
+
# ensure-node.sh --explain the same, narrating every place it looked (on stderr)
|
|
10
|
+
# ensure-node.sh --plan say exactly what --install would fetch and where; change nothing
|
|
11
|
+
# ensure-node.sh --install fetch one into ~/.claude/studio-runtime (asks first)
|
|
12
|
+
# ensure-node.sh --install --yes fetch without asking (for scripts and CI)
|
|
13
|
+
# ensure-node.sh --probe <path> exit 0 if that binary is a Node at or above the floor
|
|
14
|
+
#
|
|
15
|
+
# What it will not do: touch the system. No admin rights, no package manager, no PATH
|
|
16
|
+
# edit, no shell profile. The runtime lands in one directory under $HOME and is used by
|
|
17
|
+
# its full path. Removing that directory undoes everything this script ever did.
|
|
18
|
+
#
|
|
19
|
+
# Env: CREW_STUDIO_NODE a node binary to trust ahead of any search
|
|
20
|
+
# CREW_STUDIO_RUNTIME where fetched runtimes live (default ~/.claude/studio-runtime)
|
|
21
|
+
# CREW_STUDIO_NODE_VERSION pin a version instead of asking nodejs.org for the newest LTS
|
|
22
|
+
set -u
|
|
23
|
+
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
24
|
+
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
25
|
+
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
|
|
26
|
+
|
|
27
|
+
# The floor is stated here and in eval/preflight.sh. Two copies, because preflight ships
|
|
28
|
+
# in editions that carry no panel — so selfcheck pins them equal instead.
|
|
29
|
+
FLOOR=18
|
|
30
|
+
DIST=https://nodejs.org/dist
|
|
31
|
+
# Used only when nodejs.org cannot be reached to ask which release is current.
|
|
32
|
+
FALLBACK_VERSION=v24.21.0
|
|
33
|
+
|
|
34
|
+
# Windows caps a full file path at 260 characters and PowerShell's Expand-Archive enforces it.
|
|
35
|
+
# Measured: a 141-character target failed with PathTooLongException at __generated__\google\api\…
|
|
36
|
+
# after 17 seconds and left nothing behind, while unzip extracted the same archive to the same
|
|
37
|
+
# place in 3 seconds. So the fallback is not merely slower than unzip, it is strictly weaker,
|
|
38
|
+
# and the difference only shows on a long path.
|
|
39
|
+
WIN_MAX_PATH=260
|
|
40
|
+
NODE_DEEPEST_ENTRY=125 # measured inside v24.21.0's zip; a deeper future release makes
|
|
41
|
+
# this under-warn rather than over-warn, which is the safe side
|
|
42
|
+
# Minus two, not one: the cap is exclusive. Measured on Windows 11 — a 133-character unpack
|
|
43
|
+
# directory works and 134 fails, because 134 + 1 + 125 is exactly 260 and Windows wants less.
|
|
44
|
+
WIN_DIR_BUDGET=$((WIN_MAX_PATH - NODE_DEEPEST_ENTRY - 2))
|
|
45
|
+
|
|
46
|
+
# Under $HOME, and deliberately so in BOTH editions — including the plugin, where writing into the
|
|
47
|
+
# user's ~/.claude looks at first glance like the thing we ruled out. It is not the same thing. What
|
|
48
|
+
# was ruled out is a plugin installing KIT FILES there: an update would leave orphans nobody owns.
|
|
49
|
+
# This is a runtime cache the user says yes to, self-contained, and documented as "delete it and this
|
|
50
|
+
# never happened". A plugin update does not touch it. The alternative is worse: a runtime inside the
|
|
51
|
+
# plugin directory would be thrown away and re-fetched, 36 MB at a time, on every plugin update.
|
|
52
|
+
RUNTIME="${CREW_STUDIO_RUNTIME:-$HOME/.claude/studio-runtime}"
|
|
53
|
+
EXPLAIN=0
|
|
54
|
+
YES=0
|
|
55
|
+
MODE=resolve
|
|
56
|
+
PROBE=""
|
|
57
|
+
|
|
58
|
+
say() { [ "$EXPLAIN" = 1 ] && printf '%s\n' "$*" >&2; return 0; }
|
|
59
|
+
die() { printf '%s\n' "$*" >&2; exit 1; }
|
|
60
|
+
|
|
61
|
+
# A name that resolves is not a working interpreter: the Windows Store ships a stub that
|
|
62
|
+
# satisfies `command -v`, prints nothing and exits 49. So run it and read the major it
|
|
63
|
+
# reports. The cheap tests come first so an unmatched glob never costs a process — on
|
|
64
|
+
# Git Bash a fork is 62-135 ms idle and around 400 ms on a busy machine, and this list is long.
|
|
65
|
+
works() {
|
|
66
|
+
[ -n "${1:-}" ] || return 1
|
|
67
|
+
case "$1" in
|
|
68
|
+
*/*) [ -f "$1" ] || return 1 ;;
|
|
69
|
+
*) command -v "$1" >/dev/null 2>&1 || return 1 ;;
|
|
70
|
+
esac
|
|
71
|
+
v="$("$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null)" || return 1
|
|
72
|
+
case "$v" in ''|*[!0-9]*) return 1 ;; esac
|
|
73
|
+
[ "$v" -ge "$FLOOR" ]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Everywhere a Node might be that a non-interactive shell would not see. A version manager
|
|
77
|
+
# puts its node on PATH from a login shell only, so "nvm is installed" and "this script can
|
|
78
|
+
# find node" are different facts, and the second one is the one that matters here.
|
|
79
|
+
candidates() {
|
|
80
|
+
[ -n "${CREW_STUDIO_NODE:-}" ] && printf '%s\n' "$CREW_STUDIO_NODE"
|
|
81
|
+
printf 'node\n'
|
|
82
|
+
# Guarded, because that pipeline costs three processes whether or not anything is there,
|
|
83
|
+
# and a process is not always cheap: a bare /usr/bin/true measured 880-1483 ms on two machines
|
|
84
|
+
# against 3-5 ms on a healthy one, and one session traced that to endpoint software inspecting every
|
|
85
|
+
# process creation. A directory test costs none.
|
|
86
|
+
if [ -d "$RUNTIME" ]; then
|
|
87
|
+
ls -1d "$RUNTIME"/node-v*/ 2>/dev/null | sort -r | while read -r d; do
|
|
88
|
+
printf '%s\n' "${d}bin/node" "${d}node.exe"
|
|
89
|
+
done
|
|
90
|
+
fi
|
|
91
|
+
printf '%s\n' \
|
|
92
|
+
"$HOME"/.nvm/versions/node/*/bin/node \
|
|
93
|
+
"$HOME"/.local/share/fnm/node-versions/*/installation/bin/node \
|
|
94
|
+
"$HOME"/.fnm/node-versions/*/installation/bin/node \
|
|
95
|
+
"$HOME"/.volta/tools/image/node/*/bin/node \
|
|
96
|
+
"$HOME"/.asdf/installs/nodejs/*/bin/node \
|
|
97
|
+
"$HOME"/n/bin/node \
|
|
98
|
+
/opt/homebrew/bin/node \
|
|
99
|
+
/usr/local/bin/node \
|
|
100
|
+
/usr/bin/node \
|
|
101
|
+
"${LOCALAPPDATA:-$HOME/AppData/Local}/Programs/nodejs/node.exe" \
|
|
102
|
+
"${ProgramFiles:-/c/Program Files}/nodejs/node.exe" \
|
|
103
|
+
"/c/Program Files/nodejs/node.exe" 2>/dev/null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
resolve() {
|
|
107
|
+
candidates | while read -r c; do
|
|
108
|
+
[ -n "$c" ] || continue
|
|
109
|
+
if works "$c"; then printf '%s\n' "$c"; return 0; fi
|
|
110
|
+
done | head -1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# --- platform ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
plat_arch() {
|
|
116
|
+
p="" a=""
|
|
117
|
+
case "$(uname -s 2>/dev/null)" in
|
|
118
|
+
Darwin) p=darwin ;;
|
|
119
|
+
Linux) p=linux ;;
|
|
120
|
+
MINGW*|MSYS*|CYGWIN*|Windows_NT) p=win ;;
|
|
121
|
+
*) return 1 ;;
|
|
122
|
+
esac
|
|
123
|
+
case "$(uname -m 2>/dev/null)" in
|
|
124
|
+
arm64|aarch64) a=arm64 ;;
|
|
125
|
+
x86_64|amd64) a=x64 ;;
|
|
126
|
+
armv7l) a=armv7l ;;
|
|
127
|
+
*) return 1 ;;
|
|
128
|
+
esac
|
|
129
|
+
printf '%s %s\n' "$p" "$a"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fetcher() {
|
|
133
|
+
if command -v curl >/dev/null 2>&1; then printf 'curl\n'
|
|
134
|
+
elif command -v wget >/dev/null 2>&1; then printf 'wget\n'
|
|
135
|
+
else return 1; fi
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get() { # get <url> <dest|->
|
|
139
|
+
case "$(fetcher)" in
|
|
140
|
+
curl) curl -fsSL --max-time 600 -o "$2" "$1" ;;
|
|
141
|
+
wget) wget -q -T 600 -O "$2" "$1" ;;
|
|
142
|
+
*) return 1 ;;
|
|
143
|
+
esac
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# No checksum tool means no install. An unverified runtime is a worse answer than an honest
|
|
147
|
+
# refusal, so this fails closed rather than trusting the transfer.
|
|
148
|
+
#
|
|
149
|
+
# And the tool is not trusted for resolving either. A name that resolves is not a working
|
|
150
|
+
# program — the same lesson as the Windows Store's node stub — and a hash tool that returns
|
|
151
|
+
# something plausible but wrong would turn the verification step into theatre. So each
|
|
152
|
+
# candidate is run against an answer already known, and the first one that gets it right is
|
|
153
|
+
# the one used.
|
|
154
|
+
SHA_TOOL=""
|
|
155
|
+
SHA_VECTOR=ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad # sha256("abc")
|
|
156
|
+
|
|
157
|
+
sha256_with() { # sha256_with <tool> <file>
|
|
158
|
+
# Three of the four read stdin, and that is why they are fed that way: given a path they
|
|
159
|
+
# print it beside the digest, and a path is attacker-shaped input as far as a parser is
|
|
160
|
+
# concerned. Measured: with the file under a directory whose name is 64 hex characters,
|
|
161
|
+
# openssl and certutil put that path where a first-match rule finds it and the wrong value
|
|
162
|
+
# gets compared. From stdin there is no path in the output at all.
|
|
163
|
+
#
|
|
164
|
+
# certutil cannot be fed stdin, so it is read from the end instead: it prints the path in a
|
|
165
|
+
# header line before the digest, and nothing after the digest is hex.
|
|
166
|
+
pick=first
|
|
167
|
+
case "$1" in
|
|
168
|
+
sha256sum) out="$(sha256sum < "$2" 2>/dev/null)" ;;
|
|
169
|
+
shasum) out="$(shasum -a 256 < "$2" 2>/dev/null)" ;;
|
|
170
|
+
openssl) out="$(openssl dgst -sha256 < "$2" 2>/dev/null)" ;;
|
|
171
|
+
certutil) out="$(certutil -hashfile "$2" SHA256 2>/dev/null)"; pick=last ;;
|
|
172
|
+
*) return 1 ;;
|
|
173
|
+
esac
|
|
174
|
+
# Four tools, four shapes, and a per-tool field rule gets one of them wrong every time:
|
|
175
|
+
# sha256sum / shasum <hash> *file hash is field 1
|
|
176
|
+
# openssl 3.x SHA2-256(file)= <hash>\r hash is the LAST field, label is not "SHA256"
|
|
177
|
+
# certutil a header line, then <hash>\r, then a footer not a field at all
|
|
178
|
+
# Windows builds also append CR, and a trailing CR is enough to fail the comparison and
|
|
179
|
+
# silently drop a tool that works. One rule covers all four shapes AND the CR: take the
|
|
180
|
+
# first 64-hex token anywhere in the output. CR is not a hex character, so it ends a token
|
|
181
|
+
# rather than joining one — an explicit strip here would be a line no test could fail.
|
|
182
|
+
printf '%s' "$out" | tr 'A-Z' 'a-z' | tr -cs '0-9a-f' '\n' |
|
|
183
|
+
awk -v pick="$pick" '
|
|
184
|
+
length($0) == 64 { h = $0; if (pick == "first") { print; exit } }
|
|
185
|
+
END { if (pick == "last" && h != "") print h }'
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
sha256_calibrate() {
|
|
189
|
+
[ -n "$SHA_TOOL" ] && return 0
|
|
190
|
+
probe="$(mktemp 2>/dev/null || printf '%s/crew-sha-%s' "${TMPDIR:-/tmp}" "$$")" || return 1
|
|
191
|
+
printf abc > "$probe" || { rm -f "$probe"; return 1; }
|
|
192
|
+
for t in sha256sum shasum openssl certutil; do
|
|
193
|
+
command -v "$t" >/dev/null 2>&1 || continue
|
|
194
|
+
if [ "$(sha256_with "$t" "$probe")" = "$SHA_VECTOR" ]; then SHA_TOOL="$t"; break; fi
|
|
195
|
+
done
|
|
196
|
+
rm -f "$probe"
|
|
197
|
+
[ -n "$SHA_TOOL" ]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
sha256_of() {
|
|
201
|
+
sha256_calibrate || return 1
|
|
202
|
+
sha256_with "$SHA_TOOL" "$1"
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# index.tab is the same release list as index.json in a form that needs no JSON parser —
|
|
206
|
+
# which matters, because the machine running this is by definition the one without Node.
|
|
207
|
+
# Column 10 is `lts`, and `-` there means the release is not one.
|
|
208
|
+
newest_lts() {
|
|
209
|
+
[ -n "${CREW_STUDIO_NODE_VERSION:-}" ] && { printf '%s\n' "$CREW_STUDIO_NODE_VERSION"; return 0; }
|
|
210
|
+
tab="$(get "$DIST/index.tab" - 2>/dev/null)" || return 1
|
|
211
|
+
printf '%s\n' "$tab" | awk -F'\t' 'NR>1 && $10!="-" && $10!="" { print $1; exit }'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# Exit codes are not the answer here, so every branch is judged by what landed on disk.
|
|
215
|
+
# Measured on Windows 11: Info-ZIP's unzip returns 1 for "extracted, with warnings" — files
|
|
216
|
+
# present, status 1 — so `unzip || fallback` runs the fallback after a success and
|
|
217
|
+
# `unzip && ok` calls a success a failure. What the archive was meant to leave behind is the
|
|
218
|
+
# only thing worth checking.
|
|
219
|
+
extract() { # extract <archive> <into> <expected-dir-name>
|
|
220
|
+
case "$1" in
|
|
221
|
+
*.zip)
|
|
222
|
+
tar -xf "$1" -C "$2" 2>/dev/null # bsdtar reads zip; GNU tar, on Git Bash, does not
|
|
223
|
+
[ -d "$2/$3" ] && return 0
|
|
224
|
+
|
|
225
|
+
if command -v unzip >/dev/null 2>&1; then
|
|
226
|
+
unzip -q -o "$1" -d "$2" >/dev/null 2>&1
|
|
227
|
+
[ -d "$2/$3" ] && return 0
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
if command -v powershell >/dev/null 2>&1; then
|
|
231
|
+
# Expand-Archive is a Windows program and reads Windows paths. Handed a POSIX one it
|
|
232
|
+
# exits 1 having written nothing — measured, not assumed — so convert first.
|
|
233
|
+
#
|
|
234
|
+
# -wa, not -w: -w leaves a relative path relative, and measured on Windows, a relative
|
|
235
|
+
# path handed to a Windows program that is not running from the same directory fails
|
|
236
|
+
# to find the file. That failure would arrive as "the download is broken", which is
|
|
237
|
+
# the wrong suspect.
|
|
238
|
+
pa="$1"; pb="$2"
|
|
239
|
+
if command -v cygpath >/dev/null 2>&1; then
|
|
240
|
+
pa="$(cygpath -wa "$1" 2>/dev/null || printf '%s' "$1")"
|
|
241
|
+
pb="$(cygpath -wa "$2" 2>/dev/null || printf '%s' "$2")"
|
|
242
|
+
fi
|
|
243
|
+
# Said BEFORE, not after. Measured on Windows 11: Expand-Archive takes 26 s on Node's
|
|
244
|
+
# zip where unzip takes 3 s — and this branch only runs on machines with no unzip, so
|
|
245
|
+
# the person who waits is the one with no alternative. A minute of silence reads as a
|
|
246
|
+
# hang, and by the time an explanation arrives they have already decided that.
|
|
247
|
+
printf 'unpacking with PowerShell (no unzip on this machine) — this can take up to a minute\n' >&2
|
|
248
|
+
powershell -NoProfile -Command "Expand-Archive -LiteralPath '$pa' -DestinationPath '$pb' -Force" >/dev/null 2>&1
|
|
249
|
+
[ -d "$2/$3" ] && return 0
|
|
250
|
+
fi
|
|
251
|
+
return 1 ;;
|
|
252
|
+
*)
|
|
253
|
+
tar -xzf "$1" -C "$2" 2>/dev/null
|
|
254
|
+
[ -d "$2/$3" ] && return 0
|
|
255
|
+
return 1 ;;
|
|
256
|
+
esac
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# --- the plan ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
# Everything --install would do, computed without doing any of it. Kept as one function so
|
|
262
|
+
# --plan and --install cannot describe different downloads.
|
|
263
|
+
plan() {
|
|
264
|
+
pa="$(plat_arch)" || die "ensure-node.sh: unsupported platform $(uname -s 2>/dev/null)/$(uname -m 2>/dev/null) — nodejs.org publishes no build for it."
|
|
265
|
+
PLAT="${pa%% *}"; ARCH="${pa##* }"
|
|
266
|
+
fetcher >/dev/null || die "ensure-node.sh: neither curl nor wget is here, so nothing can be fetched. Install Node yourself: https://nodejs.org"
|
|
267
|
+
VERSION="$(newest_lts)" || VERSION=""
|
|
268
|
+
if [ -z "$VERSION" ]; then
|
|
269
|
+
VERSION="$FALLBACK_VERSION"
|
|
270
|
+
VERSION_SOURCE="pinned fallback — nodejs.org could not be reached to ask"
|
|
271
|
+
elif [ -n "${CREW_STUDIO_NODE_VERSION:-}" ]; then
|
|
272
|
+
VERSION_SOURCE="pinned by CREW_STUDIO_NODE_VERSION"
|
|
273
|
+
else
|
|
274
|
+
VERSION_SOURCE="newest LTS on nodejs.org"
|
|
275
|
+
fi
|
|
276
|
+
case "$PLAT" in win) EXT=zip ;; *) EXT=tar.gz ;; esac
|
|
277
|
+
ARTIFACT="node-$VERSION-$PLAT-$ARCH.$EXT"
|
|
278
|
+
URL="$DIST/$VERSION/$ARTIFACT"
|
|
279
|
+
SUMS="$DIST/$VERSION/SHASUMS256.txt"
|
|
280
|
+
TARGET="$RUNTIME/node-$VERSION-$PLAT-$ARCH"
|
|
281
|
+
|
|
282
|
+
# Asked here, before anything is fetched. Learning that the runtime cannot fit after a 37 MB
|
|
283
|
+
# download and a 17-second failure is the wrong order, and the failure names the archive when
|
|
284
|
+
# the archive is fine. Only when unzip is absent: unzip is not bound by the cap, so where it
|
|
285
|
+
# exists the chain stops there and this does not apply.
|
|
286
|
+
TOO_LONG=""; TOO_LONG_PATH=""
|
|
287
|
+
if [ "$PLAT" = win ] && ! command -v unzip >/dev/null 2>&1; then
|
|
288
|
+
# What to measure, and against what, have to be the same thing — this is where the two
|
|
289
|
+
# halves of the arithmetic were mismatched twice.
|
|
290
|
+
#
|
|
291
|
+
# NODE_DEEPEST_ENTRY is counted from the ARCHIVE ROOT, so it already contains the
|
|
292
|
+
# "node-<ver>-<plat>-<arch>/" segment. The object that pairs with it is therefore the
|
|
293
|
+
# directory the archive is expanded INTO — $RUNTIME/.tmp.<pid> — and not that directory
|
|
294
|
+
# plus the segment, which counts the same 22 characters twice and refuses paths that work.
|
|
295
|
+
# $TARGET is the wrong object for a different reason: the unpack happens under .tmp.<pid>
|
|
296
|
+
# and is moved afterwards, so $TARGET is shorter than what actually exists mid-install.
|
|
297
|
+
#
|
|
298
|
+
# A fixed widest tail, deliberately, and NOT the real `$$`. Using the actual pid looks
|
|
299
|
+
# more precise and is worse: measured on Windows, MSYS runs its own small pid space —
|
|
300
|
+
# three digits after a fresh boot, growing with uptime — so the accepted length would
|
|
301
|
+
# drift day to day on one machine, and `--plan` and `--install` are separate processes
|
|
302
|
+
# with different pids, so at the exact boundary they could disagree with each other about
|
|
303
|
+
# the same path. This check exists to give one definite answer before a 37 MB download.
|
|
304
|
+
# Seven digits is the widest a pid gets here; the cost is a couple of characters of
|
|
305
|
+
# conservatism, which is cheaper than a limit nobody can reproduce.
|
|
306
|
+
probe="$RUNTIME/.tmp.0000000"
|
|
307
|
+
if command -v cygpath >/dev/null 2>&1; then
|
|
308
|
+
probe="$(cygpath -wa "$probe" 2>/dev/null || printf '%s' "$probe")"
|
|
309
|
+
fi
|
|
310
|
+
# Both, because a message that names one path and reports another's length sends the
|
|
311
|
+
# reader to count the wrong thing — the same mismatch as the arithmetic, surviving in prose.
|
|
312
|
+
if [ "${#probe}" -gt "$WIN_DIR_BUDGET" ]; then TOO_LONG="${#probe}"; TOO_LONG_PATH="$probe"; fi
|
|
313
|
+
fi
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
human_size() {
|
|
317
|
+
b="$1"
|
|
318
|
+
case "$b" in ''|*[!0-9]*) printf 'size unknown\n'; return ;; esac
|
|
319
|
+
printf '%s MB\n' "$(( (b + 524288) / 1048576 ))"
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
remote_size() {
|
|
323
|
+
command -v curl >/dev/null 2>&1 || { printf 'unknown\n'; return; }
|
|
324
|
+
curl -fsSLI --max-time 30 "$1" 2>/dev/null |
|
|
325
|
+
awk 'tolower($1)=="content-length:"{v=$2} END{gsub(/\r/,"",v); print (v==""?"unknown":v)}'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# --- modes ---------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
# Everything above is definitions, so the file can be sourced to reach one function at a
|
|
331
|
+
# time. The checksum refusal and the empty-search branch are only reachable on a machine
|
|
332
|
+
# with no Node at all, and the tests run on machines that have one.
|
|
333
|
+
main() {
|
|
334
|
+
|
|
335
|
+
while [ $# -gt 0 ]; do
|
|
336
|
+
case "$1" in
|
|
337
|
+
--resolve) MODE=resolve ;;
|
|
338
|
+
--explain) MODE=resolve; EXPLAIN=1 ;;
|
|
339
|
+
--plan) MODE=plan ;;
|
|
340
|
+
--install) MODE=install ;;
|
|
341
|
+
--probe) MODE=probe; shift; PROBE="${1:-}" ;;
|
|
342
|
+
--yes|-y) YES=1 ;;
|
|
343
|
+
-h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
344
|
+
*) printf 'ensure-node.sh: unknown argument %s\n' "$1" >&2; exit 2 ;;
|
|
345
|
+
esac
|
|
346
|
+
shift
|
|
347
|
+
done
|
|
348
|
+
|
|
349
|
+
case "$MODE" in
|
|
350
|
+
probe)
|
|
351
|
+
works "${PROBE:-}" && exit 0 || exit 1
|
|
352
|
+
;;
|
|
353
|
+
|
|
354
|
+
resolve)
|
|
355
|
+
say "looking for a Node $FLOOR or newer"
|
|
356
|
+
found="$(resolve)"
|
|
357
|
+
if [ -n "$found" ]; then
|
|
358
|
+
# A bare name is not an answer. The caller hands this string to a launcher that may not share this PATH,
|
|
359
|
+
# and /crew-studio tells the model "use the path it prints" -- while this printed `node`, measured. Only one
|
|
360
|
+
# candidate can be a name (PATH's own), and works() has already run it, so `command -v` names the very file
|
|
361
|
+
# that answered. If it somehow resolves to nothing, keep the name rather than lose a working runtime.
|
|
362
|
+
case "$found" in
|
|
363
|
+
*/*) : ;;
|
|
364
|
+
*) _abs="$(command -v "$found" 2>/dev/null)"; [ -n "$_abs" ] && found="$_abs" ;;
|
|
365
|
+
esac
|
|
366
|
+
say "found: $found"
|
|
367
|
+
printf '%s\n' "$found"
|
|
368
|
+
exit 0
|
|
369
|
+
fi
|
|
370
|
+
say "nothing usable on PATH, in $RUNTIME, or in any version manager's directory"
|
|
371
|
+
exit 1
|
|
372
|
+
;;
|
|
373
|
+
|
|
374
|
+
plan)
|
|
375
|
+
plan
|
|
376
|
+
printf 'Crewforth Studio needs Node %s+. This machine has none that runs.\n\n' "$FLOOR"
|
|
377
|
+
printf ' fetch %s\n' "$URL"
|
|
378
|
+
printf ' size %s (%s)\n' "$(human_size "$(remote_size "$URL")")" "$VERSION_SOURCE"
|
|
379
|
+
printf ' verify %s (refuses to install if the hash does not match)\n' "$SUMS"
|
|
380
|
+
printf ' unpack %s\n' "$TARGET"
|
|
381
|
+
if [ -n "$TOO_LONG" ]; then
|
|
382
|
+
printf '\n %s\n' "WILL NOT WORK HERE. The archive is expanded here first:"
|
|
383
|
+
printf ' %s\n' "$TOO_LONG_PATH"
|
|
384
|
+
printf ' %s\n' "which is $TOO_LONG characters. Windows caps a full file path at $WIN_MAX_PATH, Node's deepest"
|
|
385
|
+
printf ' %s\n' "entry inside the zip is $NODE_DEEPEST_ENTRY, and this machine has no unzip — only PowerShell's"
|
|
386
|
+
printf ' %s\n' "Expand-Archive, which enforces the cap. Point CREW_STUDIO_RUNTIME somewhere shorter, or"
|
|
387
|
+
printf ' %s\n' "install unzip, which is not bound by it."
|
|
388
|
+
fi
|
|
389
|
+
printf '\nNothing outside that directory is touched: no admin rights, no package manager,\n'
|
|
390
|
+
printf 'no PATH or profile edit. Delete it and this never happened.\n'
|
|
391
|
+
printf '\n do it bash %s --install\n' "$0"
|
|
392
|
+
printf ' or your own way, if you would rather the system owned it:\n'
|
|
393
|
+
printf ' Windows winget install OpenJS.NodeJS.LTS\n'
|
|
394
|
+
printf ' macOS brew install node\n'
|
|
395
|
+
printf ' Linux see https://nodejs.org/en/download/package-manager\n'
|
|
396
|
+
exit 0
|
|
397
|
+
;;
|
|
398
|
+
|
|
399
|
+
install)
|
|
400
|
+
found="$(resolve)"
|
|
401
|
+
if [ -n "$found" ]; then printf '%s\n' "$found"; exit 0; fi
|
|
402
|
+
plan
|
|
403
|
+
|
|
404
|
+
[ -z "$TOO_LONG" ] ||
|
|
405
|
+
die "ensure-node.sh: the archive would be expanded into
|
|
406
|
+
$TOO_LONG_PATH
|
|
407
|
+
which is $TOO_LONG characters. Windows caps a full file path at $WIN_MAX_PATH, Node's deepest entry inside the zip
|
|
408
|
+
is $NODE_DEEPEST_ENTRY, and this machine has no unzip — only PowerShell's Expand-Archive, which enforces that cap.
|
|
409
|
+
Nothing was downloaded. Point CREW_STUDIO_RUNTIME somewhere shorter, or install unzip."
|
|
410
|
+
|
|
411
|
+
sha256_calibrate ||
|
|
412
|
+
die "ensure-node.sh: no SHA-256 tool here that returns the right answer for a known input (tried sha256sum, shasum, openssl, certutil), so a download could not be verified. Refusing to install one unchecked. Install Node yourself: https://nodejs.org"
|
|
413
|
+
|
|
414
|
+
if [ "$YES" != 1 ]; then
|
|
415
|
+
# `[ -r /dev/tty ]` is true on a machine that has one even when this process cannot
|
|
416
|
+
# open it. Opening it is the only honest test, and getting that wrong printed a
|
|
417
|
+
# prompt nobody could answer into a pipe.
|
|
418
|
+
reply=""
|
|
419
|
+
ask="Fetch $ARTIFACT ($(human_size "$(remote_size "$URL")")) into $RUNTIME? [y/N] "
|
|
420
|
+
if [ -t 0 ]; then
|
|
421
|
+
printf '%s' "$ask" >&2; read -r reply || reply=""
|
|
422
|
+
elif ( exec 3< /dev/tty ) 2>/dev/null; then
|
|
423
|
+
printf '%s' "$ask" >&2; read -r reply < /dev/tty || reply=""
|
|
424
|
+
else
|
|
425
|
+
die "ensure-node.sh: no terminal to ask on, and --yes was not given. See what it would do: bash $0 --plan"
|
|
426
|
+
fi
|
|
427
|
+
case "$reply" in y|Y|yes|YES) ;; *) die "ensure-node.sh: not fetching anything." ;; esac
|
|
428
|
+
fi
|
|
429
|
+
|
|
430
|
+
mkdir -p "$RUNTIME" || die "ensure-node.sh: cannot create $RUNTIME"
|
|
431
|
+
tmp="$RUNTIME/.tmp.$$"
|
|
432
|
+
rm -rf "$tmp"; mkdir -p "$tmp" || die "ensure-node.sh: cannot create $tmp"
|
|
433
|
+
trap 'rm -rf "$tmp"' EXIT INT TERM
|
|
434
|
+
|
|
435
|
+
printf 'fetching %s\n' "$URL" >&2
|
|
436
|
+
get "$URL" "$tmp/$ARTIFACT" || die "ensure-node.sh: download failed — $URL"
|
|
437
|
+
get "$SUMS" "$tmp/SHASUMS256.txt" || die "ensure-node.sh: could not fetch the checksum list, so the download cannot be verified. Nothing installed."
|
|
438
|
+
|
|
439
|
+
want="$(awk -v f="$ARTIFACT" '$2==f || $2=="*"f {print $1; exit}' "$tmp/SHASUMS256.txt")"
|
|
440
|
+
[ -n "$want" ] || die "ensure-node.sh: $ARTIFACT is not listed in SHASUMS256.txt. Nothing installed."
|
|
441
|
+
got="$(sha256_of "$tmp/$ARTIFACT")" || die "ensure-node.sh: could not hash the download. Nothing installed."
|
|
442
|
+
[ "$want" = "$got" ] || die "ensure-node.sh: checksum mismatch for $ARTIFACT
|
|
443
|
+
expected $want
|
|
444
|
+
got $got
|
|
445
|
+
Nothing installed."
|
|
446
|
+
printf 'checksum ok\n' >&2
|
|
447
|
+
|
|
448
|
+
src="node-$VERSION-$PLAT-$ARCH"
|
|
449
|
+
extract "$tmp/$ARTIFACT" "$tmp" "$src" ||
|
|
450
|
+
die "ensure-node.sh: unpacking $ARTIFACT did not produce $src (tried tar, unzip and powershell).
|
|
451
|
+
The archive matched its published checksum a moment ago, so this is the unpacker on this machine, not the download.
|
|
452
|
+
Nothing installed."
|
|
453
|
+
src="$tmp/$src"
|
|
454
|
+
|
|
455
|
+
rm -rf "$TARGET"
|
|
456
|
+
mkdir -p "$(dirname "$TARGET")"
|
|
457
|
+
mv "$src" "$TARGET" || die "ensure-node.sh: could not move the runtime into $TARGET"
|
|
458
|
+
|
|
459
|
+
bin="$TARGET/bin/node"; [ -f "$bin" ] || bin="$TARGET/node.exe"
|
|
460
|
+
works "$bin" || die "ensure-node.sh: unpacked $TARGET but its node does not run. Left in place so you can look at it."
|
|
461
|
+
printf 'installed %s\n' "$TARGET" >&2
|
|
462
|
+
printf '%s\n' "$bin"
|
|
463
|
+
exit 0
|
|
464
|
+
;;
|
|
465
|
+
esac
|
|
466
|
+
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
# Sourced for a test, or run for real. `${BASH_SOURCE[0]}` is this file either way; `$0` is
|
|
470
|
+
# this file only when it was executed.
|
|
471
|
+
if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then main "$@"; fi
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewforth/studio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Visual orchestration panel for Claude Code — see which agent is running and what it is doing.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"crewforth-studio": "server/index.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT"
|
|
14
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse bridge: park the decision, ask the panel, answer with an exit code.
|
|
3
|
+
#
|
|
4
|
+
# The harness kills a hook that outruns its timeout and then lets the tool
|
|
5
|
+
# proceed — measured on this machine: a hook sleeping past a 3s limit produced
|
|
6
|
+
# permission_denials=0 and the command ran. So this never reaches that limit.
|
|
7
|
+
# It decides for itself at CREW_GATE_WAIT seconds, well inside the timeout
|
|
8
|
+
# configured in the settings file, and a hook the harness never has to kill
|
|
9
|
+
# fails CLOSED.
|
|
10
|
+
#
|
|
11
|
+
# No jq: Crewforth's hooks run on stock Git Bash, which has none. The fields
|
|
12
|
+
# needed here are flat strings, and parameter expansion reads them without
|
|
13
|
+
# spawning anything.
|
|
14
|
+
#
|
|
15
|
+
# Usage: studio-gate.sh <spool-dir>
|
|
16
|
+
set -u
|
|
17
|
+
|
|
18
|
+
SPOOL="${1:-}"
|
|
19
|
+
[ -n "$SPOOL" ] || exit 0 # misconfigured: do not stand in the way
|
|
20
|
+
|
|
21
|
+
WAIT="${CREW_GATE_WAIT:-45}"
|
|
22
|
+
POLL_MS=150
|
|
23
|
+
|
|
24
|
+
IN=""
|
|
25
|
+
# Bounded read: an open-but-silent stdin must not hang the session.
|
|
26
|
+
while IFS= read -r -t "${CREW_STDIN_TIMEOUT:-5}" line || [ -n "$line" ]; do
|
|
27
|
+
IN="$IN$line"
|
|
28
|
+
line=""
|
|
29
|
+
done
|
|
30
|
+
|
|
31
|
+
# ---- flat-string extraction, no subprocesses -------------------------------
|
|
32
|
+
_field() { # _field <key> -> value on stdout
|
|
33
|
+
local r="${IN#*\"$1\":\"}"
|
|
34
|
+
[ "$r" = "$IN" ] && return 1
|
|
35
|
+
printf '%s' "${r%%\"*}"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
TOOL="$(_field tool_name || true)"
|
|
39
|
+
TUID="$(_field tool_use_id || true)"
|
|
40
|
+
SID="$(_field session_id || true)"
|
|
41
|
+
[ -n "$TOOL" ] || exit 0 # nothing to ask about
|
|
42
|
+
|
|
43
|
+
# Ids become filenames; anything unexpected is refused rather than sanitised
|
|
44
|
+
# into something that might escape the spool.
|
|
45
|
+
case "$TUID" in
|
|
46
|
+
''|*[!A-Za-z0-9_-]*) TUID="unknown-$$" ;;
|
|
47
|
+
esac
|
|
48
|
+
|
|
49
|
+
REQ="$SPOOL/req"; ANS="$SPOOL/ans"; ALWAYS="$SPOOL/always"
|
|
50
|
+
mkdir -p "$REQ" "$ANS" "$ALWAYS" 2>/dev/null || exit 0
|
|
51
|
+
|
|
52
|
+
# A tool the panel already blanket-approved for this session skips the round trip.
|
|
53
|
+
case "$TOOL" in
|
|
54
|
+
*[!A-Za-z0-9_-]*) ;; # odd tool name: never treated as approved
|
|
55
|
+
*) [ -f "$ALWAYS/$TOOL" ] && exit 0 ;;
|
|
56
|
+
esac
|
|
57
|
+
|
|
58
|
+
# The request carries the whole hook payload, so the panel can show the command
|
|
59
|
+
# itself rather than just the tool's name.
|
|
60
|
+
printf '%s' "$IN" > "$REQ/$TUID.json" 2>/dev/null || exit 0
|
|
61
|
+
|
|
62
|
+
# The wait must not cost a process per poll. On Git Bash a fork is 20-50 ms, so
|
|
63
|
+
# a naive `date` + `sleep` loop spends seconds of pure overhead on every prompt
|
|
64
|
+
# and freezes the session it is meant to be guarding.
|
|
65
|
+
#
|
|
66
|
+
# deadline -> SECONDS, a shell builtin, instead of `date +%s`
|
|
67
|
+
# the pause -> a bounded read on a fifo held open read-write, which blocks for
|
|
68
|
+
# the timeout and never sees EOF; zero processes per iteration
|
|
69
|
+
#
|
|
70
|
+
# The fifo is probed rather than assumed: it is emulated on Windows and may
|
|
71
|
+
# return immediately. If it does, fall back to sleep at a coarser interval.
|
|
72
|
+
FIFO="$SPOOL/.wait.$$"
|
|
73
|
+
|
|
74
|
+
# Pausing without forking. On Git Bash a fork is 20-50 ms, so a `sleep` per poll
|
|
75
|
+
# spends seconds of overhead on a prompt that is supposed to feel instant.
|
|
76
|
+
#
|
|
77
|
+
# A bounded read on a fifo held open read-write blocks for its timeout and never
|
|
78
|
+
# sees EOF, which costs no process at all. Two things about it have to be
|
|
79
|
+
# measured rather than assumed:
|
|
80
|
+
#
|
|
81
|
+
# - the fifo may be emulated (Windows) and return immediately
|
|
82
|
+
# - bash 3.2, still the system shell on macOS, ignores a FRACTIONAL -t and
|
|
83
|
+
# returns instantly, turning the poll into a hot spin on a whole core
|
|
84
|
+
#
|
|
85
|
+
# So the probe runs exactly what the loop will run. Probing with a different
|
|
86
|
+
# timeout than the loop uses is how the first version of this passed while
|
|
87
|
+
# spinning 185,000 times in five seconds.
|
|
88
|
+
fifo_fast() { read -r -t 0.15 _ <&9 2>/dev/null || :; }
|
|
89
|
+
fifo_slow() { read -r -t 1 _ <&9 2>/dev/null || :; }
|
|
90
|
+
sleep_pause() { sleep 1; }
|
|
91
|
+
|
|
92
|
+
PAUSE=sleep_pause
|
|
93
|
+
|
|
94
|
+
# The probe costs about a second, and it answers the same question every time
|
|
95
|
+
# for a given machine. Measure once per spool and remember.
|
|
96
|
+
CACHE="$SPOOL/.pause-mode"
|
|
97
|
+
if [ -f "$CACHE" ]; then
|
|
98
|
+
read -r _cached < "$CACHE" 2>/dev/null || _cached=""
|
|
99
|
+
case "$_cached" in
|
|
100
|
+
fifo_fast|fifo_slow|sleep_pause) PAUSE="$_cached" ;;
|
|
101
|
+
esac
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
_probe() { # _probe <fn> <min-ms>; 0 if it really waited
|
|
105
|
+
local t0 t1
|
|
106
|
+
t0=$SECONDS
|
|
107
|
+
"$1"
|
|
108
|
+
t1=$SECONDS
|
|
109
|
+
[ $(( t1 - t0 )) -ge "$2" ]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if [ "$PAUSE" = sleep_pause ] && [ ! -f "$CACHE" ] && mkfifo "$FIFO" 2>/dev/null; then
|
|
113
|
+
# `exec` with redirections and no command rebinds the SHELL's descriptors, so
|
|
114
|
+
# `exec 9<>"$FIFO" 2>/dev/null` silences stderr for the rest of the hook's
|
|
115
|
+
# life — every denial message with it. Save it, then put it back.
|
|
116
|
+
exec 7>&2
|
|
117
|
+
if exec 9<>"$FIFO" 2>/dev/null; then
|
|
118
|
+
exec 2>&7 7>&-
|
|
119
|
+
rm -f "$FIFO" 2>/dev/null # unlinked; the open descriptor keeps it alive
|
|
120
|
+
# Fractional first. Ten of them must add up to at least a second, or this
|
|
121
|
+
# shell is not honouring the fraction.
|
|
122
|
+
_t0=$SECONDS
|
|
123
|
+
fifo_fast; fifo_fast; fifo_fast; fifo_fast; fifo_fast
|
|
124
|
+
fifo_fast; fifo_fast; fifo_fast; fifo_fast; fifo_fast
|
|
125
|
+
if [ $(( SECONDS - _t0 )) -ge 1 ]; then
|
|
126
|
+
PAUSE=fifo_fast
|
|
127
|
+
elif _probe fifo_slow 1; then
|
|
128
|
+
PAUSE=fifo_slow # whole seconds, still no forks
|
|
129
|
+
fi
|
|
130
|
+
else
|
|
131
|
+
exec 2>&7 7>&-
|
|
132
|
+
rm -f "$FIFO" 2>/dev/null
|
|
133
|
+
fi
|
|
134
|
+
printf '%s\n' "$PAUSE" > "$CACHE" 2>/dev/null || :
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# A cached fifo mode still needs its descriptor in this process.
|
|
138
|
+
case "$PAUSE" in
|
|
139
|
+
fifo_*)
|
|
140
|
+
if [ ! -e /dev/fd/9 ]; then
|
|
141
|
+
if mkfifo "$FIFO" 2>/dev/null; then
|
|
142
|
+
exec 7>&2
|
|
143
|
+
exec 9<>"$FIFO" 2>/dev/null || PAUSE=sleep_pause
|
|
144
|
+
exec 2>&7 7>&-
|
|
145
|
+
rm -f "$FIFO" 2>/dev/null
|
|
146
|
+
else
|
|
147
|
+
PAUSE=sleep_pause
|
|
148
|
+
fi
|
|
149
|
+
fi
|
|
150
|
+
;;
|
|
151
|
+
esac
|
|
152
|
+
|
|
153
|
+
START=$SECONDS
|
|
154
|
+
while :; do
|
|
155
|
+
if [ -f "$ANS/$TUID" ]; then
|
|
156
|
+
read -r VERDICT < "$ANS/$TUID" 2>/dev/null || VERDICT=deny
|
|
157
|
+
rm -f "$ANS/$TUID" "$REQ/$TUID.json" 2>/dev/null
|
|
158
|
+
case "$VERDICT" in
|
|
159
|
+
allow|always) exit 0 ;;
|
|
160
|
+
*) printf 'studio: denied in the panel\n' >&2; exit 2 ;;
|
|
161
|
+
esac
|
|
162
|
+
fi
|
|
163
|
+
[ $(( SECONDS - START )) -ge "$WAIT" ] && break
|
|
164
|
+
"$PAUSE"
|
|
165
|
+
done
|
|
166
|
+
|
|
167
|
+
rm -f "$REQ/$TUID.json" 2>/dev/null
|
|
168
|
+
printf 'studio: no answer within %ss — denied\n' "$WAIT" >&2
|
|
169
|
+
exit 2
|