@neuralnomads/codenomad-dev 0.18.0-dev-20260628-e8623d31 → 0.18.0-dev-20260709-ca06bd99
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/dist/events/bus.js +4 -0
- package/dist/permissions/auto-accept-manager.js +215 -0
- package/dist/permissions/auto-accept-manager.test.js +528 -0
- package/dist/permissions/auto-accept-store.js +115 -0
- package/dist/permissions/auto-accept-store.test.js +157 -0
- package/dist/permissions/opencode-replier.js +31 -0
- package/dist/server/http-server.js +14 -0
- package/dist/server/routes/yolo.js +12 -0
- package/dist/workspaces/instance-client.js +40 -0
- package/package.json +2 -1
- package/public/assets/{FilesTab-BvAKbOAe.js → FilesTab-4OYnsEpA.js} +2 -2
- package/public/assets/{GitChangesTab-CvmcUbYv.js → GitChangesTab-BRg5IAgC.js} +2 -2
- package/public/assets/{SplitFilePanel-CxDUaPOb.js → SplitFilePanel-CBrsPjMw.js} +1 -1
- package/public/assets/{StatusTab-D5zJNjO2.js → StatusTab-DKKxkw1O.js} +1 -1
- package/public/assets/{align-justify-Bn8CDzlS.js → align-justify-BBbFmxDX.js} +1 -1
- package/public/assets/{bundle-full-DBhFsM8v.js → bundle-full-CaqU_-Bu.js} +1 -1
- package/public/assets/{diff-viewer-CovNS2gz.js → diff-viewer-X5HokaV7.js} +1 -1
- package/public/assets/{index-SO3ktACc.js → index-BVzVj6yO.js} +1 -1
- package/public/assets/{index-D0m4YbHz.js → index-B_mSCVdz.js} +1 -1
- package/public/assets/{index-CGKhj9MH.js → index-BbH6mwer.js} +1 -1
- package/public/assets/{index-D8Us4B7P.js → index-BcHO9P9h.js} +1 -1
- package/public/assets/{index-DtbpjUma.js → index-CNR6iBgw.js} +1 -1
- package/public/assets/{index-DlKCavfp.js → index-Cb1cfkUb.js} +1 -1
- package/public/assets/{index-byf9BI5h.js → index-DHYmm6_R.js} +2 -2
- package/public/assets/{index-CWut2MH1.js → index-DcfoDBMN.js} +1 -1
- package/public/assets/{index-Df02kJ_l.js → index-DqvdokW8.js} +1 -1
- package/public/assets/{index-coIdctUe.js → index-cfYxASJ7.js} +1 -1
- package/public/assets/{index-Dwb5ls4Z.js → index-jd2JD4UX.js} +1 -1
- package/public/assets/{loading-BitVzHui.js → loading-Bm_4ihk-.js} +1 -1
- package/public/assets/main-BYOHwhSs.js +68 -0
- package/public/assets/{markdown-0328GCk-.js → markdown-AEVUKCN6.js} +3 -3
- package/public/assets/{monaco-viewer-q26M2dw6.js → monaco-viewer-B3Jl6a5p.js} +3 -3
- package/public/assets/{tool-call-B8_OPv1H.js → tool-call-Bp55vSts.js} +7 -7
- package/public/assets/{unified-picker-bwjTqCPs.js → unified-picker-_ya4P-Mo.js} +1 -1
- package/public/assets/{wrap-text-DnuQ9rVl.js → wrap-text-Clu8BV6B.js} +1 -1
- package/public/index.html +3 -3
- package/public/loading.html +3 -3
- package/public/sw.js +1 -1
- package/public/assets/main-DMQeFbbj.js +0 -68
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { EventBus } from "../events/bus";
|
|
4
|
+
import { AutoAcceptManager } from "./auto-accept-manager";
|
|
5
|
+
const noopLogger = {
|
|
6
|
+
debug() { },
|
|
7
|
+
info() { },
|
|
8
|
+
warn() { },
|
|
9
|
+
error() { },
|
|
10
|
+
trace() { },
|
|
11
|
+
isLevelEnabled() {
|
|
12
|
+
return false;
|
|
13
|
+
},
|
|
14
|
+
child() {
|
|
15
|
+
return noopLogger;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
function publishInstanceEvent(bus, instanceId, event) {
|
|
19
|
+
bus.publish({ type: "instance.event", instanceId, event: { ...event } });
|
|
20
|
+
}
|
|
21
|
+
/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */
|
|
22
|
+
function publishSession(bus, instanceId, eventType, info) {
|
|
23
|
+
publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } });
|
|
24
|
+
}
|
|
25
|
+
describe("AutoAcceptManager session tree", () => {
|
|
26
|
+
it("ingests session.updated to build the parent chain", () => {
|
|
27
|
+
const bus = new EventBus(noopLogger);
|
|
28
|
+
const replier = makeRecordingReplier();
|
|
29
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
30
|
+
manager.start();
|
|
31
|
+
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null });
|
|
32
|
+
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" });
|
|
33
|
+
assert.equal(manager.isEnabled("inst", "master"), false);
|
|
34
|
+
manager.toggle("inst", "child");
|
|
35
|
+
assert.equal(manager.isEnabled("inst", "child"), true);
|
|
36
|
+
assert.equal(manager.isEnabled("inst", "master"), true);
|
|
37
|
+
manager.stop();
|
|
38
|
+
});
|
|
39
|
+
it("treats a session with revert as a fork root", () => {
|
|
40
|
+
const bus = new EventBus(noopLogger);
|
|
41
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() });
|
|
42
|
+
manager.start();
|
|
43
|
+
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null });
|
|
44
|
+
publishSession(bus, "inst", "session.updated", {
|
|
45
|
+
id: "fork",
|
|
46
|
+
parentID: "master",
|
|
47
|
+
revert: { messageID: "m", partID: "p" },
|
|
48
|
+
});
|
|
49
|
+
manager.toggle("inst", "fork");
|
|
50
|
+
assert.equal(manager.isEnabled("inst", "fork"), true);
|
|
51
|
+
assert.equal(manager.isEnabled("inst", "master"), false);
|
|
52
|
+
manager.stop();
|
|
53
|
+
});
|
|
54
|
+
it("session.deleted removes the tree entry but keeps the toggle", () => {
|
|
55
|
+
const bus = new EventBus(noopLogger);
|
|
56
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() });
|
|
57
|
+
manager.start();
|
|
58
|
+
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null });
|
|
59
|
+
manager.toggle("inst", "master");
|
|
60
|
+
publishSession(bus, "inst", "session.deleted", { id: "master" });
|
|
61
|
+
// toggle is independent of the tree (survives deletion)
|
|
62
|
+
assert.equal(manager.isEnabled("inst", "master"), true);
|
|
63
|
+
manager.stop();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("AutoAcceptManager permission interception", () => {
|
|
67
|
+
it("auto-replies to a v2 permission on an enabled family", async () => {
|
|
68
|
+
const bus = new EventBus(noopLogger);
|
|
69
|
+
const replier = makeRecordingReplier();
|
|
70
|
+
const accepted = [];
|
|
71
|
+
bus.on("yolo.autoAccepted", (e) => accepted.push(e));
|
|
72
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
73
|
+
manager.start();
|
|
74
|
+
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null });
|
|
75
|
+
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" });
|
|
76
|
+
manager.toggle("inst", "child"); // enable the whole family root
|
|
77
|
+
publishInstanceEvent(bus, "inst", {
|
|
78
|
+
type: "permission.v2.asked",
|
|
79
|
+
properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] },
|
|
80
|
+
});
|
|
81
|
+
await flushMicrotasks();
|
|
82
|
+
assert.equal(replier.calls.length, 1);
|
|
83
|
+
const call = replier.calls[0];
|
|
84
|
+
assert.equal(call.instanceId, "inst");
|
|
85
|
+
assert.equal(call.permissionId, "perm-1");
|
|
86
|
+
assert.equal(call.sessionId, "child");
|
|
87
|
+
assert.equal(call.source, "v2");
|
|
88
|
+
assert.equal(call.reply, "once");
|
|
89
|
+
assert.equal(accepted.length, 1);
|
|
90
|
+
assert.equal(accepted[0].permissionId, "perm-1");
|
|
91
|
+
manager.stop();
|
|
92
|
+
});
|
|
93
|
+
it("auto-replies to a legacy permission.asked event", async () => {
|
|
94
|
+
const bus = new EventBus(noopLogger);
|
|
95
|
+
const replier = makeRecordingReplier();
|
|
96
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
97
|
+
manager.start();
|
|
98
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
99
|
+
manager.toggle("inst", "solo");
|
|
100
|
+
publishInstanceEvent(bus, "inst", {
|
|
101
|
+
type: "permission.asked",
|
|
102
|
+
properties: { id: "perm-2", sessionID: "solo", type: "bash" },
|
|
103
|
+
});
|
|
104
|
+
await flushMicrotasks();
|
|
105
|
+
assert.equal(replier.calls.length, 1);
|
|
106
|
+
assert.equal(replier.calls[0].source, "legacy");
|
|
107
|
+
assert.equal(replier.calls[0].permissionId, "perm-2");
|
|
108
|
+
manager.stop();
|
|
109
|
+
});
|
|
110
|
+
it("does not reply when the family is disabled", async () => {
|
|
111
|
+
const bus = new EventBus(noopLogger);
|
|
112
|
+
const replier = makeRecordingReplier();
|
|
113
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
114
|
+
manager.start();
|
|
115
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
116
|
+
publishInstanceEvent(bus, "inst", {
|
|
117
|
+
type: "permission.v2.asked",
|
|
118
|
+
properties: { id: "perm-3", sessionID: "solo" },
|
|
119
|
+
});
|
|
120
|
+
await flushMicrotasks();
|
|
121
|
+
assert.equal(replier.calls.length, 0);
|
|
122
|
+
manager.stop();
|
|
123
|
+
});
|
|
124
|
+
it("ignores permission events without an id or sessionID", async () => {
|
|
125
|
+
const bus = new EventBus(noopLogger);
|
|
126
|
+
const replier = makeRecordingReplier();
|
|
127
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
128
|
+
manager.start();
|
|
129
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
130
|
+
manager.toggle("inst", "solo");
|
|
131
|
+
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } });
|
|
132
|
+
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } });
|
|
133
|
+
await flushMicrotasks();
|
|
134
|
+
assert.equal(replier.calls.length, 0);
|
|
135
|
+
manager.stop();
|
|
136
|
+
});
|
|
137
|
+
it("deduplicates repeated emission of the same permission", async () => {
|
|
138
|
+
const bus = new EventBus(noopLogger);
|
|
139
|
+
const replier = makeRecordingReplier();
|
|
140
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
141
|
+
manager.start();
|
|
142
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
143
|
+
manager.toggle("inst", "solo");
|
|
144
|
+
for (let i = 0; i < 3; i++) {
|
|
145
|
+
publishInstanceEvent(bus, "inst", {
|
|
146
|
+
type: "permission.v2.asked",
|
|
147
|
+
properties: { id: "perm-dup", sessionID: "solo" },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
await flushMicrotasks();
|
|
151
|
+
assert.equal(replier.calls.length, 1);
|
|
152
|
+
manager.stop();
|
|
153
|
+
});
|
|
154
|
+
it("clears in-flight tracking after the reply resolves so it can retry", async () => {
|
|
155
|
+
const bus = new EventBus(noopLogger);
|
|
156
|
+
const replier = makeRecordingReplier();
|
|
157
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
158
|
+
manager.start();
|
|
159
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
160
|
+
manager.toggle("inst", "solo");
|
|
161
|
+
publishInstanceEvent(bus, "inst", {
|
|
162
|
+
type: "permission.v2.asked",
|
|
163
|
+
properties: { id: "perm-retry", sessionID: "solo" },
|
|
164
|
+
});
|
|
165
|
+
await flushMicrotasks();
|
|
166
|
+
publishInstanceEvent(bus, "inst", {
|
|
167
|
+
type: "permission.v2.asked",
|
|
168
|
+
properties: { id: "perm-retry", sessionID: "solo" },
|
|
169
|
+
});
|
|
170
|
+
await flushMicrotasks();
|
|
171
|
+
assert.equal(replier.calls.length, 2);
|
|
172
|
+
manager.stop();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
describe("AutoAcceptManager state events", () => {
|
|
176
|
+
it("publishes yolo.stateChanged with the new enabled value on toggle", () => {
|
|
177
|
+
const bus = new EventBus(noopLogger);
|
|
178
|
+
const changes = [];
|
|
179
|
+
bus.on("yolo.stateChanged", (e) => changes.push(e));
|
|
180
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() });
|
|
181
|
+
manager.start();
|
|
182
|
+
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null });
|
|
183
|
+
manager.toggle("inst", "master");
|
|
184
|
+
manager.toggle("inst", "master");
|
|
185
|
+
assert.equal(changes.length, 2);
|
|
186
|
+
assert.equal(changes[0].enabled, true);
|
|
187
|
+
assert.equal(changes[1].enabled, false);
|
|
188
|
+
assert.equal(changes[0].sessionId, "master");
|
|
189
|
+
assert.equal(changes[0].instanceId, "inst");
|
|
190
|
+
manager.stop();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe("AutoAcceptManager lifecycle", () => {
|
|
194
|
+
it("clearInstance drops tree and enabled state for the instance", () => {
|
|
195
|
+
const bus = new EventBus(noopLogger);
|
|
196
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() });
|
|
197
|
+
manager.start();
|
|
198
|
+
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null });
|
|
199
|
+
manager.toggle("inst", "master");
|
|
200
|
+
manager.clearInstance("inst");
|
|
201
|
+
assert.equal(manager.isEnabled("inst", "master"), false);
|
|
202
|
+
manager.stop();
|
|
203
|
+
});
|
|
204
|
+
it("clears state when the workspace stops", () => {
|
|
205
|
+
const bus = new EventBus(noopLogger);
|
|
206
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() });
|
|
207
|
+
manager.start();
|
|
208
|
+
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null });
|
|
209
|
+
manager.toggle("inst", "master");
|
|
210
|
+
bus.publish({ type: "workspace.stopped", workspaceId: "inst" });
|
|
211
|
+
assert.equal(manager.isEnabled("inst", "master"), false);
|
|
212
|
+
manager.stop();
|
|
213
|
+
});
|
|
214
|
+
it("stop() unsubscribes so no further events are processed", async () => {
|
|
215
|
+
const bus = new EventBus(noopLogger);
|
|
216
|
+
const replier = makeRecordingReplier();
|
|
217
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
218
|
+
manager.start();
|
|
219
|
+
manager.stop();
|
|
220
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
221
|
+
manager.toggle("inst", "solo");
|
|
222
|
+
publishInstanceEvent(bus, "inst", {
|
|
223
|
+
type: "permission.v2.asked",
|
|
224
|
+
properties: { id: "p", sessionID: "solo" },
|
|
225
|
+
});
|
|
226
|
+
await flushMicrotasks();
|
|
227
|
+
assert.equal(replier.calls.length, 0);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
describe("AutoAcceptManager pending permissions drain", () => {
|
|
231
|
+
it("drains a pending permission that arrived before enable", async () => {
|
|
232
|
+
const bus = new EventBus(noopLogger);
|
|
233
|
+
const replier = makeRecordingReplier();
|
|
234
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
235
|
+
manager.start();
|
|
236
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
237
|
+
// permission arrives while yolo is OFF
|
|
238
|
+
publishInstanceEvent(bus, "inst", {
|
|
239
|
+
type: "permission.v2.asked",
|
|
240
|
+
properties: { id: "perm-pending", sessionID: "solo" },
|
|
241
|
+
});
|
|
242
|
+
await flushMicrotasks();
|
|
243
|
+
assert.equal(replier.calls.length, 0);
|
|
244
|
+
// enabling yolo should drain the pending permission
|
|
245
|
+
manager.toggle("inst", "solo");
|
|
246
|
+
await flushMicrotasks();
|
|
247
|
+
assert.equal(replier.calls.length, 1);
|
|
248
|
+
assert.equal(replier.calls[0].permissionId, "perm-pending");
|
|
249
|
+
manager.stop();
|
|
250
|
+
});
|
|
251
|
+
it("drains pending permissions for the same family only", async () => {
|
|
252
|
+
const bus = new EventBus(noopLogger);
|
|
253
|
+
const replier = makeRecordingReplier();
|
|
254
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
255
|
+
manager.start();
|
|
256
|
+
publishSession(bus, "inst", "session.updated", { id: "root-a", parentID: null });
|
|
257
|
+
publishSession(bus, "inst", "session.updated", { id: "root-b", parentID: null });
|
|
258
|
+
publishInstanceEvent(bus, "inst", {
|
|
259
|
+
type: "permission.v2.asked",
|
|
260
|
+
properties: { id: "perm-a", sessionID: "root-a" },
|
|
261
|
+
});
|
|
262
|
+
publishInstanceEvent(bus, "inst", {
|
|
263
|
+
type: "permission.v2.asked",
|
|
264
|
+
properties: { id: "perm-b", sessionID: "root-b" },
|
|
265
|
+
});
|
|
266
|
+
await flushMicrotasks();
|
|
267
|
+
assert.equal(replier.calls.length, 0);
|
|
268
|
+
manager.toggle("inst", "root-a");
|
|
269
|
+
await flushMicrotasks();
|
|
270
|
+
assert.equal(replier.calls.length, 1);
|
|
271
|
+
assert.equal(replier.calls[0].permissionId, "perm-a");
|
|
272
|
+
manager.stop();
|
|
273
|
+
});
|
|
274
|
+
it("does not re-drain already-auto-accepted permissions", async () => {
|
|
275
|
+
const bus = new EventBus(noopLogger);
|
|
276
|
+
const replier = makeRecordingReplier();
|
|
277
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
278
|
+
manager.start();
|
|
279
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
280
|
+
manager.toggle("inst", "solo");
|
|
281
|
+
publishInstanceEvent(bus, "inst", {
|
|
282
|
+
type: "permission.v2.asked",
|
|
283
|
+
properties: { id: "perm-1", sessionID: "solo" },
|
|
284
|
+
});
|
|
285
|
+
await flushMicrotasks();
|
|
286
|
+
assert.equal(replier.calls.length, 1);
|
|
287
|
+
// toggling off then on should not re-drain the already-replied permission
|
|
288
|
+
manager.toggle("inst", "solo"); // off
|
|
289
|
+
manager.toggle("inst", "solo"); // on — drain runs but pending set is empty
|
|
290
|
+
await flushMicrotasks();
|
|
291
|
+
assert.equal(replier.calls.length, 1);
|
|
292
|
+
manager.stop();
|
|
293
|
+
});
|
|
294
|
+
it("re-drains pending when late session ancestry joins an enabled family", async () => {
|
|
295
|
+
const bus = new EventBus(noopLogger);
|
|
296
|
+
const replier = makeRecordingReplier();
|
|
297
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
298
|
+
manager.start();
|
|
299
|
+
// master exists, yolo enabled on master
|
|
300
|
+
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null });
|
|
301
|
+
manager.toggle("inst", "master");
|
|
302
|
+
// child permission arrives BEFORE child's session ancestry is known.
|
|
303
|
+
// At this point "child" is unknown to the tree, so it resolves as its
|
|
304
|
+
// own root and the permission is NOT auto-accepted.
|
|
305
|
+
publishInstanceEvent(bus, "inst", {
|
|
306
|
+
type: "permission.v2.asked",
|
|
307
|
+
properties: { id: "perm-late", sessionID: "child" },
|
|
308
|
+
});
|
|
309
|
+
await flushMicrotasks();
|
|
310
|
+
assert.equal(replier.calls.length, 0);
|
|
311
|
+
// child session ancestry arrives — child.parentID = "master".
|
|
312
|
+
// ingestSession → upsertSession → migrateEnabledRoots makes "child"
|
|
313
|
+
// resolve to "master" (enabled). drainPending should fire and accept
|
|
314
|
+
// the previously-pending permission.
|
|
315
|
+
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" });
|
|
316
|
+
await flushMicrotasks();
|
|
317
|
+
assert.equal(replier.calls.length, 1);
|
|
318
|
+
assert.equal(replier.calls[0].permissionId, "perm-late");
|
|
319
|
+
assert.equal(replier.calls[0].sessionId, "child");
|
|
320
|
+
manager.stop();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
describe("AutoAcceptManager permission replied cleanup", () => {
|
|
324
|
+
it("removes a pending permission on permission.v2.replied", async () => {
|
|
325
|
+
const bus = new EventBus(noopLogger);
|
|
326
|
+
const replier = makeRecordingReplier();
|
|
327
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
328
|
+
manager.start();
|
|
329
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
330
|
+
publishInstanceEvent(bus, "inst", {
|
|
331
|
+
type: "permission.v2.asked",
|
|
332
|
+
properties: { id: "perm-x", sessionID: "solo" },
|
|
333
|
+
});
|
|
334
|
+
await flushMicrotasks();
|
|
335
|
+
// user manually replies → replied event cleans up pending
|
|
336
|
+
publishInstanceEvent(bus, "inst", {
|
|
337
|
+
type: "permission.v2.replied",
|
|
338
|
+
properties: { id: "perm-x" },
|
|
339
|
+
});
|
|
340
|
+
// enabling yolo now should NOT drain the already-replied permission
|
|
341
|
+
manager.toggle("inst", "solo");
|
|
342
|
+
await flushMicrotasks();
|
|
343
|
+
assert.equal(replier.calls.length, 0);
|
|
344
|
+
manager.stop();
|
|
345
|
+
});
|
|
346
|
+
it("removes a pending permission on legacy permission.replied", async () => {
|
|
347
|
+
const bus = new EventBus(noopLogger);
|
|
348
|
+
const replier = makeRecordingReplier();
|
|
349
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
350
|
+
manager.start();
|
|
351
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
352
|
+
publishInstanceEvent(bus, "inst", {
|
|
353
|
+
type: "permission.asked",
|
|
354
|
+
properties: { id: "perm-y", sessionID: "solo" },
|
|
355
|
+
});
|
|
356
|
+
await flushMicrotasks();
|
|
357
|
+
publishInstanceEvent(bus, "inst", {
|
|
358
|
+
type: "permission.replied",
|
|
359
|
+
properties: { requestID: "perm-y" },
|
|
360
|
+
});
|
|
361
|
+
manager.toggle("inst", "solo");
|
|
362
|
+
await flushMicrotasks();
|
|
363
|
+
assert.equal(replier.calls.length, 0);
|
|
364
|
+
manager.stop();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
describe("AutoAcceptManager clearInstance clears pending", () => {
|
|
368
|
+
it("drops pending permissions on clearInstance", async () => {
|
|
369
|
+
const bus = new EventBus(noopLogger);
|
|
370
|
+
const replier = makeRecordingReplier();
|
|
371
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
372
|
+
manager.start();
|
|
373
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
374
|
+
publishInstanceEvent(bus, "inst", {
|
|
375
|
+
type: "permission.v2.asked",
|
|
376
|
+
properties: { id: "perm-z", sessionID: "solo" },
|
|
377
|
+
});
|
|
378
|
+
await flushMicrotasks();
|
|
379
|
+
manager.clearInstance("inst");
|
|
380
|
+
// re-create session and enable — pending set should be empty
|
|
381
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
382
|
+
manager.toggle("inst", "solo");
|
|
383
|
+
await flushMicrotasks();
|
|
384
|
+
assert.equal(replier.calls.length, 0);
|
|
385
|
+
manager.stop();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
describe("AutoAcceptManager permission.updated source inference", () => {
|
|
389
|
+
it("preserves the original v2 source when permission.updated arrives", async () => {
|
|
390
|
+
const bus = new EventBus(noopLogger);
|
|
391
|
+
const replier = makeRecordingReplier();
|
|
392
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
393
|
+
manager.start();
|
|
394
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
395
|
+
// yolo is OFF — permission goes to pending with source "v2"
|
|
396
|
+
publishInstanceEvent(bus, "inst", {
|
|
397
|
+
type: "permission.v2.asked",
|
|
398
|
+
properties: { id: "perm-v2", sessionID: "solo" },
|
|
399
|
+
});
|
|
400
|
+
await flushMicrotasks();
|
|
401
|
+
// enable yolo, then send permission.updated — should keep source "v2"
|
|
402
|
+
manager.toggle("inst", "solo");
|
|
403
|
+
publishInstanceEvent(bus, "inst", {
|
|
404
|
+
type: "permission.updated",
|
|
405
|
+
properties: { id: "perm-v2", sessionID: "solo" },
|
|
406
|
+
});
|
|
407
|
+
await flushMicrotasks();
|
|
408
|
+
assert.equal(replier.calls.length, 1);
|
|
409
|
+
assert.equal(replier.calls[0].source, "v2");
|
|
410
|
+
manager.stop();
|
|
411
|
+
});
|
|
412
|
+
it("skips permission.updated for a permission not in pending", async () => {
|
|
413
|
+
const bus = new EventBus(noopLogger);
|
|
414
|
+
const replier = makeRecordingReplier();
|
|
415
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
416
|
+
manager.start();
|
|
417
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
418
|
+
manager.toggle("inst", "solo");
|
|
419
|
+
// permission.updated for a permission that was never asked (not in pending)
|
|
420
|
+
publishInstanceEvent(bus, "inst", {
|
|
421
|
+
type: "permission.updated",
|
|
422
|
+
properties: { id: "perm-unknown", sessionID: "solo" },
|
|
423
|
+
});
|
|
424
|
+
await flushMicrotasks();
|
|
425
|
+
assert.equal(replier.calls.length, 0);
|
|
426
|
+
manager.stop();
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
describe("AutoAcceptManager replier failure handling", () => {
|
|
430
|
+
it("keeps permission pending and does not emit autoAccepted on failure", async () => {
|
|
431
|
+
const bus = new EventBus(noopLogger);
|
|
432
|
+
const autoAccepted = [];
|
|
433
|
+
bus.on("yolo.autoAccepted", (e) => autoAccepted.push(e));
|
|
434
|
+
const failingReplier = async () => {
|
|
435
|
+
throw new Error("connection refused");
|
|
436
|
+
};
|
|
437
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier });
|
|
438
|
+
manager.start();
|
|
439
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
440
|
+
manager.toggle("inst", "solo");
|
|
441
|
+
publishInstanceEvent(bus, "inst", {
|
|
442
|
+
type: "permission.v2.asked",
|
|
443
|
+
properties: { id: "perm-fail", sessionID: "solo" },
|
|
444
|
+
});
|
|
445
|
+
await flushMicrotasks();
|
|
446
|
+
assert.equal(autoAccepted.length, 0);
|
|
447
|
+
// permission should still be pending (enabling again would try again)
|
|
448
|
+
manager.toggle("inst", "solo"); // off
|
|
449
|
+
manager.toggle("inst", "solo"); // on — drain retries
|
|
450
|
+
await flushMicrotasks();
|
|
451
|
+
assert.equal(autoAccepted.length, 0);
|
|
452
|
+
manager.stop();
|
|
453
|
+
});
|
|
454
|
+
it("stops retrying after MAX_REPLY_ATTEMPTS", async () => {
|
|
455
|
+
const bus = new EventBus(noopLogger);
|
|
456
|
+
let callCount = 0;
|
|
457
|
+
const failingReplier = async () => {
|
|
458
|
+
callCount++;
|
|
459
|
+
throw new Error("timeout");
|
|
460
|
+
};
|
|
461
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier });
|
|
462
|
+
manager.start();
|
|
463
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
464
|
+
manager.toggle("inst", "solo");
|
|
465
|
+
publishInstanceEvent(bus, "inst", {
|
|
466
|
+
type: "permission.v2.asked",
|
|
467
|
+
properties: { id: "perm-stuck", sessionID: "solo" },
|
|
468
|
+
});
|
|
469
|
+
await flushMicrotasks();
|
|
470
|
+
const attemptsAfterFirst = callCount;
|
|
471
|
+
// trigger drains via session events
|
|
472
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
473
|
+
await flushMicrotasks();
|
|
474
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
475
|
+
await flushMicrotasks();
|
|
476
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
477
|
+
await flushMicrotasks();
|
|
478
|
+
// should have attempted at most MAX_REPLY_ATTEMPTS times total
|
|
479
|
+
assert.ok(callCount <= 3, `expected at most 3 attempts, got ${callCount}`);
|
|
480
|
+
assert.equal(callCount, attemptsAfterFirst + 2); // 2 more retries (total 3), then stops
|
|
481
|
+
manager.stop();
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
describe("AutoAcceptManager workspace.error cleanup", () => {
|
|
485
|
+
it("clears state when the workspace errors", () => {
|
|
486
|
+
const bus = new EventBus(noopLogger);
|
|
487
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() });
|
|
488
|
+
manager.start();
|
|
489
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
490
|
+
manager.toggle("inst", "solo");
|
|
491
|
+
assert.equal(manager.isEnabled("inst", "solo"), true);
|
|
492
|
+
bus.publish({ type: "workspace.error", workspace: { id: "inst" } });
|
|
493
|
+
assert.equal(manager.isEnabled("inst", "solo"), false);
|
|
494
|
+
manager.stop();
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
describe("AutoAcceptManager session.deleted clears pending", () => {
|
|
498
|
+
it("removes pending permissions for a deleted session", async () => {
|
|
499
|
+
const bus = new EventBus(noopLogger);
|
|
500
|
+
const replier = makeRecordingReplier();
|
|
501
|
+
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier });
|
|
502
|
+
manager.start();
|
|
503
|
+
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null });
|
|
504
|
+
// permission arrives while yolo is OFF → goes to pending
|
|
505
|
+
publishInstanceEvent(bus, "inst", {
|
|
506
|
+
type: "permission.v2.asked",
|
|
507
|
+
properties: { id: "perm-doomed", sessionID: "solo" },
|
|
508
|
+
});
|
|
509
|
+
await flushMicrotasks();
|
|
510
|
+
// session deleted → pending should be cleared
|
|
511
|
+
publishSession(bus, "inst", "session.deleted", { id: "solo" });
|
|
512
|
+
// enabling yolo should not drain the deleted session's permission
|
|
513
|
+
manager.toggle("inst", "solo");
|
|
514
|
+
await flushMicrotasks();
|
|
515
|
+
assert.equal(replier.calls.length, 0);
|
|
516
|
+
manager.stop();
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
function makeRecordingReplier() {
|
|
520
|
+
const calls = [];
|
|
521
|
+
const replier = async (reply) => {
|
|
522
|
+
calls.push(reply);
|
|
523
|
+
};
|
|
524
|
+
return Object.assign(replier, { calls });
|
|
525
|
+
}
|
|
526
|
+
function flushMicrotasks() {
|
|
527
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
528
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory permission auto-accept (Yolo) state, owned by the server.
|
|
3
|
+
*
|
|
4
|
+
* This is a faithful port of the previous frontend implementation
|
|
5
|
+
* (`packages/ui/src/stores/permission-auto-accept.ts`) so the inheritance
|
|
6
|
+
* semantics are preserved exactly:
|
|
7
|
+
* - state is keyed by the resolved *family root* session id
|
|
8
|
+
* - a session with a `revert` snapshot is treated as its own root (fork)
|
|
9
|
+
* - enabling any session enables its whole family root and vice-versa
|
|
10
|
+
*
|
|
11
|
+
* No persistence: state is lost on server restart, matching the "no
|
|
12
|
+
* persistence for now" milestone.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the family-root session id for `sessionId` by walking the parent
|
|
16
|
+
* chain. Mirrors `resolvePermissionAutoAcceptFamilyRoot` from the UI so
|
|
17
|
+
* inheritance behaviour does not change.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveFamilyRoot(sessionId, getSession) {
|
|
20
|
+
let currentId = sessionId;
|
|
21
|
+
let lastKnownId = sessionId;
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
while (currentId && !seen.has(currentId)) {
|
|
24
|
+
seen.add(currentId);
|
|
25
|
+
const session = getSession(currentId);
|
|
26
|
+
if (!session)
|
|
27
|
+
return lastKnownId;
|
|
28
|
+
lastKnownId = session.id;
|
|
29
|
+
if (session.revert)
|
|
30
|
+
return session.id;
|
|
31
|
+
if (!session.parentId)
|
|
32
|
+
return session.id;
|
|
33
|
+
currentId = session.parentId;
|
|
34
|
+
}
|
|
35
|
+
return currentId || sessionId;
|
|
36
|
+
}
|
|
37
|
+
export class AutoAcceptStore {
|
|
38
|
+
constructor() {
|
|
39
|
+
/** instanceId -> set of enabled family-root session ids */
|
|
40
|
+
this.enabled = new Map();
|
|
41
|
+
/** instanceId -> (sessionId -> info) */
|
|
42
|
+
this.sessions = new Map();
|
|
43
|
+
}
|
|
44
|
+
isEnabled(instanceId, sessionId) {
|
|
45
|
+
const root = this.familyRoot(instanceId, sessionId);
|
|
46
|
+
return this.enabled.get(instanceId)?.has(root) ?? false;
|
|
47
|
+
}
|
|
48
|
+
setEnabled(instanceId, sessionId, enabled) {
|
|
49
|
+
const root = this.familyRoot(instanceId, sessionId);
|
|
50
|
+
let roots = this.enabled.get(instanceId);
|
|
51
|
+
if (!roots) {
|
|
52
|
+
if (!enabled)
|
|
53
|
+
return;
|
|
54
|
+
roots = new Set();
|
|
55
|
+
this.enabled.set(instanceId, roots);
|
|
56
|
+
}
|
|
57
|
+
if (enabled) {
|
|
58
|
+
roots.add(root);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
roots.delete(root);
|
|
62
|
+
if (roots.size === 0) {
|
|
63
|
+
this.enabled.delete(instanceId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
toggle(instanceId, sessionId) {
|
|
68
|
+
const next = !this.isEnabled(instanceId, sessionId);
|
|
69
|
+
this.setEnabled(instanceId, sessionId, next);
|
|
70
|
+
return next;
|
|
71
|
+
}
|
|
72
|
+
upsertSession(instanceId, info) {
|
|
73
|
+
let tree = this.sessions.get(instanceId);
|
|
74
|
+
if (!tree) {
|
|
75
|
+
tree = new Map();
|
|
76
|
+
this.sessions.set(instanceId, tree);
|
|
77
|
+
}
|
|
78
|
+
tree.set(info.id, {
|
|
79
|
+
id: info.id,
|
|
80
|
+
parentId: info.parentId ?? null,
|
|
81
|
+
revert: info.revert,
|
|
82
|
+
});
|
|
83
|
+
this.migrateEnabledRoots(instanceId);
|
|
84
|
+
}
|
|
85
|
+
removeSession(instanceId, sessionId) {
|
|
86
|
+
this.sessions.get(instanceId)?.delete(sessionId);
|
|
87
|
+
}
|
|
88
|
+
clearInstance(instanceId) {
|
|
89
|
+
this.sessions.delete(instanceId);
|
|
90
|
+
this.enabled.delete(instanceId);
|
|
91
|
+
}
|
|
92
|
+
/** Resolves the family-root session id for the given session. */
|
|
93
|
+
familyRoot(instanceId, sessionId) {
|
|
94
|
+
const tree = this.sessions.get(instanceId);
|
|
95
|
+
return resolveFamilyRoot(sessionId, (id) => tree?.get(id));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Re-resolves every enabled family root for an instance after the session
|
|
99
|
+
* tree changes (new session, updated parent/revert). If a root now resolves
|
|
100
|
+
* to a different id, the enabled entry is migrated so toggles survive late
|
|
101
|
+
* ancestry discovery.
|
|
102
|
+
*/
|
|
103
|
+
migrateEnabledRoots(instanceId) {
|
|
104
|
+
const roots = this.enabled.get(instanceId);
|
|
105
|
+
if (!roots || roots.size === 0)
|
|
106
|
+
return;
|
|
107
|
+
for (const oldRoot of Array.from(roots)) {
|
|
108
|
+
const newRoot = this.familyRoot(instanceId, oldRoot);
|
|
109
|
+
if (newRoot !== oldRoot) {
|
|
110
|
+
roots.delete(oldRoot);
|
|
111
|
+
roots.add(newRoot);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|