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
53 changes: 50 additions & 3 deletions scripts/setup-claude-cloud.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,58 @@ mark_tier_done() {

have() { command -v "$1" >/dev/null 2>&1; }

# Serialises everything that drives apt, which the per-tier lock deliberately does not.
#
# The browsers tier shells out to `playwright install --with-deps` and the python tier calls
# apt_install for tesseract, so the two reach the same dpkg lock from different tiers. Session mode
# runs them in one detached child while telling the model to confirm with
# `bash scripts/setup-claude-cloud.sh browsers python`, and that confirmation command is exactly what
# collides. Observed 2026-09-06: the confirmation run's Playwright step died with "Installation
# process exited with code: 100" while the background child was unpacking tesseract, and the log
# carried apt's own explanation — "E: dpkg was interrupted, you must manually run 'dpkg --configure
# -a'". The browsers tier was then reported as failed even though nothing about it was broken.
#
# Waiting rather than failing is the point: apt is genuinely busy for a bounded time, so a caller that
# waits gets the install it asked for. Ten minutes is well past the slowest observed apt step and short
# enough that a truly wedged lock still surfaces instead of hanging the container.
#
# The timeout FAILS THE TIER; it never runs the command unlocked. Falling through to an unlocked run
# would recreate the exact concurrent dpkg access this function exists to prevent, and it would do so
# in the one situation where the other holder is provably still working — turning a bounded wait back
# into the interrupted-dpkg state, with the tier reported as attempted. A tier that fails saying "apt
# was busy, re-run this" is recoverable in one command; a corrupted package state is not. The two-hour
# stale sweep above is the separate, safe case: a lock that old belongs to a run that is gone, so it is
# reclaimed and then acquired properly rather than bypassed.
with_apt_lock() {
local lock="$marker_dir/apt.lock" waited=0
local timeout="${CLAUDE_CLOUD_APT_LOCK_TIMEOUT:-600}"
while ! mkdir "$lock" 2>/dev/null; do
if [ -n "$(find "$lock" -maxdepth 0 -mmin +120 2>/dev/null)" ]; then
warn "clearing a stale apt lock"
rm -rf "$lock"
continue
fi
if [ "$waited" -ge "$timeout" ]; then
warn "apt is still held by another run after ${timeout}s; not running it unlocked"
warn "re-run this tier once the other run finishes"
return 1
fi
[ "$waited" -eq 0 ] && log "waiting for another run's apt step to finish"
sleep 5
waited=$((waited + 5))
done
"$@"
local status=$?
rm -rf "$lock" 2>/dev/null
return "$status"
}

apt_install() {
have apt-get || { warn "apt-get is unavailable; cannot install: $*"; return 1; }
if [ "$(id -u)" = "0" ]; then
apt-get update -qq && apt-get install -y --no-install-recommends "$@"
with_apt_lock sh -c 'apt-get update -qq && apt-get install -y --no-install-recommends "$@"' _ "$@"
elif have sudo; then
sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends "$@"
with_apt_lock sh -c 'sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends "$@"' _ "$@"
else
warn "neither root nor sudo; cannot install: $*"
return 1
Expand Down Expand Up @@ -240,8 +286,9 @@ tier_deno() {

tier_browsers() {
[ -x ./node_modules/.bin/playwright ] || { warn "playwright is not installed; run npm ci first"; return 1; }
# `--with-deps` runs apt, so it takes the shared apt lock like apt_install does; see with_apt_lock.
# shellcheck disable=SC2086
./node_modules/.bin/playwright install --with-deps ${CLAUDE_CLOUD_BROWSERS:-chromium firefox webkit}
with_apt_lock ./node_modules/.bin/playwright install --with-deps ${CLAUDE_CLOUD_BROWSERS:-chromium firefox webkit}
}

tier_python() {
Expand Down
8 changes: 7 additions & 1 deletion src/components/therapy-compass/use-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@

import { useCallback, useEffect, useRef, useState } from "react";
import { copyTextToClipboard } from "@/lib/copy-to-clipboard";
import { plainClinicalText } from "@/lib/plain-clinical-text";

/**
* Write text to the clipboard, guarded for SSR / unavailable API. Resolves to
* whether the write actually succeeded: a rejected write (permission denied,
* lost focus, a blocked user gesture) resolves to `false` instead of throwing,
* so callers never signal success for a copy that didn't happen and no unhandled
* promise rejection escapes.
*
* Everything copied here is destined for a progress note, so it goes through
* `plainClinicalText` first. The Therapy corpus carries 1,956 `\u2192` arrows plus
* en/em dashes and curly quotes, and the record systems this is pasted into
* render those as replacement glyphs or drop them silently.
*/
export async function copyText(text: string): Promise<boolean> {
if (typeof navigator === "undefined" || !text) return false;
try {
await copyTextToClipboard(text);
await copyTextToClipboard(plainClinicalText(text));
return true;
} catch {
return false;
Expand Down
39 changes: 3 additions & 36 deletions src/lib/dsm-note.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { dsmCriteria, dsmSpecifierSplit, type DsmDiagnosis, type DsmLabeledText, type DsmSpecifier } from "@/lib/dsm";
import { plainClinicalText } from "@/lib/plain-clinical-text";

export { plainClinicalText };

/**
* Note text generation for the DSM diagnosis page's note builder.
Expand Down Expand Up @@ -36,42 +39,6 @@ export type DsmNoteInput = {
includeCriterionText: boolean;
};

/**
* Characters that survive a copy into a clinical record system.
*
* The vendored DSM export uses typographic characters throughout — 216 uses of
* `≥` alone, plus `≤ ≈ × ² – — → ↑` and curly quotes. Several of the systems
* this text is pasted into render those as replacement glyphs or drop them, and
* `≥4` silently becoming `4` reverses the meaning of a threshold. Each one is
* therefore spelled out rather than stripped.
*
* `â` (khyâl) and `é` (Guillain-Barré) are left alone: both are ordinary Latin-1
* letters inside a correctly spelled clinical term, not typography.
*
* Semicolons are folded to commas because every semicolon in this corpus joins
* list items or an "or" clause, where a comma reads identically and matches how
* notes are written.
*/
const PLAIN_TEXT_REPLACEMENTS: ReadonlyArray<readonly [RegExp, string]> = [
[/≥\s*/g, "at least "],
[/≤\s*/g, "no more than "],
[/≈\s*/g, "approximately "],
[/↑\s*/g, "increased "],
[/\s*→\s*/g, " leading to "],
[/×/g, "x"],
[/²/g, "2"],
[/[–—]/g, "-"],
[/[‘’]/g, "'"],
[/[“”]/g, '"'],
[/;\s*/g, ", "],
];

export function plainClinicalText(value: string): string {
let text = value;
for (const [pattern, replacement] of PLAIN_TEXT_REPLACEMENTS) text = text.replace(pattern, replacement);
return text.replace(/[ \t]{2,}/g, " ").trim();
}

/**
* The specifier rows that are safe to offer as a tick box.
*
Expand Down
39 changes: 39 additions & 0 deletions src/lib/plain-clinical-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Characters that survive a copy into a clinical record system.
*
* The vendored DSM export uses typographic characters throughout — 216 uses of
* `≥` alone, plus `≤ ≈ × ² – — → ↑` and curly quotes. Several of the systems
* this text is pasted into render those as replacement glyphs or drop them, and
* `≥4` silently becoming `4` reverses the meaning of a threshold. Each one is
* therefore spelled out rather than stripped.
*
* `â` (khyâl) and `é` (Guillain-Barré) are left alone: both are ordinary Latin-1
* letters inside a correctly spelled clinical term, not typography.
*
* Semicolons are folded to commas because every semicolon in this corpus joins
* list items or an "or" clause, where a comma reads identically and matches how
* notes are written.
*
* This module holds no data imports on purpose. The Therapy catalogue reaches a
* record through the same paste and needs the same guarantee, and importing it
* from `dsm-note.ts` would pull the whole DSM corpus into the therapy bundle.
*/
const PLAIN_TEXT_REPLACEMENTS: ReadonlyArray<readonly [RegExp, string]> = [
[/≥\s*/g, "at least "],
[/≤\s*/g, "no more than "],
[/≈\s*/g, "approximately "],
[/↑\s*/g, "increased "],
[/\s*→\s*/g, " leading to "],
[/×/g, "x"],
[/²/g, "2"],
[/[–—]/g, "-"],
[/[‘’]/g, "'"],
[/[“”]/g, '"'],
[/;\s*/g, ", "],
];

export function plainClinicalText(value: string): string {
let text = value;
for (const [pattern, replacement] of PLAIN_TEXT_REPLACEMENTS) text = text.replace(pattern, replacement);
return text.replace(/[ \t]{2,}/g, " ").trim();
}
82 changes: 82 additions & 0 deletions tests/claude-cloud-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,88 @@ describe("SessionStart registration", () => {
});
});

describe("setup-claude-cloud apt serialisation", () => {
// The browsers tier shells out to `playwright install --with-deps` and the python tier calls
// apt_install for tesseract. Both drive apt, and the per-tier lock deliberately does not serialise
// across tiers, so on 2026-09-06 the documented confirmation command
// (`bash scripts/setup-claude-cloud.sh browsers python`) collided with the session hook's own
// background child: the browsers tier died with Playwright's opaque "Installation process exited
// with code: 100" while the log carried apt's real explanation, "E: dpkg was interrupted".
//
// `with_apt_lock` is extracted from the real script rather than restated here, so these tests
// cannot pass against a copy that has drifted from the code that ships.
function aptLockHarness(home: string, body: string) {
const source = readFileSync(provisioner, "utf8");
const start = source.indexOf("with_apt_lock() {");
expect(start).toBeGreaterThan(-1);
const end = source.indexOf("\n}\n", start) + 3;
const fn = source.slice(start, end);
expect(fn).toContain('mkdir "$lock"');

const harness = join(home, "harness.sh");
writeFileSync(harness, ['marker_dir="$1"', "log() { :; }", "warn() { :; }", fn, body].join("\n") + "\n", "utf8");
return harness;
}

it("fails the caller instead of running the command unlocked when the wait expires", () => {
const home = makeSandboxHome();
const markers = join(home, "markers");
mkdirSync(join(markers, "apt.lock"), { recursive: true });
const ran = join(home, "ran");
const harness = aptLockHarness(home, `with_apt_lock touch "${ran.replace(/\\/g, "/")}"; echo "status=$?"`);

const result = spawnSync(bashCommand, [harness, markers], {
encoding: "utf8",
env: { ...process.env, CLAUDE_CLOUD_APT_LOCK_TIMEOUT: "0" },
});

// Falling through to an unlocked run is the whole bug: it would reproduce the concurrent dpkg
// access precisely when the other holder is provably still working.
expect(result.stdout).toContain("status=1");
expect(existsSync(ran)).toBe(false);
});

it("serialises two concurrent callers rather than letting them overlap", () => {
const home = makeSandboxHome();
const markers = join(home, "markers");
mkdirSync(markers, { recursive: true });
const out = join(home, "out").replace(/\\/g, "/");
const harness = aptLockHarness(
home,
`with_apt_lock sh -c 'echo "START $2" >> "$1"; sleep 1; echo "END $2" >> "$1"' _ "${out}" "$2"`,
);

const runs = ["A", "B"].map((label) =>
spawnSync(
bashCommand,
["-c", `bash "${harness.replace(/\\/g, "/")}" "${markers.replace(/\\/g, "/")}" ${label} &`],
{
encoding: "utf8",
env: { ...process.env, CLAUDE_CLOUD_APT_LOCK_TIMEOUT: "60" },
},
),
);
for (const run of runs) expect(run.status).toBe(0);

// Give both detached callers time to finish their 1s critical sections plus the 5s poll.
const deadline = Date.now() + 30_000;
let lines: string[] = [];
while (Date.now() < deadline) {
lines = existsSync(out) ? readFileSync(out, "utf8").trim().split(/\r?\n/).filter(Boolean) : [];
if (lines.length >= 4) break;
spawnSync(bashCommand, ["-c", "sleep 0.5"]);
}

expect(lines).toHaveLength(4);
// Whichever won, its END must precede the other's START; interleaving is the failure.
expect(lines[0]).toMatch(/^START /);
expect(lines[1]).toBe(lines[0].replace("START", "END"));
expect(lines[2]).toMatch(/^START /);
expect(lines[2]).not.toBe(lines[0]);
expect(lines[3]).toBe(lines[2].replace("START", "END"));
});
});

describe("profile snapshot fidelity", () => {
it.skipIf(!hasVendoredSkills)("copies skills as real directories, not as the workstation's symlinks", () => {
// ~/.claude/skills is mostly symlinks into ~/.agents/skills. A snapshot that preserved them would
Expand Down
2 changes: 1 addition & 1 deletion tests/diff-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe("countTestCases", () => {
const counts: Record<string, number> = {
"tests/guard-push-no-merge-base.test.ts": 1,
"tests/pdf-extractor.test.ts": 6,
"tests/claude-cloud-profile.test.ts": 24,
"tests/claude-cloud-profile.test.ts": 26,
};
for (const [path, expected] of Object.entries(counts)) {
const source = readFileSync(resolve(REPOSITORY_ROOT, path), "utf8");
Expand Down
28 changes: 28 additions & 0 deletions tests/therapy-compass-clipboard.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,34 @@ afterEach(() => {
}
});

describe("copyText record safety", () => {
// The Therapy corpus carries 1,956 arrows across body/patientExplanation/
// deliverySteps/briefVersion. Copy is the boundary where that text stops being
// a web page and becomes note content, so it is sanitised here rather than by
// rewriting the reviewed source records.
it("spells out arrows and typographic characters before writing", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
setWriteText(writeText);

await copyText(
"Build engagement \u2192 set goals \u2192 review; \u2265 4 sessions \u2014 \u201Cas tolerated\u201D",
);

expect(writeText).toHaveBeenCalledWith(
'Build engagement leading to set goals leading to review, at least 4 sessions - "as tolerated"',
);
});

it("leaves plain clinical text unchanged", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
setWriteText(writeText);

await copyText("Behavioural activation, then graded exposure");

expect(writeText).toHaveBeenCalledWith("Behavioural activation, then graded exposure");
});
});

describe("copyText", () => {
it("resolves true and writes when the clipboard accepts the text", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Expand Down
Loading