@@ -32,6 +32,12 @@ import { detectAll } from "../detect/index.ts";
3232import type { HarnessId } from "../detect/types.ts" ;
3333import { multiselect } from "../tui/multiselect.tsx" ;
3434import { apiClient , type InstallFileOp } from "../util/api.ts" ;
35+ import {
36+ checkSafeTarget ,
37+ executeFileOps ,
38+ executeUninstallOps ,
39+ readExistingFiles ,
40+ } from "../util/install-fs.ts" ;
3541import { home } from "../util/paths.ts" ;
3642import { mintAttestedSession , type AttestedTier } from "../util/session-mint.ts" ;
3743
@@ -262,35 +268,16 @@ export async function runInstall(argv: string[] = []): Promise<void> {
262268 const daemonUrl = flags . daemonUrl ?? api . baseUrl ;
263269
264270 // 3.5. Capabilities probe -------------------------------------------
265- // Read-only check against the daemon so we fail fast — before fetching
266- // the plan, before showing y/N — when apply is disabled or when the
267- // daemon's view of the host filesystem won't match the user's. Older
268- // daemons (pre-0.1.10) don't expose this endpoint; treat 404/network
269- // errors as "unknown" and fall through to the existing post-action
270- // error handling.
271+ // The daemon no longer writes host files (the CLI does), so there's
272+ // no apply / real-home gate to check. We still call the endpoint so
273+ // unattested-disabled daemons surface early — but the check above
274+ // (createUnattestedSession) already covers that case for tier=unattested.
275+ // Older daemons (pre-0.1.10) don't expose the endpoint; ignore.
271276 try {
272- const caps = await api . installCapabilities ( ) ;
273- if ( ! caps . apply_enabled ) {
274- process . stderr . write (
275- "\ninstall apply is disabled on this daemon.\n" +
276- "restart the daemon with -e AGENTLOCK_ALLOW_APPLY=1 added.\n" +
277- "see https://openagentlock.github.io/OpenAgentLock/guide/daemon-flags/\n" ,
278- ) ;
279- process . exitCode = 2 ;
280- return ;
281- }
282- if ( caps . container && ! flags . configDirOverride ) {
283- process . stdout . write (
284- "\nwarning: daemon is running in a container.\n" +
285- " install will reference your host paths (e.g. ~/.claude). For\n" +
286- " writes to actually land on the host, the daemon must have\n" +
287- " same-path bind mounts (e.g. -v $HOME/.claude:$HOME/.claude).\n" +
288- " see https://openagentlock.github.io/OpenAgentLock/guide/installation/\n" ,
289- ) ;
290- }
277+ await api . installCapabilities ( ) ;
291278 } catch {
292279 // Probe failed — older daemon or transient. Continue; downstream
293- // calls will surface specific errors with the same hints .
280+ // calls will surface specific errors.
294281 }
295282
296283 // 3a. Per-harness uninstall for rows the user just deselected. Runs
@@ -300,12 +287,26 @@ export async function runInstall(argv: string[] = []): Promise<void> {
300287 process . stdout . write (
301288 `\nuninstalling deselected harnesses: ${ toUninstall . join ( ", " ) } \n` ,
302289 ) ;
290+ // Pass the current contents of every per-harness file so the daemon
291+ // can compute the post-strip bytes without reading host paths.
292+ const uninstallPaths : string [ ] = [ ] ;
293+ for ( const id of toUninstall ) {
294+ const dir = hostConfigDirs [ id ] ;
295+ if ( ! dir ) continue ;
296+ if ( id === "claude-code" ) {
297+ uninstallPaths . push ( resolve ( join ( dir , "settings.json" ) ) ) ;
298+ } else if ( id === "codex" || id === "cursor" ) {
299+ uninstallPaths . push ( resolve ( join ( dir , "hooks.json" ) ) ) ;
300+ }
301+ }
302+ const uninstallExisting = await readExistingFiles ( uninstallPaths ) ;
303303 try {
304304 const u = await api . installUninstallHarnesses ( {
305305 session_id : sessionId ,
306306 harnesses : toUninstall ,
307307 config_dir_override : flags . configDirOverride ,
308308 harness_config_dirs : hostConfigDirs ,
309+ existing_files : uninstallExisting ,
309310 } ) ;
310311 for ( const op of u . operations ) {
311312 const note = op . error ? ` ERROR: ${ op . error } ` : "" ;
@@ -317,18 +318,13 @@ export async function runInstall(argv: string[] = []): Promise<void> {
317318 process . stderr . write (
318319 `\n${ u . failures } uninstall op(s) failed; see above. Continuing with install.\n` ,
319320 ) ;
321+ } else {
322+ // Execute the strip / remove ops on the host now that the daemon
323+ // has signed the diff into the ledger.
324+ await executeUninstallOps ( u . operations ) ;
320325 }
321326 } catch ( err ) {
322327 const msg = ( err as Error ) . message ;
323- if ( msg . includes ( "apply_disabled" ) ) {
324- process . stderr . write (
325- "\nuninstall is disabled on this daemon.\n" +
326- "restart the daemon with -e AGENTLOCK_ALLOW_APPLY=1 added.\n" +
327- "see https://openagentlock.github.io/OpenAgentLock/guide/daemon-flags/\n" ,
328- ) ;
329- process . exitCode = 2 ;
330- return ;
331- }
332328 process . stderr . write ( `\nuninstall failed: ${ msg } \n` ) ;
333329 // Continue: a failed uninstall shouldn't block re-installing the
334330 // ones the user kept selected.
@@ -340,6 +336,22 @@ export async function runInstall(argv: string[] = []): Promise<void> {
340336 return ;
341337 }
342338
339+ // Read every host file the daemon needs to merge against, so the plan
340+ // ops carry the final byte-for-byte content the CLI will write. Missing
341+ // files are silently skipped (readExistingFiles drops ENOENT).
342+ const claudeSettings = resolve (
343+ join ( hostConfigDirs [ "claude-code" ] , "settings.json" ) ,
344+ ) ;
345+ const codexHooks = resolve ( join ( hostConfigDirs [ "codex" ] , "hooks.json" ) ) ;
346+ const codexConfig = resolve ( join ( hostConfigDirs [ "codex" ] , "config.toml" ) ) ;
347+ const cursorHooks = resolve ( join ( hostConfigDirs [ "cursor" ] , "hooks.json" ) ) ;
348+ const existingFiles = await readExistingFiles ( [
349+ claudeSettings ,
350+ codexHooks ,
351+ codexConfig ,
352+ cursorHooks ,
353+ ] ) ;
354+
343355 const planReq = {
344356 session_id : sessionId ,
345357 harnesses : chosen ,
@@ -351,6 +363,7 @@ export async function runInstall(argv: string[] = []): Promise<void> {
351363 // override (e.g. point at the compiled single-file binary).
352364 agentlock_binary : process . env . AGENTLOCK_BINARY ?? defaultAgentlockBinary ( ) ,
353365 harness_config_dirs : hostConfigDirs ,
366+ existing_files : existingFiles ,
354367 } ;
355368
356369 // 4. Plan dry-run ------------------------------------------------------
@@ -404,7 +417,36 @@ export async function runInstall(argv: string[] = []): Promise<void> {
404417 }
405418 }
406419
420+ // 5.5. Safety check + execute on the host ---------------------------
421+ // The plan was returned by the daemon but it never touched disk. We
422+ // refuse paths that don't resolve under one of the real harness home
423+ // subtrees unless the caller explicitly opted into a dev sandbox via
424+ // --config-dir or AGENTLOCK_DEV_HOME.
425+ const bypass =
426+ ! ! flags . configDirOverride || ! ! process . env . AGENTLOCK_DEV_HOME ;
427+ try {
428+ for ( const op of plan . operations ) {
429+ checkSafeTarget ( op . path , { bypass } ) ;
430+ }
431+ } catch ( err ) {
432+ process . stderr . write ( `\n${ ( err as Error ) . message } \n` ) ;
433+ process . stderr . write (
434+ "use --config-dir ./dev/.claude (or ./dev/.codex, ./dev/.cursor) for dev runs.\n" ,
435+ ) ;
436+ process . exitCode = 2 ;
437+ return ;
438+ }
439+ try {
440+ await executeFileOps ( plan . operations ) ;
441+ } catch ( err ) {
442+ process . stderr . write ( `\nfile write failed: ${ ( err as Error ) . message } \n` ) ;
443+ process . exitCode = 2 ;
444+ return ;
445+ }
446+
407447 // 6. Apply -------------------------------------------------------------
448+ // Files are already on disk; this call records the manifest + signs
449+ // the install into the ledger.
408450 process . stdout . write ( "\napplying...\n" ) ;
409451 try {
410452 const result = await api . installApply ( planReq ) ;
@@ -416,21 +458,7 @@ export async function runInstall(argv: string[] = []): Promise<void> {
416458 }
417459 } catch ( err ) {
418460 const msg = ( err as Error ) . message ;
419- if ( msg . includes ( "apply_disabled" ) ) {
420- process . stderr . write (
421- "\ninstall apply is disabled on this daemon.\n" +
422- "restart the daemon with -e AGENTLOCK_ALLOW_APPLY=1 added.\n" +
423- "see https://openagentlock.github.io/OpenAgentLock/guide/daemon-flags/\n" ,
424- ) ;
425- } else if ( msg . includes ( "unsafe_target" ) ) {
426- process . stderr . write (
427- "\ndaemon refused to write to a path under real ~/.claude, ~/.codex, or ~/.cursor.\n" +
428- "use --config-dir ./dev/.claude (or ./dev/.codex, ./dev/.cursor) for dev runs, or set\n" +
429- "AGENTLOCK_ALLOW_APPLY_REAL_HOME=1 on the daemon for real installs.\n" ,
430- ) ;
431- } else {
432- process . stderr . write ( `\napply failed: ${ msg } \n` ) ;
433- }
461+ process . stderr . write ( `\napply failed: ${ msg } \n` ) ;
434462 process . exitCode = 2 ;
435463 return ;
436464 }
0 commit comments