@topolo/cli 0.13.7 → 0.13.9
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.
|
@@ -24,7 +24,8 @@ async function nativeClaudePackOperation(manager, sidebar, plan, apply, checkpoi
|
|
|
24
24
|
if (plan.noEffects && (plan.mode !== "recover" || previous?.effectAttempted)) throw new Error("No-effect recovery is not qualified.");
|
|
25
25
|
if (apply) manager[fenceKey] = { operationId, running: true, effectAttempted: false };
|
|
26
26
|
try {
|
|
27
|
-
|
|
27
|
+
const incremental = (plan.mode ?? "configure") === "configure";
|
|
28
|
+
verifyContext(incremental ? [] : void 0);
|
|
28
29
|
const checkDeadline = () => {
|
|
29
30
|
if (Date.now() >= plan.deadline) throw new Error("Native operation deadline expired; reconcile journal before retry.");
|
|
30
31
|
};
|
|
@@ -64,18 +65,28 @@ async function nativeClaudePackOperation(manager, sidebar, plan, apply, checkpoi
|
|
|
64
65
|
const result = [];
|
|
65
66
|
for (const member of members) {
|
|
66
67
|
checkDeadline();
|
|
67
|
-
|
|
68
|
+
let session = await manager.getSession(member.sessionId);
|
|
68
69
|
if (!session || session.sessionId !== member.sessionId || session.cwd !== member.cwd || session.title !== member.title || session.isArchived && !["switch", "recover"].includes(plan.mode ?? "configure"))
|
|
69
70
|
throw new Error("Owned session identity or active membership changed: " + member.key);
|
|
70
|
-
|
|
71
|
+
let descriptor;
|
|
72
|
+
if (!incremental && (manager.hasLosableWork(member.sessionId) || session.isRunning || session.starting || session.pendingToolPermissions?.length))
|
|
71
73
|
throw new Error("Session has live or pending work; no changes permitted: " + member.key);
|
|
72
74
|
if (!plan.noEffects) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
descriptor = await nativeDescriptor(member);
|
|
76
|
+
session = await manager.getSession(member.sessionId);
|
|
77
|
+
checkDeadline();
|
|
78
|
+
if (!session || session.sessionId !== member.sessionId || session.cwd !== member.cwd || session.title !== member.title || (session.isArchived || descriptor.isArchived) && incremental) throw new Error("Owned session identity or active membership changed: " + member.key);
|
|
79
|
+
if (!incremental && (manager.hasLosableWork(member.sessionId) || session.isRunning || session.starting || session.pendingToolPermissions?.length || descriptor.isRunning || descriptor.starting || descriptor.pendingToolPermissions?.length))
|
|
75
80
|
throw new Error("Session has live or pending work; no changes permitted: " + member.key);
|
|
76
81
|
if (descriptor.backend?.kind !== "local" || descriptor.backend.remoteTarget || descriptor.worktreePath || descriptor.worktreeLazy || descriptor.spawnedFrom)
|
|
77
82
|
throw new Error("Incoming session is not a qualified local role.");
|
|
78
83
|
}
|
|
84
|
+
const busyReasons = [
|
|
85
|
+
...manager.hasLosableWork(member.sessionId) ? ["losable"] : [],
|
|
86
|
+
...session.isRunning || descriptor?.isRunning ? ["running"] : [],
|
|
87
|
+
...session.starting || descriptor?.starting ? ["starting"] : [],
|
|
88
|
+
...session.pendingToolPermissions?.length || descriptor?.pendingToolPermissions?.length ? ["pending-permissions"] : []
|
|
89
|
+
];
|
|
79
90
|
result.push({
|
|
80
91
|
key: member.key,
|
|
81
92
|
sessionId: session.sessionId,
|
|
@@ -86,7 +97,8 @@ async function nativeClaudePackOperation(manager, sidebar, plan, apply, checkpoi
|
|
|
86
97
|
model: session.model ?? null,
|
|
87
98
|
effort: session.effort ?? null,
|
|
88
99
|
permissionMode: session.permissionMode ?? null,
|
|
89
|
-
archived: session.isArchived === true
|
|
100
|
+
archived: session.isArchived === true,
|
|
101
|
+
...incremental ? { busy: busyReasons.length > 0, busyReasons } : {}
|
|
90
102
|
});
|
|
91
103
|
}
|
|
92
104
|
return result;
|
|
@@ -118,6 +130,124 @@ async function nativeClaudePackOperation(manager, sidebar, plan, apply, checkpoi
|
|
|
118
130
|
const outgoingBefore = await outgoingSnapshot();
|
|
119
131
|
const sections = await command({ kind: "list_sections" });
|
|
120
132
|
checkpoint({ kind: "baseline", before, outgoingBefore, sections: sections.sections, at: Date.now() });
|
|
133
|
+
if (incremental) {
|
|
134
|
+
const settings = ["group", "pinned", "model", "effort", "permissionMode"];
|
|
135
|
+
const idleBefore = plan.members.filter((_, i) => !before[i].busy);
|
|
136
|
+
verifyContext([]);
|
|
137
|
+
if (!apply) return {
|
|
138
|
+
state: "preflight",
|
|
139
|
+
before,
|
|
140
|
+
outgoingBefore,
|
|
141
|
+
sections: sections.sections,
|
|
142
|
+
availableModels: models,
|
|
143
|
+
applied: false,
|
|
144
|
+
contextVerifiedSessionIds: idleBefore.map((m) => m.sessionId)
|
|
145
|
+
};
|
|
146
|
+
const appliedFields = [];
|
|
147
|
+
const deferredReasons = /* @__PURE__ */ new Map();
|
|
148
|
+
const differences = (member, row) => settings.filter((field) => member[field] !== row[field]);
|
|
149
|
+
for (const member of plan.members) {
|
|
150
|
+
const initial = (await snapshot([member]))[0];
|
|
151
|
+
if (initial.busy) {
|
|
152
|
+
deferredReasons.set(member.sessionId, initial.busyReasons);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
for (const setting of differences(member, initial)) {
|
|
156
|
+
let row = (await snapshot([member]))[0];
|
|
157
|
+
if (row.busy) {
|
|
158
|
+
deferredReasons.set(member.sessionId, row.busyReasons);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (row[setting] === member[setting]) continue;
|
|
162
|
+
verifyContext([member]);
|
|
163
|
+
if (setting === "group" && member.group !== null) {
|
|
164
|
+
let matching = manager.listCustomGroups()?.filter((g) => g.name === member.group);
|
|
165
|
+
if (!matching || matching.length > 1) throw new Error("Ambiguous native sidebar group: " + member.group);
|
|
166
|
+
if (!matching.length) {
|
|
167
|
+
row = (await snapshot([member]))[0];
|
|
168
|
+
if (row.busy) {
|
|
169
|
+
deferredReasons.set(member.sessionId, row.busyReasons);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
verifyContext([member]);
|
|
173
|
+
checkDeadline();
|
|
174
|
+
checkpoint({ kind: "attempt", operation: "create_group", key: member.key, name: member.group, at: Date.now() });
|
|
175
|
+
manager[fenceKey].effectAttempted = true;
|
|
176
|
+
const created = await command({ kind: "create_group", name: member.group });
|
|
177
|
+
checkpoint({ kind: "acknowledged", operation: "create_group", key: member.key, name: member.group, at: Date.now() });
|
|
178
|
+
matching = manager.listCustomGroups()?.filter((g) => g.name === member.group);
|
|
179
|
+
if (!created.groupId) throw new Error("Native create_group did not return an identity.");
|
|
180
|
+
if (matching?.length !== 1 || matching[0].id !== created.groupId)
|
|
181
|
+
throw new Error("Native group read-back mismatch.");
|
|
182
|
+
verifyContext([member]);
|
|
183
|
+
appliedFields.push({ key: member.key, operation: "create_group", name: member.group });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
row = (await snapshot([member]))[0];
|
|
187
|
+
if (row.busy) {
|
|
188
|
+
deferredReasons.set(member.sessionId, row.busyReasons);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (row[setting] === member[setting]) continue;
|
|
192
|
+
verifyContext([member]);
|
|
193
|
+
checkDeadline();
|
|
194
|
+
if (setting === "model" && !manager.getAvailableCodeModels().includes(member.model))
|
|
195
|
+
throw new Error("Requested model is absent from the native account catalog: " + member.model);
|
|
196
|
+
const operation = setting === "group" ? "move_sessions" : setting === "pinned" ? "set_pinned" : setting === "model" ? "set_model" : setting === "effort" ? "set_effort" : "set_permission_mode";
|
|
197
|
+
checkpoint({ kind: "attempt", operation, setting, key: member.key, sessionId: member.sessionId, at: Date.now() });
|
|
198
|
+
manager[fenceKey].effectAttempted = true;
|
|
199
|
+
if (setting === "group") {
|
|
200
|
+
const matching = member.group === null ? [] : manager.listCustomGroups()?.filter((g) => g.name === member.group);
|
|
201
|
+
if (!matching || member.group !== null && matching.length !== 1) throw new Error("Native group identity changed.");
|
|
202
|
+
await command({
|
|
203
|
+
kind: "move_sessions",
|
|
204
|
+
sessionIds: [member.sessionId],
|
|
205
|
+
...member.group === null ? {} : { groupId: matching[0].id }
|
|
206
|
+
});
|
|
207
|
+
} else if (setting === "pinned") await command({ kind: "set_pinned", sessionId: member.sessionId, pinned: member.pinned });
|
|
208
|
+
else if (setting === "model") await manager.setModel(member.sessionId, member.model);
|
|
209
|
+
else if (setting === "effort") await manager.setEffort(member.sessionId, member.effort);
|
|
210
|
+
else if (!await manager.setPermissionMode(member.sessionId, member.permissionMode, "picker"))
|
|
211
|
+
throw new Error("Native permission mode was refused: " + member.key);
|
|
212
|
+
checkpoint({ kind: "acknowledged", operation, setting, key: member.key, sessionId: member.sessionId, at: Date.now() });
|
|
213
|
+
const actual = (await snapshot([member]))[0];
|
|
214
|
+
if (actual[setting] !== member[setting]) throw new Error("Native read-back mismatch: " + member.key + " / " + setting);
|
|
215
|
+
verifyContext([member]);
|
|
216
|
+
appliedFields.push({ key: member.key, sessionId: member.sessionId, setting, value: member[setting] });
|
|
217
|
+
checkpoint({ kind: "verified-field", operation, setting, key: member.key, sessionId: member.sessionId, at: Date.now() });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const after2 = await snapshot();
|
|
221
|
+
const idleAfter = plan.members.filter((member, i) => !after2[i].busy && !deferredReasons.has(member.sessionId));
|
|
222
|
+
verifyContext(idleAfter);
|
|
223
|
+
const deferred = plan.members.flatMap((member, i) => {
|
|
224
|
+
const row = after2[i], fields = differences(member, row);
|
|
225
|
+
if (!fields.length) return [];
|
|
226
|
+
const reasons = deferredReasons.get(member.sessionId) ?? row.busyReasons;
|
|
227
|
+
if (!reasons?.length) throw new Error("Native read-back mismatch: " + member.key + " / " + fields.join(","));
|
|
228
|
+
return [{ key: member.key, sessionId: member.sessionId, fields, reasons }];
|
|
229
|
+
});
|
|
230
|
+
const attestedBusyKeys = after2.filter((row) => row.busy && !deferred.some((d) => d.sessionId === row.sessionId)).map((row) => row.key);
|
|
231
|
+
checkpoint({ kind: deferred.length ? "partial-verified" : "verified", after: after2, appliedFields, deferred, attestedBusyKeys, at: Date.now() });
|
|
232
|
+
delete manager[fenceKey];
|
|
233
|
+
return {
|
|
234
|
+
state: deferred.length ? "native-settings-partial" : "native-settings-verified",
|
|
235
|
+
before,
|
|
236
|
+
after: after2,
|
|
237
|
+
outgoingAfter: [],
|
|
238
|
+
verifiedAgents: after2.length - deferred.length,
|
|
239
|
+
complete: deferred.length === 0,
|
|
240
|
+
applied: appliedFields.length > 0,
|
|
241
|
+
appliedFields,
|
|
242
|
+
deferred,
|
|
243
|
+
attestedBusyKeys,
|
|
244
|
+
contextVerifiedSessionIds: idleAfter.map((m) => m.sessionId),
|
|
245
|
+
busyObservations: [...deferredReasons].map(([sessionId, reasons]) => ({ key: plan.members.find((m) => m.sessionId === sessionId).key, sessionId, reasons })),
|
|
246
|
+
modelInferenceVerified: false,
|
|
247
|
+
readOnlyConfinementVerified: false,
|
|
248
|
+
fullPackSwitchSupported: false
|
|
249
|
+
};
|
|
250
|
+
}
|
|
121
251
|
if (plan.mode === "recover") {
|
|
122
252
|
if (plan.noEffects) {
|
|
123
253
|
verifyContext();
|
|
@@ -672,6 +802,7 @@ function prepareClaudeNativePlan(store, pack, policyPath, mode = "configure") {
|
|
|
672
802
|
if (!records.length || records.length !== policy.agents.length) throw new Error("Reviewed policy must cover the complete installed roster.");
|
|
673
803
|
const members = [];
|
|
674
804
|
const context = {};
|
|
805
|
+
const roleContextFiles = {};
|
|
675
806
|
const contextInventories = {};
|
|
676
807
|
const captureMarkdown = (cwd) => {
|
|
677
808
|
contextInventories[cwd] = snapshotClaudeMarkdown(cwd, filesystem, cryptography, paths);
|
|
@@ -690,6 +821,7 @@ function prepareClaudeNativePlan(store, pack, policyPath, mode = "configure") {
|
|
|
690
821
|
const transcript = realpathSync(record.transcript);
|
|
691
822
|
context[charter] = createHash("sha256").update(readFileSync(charter)).digest("hex");
|
|
692
823
|
context[transcript] = createHash("sha256").update(readFileSync(transcript)).digest("hex");
|
|
824
|
+
roleContextFiles[record.desktopSessionId] = [charter, transcript];
|
|
693
825
|
members.push({
|
|
694
826
|
key: record.key,
|
|
695
827
|
sessionId: record.desktopSessionId,
|
|
@@ -744,6 +876,8 @@ function prepareClaudeNativePlan(store, pack, policyPath, mode = "configure") {
|
|
|
744
876
|
outgoing,
|
|
745
877
|
context,
|
|
746
878
|
contextInventories,
|
|
879
|
+
roleContextFiles,
|
|
880
|
+
globalContextFiles: [resolve(root, "claude/state.json"), realpathSync(policyPath)],
|
|
747
881
|
approvalRoutes,
|
|
748
882
|
installationConsent: policy.installationConsent,
|
|
749
883
|
libraryRoot: root,
|
|
@@ -752,6 +886,27 @@ function prepareClaudeNativePlan(store, pack, policyPath, mode = "configure") {
|
|
|
752
886
|
activeBefore: active
|
|
753
887
|
};
|
|
754
888
|
}
|
|
889
|
+
function verifyClaudePlanContext(plan, members, fs, crypto, path, snapshotMarkdown) {
|
|
890
|
+
if (!plan.contextInventories || !Object.keys(plan.contextInventories).length) throw new Error("Complete context inventory proof is unavailable.");
|
|
891
|
+
if (members && (!plan.roleContextFiles || !plan.globalContextFiles?.length)) throw new Error("Scoped role context proof is unavailable.");
|
|
892
|
+
const selected = members ?? plan.members;
|
|
893
|
+
if (new Set(selected.map((m) => m.sessionId)).size !== selected.length || selected.some((m) => !plan.members.some((p) => p.sessionId === m.sessionId && p.key === m.key && p.cwd === m.cwd && p.title === m.title)))
|
|
894
|
+
throw new Error("Scoped context identity differs from the complete roster.");
|
|
895
|
+
const roots = members ? selected.map((m) => m.cwd) : Object.keys(plan.contextInventories);
|
|
896
|
+
for (const root of roots) {
|
|
897
|
+
const inventory = plan.contextInventories[root];
|
|
898
|
+
if (!inventory || JSON.stringify(snapshotMarkdown(root, fs, crypto, path)) !== JSON.stringify(inventory))
|
|
899
|
+
throw new Error("Context or transcript changed during configuration: Markdown inventory " + root);
|
|
900
|
+
}
|
|
901
|
+
const files = members ? [...plan.globalContextFiles, ...selected.flatMap((m) => {
|
|
902
|
+
const files2 = plan.roleContextFiles[m.sessionId];
|
|
903
|
+
if (!files2?.length) throw new Error("Scoped role context proof is unavailable.");
|
|
904
|
+
return files2;
|
|
905
|
+
})] : Object.keys(plan.context);
|
|
906
|
+
for (const file of files)
|
|
907
|
+
if (!plan.context[file] || crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") !== plan.context[file])
|
|
908
|
+
throw new Error("Context or transcript changed during configuration: " + file);
|
|
909
|
+
}
|
|
755
910
|
function qualifyPermissionConsent(policy, key, records, full) {
|
|
756
911
|
const consent = policy.installationConsent;
|
|
757
912
|
const intakeKey = consent?.intakeRoutes?.[key];
|
|
@@ -784,15 +939,9 @@ function nativePackExpression(pid, plan, journal, apply) {
|
|
|
784
939
|
const context = ${JSON.stringify(plan.context)};
|
|
785
940
|
const inventories = ${JSON.stringify(plan.contextInventories)};
|
|
786
941
|
const snapshotMarkdown = (${snapshotClaudeMarkdown.toString()});
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
if (JSON.stringify(snapshotMarkdown(root, fs, crypto, path)) !== JSON.stringify(inventory))
|
|
791
|
-
throw new Error('Context or transcript changed during configuration: Markdown inventory ' + root);
|
|
792
|
-
for (const [file, hash] of Object.entries(context))
|
|
793
|
-
if (crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== hash)
|
|
794
|
-
throw new Error('Context or transcript changed during configuration: ' + file);
|
|
795
|
-
};
|
|
942
|
+
const contextPlan = {context, contextInventories: inventories, members: ${JSON.stringify(plan.members)},
|
|
943
|
+
roleContextFiles: ${JSON.stringify(plan.roleContextFiles)}, globalContextFiles: ${JSON.stringify(plan.globalContextFiles)}};
|
|
944
|
+
const verifyContext = members => (${verifyClaudePlanContext.toString()})(contextPlan, members, fs, crypto, path, snapshotMarkdown);
|
|
796
945
|
const result = await (${nativeClaudePackOperation.toString()})(manager,
|
|
797
946
|
value => transport.P(value, transport.M),
|
|
798
947
|
{ members: ${JSON.stringify(plan.members)}, outgoing: ${JSON.stringify(plan.outgoing)},
|
|
@@ -815,15 +964,61 @@ function nativePackExpression(pid, plan, journal, apply) {
|
|
|
815
964
|
row => checkpoint({kind: 'approval', ...row}),
|
|
816
965
|
fs.readFileSync(expected.journal, 'utf8').split('\\n').filter(Boolean).map(line => JSON.parse(line)));
|
|
817
966
|
if (expected.apply) {
|
|
818
|
-
result.approvalRouting = await invoke({kind: 'install'});
|
|
819
967
|
const control = ${JSON.stringify(plan.control)};
|
|
820
968
|
if (!control) throw new Error('Owned native control setup is unavailable.');
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
969
|
+
const identity = fingerprint(routes);
|
|
970
|
+
const current = manager[Symbol.for('topolo.agent-pack.approval-control.v1')];
|
|
971
|
+
const listener = manager[Symbol.for('topolo.agent-pack.approvals.v1')];
|
|
972
|
+
const incremental = ${JSON.stringify(plan.mode === "configure" && !plan.recovery)};
|
|
973
|
+
const unchanged = listener?.identity === identity && listener.listener && !listener.auditFailed &&
|
|
974
|
+
current?.descriptor.pid === expected.pid && current.descriptor.identity === identity &&
|
|
975
|
+
current.descriptor.socket === control.socket && current.server?.listening === true && !current.failure;
|
|
976
|
+
if (!incremental) {
|
|
977
|
+
result.approvalRouting = await invoke({kind: 'install'});
|
|
978
|
+
const stat = fs.lstatSync(control.secretPath);
|
|
979
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 63)) throw new Error('Unowned native control secret.');
|
|
980
|
+
const secret = JSON.parse(fs.readFileSync(control.secretPath, 'utf8'));
|
|
981
|
+
result.approvalRouting.control = await (${nativeClaudeApprovalControl.toString()})(manager,
|
|
982
|
+
{socket: control.socket, pid: expected.pid, identity}, secret.token,
|
|
983
|
+
load('node:net'), fs, invoke, qualifyNative, checkpoint);
|
|
984
|
+
} else if (unchanged) {
|
|
985
|
+
// Re-attest the owned listener/control; do not replace matching hooks
|
|
986
|
+
// or interrupt an in-flight request merely to configure idle settings.
|
|
987
|
+
result.approvalRouting = {state: 'native-approval-listener-installed', roles: routes.length,
|
|
988
|
+
identity, control: current.descriptor, unchanged: true};
|
|
989
|
+
} else {
|
|
990
|
+
qualifyNative();
|
|
991
|
+
const deferred = [];
|
|
992
|
+
for (const route of routes) {
|
|
993
|
+
const descriptor = await manager.getSessionWhenLoaded(route.sessionId, 'topolo-agent-pack-approval', {warnOnMiss: false});
|
|
994
|
+
const session = await manager.getSession(route.sessionId);
|
|
995
|
+
if (!descriptor || descriptor.sessionId !== route.sessionId || descriptor.cwd !== route.cwd || descriptor.title !== route.title ||
|
|
996
|
+
descriptor.isArchived || descriptor.backend?.kind !== 'local' || descriptor.backend.remoteTarget || descriptor.worktreePath ||
|
|
997
|
+
descriptor.worktreeLazy || descriptor.spawnedFrom || !session || session.sessionId !== route.sessionId ||
|
|
998
|
+
session.cwd !== route.cwd || session.title !== route.title || session.isArchived)
|
|
999
|
+
throw new Error('Native approval route ownership changed.');
|
|
1000
|
+
if (session.pendingToolPermissions?.length || descriptor.pendingToolPermissions?.length)
|
|
1001
|
+
deferred.push({key: route.key, fields: ['intakeHooks'], reason: 'pending-permissions'});
|
|
1002
|
+
}
|
|
1003
|
+
if (current?.active || current?.failure || listener?.auditFailed ||
|
|
1004
|
+
(listener && listener.identity !== identity) ||
|
|
1005
|
+
(current && (current.descriptor.pid !== expected.pid || current.descriptor.identity !== identity)))
|
|
1006
|
+
for (const route of routes) deferred.push({key: route.key, fields: ['intakeHooks'], reason: 'existing-control-custody'});
|
|
1007
|
+
if (deferred.length) {
|
|
1008
|
+
result.routingDeferred = deferred;
|
|
1009
|
+
result.state = 'native-settings-partial';result.complete = false;
|
|
1010
|
+
checkpoint({kind: 'routing-deferred', deferred, at: Date.now()});
|
|
1011
|
+
} else {
|
|
1012
|
+
verifyContext(result.after.filter(row => !row.busy).map(row => contextPlan.members.find(m => m.sessionId === row.sessionId)));
|
|
1013
|
+
result.approvalRouting = await invoke({kind: 'install'});
|
|
1014
|
+
const stat = fs.lstatSync(control.secretPath);
|
|
1015
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 63)) throw new Error('Unowned native control secret.');
|
|
1016
|
+
const secret = JSON.parse(fs.readFileSync(control.secretPath, 'utf8'));
|
|
1017
|
+
result.approvalRouting.control = await (${nativeClaudeApprovalControl.toString()})(manager,
|
|
1018
|
+
{socket: control.socket, pid: expected.pid, identity}, secret.token,
|
|
1019
|
+
load('node:net'), fs, invoke, qualifyNative, checkpoint);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
827
1022
|
} else if (${JSON.stringify(plan.recovery === true && !plan.noEffects)}) {
|
|
828
1023
|
const current = manager[Symbol.for('topolo.agent-pack.approval-control.v1')];
|
|
829
1024
|
if (!current || current.descriptor.pid !== expected.pid || current.descriptor.identity !== fingerprint(routes))
|
|
@@ -920,13 +1115,8 @@ function syncDirectory(path) {
|
|
|
920
1115
|
closeSync(fd);
|
|
921
1116
|
}
|
|
922
1117
|
}
|
|
923
|
-
function verifyPlanContext(plan) {
|
|
924
|
-
|
|
925
|
-
for (const [root, inventory] of Object.entries(plan.contextInventories))
|
|
926
|
-
if (JSON.stringify(snapshotClaudeMarkdown(root, filesystem, cryptography, paths)) !== JSON.stringify(inventory))
|
|
927
|
-
throw new Error("Context or transcript changed during configuration: Markdown inventory " + root);
|
|
928
|
-
for (const [file, hash] of Object.entries(plan.context))
|
|
929
|
-
if (createHash("sha256").update(readFileSync(file)).digest("hex") !== hash) throw new Error("Context or transcript changed during configuration: " + file);
|
|
1118
|
+
function verifyPlanContext(plan, members) {
|
|
1119
|
+
verifyClaudePlanContext(plan, members, filesystem, cryptography, paths, snapshotClaudeMarkdown);
|
|
930
1120
|
}
|
|
931
1121
|
function verifyClaudeOrganizationPolicy(root, pack, consent, run = spawnSync, locate = existsSync) {
|
|
932
1122
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -976,18 +1166,35 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
|
|
|
976
1166
|
} else {
|
|
977
1167
|
plan = { ...prepareClaudeNativePlan(root, options.pack, options.policy, options.mode === "switch" ? "switch" : "configure"), operationId };
|
|
978
1168
|
if (options.apply && plan.approvalRoutes.length) {
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
const
|
|
985
|
-
if (
|
|
986
|
-
|
|
1169
|
+
const connectionPath = resolve(root, "claude/approval-connection.json");
|
|
1170
|
+
if (plan.mode === "configure" && existsSync(connectionPath)) {
|
|
1171
|
+
const entry = lstatSync(connectionPath);
|
|
1172
|
+
if (!entry.isFile() || entry.isSymbolicLink() || typeof process.getuid !== "function" || entry.uid !== process.getuid() || entry.mode & 63)
|
|
1173
|
+
throw new Error("Unowned native approval connection.");
|
|
1174
|
+
const connection = JSON.parse(readFileSync(connectionPath, "utf8"));
|
|
1175
|
+
if (connection.pid === options.pid && connection.pack === options.pack && JSON.stringify(connection.routes) === JSON.stringify(plan.approvalRoutes) && JSON.stringify(connection.installationConsent) === JSON.stringify(plan.installationConsent)) {
|
|
1176
|
+
if (typeof connection.secretPath !== "string" || typeof connection.socket !== "string" || !resolve(connection.secretPath).startsWith(realpathSync(resolve(root, "claude/private")) + sep) || realpathSync(connection.secretPath) !== resolve(connection.secretPath) || dirname(resolve(connection.socket)) !== resolve("/tmp", "topolo-claude-pack-" + createHash("sha256").update(root).digest("hex").slice(0, 16)))
|
|
1177
|
+
throw new Error("Native approval connection is outside owned control paths.");
|
|
1178
|
+
const secret = lstatSync(connection.secretPath);
|
|
1179
|
+
if (!secret.isFile() || secret.isSymbolicLink() || typeof process.getuid !== "function" || secret.uid !== process.getuid() || secret.mode & 63)
|
|
1180
|
+
throw new Error("Unowned native control secret.");
|
|
1181
|
+
plan.control = { socket: connection.socket, secretPath: connection.secretPath };
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (!plan.control) {
|
|
1185
|
+
const directory2 = resolve(root, "claude/private");
|
|
1186
|
+
mkdirSync(directory2, { recursive: true, mode: 448 });
|
|
1187
|
+
const socketDirectory = resolve("/tmp", "topolo-claude-pack-" + createHash("sha256").update(root).digest("hex").slice(0, 16));
|
|
1188
|
+
mkdirSync(socketDirectory, { recursive: true, mode: 448 });
|
|
1189
|
+
for (const path of [directory2, socketDirectory]) {
|
|
1190
|
+
const stat = lstatSync(path);
|
|
1191
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 63)
|
|
1192
|
+
throw new Error("Native control directory must be owned and private.");
|
|
1193
|
+
}
|
|
1194
|
+
plan.control = { socket: resolve(socketDirectory, operationId + ".sock"), secretPath: resolve(directory2, operationId + ".json") };
|
|
1195
|
+
durableRecord(plan.control.secretPath, { token: randomBytes(32).toString("hex") }, "wx");
|
|
1196
|
+
syncDirectory(directory2);
|
|
987
1197
|
}
|
|
988
|
-
plan.control = { socket: resolve(socketDirectory, operationId + ".sock"), secretPath: resolve(directory2, operationId + ".json") };
|
|
989
|
-
durableRecord(plan.control.secretPath, { token: randomBytes(32).toString("hex") }, "wx");
|
|
990
|
-
syncDirectory(directory2);
|
|
991
1198
|
}
|
|
992
1199
|
journal = resolve(directory, operationId + ".jsonl");
|
|
993
1200
|
const record = { kind: "plan", pack: options.pack, ...plan, apply: options.apply };
|
|
@@ -998,7 +1205,8 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
|
|
|
998
1205
|
syncDirectory(resolve(root, "claude"));
|
|
999
1206
|
}
|
|
1000
1207
|
try {
|
|
1001
|
-
|
|
1208
|
+
const incremental = !recovering && plan.mode === "configure";
|
|
1209
|
+
verifyPlanContext(plan, incremental ? [] : void 0);
|
|
1002
1210
|
const organizationPolicy = options.apply && plan.approvalRoutes.length ? qualify(root, options.pack, plan.installationConsent) : null;
|
|
1003
1211
|
if (options.apply) {
|
|
1004
1212
|
const dispatched = { ...marker, dispatchState: "may-have-effects" };
|
|
@@ -1009,7 +1217,18 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
|
|
|
1009
1217
|
marker = dispatched;
|
|
1010
1218
|
}
|
|
1011
1219
|
const result = await evaluate(options.endpoint, nativePackExpression(options.pid, plan, journal, options.apply));
|
|
1012
|
-
|
|
1220
|
+
if (incremental && result.contextVerifiedSessionIds !== void 0) {
|
|
1221
|
+
if (!Array.isArray(result.contextVerifiedSessionIds) || new Set(result.contextVerifiedSessionIds).size !== result.contextVerifiedSessionIds.length || result.contextVerifiedSessionIds.some((id) => !plan.members.some((m) => m.sessionId === id)))
|
|
1222
|
+
throw new Error("Native scoped context identities differ.");
|
|
1223
|
+
const observations = result.busyObservations ?? [];
|
|
1224
|
+
if (!Array.isArray(observations) || observations.some((row) => !plan.members.some((m) => m.key === row.key && m.sessionId === row.sessionId) || !Array.isArray(row.reasons) || !row.reasons.length || row.reasons.some((reason) => !["running", "starting", "losable", "pending-permissions"].includes(reason))))
|
|
1225
|
+
throw new Error("Native busy context attestation differs.");
|
|
1226
|
+
for (let i = 0; i < plan.members.length; i++)
|
|
1227
|
+
if (!result.contextVerifiedSessionIds.includes(plan.members[i].sessionId) && result.after?.[i]?.busy !== true && !observations.some((row) => row.sessionId === plan.members[i].sessionId))
|
|
1228
|
+
throw new Error("Native result omits idle context qualification.");
|
|
1229
|
+
}
|
|
1230
|
+
const contextMembers = incremental && Array.isArray(result.after) ? plan.members.filter((m, i) => result.after[i]?.busy !== true && (result.contextVerifiedSessionIds === void 0 || result.contextVerifiedSessionIds.includes(m.sessionId))) : void 0;
|
|
1231
|
+
verifyPlanContext(plan, contextMembers);
|
|
1013
1232
|
if (options.apply || recovering) {
|
|
1014
1233
|
if (plan.noEffects) {
|
|
1015
1234
|
if (result.state !== "native-reconciled-no-effects" || result.nativeEffectsReplayed !== false || result.after?.length !== plan.members.length || result.after.some((actual, i) => ["key", "sessionId", "cwd", "title"].some((key) => actual[key] !== plan.members[i][key])))
|
|
@@ -1023,6 +1242,46 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
|
|
|
1023
1242
|
syncDirectory(resolve(root, "claude"));
|
|
1024
1243
|
return { ...result, journal, contextPreserved: true };
|
|
1025
1244
|
}
|
|
1245
|
+
if (incremental && result.state === "native-settings-partial") {
|
|
1246
|
+
const settings = ["group", "pinned", "model", "effort", "permissionMode"];
|
|
1247
|
+
if (result.after?.length !== plan.members.length || !Array.isArray(result.deferred) || !result.deferred.length && !result.routingDeferred?.length) throw new Error("Native partial result has no qualified complete roster.");
|
|
1248
|
+
if (new Set(result.deferred.map((d) => d.sessionId)).size !== result.deferred.length || result.deferred.some((d) => !plan.members.some((m) => m.key === d.key && m.sessionId === d.sessionId) || !Array.isArray(d.fields) || !d.fields.length || new Set(d.fields).size !== d.fields.length || d.fields.some((field) => !settings.includes(field)) || !Array.isArray(d.reasons) || !d.reasons.length || d.reasons.some((reason) => !["running", "starting", "losable", "pending-permissions"].includes(reason))))
|
|
1249
|
+
throw new Error("Native deferred identity or fields differ.");
|
|
1250
|
+
if (result.routingDeferred !== void 0 && (!Array.isArray(result.routingDeferred) || result.routingDeferred.some((d) => !plan.approvalRoutes.some((r) => r.key === d.key) || JSON.stringify(d.fields) !== JSON.stringify(["intakeHooks"]) || !["pending-permissions", "existing-control-custody"].includes(d.reason))))
|
|
1251
|
+
throw new Error("Native deferred routing ownership differs.");
|
|
1252
|
+
for (let i = 0; i < plan.members.length; i++) {
|
|
1253
|
+
const wanted = plan.members[i], actual = result.after[i];
|
|
1254
|
+
if (actual.archived !== false || ["key", "sessionId", "cwd", "title"].some((key) => actual[key] !== wanted[key]))
|
|
1255
|
+
throw new Error("Native partial roster identity differs.");
|
|
1256
|
+
const mismatch = settings.filter((key) => actual[key] !== wanted[key]);
|
|
1257
|
+
const deferred = result.deferred.find((d) => d.sessionId === wanted.sessionId);
|
|
1258
|
+
if (JSON.stringify(mismatch.slice().sort()) !== JSON.stringify((deferred?.fields ?? []).slice().sort()))
|
|
1259
|
+
throw new Error("Native partial result hides an undeferred mismatch.");
|
|
1260
|
+
}
|
|
1261
|
+
const activePath2 = resolve(root, "claude/active.json");
|
|
1262
|
+
const active2 = existsSync(activePath2) ? JSON.parse(readFileSync(activePath2, "utf8")) : null;
|
|
1263
|
+
if (JSON.stringify(active2) !== JSON.stringify(plan.activeBefore)) throw new Error("Active roster changed during partial configuration.");
|
|
1264
|
+
durableRecord(journal, {
|
|
1265
|
+
kind: "partial-verified",
|
|
1266
|
+
after: result.after,
|
|
1267
|
+
deferred: result.deferred,
|
|
1268
|
+
routingDeferred: result.routingDeferred ?? [],
|
|
1269
|
+
appliedFields: result.appliedFields ?? [],
|
|
1270
|
+
activeUnchanged: true
|
|
1271
|
+
});
|
|
1272
|
+
if (JSON.stringify(JSON.parse(readFileSync(markerPath, "utf8"))) !== JSON.stringify(marker)) throw new Error("Recovery marker identity changed.");
|
|
1273
|
+
unlinkSync(markerPath);
|
|
1274
|
+
syncDirectory(resolve(root, "claude"));
|
|
1275
|
+
return {
|
|
1276
|
+
...result,
|
|
1277
|
+
complete: false,
|
|
1278
|
+
journal,
|
|
1279
|
+
contextPreserved: contextMembers?.length === plan.members.length,
|
|
1280
|
+
idleContextsVerified: contextMembers?.map((m) => m.key),
|
|
1281
|
+
activeRosterUnchanged: true,
|
|
1282
|
+
resume: "explicit-configure-only"
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1026
1285
|
if (result.state !== (recovering ? "native-reconciled" : "native-settings-verified") || result.after?.length !== plan.members.length) throw new Error("Native result has no complete verified roster.");
|
|
1027
1286
|
for (let i = 0; i < plan.members.length; i++) {
|
|
1028
1287
|
const wanted = plan.members[i];
|
|
@@ -1066,7 +1325,12 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
|
|
|
1066
1325
|
unlinkSync(markerPath);
|
|
1067
1326
|
syncDirectory(resolve(root, "claude"));
|
|
1068
1327
|
} else durableRecord(journal, { kind: "context-verified", context: plan.context });
|
|
1069
|
-
return {
|
|
1328
|
+
return {
|
|
1329
|
+
...result,
|
|
1330
|
+
journal,
|
|
1331
|
+
contextPreserved: !incremental || contextMembers?.length === plan.members.length,
|
|
1332
|
+
...incremental ? { idleContextsVerified: contextMembers?.map((m) => m.key) } : {}
|
|
1333
|
+
};
|
|
1070
1334
|
} catch (error) {
|
|
1071
1335
|
durableRecord(journal, { kind: "recovery-required" });
|
|
1072
1336
|
throw new Error(`Claude configuration is incomplete. Reconcile ${journal}. ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1203,5 +1467,6 @@ export {
|
|
|
1203
1467
|
runClaudeSmokeHelper,
|
|
1204
1468
|
snapshotClaudeMarkdown,
|
|
1205
1469
|
validateInspectorEndpoint,
|
|
1206
|
-
verifyClaudeOrganizationPolicy
|
|
1470
|
+
verifyClaudeOrganizationPolicy,
|
|
1471
|
+
verifyClaudePlanContext
|
|
1207
1472
|
};
|