impel-cli 0.20.2 → 0.20.4

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.
@@ -0,0 +1,248 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+
4
+ import { isSessionsUUID } from "./broker.js";
5
+
6
+ const PROVIDERS = new Set(["codex", "claude"]);
7
+ const MODES = new Set(["fork", "resume"]);
8
+ const SURFACES = new Set(["desktop", "cli", "app_server", "runner"]);
9
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u;
10
+ const SHA_RE = /^[a-f0-9]{64}$/u;
11
+ const DENIED_PATH_RE = /(?:^|\/)(?:auth|credential|credentials|token|tokens|keychain|config)(?:\.|\/|$)/iu;
12
+ const MAX_FILES = 20_000;
13
+ const MAX_FILE_BYTES = 128 << 20;
14
+ const MAX_TOTAL_BYTES = 512 << 20;
15
+ const MANIFEST_KEYS = new Set([
16
+ "schemaVersion", "itemCount", "provider", "providerVersion", "sourceSurface", "destinationSurface",
17
+ "mode", "runId", "providerSessionId", "providerThreadId", "impelSessionId", "quiescence", "files",
18
+ "createdAt", "closedAt", "rootManifestHash",
19
+ ]);
20
+
21
+ function exactKeys(value, allowed) {
22
+ return value && typeof value === "object" && !Array.isArray(value)
23
+ && Object.keys(value).every((key) => allowed.has(key));
24
+ }
25
+
26
+ function sha256(value) {
27
+ return crypto.createHash("sha256").update(value).digest("hex");
28
+ }
29
+
30
+ function sha256File(filePath) {
31
+ return sha256(fs.readFileSync(filePath));
32
+ }
33
+
34
+ function canonicalManifestCore(manifest, runId = manifest.runId) {
35
+ return {
36
+ schemaVersion: manifest.schemaVersion,
37
+ itemCount: manifest.itemCount,
38
+ provider: manifest.provider,
39
+ providerVersion: manifest.providerVersion,
40
+ sourceSurface: manifest.sourceSurface,
41
+ destinationSurface: manifest.destinationSurface,
42
+ mode: manifest.mode,
43
+ runId,
44
+ providerSessionId: manifest.providerSessionId,
45
+ ...(manifest.providerThreadId ? { providerThreadId: manifest.providerThreadId } : {}),
46
+ impelSessionId: manifest.impelSessionId,
47
+ quiescence: {
48
+ proven: manifest.quiescence?.proven,
49
+ mechanism: manifest.quiescence?.mechanism,
50
+ },
51
+ files: Array.isArray(manifest.files) ? manifest.files.map((file) => ({
52
+ path: file.path,
53
+ length: file.length,
54
+ sha256: file.sha256,
55
+ })) : manifest.files,
56
+ createdAt: manifest.createdAt,
57
+ closedAt: manifest.closedAt,
58
+ };
59
+ }
60
+
61
+ function safePath(value) {
62
+ const relative = String(value || "").replaceAll("\\", "/");
63
+ if (!relative || relative.startsWith("/") || relative.includes("\0")
64
+ || relative.split("/").some((part) => !part || part === "." || part === "..")) {
65
+ throw new Error("provider checkpoint manifest contains an unsafe path");
66
+ }
67
+ if (DENIED_PATH_RE.test(relative)) throw new Error(`provider checkpoint contains denied credential/config path ${relative}`);
68
+ return relative;
69
+ }
70
+
71
+ function octal(buffer) {
72
+ const text = buffer.toString("ascii").replace(/\0.*$/u, "").trim();
73
+ return text ? Number.parseInt(text, 8) : 0;
74
+ }
75
+
76
+ function inspectTar(archivePath) {
77
+ const stat = fs.statSync(archivePath);
78
+ if (!stat.isFile() || stat.size < 1 || stat.size > MAX_TOTAL_BYTES) {
79
+ throw new Error("provider checkpoint archive exceeds configured limits");
80
+ }
81
+ const archive = fs.readFileSync(archivePath);
82
+ const entries = [];
83
+ let totalBytes = 0;
84
+ let offset = 0;
85
+ while (offset + 512 <= archive.length) {
86
+ const header = archive.subarray(offset, offset + 512);
87
+ offset += 512;
88
+ if (header.every((byte) => byte === 0)) break;
89
+ const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/u, "");
90
+ const prefix = header.subarray(345, 500).toString("utf8").replace(/\0.*$/u, "");
91
+ const relative = safePath(prefix ? `${prefix}/${name}` : name);
92
+ const type = header[156];
93
+ if (type !== 0 && type !== 0x30) throw new Error(`provider checkpoint contains unsupported link or special entry ${relative}`);
94
+ const length = octal(header.subarray(124, 136));
95
+ if (!Number.isSafeInteger(length) || length < 0) {
96
+ throw new Error("provider checkpoint archive contains an invalid length");
97
+ }
98
+ totalBytes += length;
99
+ if (entries.length >= MAX_FILES + 1 || length > MAX_FILE_BYTES || totalBytes > MAX_TOTAL_BYTES) {
100
+ throw new Error("provider checkpoint archive exceeds configured limits");
101
+ }
102
+ if (offset + length > archive.length) {
103
+ throw new Error("provider checkpoint archive is truncated");
104
+ }
105
+ const contents = archive.subarray(offset, offset + length);
106
+ entries.push({ path: relative, length, sha256: sha256(contents), contents: Buffer.from(contents), mode: octal(header.subarray(100, 108)) });
107
+ offset += Math.ceil(length / 512) * 512;
108
+ }
109
+ return entries;
110
+ }
111
+
112
+ function writeField(header, offset, length, value) {
113
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
114
+ if (bytes.length > length) throw new Error("provider checkpoint tar header overflow");
115
+ bytes.copy(header, offset);
116
+ }
117
+
118
+ function writeOctal(header, offset, length, value) {
119
+ writeField(header, offset, length, `${Math.max(0, value).toString(8).padStart(length - 1, "0")}\0`);
120
+ }
121
+
122
+ function tarFields(relative) {
123
+ const normalized = safePath(relative);
124
+ const bytes = Buffer.from(normalized, "utf8");
125
+ if (bytes.length <= 100) return { name: bytes, prefix: Buffer.alloc(0) };
126
+ const parts = normalized.split("/");
127
+ for (let index = parts.length - 1; index > 0; index -= 1) {
128
+ const prefix = Buffer.from(parts.slice(0, index).join("/"), "utf8");
129
+ const name = Buffer.from(parts.slice(index).join("/"), "utf8");
130
+ if (prefix.length <= 155 && name.length <= 100) return { name, prefix };
131
+ }
132
+ throw new Error(`provider checkpoint path is too long: ${relative}`);
133
+ }
134
+
135
+ function tarHeader(relative, length, mode = 0o600) {
136
+ const header = Buffer.alloc(512);
137
+ const fields = tarFields(relative);
138
+ writeField(header, 0, 100, fields.name);
139
+ writeOctal(header, 100, 8, mode & 0o777);
140
+ writeOctal(header, 108, 8, 0);
141
+ writeOctal(header, 116, 8, 0);
142
+ writeOctal(header, 124, 12, length);
143
+ writeOctal(header, 136, 12, 0);
144
+ header.fill(0x20, 148, 156);
145
+ writeField(header, 156, 1, "0");
146
+ writeField(header, 257, 6, "ustar\0");
147
+ writeField(header, 263, 2, "00");
148
+ writeField(header, 345, 155, fields.prefix);
149
+ const checksum = header.reduce((sum, byte) => sum + byte, 0);
150
+ writeField(header, 148, 8, `${checksum.toString(8).padStart(6, "0")}\0 `);
151
+ return header;
152
+ }
153
+
154
+ function writeTar(target, entries) {
155
+ const descriptor = fs.openSync(target, "w", 0o600);
156
+ try {
157
+ for (const entry of entries) {
158
+ fs.writeSync(descriptor, tarHeader(entry.path, entry.contents.length, entry.mode));
159
+ fs.writeSync(descriptor, entry.contents);
160
+ const remainder = entry.contents.length % 512;
161
+ if (remainder) fs.writeSync(descriptor, Buffer.alloc(512 - remainder));
162
+ }
163
+ fs.writeSync(descriptor, Buffer.alloc(1024));
164
+ } finally { fs.closeSync(descriptor); }
165
+ }
166
+
167
+ export function prepareProviderCheckpoint({ archivePath, manifestPath, outputPath, provider, mode, runId, sessionId }) {
168
+ const raw = fs.readFileSync(manifestPath, "utf8");
169
+ let manifest;
170
+ try { manifest = JSON.parse(raw); } catch { throw new Error("provider checkpoint manifest is not valid JSON"); }
171
+ if (
172
+ !exactKeys(manifest, MANIFEST_KEYS)
173
+ || manifest?.schemaVersion !== 1
174
+ || manifest.itemCount !== 1
175
+ || manifest.provider !== provider
176
+ || !PROVIDERS.has(manifest.provider)
177
+ || typeof manifest.providerVersion !== "string" || !manifest.providerVersion.trim()
178
+ || !SURFACES.has(manifest.sourceSurface)
179
+ || !SURFACES.has(manifest.destinationSurface)
180
+ || manifest.mode !== mode || !MODES.has(manifest.mode)
181
+ || (manifest.runId !== undefined && manifest.runId !== null && manifest.runId !== runId)
182
+ || !ID_RE.test(String(manifest.providerSessionId || ""))
183
+ || (manifest.providerThreadId !== undefined && !ID_RE.test(String(manifest.providerThreadId || "")))
184
+ || !isSessionsUUID(sessionId)
185
+ || manifest.impelSessionId !== sessionId
186
+ || typeof manifest.createdAt !== "string" || !Number.isFinite(Date.parse(manifest.createdAt))
187
+ || typeof manifest.closedAt !== "string" || !Number.isFinite(Date.parse(manifest.closedAt))
188
+ || Date.parse(manifest.closedAt) < Date.parse(manifest.createdAt)
189
+ || !exactKeys(manifest.quiescence, new Set(["proven", "mechanism"]))
190
+ || manifest.quiescence?.proven !== true
191
+ || !new Set(["provider_snapshot", "stopped_and_flushed", "fork_operation"]).has(manifest.quiescence?.mechanism)
192
+ || !Array.isArray(manifest.files) || manifest.files.length > MAX_FILES
193
+ || !SHA_RE.test(String(manifest.rootManifestHash || ""))
194
+ ) throw new Error("provider checkpoint is not a closed, versioned, quiesced exact checkpoint for this run");
195
+ const core = canonicalManifestCore(manifest);
196
+ if (sha256(Buffer.from(JSON.stringify(core), "utf8")) !== manifest.rootManifestHash) {
197
+ throw new Error("provider checkpoint root manifest hash mismatch");
198
+ }
199
+ const declared = manifest.files.map((file) => ({
200
+ path: safePath(file?.path),
201
+ length: file?.length,
202
+ sha256: file?.sha256,
203
+ }));
204
+ if (
205
+ manifest.files.some((file) => !exactKeys(file, new Set(["path", "length", "sha256"])))
206
+ || declared.some((file) => !Number.isSafeInteger(file.length) || file.length < 0 || file.length > MAX_FILE_BYTES || !SHA_RE.test(String(file.sha256 || "")))
207
+ || declared.reduce((total, file) => total + file.length, 0) > MAX_TOTAL_BYTES
208
+ ) {
209
+ throw new Error("provider checkpoint manifest contains invalid file metadata");
210
+ }
211
+ const archiveEntries = inspectTar(archivePath);
212
+ const embeddedManifest = archiveEntries.find((entry) => entry.path === "manifest.json");
213
+ if (embeddedManifest && embeddedManifest.contents.toString("utf8").trim() !== raw.trim()) {
214
+ throw new Error("provider checkpoint embedded manifest does not match the capture manifest");
215
+ }
216
+ const providerEntries = archiveEntries.filter((entry) => entry.path !== "manifest.json");
217
+ const actual = providerEntries.map(({ path: entryPath, length, sha256: entrySha }) => ({
218
+ path: entryPath, length, sha256: entrySha,
219
+ }));
220
+ if (JSON.stringify(actual) !== JSON.stringify(declared)) {
221
+ throw new Error("provider checkpoint archive does not match its manifest");
222
+ }
223
+ // Bind only the canonical manifest after the broker has assigned the run.
224
+ // Provider-native bytes are copied unchanged and rehashed above immediately
225
+ // before packing.
226
+ const boundCore = canonicalManifestCore(manifest, runId);
227
+ const boundManifestHash = sha256(Buffer.from(JSON.stringify(boundCore), "utf8"));
228
+ const boundManifest = { ...boundCore, rootManifestHash: boundManifestHash };
229
+ const boundManifestBytes = Buffer.from(`${JSON.stringify(boundManifest, null, 2)}\n`, "utf8");
230
+ writeTar(outputPath, [
231
+ { path: "manifest.json", contents: boundManifestBytes, mode: 0o600 },
232
+ ...providerEntries.map((entry) => ({ path: entry.path, contents: entry.contents, mode: entry.mode })),
233
+ ]);
234
+ const stat = fs.statSync(outputPath);
235
+ return {
236
+ path: outputPath,
237
+ length: stat.size,
238
+ sha256: sha256File(outputPath),
239
+ manifestHash: boundManifestHash,
240
+ manifest: boundManifest,
241
+ };
242
+ }
243
+
244
+ export const validateProviderCheckpoint = prepareProviderCheckpoint;
245
+
246
+ export function exactCheckpointRequiredMessage(provider) {
247
+ return `${provider} fork requires a closed, version-matched provider checkpoint produced by a supported snapshot/fork operation; this CLI will not treat mirrored open provider files as exact. Rerun with --mode portable for an explicit non-exact transfer.`;
248
+ }
@@ -0,0 +1,89 @@
1
+ export const REMOTE_CAPABILITY_SCHEMA_VERSION = 1;
2
+ export const REMOTE_MODES = Object.freeze(["native", "fork", "portable"]);
3
+ export const REMOTE_PROVIDERS = Object.freeze(["codex", "claude"]);
4
+ export const REMOTE_SOURCE_SURFACES = Object.freeze(["desktop", "cli", "app_server"]);
5
+
6
+ const MODE_SET = new Set(REMOTE_MODES);
7
+ const PROVIDER_SET = new Set(REMOTE_PROVIDERS);
8
+ const SURFACE_SET = new Set(REMOTE_SOURCE_SURFACES);
9
+ const REASON_CODE_RE = /^[A-Z][A-Z0-9_]{0,127}$/u;
10
+
11
+ export function normalizeRemoteMode(value) {
12
+ const mode = String(value || "").trim().toLowerCase();
13
+ if (!MODE_SET.has(mode)) {
14
+ throw new Error("--mode must be native, fork, or portable");
15
+ }
16
+ return mode;
17
+ }
18
+
19
+ export function normalizeRemoteProvider(value) {
20
+ const provider = String(value || "").trim().toLowerCase();
21
+ if (!PROVIDER_SET.has(provider)) throw new Error("--provider must be codex or claude");
22
+ return provider;
23
+ }
24
+
25
+ export function normalizeSourceSurface(value) {
26
+ const surface = String(value || "").trim().toLowerCase().replaceAll("-", "_");
27
+ if (!SURFACE_SET.has(surface)) throw new Error("--source must be desktop, cli, or app-server");
28
+ return surface;
29
+ }
30
+
31
+ export function assertRemoteCommandContract({ action, provider, mode, sourceSurface }) {
32
+ const normalizedProvider = normalizeRemoteProvider(provider);
33
+ const normalizedMode = normalizeRemoteMode(mode);
34
+ const normalizedSurface = normalizeSourceSurface(sourceSurface);
35
+ if (action === "handoff" && normalizedMode !== "native") {
36
+ throw new Error("handoff requires --mode native; use `impel remote dispatch` for fork or portable");
37
+ }
38
+ if (action === "dispatch" && !new Set(["fork", "portable"]).has(normalizedMode)) {
39
+ throw new Error("dispatch requires --mode fork or --mode portable; use `impel remote handoff` for native");
40
+ }
41
+ if (normalizedMode === "native" && normalizedProvider === "claude") {
42
+ throw new Error("Claude through the Impel gateway does not support native handoff; use --mode fork or --mode portable");
43
+ }
44
+ if (normalizedMode === "native" && normalizedSurface === "app_server") {
45
+ throw new Error("Codex App Server does not support Desktop native handoff; use --mode fork or --mode portable");
46
+ }
47
+ return {
48
+ action,
49
+ provider: normalizedProvider,
50
+ mode: normalizedMode,
51
+ sourceSurface: normalizedSurface,
52
+ };
53
+ }
54
+
55
+ export function normalizeCapabilityResponse(value, expected) {
56
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
57
+ throw new Error("broker returned an invalid remote capability response");
58
+ }
59
+ const alternatives = value.alternatives;
60
+ if (
61
+ value.schemaVersion !== REMOTE_CAPABILITY_SCHEMA_VERSION
62
+ || value.provider !== expected.provider
63
+ || value.sourceSurface !== expected.sourceSurface
64
+ || value.requestedMode !== expected.mode
65
+ || typeof value.supported !== "boolean"
66
+ || !Array.isArray(alternatives)
67
+ || alternatives.some((mode) => !MODE_SET.has(mode))
68
+ || (value.reasonCode !== null && value.reasonCode !== undefined
69
+ && (typeof value.reasonCode !== "string" || !REASON_CODE_RE.test(value.reasonCode)))
70
+ ) {
71
+ throw new Error("broker returned a mismatched or unsupported remote capability contract");
72
+ }
73
+ return Object.freeze({
74
+ schemaVersion: REMOTE_CAPABILITY_SCHEMA_VERSION,
75
+ provider: value.provider,
76
+ sourceSurface: value.sourceSurface,
77
+ requestedMode: value.requestedMode,
78
+ supported: value.supported,
79
+ alternatives: Object.freeze([...new Set(alternatives)]),
80
+ reasonCode: value.reasonCode || null,
81
+ });
82
+ }
83
+
84
+ export function capabilityFailureMessage(capability) {
85
+ const alternatives = capability.alternatives.length
86
+ ? ` Available explicit alternatives: ${capability.alternatives.join(", ")}.`
87
+ : "";
88
+ return `The broker does not support ${capability.provider} ${capability.requestedMode} from ${capability.sourceSurface} (${capability.reasonCode || "CAPABILITY_NOT_ENABLED"}).${alternatives}`;
89
+ }
@@ -12,7 +12,7 @@ export const REMOTE_ROOT = process.env.IMPEL_REMOTE_STATE_DIR || path.join(CONFI
12
12
  export const REMOTE_RUNS_DIR = path.join(REMOTE_ROOT, "runs");
13
13
  export const SSH_CONFIG_PATH = process.env.IMPEL_REMOTE_SSH_CONFIG || path.join(os.homedir(), ".ssh", "config");
14
14
 
15
- const RUN_ID_RE = /^run_[a-z0-9]{16}$/u;
15
+ const RUN_ID_RE = /^run_[A-Za-z0-9_-]{8,128}$/u;
16
16
  const SSH_START_PREFIX = "# >>> impel remote ";
17
17
  const SSH_END_PREFIX = "# <<< impel remote ";
18
18
 
@@ -74,6 +74,8 @@ export function runPaths(runId) {
74
74
  fileList: path.join(root, "files.list"),
75
75
  archive: path.join(root, "working-tree.tar"),
76
76
  deletedList: path.join(root, "deleted.list"),
77
+ workspaceRoot: path.join(root, "workspace"),
78
+ providerCheckpoint: path.join(root, "provider-checkpoint.tar"),
77
79
  };
78
80
  }
79
81
 
@@ -196,7 +198,17 @@ export function removeRunSecrets(runId) {
196
198
  paths.fileList,
197
199
  paths.archive,
198
200
  paths.deletedList,
201
+ paths.providerCheckpoint,
199
202
  ]) {
200
203
  try { fs.rmSync(target, { force: true }); } catch { /* Best-effort local cleanup. */ }
201
204
  }
205
+ try { fs.rmSync(paths.workspaceRoot, { recursive: true, force: true }); } catch { /* Best-effort local cleanup. */ }
206
+ }
207
+
208
+ export function removeTransferArtifacts(runId) {
209
+ const paths = runPaths(runId);
210
+ for (const target of [paths.bundle, paths.fileList, paths.archive, paths.deletedList, paths.providerCheckpoint]) {
211
+ try { fs.rmSync(target, { force: true }); } catch { /* Best-effort local cleanup. */ }
212
+ }
213
+ try { fs.rmSync(paths.workspaceRoot, { recursive: true, force: true }); } catch { /* Best-effort local cleanup. */ }
202
214
  }
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { REMOTE_ROOT, validateRunId } from "./state.js";
5
+ import { normalizeTenantId } from "../tenants.js";
6
+
7
+ const TELEMETRY_PATH = process.env.IMPEL_REMOTE_TELEMETRY_PATH
8
+ || path.join(REMOTE_ROOT, "telemetry.jsonl");
9
+
10
+ /**
11
+ * The rollback metric deliberately has a closed schema. Never pass arbitrary
12
+ * command options here: they can contain repository paths, prompts, provider
13
+ * payloads, environment values, or credentials.
14
+ */
15
+ export function recordPortableLegacyInvocation({ tenantId, runId, now = new Date() }) {
16
+ const record = {
17
+ schemaVersion: 1,
18
+ counter: "remote.portable_legacy.invocation",
19
+ tenantId: normalizeTenantId(tenantId),
20
+ runId: validateRunId(runId),
21
+ recordedAt: now.toISOString(),
22
+ };
23
+ fs.mkdirSync(path.dirname(TELEMETRY_PATH), { recursive: true, mode: 0o700 });
24
+ fs.appendFileSync(TELEMETRY_PATH, `${JSON.stringify(record)}\n`, { mode: 0o600 });
25
+ try { fs.chmodSync(TELEMETRY_PATH, 0o600); } catch { /* Best effort on Windows. */ }
26
+ return record;
27
+ }
28
+
29
+ export const PORTABLE_LEGACY_TELEMETRY_PATH = TELEMETRY_PATH;
@@ -6,6 +6,11 @@ import { redactSecretText } from "../config.js";
6
6
  import { runBuffer, runCapture, runInteractive, sleep } from "./process.js";
7
7
  import { runPaths } from "./state.js";
8
8
 
9
+ // LEGACY ONLY: this module's SSH upload and PAT-bearing remote configuration
10
+ // are isolated to portable-legacy. Broker-backed modes use immutable workspace
11
+ // artifacts and opaque provider-checkpoint uploads instead.
12
+ export const LEGACY_SSH_TRANSFER_ONLY = true;
13
+
9
14
  const REMOTE_REPO = "/workspace/repo";
10
15
  const REMOTE_BUNDLE = "/tmp/impel-remote-repo.bundle";
11
16
  const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u;
@@ -52,6 +57,8 @@ export function inspectRepository(requestedPath = process.cwd()) {
52
57
  const requested = path.resolve(requestedPath);
53
58
  const candidate = fs.realpathSync(requested);
54
59
  const root = fs.realpathSync(git(["-C", candidate, "rev-parse", "--show-toplevel"]));
60
+ const gitPrefix = git(["-C", candidate, "rev-parse", "--show-prefix"])
61
+ .replaceAll("\\", "/").replace(/\/+$/u, "");
55
62
  const head = git(["-C", root, "rev-parse", "HEAD"]);
56
63
  const branchResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
57
64
  "-C", root, "symbolic-ref", "--quiet", "--short", "HEAD",
@@ -65,10 +72,11 @@ export function inspectRepository(requestedPath = process.cwd()) {
65
72
  const userEmailResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
66
73
  "-C", root, "config", "--get", "user.email",
67
74
  ], { allowFailure: true });
68
- const relativeProjectPath = path.relative(root, candidate) || ".";
69
- if (relativeProjectPath === ".." || relativeProjectPath.startsWith(`..${path.sep}`)) {
70
- throw new Error(`${candidate} is not inside its Git repository`);
71
- }
75
+ // Git is the authority for worktree membership and the path relative to its
76
+ // root. Comparing canonical filesystem strings is not reliable on Windows:
77
+ // Node can preserve an 8.3 path such as RUNNER~1 while Git returns the long
78
+ // equivalent, making the same directory appear to be outside the worktree.
79
+ const relativeProjectPath = gitPrefix || ".";
72
80
  return {
73
81
  root,
74
82
  head,
@@ -76,10 +84,10 @@ export function inspectRepository(requestedPath = process.cwd()) {
76
84
  origin: originResult.status === 0 ? originResult.stdout.trim() : null,
77
85
  userName: userNameResult.status === 0 ? userNameResult.stdout.trim() : null,
78
86
  userEmail: userEmailResult.status === 0 ? userEmailResult.stdout.trim() : null,
79
- relativeProjectPath: relativeProjectPath.split(path.sep).join("/"),
87
+ relativeProjectPath,
80
88
  remoteProjectPath: relativeProjectPath === "."
81
89
  ? REMOTE_REPO
82
- : `${REMOTE_REPO}/${relativeProjectPath.split(path.sep).join("/")}`,
90
+ : `${REMOTE_REPO}/${relativeProjectPath}`,
83
91
  };
84
92
  }
85
93