@gajae-code/coding-agent 0.18.11 → 0.18.12
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/CHANGELOG.md +293 -0
- package/dist/types/async/job-manager.d.ts +7 -3
- package/dist/types/async/result-formatting.d.ts +2 -0
- package/dist/types/autoresearch/git.d.ts +1 -1
- package/dist/types/capability/fs.d.ts +2 -2
- package/dist/types/cli/accounts-cli.d.ts +2 -2
- package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
- package/dist/types/cli/install-activation.d.ts +10 -4
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/update-cli.d.ts +14 -1
- package/dist/types/cli-ordinary.d.ts +2 -2
- package/dist/types/commands/accounts.d.ts +2 -1
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +5 -0
- package/dist/types/commit/changelog/index.d.ts +1 -0
- package/dist/types/config/autorouting-tier-map.d.ts +30 -0
- package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
- package/dist/types/config/config-hot-reload.d.ts +39 -0
- package/dist/types/config/file-lock.d.ts +8 -1
- package/dist/types/config/model-discovery-manager.d.ts +2 -0
- package/dist/types/config/model-profile-activation.d.ts +62 -6
- package/dist/types/config/model-registry.d.ts +56 -5
- package/dist/types/config/model-resolver.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/settings-schema.d.ts +46 -3
- package/dist/types/config/settings.d.ts +33 -1
- package/dist/types/dap/config.d.ts +8 -4
- package/dist/types/debug/system-info.d.ts +6 -1
- package/dist/types/discovery/mcp-compat.d.ts +3 -1
- package/dist/types/edit/modes/patch.d.ts +5 -0
- package/dist/types/edit/modes/replace.d.ts +3 -0
- package/dist/types/exec/bash-executor.d.ts +12 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
- package/dist/types/migrate/adapters/codex.d.ts +2 -1
- package/dist/types/migrate/adapters/index.d.ts +10 -1
- package/dist/types/migrate/executor.d.ts +3 -1
- package/dist/types/migrate/types.d.ts +15 -3
- package/dist/types/modes/components/settings-selector.d.ts +6 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
- package/dist/types/runtime-mcp/manager.d.ts +7 -0
- package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
- package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
- package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
- package/dist/types/sdk/broker/broker.d.ts +7 -1
- package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
- package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
- package/dist/types/sdk/broker/ensure.d.ts +136 -7
- package/dist/types/sdk/broker/hop.d.ts +36 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
- package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
- package/dist/types/sdk/broker/session-index.d.ts +4 -1
- package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +23 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
- package/dist/types/sdk/cli/session-cli.d.ts +0 -1
- package/dist/types/sdk/host/session-runtime.d.ts +32 -0
- package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
- package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
- package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
- package/dist/types/sdk/session-list.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +58 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
- package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
- package/dist/types/session/messages.d.ts +5 -1
- package/dist/types/session/session-manager.d.ts +13 -3
- package/dist/types/session/startup-auth-config.d.ts +24 -2
- package/dist/types/session/streaming-output.d.ts +11 -0
- package/dist/types/session/terminal-abort.d.ts +3 -1
- package/dist/types/session-import/service.d.ts +1 -1
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
- package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
- package/dist/types/task/executor.d.ts +19 -2
- package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
- package/dist/types/tools/atomic-file-write.d.ts +9 -7
- package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
- package/dist/types/tools/descriptors.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +1 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/python.d.ts +1 -1
- package/dist/types/tools/report-issue.d.ts +41 -0
- package/dist/types/tools/subagent.d.ts +23 -0
- package/dist/types/web/scrapers/types.d.ts +8 -1
- package/dist/types/web/search/providers/insane.d.ts +7 -1
- package/dist/types/web/search/providers/searxng.d.ts +5 -0
- package/package.json +8 -8
- package/scripts/build-binary.ts +2 -1
- package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
- package/scripts/generate-docs-index.ts +8 -1
- package/scripts/generate-sdk-operation-inventory.ts +22 -4
- package/src/async/job-manager.ts +356 -56
- package/src/async/result-formatting.ts +40 -0
- package/src/autoresearch/git.ts +9 -2
- package/src/autoresearch/prompts/prompt-setup.md +1 -1
- package/src/capability/fs.ts +27 -19
- package/src/capability/index.ts +2 -2
- package/src/cli/accounts-cli.ts +176 -8
- package/src/cli/auth-broker-cli.ts +21 -11
- package/src/cli/doctor/install-repairs.ts +35 -5
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/install-activation.ts +47 -4
- package/src/cli/migrate-cli.ts +7 -5
- package/src/cli/public-command-errors.ts +4 -0
- package/src/cli/root-flags.ts +2 -2
- package/src/cli/stats-cli.ts +9 -5
- package/src/cli/update-cli.ts +224 -7
- package/src/cli-ordinary.ts +7 -2
- package/src/commands/accounts.ts +10 -5
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +199 -42
- package/src/commands/ultragoal.ts +16 -8
- package/src/commit/changelog/index.ts +39 -1
- package/src/commit/message.ts +10 -3
- package/src/config/autorouting-tier-map.ts +30 -0
- package/src/config/config-hot-reload-trust.ts +148 -0
- package/src/config/config-hot-reload.ts +734 -0
- package/src/config/file-lock.ts +216 -56
- package/src/config/model-bindings-applier.ts +85 -47
- package/src/config/model-discovery-manager.ts +10 -0
- package/src/config/model-profile-activation.ts +716 -113
- package/src/config/model-registry.ts +1334 -158
- package/src/config/model-resolver.ts +3 -1
- package/src/config/models-config-schema.ts +6 -0
- package/src/config/settings-schema.ts +60 -3
- package/src/config/settings.ts +458 -24
- package/src/coordinator-mcp/policy.ts +26 -1
- package/src/crash/sanitize.ts +4 -0
- package/src/customization/import.ts +3 -1
- package/src/dap/config.ts +31 -11
- package/src/dap/defaults.json +1 -1
- package/src/debug/crash-diagnostics.ts +2 -1
- package/src/debug/report-bundle.ts +19 -0
- package/src/debug/system-info.ts +8 -3
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
- package/src/discovery/agents-md.ts +1 -1
- package/src/discovery/helpers.ts +1 -2
- package/src/discovery/mcp-compat.ts +120 -17
- package/src/edit/apply-patch/index.ts +12 -1
- package/src/edit/index.ts +13 -17
- package/src/edit/modes/patch.ts +187 -8
- package/src/edit/modes/replace.ts +136 -0
- package/src/eval/js/context-manager.ts +29 -3
- package/src/eval/js/worker-entry.ts +51 -18
- package/src/eval/py/executor.ts +14 -4
- package/src/eval/py/runner.py +144 -5
- package/src/exec/bash-executor.ts +81 -4
- package/src/exec/bash-shell-guardian.ts +4 -1
- package/src/exec/bash-shell-supervisor.ts +8 -4
- package/src/exec/isolated-shell.ts +5 -1
- package/src/export/html/index.ts +46 -6
- package/src/extensibility/custom-tools/wrapper.ts +3 -1
- package/src/extensibility/extensions/loader.ts +8 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
- package/src/gjc-runtime/gc-runtime.ts +33 -6
- package/src/gjc-runtime/launch-tmux.ts +7 -2
- package/src/gjc-runtime/launch-worktree.test.ts +148 -0
- package/src/gjc-runtime/launch-worktree.ts +101 -14
- package/src/gjc-runtime/managed-owner-admission.ts +46 -2
- package/src/gjc-runtime/session-resolution.ts +38 -6
- package/src/gjc-runtime/session-state-lock.ts +59 -14
- package/src/gjc-runtime/state-runtime.ts +23 -19
- package/src/hooks/native-skill-hook.ts +14 -11
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/internal-urls/local-protocol.ts +142 -8
- package/src/lsp/index.ts +29 -0
- package/src/lsp/workspace-path.ts +23 -0
- package/src/migrate/action-planner.ts +57 -0
- package/src/migrate/adapters/claude-code.ts +23 -4
- package/src/migrate/adapters/codex.ts +22 -4
- package/src/migrate/adapters/index.ts +22 -4
- package/src/migrate/adapters/opencode.ts +6 -1
- package/src/migrate/executor.ts +84 -7
- package/src/migrate/report.ts +19 -2
- package/src/migrate/types.ts +14 -4
- package/src/modes/DESIGN.md +51 -0
- package/src/modes/acp/permission-mode.ts +18 -1
- package/src/modes/components/assistant-message.ts +55 -1
- package/src/modes/components/settings-selector.ts +103 -37
- package/src/modes/controllers/command-controller.ts +38 -2
- package/src/modes/controllers/plan-mode-controller.ts +17 -7
- package/src/modes/controllers/selector-controller.ts +62 -6
- package/src/modes/interactive-mode.ts +68 -0
- package/src/modes/theme/theme.ts +15 -0
- package/src/prompts/tools/debug.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/report-issue.md +13 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/runtime-credential-selector.ts +2 -2
- package/src/runtime-mcp/content-limits.ts +7 -0
- package/src/runtime-mcp/manager-pool.test.ts +5 -4
- package/src/runtime-mcp/manager.ts +32 -0
- package/src/runtime-mcp/oauth-flow.ts +32 -6
- package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
- package/src/runtime-mcp/render.ts +16 -1
- package/src/runtime-mcp/tool-bridge.ts +126 -17
- package/src/sdk/broker/broker-exit.ts +8 -5
- package/src/sdk/broker/broker.ts +342 -82
- package/src/sdk/broker/daemon-entry.ts +338 -116
- package/src/sdk/broker/doctor-restart.ts +2 -1
- package/src/sdk/broker/ensure.ts +1143 -265
- package/src/sdk/broker/hop.ts +274 -0
- package/src/sdk/broker/lifecycle-ledger.ts +35 -11
- package/src/sdk/broker/lock-host-identity.ts +29 -0
- package/src/sdk/broker/session-index.ts +62 -12
- package/src/sdk/bus/chat-command-policy.ts +16 -3
- package/src/sdk/bus/index.ts +52 -21
- package/src/sdk/bus/reconciliation-store.ts +201 -8
- package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
- package/src/sdk/cli/session-cli.ts +21 -6
- package/src/sdk/host/session-runtime.test.ts +118 -23
- package/src/sdk/host/session-runtime.ts +352 -47
- package/src/sdk/lifecycle/service.ts +1 -0
- package/src/sdk/prompt-deadline-flush.ts +78 -5
- package/src/sdk/prompt-deadline-manager.ts +68 -14
- package/src/sdk/protocol/adapter-validation.ts +53 -11
- package/src/sdk/protocol/operation-inventory.generated.json +101 -2
- package/src/sdk/reconciliation-extensions.ts +1 -0
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +114 -61
- package/src/session/agent-session.ts +1043 -81
- package/src/session/internal/managed-session-scope.ts +126 -39
- package/src/session/internal/managed-session-storage.ts +14 -11
- package/src/session/internal/native-publish-outcome.ts +5 -1
- package/src/session/messages.ts +47 -6
- package/src/session/session-manager.ts +236 -14
- package/src/session/startup-auth-config.ts +116 -8
- package/src/session/streaming-output.ts +262 -4
- package/src/session/terminal-abort.ts +14 -2
- package/src/session-import/service.ts +1 -0
- package/src/skill-state/workflow-mutation-guard.ts +491 -32
- package/src/slash-commands/builtin-registry.ts +15 -3
- package/src/slash-commands/helpers/credential-switch.ts +10 -5
- package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
- package/src/task/executor.ts +103 -12
- package/src/task/index.ts +389 -175
- package/src/tools/ast-edit-write-path.ts +64 -0
- package/src/tools/ast-edit.ts +18 -1
- package/src/tools/atomic-file-write.ts +46 -15
- package/src/tools/auto-generated-guard.ts +3 -0
- package/src/tools/bash-allowed-prefixes.ts +153 -5
- package/src/tools/bash-interactive.ts +14 -2
- package/src/tools/bash.ts +14 -0
- package/src/tools/debug.ts +7 -6
- package/src/tools/descriptors.test.ts +44 -0
- package/src/tools/descriptors.ts +34 -2
- package/src/tools/fetch.ts +3 -2
- package/src/tools/index.ts +2 -0
- package/src/tools/output-meta.ts +23 -2
- package/src/tools/python.ts +1 -1
- package/src/tools/read.ts +17 -35
- package/src/tools/report-issue.ts +184 -0
- package/src/tools/subagent-render.ts +114 -12
- package/src/tools/subagent.ts +108 -1
- package/src/tools/todo-write.ts +41 -3
- package/src/tools/tool-catalog.generated.ts +31 -3
- package/src/tools/write.ts +35 -3
- package/src/utils/commit-message-generator.ts +9 -2
- package/src/utils/file-mentions.ts +29 -1
- package/src/utils/title-generator.ts +7 -2
- package/src/web/scrapers/index.ts +3 -1
- package/src/web/scrapers/types.ts +8 -0
- package/src/web/scrapers/youtube.ts +3 -2
- package/src/web/search/providers/insane.ts +91 -28
- package/src/web/search/providers/searxng.ts +62 -10
|
@@ -179,6 +179,86 @@ async function assertDirectoryNotSymlink(directoryPath: string): Promise<void> {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** Group-write and world-write bits. Either one lets another user rename the session directory. */
|
|
183
|
+
const GROUP_OR_WORLD_WRITABLE = 0o022;
|
|
184
|
+
|
|
185
|
+
interface DirectoryIdentityStat {
|
|
186
|
+
isDirectory(): boolean;
|
|
187
|
+
isSymbolicLink(): boolean;
|
|
188
|
+
dev: number;
|
|
189
|
+
ino: number;
|
|
190
|
+
uid: number;
|
|
191
|
+
mode: number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface ManagedRootIdentity {
|
|
195
|
+
readonly dev: number;
|
|
196
|
+
readonly ino: number;
|
|
197
|
+
readonly uid: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isUnsafeDirectory(stat: DirectoryIdentityStat): boolean {
|
|
201
|
+
return !stat.isDirectory() || stat.isSymbolicLink();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function managedRootIdentity(stat: DirectoryIdentityStat): ManagedRootIdentity {
|
|
205
|
+
return { dev: stat.dev, ino: stat.ino, uid: stat.uid };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sameManagedRoot(stat: DirectoryIdentityStat, recorded: ManagedRootIdentity): boolean {
|
|
209
|
+
if (isUnsafeDirectory(stat) || stat.dev !== recorded.dev || stat.ino !== recorded.ino) return false;
|
|
210
|
+
// A later chmod to 0755 keeps the same directory. Only a writable bit, a new
|
|
211
|
+
// inode, or a different owner means the root was replaced or opened up.
|
|
212
|
+
if (process.platform === "win32") return true;
|
|
213
|
+
return stat.uid === recorded.uid && (stat.mode & GROUP_OR_WORLD_WRITABLE) === 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* An existing managed session directory is adopted only when this user owns it
|
|
218
|
+
* and the group and world cannot write it. Windows has no POSIX owner bits.
|
|
219
|
+
*/
|
|
220
|
+
function assertAdoptedManagedRoot(stat: DirectoryIdentityStat): void {
|
|
221
|
+
if (isUnsafeDirectory(stat)) throw new Error("Unsafe local:// root");
|
|
222
|
+
if (process.platform === "win32") return;
|
|
223
|
+
const uid = process.getuid?.();
|
|
224
|
+
if (uid === undefined || stat.uid !== uid || (stat.mode & GROUP_OR_WORLD_WRITABLE) !== 0) {
|
|
225
|
+
throw new Error("Unsafe local:// root");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function assertManagedParentSync(localRoot: string): void {
|
|
230
|
+
let parentStat: fsSync.Stats;
|
|
231
|
+
try {
|
|
232
|
+
parentStat = fsSync.lstatSync(path.dirname(localRoot));
|
|
233
|
+
} catch {
|
|
234
|
+
throw new Error("Unsafe local:// root");
|
|
235
|
+
}
|
|
236
|
+
assertAdoptedManagedRoot(parentStat);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Rebind a pinned root only when this call created it because the path was
|
|
241
|
+
* absent. An already-present directory must still be the pinned one.
|
|
242
|
+
*/
|
|
243
|
+
function acceptManagedRootForInit(
|
|
244
|
+
localRoot: string,
|
|
245
|
+
rootStat: DirectoryIdentityStat,
|
|
246
|
+
recorded: ManagedRootIdentity | undefined,
|
|
247
|
+
adoptedExisting: boolean,
|
|
248
|
+
): void {
|
|
249
|
+
if (recorded && adoptedExisting) {
|
|
250
|
+
if (sameManagedRoot(rootStat, recorded)) return;
|
|
251
|
+
const pending = pendingCreatedRoots.get(localRoot);
|
|
252
|
+
if (pending && sameManagedRoot(rootStat, pending)) return;
|
|
253
|
+
throw new Error("Unsafe local:// root");
|
|
254
|
+
}
|
|
255
|
+
if (recorded || adoptedExisting) {
|
|
256
|
+
assertAdoptedManagedRoot(rootStat);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (isUnsafeDirectory(rootStat)) throw new Error("Unsafe local:// root");
|
|
260
|
+
}
|
|
261
|
+
|
|
182
262
|
const LEGACY_MIGRATION_MARKER = ".gjc-local-legacy-migrated-v1";
|
|
183
263
|
const MAX_LEGACY_LOCAL_BYTES = 64 * 1024 * 1024;
|
|
184
264
|
|
|
@@ -508,9 +588,33 @@ async function migrateLegacyLocal(
|
|
|
508
588
|
}
|
|
509
589
|
}
|
|
510
590
|
|
|
511
|
-
const initializedLocalRoots = new
|
|
591
|
+
const initializedLocalRoots = new Map<string, ManagedRootIdentity>();
|
|
592
|
+
/** A directory this process created and has not finished migrating. */
|
|
593
|
+
const pendingCreatedRoots = new Map<string, ManagedRootIdentity>();
|
|
512
594
|
const initializingLocalRoots = new Map<string, Promise<string>>();
|
|
513
595
|
|
|
596
|
+
function assertRememberedManagedRoot(localRoot: string, recorded: ManagedRootIdentity): void {
|
|
597
|
+
let stat: fsSync.Stats;
|
|
598
|
+
try {
|
|
599
|
+
stat = fsSync.lstatSync(localRoot);
|
|
600
|
+
} catch {
|
|
601
|
+
throw new Error("Unsafe local:// root");
|
|
602
|
+
}
|
|
603
|
+
if (!sameManagedRoot(stat, recorded)) throw new Error("Unsafe local:// root");
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function rememberManagedRoot(localRoot: string, expected: ManagedRootIdentity): void {
|
|
607
|
+
let stat: fsSync.Stats;
|
|
608
|
+
try {
|
|
609
|
+
stat = fsSync.lstatSync(localRoot);
|
|
610
|
+
} catch {
|
|
611
|
+
throw new Error("Unsafe local:// root");
|
|
612
|
+
}
|
|
613
|
+
if (!sameManagedRoot(stat, expected)) throw new Error("Unsafe local:// root");
|
|
614
|
+
initializedLocalRoots.set(localRoot, managedRootIdentity(stat));
|
|
615
|
+
pendingCreatedRoots.delete(localRoot);
|
|
616
|
+
}
|
|
617
|
+
|
|
514
618
|
/**
|
|
515
619
|
* Completes the mandatory legacy migration gate for a local root.
|
|
516
620
|
* Call and await this before using the synchronous path resolver for reads or writes.
|
|
@@ -559,16 +663,21 @@ async function initializeManagedLocalRoot(options: LocalProtocolOptions, localRo
|
|
|
559
663
|
|
|
560
664
|
await fs.mkdir(scratchParent, { recursive: true, mode: 0o700 });
|
|
561
665
|
await assertDirectoryNotSymlink(scratchParent);
|
|
666
|
+
assertAdoptedManagedRoot(await fs.lstat(scratchParent));
|
|
667
|
+
let adoptedExisting = false;
|
|
562
668
|
try {
|
|
563
669
|
await fs.mkdir(localRoot, { mode: 0o700 });
|
|
564
670
|
} catch (error) {
|
|
565
671
|
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
|
|
566
|
-
|
|
672
|
+
adoptedExisting = true;
|
|
567
673
|
} else {
|
|
568
674
|
throw error;
|
|
569
675
|
}
|
|
570
676
|
}
|
|
571
|
-
await
|
|
677
|
+
const rootStat = await fs.lstat(localRoot);
|
|
678
|
+
const recorded = initializedLocalRoots.get(localRoot);
|
|
679
|
+
acceptManagedRootForInit(localRoot, rootStat, recorded, adoptedExisting);
|
|
680
|
+
if (!adoptedExisting) pendingCreatedRoots.set(localRoot, managedRootIdentity(rootStat));
|
|
572
681
|
await migrateLegacyLocal(options, localRoot, scratchParent);
|
|
573
682
|
|
|
574
683
|
const resolvedScratchParent = await fs.realpath(scratchParent);
|
|
@@ -577,7 +686,10 @@ async function initializeManagedLocalRoot(options: LocalProtocolOptions, localRo
|
|
|
577
686
|
if (resolvedLocalRoot === resolvedScratchParent) {
|
|
578
687
|
throw new Error("Unsafe local:// root");
|
|
579
688
|
}
|
|
580
|
-
|
|
689
|
+
// Key the lexical path the sync resolver checks. realpath(/var -> /private/var)
|
|
690
|
+
// is a different string for the same directory, so caching only the real path
|
|
691
|
+
// let a later rename-swap skip this identity check.
|
|
692
|
+
rememberManagedRoot(localRoot, managedRootIdentity(rootStat));
|
|
581
693
|
return resolvedLocalRoot;
|
|
582
694
|
}
|
|
583
695
|
|
|
@@ -587,18 +699,39 @@ function initializeLocalRootSyncWhenLegacyAbsent(options: LocalProtocolOptions,
|
|
|
587
699
|
initializeExplicitLocalRootSync(explicitRoot);
|
|
588
700
|
return;
|
|
589
701
|
}
|
|
590
|
-
|
|
702
|
+
const recorded = initializedLocalRoots.get(localRoot);
|
|
703
|
+
if (recorded) {
|
|
704
|
+
assertManagedParentSync(localRoot);
|
|
705
|
+
let present: fsSync.Stats | undefined;
|
|
706
|
+
try {
|
|
707
|
+
present = fsSync.lstatSync(localRoot);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
if (!isEnoent(error)) throw new Error("Unsafe local:// root");
|
|
710
|
+
}
|
|
711
|
+
if (present) {
|
|
712
|
+
if (sameManagedRoot(present, recorded)) {
|
|
713
|
+
assertRememberedManagedRoot(localRoot, recorded);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const pending = pendingCreatedRoots.get(localRoot);
|
|
717
|
+
if (!(pending && sameManagedRoot(present, pending))) throw new Error("Unsafe local:// root");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
591
720
|
const scratchParent = path.dirname(localRoot);
|
|
592
721
|
fsSync.mkdirSync(scratchParent, { recursive: true, mode: 0o700 });
|
|
593
722
|
const parentStat = fsSync.lstatSync(scratchParent);
|
|
594
723
|
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) throw new Error("Unsafe local:// root");
|
|
724
|
+
assertAdoptedManagedRoot(parentStat);
|
|
725
|
+
let adoptedExisting = false;
|
|
595
726
|
try {
|
|
596
727
|
fsSync.mkdirSync(localRoot, { mode: 0o700 });
|
|
597
728
|
} catch (error) {
|
|
598
729
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
730
|
+
adoptedExisting = true;
|
|
599
731
|
}
|
|
600
732
|
const rootStat = fsSync.lstatSync(localRoot);
|
|
601
|
-
|
|
733
|
+
acceptManagedRootForInit(localRoot, rootStat, recorded, adoptedExisting);
|
|
734
|
+
if (!adoptedExisting) pendingCreatedRoots.set(localRoot, managedRootIdentity(rootStat));
|
|
602
735
|
const marker = path.join(localRoot, LEGACY_MIGRATION_MARKER);
|
|
603
736
|
try {
|
|
604
737
|
// Accept exactly the marker states the async gate treats as settled. A
|
|
@@ -608,7 +741,7 @@ function initializeLocalRootSyncWhenLegacyAbsent(options: LocalProtocolOptions,
|
|
|
608
741
|
// async gate had already completed.
|
|
609
742
|
if (!isSettledMigrationMarkerValue(fsSync.readFileSync(marker, "utf8")))
|
|
610
743
|
throw new Error("Unsafe local:// migration marker");
|
|
611
|
-
|
|
744
|
+
rememberManagedRoot(localRoot, managedRootIdentity(rootStat));
|
|
612
745
|
return;
|
|
613
746
|
} catch (error) {
|
|
614
747
|
if (!isEnoent(error)) throw error;
|
|
@@ -629,7 +762,7 @@ function initializeLocalRootSyncWhenLegacyAbsent(options: LocalProtocolOptions,
|
|
|
629
762
|
if (!isSettledMigrationMarkerValue(fsSync.readFileSync(marker, "utf8")))
|
|
630
763
|
throw new Error("Unsafe local:// migration marker");
|
|
631
764
|
}
|
|
632
|
-
|
|
765
|
+
rememberManagedRoot(localRoot, managedRootIdentity(rootStat));
|
|
633
766
|
}
|
|
634
767
|
|
|
635
768
|
export function resolveLocalRoot(options: LocalProtocolOptions): string {
|
|
@@ -696,6 +829,7 @@ export class LocalProtocolHandler implements ProtocolHandler {
|
|
|
696
829
|
LocalProtocolHandler.#override = undefined;
|
|
697
830
|
LocalProtocolHandler.#ownedOverrides = [];
|
|
698
831
|
initializedLocalRoots.clear();
|
|
832
|
+
pendingCreatedRoots.clear();
|
|
699
833
|
initializingLocalRoots.clear();
|
|
700
834
|
}
|
|
701
835
|
|
package/src/lsp/index.ts
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
symbolKindToIcon,
|
|
78
78
|
uriToFile,
|
|
79
79
|
} from "./utils";
|
|
80
|
+
import { assertInsideWorkspace } from "./workspace-path";
|
|
80
81
|
|
|
81
82
|
export type { LspServerStatus } from "./client";
|
|
82
83
|
export type { LspToolDetails } from "./types";
|
|
@@ -347,6 +348,17 @@ interface FileRenamePair {
|
|
|
347
348
|
* For files this is a single pair. For directories this walks every regular file underneath
|
|
348
349
|
* and produces a parallel pair anchored at the new directory root.
|
|
349
350
|
*/
|
|
351
|
+
/**
|
|
352
|
+
* POSIX `/` contains every absolute path, and the shared helper's prefix for
|
|
353
|
+
* it is `//`, so skip containment there. A Windows drive root is only that
|
|
354
|
+
* volume, so it is checked. Any other platform root keeps the previous skip.
|
|
355
|
+
*/
|
|
356
|
+
function workspaceRootSkipsContainment(root: string): boolean {
|
|
357
|
+
if (root === "/") return true;
|
|
358
|
+
if (/^[A-Za-z]:[\\/]$/.test(root)) return false;
|
|
359
|
+
return root === path.parse(root).root;
|
|
360
|
+
}
|
|
361
|
+
|
|
350
362
|
async function enumerateRenamePairs(
|
|
351
363
|
source: string,
|
|
352
364
|
dest: string,
|
|
@@ -1525,6 +1537,23 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
1525
1537
|
}
|
|
1526
1538
|
}
|
|
1527
1539
|
|
|
1540
|
+
// Reject every path this rename will write before the first write. Check the
|
|
1541
|
+
// caller signal after each await, including the last one.
|
|
1542
|
+
const cwd = this.session.cwd;
|
|
1543
|
+
throwIfAborted(signal);
|
|
1544
|
+
const workspaceRoot = await fs.promises.realpath(cwd);
|
|
1545
|
+
throwIfAborted(signal);
|
|
1546
|
+
if (!workspaceRootSkipsContainment(workspaceRoot)) {
|
|
1547
|
+
await assertInsideWorkspace(cwd, source);
|
|
1548
|
+
throwIfAborted(signal);
|
|
1549
|
+
await assertInsideWorkspace(cwd, dest);
|
|
1550
|
+
throwIfAborted(signal);
|
|
1551
|
+
for (const uri of acceptedByUri.keys()) {
|
|
1552
|
+
await assertInsideWorkspace(cwd, uriToFile(uri));
|
|
1553
|
+
throwIfAborted(signal);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1528
1557
|
for (const [uri, bucket] of acceptedByUri) {
|
|
1529
1558
|
const filePath = uriToFile(uri);
|
|
1530
1559
|
await applyTextEdits(filePath, bucket.edits);
|
|
@@ -6,7 +6,30 @@ function isEnoent(error: unknown): boolean {
|
|
|
6
6
|
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/** Drive-letter root such as `C:\`. A UNC share root is not a drive root. */
|
|
10
|
+
function windowsDriveLetter(root: string): string | null {
|
|
11
|
+
const match = /^([A-Za-z]):[\\/]$/.exec(root);
|
|
12
|
+
return match ? match[1].toLowerCase() : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A Windows drive root already ends in a separator. Prefixing another one
|
|
17
|
+
* (`C:\` + `\`) matches nothing on that volume, so compare the drive letter.
|
|
18
|
+
* Same-volume paths stay inside, including `\\?\C:\`. Other drives and UNC
|
|
19
|
+
* shares do not.
|
|
20
|
+
*/
|
|
21
|
+
function sameWindowsDrive(driveRoot: string, candidate: string): boolean {
|
|
22
|
+
const letter = windowsDriveLetter(driveRoot);
|
|
23
|
+
if (!letter) return false;
|
|
24
|
+
const normalized = candidate.replace(/\//g, "\\");
|
|
25
|
+
const extended = /^\\\\\?\\([A-Za-z]):\\/.exec(normalized);
|
|
26
|
+
if (extended) return extended[1].toLowerCase() === letter;
|
|
27
|
+
if (normalized.startsWith("\\\\")) return false;
|
|
28
|
+
return path.win32.parse(normalized).root.toLowerCase() === `${letter}:\\`;
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
function escapes(root: string, candidate: string): boolean {
|
|
32
|
+
if (windowsDriveLetter(root)) return !sameWindowsDrive(root, candidate);
|
|
10
33
|
return candidate !== root && !candidate.startsWith(`${root}${path.sep}`);
|
|
11
34
|
}
|
|
12
35
|
|
|
@@ -314,5 +314,62 @@ export async function planMigration(input: PlanInput): Promise<PlanOutput> {
|
|
|
314
314
|
}
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// 4. Instructions — all selected sources target the same scoped AGENTS.md.
|
|
318
|
+
const plannedInstructions = new Set<string>();
|
|
319
|
+
for (const result of input.results) {
|
|
320
|
+
for (const candidate of result.instructionCandidates) {
|
|
321
|
+
const destination = input.destinations.instructionsPath;
|
|
322
|
+
const base = { source: candidate.source, type: "instruction" as const, name: candidate.name, destination };
|
|
323
|
+
if (plannedInstructions.has(destination)) {
|
|
324
|
+
actions.push({
|
|
325
|
+
...base,
|
|
326
|
+
operation: "skip",
|
|
327
|
+
status: "skipped_exists",
|
|
328
|
+
reason: "another selected source already targets this file; import one source at a time",
|
|
329
|
+
});
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let exists = false;
|
|
334
|
+
try {
|
|
335
|
+
const stat = await fs.lstat(destination);
|
|
336
|
+
exists = true;
|
|
337
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
338
|
+
actions.push({
|
|
339
|
+
...base,
|
|
340
|
+
operation: input.force ? "fail" : "skip",
|
|
341
|
+
status: input.force ? "failed_invalid_destination" : "skipped_exists",
|
|
342
|
+
reason: "destination is not a regular file; refusing to replace it",
|
|
343
|
+
});
|
|
344
|
+
plannedInstructions.add(destination);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (!isEnoent(error)) {
|
|
349
|
+
actions.push({
|
|
350
|
+
...base,
|
|
351
|
+
operation: "fail",
|
|
352
|
+
status: "failed_io",
|
|
353
|
+
reason: `failed to inspect destination: ${(error as Error).message}`,
|
|
354
|
+
});
|
|
355
|
+
plannedInstructions.add(destination);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (exists && !input.force) {
|
|
361
|
+
actions.push({ ...base, operation: "skip", status: "skipped_exists" });
|
|
362
|
+
} else {
|
|
363
|
+
actions.push({
|
|
364
|
+
...base,
|
|
365
|
+
operation: exists ? "update" : "create",
|
|
366
|
+
status: exists ? "updated" : "imported",
|
|
367
|
+
instruction: { content: candidate.content },
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
plannedInstructions.add(destination);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
317
374
|
return { actions, warnings };
|
|
318
375
|
}
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Claude Code adapter: reads `~/.claude.json` (mcpServers)
|
|
2
|
+
* Claude Code adapter: reads `~/.claude.json` (mcpServers), `~/.claude/skills`,
|
|
3
|
+
* and the selected scope's `CLAUDE.md`.
|
|
3
4
|
*/
|
|
4
5
|
import * as path from "node:path";
|
|
5
6
|
import type { AdapterResult, McpCandidate } from "../types";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type Adapter,
|
|
9
|
+
type AdapterOptions,
|
|
10
|
+
collectInstructionFile,
|
|
11
|
+
collectSkillDir,
|
|
12
|
+
parseSourceJson,
|
|
13
|
+
readSourceText,
|
|
14
|
+
} from "./index";
|
|
7
15
|
|
|
8
16
|
const SOURCE = "claude-code" as const;
|
|
9
17
|
|
|
10
18
|
export const claudeCodeAdapter: Adapter = {
|
|
11
19
|
source: SOURCE,
|
|
12
|
-
async collect({ homeDir }: AdapterOptions): Promise<AdapterResult> {
|
|
13
|
-
const result: AdapterResult = {
|
|
20
|
+
async collect({ homeDir, cwd, scope }: AdapterOptions): Promise<AdapterResult> {
|
|
21
|
+
const result: AdapterResult = {
|
|
22
|
+
mcpCandidates: [],
|
|
23
|
+
skillCandidates: [],
|
|
24
|
+
instructionCandidates: [],
|
|
25
|
+
diagnostics: [],
|
|
26
|
+
};
|
|
14
27
|
|
|
15
28
|
const configPath = path.join(homeDir, ".claude.json");
|
|
16
29
|
const read = await readSourceText(configPath, SOURCE, "mcp");
|
|
@@ -34,6 +47,12 @@ export const claudeCodeAdapter: Adapter = {
|
|
|
34
47
|
result.skillCandidates.push(...skills.candidates);
|
|
35
48
|
result.diagnostics.push(...skills.diagnostics);
|
|
36
49
|
|
|
50
|
+
const instructionsPath =
|
|
51
|
+
scope === "project" ? path.join(cwd, "CLAUDE.md") : path.join(homeDir, ".claude", "CLAUDE.md");
|
|
52
|
+
const instructions = await collectInstructionFile(instructionsPath, SOURCE);
|
|
53
|
+
result.instructionCandidates.push(...instructions.candidates);
|
|
54
|
+
result.diagnostics.push(...instructions.diagnostics);
|
|
55
|
+
|
|
37
56
|
return result;
|
|
38
57
|
},
|
|
39
58
|
};
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codex adapter: reads `~/.codex/config.toml` ([mcp_servers])
|
|
2
|
+
* Codex adapter: reads `~/.codex/config.toml` ([mcp_servers]), `~/.codex/prompts`,
|
|
3
|
+
* and user-level `~/.codex/AGENTS.md` instructions. Project AGENTS.md is native GJC input.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import * as path from "node:path";
|
|
6
7
|
import { TOML } from "bun";
|
|
7
8
|
import type { AdapterResult, McpCandidate, SourceDiagnostic } from "../types";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type Adapter,
|
|
11
|
+
type AdapterOptions,
|
|
12
|
+
collectInstructionFile,
|
|
13
|
+
collectMarkdownPrompts,
|
|
14
|
+
readSourceText,
|
|
15
|
+
} from "./index";
|
|
9
16
|
|
|
10
17
|
const SOURCE = "codex" as const;
|
|
11
18
|
|
|
@@ -40,8 +47,13 @@ function parseToml(
|
|
|
40
47
|
|
|
41
48
|
export const codexAdapter: Adapter = {
|
|
42
49
|
source: SOURCE,
|
|
43
|
-
async collect({ homeDir }: AdapterOptions): Promise<AdapterResult> {
|
|
44
|
-
const result: AdapterResult = {
|
|
50
|
+
async collect({ homeDir, scope }: AdapterOptions): Promise<AdapterResult> {
|
|
51
|
+
const result: AdapterResult = {
|
|
52
|
+
mcpCandidates: [],
|
|
53
|
+
skillCandidates: [],
|
|
54
|
+
instructionCandidates: [],
|
|
55
|
+
diagnostics: [],
|
|
56
|
+
};
|
|
45
57
|
|
|
46
58
|
const configPath = path.join(homeDir, ".codex", "config.toml");
|
|
47
59
|
const read = await readSourceText(configPath, SOURCE, "mcp");
|
|
@@ -65,6 +77,12 @@ export const codexAdapter: Adapter = {
|
|
|
65
77
|
result.skillCandidates.push(...prompts.candidates);
|
|
66
78
|
result.diagnostics.push(...prompts.diagnostics);
|
|
67
79
|
|
|
80
|
+
if (scope === "user") {
|
|
81
|
+
const instructions = await collectInstructionFile(path.join(homeDir, ".codex", "AGENTS.md"), SOURCE);
|
|
82
|
+
result.instructionCandidates.push(...instructions.candidates);
|
|
83
|
+
result.diagnostics.push(...instructions.diagnostics);
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
return result;
|
|
69
87
|
},
|
|
70
88
|
};
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Source adapters for `gjc migrate`.
|
|
3
3
|
*
|
|
4
|
-
* Each adapter
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Each adapter returns normalized home-scoped MCP/skill candidates, selected-scope
|
|
5
|
+
* instruction candidates, and source-level diagnostics. Adapters never connect to
|
|
6
|
+
* anything.
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as path from "node:path";
|
|
9
10
|
import { isEnoent } from "@gajae-code/utils";
|
|
10
11
|
import { normalizeSkill } from "../skill-normalizer";
|
|
11
|
-
import type { AdapterResult, MigrateSource, SkillCandidate, SourceDiagnostic } from "../types";
|
|
12
|
+
import type { AdapterResult, InstructionCandidate, MigrateSource, SkillCandidate, SourceDiagnostic } from "../types";
|
|
12
13
|
import { claudeCodeAdapter } from "./claude-code";
|
|
13
14
|
import { codexAdapter } from "./codex";
|
|
14
15
|
import { opencodeAdapter } from "./opencode";
|
|
@@ -16,6 +17,10 @@ import { opencodeAdapter } from "./opencode";
|
|
|
16
17
|
export interface AdapterOptions {
|
|
17
18
|
/** Home directory root; overridable for tests. */
|
|
18
19
|
homeDir: string;
|
|
20
|
+
/** Source scope for instructions; other source files remain home-scoped. */
|
|
21
|
+
scope: "project" | "user";
|
|
22
|
+
/** Project root used when importing project instructions. */
|
|
23
|
+
cwd: string;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export interface Adapter {
|
|
@@ -33,6 +38,19 @@ export function getAdapter(source: MigrateSource): Adapter {
|
|
|
33
38
|
return ADAPTERS[source];
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
/** Collect one source instruction file without interpreting or rewriting its content. */
|
|
42
|
+
export async function collectInstructionFile(
|
|
43
|
+
filePath: string,
|
|
44
|
+
source: MigrateSource,
|
|
45
|
+
): Promise<{ candidates: InstructionCandidate[]; diagnostics: SourceDiagnostic[] }> {
|
|
46
|
+
const read = await readSourceText(filePath, source, "instruction");
|
|
47
|
+
if ("diagnostic" in read) return { candidates: [], diagnostics: [read.diagnostic] };
|
|
48
|
+
return {
|
|
49
|
+
candidates: [{ source, name: path.basename(filePath), content: read.text }],
|
|
50
|
+
diagnostics: [],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
/** Read a text file, classifying absence/IO errors into source diagnostics. */
|
|
37
55
|
export async function readSourceText(
|
|
38
56
|
filePath: string,
|
|
@@ -18,7 +18,12 @@ const SOURCE = "opencode" as const;
|
|
|
18
18
|
export const opencodeAdapter: Adapter = {
|
|
19
19
|
source: SOURCE,
|
|
20
20
|
async collect({ homeDir }: AdapterOptions): Promise<AdapterResult> {
|
|
21
|
-
const result: AdapterResult = {
|
|
21
|
+
const result: AdapterResult = {
|
|
22
|
+
mcpCandidates: [],
|
|
23
|
+
skillCandidates: [],
|
|
24
|
+
instructionCandidates: [],
|
|
25
|
+
diagnostics: [],
|
|
26
|
+
};
|
|
22
27
|
const baseDir = path.join(homeDir, ".config", "opencode");
|
|
23
28
|
|
|
24
29
|
const configPath = path.join(baseDir, "opencode.json");
|
package/src/migrate/executor.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as fsSync from "node:fs";
|
|
10
10
|
import * as fs from "node:fs/promises";
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
+
import { getTrustedHomeDir } from "@gajae-code/utils";
|
|
12
13
|
import { upsertMCPServer } from "../runtime-mcp/config-writer";
|
|
13
14
|
import type { MigrateAction } from "./types";
|
|
14
15
|
|
|
@@ -19,17 +20,23 @@ function trustedSkillDestinationBase(destination: string): string {
|
|
|
19
20
|
return path.basename(scopeRoot) === ".gjc" ? path.dirname(scopeRoot) : scopeRoot;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
async function ensureRealDirectoryPathNoFollow(
|
|
23
|
+
async function ensureRealDirectoryPathNoFollow(
|
|
24
|
+
directory: string,
|
|
25
|
+
trustedBase: string,
|
|
26
|
+
label = "skill destination",
|
|
27
|
+
createMissing = true,
|
|
28
|
+
mode?: number,
|
|
29
|
+
): Promise<void> {
|
|
23
30
|
const resolvedBase = path.resolve(trustedBase);
|
|
24
31
|
const resolvedDirectory = path.resolve(directory);
|
|
25
32
|
const relative = path.relative(resolvedBase, resolvedDirectory);
|
|
26
33
|
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
27
|
-
throw new Error(
|
|
34
|
+
throw new Error(`${label} escapes trusted base: ${resolvedDirectory}`);
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
const baseStat = await fs.stat(resolvedBase);
|
|
31
38
|
if (!baseStat.isDirectory()) {
|
|
32
|
-
throw new Error(
|
|
39
|
+
throw new Error(`${label} base is not a directory: ${resolvedBase}`);
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
let current = resolvedBase;
|
|
@@ -38,14 +45,17 @@ async function ensureRealDirectoryPathNoFollow(directory: string, trustedBase: s
|
|
|
38
45
|
try {
|
|
39
46
|
const stat = await fs.lstat(current);
|
|
40
47
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
41
|
-
throw new Error(
|
|
48
|
+
throw new Error(`${label} ancestor is not a real directory: ${current}`);
|
|
42
49
|
}
|
|
43
50
|
} catch (error) {
|
|
44
51
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
45
|
-
|
|
52
|
+
// A missing project `.gjc` is created by the MCP writer (mode 0o700).
|
|
53
|
+
// Do not mkdir here, or that mode is lost. An existing symlink still fails above.
|
|
54
|
+
if (!createMissing) return;
|
|
55
|
+
await fs.mkdir(current, mode === undefined ? undefined : { mode });
|
|
46
56
|
const stat = await fs.lstat(current);
|
|
47
57
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
48
|
-
throw new Error(
|
|
58
|
+
throw new Error(`${label} ancestor is not a real directory: ${current}`);
|
|
49
59
|
}
|
|
50
60
|
}
|
|
51
61
|
}
|
|
@@ -66,7 +76,56 @@ async function writeSkillFileNoFollow(destination: string, content: string): Pro
|
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
|
|
79
|
+
async function ensureInstructionDirectory(directory: string, project: boolean): Promise<void> {
|
|
80
|
+
let trustedBase = path.dirname(directory);
|
|
81
|
+
if (!project) {
|
|
82
|
+
const home = path.resolve(getTrustedHomeDir());
|
|
83
|
+
const resolvedDirectory = path.resolve(directory);
|
|
84
|
+
const relative = path.relative(home, resolvedDirectory);
|
|
85
|
+
const isWithinHome =
|
|
86
|
+
relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
87
|
+
if (isWithinHome) {
|
|
88
|
+
trustedBase = home;
|
|
89
|
+
} else {
|
|
90
|
+
let current = path.dirname(resolvedDirectory);
|
|
91
|
+
while (true) {
|
|
92
|
+
try {
|
|
93
|
+
const stat = await fs.lstat(current);
|
|
94
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
95
|
+
throw new Error(`user instruction destination ancestor is not a real directory: ${current}`);
|
|
96
|
+
}
|
|
97
|
+
trustedBase = current;
|
|
98
|
+
break;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
101
|
+
const parent = path.dirname(current);
|
|
102
|
+
if (parent === current) throw error;
|
|
103
|
+
current = parent;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await ensureRealDirectoryPathNoFollow(directory, trustedBase, "instruction destination", true, 0o700);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function writeInstructionFileNoFollow(destination: string, content: string, project: boolean): Promise<void> {
|
|
112
|
+
await ensureInstructionDirectory(path.dirname(destination), project);
|
|
113
|
+
const handle = await fs.open(
|
|
114
|
+
destination,
|
|
115
|
+
fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_TRUNC | fsSync.constants.O_NOFOLLOW,
|
|
116
|
+
0o666,
|
|
117
|
+
);
|
|
118
|
+
try {
|
|
119
|
+
await handle.writeFile(content, "utf-8");
|
|
120
|
+
} finally {
|
|
121
|
+
await handle.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function executeActions(
|
|
126
|
+
actions: MigrateAction[],
|
|
127
|
+
options?: { project?: boolean },
|
|
128
|
+
): Promise<MigrateAction[]> {
|
|
70
129
|
const out: MigrateAction[] = [];
|
|
71
130
|
for (const action of actions) {
|
|
72
131
|
if (action.operation !== "create" && action.operation !== "update") {
|
|
@@ -75,6 +134,17 @@ export async function executeActions(actions: MigrateAction[]): Promise<MigrateA
|
|
|
75
134
|
}
|
|
76
135
|
try {
|
|
77
136
|
if (action.type === "mcp" && action.mcp && action.name && action.destination) {
|
|
137
|
+
// `project` is the migrate scope, not a guess from the directory name.
|
|
138
|
+
// A user agent dir may itself be named `.gjc`.
|
|
139
|
+
if (options?.project) {
|
|
140
|
+
const directory = path.dirname(action.destination);
|
|
141
|
+
await ensureRealDirectoryPathNoFollow(
|
|
142
|
+
directory,
|
|
143
|
+
path.dirname(path.resolve(directory)),
|
|
144
|
+
"mcp destination",
|
|
145
|
+
false,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
78
148
|
await upsertMCPServer(action.destination, action.name, action.mcp.config, {
|
|
79
149
|
force: action.operation === "update",
|
|
80
150
|
});
|
|
@@ -82,6 +152,13 @@ export async function executeActions(actions: MigrateAction[]): Promise<MigrateA
|
|
|
82
152
|
} else if (action.type === "skill" && action.skill && action.destination) {
|
|
83
153
|
await writeSkillFileNoFollow(action.destination, action.skill.content);
|
|
84
154
|
out.push(action);
|
|
155
|
+
} else if (action.type === "instruction" && action.instruction && action.destination) {
|
|
156
|
+
await writeInstructionFileNoFollow(
|
|
157
|
+
action.destination,
|
|
158
|
+
action.instruction.content,
|
|
159
|
+
options?.project ?? false,
|
|
160
|
+
);
|
|
161
|
+
out.push(action);
|
|
85
162
|
} else {
|
|
86
163
|
out.push(action);
|
|
87
164
|
}
|