Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

### Fixed

- The Unix installer no longer requires `awk`. Release-manifest fields, the GitHub `tag_name` fallback, the sha256 digest, and the `# stepcode` PATH block are read with bash pattern matching, so an install works on a base system that ships no awk implementation (for example a minimal Fedora/WSL image).
- Summary requests now use the provider's model output limit and remaining-context sizing instead of fixed compaction/branch caps or reserve-derived budgets. Existing reserve arguments remain accepted for call compatibility; automatic trigger and branch source-selection settings are unchanged.
- Compaction and branch-summary inputs omit provider thinking blocks while retaining visible replies, tool calls, diagnostic output, and existing skill-retention rules. Original session history is unchanged. These changes adapt Pi commits `15e7bdc` and `6fcb486` (upstream PR #11036).

Expand Down
116 changes: 74 additions & 42 deletions infra/release/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,39 @@ download_progress() {
die "curl or wget is required";
}

json_value() {
awk -F'"' -v section="$1" -v wanted="$2" '
$0 ~ "\\\"" section "\\\"[[:space:]]*:" { in_section=1; next }
in_section && /^[[:space:]]*}/ { exit }
in_section && $2 == wanted { print $4; exit }
' "$3"
}
# The release bundle writes latest.json and manifest.json with JSON.stringify(_, null, 2),
# so both documents are line-oriented: one top-level key per line, one entry per line
# inside each object. Reading them with bash pattern matching keeps the installer free
# of a JSON parser and of awk (which is not installed in every base system, e.g. a
# minimal Fedora/WSL image).

# Emit the first string value of a top-level `key`.
json_top_value() {
awk -F'"' -v wanted="$1" '$0 ~ "\\\"" wanted "\\\"[[:space:]]*:" { print $4; exit }' "$2"
local key="$1" file="$2" line
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ \"$key\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
printf '%s' "${BASH_REMATCH[1]}"
return 0
fi
done <"$file"
return 1
}

# Emit the string value of `target` inside the top-level `section` object.
json_value() {
local section="$1" target="$2" file="$3" line in_section=0
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$in_section" -eq 0 ]]; then
if [[ "$line" =~ \"$section\"[[:space:]]*:[[:space:]]*\{ ]]; then in_section=1; fi
continue
fi
if [[ "$line" =~ ^[[:space:]]*\} ]]; then return 1; fi
if [[ "$line" =~ \"$target\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
printf '%s' "${BASH_REMATCH[1]}"
return 0
fi
done <"$file"
return 1
}

normalize_version() {
Expand Down Expand Up @@ -62,9 +85,11 @@ detect_target() {
verify_checksum() {
local archive="$1" expected="$2" actual
[[ -z "$expected" ]] && return 0
if has_cmd sha256sum; then actual="$(sha256sum "$archive" | awk '{print $1}')"
elif has_cmd shasum; then actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
if has_cmd sha256sum; then actual="$(sha256sum "$archive")"
elif has_cmd shasum; then actual="$(shasum -a 256 "$archive")"
else printf 'warning: no sha256 tool; skipped archive verification\n' >&2; return 0; fi
# Both tools print "<hash> <file>"; keep the hash only.
actual="${actual%%[[:space:]]*}"
[[ "$actual" == "$expected" ]] || die "checksum mismatch (expected $expected, got $actual)"
}

Expand Down Expand Up @@ -96,7 +121,7 @@ install_managed_tool() {
if ! download "https://api.github.com/repos/${repo}/releases/latest" "$metadata"; then
return 0
fi
version="$(awk -F'"' '/"tag_name"[[:space:]]*:/ { print $4; exit }' "$metadata")"
version="$(json_top_value tag_name "$metadata" || true)"
version="${version#v}"
archive="${WORK_DIR}/${name}.archive"
extract="${WORK_DIR}/${name}.extract"
Expand Down Expand Up @@ -157,40 +182,47 @@ strip_step_block() {
# prior write, or an unrelated "# stepcode" comment) leaves the file untouched.
# Returns non-zero for malformed markers or a failed rewrite, so the caller can
# avoid appending a duplicate block on top of one it failed to remove.
local file="$1" tmp
local file="$1" tmp line block="" blank="" inblock=0
# Never combine a dangling/nested marker with a newly appended block: the
# next reinstall could otherwise remove user content between those markers.
awk '
$0 == "# stepcode" { if (inblock) exit 1; inblock = 1 }
$0 == "# stepcode end" { if (!inblock) exit 1; inblock = 0 }
END { if (inblock) exit 1 }
' "$file" || return 1
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == "# stepcode" ]]; then
if [[ "$inblock" -eq 1 ]]; then return 1; fi
inblock=1
elif [[ "$line" == "# stepcode end" ]]; then
if [[ "$inblock" -eq 0 ]]; then return 1; fi
inblock=0
fi
done <"$file"
if [[ "$inblock" -eq 1 ]]; then return 1; fi

tmp="$(mktemp "${file}.step.XXXXXX")" || return 1
if awk '
{
if (inblock) {
block = block $0 "\n"
if ($0 == "# stepcode end") { inblock = 0; block = ""; blank = "" }
next
}
if ($0 == "# stepcode") { inblock = 1; block = $0 "\n"; next }
if ($0 ~ /^[[:space:]]*$/) { blank = blank $0 "\n"; next }
if (blank != "") { printf "%s", blank; blank = "" }
print
}
END {
# Unterminated block: restore its lines (and preceding blanks) instead
# of dropping them. Trailing blanks are preserved.
if (inblock) { if (blank != "") printf "%s", blank; printf "%s", block }
else if (blank != "") printf "%s", blank
}
' "$file" >"$tmp"; then
cat "$tmp" >"$file"
inblock=0
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$inblock" -eq 1 ]]; then
block+="$line"$'\n'
if [[ "$line" == "# stepcode end" ]]; then inblock=0; block=""; blank=""; fi
continue
fi
if [[ "$line" == "# stepcode" ]]; then inblock=1; block="$line"$'\n'; continue; fi
if [[ "$line" =~ ^[[:space:]]*$ ]]; then blank+="$line"$'\n'; continue; fi
if [[ -n "$blank" ]]; then printf '%s' "$blank"; blank=""; fi
printf '%s\n' "$line"
done <"$file" >"$tmp"
# Unterminated block: restore its lines (and preceding blanks) instead of
# dropping them. Trailing blanks are preserved.
if [[ "$inblock" -eq 1 ]]; then
printf '%s' "$blank" >>"$tmp"
printf '%s' "$block" >>"$tmp"
elif [[ -n "$blank" ]]; then
printf '%s' "$blank" >>"$tmp"
fi
if ! cat "$tmp" >"$file"; then
rm -f "$tmp"
return 0
return 1
fi
rm -f "$tmp"
return 1
return 0
}

configure_shell_path() {
Expand Down Expand Up @@ -284,13 +316,13 @@ main() {
VERSION="$(normalize_version "$VERSION")"
download "${BASE_URL}/${VERSION}/manifest.json" "$manifest"
fi
resolved="$(json_top_value version "$manifest")"
resolved="$(json_top_value version "$manifest" || true)"
[[ -n "$resolved" ]] || die 'release manifest does not contain version'
VERSION="$resolved"
local package_url
package_url="$(json_value packages "$TARGET_ID" "$manifest")"
package_url="$(json_value packages "$TARGET_ID" "$manifest" || true)"
[[ -n "$package_url" ]] || die "manifest does not contain package for ${TARGET_ID}"
expected="$(json_value checksums "$TARGET_ID" "$manifest")"
expected="$(json_value checksums "$TARGET_ID" "$manifest" || true)"
download_progress "$package_url" "$archive"
verify_checksum "$archive" "$expected"
mkdir -p "$extract"
Expand Down
162 changes: 161 additions & 1 deletion scripts/release-bundle.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -464,3 +465,162 @@ printf 'UNTERM_KEEP:%s\n' "$(grep -c 'IMPORTANT=keepme' "$2")"
}
},
);

// ---------------------------------------------------------------------------
// install.sh must not require awk (issue #149).
//
// The installer reads latest.json / manifest.json and trims sha256 output with
// bash pattern matching, so a base system without awk (minimal Fedora/WSL image)
// can still install. This test executes the rendered installer end to end with a
// PATH that contains every command it needs EXCEPT awk, against a local release
// directory, and then proves a bad checksum still aborts.
// ---------------------------------------------------------------------------

function findExecutable(command) {
for (const dir of (process.env.PATH ?? "").split(":")) {
if (!dir) continue;
const candidate = join(dir, command);
if (existsSync(candidate)) return candidate;
}
return undefined;
}

async function createNoAwkPath(dir, commands) {
await mkdir(dir, { recursive: true });
for (const command of commands) {
const source = findExecutable(command);
if (!source) return undefined;
await symlink(source, join(dir, command));
}
return dir;
}

const NO_AWK_COMMANDS = [
"bash",
"cat",
"chmod",
"cp",
"dirname",
"basename",
"env",
"find",
"grep",
"mkdir",
"mktemp",
"mv",
"rm",
"sed",
"tar",
"tr",
"uname",
];

test(
"install.sh installs a release with no awk on PATH and still rejects a bad checksum",
{ skip: commandAvailable("bash", "--version") ? false : "bash unavailable" },
async () => {
const downloader = findExecutable("curl") ? "curl" : findExecutable("wget") ? "wget" : undefined;
const digester = findExecutable("sha256sum") ? "sha256sum" : findExecutable("shasum") ? "shasum" : undefined;
if (!downloader || !digester || !findExecutable("tar")) {
return; // Reported as a pass without a skip: the environment cannot run the scenario.
}

const root = await mkdtemp(join(tmpdir(), "step-install-no-awk-"));
const releaseDir = join(root, "release");
const versionDir = join(releaseDir, "0.0.1");
const stageDir = join(root, "stage");
const installDir = join(root, "install");
const agentDir = join(root, "agent");
const homeDir = join(root, "home");
try {
const stepDir = join(stageDir, "step");
await mkdir(join(stepDir, "theme"), { recursive: true }); await writeFile(
join(stepDir, "step"),
'#!/usr/bin/env bash\ncase "${1:-}" in\n\t--version) printf "step 0.0.1\\n" ;;\n\tmcp) : ;;\nesac\nexit 0\n',
"utf8",
);
await chmod(join(stepDir, "step"), 0o755);
await writeFile(join(stepDir, "theme", "one.json"), "{}\n", "utf8");
await mkdir(versionDir, { recursive: true });
await mkdir(homeDir, { recursive: true });
// Pre-place the managed search tools: their absence makes install_managed_tool
// fetch them from GitHub, which this hermetic test must not attempt.
await mkdir(join(agentDir, "bin"), { recursive: true });
for (const tool of ["fd", "rg"]) {
await writeFile(join(agentDir, "bin", tool), "#!/usr/bin/env bash\nexit 0\n", "utf8");
await chmod(join(agentDir, "bin", tool), 0o755);
}
const archive = join(versionDir, "step-v0.0.1-linux-x64.tar.gz");
const tar = spawnSync("tar", ["-czf", archive, "-C", stageDir, "step"], { encoding: "utf8" });
assert.equal(tar.status, 0, `tar should pack the fake release: ${tar.stderr}`);

const digest = spawnSync(digester, digester === "shasum" ? ["-a", "256", archive] : [archive], {
encoding: "utf8",
});
const sha = String(digest.stdout).trim().split(/\s+/)[0];
assert.match(String(sha), /^[0-9a-f]{64}$/, "fixture digest should be a sha256 hex string");

const tarballUrl = `file://${archive}`;
const manifest = {
version: "0.0.1",
generatedAt: "2026-01-01T00:00:00Z",
packages: Object.fromEntries(
["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"].map((id) => [id, tarballUrl]),
),
checksums: Object.fromEntries(
["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"].map((id) => [id, sha]),
),
};
const writeManifest = async (checksum) => {
const document = {
...manifest,
checksums: Object.fromEntries(Object.keys(manifest.checksums).map((id) => [id, checksum])),
};
const body = `${JSON.stringify(document, null, 2)}\n`;
await writeFile(join(releaseDir, "latest.json"), body, "utf8");
await writeFile(join(versionDir, "manifest.json"), body, "utf8");
};
await writeManifest(sha);

const installer = join(root, "install.sh");
await writeFile(
installer,
renderInstallTemplate(await readFile(shPath, "utf8"), `file://${releaseDir}`),
"utf8",
);
await chmod(installer, 0o755);

const sandboxPath = await createNoAwkPath(join(root, "bin"), [...NO_AWK_COMMANDS, downloader, digester]);
assert.equal(sandboxPath !== undefined, true, "sandbox PATH should contain every required command");
assert.equal(existsSync(join(sandboxPath, "awk")), false, "awk must not be reachable on the sandbox PATH");

const baseEnv = {
PATH: sandboxPath,
HOME: homeDir,
SHELL: "/bin/zsh",
STEP_INSTALL_DIR: installDir,
STEP_CODING_AGENT_DIR: agentDir,
STEP_NO_MODIFY_PATH: "1",
};

const install = spawnSync(installer, [], { encoding: "utf8", env: baseEnv });
assert.equal(install.status, 0, `installer should succeed without awk: ${install.stdout}${install.stderr}`);
assert.match(`${install.stdout}${install.stderr}`, /installed StepCode 0\.0\.1/);
assert.equal(existsSync(join(installDir, "step")), true, "binary should be installed");
assert.equal(existsSync(join(installDir, "theme", "one.json")), true, "runtime resources should be copied");

// A wrong checksum must still stop the install before anything is replaced.
await writeManifest("0".repeat(64));
const badInstallDir = join(root, "install-bad");
const failed = spawnSync(installer, [], {
encoding: "utf8",
env: { ...baseEnv, STEP_INSTALL_DIR: badInstallDir, STEP_CODING_AGENT_DIR: join(root, "agent-bad") },
});
assert.notEqual(failed.status, 0, "installer should fail on a checksum mismatch");
assert.match(`${failed.stdout}${failed.stderr}`, /checksum mismatch/);
assert.equal(existsSync(join(badInstallDir, "step")), false, "nothing should be installed on failure");
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
Loading