@estebanforge/pi-antigravity-bridge 1.6.2 → 1.6.4
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 +6 -0
- package/README.md +1 -1
- package/extensions/index.ts +17 -8
- package/package.json +5 -21
- package/src/approval-hook.ts +116 -98
- package/src/driver.ts +6 -1
- package/docs/ACP-ADOPTION-PLAN.md +0 -940
- package/docs/ACP-PROTOCOL-REFERENCE.md +0 -462
- package/docs/ANTIGRAVITY-INTEGRATIONS.md +0 -50
- package/docs/APPROVAL-GATE.md +0 -33
- package/docs/ARCHITECTURE.md +0 -116
- package/docs/DEVELOPMENT.md +0 -110
- package/docs/ENGINES.md +0 -46
- package/docs/PI-BRIDGE-GAPS.md +0 -97
- package/docs/TODO.md +0 -21
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.6.3] - 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **The approval gate no longer leaks into standalone sessions, and its verdicts parse.** Thanks @explesy for the report (https://github.com/EstebanForge/pi-antigravity-bridge/issues/5). Two defects in the generated hook script and its staging. Every terminal path printed the bare decision scalar (`"deny"` / `"allow"`) instead of a JSON object, so any session that loaded a staged gate failed hook parsing outright (`protojson: syntax error`): the direct-POST and polling branches now print the full response object guarded by a `typeof json.decision === "string"` check, fall back to a fail-closed deny object on malformed bodies, and read ticket and poll status through `json?.`. The gate group itself was staged into the shared workspace `.agents/hooks.json`, so any standalone IDE or CLI session in that workspace loaded pi's gate: tool calls were denied with "no active antigravity turn" while pi sat idle, and mid-turn they parked into pi's approval queue. A live probe of the CLI settled the discovery rule (hooks load from `--add-dir` directories in stream-json and interactive sessions; print mode reads only the global config), and the gate now stages `.agents/hooks.json` into the session-private per-pid bridge dir beside `mcp_config.json`, which only this session's agy receives as an extra `--add-dir`. Staging into a pid-private file dropped the merge/backup/symlink surface to a plain atomic replace; the driver passes the dir when the gate is staged even without an MCP config; a legacy sweep at session start strips groups that dead sessions left in workspace files (live and foreign groups are never touched). The hook also gained per-fetch aborts (`AbortSignal.timeout`, POST capped at 10s, polls bounded by the remaining park budget) so a hung bridge request can never outlive the park deadline into an ungated soft-pass. Seven stub-server hook-execution regression tests pin the object contract; the staging suite covers the private-dir semantics and the sweep. 364 tests, tsc clean.
|
|
10
|
+
|
|
5
11
|
## [1.6.2] - 2026-09-19
|
|
6
12
|
|
|
7
13
|
### Fixed
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ A Gemini model provider **and** the `AskAntigravity` delegation tool for [pi](ht
|
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
If you also have [`@estebanforge/pi-ask-antigravity`](https://github.com/EstebanForge/pi-ask-antigravity) installed, this bridge takes over: pi-ask-antigravity detects the bridge and registers nothing, so the `AskAntigravity` tool is never duplicated.
|
|
16
|
+
If you also have [`@estebanforge/pi-ask-antigravity`](https://github.com/EstebanForge/pi-extensions/tree/main/packages/pi-ask-antigravity) installed, this bridge takes over: pi-ask-antigravity detects the bridge and registers nothing, so the `AskAntigravity` tool is never duplicated.
|
|
17
17
|
|
|
18
18
|
## What it does
|
|
19
19
|
|
package/extensions/index.ts
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
type GatePolicy,
|
|
59
59
|
} from "../src/approval-gate.js";
|
|
60
60
|
import { detectPermissionGateExtensions, resolveGateMode } from "../src/approval-detect.js";
|
|
61
|
-
import { hookScriptSource, removeGateHooks, stageGateHooks } from "../src/approval-hook.js";
|
|
61
|
+
import { hookScriptSource, removeGateHooks, stageGateHooks, sweepWorkspaceGateGroups } from "../src/approval-hook.js";
|
|
62
62
|
import { StreamDriver } from "../src/driver.js";
|
|
63
63
|
import { AcpDriver } from "../src/acp/driver.js";
|
|
64
64
|
import { runAcpAuth } from "../src/acp/auth.js";
|
|
@@ -69,7 +69,7 @@ import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, sa
|
|
|
69
69
|
import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
|
|
70
70
|
import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
|
|
71
71
|
import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
|
|
72
|
-
import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
|
|
72
|
+
import { bridgeMcpConfigDir, startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
|
|
73
73
|
import {
|
|
74
74
|
registerBridgeServer,
|
|
75
75
|
healBridgeSuppression,
|
|
@@ -752,11 +752,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
752
752
|
// config value never lies silently.
|
|
753
753
|
fileLog.log("approval-dedicated-as-shadow", {}, "warn");
|
|
754
754
|
}
|
|
755
|
+
// Legacy cleanup (issue #5): 1.6.x staged the gate into the shared
|
|
756
|
+
// workspace hooks.json, where standalone IDE/CLI sessions load it.
|
|
757
|
+
// Staging now lives in the session-private add-dir; sweep groups left
|
|
758
|
+
// behind by dead sessions from the old scheme. Live sessions' and
|
|
759
|
+
// foreign groups are never touched.
|
|
760
|
+
const sweptLegacy = sweepWorkspaceGateGroups(process.cwd());
|
|
761
|
+
if (sweptLegacy > 0) fileLog.log("approval-workspace-swept", { swept: sweptLegacy }, "info");
|
|
755
762
|
if (mode === "off") {
|
|
756
|
-
// Gate off:
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
const unstaged = removeGateHooks(process.cwd());
|
|
763
|
+
// Gate off: drop this session's private hooks file. Other sessions
|
|
764
|
+
// own their own per-pid files; nothing shared to preserve.
|
|
765
|
+
const unstaged = removeGateHooks();
|
|
760
766
|
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
761
767
|
} else {
|
|
762
768
|
// Script: per-pid file; 0600 because the bridge token is
|
|
@@ -773,7 +779,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
773
779
|
{ mode: 0o600 },
|
|
774
780
|
);
|
|
775
781
|
gateScriptPath = scriptPath;
|
|
776
|
-
|
|
782
|
+
// Private per-pid staging (issue #5 isolation): the gate group goes
|
|
783
|
+
// into the bridge add-dir that ONLY this session's agy receives.
|
|
784
|
+
// Standalone sessions in the workspace never load it.
|
|
785
|
+
const staged = stageGateHooks(bridgeMcpConfigDir(), {
|
|
777
786
|
port: r.handle.port,
|
|
778
787
|
token: r.handle.token,
|
|
779
788
|
scriptPath,
|
|
@@ -856,7 +865,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
856
865
|
unregisterBridgeServer(process.pid);
|
|
857
866
|
// Approval gate: unstage hooks and remove the per-pid script. Pending
|
|
858
867
|
// approvals already failed closed via handle close (bridge shutdown deny).
|
|
859
|
-
const unstaged = removeGateHooks(
|
|
868
|
+
const unstaged = removeGateHooks();
|
|
860
869
|
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
861
870
|
if (gateScriptPath) {
|
|
862
871
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.4",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -26,24 +26,19 @@
|
|
|
26
26
|
},
|
|
27
27
|
"repository": {
|
|
28
28
|
"type": "git",
|
|
29
|
-
"url": "git+https://github.com/EstebanForge/pi-
|
|
29
|
+
"url": "git+https://github.com/EstebanForge/pi-extensions.git",
|
|
30
|
+
"directory": "packages/pi-antigravity-bridge"
|
|
30
31
|
},
|
|
31
32
|
"publishConfig": {
|
|
32
33
|
"access": "public"
|
|
33
34
|
},
|
|
34
35
|
"type": "module",
|
|
35
|
-
"scripts": {
|
|
36
|
-
"build": "tsc --noEmit",
|
|
37
|
-
"test": "vitest run",
|
|
38
|
-
"prepublishOnly": "npm run build && npm test",
|
|
39
|
-
"smoke:pi": "bash scripts/smoke-in-pi.sh"
|
|
40
|
-
},
|
|
41
36
|
"files": [
|
|
42
37
|
"extensions",
|
|
43
38
|
"src",
|
|
44
|
-
"docs",
|
|
45
39
|
"README.md",
|
|
46
|
-
"CHANGELOG.md"
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"LICENSE"
|
|
47
42
|
],
|
|
48
43
|
"pi": {
|
|
49
44
|
"extensions": [
|
|
@@ -58,17 +53,6 @@
|
|
|
58
53
|
"optional": true
|
|
59
54
|
}
|
|
60
55
|
},
|
|
61
|
-
"devDependencies": {
|
|
62
|
-
"@earendil-works/pi-ai": "^0.86.0",
|
|
63
|
-
"@earendil-works/pi-coding-agent": "^0.86.0",
|
|
64
|
-
"@earendil-works/pi-server": "^0.86.0",
|
|
65
|
-
"@earendil-works/pi-tui": "^0.86.0",
|
|
66
|
-
"@types/node": "^22.0.0",
|
|
67
|
-
"tsx": "^4.19.0",
|
|
68
|
-
"typebox": "^1.1.38",
|
|
69
|
-
"typescript": "^5.8.0",
|
|
70
|
-
"vitest": "^4.1.7"
|
|
71
|
-
},
|
|
72
56
|
"dependencies": {
|
|
73
57
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
74
58
|
}
|
package/src/approval-hook.ts
CHANGED
|
@@ -1,30 +1,42 @@
|
|
|
1
1
|
// Staging for the approval-gate hooks.json (docs/TODO.md 2.5).
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// The agy CLI discovers `.agents/hooks.json` in its --add-dir directories
|
|
4
|
+
// (live-probed 2026-09-22: stream-json and interactive sessions load and
|
|
5
|
+
// execute PreToolUse hooks from any add-dir; print mode reads only the
|
|
6
|
+
// global config) and fires them before a gated tool runs (V2: deny
|
|
7
|
+
// honored, reason reaches the model; V3: hook TIMEOUT = soft-pass, agy
|
|
8
|
+
// proceeds ungated). Therefore:
|
|
6
9
|
// - the staged handler timeout must exceed the whole park budget with
|
|
7
10
|
// margin (never rely on timeout as a deny), and
|
|
8
11
|
// - the staged command delegates to the bundled poll script, which
|
|
9
12
|
// early-acks and polls the bridge for the terminal decision.
|
|
10
13
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// ISOLATION (issue #5): the gate group is staged into the session-private
|
|
15
|
+
// per-pid bridge dir (bridgeMcpConfigDir()), NOT the shared workspace. Only
|
|
16
|
+
// this session's agy gets that dir as an extra --add-dir (driver.ts), so
|
|
17
|
+
// standalone IDE/CLI sessions in the workspace can never load the gate -
|
|
18
|
+
// neither denying their tools while pi is idle nor parking their calls
|
|
19
|
+
// into a live pi turn. The private file is ours alone: no merge, no
|
|
20
|
+
// foreign-file backup dance.
|
|
21
|
+
//
|
|
22
|
+
// Legacy: 1.6.x staged into the shared workspace `.agents/hooks.json`.
|
|
23
|
+
// sweepWorkspaceGateGroups() removes gate groups left there by sessions
|
|
24
|
+
// whose pid is dead; groups of live sessions and foreign groups are never
|
|
25
|
+
// touched.
|
|
15
26
|
//
|
|
16
27
|
// Run: npm test
|
|
17
28
|
|
|
18
29
|
import fs from "node:fs";
|
|
19
30
|
import path from "node:path";
|
|
31
|
+
import { bridgeMcpConfigDir } from "./mcp-server.js";
|
|
20
32
|
|
|
21
33
|
export const HOOK_GROUP = "pi-bridge-gate";
|
|
22
34
|
|
|
23
|
-
/** Group-key namespace. Each
|
|
24
|
-
* (`pi-bridge-gate-<pid>`):
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
35
|
+
/** Group-key namespace. Each session's group is keyed per-pid
|
|
36
|
+
* (`pi-bridge-gate-<pid>`): the key doubles as the ownership proof for the
|
|
37
|
+
* legacy workspace sweep, which may only remove groups whose owning
|
|
38
|
+
* session is dead (a stale shared key must never strip a live session's
|
|
39
|
+
* gate - audit 2026-09-07). */
|
|
28
40
|
export const GATE_GROUP_PREFIX = "pi-bridge-gate";
|
|
29
41
|
|
|
30
42
|
/** This session's group key. */
|
|
@@ -85,6 +97,10 @@ export function hookScriptSource(opts: { port: number; token: string; deadlineMs
|
|
|
85
97
|
const PORT = ${opts.port};
|
|
86
98
|
const TOKEN = ${JSON.stringify(opts.token)};
|
|
87
99
|
const DEADLINE = Date.now() + ${opts.deadlineMs};
|
|
100
|
+
// Per-fetch abort: a hung fetch must never outlive the park deadline. A hook
|
|
101
|
+
// killed by agy's staged timeout soft-passes (V3), so the script itself must
|
|
102
|
+
// always reach a printed deny.
|
|
103
|
+
const POST_TIMEOUT_MS = Math.min(10_000, ${opts.deadlineMs});
|
|
88
104
|
let body = "";
|
|
89
105
|
process.stdin.setEncoding("utf8");
|
|
90
106
|
for await (const chunk of process.stdin) body += chunk;
|
|
@@ -94,14 +110,15 @@ try {
|
|
|
94
110
|
method: "POST",
|
|
95
111
|
headers: { "content-type": "application/json", "x-bridge-token": TOKEN },
|
|
96
112
|
body,
|
|
113
|
+
signal: AbortSignal.timeout(POST_TIMEOUT_MS),
|
|
97
114
|
});
|
|
98
115
|
const json = await res.json();
|
|
99
116
|
// Ungated payloads get a terminal decision right on the POST (no park).
|
|
100
|
-
if (json && typeof json === "object" &&
|
|
101
|
-
console.log(JSON.stringify(json
|
|
117
|
+
if (json && typeof json === "object" && typeof json.decision === "string") {
|
|
118
|
+
console.log(JSON.stringify(json));
|
|
102
119
|
process.exit(0);
|
|
103
120
|
}
|
|
104
|
-
ticket = json
|
|
121
|
+
ticket = json?.ticket ?? "";
|
|
105
122
|
} catch {}
|
|
106
123
|
if (!ticket) {
|
|
107
124
|
console.log(JSON.stringify({ decision: "deny", reason: "approval gate unreachable (bridge down?)" }));
|
|
@@ -112,10 +129,17 @@ while (Date.now() < DEADLINE) {
|
|
|
112
129
|
try {
|
|
113
130
|
const res = await fetch(\`http://127.0.0.1:\${PORT}/approval/\${encodeURIComponent(ticket)}\`, {
|
|
114
131
|
headers: { "x-bridge-token": TOKEN },
|
|
132
|
+
// Bounded by the remaining park budget: an aborted poll falls through
|
|
133
|
+
// to the loop condition and lands on the deadline deny.
|
|
134
|
+
signal: AbortSignal.timeout(Math.max(1, DEADLINE - Date.now())),
|
|
115
135
|
});
|
|
116
136
|
const json = await res.json();
|
|
117
|
-
if (json
|
|
118
|
-
|
|
137
|
+
if (json?.status !== "pending") {
|
|
138
|
+
if (json && typeof json === "object" && typeof json.decision === "string") {
|
|
139
|
+
console.log(JSON.stringify(json));
|
|
140
|
+
} else {
|
|
141
|
+
console.log(JSON.stringify({ decision: "deny", reason: "gate returned no decision" }));
|
|
142
|
+
}
|
|
119
143
|
process.exit(0);
|
|
120
144
|
}
|
|
121
145
|
} catch {}
|
|
@@ -131,7 +155,7 @@ export function stagedTimeoutSeconds(parkBudgetMs: number): number {
|
|
|
131
155
|
return Math.max(60, Math.ceil(parkBudgetMs / 1000) + 60);
|
|
132
156
|
}
|
|
133
157
|
|
|
134
|
-
/** Build our hooks.json group for
|
|
158
|
+
/** Build our hooks.json group for private-dir staging. */
|
|
135
159
|
export function buildGateGroup(opts: StageOptions): Record<string, unknown> {
|
|
136
160
|
const command = `node ${JSON.stringify(opts.scriptPath)}`;
|
|
137
161
|
return {
|
|
@@ -153,100 +177,94 @@ export function buildGateGroup(opts: StageOptions): Record<string, unknown> {
|
|
|
153
177
|
|
|
154
178
|
export interface StageResult {
|
|
155
179
|
wrote: boolean;
|
|
156
|
-
/**
|
|
157
|
-
backup?: string;
|
|
158
|
-
/** Why nothing was written (parse failure, already current, ...). */
|
|
180
|
+
/** Why nothing was written (already current, ...). */
|
|
159
181
|
reason?: string;
|
|
160
182
|
}
|
|
161
183
|
|
|
162
|
-
/**
|
|
163
|
-
*
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
+
/** The session-private hooks.json for gate staging (beside the bridge's
|
|
185
|
+
* mcp_config.json in the per-pid add-dir). */
|
|
186
|
+
function gateHooksFile(dir: string = bridgeMcpConfigDir()): string {
|
|
187
|
+
return path.join(dir, ".agents", "hooks.json");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** True if this session's gate hooks are currently staged in the private
|
|
191
|
+
* add-dir. The driver uses this to decide whether to pass the extra
|
|
192
|
+
* --add-dir even when the MCP config is absent. */
|
|
193
|
+
export function gateHooksStaged(dir: string = bridgeMcpConfigDir()): boolean {
|
|
194
|
+
return fs.existsSync(gateHooksFile(dir));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Stage the gate group into the session-private per-pid dir:
|
|
198
|
+
* <bridgeMcpConfigDir()>/.agents/hooks.json. The dir (and everything in
|
|
199
|
+
* it) belongs to this pid alone, so the write is a plain atomic replace -
|
|
200
|
+
* no merge, no foreign-file guards. */
|
|
201
|
+
export function stageGateHooks(dir: string, opts: StageOptions): StageResult {
|
|
202
|
+
const file = gateHooksFile(dir);
|
|
203
|
+
const next = JSON.stringify({ [gateGroupKey()]: buildGateGroup(opts) }, null, 2) + "\n";
|
|
204
|
+
try {
|
|
205
|
+
if (fs.readFileSync(file, "utf8") === next) return { wrote: false, reason: "already staged" };
|
|
206
|
+
} catch {
|
|
207
|
+
/* absent or unreadable: write */
|
|
208
|
+
}
|
|
209
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
210
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
211
|
+
try {
|
|
212
|
+
fs.writeFileSync(tmp, next, { mode: 0o600 });
|
|
213
|
+
fs.renameSync(tmp, file);
|
|
214
|
+
} catch (err) {
|
|
184
215
|
try {
|
|
185
|
-
|
|
186
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
187
|
-
return { wrote: false, reason: "hooks.json is not an object; refusing to touch it" };
|
|
188
|
-
}
|
|
189
|
-
current = parsed as Record<string, unknown>;
|
|
216
|
+
fs.unlinkSync(tmp);
|
|
190
217
|
} catch {
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
// Sweep gate groups whose owning session is gone. Never touch groups of
|
|
194
|
-
// live sessions (concurrent pi sessions share this workspace) or groups
|
|
195
|
-
// we cannot attribute.
|
|
196
|
-
let swept = 0;
|
|
197
|
-
for (const key of Object.keys(current)) {
|
|
198
|
-
if (key === ownKey) continue;
|
|
199
|
-
const isGateGroup = key === GATE_GROUP_PREFIX || key.startsWith(`${GATE_GROUP_PREFIX}-`);
|
|
200
|
-
if (!isGateGroup) continue;
|
|
201
|
-
const pid = gateGroupPid(current[key]);
|
|
202
|
-
if (pid === null || pidAlive(pid)) continue;
|
|
203
|
-
delete current[key];
|
|
204
|
-
swept += 1;
|
|
205
|
-
}
|
|
206
|
-
if (swept > 0 && JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
|
|
207
|
-
// Own group already current; the pass only swept dead peers.
|
|
208
|
-
fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
|
|
209
|
-
return { wrote: true, reason: `swept ${swept} dead gate group(s)` };
|
|
210
|
-
}
|
|
211
|
-
if (JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
|
|
212
|
-
return { wrote: false, reason: "already staged" };
|
|
218
|
+
/* nothing */
|
|
213
219
|
}
|
|
220
|
+
throw err;
|
|
214
221
|
}
|
|
215
|
-
|
|
216
|
-
existed && current[ownKey] === undefined
|
|
217
|
-
? `${file}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`
|
|
218
|
-
: undefined;
|
|
219
|
-
if (backup) fs.copyFileSync(file, backup);
|
|
220
|
-
current[ownKey] = group;
|
|
221
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
222
|
-
fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
|
|
223
|
-
return { wrote: true, backup };
|
|
222
|
+
return { wrote: true };
|
|
224
223
|
}
|
|
225
224
|
|
|
226
|
-
/** Remove
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
* place (harmless). */
|
|
231
|
-
export function removeGateHooks(workspaceDir: string, pid: number = process.pid): StageResult {
|
|
232
|
-
const file = path.join(workspaceDir, ".agents", "hooks.json");
|
|
225
|
+
/** Remove the private gate hooks file. The rest of the per-pid dir (the MCP
|
|
226
|
+
* config) is managed by the bridge server lifecycle and stays until close. */
|
|
227
|
+
export function removeGateHooks(dir: string = bridgeMcpConfigDir()): StageResult {
|
|
228
|
+
const file = gateHooksFile(dir);
|
|
233
229
|
if (!fs.existsSync(file)) return { wrote: false, reason: "no hooks.json" };
|
|
234
230
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
231
|
+
fs.rmSync(file, { force: true });
|
|
232
|
+
return { wrote: true };
|
|
233
|
+
} catch {
|
|
234
|
+
return { wrote: false, reason: "remove failed" };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Legacy cleanup (issue #5): remove gate groups that 1.6.x staged into the
|
|
239
|
+
* SHARED workspace `.agents/hooks.json` when their owning session is dead
|
|
240
|
+
* (its bridge is gone; the hook would fail closed forever). Live sessions'
|
|
241
|
+
* groups and groups we cannot attribute are never touched; foreign groups
|
|
242
|
+
* and foreign files are never touched. Returns the number of swept groups. */
|
|
243
|
+
export function sweepWorkspaceGateGroups(workspaceDir: string): number {
|
|
244
|
+
const file = path.join(workspaceDir, ".agents", "hooks.json");
|
|
245
|
+
let current: Record<string, unknown>;
|
|
246
|
+
try {
|
|
247
|
+
if (fs.lstatSync(file).isSymbolicLink()) return 0;
|
|
248
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
249
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
|
|
250
|
+
current = parsed as Record<string, unknown>;
|
|
238
251
|
} catch {
|
|
239
|
-
return
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
let swept = 0;
|
|
255
|
+
for (const key of Object.keys(current)) {
|
|
256
|
+
const isGateGroup = key === GATE_GROUP_PREFIX || key.startsWith(`${GATE_GROUP_PREFIX}-`);
|
|
257
|
+
if (!isGateGroup) continue;
|
|
258
|
+
const pid = gateGroupPid(current[key]);
|
|
259
|
+
if (pid === null || pidAlive(pid)) continue;
|
|
260
|
+
delete current[key];
|
|
261
|
+
swept += 1;
|
|
240
262
|
}
|
|
263
|
+
if (swept === 0) return 0;
|
|
241
264
|
try {
|
|
242
|
-
|
|
243
|
-
if (!parsed || typeof parsed !== "object") return { wrote: false, reason: "not an object; refusing" };
|
|
244
|
-
const ownKey = gateGroupKey(pid);
|
|
245
|
-
if (parsed[ownKey] === undefined) return { wrote: false, reason: "not staged" };
|
|
246
|
-
delete parsed[ownKey];
|
|
247
|
-
fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + "\n");
|
|
248
|
-
return { wrote: true };
|
|
265
|
+
fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
|
|
249
266
|
} catch {
|
|
250
|
-
return
|
|
267
|
+
return 0;
|
|
251
268
|
}
|
|
269
|
+
return swept;
|
|
252
270
|
}
|
package/src/driver.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
18
18
|
import { randomUUID } from "node:crypto";
|
|
19
19
|
import { parseAgyLine } from "./stream-events.js";
|
|
20
20
|
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
21
|
+
import { gateHooksStaged } from "./approval-hook.js";
|
|
21
22
|
import type {
|
|
22
23
|
AgyUsage,
|
|
23
24
|
DriverActivity,
|
|
@@ -335,7 +336,11 @@ export class StreamDriver implements TurnDriver {
|
|
|
335
336
|
this.#stderrTail = "";
|
|
336
337
|
|
|
337
338
|
const args: string[] = ["--add-dir", request.cwd];
|
|
338
|
-
|
|
339
|
+
// The bridge dir carries .agents/mcp_config.json (tool bridge) and, when
|
|
340
|
+
// the approval gate is on, .agents/hooks.json (gate staging). Only this
|
|
341
|
+
// session's agy ever gets it: standalone IDE/CLI sessions in the
|
|
342
|
+
// workspace must never load our gate (issue #5 isolation).
|
|
343
|
+
if (bridgeMcpConfigExists() || gateHooksStaged()) args.push("--add-dir", bridgeMcpConfigDir());
|
|
339
344
|
args.push("--model", request.model);
|
|
340
345
|
if (request.effort) args.push("--effort", request.effort);
|
|
341
346
|
args.push("--mode", request.mode);
|