install.sh reads INKWELL_SERVER, INKWELL_CHANNEL and INKWELL_TOKEN (#5372)

The installer's environment variables kept the ThoughtSync-era TS_ prefix
after the rename to Inkwell. They are now INKWELL_*, as is the
INKWELL_SERVER_DEFAULT line the server fills in when it serves the script.
The old names are not read any more; the operator approved the clean cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:01:13 -04:00
co-authored by Claude Opus 5.5
parent 027ad6f672
commit fae03c7eca
3 changed files with 18 additions and 18 deletions
+9 -9
View File
@@ -22,7 +22,7 @@
# fallback that chased the `v*` release its manifest named. That came out once # fallback that chased the `v*` release its manifest named. That came out once
# `main` had published to `stable` for real (`b6673c6`); the two channels are the # `main` had published to `stable` for real (`b6673c6`); the two channels are the
# same shape now and nothing here should special-case one of them again. # same shape now and nothing here should special-case one of them again.
# Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go # Pick one with `--channel dev` or `INKWELL_CHANNEL=dev`. Through a pipe the options go
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev # after a `--`: curl -fsSL <url> | sh -s -- --channel dev
# #
# Served from `dev` rather than `main`: `main` exists but trails day-to-day work by # Served from `dev` rather than `main`: `main` exists but trails day-to-day work by
@@ -62,12 +62,12 @@ Inkwell desktop installer.
--channel dev from the forge: rolling build from the latest green push to `dev` --channel dev from the forge: rolling build from the latest green push to `dev`
-h, --help this text -h, --help this text
The options can also come from TS_SERVER and TS_CHANNEL. Through a pipe, pass The options can also come from INKWELL_SERVER and INKWELL_CHANNEL. Through a pipe, pass
them after `--`: curl -fsSL <url> | sh -s -- --channel dev them after `--`: curl -fsSL <url> | sh -s -- --channel dev
From a server, the download needs a device token: make one in the web app under From a server, the download needs a device token: make one in the web app under
Account → Linked devices and paste it when asked. A token typed at the prompt is Account → Linked devices and paste it when asked. A token typed at the prompt is
revoked again once the download is done. TS_TOKEN supplies one without a prompt, revoked again once the download is done. INKWELL_TOKEN supplies one without a prompt,
and is left alone, since whoever set it is managing it. and is left alone, since whoever set it is managing it.
USAGE USAGE
} }
@@ -75,12 +75,12 @@ USAGE
# The address of the server that served this script. Written by that server # The address of the server that served this script. Written by that server
# (src/inkwell/installer.py rewrites exactly this line) and empty in the copy the # (src/inkwell/installer.py rewrites exactly this line) and empty in the copy the
# forge serves, which is what keeps the forge path the default there. # forge serves, which is what keeps the forge path the default there.
TS_SERVER_DEFAULT="" INKWELL_SERVER_DEFAULT=""
# --- channel ---------------------------------------------------------------- # --- channel ----------------------------------------------------------------
channel="${TS_CHANNEL:-stable}" channel="${INKWELL_CHANNEL:-stable}"
channel_asked="${TS_CHANNEL:-}" channel_asked="${INKWELL_CHANNEL:-}"
server="${TS_SERVER:-$TS_SERVER_DEFAULT}" server="${INKWELL_SERVER:-$INKWELL_SERVER_DEFAULT}"
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--channel) --channel)
@@ -200,11 +200,11 @@ say "Installing ${version:-unknown} from $server"
# The token, from the environment or from the person at the keyboard. Read from the # The token, from the environment or from the person at the keyboard. Read from the
# TERMINAL, not stdin: through `curl | sh`, stdin is this script. # TERMINAL, not stdin: through `curl | sh`, stdin is this script.
token="${TS_TOKEN:-}" token="${INKWELL_TOKEN:-}"
prompted="" prompted=""
if [ -z "$token" ]; then if [ -z "$token" ]; then
{ [ -r /dev/tty ] && [ -w /dev/tty ]; } || { [ -r /dev/tty ] && [ -w /dev/tty ]; } ||
die "the download needs a device token and there's no terminal to ask on; set TS_TOKEN." die "the download needs a device token and there's no terminal to ask on; set INKWELL_TOKEN."
printf 'A device token is needed to download from %s.\n' "$server" > /dev/tty printf 'A device token is needed to download from %s.\n' "$server" > /dev/tty
printf 'Make one in the web app under Account → Linked devices (it is revoked once the download is done).\n' > /dev/tty printf 'Make one in the web app under Account → Linked devices (it is revoked once the download is done).\n' > /dev/tty
printf 'Token: ' > /dev/tty printf 'Token: ' > /dev/tty
+3 -3
View File
@@ -12,7 +12,7 @@ editing a URL by hand, which is the friction `client_dist.py` exists to remove.
## Which way it fails ## Which way it fails
A server that can put arbitrary text into a script a person pipes to `sh` is the A server that can put arbitrary text into a script a person pipes to `sh` is the
whole attack. So the substitution is ONE variable, `TS_SERVER_DEFAULT`, given a value whole attack. So the substitution is ONE variable, `INKWELL_SERVER_DEFAULT`, given a value
that has already passed [SAFE_ADDRESS] — no quote, no space, no `$`, nothing a shell that has already passed [SAFE_ADDRESS] — no quote, no space, no `$`, nothing a shell
reads as anything but characters — and the script checks the shape again before it reads as anything but characters — and the script checks the shape again before it
uses it. If the address cannot pass, the route refuses rather than serving a script uses it. If the address cannot pass, the route refuses rather than serving a script
@@ -45,7 +45,7 @@ SOURCE_SCRIPT = Path(__file__).resolve().parents[2] / "desktop" / "packaging" /
# The one line the server rewrites. The script carries it exactly like this, and # The one line the server rewrites. The script carries it exactly like this, and
# `render` refuses a script that does not, rather than serving one with no address in. # `render` refuses a script that does not, rather than serving one with no address in.
PLACEHOLDER = 'TS_SERVER_DEFAULT=""' PLACEHOLDER = 'INKWELL_SERVER_DEFAULT=""'
# What a server address may look like before it goes into a shell script: a scheme, # What a server address may look like before it goes into a shell script: a scheme,
# a host name or IPv4 address, an optional port and an optional path. Deliberately # a host name or IPv4 address, an optional port and an optional path. Deliberately
@@ -81,7 +81,7 @@ def render(script: str, address: str) -> str | None:
"""`script` with `address` as its default server, or None if it cannot be done safely.""" """`script` with `address` as its default server, or None if it cannot be done safely."""
if not SAFE_ADDRESS.fullmatch(address) or script.count(PLACEHOLDER) != 1: if not SAFE_ADDRESS.fullmatch(address) or script.count(PLACEHOLDER) != 1:
return None return None
return script.replace(PLACEHOLDER, f"TS_SERVER_DEFAULT='{address}'") return script.replace(PLACEHOLDER, f"INKWELL_SERVER_DEFAULT='{address}'")
@bp.get("/install.sh") @bp.get("/install.sh")
+6 -6
View File
@@ -7,7 +7,7 @@ from inkwell.installer import PLACEHOLDER, SOURCE_SCRIPT, render, server_address
# DB-free, like test_client_dist: the route reads `public_url` from the live cache # DB-free, like test_client_dist: the route reads `public_url` from the live cache
# rather than from the database, which is what makes it testable here. # rather than from the database, which is what makes it testable here.
SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{TS_SERVER:-$TS_SERVER_DEFAULT}}\"\n" SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{INKWELL_SERVER:-$INKWELL_SERVER_DEFAULT}}\"\n"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -31,10 +31,10 @@ def test_the_published_script_carries_the_line_the_server_rewrites_exactly_once(
def test_render_writes_the_address_into_the_one_variable(): def test_render_writes_the_address_into_the_one_variable():
body = render(SCRIPT, "https://notes.example.com") body = render(SCRIPT, "https://notes.example.com")
assert "TS_SERVER_DEFAULT='https://notes.example.com'" in body assert "INKWELL_SERVER_DEFAULT='https://notes.example.com'" in body
assert PLACEHOLDER not in body assert PLACEHOLDER not in body
# Nothing else moved. # Nothing else moved.
assert body.replace("TS_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT assert body.replace("INKWELL_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -97,14 +97,14 @@ async def test_the_route_serves_the_script_pointed_at_this_server(app):
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.mimetype == "text/plain" assert resp.mimetype == "text/plain"
body = await resp.get_data(as_text=True) body = await resp.get_data(as_text=True)
assert "TS_SERVER_DEFAULT='http://notes.example.com'" in body assert "INKWELL_SERVER_DEFAULT='http://notes.example.com'" in body
assert body.startswith("#!/bin/sh") assert body.startswith("#!/bin/sh")
async def test_the_route_uses_the_public_address_when_one_is_set(app, monkeypatch): async def test_the_route_uses_the_public_address_when_one_is_set(app, monkeypatch):
monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com") monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com")
resp = await app.test_client().get("/install.sh", headers={"Host": "inkwell:5000"}) resp = await app.test_client().get("/install.sh", headers={"Host": "inkwell:5000"})
assert "TS_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True) assert "INKWELL_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True)
async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkeypatch): async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkeypatch):
@@ -113,7 +113,7 @@ async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkey
monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com/'$(id)'") monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com/'$(id)'")
resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"}) resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"})
assert resp.status_code == 500 assert resp.status_code == 500
assert "TS_SERVER_DEFAULT" not in await resp.get_data(as_text=True) assert "INKWELL_SERVER_DEFAULT" not in await resp.get_data(as_text=True)
async def test_a_server_with_no_copy_of_the_script_404s(app, monkeypatch, tmp_path): async def test_a_server_with_no_copy_of_the_script_404s(app, monkeypatch, tmp_path):