sortie-dogs 0.14.2 → 0.14.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.
package/README.md CHANGED
@@ -19,8 +19,8 @@ evidence-backed result, not more agents for their own sake.
19
19
  [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
20
20
  [![MIT License](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
21
21
 
22
- **Current release: [v0.14.2](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.14.2)**
23
- · [Release notes](docs/release-v0.14.2.md) · [Execution vision](docs/v0.14.0-execution-vision.md)
22
+ **Current release: [v0.14.4](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.14.4)**
23
+ · [Release notes](docs/release-v0.14.4.md) · [Execution vision](docs/v0.14.0-execution-vision.md)
24
24
 
25
25
  [Quick start](#quick-start) · [Workflow](#mission-workflow) · [Models](#default-models) ·
26
26
  [Measured results](#measured-results) · [Configuration](#configuration) · [Documentation](#documentation)
@@ -57,7 +57,7 @@ Use an existing Codex CLI signed in with **ChatGPT**. On Windows, PowerShell **7
57
57
  available; POSIX uses bash. Sortie does not install Codex, start login or use a metered API fallback.
58
58
 
59
59
  ```sh
60
- npm install --save-dev sortie-dogs@0.14.2
60
+ npm install --save-dev sortie-dogs@0.14.4
61
61
  npx --no-install sortie-dogs codex init .
62
62
  ```
63
63
 
@@ -86,7 +86,7 @@ bridge. [Codex guide: SDK, permissions, progress, resume and manifest route](doc
86
86
  Use **OpenCode V2**. In the target project:
87
87
 
88
88
  ```sh
89
- npm install --save-dev sortie-dogs@0.14.2
89
+ npm install --save-dev sortie-dogs@0.14.4
90
90
  npx --no-install sortie-dogs init .
91
91
  ```
92
92
 
@@ -242,7 +242,7 @@ Install the desired package version and rerun **your host's** initializer:
242
242
 
243
243
  An OpenCode config-local bridge may resolve a separate package; npm-global update alone does not
244
244
  update it. [Global/update instructions](docs/configuration.md#global-availability).
245
- The current Mission asset marker is `0.14.2-cold-resume-v1`; the unchanged Codex skill marker is
245
+ The current Mission asset marker is `0.14.4-docker-read-v1`; the unchanged Codex skill marker is
246
246
  `0.13.11-codex-skill-v2`. Installed markers alone do not prove a running host reloaded the new version.
247
247
 
248
248
  There is no supported uninstall command. Remove the dependency and only known Sortie-owned paths;
@@ -258,7 +258,7 @@ follow the [manual removal guide](docs/uninstall.md), never delete the entire `.
258
258
  [SWE-bench history](docs/benchmarks/swebench-lite-history.md) · [Historical local case study](docs/benchmark-reference.md).
259
259
  - **Development:** [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md) ·
260
260
  [Windows tests](docs/windows-tests.md) · [Release routine](docs/release-batch.md).
261
- - **Releases:** [v0.14.2](docs/release-v0.14.2.md) · [v0.14.1](docs/release-v0.14.1.md) · [v0.14.0](docs/release-v0.14.0.md) · [v0.13.22](docs/release-v0.13.22.md) · [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
261
+ - **Releases:** [v0.14.4](docs/release-v0.14.4.md) · [v0.14.3](docs/release-v0.14.3.md) · [v0.14.2](docs/release-v0.14.2.md) · [v0.14.1](docs/release-v0.14.1.md) · [v0.14.0](docs/release-v0.14.0.md) · [v0.13.22](docs/release-v0.13.22.md) · [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
262
262
  [v0.13.9](docs/release-v0.13.9.md) · [All GitHub releases](https://github.com/zufall-upon/Sortie-dogs/releases).
263
263
 
264
264
  ## Community
@@ -3,6 +3,6 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.14.2-cold-resume-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.14.4-docker-read-v1";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
8
8
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION | typeof CODEX_SKILL_ASSET_VERSION;
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.14.2-cold-resume-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.14.4-docker-read-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
@@ -45,6 +45,16 @@ export interface GoalEvidence {
45
45
  /** Non-generating native validation pins inputs/concrete outputs, not write-only directory bookkeeping.
46
46
  * Missing on old evidence: retain its full source/output recipe. */
47
47
  readonly validation_policy?: "inputs-and-concrete-outputs-v1";
48
+ /** Snapshot and recheck the exact Git workspace inside one validated Docker task container. */
49
+ readonly docker_workspace?: {
50
+ readonly container_id: string;
51
+ readonly cwd: string;
52
+ /** Physical Docker CWD for a declared logical alias; absent on legacy/non-alias proof. */
53
+ readonly actual_cwd?: string;
54
+ readonly repository_root: string;
55
+ readonly source: string;
56
+ readonly candidate: string;
57
+ };
48
58
  /** Fixed when validation starts. Full manifest_hash still identifies the historical execution contract. */
49
59
  readonly freshness?: {
50
60
  readonly contract_hash: string;
@@ -59,6 +59,7 @@ export function validGoalEvidence(value, state) {
59
59
  return false;
60
60
  const criteria = state.acceptance_contract?.criteria.filter(({ criterion_id }) => value.measurement.criterion_ids.includes(criterion_id)) ?? [];
61
61
  const protectedBinding = value.protected_binding;
62
+ const dockerWorkspace = protectedBinding?.docker_workspace;
62
63
  const protectedBindingValid = protectedBinding !== undefined && HASH.test(protectedBinding.manifest_hash) &&
63
64
  text(protectedBinding.project_root) && text(protectedBinding.manifest_path) && Array.isArray(protectedBinding.source_paths) &&
64
65
  protectedBinding.source_paths.every(text) && Array.isArray(protectedBinding.candidate_paths) &&
@@ -67,6 +68,11 @@ export function validGoalEvidence(value, state) {
67
68
  protectedBinding.source_policy === "declared-paths-v1") &&
68
69
  (protectedBinding.candidate_policy === undefined || protectedBinding.candidate_policy === "project-root-artifacts-v1") &&
69
70
  (protectedBinding.validation_policy === undefined || protectedBinding.validation_policy === "inputs-and-concrete-outputs-v1") &&
71
+ (dockerWorkspace === undefined || (typeof dockerWorkspace.container_id === "string" && /^[a-f0-9]{12,64}$/u.test(dockerWorkspace.container_id) &&
72
+ typeof dockerWorkspace.cwd === "string" && dockerWorkspace.cwd.startsWith("/") &&
73
+ (dockerWorkspace.actual_cwd === undefined || typeof dockerWorkspace.actual_cwd === "string" && dockerWorkspace.actual_cwd.startsWith("/")) &&
74
+ typeof dockerWorkspace.repository_root === "string" && dockerWorkspace.repository_root.startsWith("/") &&
75
+ HASH.test(dockerWorkspace.source) && HASH.test(dockerWorkspace.candidate))) &&
70
76
  (protectedBinding.freshness === undefined || (protectedBinding.freshness !== null && typeof protectedBinding.freshness === "object" &&
71
77
  HASH.test(protectedBinding.freshness.contract_hash) &&
72
78
  Array.isArray(protectedBinding.freshness.scratch_paths) && protectedBinding.freshness.scratch_paths.every(text) &&
@@ -89,10 +95,10 @@ export function validGoalEvidence(value, state) {
89
95
  sameStrings(criterion.oracle_coverage, value.measurement.oracle_coverage) &&
90
96
  criterion.build_boundary === value.measurement.build_boundary &&
91
97
  (criterion.source_binding === "current-protected"
92
- ? protectedBindingValid && HASH.test(value.identity.source)
98
+ ? protectedBindingValid && HASH.test(value.identity.source) && (dockerWorkspace === undefined || dockerWorkspace.source === value.identity.source)
93
99
  : criterion.source === value.identity.source) &&
94
100
  (criterion.candidate_binding === "current-protected"
95
- ? protectedBindingValid && HASH.test(value.identity.candidate)
101
+ ? protectedBindingValid && HASH.test(value.identity.candidate) && (dockerWorkspace === undefined || dockerWorkspace.candidate === value.identity.candidate)
96
102
  : criterion.candidate === value.identity.candidate) &&
97
103
  (criterion.validation_command === undefined ||
98
104
  (value.execution.command.length === 1 && value.execution.command[0] === criterion.validation_command)) &&
@@ -1344,7 +1344,9 @@ export class OperatorRuntime {
1344
1344
  }
1345
1345
  if (unit.reviewerCorrection ? args.task_id !== unit.reviewerCorrection.author : args.task_id !== undefined && args.task_id !== "")
1346
1346
  return false;
1347
- if (args.subagent_type !== unit.task.subagent_type || args.description !== unit.task.description)
1347
+ // The caller's display label does not change the retained task or its signed
1348
+ // reference. Keep agent, continuation identity and exact prompt authoritative.
1349
+ if (args.subagent_type !== unit.task.subagent_type || typeof args.description !== "string")
1348
1350
  return false;
1349
1351
  return args.prompt === unit.task.prompt || args.prompt === this.workerTask(state, unit).prompt;
1350
1352
  }
@@ -0,0 +1,17 @@
1
+ export interface DockerWorkspaceSnapshot {
2
+ readonly container_id: string;
3
+ /** Declared Docker --workdir; keep its logical alias for validation matching. */
4
+ readonly cwd: string;
5
+ /** Physical CWD when Docker resolves the declared path through an alias. */
6
+ readonly actual_cwd?: string;
7
+ readonly repository_root: string;
8
+ readonly source: string;
9
+ readonly candidate: string;
10
+ }
11
+ /** Hash the fixed container's actual workspace, including non-Git and ignored inputs. */
12
+ export declare function captureDockerWorkspaceSnapshot(containerID: string, cwd: string): Promise<DockerWorkspaceSnapshot | undefined>;
13
+ export declare function isDockerContainerID(value: unknown): value is string;
14
+ export declare function isDockerWorkspacePath(value: unknown): value is string;
15
+ /** Inspect bytes, never execute caller-supplied code. The Review envelope's
16
+ * immutable Docker candidate must match both before and after the read. */
17
+ export declare function readDockerWorkspaceFile(containerID: string, cwd: string, candidate: string, path: string, offset?: number, limit?: number): Promise<Record<string, unknown>>;
@@ -0,0 +1,278 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { posix } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { readDockerWorkspaceWithoutPython } from "./docker-workspace-read-fallback.js";
6
+ const exec = promisify(execFile);
7
+ const CONTAINER_ID = /^[a-f0-9]{12,64}$/u;
8
+ const SNAPSHOT_MARKER = "SORTIE_DOCKER_WORKSPACE_V1";
9
+ // Terminal tasks need not contain Git at all. Capture actual bytes and file
10
+ // identity, including ignored inputs; timestamps alone are not candidate edits.
11
+ const FILE_SNAPSHOT = String.raw `
12
+ import errno
13
+ import hashlib, json, os, stat, subprocess
14
+ cwd = os.path.realpath(os.getcwd())
15
+ root = cwd
16
+ try:
17
+ # Remove Git's terminating LF, not meaningful whitespace in the root path.
18
+ root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL).decode("utf-8")[:-1]
19
+ head = subprocess.check_output(["git", "rev-parse", "--verify", "HEAD^{tree}"], stderr=subprocess.DEVNULL, text=True).strip()
20
+ except (OSError, subprocess.CalledProcessError):
21
+ # An unborn repository still has a root; missing HEAD must not narrow it.
22
+ head = "no-git"
23
+ root = os.path.realpath(root)
24
+ rows = []
25
+ def digest(path):
26
+ h = hashlib.sha256()
27
+ with open(path, "rb") as stream:
28
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
29
+ h.update(block)
30
+ return h.hexdigest()
31
+ for directory, dirs, files in os.walk(root, followlinks=False, onerror=lambda error: (_ for _ in ()).throw(error)):
32
+ if directory == root:
33
+ dirs[:] = [name for name in dirs if name != ".git"]
34
+ files = [name for name in files if name != ".git"]
35
+ for name in [".", *dirs, *files]:
36
+ path = directory if name == "." else os.path.join(directory, name)
37
+ relative = os.path.relpath(path, root)
38
+ info = os.lstat(path)
39
+ mode = stat.S_IMODE(info.st_mode)
40
+ if stat.S_ISREG(info.st_mode):
41
+ value = [relative, "file", mode, digest(path)]
42
+ elif stat.S_ISLNK(info.st_mode):
43
+ target = os.path.realpath(path)
44
+ # Missing/cyclic targets retain their literal link identity. Do not
45
+ # let isfile/isdir turn permission or I/O errors into missing input.
46
+ try:
47
+ referent = os.stat(path)
48
+ except OSError as error:
49
+ if error.errno not in (errno.ENOENT, errno.ENOTDIR, errno.ELOOP):
50
+ raise
51
+ referent = None
52
+ is_file = referent is not None and stat.S_ISREG(referent.st_mode)
53
+ is_directory = referent is not None and stat.S_ISDIR(referent.st_mode)
54
+ if referent is not None and not (is_file or is_directory):
55
+ raise RuntimeError("unsupported-workspace-file-type")
56
+ # Internal directory targets are captured at their own lexical path.
57
+ # External directory trees are not silently omitted from a proof.
58
+ if is_directory and os.path.commonpath([root, target]) != root:
59
+ raise RuntimeError("external-directory-link-snapshot-unavailable")
60
+ value = [relative, "link", mode, os.readlink(path), digest(path) if is_file else None]
61
+ if is_file or is_directory:
62
+ value.extend([target, stat.S_IMODE(referent.st_mode)])
63
+ elif stat.S_ISDIR(info.st_mode):
64
+ value = [relative, "directory", mode]
65
+ else:
66
+ raise RuntimeError("unsupported-workspace-file-type")
67
+ rows.append(value)
68
+ tree = hashlib.sha256(json.dumps(sorted(rows), ensure_ascii=True, separators=(",", ":")).encode()).hexdigest()
69
+ print("SORTIE_DOCKER_WORKSPACE_V1")
70
+ print(cwd)
71
+ print(root)
72
+ print(tree)
73
+ print(hashlib.sha256(head.encode()).hexdigest())
74
+ print("0" * 64)
75
+ `;
76
+ const SNAPSHOT_COMMAND = [
77
+ "set -eu",
78
+ "if command -v python3 >/dev/null 2>&1; then",
79
+ "python3 -I -S - <<'SORTIE_WORKSPACE_PY'",
80
+ FILE_SNAPSHOT,
81
+ "SORTIE_WORKSPACE_PY",
82
+ "else",
83
+ "actual_cwd=$(pwd -P)",
84
+ "root=$(git rev-parse --show-toplevel)",
85
+ "cd \"$root\"",
86
+ "tree=$(git rev-parse --verify 'HEAD^{tree}')",
87
+ // Capture successful producer output before hashing: POSIX pipelines otherwise
88
+ // hide failed Git/file reads behind a successful hash of empty/partial data.
89
+ "tracked=$(git diff --binary HEAD -- && printf '.')",
90
+ "tracked=${tracked%.}",
91
+ "tracked=$(printf '%s' \"$tracked\" | sha256sum)",
92
+ "tracked=${tracked%% *}",
93
+ // A NUL on enumeration failure is an invalid empty filename, so xargs fails
94
+ // rather than treating a failed Git listing as an empty untracked set.
95
+ // An untracked internal directory alias is already covered at its target.
96
+ // Filter only that case before the batched hash; retain all producer failures.
97
+ "untracked_bytes=$({",
98
+ "{ git ls-files -z --others || printf '\\0'; } | xargs -0 -r sh -c '",
99
+ "root=$1; shift",
100
+ "for path do",
101
+ " [ -n \"$path\" ] || exit 1",
102
+ " if [ -L \"$path\" ] && [ -d \"$path\" ]; then",
103
+ " target=$(readlink -f -- \"$path\") || exit 1",
104
+ " case \"$target\" in \"$root\"|\"$root\"/*) : ;; *) exit 1 ;; esac",
105
+ " else printf \"%s\\0\" \"$path\"",
106
+ " fi",
107
+ "done' sh \"$root\" || printf '\\0'",
108
+ "} | xargs -0 -r sha256sum --)",
109
+ // Git may ignore tracked executable-bit changes (core.filemode=false).
110
+ // Observe workspace modes/links independently, excluding only Git bookkeeping.
111
+ "workspace_modes=$({ find . -path ./.git -prune -o -print0 || printf '\\0'; } | LC_ALL=C QUOTING_STYLE=c xargs -0 -r stat -c '%f %N' --)",
112
+ "workspace_modes=$(printf '%s' \"$workspace_modes\" | LC_ALL=C sort)",
113
+ // Index visibility flags and Git's stat cache can hide changed tracked bytes.
114
+ // Observe actual regular files independently, with stable filename/hash pairs.
115
+ "workspace_bytes=$({ find . -path ./.git -prune -o -type f -print0 || printf '\\0'; } | xargs -0 -r sha256sum --)",
116
+ "workspace_bytes=$(printf '%s' \"$workspace_bytes\" | LC_ALL=C sort)",
117
+ // Git records a tracked link's text, not the regular-file bytes it reads.
118
+ "link_bytes=$({ find . -path ./.git -prune -o -type l -print0 || printf '\\0'; } | xargs -0 -r sh -c '",
119
+ "root=$1; shift",
120
+ "for path do",
121
+ " [ -n \"$path\" ] || exit 1",
122
+ // find's target type distinguishes known missing/cyclic links (N/L) from
123
+ // failed observations; shell -f/-d predicates hide permission and I/O errors.
124
+ " kind=$(find \"$path\" -maxdepth 0 -printf \"%Y\") || exit 1",
125
+ " case \"$kind\" in",
126
+ " f) sha256sum -- \"$path\" || exit 1 ;;",
127
+ " d) : ;;",
128
+ " N|L) continue ;;",
129
+ " *) exit 1 ;;",
130
+ " esac",
131
+ // Pair the lexical link with its resolved target/mode before hashing. Keeping
132
+ // each pair together prevents swapping equal-byte targets between two links.
133
+ " target=$(readlink -fn -- \"$path\" && printf .) || exit 1",
134
+ " target=${target%.}",
135
+ " if [ \"$kind\" = d ]; then",
136
+ " case \"$target\" in \"$root\"|\"$root\"/*) : ;; *) exit 1 ;; esac",
137
+ " fi",
138
+ " referent=$(LC_ALL=C QUOTING_STYLE=c stat -L -c \"%f %N\" -- \"$path\" \"$target\") || exit 1",
139
+ " printf \"%s\" \"$referent\" | sha256sum || exit 1",
140
+ "done' sh \"$root\")",
141
+ "link_bytes=$(printf '%s' \"$link_bytes\" | LC_ALL=C sort)",
142
+ "if [ -n \"$link_bytes\" ]; then",
143
+ "untracked=$(printf '%s\\0%s\\0%s\\0%s' \"$untracked_bytes\" \"$workspace_modes\" \"$workspace_bytes\" \"$link_bytes\" | sha256sum)",
144
+ "else",
145
+ "untracked=$(printf '%s\\0%s\\0%s' \"$untracked_bytes\" \"$workspace_modes\" \"$workspace_bytes\" | sha256sum)",
146
+ "fi",
147
+ "untracked=${untracked%% *}",
148
+ "printf '%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n' 'SORTIE_DOCKER_WORKSPACE_V1' \"$actual_cwd\" \"$root\" \"$tree\" \"$tracked\" \"$untracked\"",
149
+ "fi",
150
+ ].join("\n");
151
+ /** Hash the fixed container's actual workspace, including non-Git and ignored inputs. */
152
+ export async function captureDockerWorkspaceSnapshot(containerID, cwd) {
153
+ if (!CONTAINER_ID.test(containerID) || !posix.isAbsolute(cwd) || posix.normalize(cwd) !== cwd || cwd.includes("\0"))
154
+ return undefined;
155
+ try {
156
+ const result = await exec("docker", ["exec", "--workdir", cwd, containerID, "sh", "-lc", SNAPSHOT_COMMAND], {
157
+ encoding: "buffer", timeout: 30_000, maxBuffer: 64 * 1024 * 1024,
158
+ });
159
+ const output = result.stdout;
160
+ let offset = 0;
161
+ const line = () => {
162
+ const end = output.indexOf(0x0a, offset);
163
+ if (end < 0)
164
+ return undefined;
165
+ const value = output.subarray(offset, end).toString("utf8");
166
+ offset = end + 1;
167
+ return value;
168
+ };
169
+ if (line() !== SNAPSHOT_MARKER)
170
+ return undefined;
171
+ const actualCwd = line(), repositoryRoot = line(), tree = line(), tracked = line(), untracked = line();
172
+ if (!actualCwd || !isDockerWorkspacePath(actualCwd) || !repositoryRoot || !posix.isAbsolute(repositoryRoot) || !tree || !/^[a-f0-9]{40,64}$/u.test(tree) ||
173
+ !tracked || !/^[a-f0-9]{64}$/u.test(tracked) || !untracked || !/^[a-f0-9]{64}$/u.test(untracked) || offset !== output.length)
174
+ return undefined;
175
+ const source = `sha256:${createHash("sha256").update(`git-tree\0${repositoryRoot}\0${tree}`).digest("hex")}`;
176
+ // Preserve existing non-alias identities. For aliases bind the real execution
177
+ // directory too: retargeting within the same repository must stale the proof.
178
+ const physicalCwd = actualCwd === cwd ? [] : [actualCwd];
179
+ const candidate = `sha256:${createHash("sha256").update(JSON.stringify([containerID, cwd, repositoryRoot, tree, tracked, untracked, ...physicalCwd])).digest("hex")}`;
180
+ return { container_id: containerID, cwd, ...(actualCwd === cwd ? {} : { actual_cwd: actualCwd }),
181
+ repository_root: repositoryRoot, source, candidate };
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ }
187
+ export function isDockerContainerID(value) {
188
+ return typeof value === "string" && CONTAINER_ID.test(value);
189
+ }
190
+ export function isDockerWorkspacePath(value) {
191
+ return typeof value === "string" && value.length > 0 && posix.isAbsolute(value) &&
192
+ posix.normalize(value) === value && !value.includes("\0");
193
+ }
194
+ const READ_WORKSPACE = String.raw `
195
+ import hashlib, json, os, stat, sys
196
+ root, requested, offset, limit = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
197
+ requested_path = os.path.join(root, requested)
198
+ path = os.path.realpath(requested_path)
199
+ if os.path.commonpath([root, path]) != root:
200
+ # Admit only the actual candidate entry and its bound regular-file target.
201
+ # A canceled outside-directory hop can normalize inside root yet reach a
202
+ # different link; file/../ hops must not create an entry either. Resolve the
203
+ # parent to exclude root Git bookkeeping even through directory aliases.
204
+ lexical = os.path.abspath(requested_path)
205
+ entry = os.path.join(os.path.realpath(os.path.dirname(lexical)), os.path.basename(lexical))
206
+ if (os.path.commonpath([root, lexical]) != root or os.path.commonpath([root, entry]) != root or
207
+ os.path.relpath(entry, root).split(os.sep)[0] == ".git" or not os.path.islink(entry) or
208
+ os.path.realpath(entry) != path or not os.path.islink(requested_path) or
209
+ not stat.S_ISREG(os.stat(entry).st_mode)):
210
+ raise ValueError("file-outside-candidate-workspace")
211
+ if os.path.isdir(path):
212
+ entries = sorted(os.listdir(path))
213
+ result = {"kind": "directory", "entries": entries[offset - 1:offset - 1 + limit], "total": len(entries)}
214
+ elif stat.S_ISREG(os.stat(path).st_mode):
215
+ h = hashlib.sha256()
216
+ lines, total, size = [], 0, 0
217
+ excerpt, pending = bytearray(), False
218
+ with open(path, "rb") as stream:
219
+ # Bound allocations even when a logical line is a large generated file.
220
+ for block in iter(lambda: stream.readline(64000), b""):
221
+ h.update(block)
222
+ pending = True
223
+ if offset <= total + 1 < offset + limit and size < 64000:
224
+ retained = block[:64000-size]
225
+ excerpt.extend(retained)
226
+ size += len(retained)
227
+ if block.endswith(b"\n"):
228
+ total += 1
229
+ if excerpt:
230
+ lines.append([total, excerpt.decode("utf-8", errors="replace")])
231
+ excerpt.clear()
232
+ pending = False
233
+ if pending:
234
+ total += 1
235
+ if excerpt:
236
+ lines.append([total, excerpt.decode("utf-8", errors="replace")])
237
+ result = {"kind": "file", "lines": lines, "total_lines": total, "sha256": h.hexdigest()}
238
+ else:
239
+ raise ValueError("candidate-file-not-readable")
240
+ print(json.dumps({"path": path, **result}, ensure_ascii=True))
241
+ `;
242
+ /** Inspect bytes, never execute caller-supplied code. The Review envelope's
243
+ * immutable Docker candidate must match both before and after the read. */
244
+ export async function readDockerWorkspaceFile(containerID, cwd, candidate, path, offset = 1, limit = 200) {
245
+ if (!/^sha256:[a-f0-9]{64}$/u.test(candidate) || typeof path !== "string" || path.includes("\0") ||
246
+ !Number.isSafeInteger(offset) || offset < 1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 2000) {
247
+ throw new Error("docker-candidate-read-invalid");
248
+ }
249
+ const before = await captureDockerWorkspaceSnapshot(containerID, cwd);
250
+ if (!before || before.candidate !== candidate)
251
+ throw new Error("docker-review-candidate-stale-or-unavailable");
252
+ // An admitted 2000-name page (255-byte names) can expand sixfold in JSON;
253
+ // the 64 KB file excerpt also needs escaping headroom. Keep the read limits.
254
+ let result;
255
+ try {
256
+ const { stdout } = await exec("docker", ["exec", "--workdir", cwd, containerID, "python3", "-I", "-S", "-c", READ_WORKSPACE,
257
+ before.repository_root, path, String(offset), String(limit)], { encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
258
+ result = JSON.parse(stdout);
259
+ }
260
+ catch (error) {
261
+ // A failed observation is not a reason to switch recipes. Use the existing
262
+ // shell reader only after the task itself confirms Python is absent.
263
+ let absent = false;
264
+ try {
265
+ const probe = await exec("docker", ["exec", "--workdir", cwd, containerID, "sh", "-c",
266
+ "if command -v python3 >/dev/null 2>&1; then printf 'SORTIE_PYTHON_PRESENT'; else printf 'SORTIE_PYTHON_ABSENT'; fi"], { encoding: "utf8", timeout: 30_000, maxBuffer: 1024 });
267
+ absent = probe.stdout === "SORTIE_PYTHON_ABSENT";
268
+ }
269
+ catch { /* Preserve the original read failure when capability is unknown. */ }
270
+ if (!absent)
271
+ throw error;
272
+ result = await readDockerWorkspaceWithoutPython(containerID, cwd, before.repository_root, path, offset, limit);
273
+ }
274
+ const after = await captureDockerWorkspaceSnapshot(containerID, cwd);
275
+ if (!after || after.candidate !== candidate)
276
+ throw new Error("docker-review-candidate-changed-during-read");
277
+ return { workspace: before, ...result };
278
+ }
@@ -0,0 +1,2 @@
1
+ /** Same result as the Python reader, without mutating/installing in the task. */
2
+ export declare function readDockerWorkspaceWithoutPython(containerID: string, cwd: string, root: string, requested: string, offset: number, limit: number): Promise<Record<string, unknown>>;
@@ -0,0 +1,185 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { posix } from "node:path";
4
+ // Fixed read-only transport for images with Git/coreutils but no Python. Paths
5
+ // are argv, never shell source. Node frames names and streams file bytes so long
6
+ // logical lines cannot become unbounded shell variables or buffered stdout.
7
+ const READ = String.raw `
8
+ set -eu
9
+ root=$1; requested=$2; lexical=$3; parent=$4; name=$5
10
+ fail() { printf '%s\n' "$1" >&2; exit 1; }
11
+ physical() {
12
+ resolved=$(readlink -fn -- "$1" && printf .) || fail file-outside-candidate-workspace
13
+ resolved=${"$"}{resolved%.}
14
+ }
15
+ inside() {
16
+ [ "$root" = / ] && return 0
17
+ case "$1" in "$root"|"$root"/*) return 0 ;; esac
18
+ return 1
19
+ }
20
+ physical "$requested"; path=$resolved
21
+ if ! inside "$path"; then
22
+ physical "$parent"; entry="${"$"}{resolved%/}/$name"
23
+ inside "$lexical" && inside "$entry" || fail file-outside-candidate-workspace
24
+ case "$entry" in "$root/.git"|"$root/.git/"*) fail file-outside-candidate-workspace ;; esac
25
+ [ -L "$entry" ] && [ -L "$requested" ] && [ -f "$entry" ] || fail file-outside-candidate-workspace
26
+ physical "$entry"
27
+ [ "$resolved" = "$path" ] || fail file-outside-candidate-workspace
28
+ fi
29
+ if [ -d "$path" ]; then kind=d
30
+ elif [ -f "$path" ]; then kind=f
31
+ else fail candidate-file-not-readable
32
+ fi
33
+ printf 'SORTIE_READ_V1\0%s\0%s\0' "$path" "$kind"
34
+ if [ "$kind" = f ]; then cat -- "$path"
35
+ else find "$path" -mindepth 1 -maxdepth 1 -print0
36
+ fi
37
+ `;
38
+ /** Same result as the Python reader, without mutating/installing in the task. */
39
+ export function readDockerWorkspaceWithoutPython(containerID, cwd, root, requested, offset, limit) {
40
+ // Keep the raw path for real filesystem resolution; a normalized canceled
41
+ // directory hop must not select a different entry outside the candidate.
42
+ const raw = posix.isAbsolute(requested) ? requested : `${root}/${requested}`;
43
+ const lexical = posix.resolve(root, requested);
44
+ return new Promise((resolve, reject) => {
45
+ const child = spawn("docker", ["exec", "--workdir", cwd, containerID, "sh", "-c", READ,
46
+ "sortie-read", root, raw, lexical, posix.dirname(lexical), posix.basename(lexical)], { stdio: ["ignore", "pipe", "pipe"], timeout: 30_000 });
47
+ let failure, stderr = Buffer.alloc(0), field = Buffer.alloc(0);
48
+ const header = [], lines = [];
49
+ const hash = createHash("sha256");
50
+ let total = 0, size = 0, pending = false, excerpt = [];
51
+ // Keep only the first offset+limit directory names in a max-heap, rather
52
+ // than retaining an entire large directory in the gateway process.
53
+ const names = [], count = Math.min(Number.MAX_SAFE_INTEGER, offset - 1 + limit);
54
+ const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
55
+ const fail = (error) => {
56
+ if (!failure) {
57
+ failure = error instanceof Error ? error : new Error(String(error));
58
+ child.kill();
59
+ }
60
+ };
61
+ const keepName = (value) => {
62
+ if (!value.length)
63
+ throw new Error("docker-candidate-directory-frame-invalid");
64
+ // find emits actual paths using its existing -print0 capability. Keep
65
+ // filenames only, without requiring image-side GNU -printf or JSON tools.
66
+ value = Buffer.from(posix.basename(utf8.decode(value)));
67
+ total++;
68
+ if (names.length < count) {
69
+ names.push(Buffer.from(value));
70
+ for (let i = names.length - 1; i > 0;) {
71
+ const parent = (i - 1) >>> 1;
72
+ if (Buffer.compare(names[parent], names[i]) >= 0)
73
+ break;
74
+ [names[parent], names[i]] = [names[i], names[parent]];
75
+ i = parent;
76
+ }
77
+ }
78
+ else if (Buffer.compare(value, names[0]) < 0) {
79
+ names[0] = Buffer.from(value);
80
+ for (let i = 0;;) {
81
+ const left = i * 2 + 1, right = left + 1;
82
+ if (left >= names.length)
83
+ break;
84
+ const next = right < names.length && Buffer.compare(names[right], names[left]) > 0 ? right : left;
85
+ if (Buffer.compare(names[i], names[next]) >= 0)
86
+ break;
87
+ [names[i], names[next]] = [names[next], names[i]];
88
+ i = next;
89
+ }
90
+ }
91
+ };
92
+ const finishLine = () => {
93
+ total++;
94
+ if (excerpt.length) {
95
+ lines.push([total, Buffer.concat(excerpt).toString("utf8")]);
96
+ excerpt = [];
97
+ }
98
+ pending = false;
99
+ };
100
+ const fileBytes = (chunk) => {
101
+ hash.update(chunk);
102
+ for (let start = 0; start < chunk.length;) {
103
+ const newline = chunk.indexOf(10, start), end = newline < 0 ? chunk.length : newline + 1;
104
+ pending = true;
105
+ if (total >= offset - 1 && total < offset - 1 + limit && size < 64000) {
106
+ const kept = chunk.subarray(start, Math.min(end, start + 64000 - size));
107
+ excerpt.push(Buffer.from(kept));
108
+ size += kept.length;
109
+ }
110
+ if (newline >= 0)
111
+ finishLine();
112
+ start = end;
113
+ }
114
+ };
115
+ child.stdout.on("data", (chunk) => {
116
+ if (failure)
117
+ return;
118
+ try {
119
+ let start = 0;
120
+ while (start < chunk.length && header.length < 3) {
121
+ const end = chunk.indexOf(0, start);
122
+ const part = chunk.subarray(start, end < 0 ? chunk.length : end);
123
+ if (field.length + part.length > 65536)
124
+ throw new Error("docker-candidate-read-header-invalid");
125
+ field = Buffer.concat([field, part]);
126
+ if (end < 0)
127
+ return;
128
+ header.push(utf8.decode(field));
129
+ field = Buffer.alloc(0);
130
+ start = end + 1;
131
+ if (header.length === 3 && (header[0] !== "SORTIE_READ_V1" || !posix.isAbsolute(header[1]) || !["f", "d"].includes(header[2]))) {
132
+ throw new Error("docker-candidate-read-header-invalid");
133
+ }
134
+ }
135
+ if (header.length < 3)
136
+ return;
137
+ if (header[2] === "f")
138
+ fileBytes(chunk.subarray(start));
139
+ else
140
+ while (start < chunk.length) {
141
+ const end = chunk.indexOf(0, start), part = chunk.subarray(start, end < 0 ? chunk.length : end);
142
+ if (field.length + part.length > 65536)
143
+ throw new Error("docker-candidate-directory-frame-invalid");
144
+ field = Buffer.concat([field, part]);
145
+ if (end < 0)
146
+ return;
147
+ keepName(field);
148
+ field = Buffer.alloc(0);
149
+ start = end + 1;
150
+ }
151
+ }
152
+ catch (error) {
153
+ fail(error);
154
+ }
155
+ });
156
+ child.stderr.on("data", (chunk) => {
157
+ if (stderr.length < 64000)
158
+ stderr = Buffer.concat([stderr, chunk.subarray(0, 64000 - stderr.length)]);
159
+ });
160
+ child.once("error", error => { fail(error); reject(error); });
161
+ child.once("close", (code, signal) => {
162
+ if (failure) {
163
+ reject(failure);
164
+ return;
165
+ }
166
+ if (code !== 0 || signal) {
167
+ reject(new Error(`docker-candidate-read-failed:${signal ?? code}:${stderr.toString("utf8")}`));
168
+ return;
169
+ }
170
+ if (header.length !== 3 || field.length) {
171
+ reject(new Error("docker-candidate-read-frame-incomplete"));
172
+ return;
173
+ }
174
+ if (header[2] === "f") {
175
+ if (pending)
176
+ finishLine();
177
+ resolve({ path: header[1], kind: "file", lines, total_lines: total, sha256: hash.digest("hex") });
178
+ }
179
+ else {
180
+ names.sort(Buffer.compare);
181
+ resolve({ path: header[1], kind: "directory", entries: names.slice(offset - 1, offset - 1 + limit).map(name => utf8.decode(name)), total });
182
+ }
183
+ });
184
+ });
185
+ }