@saadnvd1/agent-os 0.3.778 → 0.3.780
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/app/api/live-shares/[id]/route.ts +21 -3
- package/app/api/live-shares/route.test.ts +36 -0
- package/app/api/live-shares/route.ts +14 -3
- package/components/Chat/Approval.tsx +13 -2
- package/components/Chat/EventRow.tsx +5 -0
- package/components/Chat/Queue.tsx +27 -15
- package/components/Live/DriveBanner.tsx +42 -0
- package/components/Live/InviteDialog.tsx +44 -13
- package/components/Live/LiveChip.tsx +34 -13
- package/components/Live/LivePanel.tsx +26 -9
- package/components/Live/LobbyCard.tsx +18 -21
- package/components/ui/segmented.tsx +2 -1
- package/data/live/queries.ts +25 -5
- package/lib/chat/driver.ts +4 -0
- package/lib/chat/drivers/claude-approvals.ts +93 -25
- package/lib/chat/drivers/claude.ts +2 -1
- package/lib/chat/events.ts +21 -1
- package/lib/chat/queued.ts +57 -10
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +65 -2
- package/lib/chat/socket.ts +12 -9
- package/lib/chat/worker/gate-testing.ts +131 -0
- package/lib/chat/worker/guest-gate.ts +511 -0
- package/lib/chat/worker/host.ts +50 -6
- package/lib/chat/worker/main.ts +16 -2
- package/lib/chat/worker/protocol.ts +11 -0
- package/lib/connect/control-api.ts +6 -2
- package/lib/db/migrations/20261030T1200_live_share_level.ts +19 -0
- package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
- package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
- package/lib/live/clean.ts +6 -0
- package/lib/live/drive.ts +398 -0
- package/lib/live/frames.ts +9 -2
- package/lib/live/inbox.ts +47 -1
- package/lib/live/levels.ts +74 -0
- package/lib/live/publisher.ts +10 -0
- package/lib/live/runtime.ts +20 -0
- package/lib/live/service.ts +133 -10
- package/lib/live/store.ts +54 -3
- package/lib/live/testing.ts +281 -0
- package/lib/live/types.ts +52 -0
- package/lib/share/connect.ts +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* For tests: a real chat worker (ChatHost) over a fake agent, with the guest
|
|
3
|
+
* gate's PreToolUse hook driven as the SDK would and real approval cards.
|
|
4
|
+
* A test file mocks the drivers with this module's `drivers`:
|
|
5
|
+
* vi.mock("@/lib/chat/drivers", async () => (await import("@/lib/chat/worker/gate-testing")).drivers)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
9
|
+
import { vi } from "vitest";
|
|
10
|
+
import type { HookJSONOutput } from "@anthropic-ai/claude-agent-sdk";
|
|
11
|
+
import type { Session } from "@/lib/db";
|
|
12
|
+
import type { ChatConversation, ChatStartOptions } from "../driver";
|
|
13
|
+
import type { ChatItem, DriverEvent } from "../events";
|
|
14
|
+
import { InputQueue } from "../queue";
|
|
15
|
+
import { Approvals, preToolUseHooks } from "../drivers/claude-approvals";
|
|
16
|
+
import type { WorkerEvent } from "./protocol";
|
|
17
|
+
|
|
18
|
+
export function fakeConversation() {
|
|
19
|
+
const events = new InputQueue<DriverEvent>();
|
|
20
|
+
return {
|
|
21
|
+
send: vi.fn(() => "checkpoint-1"),
|
|
22
|
+
runLocal: vi.fn(() => null),
|
|
23
|
+
interrupt: vi.fn(async () => {}),
|
|
24
|
+
setModel: vi.fn(async () => {}),
|
|
25
|
+
setAccess: vi.fn(async () => {}),
|
|
26
|
+
setPlan: vi.fn(async () => {}),
|
|
27
|
+
respond: vi.fn(),
|
|
28
|
+
stopTask: vi.fn(async () => {}),
|
|
29
|
+
backgroundTask: vi.fn(async () => true),
|
|
30
|
+
undo: vi.fn(),
|
|
31
|
+
close: vi.fn(() => events.end()),
|
|
32
|
+
events,
|
|
33
|
+
} satisfies ChatConversation;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fake: {
|
|
37
|
+
current?: ReturnType<typeof fakeConversation>;
|
|
38
|
+
started?: ChatStartOptions;
|
|
39
|
+
} = {};
|
|
40
|
+
|
|
41
|
+
export const drivers = {
|
|
42
|
+
chatDriverFor: () => ({
|
|
43
|
+
id: "fake",
|
|
44
|
+
atRest: true,
|
|
45
|
+
start: (options: ChatStartOptions) => {
|
|
46
|
+
fake.started = options;
|
|
47
|
+
return fake.current!;
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
53
|
+
|
|
54
|
+
// The host is imported late: it imports the drivers, which this module mocks.
|
|
55
|
+
export async function gatedHost(
|
|
56
|
+
session: Session,
|
|
57
|
+
cwd: string,
|
|
58
|
+
onEvent: (e: WorkerEvent) => void = () => {}
|
|
59
|
+
) {
|
|
60
|
+
const { ChatHost } = await import("./host");
|
|
61
|
+
fake.current = fakeConversation();
|
|
62
|
+
const conversation = fake.current;
|
|
63
|
+
const emitted: ChatItem[] = [];
|
|
64
|
+
const host = new ChatHost(session, (e) => {
|
|
65
|
+
if (e.type === "item") emitted.push(e.item);
|
|
66
|
+
onEvent(e);
|
|
67
|
+
});
|
|
68
|
+
const approvals = new Approvals((e) => conversation.events.push(e));
|
|
69
|
+
conversation.respond.mockImplementation((cardId, answer) =>
|
|
70
|
+
approvals.respond(cardId, answer)
|
|
71
|
+
);
|
|
72
|
+
const gate = fake.started!.guestGate!;
|
|
73
|
+
const [first] = preToolUseHooks(approvals, undefined, null, { gate, cwd });
|
|
74
|
+
const hook = first.hooks[0];
|
|
75
|
+
const results = new Map<string, HookJSONOutput>();
|
|
76
|
+
const call = (tool: string, input: Record<string, unknown>) => {
|
|
77
|
+
const useId = `tu-${randomUUID().slice(0, 8)}`;
|
|
78
|
+
const ac = new AbortController();
|
|
79
|
+
void hook(
|
|
80
|
+
{
|
|
81
|
+
hook_event_name: "PreToolUse",
|
|
82
|
+
tool_name: tool,
|
|
83
|
+
tool_input: input,
|
|
84
|
+
tool_use_id: useId,
|
|
85
|
+
session_id: "x",
|
|
86
|
+
transcript_path: "",
|
|
87
|
+
cwd,
|
|
88
|
+
} as never,
|
|
89
|
+
useId,
|
|
90
|
+
{ signal: ac.signal }
|
|
91
|
+
).then((r) => results.set(useId, r));
|
|
92
|
+
return { useId, abort: () => ac.abort() };
|
|
93
|
+
};
|
|
94
|
+
/** allow, deny, none (no decision), or pending (still waiting on the host). */
|
|
95
|
+
const decision = async (useId: string) => {
|
|
96
|
+
try {
|
|
97
|
+
await gate.placedFiles(cwd);
|
|
98
|
+
} catch {
|
|
99
|
+
// A test that makes it throw.
|
|
100
|
+
}
|
|
101
|
+
await tick();
|
|
102
|
+
await tick();
|
|
103
|
+
const r = results.get(useId) as
|
|
104
|
+
{ hookSpecificOutput?: { permissionDecision?: string } } | undefined;
|
|
105
|
+
return (
|
|
106
|
+
r?.hookSpecificOutput?.permissionDecision ?? (r ? "none" : "pending")
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
const card = (useId: string) =>
|
|
110
|
+
emitted
|
|
111
|
+
.filter(
|
|
112
|
+
(i): i is Extract<ChatItem, { kind: "approval" }> =>
|
|
113
|
+
i.kind === "approval" && i.id === `approval-${useId}`
|
|
114
|
+
)
|
|
115
|
+
.at(-1);
|
|
116
|
+
const idle = async () => {
|
|
117
|
+
conversation.events.push({ type: "state", state: "idle" });
|
|
118
|
+
await tick();
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
host,
|
|
122
|
+
gate,
|
|
123
|
+
approvals,
|
|
124
|
+
conversation,
|
|
125
|
+
call,
|
|
126
|
+
decision,
|
|
127
|
+
card,
|
|
128
|
+
idle,
|
|
129
|
+
emitted,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guest gate. A turn a live guest started (Drive) runs only what the
|
|
3
|
+
* host taps for, whatever the session's access, its allow rules or No
|
|
4
|
+
* approvals mode say: reading non-secret files inside the task's worktree is
|
|
5
|
+
* the one thing that goes ahead alone. It is a PreToolUse hook, first in
|
|
6
|
+
* line, so it decides before any permission rule could allow a call.
|
|
7
|
+
*
|
|
8
|
+
* The steer is sticky: set when a guest's message goes to the agent, and
|
|
9
|
+
* cleared only when a message the host sent themselves starts its turn.
|
|
10
|
+
* Every turn in between (a background task's follow-up, the wind-down of a
|
|
11
|
+
* stopped one) is gated. It is kept on the session (chat_guest_steers), so
|
|
12
|
+
* a worker that restarts picks it up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import type {
|
|
18
|
+
HookCallback,
|
|
19
|
+
HookJSONOutput,
|
|
20
|
+
PermissionResult,
|
|
21
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
22
|
+
import type { ChatItem, SentBy } from "../events";
|
|
23
|
+
import { db } from "../../db";
|
|
24
|
+
import { isSecretPath } from "../../secret-paths";
|
|
25
|
+
import { readPlaced } from "../../worktree-placed";
|
|
26
|
+
import { deleteQueuedWhere } from "../queued";
|
|
27
|
+
|
|
28
|
+
export const GUEST_MAX_TOOL_CALLS = 40;
|
|
29
|
+
export const GUEST_TURN_MS = 20 * 60_000;
|
|
30
|
+
// host.ts's HARD_STOP_MS: how long a hard stop's aftermath can run.
|
|
31
|
+
const WIND_DOWN_MS = 30_000;
|
|
32
|
+
|
|
33
|
+
const NOT_ALLOWED = "The host didn't allow this step.";
|
|
34
|
+
const FAILED = "The guest gate failed, so this step didn't run.";
|
|
35
|
+
const OUT_OF_TIME = "This guest's turn is out of time.";
|
|
36
|
+
|
|
37
|
+
export interface GuestSteer {
|
|
38
|
+
shareId: string;
|
|
39
|
+
wheel: string;
|
|
40
|
+
guestId: string;
|
|
41
|
+
name: string;
|
|
42
|
+
toolCalls: number;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
// Its Drive ended (downgrade, take-back, kick, end) while its turn ran.
|
|
45
|
+
endedAt?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the gate needs of the approval cards (lib/chat/drivers/claude-approvals). */
|
|
49
|
+
export interface GateApprovals {
|
|
50
|
+
askGuest(
|
|
51
|
+
toolName: string,
|
|
52
|
+
input: Record<string, unknown>,
|
|
53
|
+
toolUseID: string,
|
|
54
|
+
signal: AbortSignal,
|
|
55
|
+
guest: { name: string }
|
|
56
|
+
): Promise<PermissionResult>;
|
|
57
|
+
/** The gate let this call through: the permission prompt that may follow is answered. */
|
|
58
|
+
passed(toolUseID: string): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const isGuestDrive = (m: SentBy): boolean =>
|
|
62
|
+
m.origin?.kind === "guest-drive";
|
|
63
|
+
|
|
64
|
+
// Sent by the host themselves (their composer, their own taps). Agents,
|
|
65
|
+
// events, schedules and AgentOS's own resends aren't the host speaking, so
|
|
66
|
+
// they leave a steer as it is.
|
|
67
|
+
const byHost = (m: SentBy): boolean =>
|
|
68
|
+
m.host === true && !m.from && !m.peer && !isGuestDrive(m);
|
|
69
|
+
|
|
70
|
+
type SteerRow = {
|
|
71
|
+
share_id: string;
|
|
72
|
+
wheel: string;
|
|
73
|
+
guest_id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
tool_calls: number;
|
|
76
|
+
started_at: number;
|
|
77
|
+
ended_at: number | null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function loadSteer(sessionId: string): GuestSteer | null {
|
|
81
|
+
const r = db
|
|
82
|
+
.prepare(`SELECT * FROM chat_guest_steers WHERE session_id = ?`)
|
|
83
|
+
.get(sessionId) as SteerRow | undefined;
|
|
84
|
+
return r
|
|
85
|
+
? {
|
|
86
|
+
shareId: r.share_id,
|
|
87
|
+
wheel: r.wheel,
|
|
88
|
+
guestId: r.guest_id,
|
|
89
|
+
name: r.name,
|
|
90
|
+
toolCalls: r.tool_calls,
|
|
91
|
+
startedAt: r.started_at,
|
|
92
|
+
...(r.ended_at ? { endedAt: r.ended_at } : {}),
|
|
93
|
+
}
|
|
94
|
+
: null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function saveSteer(sessionId: string, s: GuestSteer | null): void {
|
|
98
|
+
if (!s) {
|
|
99
|
+
db.prepare(`DELETE FROM chat_guest_steers WHERE session_id = ?`).run(
|
|
100
|
+
sessionId
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
db.prepare(
|
|
105
|
+
`INSERT OR REPLACE INTO chat_guest_steers (session_id, share_id, wheel, guest_id, name, tool_calls, started_at, ended_at)
|
|
106
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
107
|
+
).run(
|
|
108
|
+
sessionId,
|
|
109
|
+
s.shareId,
|
|
110
|
+
s.wheel,
|
|
111
|
+
s.guestId,
|
|
112
|
+
s.name,
|
|
113
|
+
s.toolCalls,
|
|
114
|
+
s.startedAt,
|
|
115
|
+
s.endedAt ?? null
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class GuestGate {
|
|
120
|
+
steer: GuestSteer | null;
|
|
121
|
+
// Wheels whose Drive is over: nothing sent under them is taken in.
|
|
122
|
+
private released = new Set<string>();
|
|
123
|
+
private timer?: NodeJS.Timeout;
|
|
124
|
+
private asking = new Set<AbortController>();
|
|
125
|
+
// Turns started, and the one (by count) the host's own message starts:
|
|
126
|
+
// the steer ends when that one does.
|
|
127
|
+
private starts = 0;
|
|
128
|
+
private clearAt = 0;
|
|
129
|
+
private placed?: Promise<Set<string>>;
|
|
130
|
+
private running = false;
|
|
131
|
+
private endTimer?: NodeJS.Timeout;
|
|
132
|
+
|
|
133
|
+
constructor(
|
|
134
|
+
// Stops the running turn, with a line for the host saying why.
|
|
135
|
+
private onStop: (note: ChatItem) => void,
|
|
136
|
+
private sessionId?: string
|
|
137
|
+
) {
|
|
138
|
+
this.steer = sessionId ? loadSteer(sessionId) : null;
|
|
139
|
+
// A restarted worker has no turn left from a Drive that ended.
|
|
140
|
+
if (this.steer?.endedAt) this.set(null);
|
|
141
|
+
if (this.steer) this.arm(this.steer);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private set(s: GuestSteer | null): void {
|
|
145
|
+
this.steer = s;
|
|
146
|
+
if (!s) {
|
|
147
|
+
clearTimeout(this.timer);
|
|
148
|
+
clearTimeout(this.endTimer);
|
|
149
|
+
}
|
|
150
|
+
if (this.sessionId) saveSteer(this.sessionId, s);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A steer whose Drive ended goes once its stopped turn is over: no turn
|
|
155
|
+
* running and the hard stop's wind-down (a turn the CLI starts to reply
|
|
156
|
+
* to the interruption, which the host cuts short) past.
|
|
157
|
+
*/
|
|
158
|
+
private endIfOver(): void {
|
|
159
|
+
const s = this.steer;
|
|
160
|
+
if (s?.endedAt && !this.running && Date.now() >= s.endedAt + WIND_DOWN_MS)
|
|
161
|
+
this.set(null);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private arm(s: GuestSteer): void {
|
|
165
|
+
clearTimeout(this.timer);
|
|
166
|
+
this.timer = setTimeout(
|
|
167
|
+
() => this.stop(`${s.name}'s turn ran for 20 minutes`),
|
|
168
|
+
Math.max(0, s.startedAt + GUEST_TURN_MS - Date.now())
|
|
169
|
+
);
|
|
170
|
+
this.timer.unref?.();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A message goes to the agent. `midTurn`: it joined a running turn;
|
|
175
|
+
* `ahead`: turns that start before its own (one sent but not started).
|
|
176
|
+
*/
|
|
177
|
+
onSend(m: SentBy, midTurn: boolean, ahead = 0): void {
|
|
178
|
+
if (isGuestDrive(m)) {
|
|
179
|
+
const o = m.origin!;
|
|
180
|
+
const s: GuestSteer = {
|
|
181
|
+
shareId: o.share ?? "",
|
|
182
|
+
wheel: o.wheel ?? "",
|
|
183
|
+
guestId: o.guestId ?? "",
|
|
184
|
+
name: o.label,
|
|
185
|
+
toolCalls: 0,
|
|
186
|
+
startedAt: Date.now(),
|
|
187
|
+
};
|
|
188
|
+
this.set(s);
|
|
189
|
+
this.arm(s);
|
|
190
|
+
this.clearAt = 0;
|
|
191
|
+
} else if (!midTurn && this.steer && byHost(m))
|
|
192
|
+
this.clearAt = this.starts + ahead + 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The agent started a turn: the host's own message's turn ends a steer. */
|
|
196
|
+
onTurnStart(): void {
|
|
197
|
+
this.endIfOver();
|
|
198
|
+
this.running = true;
|
|
199
|
+
this.starts++;
|
|
200
|
+
if (this.clearAt && this.starts >= this.clearAt) {
|
|
201
|
+
this.clearAt = 0;
|
|
202
|
+
this.set(null);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A turn ended (or was stopped). */
|
|
207
|
+
onIdle(): void {
|
|
208
|
+
this.running = false;
|
|
209
|
+
this.endIfOver();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Where a guest card stops waiting: the guest's 20 minutes are up. */
|
|
213
|
+
deadline(): number {
|
|
214
|
+
return (this.steer?.startedAt ?? 0) + GUEST_TURN_MS;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Files AgentOS copied into the worktree (env files, agentos.json `copy`). */
|
|
218
|
+
placedFiles(cwd: string): Promise<Set<string>> {
|
|
219
|
+
this.placed ??= readPlaced(cwd).then(
|
|
220
|
+
(p) => new Set(Object.keys(p.files).map((f) => path.normalize(f)))
|
|
221
|
+
);
|
|
222
|
+
return this.placed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A guest's message from a Drive that's over (or naming none). */
|
|
226
|
+
refuses(m: SentBy): boolean {
|
|
227
|
+
return (
|
|
228
|
+
isGuestDrive(m) &&
|
|
229
|
+
(!m.origin?.share || !m.origin.wheel || this.released.has(m.origin.wheel))
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Ends a Drive: its queued prompts go, its pending cards expire as
|
|
235
|
+
* denied, and later messages under that wheel are refused. A steer it
|
|
236
|
+
* holds goes now if no turn runs, else once the stopped turn is over
|
|
237
|
+
* (gated till then), so later turns of the agent's own run by the
|
|
238
|
+
* host's settings, not the guest's.
|
|
239
|
+
*/
|
|
240
|
+
release(
|
|
241
|
+
sessionId: string,
|
|
242
|
+
share: string,
|
|
243
|
+
wheel: string,
|
|
244
|
+
busy = false
|
|
245
|
+
): { purged: boolean; steered: boolean } {
|
|
246
|
+
if (wheel) this.released.add(wheel);
|
|
247
|
+
const purged =
|
|
248
|
+
deleteQueuedWhere(
|
|
249
|
+
sessionId,
|
|
250
|
+
(by) => isGuestDrive(by) && by.origin?.share === share
|
|
251
|
+
).length > 0;
|
|
252
|
+
const steered = this.steeredBy(share);
|
|
253
|
+
if (steered) {
|
|
254
|
+
clearTimeout(this.timer);
|
|
255
|
+
this.clearAt = 0;
|
|
256
|
+
this.expire();
|
|
257
|
+
if (!busy) this.set(null);
|
|
258
|
+
else if (!this.steer!.endedAt) {
|
|
259
|
+
const s = { ...this.steer!, endedAt: Date.now() };
|
|
260
|
+
this.set(s);
|
|
261
|
+
this.endTimer = setTimeout(() => this.endIfOver(), WIND_DOWN_MS + 1);
|
|
262
|
+
this.endTimer.unref?.();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { purged, steered: steered && busy };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
steeredBy(share: string, wheel?: string): boolean {
|
|
269
|
+
return (
|
|
270
|
+
!!share &&
|
|
271
|
+
this.steer?.shareId === share &&
|
|
272
|
+
(wheel === undefined || this.steer.wheel === wheel)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The worker's guest_release and guest_stop. */
|
|
277
|
+
command(
|
|
278
|
+
cmd: { type: "guest_release" | "guest_stop"; share: string; wheel: string },
|
|
279
|
+
sessionId: string,
|
|
280
|
+
fx: {
|
|
281
|
+
queueChanged: () => void;
|
|
282
|
+
stopTurn: () => Promise<void> | void;
|
|
283
|
+
busy: () => boolean;
|
|
284
|
+
}
|
|
285
|
+
): Promise<void> | void {
|
|
286
|
+
if (cmd.type === "guest_stop")
|
|
287
|
+
return this.steeredBy(cmd.share, cmd.wheel) ? fx.stopTurn() : undefined;
|
|
288
|
+
const { purged, steered } = this.release(
|
|
289
|
+
sessionId,
|
|
290
|
+
cmd.share,
|
|
291
|
+
cmd.wheel,
|
|
292
|
+
fx.busy()
|
|
293
|
+
);
|
|
294
|
+
if (purged) fx.queueChanged();
|
|
295
|
+
return steered ? fx.stopTurn() : undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Counts a call of the steered turn; false past the cap, which stops it. */
|
|
299
|
+
count(): boolean {
|
|
300
|
+
const s = this.steer;
|
|
301
|
+
if (!s) return true;
|
|
302
|
+
s.toolCalls++;
|
|
303
|
+
if (this.sessionId) saveSteer(this.sessionId, s);
|
|
304
|
+
if (s.toolCalls <= GUEST_MAX_TOOL_CALLS) return true;
|
|
305
|
+
if (s.toolCalls === GUEST_MAX_TOOL_CALLS + 1)
|
|
306
|
+
this.stop(`${s.name}'s turn reached ${GUEST_MAX_TOOL_CALLS} steps`);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
track(ac: AbortController): () => void {
|
|
311
|
+
this.asking.add(ac);
|
|
312
|
+
return () => this.asking.delete(ac);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private expire(): void {
|
|
316
|
+
for (const ac of this.asking) ac.abort();
|
|
317
|
+
this.asking.clear();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private stop(why: string): void {
|
|
321
|
+
clearTimeout(this.timer);
|
|
322
|
+
this.expire();
|
|
323
|
+
this.onStop({
|
|
324
|
+
id: `note-guest-stop-${Date.now()}`,
|
|
325
|
+
kind: "note",
|
|
326
|
+
tone: "note",
|
|
327
|
+
text: `Stopped: ${why}`,
|
|
328
|
+
createdAt: Date.now(),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
close(): void {
|
|
333
|
+
clearTimeout(this.timer);
|
|
334
|
+
this.expire();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The agent's questions and plans are host cards already (their own hooks).
|
|
339
|
+
const HOST_CARDS = new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
340
|
+
|
|
341
|
+
const decide = (
|
|
342
|
+
permissionDecision: "allow" | "deny",
|
|
343
|
+
extra: { updatedInput?: Record<string, unknown>; reason?: string }
|
|
344
|
+
): HookJSONOutput => ({
|
|
345
|
+
hookSpecificOutput: {
|
|
346
|
+
hookEventName: "PreToolUse",
|
|
347
|
+
permissionDecision,
|
|
348
|
+
...(extra.updatedInput && { updatedInput: extra.updatedInput }),
|
|
349
|
+
...(extra.reason && { permissionDecisionReason: extra.reason }),
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
export function guestGateHook(
|
|
354
|
+
approvals: GateApprovals,
|
|
355
|
+
gate: GuestGate,
|
|
356
|
+
cwd: string
|
|
357
|
+
): HookCallback {
|
|
358
|
+
// Any failure denies: an error must never let a guest's step through.
|
|
359
|
+
return async (input, toolUseID, opts) => {
|
|
360
|
+
try {
|
|
361
|
+
return await gateCall(approvals, gate, cwd, input, toolUseID, opts);
|
|
362
|
+
} catch {
|
|
363
|
+
return decide("deny", { reason: FAILED });
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function gateCall(
|
|
369
|
+
approvals: GateApprovals,
|
|
370
|
+
gate: GuestGate,
|
|
371
|
+
cwd: string,
|
|
372
|
+
...[input, toolUseID, { signal }]: Parameters<HookCallback>
|
|
373
|
+
): Promise<HookJSONOutput> {
|
|
374
|
+
if (input.hook_event_name !== "PreToolUse") return {};
|
|
375
|
+
const steer = gate.steer;
|
|
376
|
+
if (!steer) return {};
|
|
377
|
+
if (!gate.count())
|
|
378
|
+
return decide("deny", { reason: "This guest's turn is out of steps." });
|
|
379
|
+
const name = input.tool_name;
|
|
380
|
+
const args = (input.tool_input ?? {}) as Record<string, unknown>;
|
|
381
|
+
if (HOST_CARDS.has(name)) return {};
|
|
382
|
+
const id = toolUseID ?? input.tool_use_id;
|
|
383
|
+
// What AgentOS copied in is read (git, so slowly) only for a read that
|
|
384
|
+
// would otherwise go ahead.
|
|
385
|
+
if (
|
|
386
|
+
worktreeRead(name, args, cwd) &&
|
|
387
|
+
worktreeRead(name, args, cwd, await gate.placedFiles(cwd))
|
|
388
|
+
) {
|
|
389
|
+
approvals.passed(id);
|
|
390
|
+
return decide("allow", { updatedInput: args });
|
|
391
|
+
}
|
|
392
|
+
// The card expires (denied) when the turn stops, the Drive ends or the
|
|
393
|
+
// guest's 20 minutes run out, well before the SDK's own hook timeout.
|
|
394
|
+
const left = gate.deadline() - Date.now();
|
|
395
|
+
if (left <= 0) return decide("deny", { reason: OUT_OF_TIME });
|
|
396
|
+
const ac = new AbortController();
|
|
397
|
+
const abort = () => ac.abort();
|
|
398
|
+
if (signal.aborted) ac.abort();
|
|
399
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
400
|
+
const timer = setTimeout(abort, left);
|
|
401
|
+
timer.unref?.();
|
|
402
|
+
const untrack = gate.track(ac);
|
|
403
|
+
try {
|
|
404
|
+
const r = await approvals.askGuest(name, args, id, ac.signal, {
|
|
405
|
+
name: steer.name,
|
|
406
|
+
});
|
|
407
|
+
if (r.behavior !== "allow")
|
|
408
|
+
return decide("deny", { reason: r.message || NOT_ALLOWED });
|
|
409
|
+
approvals.passed(id);
|
|
410
|
+
return decide("allow", { updatedInput: r.updatedInput ?? args });
|
|
411
|
+
} finally {
|
|
412
|
+
clearTimeout(timer);
|
|
413
|
+
untrack();
|
|
414
|
+
signal.removeEventListener("abort", abort);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Reads that need no tap: Read, Glob and Grep whose target resolves (links
|
|
420
|
+
* followed) inside the worktree, outside .git, and is no secret file nor
|
|
421
|
+
* one AgentOS copied in (`placed`); and TodoWrite. A Grep names its path
|
|
422
|
+
* and, over a folder, is narrowed to a file type with no copied file under
|
|
423
|
+
* it; one with a `glob` is a card (rg's --glob overrides --type and
|
|
424
|
+
* .gitignore, so it could reach an env file).
|
|
425
|
+
*/
|
|
426
|
+
export function worktreeRead(
|
|
427
|
+
name: string,
|
|
428
|
+
args: Record<string, unknown>,
|
|
429
|
+
cwd: string,
|
|
430
|
+
placed: Set<string> = new Set()
|
|
431
|
+
): boolean {
|
|
432
|
+
if (name === "TodoWrite") return true;
|
|
433
|
+
if (name !== "Read" && name !== "Glob" && name !== "Grep") return false;
|
|
434
|
+
let root: string;
|
|
435
|
+
try {
|
|
436
|
+
root = fs.realpathSync.native(cwd);
|
|
437
|
+
} catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
if (name === "Grep" && (args.glob !== undefined || args.path === undefined))
|
|
441
|
+
return false;
|
|
442
|
+
const target = name === "Read" ? args.file_path : (args.path ?? cwd);
|
|
443
|
+
if (typeof target !== "string" || !target) return false;
|
|
444
|
+
const real = inside(root, cwd, target);
|
|
445
|
+
if (!real) return false;
|
|
446
|
+
// Lowercase: macOS folders are case-insensitive, so CONFIG/APP.YML is
|
|
447
|
+
// config/app.yml.
|
|
448
|
+
const rel = real.rel.toLowerCase();
|
|
449
|
+
const copied = real.file
|
|
450
|
+
? [...placed].some((f) => f.toLowerCase() === rel)
|
|
451
|
+
: [...placed].some(
|
|
452
|
+
(f) => !rel || f.toLowerCase().startsWith(rel + path.sep)
|
|
453
|
+
);
|
|
454
|
+
if (name === "Read") return real.file && !copied;
|
|
455
|
+
if (name === "Glob") return safePattern(args.pattern);
|
|
456
|
+
if (copied) return false;
|
|
457
|
+
return real.file || narrowed(args);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Secret by name, whatever isSecretPath says: every env file (env-setup
|
|
461
|
+
// copies them all in), Wrangler's, Terraform state, cloud keys.
|
|
462
|
+
const SECRET_NAME =
|
|
463
|
+
/^(\.env.*|\.dev\.vars.*|.*\.tfstate(\..*)?|.*service[-_]?account.*\.json|credentials.*\.json|\.npmrc|\.netrc|\.pgpass)$/i;
|
|
464
|
+
|
|
465
|
+
function inside(
|
|
466
|
+
root: string,
|
|
467
|
+
cwd: string,
|
|
468
|
+
target: string
|
|
469
|
+
): { file: boolean; rel: string } | null {
|
|
470
|
+
let real: string;
|
|
471
|
+
let stat: fs.Stats;
|
|
472
|
+
try {
|
|
473
|
+
real = fs.realpathSync.native(path.resolve(cwd, target));
|
|
474
|
+
stat = fs.statSync(real);
|
|
475
|
+
} catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const rel = path.relative(root, real);
|
|
479
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
480
|
+
const parts = rel.split(path.sep);
|
|
481
|
+
if (parts.some((p) => p.toLowerCase() === ".git")) return null;
|
|
482
|
+
if (parts.some((p) => SECRET_NAME.test(p))) return null;
|
|
483
|
+
if ((rel && isSecretPath(rel)) || isSecretPath(real)) return null;
|
|
484
|
+
if (!stat.isFile() && !stat.isDirectory()) return null;
|
|
485
|
+
return { file: stat.isFile(), rel };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// A pattern that stays below its folder: no braces (which can list one).
|
|
489
|
+
function safePattern(p: unknown): boolean {
|
|
490
|
+
return (
|
|
491
|
+
typeof p === "string" &&
|
|
492
|
+
!!p &&
|
|
493
|
+
!path.isAbsolute(p) &&
|
|
494
|
+
!p.startsWith("~") &&
|
|
495
|
+
!/[{}]/.test(p) &&
|
|
496
|
+
!p.includes("..")
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const SECRET_EXT = /^(env|pem|key|p12|pfx|npmrc|netrc|pgpass)$/i;
|
|
501
|
+
|
|
502
|
+
// Only files of one type, none of them a secret one.
|
|
503
|
+
function narrowed(args: Record<string, unknown>): boolean {
|
|
504
|
+
const type = args.type;
|
|
505
|
+
return (
|
|
506
|
+
typeof type === "string" &&
|
|
507
|
+
/^[a-z0-9]+$/i.test(type) &&
|
|
508
|
+
type !== "all" &&
|
|
509
|
+
!SECRET_EXT.test(type)
|
|
510
|
+
);
|
|
511
|
+
}
|