Skip to content
Merged
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
37 changes: 20 additions & 17 deletions .devcontainer/claude-code/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -81,23 +81,26 @@ install_claude_code() {
pixi global install --channel https://prefix.dev/blooop claude-shim
fi

# Add pixi bin path to user's profile if not already there
local profile="$TARGET_HOME/.profile"
local pixi_path_line='export PATH="$HOME/.pixi/bin:$PATH"'
if [ -f "$profile" ] && ! grep -q '\.pixi/bin' "$profile"; then
echo "$pixi_path_line" >> "$profile"
elif [ ! -f "$profile" ]; then
echo "$pixi_path_line" > "$profile"
chown "$TARGET_USER:$TARGET_USER" "$profile" 2>/dev/null || true
fi

# Workaround: pixi trampoline fails for bash scripts, so add env bin directly
# This conditionally adds the path only if the env exists
local env_path_line='[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"'
if [ -f "$profile" ] && ! grep -q 'pixi/envs/claude-shim' "$profile"; then
echo "# Workaround: pixi trampoline fails for bash scripts" >> "$profile"
echo "$env_path_line" >> "$profile"
fi
# Put pixi's bin directory on the user's login PATH, and the claude-shim
# env's bin beside it (the pixi trampoline fails for packages that ship a
# shell script -- the same workaround devlaunch's provision script
# carries). Each line goes in under a `# devlaunch:` mark, and the guard
# is an exact-line match on that mark: this file is the *user's* profile,
# not this feature's, and a guard that asks about a directory name reads
# a base image's own PATH block as work already done -- the misread that
# cost devlaunch a re-paid transfer on every launch (#164).
#
# Both edits are devlaunch's _profile_prepend output, verbatim. The marks
# are content hashes of the lines they guard, which sh cannot rederive,
# so they are pasted rather than computed -- and matching them is what
# lets this installer (at image build) and devlaunch's provision script
# (at `up`) recognise each other's work instead of each appending its own
# copy of the same line. A test pins these fragments byte-for-byte;
# regenerate with devlaunch.tools._profile_prepend if a line changes.
PROFILE="$TARGET_HOME/.profile"
grep -qxF '# devlaunch: 6b593c3a6327' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 6b593c3a6327' 'export PATH="$HOME/.pixi/bin:$PATH"' >> "$PROFILE"
grep -qxF '# devlaunch: 12897b113bea' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 12897b113bea' '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE"
chown "$TARGET_USER:$TARGET_USER" "$PROFILE" 2>/dev/null || true

# Verify installation by checking the trampoline exists (don't run it - that triggers download)
local pixi_bin_path="$TARGET_HOME/.pixi/bin"
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Two guards around the tools lend were weaker than what was claimed of them**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): The phrase "tools lend" is unclear and may be a typo or miswording.

In the heading, the phrase “tools lend” is difficult to understand and may be a typo or incorrect wording. Please rephrase (e.g., clarify whether it should be possessive or use a different noun) so the heading’s meaning is immediately clear.

Suggested change
- **Two guards around the tools lend were weaker than what was claimed of them**
- **Two guards around the tools were weaker than what was claimed of them**

([#166](https://github.com/blooop/devlaunch/issues/166)) — the follow-ups the
final re-verdict on [#164](https://github.com/blooop/devlaunch/pull/164) judged
non-blocking, worked once. "The probe never executes the candidate `claude`"
was asserted by scrubbing the rendered script's text for an invocation, and a
probe that executed `"$(command -v claude)"` walked straight past it — once the
lookup is scrubbed, no literal `claude` remains in the execution. The property
is now proven behaviourally: the probe runs against a claude that records
being invoked (shell builtins only, so the recorder cannot silently fail on
the probe's stripped `PATH`), and the record must stay empty. The shipped
probe passes — it never did execute the candidate — so this hardens a guard
rather than fixing a live defect; the text scrub stays as a complement under
a docstring that no longer overclaims.

The `# devlaunch:` marks guarding profile edits used hand-picked tags, and
nothing kept the tags distinct: two lines under one mark silently drop
whichever is appended second, with every script still exiting 0. The tag is
now derived from the content of the line it guards, so a collision is
unrepresentable rather than asserted. A profile marked by an older devlaunch
gains one duplicate `PATH` entry the first time it is seen — the cost the
module already accepts for any mark change — and nothing after.

`.devcontainer/claude-code/install.sh` now carries the same guards. It was
excluded from the #164 sweep on the stated grounds that it guards its own
line in its own file; that reason was wrong — `$TARGET_HOME/.profile` is the
*user's* login profile, and the installer's guards substring-matched
`.pixi/bin` and `pixi/envs/claude-shim`, the very strings that sweep stopped
matching. Latent rather than live (the stock base-image profile contains
neither string), and corrected along with the record. Its two edits are now
devlaunch's own rendered fragments verbatim, marks included, so the installer
(at image build) and the provision script (at `up`) recognise each other's
work instead of each appending its own copy of the same line.

- **`dl <ws> rm` could destroy unsaved work without being asked for `--force`**
([#174](https://github.com/blooop/devlaunch/issues/174)). The guard read the
clone directory off `local_path` while the delete fell back to the derived path
Expand Down
30 changes: 20 additions & 10 deletions devlaunch/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"""

import enum
import hashlib
import json
import logging
import os
Expand Down Expand Up @@ -182,26 +183,38 @@ def _is_official_claude(versions_dir: str, claude_binary: str) -> bool:
return pathlib.PurePosixPath(claude_binary).parent == pathlib.PurePosixPath(versions_dir)


def _profile_prepend(tag: str, line: str, on_failure: str = "") -> str:
def _profile_prepend(line: str, on_failure: str = "") -> str:
"""One PATH line appended to `$PROFILE` at most once, ever.

The line is written under a `PROFILE_MARK` comment naming `tag`, and the
guard is an exact-line match on that comment -- so what decides whether the
The line is written under a `PROFILE_MARK` comment, and the guard is an
exact-line match on that comment -- so what decides whether the
edit has already been made is a line only these scripts ever write.
Substring-matching the directory being added cannot do that job: a base
image is free to mention, or even prepend, the same directory for its own
reasons, and then the guard skips an append the workspace still needs.

The mark names the line by a hash of its content rather than by a
hand-picked tag, so two different lines cannot share a mark: under a
shared mark, whichever line is appended second is silently dropped --
its guard finds the first line's mark and reads it as its own work
already done -- and every script involved still exits 0. Hand-picked
tags left that collision one edit away and guarded by nothing;
derivation makes it unrepresentable rather than asserted. Identical
lines sharing one mark is not a collision but the dedupe itself, and
twelve hex characters of SHA-256 cannot collide across the handful of
lines devlaunch will ever append.

Exact-line (`-x`) and fixed-string (`-F`) rather than a pattern, so nothing
in the mark is read as a regex and a longer line that merely contains it
does not count as a match.

Appending under a *new* mark to a profile some older devlaunch already
edited duplicates one PATH entry, once: harmless (a directory twice on PATH
resolves the same), self-limiting (the mark is there from then on), and the
price of the guard no longer being able to lie.
price of the guard no longer being able to lie. Renaming the marks --
which switching them to hashes did once -- costs exactly that.
"""
mark = f"{PROFILE_MARK} {tag}"
mark = f"{PROFILE_MARK} {hashlib.sha256(line.encode('utf-8')).hexdigest()[:12]}"
tail = f" || {on_failure}" if on_failure else ""
return (
f'grep -qxF {shlex.quote(mark)} "$PROFILE" 2>/dev/null || '
Expand Down Expand Up @@ -246,11 +259,8 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str:
# (since the check above is `command -v`) reinstalled from scratch
# on every single launch.
_PROFILE_RESOLUTION,
_profile_prepend('export PATH="$HOME/.pixi/bin:$PATH"', on_failure="failed=1"),
_profile_prepend(
"pixi-bin", 'export PATH="$HOME/.pixi/bin:$PATH"', on_failure="failed=1"
),
_profile_prepend(
"claude-shim-bin",
'[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && '
'export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"',
on_failure="failed=1",
Expand Down Expand Up @@ -507,7 +517,7 @@ def transfer_script(payload: HostPayload) -> str:
profile_lines = "\n".join(
[
_PROFILE_RESOLUTION,
_profile_prepend("local-bin", 'export PATH="$HOME/.local/bin:$PATH"'),
_profile_prepend('export PATH="$HOME/.local/bin:$PATH"'),
]
)
return "\n".join(
Expand Down
Loading