From 5de11a9db16354790b3ce1994f592a305cdbd0d5 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 17 Sep 2026 20:28:02 +0300 Subject: [PATCH 1/2] Connect installed Cosift CLI to the shared agent account --- README.md | 50 +++++++++- docs/PP-HANDOFF.md | 4 +- docs/UNINSTALL.md | 5 + docs/WHAT-HAPPENS.md | 15 ++- install.sh | 87 ++++++++++++++++- tests/community-cli.sh | 207 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 tests/community-cli.sh diff --git a/README.md b/README.md index 6e08922..18de928 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ on the machine), writes the server entry, and verifies that the entry actually w it reports success. It also sets up [onboarding](#onboarding-one-step), the one step that tells Cosift what you work on. +If the signed Cosift CLI is already on your `PATH`, the installer also connects it +to the same account. It saves a verified session in +`${XDG_CONFIG_HOME:-~/.config}/cosift/community-session.json`, mode `0600`, without +printing the token or asking you to copy it from a harness. `--no-cli` skips this; +`--cli` makes a missing CLI or failed handoff an error. An existing session is +never replaced. The web app at [cosift.pilotprotocol.network](https://cosift.pilotprotocol.network) +uses the same account when you sign in with the same email. + Every file it writes, every network call it makes and every line it adds to your Claude Code settings is enumerated in [docs/WHAT-HAPPENS.md](docs/WHAT-HAPPENS.md), step by step, for anyone who wants to audit it rather than trust a summary. @@ -62,6 +70,7 @@ server accepts is reused. | the opencode config named above | the `cosift` entry, written by `opencode mcp add` — the script never edits this file when adding | | `.cosift-backup-` | a copy of the file as it was before the edit | | `${XDG_CONFIG_HOME:-~/.config}/cosift/state.json` | what was installed, mode `0600` | +| `${XDG_CONFIG_HOME:-~/.config}/cosift/community-session.json` | verified CLI token, origin and expiry, mode `0600`, when a compatible CLI is available | | one file per selected harness, named under [onboarding](#onboarding-one-step) | the interview itself, mode `0644` — only if you agree to it | | `~/.local/bin/cosift-onboarding` | the local command the interview uses, mode `0755` — same condition | | `~/.claude/settings.json` | one `SessionStart` hook and five permission grants, so the interview can start and run without prompting you — same condition, Claude Code only | @@ -83,8 +92,10 @@ refuses and exits `5` rather than overwriting it. ### Network -The script contacts exactly two hosts: the Cosift auth service (`COSIFT_AUTH_BASE`) and the -Cosift MCP endpoint (`COSIFT_MCP_URL`), both shown in `--help`. It sends your email address +The script contacts the Cosift auth service (`COSIFT_AUTH_BASE`) and the +Cosift MCP endpoint (`COSIFT_MCP_URL`), both shown in `--help`. When connecting an +installed CLI, it also verifies the token at the web origin (`COSIFT_COMMUNITY_URL`). +It sends your email address to the auth service in order to email you a code, and it sends the credential to the MCP endpoint to check that it works. @@ -104,6 +115,7 @@ The files that hold it are: - Claude Code — `~/.claude.json` - Codex CLI — `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`) - opencode — `$XDG_CONFIG_HOME/opencode/opencode.json[c]`, else `~/.config/opencode/opencode.json[c]` +- Cosift CLI, when connected — `${XDG_CONFIG_HOME:-~/.config}/cosift/community-session.json` Consequences worth taking seriously: @@ -118,6 +130,35 @@ Consequences worth taking seriously: If a token leaks, revoke it — see [Revoking a token](#revoking-a-token). +## Use the web app and CLI together + +The installer configures agents; the [web app](https://cosift.pilotprotocol.network) +provides Search, Answer, Research, saved requests, followed topics, contributions +and credits. Sign in there with the email used during installation. + +Install the current signed binary for your platform from +[Cosift releases](https://github.com/pilot-protocol/cosift/releases), verify its +checksum and minisign signature using the release instructions, then rerun: + +```sh +sh install.sh --cli +cosift request -query 'Rust async runtimes' +cosift contribute https://example.org/article +cosift contribute -credits +``` + +The CLI discovers only the session file written by this installer and uses its +recorded origin. Explicit credentials or `-session-file` take precedence; +`-server` cannot send that session to another origin. `-guest` ignores it. +The installer does not download or overwrite a binary. A missing or unsupported +CLI leaves the agent install usable and prints the signed-release link. + +An existing CLI session is left unchanged, including with `--new-account`. +To switch, run `cosift logout` first and rerun the installer. Logout revokes the +shared token in any agent still using it as well. `--uninstall` removes agent +integration but leaves this independently usable CLI session; use `cosift logout` +to revoke and remove it. + ## Onboarding: one step Install it, open your agent, approve one list. That is the whole thing. @@ -257,6 +298,8 @@ install.sh [OPTIONS] --yes accept all detected harnesses without prompting --onboarding install the onboarding interview without asking --no-onboarding do not install the onboarding interview + --cli require connecting an already installed Cosift CLI + --no-cli skip connecting an installed CLI --help print usage to stdout and exit 0 --version print the version string to stdout and exit 0 ``` @@ -296,7 +339,8 @@ warns and carries on, and nothing that was already written is rolled back. | --- | --- | | `COSIFT_AUTH_BASE` | override the auth service base URL used for the email code flow | | `COSIFT_MCP_URL` | override the MCP server URL that gets written into the harness config | -| `COSIFT_EXTRA_HEADER` | one additional header in `Name: value` form, sent on every request the installer makes and written into the harness config next to `Authorization`; needed only when your Cosift deployment sits behind a gateway that requires a second header | +| `COSIFT_COMMUNITY_URL` | web and CLI origin; defaults to `https://cosift.pilotprotocol.network` | +| `COSIFT_EXTRA_HEADER` | one additional header in `Name: value` form, sent on every auth/MCP request and written into the harness config next to `Authorization`; it is not forwarded to the community CLI | | `COSIFT_CODEX_SKILLS_DIR` | the skills root the Codex onboarding file is written under; defaults to `~/.agents/skills`. It has no effect on the MCP config, and `CODEX_HOME` has no effect on it | Most people never set any of these. They exist so that a private or self-hosted Cosift diff --git a/docs/PP-HANDOFF.md b/docs/PP-HANDOFF.md index 15873de..9aa8b12 100644 --- a/docs/PP-HANDOFF.md +++ b/docs/PP-HANDOFF.md @@ -48,8 +48,8 @@ contents on the site — one copy stays accurate, two do not. ## Two points worth keeping in any copy you write -- The installer sends **no telemetry** and contacts only the two Cosift endpoints. This is a - genuine differentiator for a piped-to-shell installer and is safe to state plainly. +- The installer sends **no telemetry**. It contacts Cosift auth and MCP, and the + web origin when it connects an already installed Cosift CLI. - After install, the credential is stored **in cleartext in the tool's own config file** — that is how MCP headers work everywhere. The README says so prominently. Don't write copy that implies the credential is stored more securely than that. diff --git a/docs/UNINSTALL.md b/docs/UNINSTALL.md index 0fbdd2e..bdb74b8 100644 --- a/docs/UNINSTALL.md +++ b/docs/UNINSTALL.md @@ -46,6 +46,11 @@ backups below still contain the token. There is no uninstall preview: `--dry-run` with `--uninstall` is a usage error and exits `2`. +The Cosift CLI binary and `${XDG_CONFIG_HOME:-~/.config}/cosift/community-session.json` +are left available for independent CLI use. Run `cosift logout` to revoke and +remove that session. This also invalidates the same token in any remaining +agent configuration; it does not revoke other tokens belonging to the account. + ## Manual removal Use this if the installer is unavailable, if `--uninstall` reported a failure or a warning, or diff --git a/docs/WHAT-HAPPENS.md b/docs/WHAT-HAPPENS.md index c58da12..8429058 100644 --- a/docs/WHAT-HAPPENS.md +++ b/docs/WHAT-HAPPENS.md @@ -6,7 +6,8 @@ every step rather than take a summary on trust. Everything here is checkable against two files: `install.sh` and the `cosift-onboarding` command, whose source the installer carries inside itself (`onboarding/bin/cosift-onboarding` -in this repository). Nothing is fetched at runtime. +in this repository). The optional CLI handoff also invokes the already installed +[Cosift CLI](https://github.com/pilot-protocol/cosift). No code is fetched at runtime. ## 1. The install command @@ -29,7 +30,12 @@ In order, the script: 6. Installs onboarding, unless you declined it: one file per harness, the local command, and on Claude Code the hook and permission grants in `~/.claude/settings.json`. 7. Makes one MCP `initialize` call with the credential it just wrote, to prove the entry works. -8. Writes its state file and prints a summary of every path it touched. +8. If a compatible `cosift` binary is on `PATH`, passes the verified token to its + `login` command through the subprocess environment. The CLI verifies it at + `GET $COSIFT_COMMUNITY_URL/api/me` and creates a private origin-bound session. + `--no-cli` skips this, and an existing session is never overwritten. No binary + is downloaded. `--cli` makes an unavailable or unsuccessful CLI handoff an error. +9. Writes its state file and prints a summary of every path it touched. `--dry-run` stops after step 3: it prints the per-harness plan, including the `settings.json` line and the state of each interview path, and then exits without writing a byte under your @@ -59,6 +65,7 @@ call with a credential already on the machine, to say whether that one would be | `~/.local/bin/cosift-onboarding` | the local command described in [section 5](#5-cosift-onboarding-digest) | `0755` | | `~/.claude/settings.json` | the hook and the permission grants in [section 3](#3-the-change-to-claudesettingsjson) | preserved; `0600` if the installer created the file | | `${XDG_CONFIG_HOME:-~/.config}/cosift/state.json` | what was installed | `0600` | +| `${XDG_CONFIG_HOME:-~/.config}/cosift/community-session.json` | token, origin and expiry for the installed CLI; only if this file does not already exist | `0600` | | `.cosift-backup-` | a copy of the file as it was before the edit | `0600` | | a directory under `$TMPDIR` | request bodies and curl header files, deleted on exit | `0700` | @@ -73,13 +80,15 @@ can read the token, and so can any backup taken after it was written. ### Network calls -The installer contacts two hosts and no others: +The installer contacts the auth and MCP hosts, plus the community origin when +connecting an installed CLI: | Request | To | Body | | --- | --- | --- | | `POST /auth/start` | `$COSIFT_AUTH_BASE` | the email address you typed | | `POST /auth/verify` | `$COSIFT_AUTH_BASE` | the request id from the previous call and the six-digit code | | `POST` the MCP endpoint | `$COSIFT_MCP_URL` | a JSON-RPC `initialize`, carrying the credential in a header | +| `GET /api/me` through the installed CLI | `$COSIFT_COMMUNITY_URL` | no body; the same credential in the authorization header | `/auth/start` returns the same `200` for a deliverable address, an unknown one, a malformed one and one over its cap, on purpose — a differing response would let anyone test whether an address diff --git a/install.sh b/install.sh index 08e5c91..d512dac 100755 --- a/install.sh +++ b/install.sh @@ -13,6 +13,8 @@ # codex ${CODEX_HOME:-~/.codex}/config.toml (marker-delimited block, appended) # opencode ${XDG_CONFIG_HOME:-~/.config}/opencode/opencode.json[c] (via `opencode mcp add`) # state ${XDG_CONFIG_HOME:-~/.config}/cosift/state.json (mode 0600) +# CLI ${XDG_CONFIG_HOME:-~/.config}/cosift/community-session.json (mode 0600) +# when a compatible cosift binary is installed; --no-cli skips this # Every file touched is copied to .cosift-backup- first. # # THESE FILES THEN HOLD A LIVE CREDENTIAL. Treat them like a password store. @@ -57,6 +59,7 @@ fi COSIFT_AUTH_BASE="${COSIFT_AUTH_BASE:-https://cosift-auth.pilotprotocol.network}" COSIFT_MCP_URL="${COSIFT_MCP_URL:-https://cosift-mcp.pilotprotocol.network/v1/mcp}" +COSIFT_COMMUNITY_URL="${COSIFT_COMMUNITY_URL:-https://cosift.pilotprotocol.network}" COSIFT_EXTRA_HEADER="${COSIFT_EXTRA_HEADER:-}" MCP_PROTOCOL_VERSION="2025-06-18" @@ -73,6 +76,7 @@ CODEX_CONFIG="$CODEX_HOME_DIR/config.toml" OPENCODE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode" STATE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/cosift" STATE_FILE="$STATE_DIR/state.json" +CLI_SESSION_FILE="$STATE_DIR/community-session.json" # The state command writes this one when state.json is absent. ONBOARDING_STATE_FILE="$STATE_DIR/onboarding.json" @@ -102,6 +106,8 @@ OPT_DRY_RUN=0 OPT_UNINSTALL=0 OPT_YES=0 OPT_NEW_ACCOUNT=0 +OPT_CLI=auto +CLI_CONNECTED=0 OPT_HARNESS="" # "" = ask, 1 = install the interview, 0 = skip it. OPT_ONBOARDING="" @@ -3877,6 +3883,8 @@ OPTIONS --no-launch do not offer to open a harness when the install is done --new-account always sign up by email; never reuse a credential that is already on this machine + --cli require connecting an already installed Cosift CLI + --no-cli skip the installed CLI (otherwise it is connected when available) --help show this help and exit --version print the version and exit @@ -3906,8 +3914,10 @@ ENVIRONMENT (default: https://cosift-auth.pilotprotocol.network) COSIFT_MCP_URL override the MCP URL (default: https://cosift-mcp.pilotprotocol.network/v1/mcp) + COSIFT_COMMUNITY_URL web and CLI origin; HTTPS required except localhost + (default: https://cosift.pilotprotocol.network) COSIFT_EXTRA_HEADER one extra header in "Name: value" form, sent on every - request and written into each harness config alongside + auth/MCP request and written into each harness config alongside Authorization COSIFT_CODEX_SKILLS_DIR where the codex interview file goes @@ -4161,6 +4171,52 @@ final_verify() { esac } +# Reuse the token already in memory. The CLI validates the origin, verifies the +# token against /api/me, and creates a new private, origin-bound session file. +# Never read a credential back out of another application's configuration. +community_cli_step() { + if [ "$OPT_CLI" = no ]; then return 0; fi + _cli=$(command -v cosift 2>/dev/null || :) + if [ -z "$_cli" ]; then + warn "The Cosift CLI is not installed; your agent connection is ready." + warn "Install a signed CLI release from https://github.com/pilot-protocol/cosift/releases, then rerun install.sh --cli." + if [ "$OPT_CLI" = required ]; then return 1; fi + return 0 + fi + if [ -e "$CLI_SESSION_FILE" ] || [ -L "$CLI_SESSION_FILE" ]; then + warn "Existing CLI session left unchanged: $CLI_SESSION_FILE" + warn "To switch it, run cosift logout before rerunning the installer. Logout also revokes that token in any agents sharing it." + if [ "$OPT_CLI" = required ]; then return 1; fi + return 0 + fi + "$_cli" login -help >"$TMPD/cli-help" 2>&1 || : + if ! grep -q -- '-session-file' "$TMPD/cli-help"; then + warn "The installed cosift does not support shared CLI sessions; install the current signed release and rerun install.sh --cli." + if [ "$OPT_CLI" = required ]; then return 1; fi + return 0 + fi + if ! mkdir -p "$STATE_DIR"; then + warn "Could not create the CLI session directory: $STATE_DIR" + if [ "$OPT_CLI" = required ]; then return 1; fi + return 0 + fi + step "Connecting the Cosift CLI to $COSIFT_COMMUNITY_URL" + if COSIFT_TOKEN="$TOKEN" COSIFT_SESSION_FILE='' COSIFT_EMAIL='' COSIFT_PASSWORD='' \ + "$_cli" login -server "$COSIFT_COMMUNITY_URL" -session-file "$CLI_SESSION_FILE" \ + >"$TMPD/cli-login" 2>&1; then + CLI_CONNECTED=1 + ok "CLI session saved in $CLI_SESSION_FILE (0600)." + if [ -n "${COSIFT_TOKEN:-}${COSIFT_EMAIL:-}${COSIFT_PASSWORD:-}${COSIFT_SESSION_FILE:-}" ]; then + warn "Your existing COSIFT_TOKEN, COSIFT_EMAIL, COSIFT_PASSWORD or COSIFT_SESSION_FILE environment takes precedence. Unset those selectors to use the installed session." + fi + return 0 + fi + warn "The agent is configured, but the CLI could not connect:" + redact <"$TMPD/cli-login" >&2 + if [ "$OPT_CLI" = required ]; then return 1; fi + return 0 +} + summary() { say "" say "${CLR_OK}${OK_MARK}Cosift is connected.${CLR_RESET}" @@ -4170,6 +4226,12 @@ summary() { done # Short, but never silent: these files did not hold a credential before we ran. say " $(dim "Those files now hold your Cosift credential.")" + say "Web account $COSIFT_COMMUNITY_URL (use the same email as your agents)" + if [ "$CLI_CONNECTED" -eq 1 ]; then + say "CLI ready cosift request -query 'Your question'" + say "Contribute cosift contribute https://example.org/article" + say "Credits cosift contribute -credits" + fi if [ -n "$TIGHTENED" ]; then say " $(dim "Tightened to 0600, having been readable by others:")" printf '%s\n' "$TIGHTENED" | sed -e "s/^/ $CLR_DIM/" -e "s/\$/$CLR_RESET/" @@ -4300,6 +4362,10 @@ onboarding_dry_run() { dry_run() { step "Dry run - nothing will be written" + if [ "$OPT_CLI" != no ]; then + say "CLI: an installed cosift would verify this token at $COSIFT_COMMUNITY_URL" + say " and create $CLI_SESSION_FILE (0600); existing files stay untouched." + fi say "" if [ "$OPT_NEW_ACCOUNT" -eq 1 ]; then say "Token source: a new account. Any credential already on this machine is left" @@ -4409,7 +4475,11 @@ do_install() { fi obtain_token configure_harnesses - final_verify + if final_verify; then + if ! community_cli_step; then + exit "$EX_HARNESS" + fi + fi if ! write_state; then exit "$EX_HARNESS" fi @@ -4522,6 +4592,8 @@ do_uninstall() { parse_args() { _want_onb=0 _skip_onb=0 + _want_cli=0 + _skip_cli=0 if [ -n "${COSIFT_NO_LAUNCH+set}" ]; then OPT_LAUNCH=0; fi while [ $# -gt 0 ]; do case "$1" in @@ -4532,6 +4604,8 @@ parse_args() { --no-onboarding) _skip_onb=1 ;; --no-launch) OPT_LAUNCH=0 ;; --new-account) OPT_NEW_ACCOUNT=1 ;; + --cli) _want_cli=1 ;; + --no-cli) _skip_cli=1 ;; --harness=*) OPT_HARNESS=${1#--harness=} if [ -z "$OPT_HARNESS" ]; then @@ -4575,6 +4649,15 @@ parse_args() { usage >&2 exit "$EX_USAGE" fi + if [ "$_want_cli" -eq 1 ] && [ "$_skip_cli" -eq 1 ]; then + err "--cli and --no-cli cannot be combined." + exit "$EX_USAGE" + fi + if [ "$_skip_cli" -eq 1 ]; then + OPT_CLI=no + elif [ "$_want_cli" -eq 1 ]; then + OPT_CLI=required + fi if [ "$_skip_onb" -eq 1 ]; then OPT_ONBOARDING=0 elif [ "$_want_onb" -eq 1 ] || [ "$OPT_YES" -eq 1 ]; then diff --git a/tests/community-cli.sh b/tests/community-cli.sh new file mode 100644 index 0000000..5c172c6 --- /dev/null +++ b/tests/community-cli.sh @@ -0,0 +1,207 @@ +#!/bin/sh +# Offline installer handoff tests. Fixtures use only fabricated credentials and +# isolated HOME/PATH values; no real harness configuration or server is read. +set -eu +exec python3 - "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" <<'PY' +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +INSTALLER = Path(sys.argv.pop(1)) / "install.sh" +TOKEN = "ck_1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +ORIGIN = "https://community.example.invalid" + + +class CommunityCLIHandoff(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.home = self.root / "home" + self.home.mkdir() + self.bin = self.root / "bin" + self.bin.mkdir() + self.env = { + "PATH": str(self.bin) + ":/usr/bin:/bin", + "HOME": str(self.home), + "XDG_CONFIG_HOME": str(self.home / "config"), + "COSIFT_COMMUNITY_URL": ORIGIN, + "COSIFT_AUTH_BASE": "https://auth.example.invalid", + "COSIFT_MCP_URL": "https://mcp.example.invalid/v1/mcp", + "NO_COLOR": "1", + "CLI_CALL_LOG": str(self.root / "cli-calls.json"), + "TEST_TOKEN": TOKEN, + } + self.session = self.home / "config/cosift/community-session.json" + config = self.home / ".codex/config.toml" + config.parent.mkdir() + config.write_text( + '# >>> cosift (managed by cosift-install — do not edit)\n' + '[mcp_servers.cosift]\nurl = "https://mcp.example.invalid/v1/mcp"\n' + '[mcp_servers.cosift.http_headers]\nAuthorization = "Bearer ' + TOKEN + '"\n' + '# <<< cosift\n' + ) + config.chmod(0o600) + self.executable("codex", "#!/bin/sh\nexit 0\n") + self.executable("curl", f"#!{sys.executable}\n" + ''' +import json, pathlib, sys +args = sys.argv[1:] +out = pathlib.Path(args[args.index("-o") + 1]) +out.write_text(json.dumps({"jsonrpc":"2.0", "id":1, "result":{}})) +print("200", end="") +''') + self.executable("cosift", f"#!{sys.executable}\n" + ''' +import json, os, pathlib, sys +args = sys.argv[1:] +if args == ["login", "-help"]: + print(" -session-file string", file=sys.stderr) + sys.exit(2) +assert args[0] == "login", args +assert os.environ["COSIFT_TOKEN"] == os.environ["TEST_TOKEN"] +assert os.environ.get("COSIFT_SESSION_FILE") == "" +assert os.environ.get("COSIFT_EMAIL") == "" +assert os.environ.get("COSIFT_PASSWORD") == "" +assert os.environ["COSIFT_TOKEN"] not in " ".join(args) +pathlib.Path(os.environ["CLI_CALL_LOG"]).write_text(json.dumps(args)) +if os.environ.get("CLI_FAIL"): + print("rejected " + os.environ["COSIFT_TOKEN"], file=sys.stderr) + sys.exit(1) +origin = args[args.index("-server") + 1] +dest = pathlib.Path(args[args.index("-session-file") + 1]) +fd = os.open(dest, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) +with os.fdopen(fd, "w") as f: + json.dump({"origin":origin, "token":os.environ["COSIFT_TOKEN"], "expires":"2030-01-01T00:00:00Z"}, f) +print('{"logged_in":true}') +''') + + def executable(self, name, source): + path = self.bin / name + path.write_text(source) + path.chmod(0o700) + + def run_install(self, *extra): + result = subprocess.run( + ["/bin/sh", str(INSTALLER), "--harness=codex", "--yes", "--no-onboarding", "--no-launch", *extra], + env=self.env, capture_output=True, text=True, timeout=20, + ) + self.assertNotIn(TOKEN, result.stdout + result.stderr) + return result + + def test_installed_cli_reuses_in_memory_token(self): + self.env.update(COSIFT_EMAIL="irrelevant@example.invalid", COSIFT_PASSWORD="irrelevant", COSIFT_SESSION_FILE="irrelevant") + result = self.run_install() + self.assertEqual(result.returncode, 0, result.stderr) + saved = json.loads(self.session.read_text()) + self.assertEqual(saved["origin"], ORIGIN) + self.assertEqual(saved["token"], TOKEN) + self.assertEqual(stat.S_IMODE(self.session.stat().st_mode), 0o600) + self.assertIn("cosift request -query", result.stdout) + + def test_no_cli_leaves_agent_install_usable(self): + (self.bin / "cosift").unlink() + result = self.run_install() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("signed CLI release", result.stderr) + self.assertFalse(self.session.exists()) + + def test_explicit_cli_requires_binary(self): + (self.bin / "cosift").unlink() + self.assertEqual(self.run_install("--cli").returncode, 5) + + def test_no_cli_flag_skips_handoff(self): + self.assertEqual(self.run_install("--no-cli").returncode, 0) + self.assertFalse(self.session.exists()) + self.assertFalse(Path(self.env["CLI_CALL_LOG"]).exists()) + + def test_dry_run_does_not_invoke_cli(self): + result = self.run_install("--dry-run") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(str(self.session), result.stdout) + self.assertFalse(self.session.exists()) + self.assertFalse(Path(self.env["CLI_CALL_LOG"]).exists()) + + def test_existing_session_is_never_overwritten(self): + self.session.parent.mkdir(parents=True) + self.session.write_text("keep this existing credential") + result = self.run_install("--cli") + self.assertEqual(result.returncode, 5) + self.assertEqual(self.session.read_text(), "keep this existing credential") + self.assertFalse(Path(self.env["CLI_CALL_LOG"]).exists()) + + def test_symlink_session_is_never_followed(self): + self.session.parent.mkdir(parents=True) + destination = self.root / "untouched" + destination.write_text("keep") + self.session.symlink_to(destination) + self.assertEqual(self.run_install("--cli").returncode, 5) + self.assertEqual(destination.read_text(), "keep") + + def test_cli_failure_is_redacted_and_does_not_break_agents(self): + self.env["CLI_FAIL"] = "1" + result = self.run_install() + self.assertEqual(result.returncode, 0) + self.assertIn("could not connect", result.stderr) + self.assertFalse(self.session.exists()) + self.assertEqual(self.run_install("--cli").returncode, 5) + + def test_conflicting_flags_fail_before_handoff(self): + self.assertEqual(self.run_install("--cli", "--no-cli").returncode, 2) + self.assertFalse(Path(self.env["CLI_CALL_LOG"]).exists()) + + @unittest.skipUnless(os.environ.get("COSIFT_TEST_CLI"), "set COSIFT_TEST_CLI to test the real compiled CLI") + def test_real_cli_handoff_and_default_request(self): + calls = [] + class API(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + calls.append(self.path) + if self.path == "/api/me": + valid = self.headers.get("Authorization") == "Bearer " + TOKEN + payload = {"id":"fixture-account"} + elif self.path == "/api/credits": + valid = self.headers.get("Cookie") == "cosift_session=" + TOKEN + payload = {"balance":7} + else: + valid = False + payload = {} + body = json.dumps(payload).encode() + self.send_response(200 if valid else 401) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + api = ThreadingHTTPServer(("127.0.0.1", 0), API) + thread = threading.Thread(target=api.serve_forever, daemon=True) + thread.start() + try: + self.env["COSIFT_COMMUNITY_URL"] = "http://127.0.0.1:" + str(api.server_port) + (self.bin / "cosift").unlink() + (self.bin / "cosift").symlink_to(os.environ["COSIFT_TEST_CLI"]) + installed = self.run_install("--cli") + self.assertEqual(installed.returncode, 0, installed.stderr) + command = subprocess.run( + [str(self.bin / "cosift"), "contribute", "-credits"], cwd=self.home, + env=self.env, capture_output=True, text=True, timeout=10, + ) + self.assertEqual(command.returncode, 0, command.stderr) + self.assertEqual(json.loads(command.stdout)["balance"], 7) + self.assertEqual(calls, ["/api/me", "/api/credits"]) + self.assertEqual(stat.S_IMODE(self.session.stat().st_mode), 0o600) + finally: + api.shutdown() + api.server_close() + thread.join(timeout=5) + + +unittest.main() +PY From f0b6411a2aad4c66982f172e5142c5951a54e62a Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 17 Sep 2026 21:01:43 +0300 Subject: [PATCH 2/2] Prepare installer v0.4.0 for shared production services --- README.md | 13 +++++-- docs/CLI-INSTALL.md | 79 ++++++++++++++++++++++++++++++++++++++++++ docs/HARNESSES.md | 2 +- docs/UNINSTALL.md | 6 ++-- install.sh | 10 +++--- tests/community-cli.sh | 13 +++++++ tests/run.sh | 13 ++++--- 7 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 docs/CLI-INSTALL.md diff --git a/README.md b/README.md index 18de928..7d62457 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ you run it is a reasonable thing to do. ## Install +This is installer v0.4.0. The `v1` URL follows the current compatible release; +use `v0.4.0` in its place to pin this version. + ```sh curl -fsSL https://raw.githubusercontent.com/pilot-protocol/cosift-install/v1/install.sh | sh ``` @@ -95,6 +98,11 @@ refuses and exits `5` rather than overwriting it. The script contacts the Cosift auth service (`COSIFT_AUTH_BASE`) and the Cosift MCP endpoint (`COSIFT_MCP_URL`), both shown in `--help`. When connecting an installed CLI, it also verifies the token at the web origin (`COSIFT_COMMUNITY_URL`). +This release uses the production Cloud Run origins directly: +`https://cosift-auth-udik5erlkq-uw.a.run.app` and +`https://cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp`. The stable custom auth/MCP +domains can replace them once DNS is configured; environment overrides remain +available for a different deployment. It sends your email address to the auth service in order to email you a code, and it sends the credential to the MCP endpoint to check that it works. @@ -136,9 +144,8 @@ The installer configures agents; the [web app](https://cosift.pilotprotocol.netw provides Search, Answer, Research, saved requests, followed topics, contributions and credits. Sign in there with the email used during installation. -Install the current signed binary for your platform from -[Cosift releases](https://github.com/pilot-protocol/cosift/releases), verify its -checksum and minisign signature using the release instructions, then rerun: +Install the signed v0.2.7 binary for your platform using the +[CLI download and verification instructions](docs/CLI-INSTALL.md), then rerun: ```sh sh install.sh --cli diff --git a/docs/CLI-INSTALL.md b/docs/CLI-INSTALL.md new file mode 100644 index 0000000..7c53325 --- /dev/null +++ b/docs/CLI-INSTALL.md @@ -0,0 +1,79 @@ +# Install the signed Cosift CLI + +Cosift CLI v0.2.7 connects to the same account as the agent installer and web app. +Use the official [v0.2.7 release](https://github.com/pilot-protocol/cosift/releases/tag/v0.2.7). +Each binary has a SHA-256 checksum and a minisign signature. The release also +publishes `cosift-minisign.pub`; its signing key ID is `6184E2C01CA477A6`. + +## Linux and macOS + +Install [minisign](https://jedisct1.github.io/minisign/) if it is not already +available. Then run this block. It downloads to a temporary directory, verifies +both checks, and installs only the verified binary in `~/.local/bin/cosift`. + +```sh +( + set -eu + command -v minisign >/dev/null || { echo 'Install minisign first.' >&2; exit 1; } + case "$(uname -s)/$(uname -m)" in + Linux/x86_64) asset=cosift-linux-amd64 ;; + Linux/aarch64|Linux/arm64) asset=cosift-linux-arm64 ;; + Darwin/x86_64) asset=cosift-darwin-amd64 ;; + Darwin/arm64) asset=cosift-darwin-arm64 ;; + *) echo 'Unsupported platform; see the release assets.' >&2; exit 1 ;; + esac + release=https://github.com/pilot-protocol/cosift/releases/download/v0.2.7 + scratch=$(mktemp -d) + trap 'rm -rf "$scratch"' EXIT + cd "$scratch" + for file in "$asset" "$asset.sha256" "$asset.minisig" cosift-minisign.pub; do + curl --proto '=https' --tlsv1.2 -fsSL "$release/$file" -o "$file" + done + if command -v sha256sum >/dev/null; then + sha256sum -c "$asset.sha256" + else + shasum -a 256 -c "$asset.sha256" + fi + minisign -Vm "$asset" -p cosift-minisign.pub + mkdir -p "$HOME/.local/bin" + install -m 0755 "$asset" "$HOME/.local/bin/cosift" +) +export PATH="$HOME/.local/bin:$PATH" +cosift version +``` + +Keep `~/.local/bin` on your shell's `PATH`. A checksum or signature failure stops +the block before installation. Check the release's trusted version comment is +`cosift v0.2.7` followed by the selected asset name. + +Download and run the pinned agent installer after installing the CLI: + +```sh +curl --proto '=https' --tlsv1.2 -fsSL \ + https://raw.githubusercontent.com/pilot-protocol/cosift-install/v0.4.0/install.sh \ + -o install.sh +sh install.sh --cli +cosift request -query 'Rust async runtimes' +cosift contribute -credits +``` + +The installer detects your harnesses, signs in by email or reuses their verified +Cosift token, and saves the CLI session. Existing sessions are preserved. The +web app at [cosift.pilotprotocol.network](https://cosift.pilotprotocol.network) +uses the same account when you sign in with the same email. + +## Windows + +The release includes `cosift-windows-amd64.exe`, its `.sha256` and `.minisig` +files, and `cosift-minisign.pub`. Download them from the same official release. +Compare `Get-FileHash -Algorithm SHA256 .\cosift-windows-amd64.exe` with the +checksum file, then verify with: + +```powershell +minisign -Vm .\cosift-windows-amd64.exe -p .\cosift-minisign.pub +``` + +Place the verified executable on your `PATH` as `cosift.exe`. The POSIX agent +installer currently supports Linux and macOS; Windows users can use the CLI's +explicit login options (`cosift login -help`) or run the Linux installer in WSL +with the Linux binary. diff --git a/docs/HARNESSES.md b/docs/HARNESSES.md index cbb6d3d..36a4384 100644 --- a/docs/HARNESSES.md +++ b/docs/HARNESSES.md @@ -62,7 +62,7 @@ so that removal is exact: ```toml # >>> cosift (managed by cosift-install — do not edit) [mcp_servers.cosift] -url = "https://cosift-mcp.pilotprotocol.network/v1/mcp" +url = "https://cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp" [mcp_servers.cosift.http_headers] Authorization = "Bearer ck_..." # <<< cosift diff --git a/docs/UNINSTALL.md b/docs/UNINSTALL.md index bdb74b8..ebe7f88 100644 --- a/docs/UNINSTALL.md +++ b/docs/UNINSTALL.md @@ -81,7 +81,7 @@ If you must edit the file by hand, the entry is the `"cosift"` key of the top-le ```json "cosift": { "type": "http", - "url": "https://cosift-mcp.pilotprotocol.network/v1/mcp", + "url": "https://cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp", "headers": { "Authorization": "Bearer ck_..." } } ``` @@ -101,7 +101,7 @@ comments included: ```toml # >>> cosift (managed by cosift-install — do not edit) [mcp_servers.cosift] -url = "https://cosift-mcp.pilotprotocol.network/v1/mcp" +url = "https://cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp" [mcp_servers.cosift.http_headers] Authorization = "Bearer ck_..." # <<< cosift @@ -150,7 +150,7 @@ like this: }, "cosift": { "type": "remote", - "url": "https://cosift-mcp.pilotprotocol.network/v1/mcp", + "url": "https://cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp", "headers": { "Authorization": "Bearer ck_..." } } } diff --git a/install.sh b/install.sh index d512dac..e402f82 100755 --- a/install.sh +++ b/install.sh @@ -45,7 +45,7 @@ # # POSIX sh only (this runs under dash via curl | sh). No bashisms. -VERSION="0.1.0" +VERSION="0.4.0" umask 077 @@ -57,8 +57,8 @@ fi # ---------------------------------------------------------------- configuration -COSIFT_AUTH_BASE="${COSIFT_AUTH_BASE:-https://cosift-auth.pilotprotocol.network}" -COSIFT_MCP_URL="${COSIFT_MCP_URL:-https://cosift-mcp.pilotprotocol.network/v1/mcp}" +COSIFT_AUTH_BASE="${COSIFT_AUTH_BASE:-https://cosift-auth-udik5erlkq-uw.a.run.app}" +COSIFT_MCP_URL="${COSIFT_MCP_URL:-https://cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp}" COSIFT_COMMUNITY_URL="${COSIFT_COMMUNITY_URL:-https://cosift.pilotprotocol.network}" COSIFT_EXTRA_HEADER="${COSIFT_EXTRA_HEADER:-}" @@ -3911,9 +3911,9 @@ THE ONBOARDING INTERVIEW ENVIRONMENT COSIFT_AUTH_BASE override the auth base URL - (default: https://cosift-auth.pilotprotocol.network) + (default: https://cosift-auth-udik5erlkq-uw.a.run.app) COSIFT_MCP_URL override the MCP URL - (default: https://cosift-mcp.pilotprotocol.network/v1/mcp) + (default: https://cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp) COSIFT_COMMUNITY_URL web and CLI origin; HTTPS required except localhost (default: https://cosift.pilotprotocol.network) COSIFT_EXTRA_HEADER one extra header in "Name: value" form, sent on every diff --git a/tests/community-cli.sh b/tests/community-cli.sh index 5c172c6..d574ecb 100644 --- a/tests/community-cli.sh +++ b/tests/community-cli.sh @@ -127,6 +127,19 @@ print('{"logged_in":true}') self.assertFalse(self.session.exists()) self.assertFalse(Path(self.env["CLI_CALL_LOG"]).exists()) + def test_release_defaults_use_production_origins_without_network(self): + self.env.pop("COSIFT_AUTH_BASE") + self.env.pop("COSIFT_MCP_URL") + result = self.run_install("--dry-run") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("https://cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp", result.stdout) + help_result = self.run_install("--help") + self.assertEqual(help_result.returncode, 0, help_result.stderr) + self.assertIn("cosift-install 0.4.0", help_result.stdout) + self.assertIn("https://cosift-auth-udik5erlkq-uw.a.run.app", help_result.stdout) + self.assertFalse(self.session.exists()) + self.assertFalse(Path(self.env["CLI_CALL_LOG"]).exists()) + def test_existing_session_is_never_overwritten(self): self.session.parent.mkdir(parents=True) self.session.write_text("keep this existing credential") diff --git a/tests/run.sh b/tests/run.sh index dfa952d..7e395a8 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -46,7 +46,7 @@ CASE_TABLE=( "C24|no-harness-detected-exits-3|root" "C25|preflight-missing-curl-exits-3|root" "C26|preflight-unwritable-home-exits-3|root" - "C27|default-endpoints-are-not-run-app|tester" + "C27|default-endpoints-use-production-cloud-run|tester" "C28|recovery-stale-token-falls-through|tester" "C29|token-never-leaks-outside-configs|tester" "C30|unparseable-claude-config-backed-up-before-cli|tester" @@ -1626,11 +1626,14 @@ case_C27() { # no endpoint overrides at all: the shipped defaults must be sane OUT=$(env -u COSIFT_AUTH_BASE -u COSIFT_MCP_URL \ timeout 90 sh "$INSTALL_SH" --dry-run --yes &1) RC=$? - chk_not_contains "--dry-run never shows a *.run.app URL" "$OUT" "run.app" - chk_contains "default MCP URL is the stable hostname" "$OUT" \ - "cosift-mcp.pilotprotocol.network/v1/mcp" + chk_eq "default dry-run exit code" 0 "$RC" + chk_contains "default MCP URL is the production origin" "$OUT" \ + "cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp" install_split --help - chk_not_contains "--help never shows a *.run.app URL" "$OUT" "run.app" + chk_contains "--help documents the production auth origin" "$OUT" \ + "cosift-auth-udik5erlkq-uw.a.run.app" + chk_contains "--help documents the production MCP origin" "$OUT" \ + "cosift-mcp-udik5erlkq-uw.a.run.app/v1/mcp" chk_eq "the mock saw no traffic (nothing was pointed at it)" 0 \ "$(nreq '*' /auth/start)" mock_stop