Skip to content
Draft
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 frontend/sandbox-image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ python3 -m unittest discover -s tests -v
配置 `MODEL_AGENT_API_KEY`、`MODEL_AGENT_NAME` 和 `MODEL_AGENT_BASE_URL` 后,基础镜像生成 Codex 模型配置,无需交互登录;凭据只在启动时传入,不写入镜像

Bash 使用上游 ble.sh v0.4.0-devel3 提供输入高亮,采用固定 SHA256 的官方发布包,不切换 Shell
全量和增量镜像构建都会应用 Readline 缓存并发修复:不同终端使用独立临时文件,再保存为共享缓存,避免首次并发启动时出现 `.part` 文件丢失错误

本地 Apple Silicon 在 QEMU 中运行 x64 Codex 0.139.0 时,可能因不支持 TCGETS2 终端接口报 `os error 38`
本地预览容器可通过基础镜像已有的 `CODEX_REAL_BIN` 覆盖为相同版本 Linux ARM64 musl 程序
Expand Down
46 changes: 46 additions & 0 deletions frontend/sandbox-image/runtime/ble_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Backport process-specific Readline cache files to the pinned ble.sh release."""

import os
import stat
import tempfile
from pathlib import Path


def patch_blesh(path: Path) -> None:
content = path.read_text()
before, start, tail = content.partition(
"function ble/builtin/bind/read-user-settings/.collect {\n"
)
body, end, after = tail.partition("\n}\n")
old, new = '"$cache.part"', '"$cache.$$.part"'
if not start or not end:
raise ValueError("Pinned ble.sh Readline cache function changed")
if body.count(new) == 2 and old not in body:
return
if body.count(old) != 2 or new in body:
raise ValueError("Pinned ble.sh Readline cache writer changed")
# $$ stays identical in the pipeline writer and the following mv process.
updated = before + start + body.replace(old, new) + end + after
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as output:
temporary = Path(output.name)
try:
output.write(updated)
output.flush()
os.fchmod(output.fileno(), stat.S_IMODE(path.stat().st_mode))
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
2 changes: 2 additions & 0 deletions frontend/sandbox-image/runtime/install-image.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import zipfile
from pathlib import Path

from ble_cache import patch_blesh
from editor_assets import FONTS, apply_fonts, offline_vsix, preserve_editor_settings
from editor_routing import patch_editor

Expand Down Expand Up @@ -145,6 +146,7 @@ def main() -> None:
archive.extractall(temporary, filter="data")
source = next(Path(temporary).iterdir())
shutil.copytree(source, STUDIO / "ble/share/blesh", dirs_exist_ok=True)
patch_blesh(STUDIO / "ble/share/blesh/ble.sh")
run(
"/opt/agentkit-code-env/venv/bin/python",
str(STUDIO / "runtime/configure-runtime.py"),
Expand Down
2 changes: 2 additions & 0 deletions frontend/sandbox-image/runtime/refresh-editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
import tempfile
from pathlib import Path

from ble_cache import patch_blesh
from editor_routing import patch_editor

studio = Path("/opt/studio-sandbox")
patch_blesh(studio / "ble/share/blesh/ble.sh")
editor = (studio / "base-code-server").resolve(strict=True).parent.parent
patch_editor(editor)
seed = Path("/opt/gem/vscode/User/settings.json")
Expand Down
139 changes: 139 additions & 0 deletions frontend/sandbox-image/tests/test_ble_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))

# Preserve the pinned release's pipeline/rename structure and function boundary.
ORIGINAL = """function ble/builtin/bind/read-user-settings/.collect {
local cache=$1
printf '%s\\n' "$2" | cat >| "$cache.part" &&
ble/bin/mv "$cache.part" "$cache"
}
unrelated='"$cache.part"'
"""
WORKER = """source "$1"
function ble/bin/mv {
printf 'ready\\n'
read -r release
command mv "$@"
}
ble/builtin/bind/read-user-settings/.collect "$2" "$3"
"""


class BleCacheTest(unittest.TestCase):
def writers(self, script: Path) -> list[subprocess.CompletedProcess[str]]:
workers = [
subprocess.Popen(
[
"/bin/bash",
"--noprofile",
"--norc",
"-c",
WORKER,
"bash",
str(script),
str(script.parent / "cache"),
value,
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
for value in ("first complete cache", "second complete cache")
]
try:
for worker in workers:
assert worker.stdout is not None
self.assertEqual(worker.stdout.readline(), "ready\n")
results = []
for worker in workers:
stdout, stderr = worker.communicate("\n", timeout=5)
results.append(
subprocess.CompletedProcess(
worker.args, worker.returncode, stdout, stderr
)
)
return results
finally:
for worker in workers:
if worker.poll() is None:
worker.kill()
worker.communicate()

def test_original_shared_temporary_file_loses_a_writer(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
script = Path(temporary) / "ble.sh"
script.write_text(ORIGINAL)
results = self.writers(script)
self.assertEqual(sum(result.returncode != 0 for result in results), 1)

def test_patched_concurrent_writers_publish_complete_cache(self) -> None:
from ble_cache import patch_blesh

with tempfile.TemporaryDirectory() as temporary:
script = Path(temporary) / "ble.sh"
script.write_text(ORIGINAL)
patch_blesh(script)
for result in self.writers(script):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
self.assertIn(
(script.parent / "cache").read_text(),
{"first complete cache\n", "second complete cache\n"},
)
self.assertFalse(list(script.parent.glob("*.part")))

def test_patch_is_scoped_and_idempotent(self) -> None:
from ble_cache import patch_blesh

with tempfile.TemporaryDirectory() as temporary:
script = Path(temporary) / "ble.sh"
script.write_text(ORIGINAL)
script.chmod(0o644)
patch_blesh(script)
content, modified = script.read_text(), script.stat().st_mtime_ns
patch_blesh(script)
self.assertEqual(script.read_text(), content)
self.assertEqual(script.stat().st_mtime_ns, modified)
self.assertEqual(script.stat().st_mode & 0o777, 0o644)
self.assertTrue(content.endswith("unrelated='\"$cache.part\"'\n"))

def test_unexpected_source_is_not_modified(self) -> None:
from ble_cache import patch_blesh

for content in (
"new upstream source",
ORIGINAL.replace('"$cache.part"', '"$other"', 1),
):
with (
self.subTest(content=content),
tempfile.TemporaryDirectory() as temporary,
):
script = Path(temporary) / "ble.sh"
script.write_text(content)
with self.assertRaises(ValueError):
patch_blesh(script)
self.assertEqual(script.read_text(), content)


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Concurrent Bash cache initialization

[中文版](2026-10-24-concurrent-cache.zh.md)

- Change ID: `studio-blesh-cache`
- Created/revised: 2026-10-24
- Status: implemented
- Component impact: implementation-only correction; no public API, shell configuration contract, final cache format, or permission change. No component spec update is needed.

## Evidence and scope

The pinned ble.sh v0.4.0-devel3 release writes and renames the same `$cache.part` in `ble/builtin/bind/read-user-settings/.collect`. Concurrent shells can rename another shell's temporary file, producing `mv: cannot stat` on first workspace entry. The deployed release and these two lines were inspected. Upstream now uses a process-specific temporary filename in [src/decode.sh](https://github.com/akinomyoga/ble.sh/blob/master/src/decode.sh).

Preserve Bash, highlighting, key bindings, and the shared final cache. Do not upgrade unrelated dependencies, change project files, publish an image, or restart running work. The user approved fixing both image source and the current workspace on 2026-10-24.

## Requirements and design

- FR-1: Concurrent shells must publish complete Readline caches without sharing a temporary filename. Backport the upstream `$$` filename suffix, which remains stable across the pipeline and subsequent rename.
- FR-2: Full builds and incremental image builds must apply the same idempotent patch. Unexpected vendor source must fail before modification. Write through a sibling temporary file and atomically replace the vendor file, preserving its permissions.
- FR-3: Repair the current workspace without privilege changes. Its system plugin is read-only and sudo is disabled, so copy the installed plugin to the user's local data directory, apply the same patch, back up `.bashrc`, and change only the plugin source path. New terminals use the copy; existing terminals are left running.

Affected code: `frontend/sandbox-image/runtime/ble_cache.py` (new), `install-image.py`, and `refresh-editor.py`. Regression coverage is under `frontend/sandbox-image/tests/`. The current workspace changes are outside the repository and contain no credentials or project edits.

## Tasks and acceptance

| Requirement | Task | Acceptance | Verification | Result |
| --- | --- | --- | --- | --- |
| FR-1 | T-1: reproduce then patch | AC-1: synchronized competing writers finish without missing temporary files or incomplete caches | Targeted Bash subprocess regression | pass: original loses one writer; patched writers both succeed |
| FR-2 | T-2: wire both builds | AC-2: repeat patch preserves bytes, permissions, and mtime; unexpected source stays unchanged | Python patch tests and build entry-point review | pass |
| FR-3 | T-3: repair live workspace | AC-3: fresh terminals load patched ble.sh and run commands successfully | Browser terminal verification | pass: fresh terminal confirmed the loaded path and patched function, then executed `echo ok` |

Commands: `python3 -m unittest discover -s frontend/sandbox-image/tests -v`; Ruff and Pyright for changed Python files; `git diff --check`.

## Review, risks, and recovery

Direct design review found no API, ownership, credential, or dependency change. English and Chinese requirements and acceptance are equivalent. The pinned-source check prevents an unnoticed future vendor change. Deterministic concurrent tests cover the actual write/rename race; UI-only checks cannot prove concurrency. The patch does not suppress genuine filesystem errors. The temporary suffix does not change persistent cache names.

Current workspace rollback restores the backed-up `.bashrc`; no system file is changed. Full image build/publication and broad application tests are not in this task's approved scope. Image source is prepared locally; new cloud workspaces require a later image release. Live repair lifetime follows the current sandbox filesystem.

Verification on 2026-10-24, local diff based on `c662cc8d4`: `python3 -m unittest discover -s frontend/sandbox-image/tests -v` passed all 15 tests, including the pre-submission rerun. The repository's existing Python environment ran Ruff and Pyright on `runtime/ble_cache.py`, `runtime/install-image.py`, `runtime/refresh-editor.py`, and `tests/test_ble_cache.py`: pass, no errors. `pre-commit run --all-files`: pass, including Ruff, formatting, secret scanning, and dependency policy. `git diff --check`: pass. Full Docker builds and broad application tests: not_run, limited to the requested source repair and lightweight checks. A draft PR was requested on 2026-10-24; image publication remains outside scope.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Bash 缓存并发初始化修复

[English](2026-10-24-concurrent-cache.md)

- 变更 ID:`studio-blesh-cache`
- 创建/修订日期:2026-10-24
- 状态:implemented
- 组件影响:仅修正内部实现,不改变公开 API、Shell 配置契约、最终缓存格式或权限,无需修改组件 spec。

## 证据与范围

固定使用的 ble.sh v0.4.0-devel3 在 `ble/builtin/bind/read-user-settings/.collect` 中写入并重命名同一个 `$cache.part`。多个 Shell 并发启动时可能移走其他 Shell 的临时文件,首次进入工作区出现 `mv: cannot stat`。已核对线上版本及这两行实现。上游 [src/decode.sh](https://github.com/akinomyoga/ble.sh/blob/master/src/decode.sh) 已改用进程独立的临时文件名。

保留 Bash、高亮、快捷键和共享最终缓存。不升级无关依赖、不修改项目文件、不发布镜像、不重启正在运行的工作。用户在 2026-10-24 确认同时修复镜像源码和当前工作区。

## 需求与方案

- FR-1:多个 Shell 必须能并发生成完整 Readline 缓存,不共用临时文件。移植上游的 `$$` 文件名后缀,使管道写入和随后重命名使用相同的进程标识。
- FR-2:全量和增量镜像构建应用同一份可重复执行的修复。上游源码不符合预期时,修改前报错。通过同目录临时文件原子替换插件文件,保留文件权限。
- FR-3:不改变权限设置,修复当前工作区。系统插件只读且 sudo 被禁用,因此将已安装插件复制到用户本地数据目录,应用相同修复,备份 `.bashrc` 后仅调整插件加载路径。新终端使用副本,保留已运行的终端。

代码范围:`frontend/sandbox-image/runtime/ble_cache.py`(新增)、`install-image.py` 和 `refresh-editor.py`。回归测试位于 `frontend/sandbox-image/tests/`。当前工作区的修改在仓库外,不包含凭据或项目文件变更。

## 任务与验收

| 需求 | 任务 | 验收 | 验证 | 结果 |
| --- | --- | --- | --- | --- |
| FR-1 | T-1:复现后修复 | AC-1:同步启动的竞争写入者均成功,不丢失临时文件,缓存完整 | 定向 Bash 子进程回归 | pass:旧版丢失一个写入者,修复后两者均成功 |
| FR-2 | T-2:接入两种构建 | AC-2:重复修复保留内容、权限和修改时间;异常源码不变 | Python 修复测试和构建入口审查 | pass |
| FR-3 | T-3:修复线上工作区 | AC-3:新终端加载修复版 ble.sh 并正常执行命令 | 浏览器终端验证 | pass:新终端已确认加载路径和修复函数,并执行 `echo ok` |

命令:`python3 -m unittest discover -s frontend/sandbox-image/tests -v`;对改动 Python 文件执行 Ruff、Pyright;`git diff --check`。

## 审查、风险与恢复

直接设计审查确认不改变 API、归属、凭据或依赖;中英文需求和验收一致。固定源码校验防止上游变化后误改。确定性并发测试覆盖实际写入/重命名冲突,仅验证界面不足以证明并发安全。修复不屏蔽真实文件系统错误,临时文件后缀不改变持久缓存名称。

当前工作区可通过恢复备份的 `.bashrc` 回滚,不修改系统文件。完整镜像构建、发布和大范围应用测试不在本次授权范围;镜像源码先保存在本地,新云端工作区需后续发布镜像。线上修复的保留时间取决于当前沙箱文件系统的生命周期。

2026-10-24 验证,基于 `c662cc8d4` 的本地改动:`python3 -m unittest discover -s frontend/sandbox-image/tests -v` 的 15 项测试全部通过,提交前复跑亦通过。使用仓库已有 Python 环境对 `runtime/ble_cache.py`、`runtime/install-image.py`、`runtime/refresh-editor.py` 和 `tests/test_ble_cache.py` 执行 Ruff、Pyright:pass,无错误。`pre-commit run --all-files`:pass,包含 Ruff、格式、密钥扫描和依赖规则检查。`git diff --check`:pass。完整 Docker 构建和大范围应用测试:not_run,本次限定为源码修复和轻量检查。2026-10-24 已要求提交 Draft PR;镜像发布仍不在本次范围。