sortie-dogs 0.14.3 → 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 +6 -6
- package/dist/asset-version.d.ts +1 -1
- package/dist/asset-version.js +1 -1
- package/dist/core/goal-bound.d.ts +2 -0
- package/dist/core/goal-bound.js +1 -0
- package/dist/core/operator-runtime.js +3 -1
- package/dist/plugin/docker-validation.d.ts +3 -0
- package/dist/plugin/docker-validation.js +146 -21
- package/dist/plugin/docker-workspace-read-fallback.d.ts +2 -0
- package/dist/plugin/docker-workspace-read-fallback.js +185 -0
- package/dist/plugin/mission-review.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,8 +19,8 @@ evidence-backed result, not more agents for their own sake.
|
|
|
19
19
|
[](https://www.npmjs.com/package/sortie-dogs)
|
|
20
20
|
[](LICENSE)
|
|
21
21
|
|
|
22
|
-
**Current release: [v0.14.
|
|
23
|
-
· [Release notes](docs/release-v0.14.
|
|
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.
|
|
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.
|
|
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.
|
|
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.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) ·
|
|
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
|
package/dist/asset-version.d.ts
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/asset-version.js
CHANGED
|
@@ -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.
|
|
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";
|
|
@@ -49,6 +49,8 @@ export interface GoalEvidence {
|
|
|
49
49
|
readonly docker_workspace?: {
|
|
50
50
|
readonly container_id: string;
|
|
51
51
|
readonly cwd: string;
|
|
52
|
+
/** Physical Docker CWD for a declared logical alias; absent on legacy/non-alias proof. */
|
|
53
|
+
readonly actual_cwd?: string;
|
|
52
54
|
readonly repository_root: string;
|
|
53
55
|
readonly source: string;
|
|
54
56
|
readonly candidate: string;
|
package/dist/core/goal-bound.js
CHANGED
|
@@ -70,6 +70,7 @@ export function validGoalEvidence(value, state) {
|
|
|
70
70
|
(protectedBinding.validation_policy === undefined || protectedBinding.validation_policy === "inputs-and-concrete-outputs-v1") &&
|
|
71
71
|
(dockerWorkspace === undefined || (typeof dockerWorkspace.container_id === "string" && /^[a-f0-9]{12,64}$/u.test(dockerWorkspace.container_id) &&
|
|
72
72
|
typeof dockerWorkspace.cwd === "string" && dockerWorkspace.cwd.startsWith("/") &&
|
|
73
|
+
(dockerWorkspace.actual_cwd === undefined || typeof dockerWorkspace.actual_cwd === "string" && dockerWorkspace.actual_cwd.startsWith("/")) &&
|
|
73
74
|
typeof dockerWorkspace.repository_root === "string" && dockerWorkspace.repository_root.startsWith("/") &&
|
|
74
75
|
HASH.test(dockerWorkspace.source) && HASH.test(dockerWorkspace.candidate))) &&
|
|
75
76
|
(protectedBinding.freshness === undefined || (protectedBinding.freshness !== null && typeof protectedBinding.freshness === "object" &&
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export interface DockerWorkspaceSnapshot {
|
|
2
2
|
readonly container_id: string;
|
|
3
|
+
/** Declared Docker --workdir; keep its logical alias for validation matching. */
|
|
3
4
|
readonly cwd: string;
|
|
5
|
+
/** Physical CWD when Docker resolves the declared path through an alias. */
|
|
6
|
+
readonly actual_cwd?: string;
|
|
4
7
|
readonly repository_root: string;
|
|
5
8
|
readonly source: string;
|
|
6
9
|
readonly candidate: string;
|
|
@@ -2,19 +2,24 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { posix } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
+
import { readDockerWorkspaceWithoutPython } from "./docker-workspace-read-fallback.js";
|
|
5
6
|
const exec = promisify(execFile);
|
|
6
7
|
const CONTAINER_ID = /^[a-f0-9]{12,64}$/u;
|
|
7
8
|
const SNAPSHOT_MARKER = "SORTIE_DOCKER_WORKSPACE_V1";
|
|
8
9
|
// Terminal tasks need not contain Git at all. Capture actual bytes and file
|
|
9
10
|
// identity, including ignored inputs; timestamps alone are not candidate edits.
|
|
10
11
|
const FILE_SNAPSHOT = String.raw `
|
|
12
|
+
import errno
|
|
11
13
|
import hashlib, json, os, stat, subprocess
|
|
12
14
|
cwd = os.path.realpath(os.getcwd())
|
|
15
|
+
root = cwd
|
|
13
16
|
try:
|
|
14
|
-
|
|
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]
|
|
15
19
|
head = subprocess.check_output(["git", "rev-parse", "--verify", "HEAD^{tree}"], stderr=subprocess.DEVNULL, text=True).strip()
|
|
16
20
|
except (OSError, subprocess.CalledProcessError):
|
|
17
|
-
root
|
|
21
|
+
# An unborn repository still has a root; missing HEAD must not narrow it.
|
|
22
|
+
head = "no-git"
|
|
18
23
|
root = os.path.realpath(root)
|
|
19
24
|
rows = []
|
|
20
25
|
def digest(path):
|
|
@@ -36,11 +41,25 @@ for directory, dirs, files in os.walk(root, followlinks=False, onerror=lambda er
|
|
|
36
41
|
value = [relative, "file", mode, digest(path)]
|
|
37
42
|
elif stat.S_ISLNK(info.st_mode):
|
|
38
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")
|
|
39
56
|
# Internal directory targets are captured at their own lexical path.
|
|
40
57
|
# External directory trees are not silently omitted from a proof.
|
|
41
|
-
if
|
|
58
|
+
if is_directory and os.path.commonpath([root, target]) != root:
|
|
42
59
|
raise RuntimeError("external-directory-link-snapshot-unavailable")
|
|
43
|
-
value = [relative, "link", mode, os.readlink(path), digest(path) if
|
|
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)])
|
|
44
63
|
elif stat.S_ISDIR(info.st_mode):
|
|
45
64
|
value = [relative, "directory", mode]
|
|
46
65
|
else:
|
|
@@ -65,8 +84,67 @@ const SNAPSHOT_COMMAND = [
|
|
|
65
84
|
"root=$(git rev-parse --show-toplevel)",
|
|
66
85
|
"cd \"$root\"",
|
|
67
86
|
"tree=$(git rev-parse --verify 'HEAD^{tree}')",
|
|
68
|
-
|
|
69
|
-
|
|
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%% *}",
|
|
70
148
|
"printf '%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n' 'SORTIE_DOCKER_WORKSPACE_V1' \"$actual_cwd\" \"$root\" \"$tree\" \"$tracked\" \"$untracked\"",
|
|
71
149
|
"fi",
|
|
72
150
|
].join("\n");
|
|
@@ -91,12 +169,16 @@ export async function captureDockerWorkspaceSnapshot(containerID, cwd) {
|
|
|
91
169
|
if (line() !== SNAPSHOT_MARKER)
|
|
92
170
|
return undefined;
|
|
93
171
|
const actualCwd = line(), repositoryRoot = line(), tree = line(), tracked = line(), untracked = line();
|
|
94
|
-
if (actualCwd
|
|
172
|
+
if (!actualCwd || !isDockerWorkspacePath(actualCwd) || !repositoryRoot || !posix.isAbsolute(repositoryRoot) || !tree || !/^[a-f0-9]{40,64}$/u.test(tree) ||
|
|
95
173
|
!tracked || !/^[a-f0-9]{64}$/u.test(tracked) || !untracked || !/^[a-f0-9]{64}$/u.test(untracked) || offset !== output.length)
|
|
96
174
|
return undefined;
|
|
97
175
|
const source = `sha256:${createHash("sha256").update(`git-tree\0${repositoryRoot}\0${tree}`).digest("hex")}`;
|
|
98
|
-
|
|
99
|
-
|
|
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 };
|
|
100
182
|
}
|
|
101
183
|
catch {
|
|
102
184
|
return undefined;
|
|
@@ -112,23 +194,46 @@ export function isDockerWorkspacePath(value) {
|
|
|
112
194
|
const READ_WORKSPACE = String.raw `
|
|
113
195
|
import hashlib, json, os, stat, sys
|
|
114
196
|
root, requested, offset, limit = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
|
|
115
|
-
|
|
197
|
+
requested_path = os.path.join(root, requested)
|
|
198
|
+
path = os.path.realpath(requested_path)
|
|
116
199
|
if os.path.commonpath([root, path]) != root:
|
|
117
|
-
|
|
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")
|
|
118
211
|
if os.path.isdir(path):
|
|
119
212
|
entries = sorted(os.listdir(path))
|
|
120
213
|
result = {"kind": "directory", "entries": entries[offset - 1:offset - 1 + limit], "total": len(entries)}
|
|
121
214
|
elif stat.S_ISREG(os.stat(path).st_mode):
|
|
122
215
|
h = hashlib.sha256()
|
|
123
216
|
lines, total, size = [], 0, 0
|
|
217
|
+
excerpt, pending = bytearray(), False
|
|
124
218
|
with open(path, "rb") as stream:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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")])
|
|
132
237
|
result = {"kind": "file", "lines": lines, "total_lines": total, "sha256": h.hexdigest()}
|
|
133
238
|
else:
|
|
134
239
|
raise ValueError("candidate-file-not-readable")
|
|
@@ -144,10 +249,30 @@ export async function readDockerWorkspaceFile(containerID, cwd, candidate, path,
|
|
|
144
249
|
const before = await captureDockerWorkspaceSnapshot(containerID, cwd);
|
|
145
250
|
if (!before || before.candidate !== candidate)
|
|
146
251
|
throw new Error("docker-review-candidate-stale-or-unavailable");
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|
|
149
274
|
const after = await captureDockerWorkspaceSnapshot(containerID, cwd);
|
|
150
275
|
if (!after || after.candidate !== candidate)
|
|
151
276
|
throw new Error("docker-review-candidate-changed-during-read");
|
|
152
|
-
return { workspace: before, ...
|
|
277
|
+
return { workspace: before, ...result };
|
|
153
278
|
}
|
|
@@ -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
|
+
}
|
|
@@ -36,6 +36,7 @@ export declare function missionReviewValidation(run: OperatorState, statePath: s
|
|
|
36
36
|
docker_workspace?: {
|
|
37
37
|
readonly container_id: string;
|
|
38
38
|
readonly cwd: string;
|
|
39
|
+
readonly actual_cwd?: string;
|
|
39
40
|
readonly repository_root: string;
|
|
40
41
|
readonly source: string;
|
|
41
42
|
readonly candidate: string;
|