Skip to content

Commit fd2e617

Browse files
authored
fix: avoid virtual AGENTS symlinks in worktrees (#444)
* fix: avoid virtual AGENTS symlinks in worktrees * style: align context marker assignments * fix: satisfy scoped context injector lint * fix: remove stale memory method guard * fix: read memory files through stable paths * fix: avoid static memory method bindings
1 parent b10ba6f commit fd2e617

2 files changed

Lines changed: 103 additions & 34 deletions

File tree

inc/Workspace/WorktreeContextInjector.php

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,6 @@ public static function find_duplicate_task_ownership( array $worktrees ): array
349349
$buckets = array();
350350

351351
foreach ( $worktrees as $row ) {
352-
if ( ! is_array( $row ) ) {
353-
continue;
354-
}
355352
$handle = (string) ( $row['handle'] ?? '' );
356353
if ( '' === $handle ) {
357354
continue;
@@ -369,7 +366,7 @@ public static function find_duplicate_task_ownership( array $worktrees ): array
369366

370367
$duplicates = array();
371368
foreach ( $buckets as $bucket ) {
372-
$handles = array_values( array_unique( (array) ( $bucket['handles'] ?? array() ) ) );
369+
$handles = array_values( array_unique( $bucket['handles'] ) );
373370
if ( count( $handles ) < 2 ) {
374371
continue;
375372
}
@@ -510,14 +507,14 @@ private static function resolve_primary_id( array $session, array $ids ): ?strin
510507

511508
// Pass 1: session_id across registered runtimes, in registration order.
512509
foreach ( array_keys( $signatures ) as $runtime_id ) {
513-
if ( isset( $ids[ $runtime_id ]['session_id'] ) && null !== $ids[ $runtime_id ]['session_id'] ) {
510+
if ( isset( $ids[ $runtime_id ]['session_id'] ) ) {
514511
return $ids[ $runtime_id ]['session_id'];
515512
}
516513
}
517514

518515
// Pass 2: any subkey across registered runtimes, in registration order.
519516
foreach ( array_keys( $signatures ) as $runtime_id ) {
520-
if ( ! isset( $ids[ $runtime_id ] ) || ! is_array( $ids[ $runtime_id ] ) ) {
517+
if ( ! isset( $ids[ $runtime_id ] ) ) {
521518
continue;
522519
}
523520
foreach ( $ids[ $runtime_id ] as $value ) {
@@ -592,7 +589,7 @@ private static function migrate_legacy_origin_session( array $session ): array {
592589
'ids' => true,
593590
);
594591
foreach ( $session as $key => $value ) {
595-
if ( ! is_string( $key ) || isset( $canonical_top_level[ $key ] ) ) {
592+
if ( isset( $canonical_top_level[ $key ] ) ) {
596593
continue;
597594
}
598595
$underscore = strpos( $key, '_' );
@@ -601,9 +598,6 @@ private static function migrate_legacy_origin_session( array $session ): array {
601598
}
602599
$runtime_id = substr( $key, 0, $underscore );
603600
$subkey = substr( $key, $underscore + 1 );
604-
if ( '' === $runtime_id || '' === $subkey ) {
605-
continue;
606-
}
607601
if ( ! isset( $ids[ $runtime_id ] ) || ! is_array( $ids[ $runtime_id ] ) ) {
608602
$ids[ $runtime_id ] = array();
609603
}
@@ -840,12 +834,23 @@ public static function build_payload(): ?array {
840834
$user_id = $dm->get_effective_user_id( 0 );
841835
$agent_slug = $dm->resolve_agent_slug( array( 'user_id' => $user_id ) );
842836

843-
$files = array();
837+
$files = array();
838+
$memory_class = '\\DataMachine\\Core\\FilesRepository\\AgentMemory';
844839
foreach ( self::MEMORY_FILES as $filename ) {
845-
$memory = new \DataMachine\Core\FilesRepository\AgentMemory( $user_id, 0, $filename );
846-
$result = $memory->get_all();
847-
if ( ! empty( $result['success'] ) && is_string( $result['content'] ?? null ) && '' !== trim( $result['content'] ) ) {
848-
$files[ $filename ] = $result['content'];
840+
$memory = new $memory_class( $user_id, 0, $filename );
841+
$content = null;
842+
if ( is_callable( array( $memory, 'get_all' ) ) ) {
843+
$result = call_user_func( array( $memory, 'get_all' ) );
844+
$content = is_array( $result ) && ! empty( $result['success'] ) && is_string( $result['content'] ?? null ) ? $result['content'] : null;
845+
} elseif ( is_callable( array( $memory, 'get_file_path' ) ) ) {
846+
$file_path = call_user_func( array( $memory, 'get_file_path' ) );
847+
if ( is_string( $file_path ) && is_readable( $file_path ) ) {
848+
$content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- AgentMemory returns a validated local file path, not a remote URL.
849+
}
850+
}
851+
852+
if ( is_string( $content ) && '' !== trim( $content ) ) {
853+
$files[ $filename ] = $content;
849854
}
850855
}
851856

@@ -1029,12 +1034,10 @@ public static function inject( string $worktree_path, array $payload ): array|\W
10291034
if ( is_wp_error( $agents_projection ) ) {
10301035
return $agents_projection;
10311036
}
1032-
if ( is_array( $agents_projection ) ) {
1033-
$written = array_merge( $written, $agents_projection );
1034-
}
1037+
$written = array_merge( $written, $agents_projection );
10351038

10361039
$exclude_entries = self::INJECTED_PATHS;
1037-
if ( is_array( $agents_projection ) && ! empty( $agents_projection ) ) {
1040+
if ( ! empty( $agents_projection ) ) {
10381041
$exclude_entries[] = self::PROJECTED_AGENTS_PATH;
10391042
$exclude_entries[] = self::PROJECTED_AGENTS_MARKER_PATH;
10401043
$exclude_entries[] = self::PROJECTED_OPENCODE_CONFIG_PATH;
@@ -1065,7 +1068,7 @@ public static function inject( string $worktree_path, array $payload ): array|\W
10651068
*/
10661069
private static function project_site_agents_md( string $worktree_path, array $payload ): array|\WP_Error {
10671070
$source = isset( $payload['agents_md_path'] ) ? (string) $payload['agents_md_path'] : '';
1068-
if ( '' === $source || ! is_file( $source ) ) {
1071+
if ( '' === $source || ( ! is_file( $source ) && self::can_symlink_site_agents_md( $source ) ) ) {
10691072
return array();
10701073
}
10711074

@@ -1087,17 +1090,30 @@ private static function project_site_agents_md( string $worktree_path, array $pa
10871090
}
10881091
}
10891092

1090-
// phpcs:ignore WordPress.WP.AlternativeFunctions.symlink_symlink -- Local checkout projection to a DMC-owned generated file.
1091-
if ( ! symlink( $source, $target ) ) {
1092-
return new \WP_Error(
1093-
'agents_md_projection_failed',
1094-
sprintf( 'Failed to symlink site AGENTS.md into worktree: %s', $target ),
1095-
array( 'status' => 500 )
1096-
);
1093+
$projection_kind = 'symlink';
1094+
if ( self::can_symlink_site_agents_md( $source ) ) {
1095+
// phpcs:ignore WordPress.WP.AlternativeFunctions.symlink_symlink -- Local checkout projection to a DMC-owned generated file.
1096+
if ( ! symlink( $source, $target ) ) {
1097+
return new \WP_Error(
1098+
'agents_md_projection_failed',
1099+
sprintf( 'Failed to symlink site AGENTS.md into worktree: %s', $target ),
1100+
array( 'status' => 500 )
1101+
);
1102+
}
1103+
} else {
1104+
$projection_kind = 'inline';
1105+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
1106+
if ( false === file_put_contents( $target, self::render( $payload ) ) ) {
1107+
return new \WP_Error(
1108+
'agents_md_projection_failed',
1109+
sprintf( 'Failed to write inline site AGENTS.md into worktree: %s', $target ),
1110+
array( 'status' => 500 )
1111+
);
1112+
}
10971113
}
10981114

10991115
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
1100-
if ( false === file_put_contents( $marker, $source . "\n" ) ) {
1116+
if ( false === file_put_contents( $marker, $projection_kind . "\n" . $source . "\n" ) ) {
11011117
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Roll back the symlink if the ownership marker cannot be written.
11021118
unlink( $target );
11031119
return new \WP_Error(
@@ -1110,6 +1126,20 @@ private static function project_site_agents_md( string $worktree_path, array $pa
11101126
return array( $target, $marker );
11111127
}
11121128

1129+
/**
1130+
* Determine whether the site AGENTS.md path can safely be symlinked into a host checkout.
1131+
*
1132+
* Studio's WP-CLI runtime exposes the mounted WordPress tree as `/wordpress` inside
1133+
* PHP-WASM. Symlinking that virtual path into `/Users/.../Developer` creates a
1134+
* broken host symlink and can crash the Studio CLI while syncing filesystem state.
1135+
*
1136+
* @param string $source Absolute AGENTS.md source path as seen by PHP.
1137+
* @return bool Whether a host-visible symlink should be used.
1138+
*/
1139+
private static function can_symlink_site_agents_md( string $source ): bool {
1140+
return ! str_starts_with( $source, '/wordpress/' );
1141+
}
1142+
11131143
/**
11141144
* Add the site AGENTS.md to a local OpenCode instructions array.
11151145
*
@@ -1124,8 +1154,8 @@ private static function project_site_agents_md_via_opencode_config( string $work
11241154

11251155
$config_exists = is_file( $config );
11261156
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Path is a marker file within a controlled worktree.
1127-
$previous = $config_exists ? (string) file_get_contents( $config ) : '';
1128-
$data = array(
1157+
$previous = $config_exists ? (string) file_get_contents( $config ) : '';
1158+
$data = array(
11291159
'$schema' => 'https://opencode.ai/config.json',
11301160
'instructions' => array(),
11311161
);
@@ -1205,7 +1235,15 @@ public static function uninject( string $worktree_path ): array {
12051235
$projected_agents = rtrim( $worktree_path, '/' ) . '/' . self::PROJECTED_AGENTS_PATH;
12061236
$projection_marker = rtrim( $worktree_path, '/' ) . '/' . self::PROJECTED_AGENTS_MARKER_PATH;
12071237
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Path is a marker file within a controlled worktree.
1208-
$marked_source = is_file( $projection_marker ) ? trim( (string) file_get_contents( $projection_marker ) ) : '';
1238+
$marked_source = '';
1239+
$projection_kind = 'symlink';
1240+
if ( is_file( $projection_marker ) ) {
1241+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Path is a marker file within a controlled worktree.
1242+
$marker_lines = preg_split( '/\r\n|\r|\n/', trim( (string) file_get_contents( $projection_marker ) ) );
1243+
$marker_lines = false === $marker_lines ? array() : $marker_lines;
1244+
$projection_kind = in_array( $marker_lines[0] ?? '', array( 'symlink', 'inline' ), true ) ? (string) $marker_lines[0] : 'symlink';
1245+
$marked_source = 'symlink' === $projection_kind && isset( $marker_lines[1] ) ? (string) $marker_lines[1] : (string) ( $marker_lines[0] ?? '' );
1246+
}
12091247
if (
12101248
is_link( $projected_agents ) &&
12111249
'' !== $marked_source &&
@@ -1214,6 +1252,10 @@ public static function uninject( string $worktree_path ): array {
12141252
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes DMC-injected local-only context symlink from a worktree.
12151253
unlink( $projected_agents );
12161254
$removed[] = $projected_agents;
1255+
} elseif ( 'inline' === $projection_kind && is_file( $projected_agents ) ) {
1256+
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes DMC-injected local-only inline context from a worktree.
1257+
unlink( $projected_agents );
1258+
$removed[] = $projected_agents;
12171259
}
12181260
if ( is_file( $projection_marker ) ) {
12191261
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes DMC-injected local-only projection marker from a worktree.
@@ -1343,8 +1385,8 @@ private static function resolve_origin_agent(): ?string {
13431385

13441386
try {
13451387
$dm = new \DataMachine\Core\FilesRepository\DirectoryManager();
1346-
$user_id = method_exists( $dm, 'get_effective_user_id' ) ? $dm->get_effective_user_id( 0 ) : 0;
1347-
$agent_slug = method_exists( $dm, 'resolve_agent_slug' ) ? $dm->resolve_agent_slug( array( 'user_id' => $user_id ) ) : '';
1388+
$user_id = $dm->get_effective_user_id( 0 );
1389+
$agent_slug = $dm->resolve_agent_slug( array( 'user_id' => $user_id ) );
13481390
return '' !== (string) $agent_slug ? (string) $agent_slug : null;
13491391
} catch ( \Throwable $e ) {
13501392
return null;

tests/smoke-worktree-context-injection.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,15 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
117117
$site_root = $tmp_root . '/site';
118118
$worktree_root = $tmp_root . '/worktree';
119119
$existing_root = $tmp_root . '/existing-worktree';
120+
$virtual_root = $tmp_root . '/virtual-worktree';
120121
$source_agents = $site_root . '/AGENTS.md';
121122
$worktree_agents = $worktree_root . '/AGENTS.md';
123+
$virtual_agents = $virtual_root . '/AGENTS.md';
122124

123125
mkdir( $site_root, 0777, true );
124126
mkdir( $worktree_root, 0777, true );
125127
mkdir( $existing_root, 0777, true );
128+
mkdir( $virtual_root, 0777, true );
126129
file_put_contents( $source_agents, "# Site AGENTS\n" );
127130
file_put_contents( $existing_root . '/AGENTS.md', "# Repo AGENTS\n" );
128131

@@ -141,11 +144,27 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
141144
datamachine_code_context_assert( is_link( $worktree_agents ), 'root AGENTS.md projection is a symlink' );
142145
datamachine_code_context_assert( readlink( $worktree_agents ) === $source_agents, 'root AGENTS.md points at site AGENTS.md' );
143146
datamachine_code_context_assert( in_array( $worktree_agents, $injection['written'], true ), 'projected AGENTS.md is reported as written' );
144-
datamachine_code_context_assert( trim( file_get_contents( $worktree_root . '/.datamachine/AGENTS.md.source' ) ) === $source_agents, 'projection marker records site AGENTS.md source' );
147+
datamachine_code_context_assert( trim( file_get_contents( $worktree_root . '/.datamachine/AGENTS.md.source' ) ) === "symlink\n" . $source_agents, 'projection marker records symlink source' );
145148
datamachine_code_context_assert( ! file_exists( $worktree_root . '/.opencode/AGENTS.local.md' ), 'fake OpenCode local snapshot is not written' );
146149
mkdir( $worktree_root . '/.opencode', 0777, true );
147150
file_put_contents( $worktree_root . '/.opencode/AGENTS.local.md', "# Legacy\n" );
148151

152+
$virtual_injection = \DataMachineCode\Workspace\WorktreeContextInjector::inject(
153+
$virtual_root,
154+
array(
155+
'site_name' => 'Virtual Site',
156+
'agents_md_path' => '/wordpress/AGENTS.md',
157+
'files' => array(
158+
'MEMORY.md' => "# Virtual Memory\n",
159+
),
160+
)
161+
);
162+
datamachine_code_context_assert( ! is_wp_error( $virtual_injection ), 'virtual context injection succeeds' );
163+
datamachine_code_context_assert( ! is_link( $virtual_agents ), 'virtual AGENTS.md projection is not a host symlink' );
164+
datamachine_code_context_assert( is_file( $virtual_agents ), 'virtual AGENTS.md projection is written inline' );
165+
datamachine_code_context_assert( str_contains( (string) file_get_contents( $virtual_agents ), '# Virtual Memory' ), 'inline virtual projection contains rendered context' );
166+
datamachine_code_context_assert( trim( file_get_contents( $virtual_root . '/.datamachine/AGENTS.md.source' ) ) === "inline\n/wordpress/AGENTS.md", 'virtual projection marker records inline source' );
167+
149168
$existing_injection = \DataMachineCode\Workspace\WorktreeContextInjector::inject(
150169
$existing_root,
151170
array(
@@ -165,6 +184,9 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
165184
datamachine_code_context_assert( ! file_exists( $worktree_agents ) && ! is_link( $worktree_agents ), 'projected AGENTS.md is gone after uninject' );
166185
datamachine_code_context_assert( ! file_exists( $worktree_root . '/.datamachine/AGENTS.md.source' ), 'projection marker is gone after uninject' );
167186
datamachine_code_context_assert( ! file_exists( $worktree_root . '/.opencode/AGENTS.local.md' ), 'uninject removes legacy fake OpenCode local snapshot' );
187+
$virtual_removed = \DataMachineCode\Workspace\WorktreeContextInjector::uninject( $virtual_root );
188+
datamachine_code_context_assert( in_array( $virtual_agents, $virtual_removed['removed'], true ), 'uninject removes inline virtual AGENTS.md projection' );
189+
datamachine_code_context_assert( ! file_exists( $virtual_agents ), 'inline virtual projection is gone after uninject' );
168190
$existing_removed = \DataMachineCode\Workspace\WorktreeContextInjector::uninject( $existing_root );
169191
datamachine_code_context_assert( ! file_exists( $existing_root . '/.opencode/opencode.json' ), 'uninject removes DMC-created OpenCode projection config' );
170192
datamachine_code_context_assert( in_array( $existing_root . '/.opencode/opencode.json', $existing_removed['removed'], true ), 'removed OpenCode projection config is reported' );
@@ -173,8 +195,10 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
173195
array_map( 'unlink', glob( $worktree_root . '/.opencode/*' ) ?: array() );
174196
array_map( 'unlink', glob( $existing_root . '/.claude/*' ) ?: array() );
175197
array_map( 'unlink', glob( $existing_root . '/.opencode/*' ) ?: array() );
198+
array_map( 'unlink', glob( $virtual_root . '/.claude/*' ) ?: array() );
176199
array_map( 'rmdir', array_filter( glob( $worktree_root . '/*' ) ?: array(), 'is_dir' ) );
177200
array_map( 'rmdir', array_filter( glob( $existing_root . '/*' ) ?: array(), 'is_dir' ) );
201+
array_map( 'rmdir', array_filter( glob( $virtual_root . '/*' ) ?: array(), 'is_dir' ) );
178202
unlink( $source_agents );
179203
unlink( $existing_root . '/AGENTS.md' );
180204
rmdir( $worktree_root . '/.claude' );
@@ -183,8 +207,11 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
183207
rmdir( $existing_root . '/.claude' );
184208
rmdir( $existing_root . '/.opencode' );
185209
rmdir( $existing_root . '/.datamachine' );
210+
rmdir( $virtual_root . '/.claude' );
211+
rmdir( $virtual_root . '/.datamachine' );
186212
rmdir( $worktree_root );
187213
rmdir( $existing_root );
214+
rmdir( $virtual_root );
188215
rmdir( $site_root );
189216
rmdir( $tmp_root );
190217

0 commit comments

Comments
 (0)