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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/openfactor/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ let
pkgs.curl
pkgs.gnutar
pkgs.gzip
pkgs.jq
];
in
{
Expand Down
37 changes: 37 additions & 0 deletions config/openfactor/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,34 @@ if ! "$OPENFACTOR_BIN" hooks install --scope host --json; then
echo "warning: OpenFactor hook registration failed; continuing activation" >&2
fi

# Each hook boots the ~90 MB Bun CLI (~0.7 CPU-seconds) only to POST the event
# to the orchestration daemon socket, and drops it when no daemon listens there.
# Check the socket first so tool-call hooks cost nothing outside orchestrated
# lanes. Prompt events stay ungated: the CLI records lane prompt receipts from
# them without a daemon. The gated command still names openfactor, so later
# `hooks install` runs keep it instead of appending a second entry.
# shellcheck disable=SC2016
socket_gate='test -S "${OPENFACTOR_ORCHESTRATION_SOCKET:-${OPENFACTOR_ORCHESTRATION_STATE:-.orchestration-state}/daemon.sock}" || exit 0; exec '
Comment thread
shunkakinoki marked this conversation as resolved.
Resolved
receipts="${XDG_STATE_HOME:-$HOME/.local/state}/openfactor/hooks"
for receipt in "$receipts"/*.json; do
[[ -f $receipt ]] || continue
config="$(jq -r '.configPath // empty' "$receipt" 2>/dev/null || true)"
[[ $config == *.json && -f $config && ! -L $config ]] || continue
gated="$(mktemp "$config.XXXXXX")"
if jq --arg gate "$socket_gate" '
walk(
if type == "string"
and test("^openfactor [a-z]+-hook [A-Za-z.]+$")
and (test("[Pp]rompt") | not)
then "sh -c '\''" + $gate + . + "'\''"
else . end
)' "$config" >"$gated" && ! cmp -s "$gated" "$config"; then
mv "$gated" "$config"
else
rm -f "$gated"
fi
done

# The generated OpenCode plugin spawns one CLI process per event, and the
# message part events fire on every streamed token. Under a busy T3 server that
# forks hundreds of hooks per second and drives the host load into the
Expand Down Expand Up @@ -80,6 +108,15 @@ if [[ -f $opencode_plugin && ! -L $opencode_plugin ]] && grep -q 'stdin: "pipe"'
' "$opencode_plugin"
fi

# Same socket gate as the JSON hooks, applied before the plugin spawns the CLI.
if [[ -f $opencode_plugin && ! -L $opencode_plugin ]] && ! grep -q 'daemon\.sock' "$opencode_plugin"; then
# shellcheck disable=SC2016
"${OPENFACTOR_PERL:-perl}" -0pi -e '
s{closeSync, openSync, unlinkSync}{closeSync, existsSync, openSync, unlinkSync};
s{(function send\(name: string, payload: unknown\): void \{\n)}{$1 if (name !== "chat.message" && !existsSync(process.env.OPENFACTOR_ORCHESTRATION_SOCKET || join(process.env.OPENFACTOR_ORCHESTRATION_STATE || ".orchestration-state", "daemon.sock"))) return;\n};
' "$opencode_plugin"
fi

# The generated pi extension writes each payload to the hook over a socketpair.
# pi exits right after session_shutdown, and the Bun-built CLI then blocks on
# that stdin forever, so every pi run strands a few hooks. Hand the payload
Expand Down
53 changes: 52 additions & 1 deletion spec/openfactor_spec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,63 @@ TS
When run env -u XDG_CONFIG_HOME HOME="$TMP_HOME" OPENFACTOR_BIN=/usr/bin/true "$BASH_BIN" "$SCRIPT"
The status should be success
The contents of file "$PLUGIN" should include '{ stdin: fd, stdout: "ignore", stderr: "ignore" }'
The contents of file "$PLUGIN" should include 'import { closeSync, openSync, unlinkSync, writeFileSync } from "node:fs";'
The contents of file "$PLUGIN" should include 'import { closeSync, existsSync, openSync, unlinkSync, writeFileSync } from "node:fs";'
The contents of file "$PLUGIN" should include 'if (name !== "chat.message" && !existsSync('
The contents of file "$PLUGIN" should include '"message.updated"'
The contents of file "$PLUGIN" should not include '"pipe"'
The contents of file "$PLUGIN" should not include 'message.part'
End

Describe 'daemon socket gate'
setup_gate() {
TMP_HOME="$(mktemp -d)"
CONFIG="$TMP_HOME/.claude/settings.json"
mkdir -p "$TMP_HOME/.claude" "$TMP_HOME/.local/state/openfactor/hooks" "$TMP_HOME/bin"
printf '{"configPath":"%s"}\n' "$CONFIG" >"$TMP_HOME/.local/state/openfactor/hooks/claude-host.json"
cat >"$CONFIG" <<'JSON'
{"hooks":{
"PreToolUse":[{"hooks":[{"type":"command","command":"openfactor claude-hook PreToolUse"}]}],
"UserPromptSubmit":[{"hooks":[{"type":"command","command":"openfactor claude-hook UserPromptSubmit"}]}],
"Stop":[{"hooks":[{"type":"command","command":"other-tool openfactor claude-hook Stop"}]}]
}}
JSON
printf '#!/bin/sh\necho "ran $*"\n' >"$TMP_HOME/bin/openfactor"
chmod +x "$TMP_HOME/bin/openfactor"
}
cleanup_gate() { rm -rf "$TMP_HOME"; }
BeforeEach 'setup_gate'
AfterEach 'cleanup_gate'

install_twice() {
env -u XDG_STATE_HOME HOME="$TMP_HOME" OPENFACTOR_BIN=/usr/bin/true bash "$SCRIPT" &&
env -u XDG_STATE_HOME HOME="$TMP_HOME" OPENFACTOR_BIN=/usr/bin/true bash "$SCRIPT" &&
jq -r '.hooks | .PreToolUse[0].hooks[0].command, .UserPromptSubmit[0].hooks[0].command, .Stop[0].hooks[0].command' "$CONFIG"
}

It 'gates tool hooks on the daemon socket and leaves prompt hooks alone'
When run install_twice
The status should be success
The line 1 of output should equal "sh -c 'test -S \"\${OPENFACTOR_ORCHESTRATION_SOCKET:-\${OPENFACTOR_ORCHESTRATION_STATE:-.orchestration-state}/daemon.sock}\" || exit 0; exec openfactor claude-hook PreToolUse'"
The line 2 of output should equal 'openfactor claude-hook UserPromptSubmit'
The line 3 of output should equal 'other-tool openfactor claude-hook Stop'
End

run_gated() {
env -u XDG_STATE_HOME HOME="$TMP_HOME" OPENFACTOR_BIN=/usr/bin/true bash "$SCRIPT" &&
cd "$TMP_HOME" &&
PATH="$TMP_HOME/bin:$PATH" sh -c "$(jq -r '.hooks.PreToolUse[0].hooks[0].command' "$CONFIG")" &&
mkdir -p .orchestration-state &&
python3 -c 'import socket,sys; socket.socket(socket.AF_UNIX).bind(sys.argv[1])' .orchestration-state/daemon.sock &&
PATH="$TMP_HOME/bin:$PATH" sh -c "$(jq -r '.hooks.PreToolUse[0].hooks[0].command' "$CONFIG")"
}

It 'runs the CLI only when the daemon socket exists'
When run run_gated
The status should be success
The output should equal 'ran claude-hook PreToolUse'
End
End

It 'warns and continues when OPENFACTOR_BIN is not executable'
TMP_HOME="$(mktemp -d)"
When run env HOME="$TMP_HOME" OPENFACTOR_BIN="$TMP_HOME/missing" "$BASH_BIN" "$SCRIPT"
Expand Down
Loading