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:
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user