diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd8edd..3c44f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ folder and broke the firmware's link. mpftp now hands the port a ready mpy-cross through `MICROPY_MPYCROSS`, so that sub-make never runs (mpftp#46). +- A board's WebREPL or bledev password now works from every front end. VS Code + reads `~/.mpftp/webrepl-passwords.json`, where the CLI, the PWA and agents + keep theirs, and a password you type in VS Code can be saved there too: it + asks once per board, and `mpftp.sharePasswords` (`ask`, `always`, `never`) + answers for all of them. Enable Wi-Fi Access says where the password ends up + (mpftp#43). ## v0.0.9 (2026-09-25) diff --git a/cli/src/mpftp/boards.py b/cli/src/mpftp/boards.py index 7994005..7e8463f 100644 --- a/cli/src/mpftp/boards.py +++ b/cli/src/mpftp/boards.py @@ -8,8 +8,9 @@ Passwords are kept apart, in ``~/.mpftp/webrepl-passwords.json``, one per board. The file is created with mode 0600 where the operating system has POSIX modes. It is plaintext on disk: anyone who can read your home directory -can read it. The VS Code extension doesn't use this file; it keeps passwords in -VS Code's SecretStorage instead. +can read it. The VS Code extension keeps its own copy in VS Code's +SecretStorage, reads this file as well, and writes a password here when the +user lets it (``mpftp.sharePasswords``), with the same keys (mpftp#43). This module runs on the side of the user's frontend (CLI or PWA server), not in the sidecar, because a Windows sidecar spawned from WSL has a different diff --git a/docs/agent-guide.md b/docs/agent-guide.md index fae01cf..6692255 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -118,6 +118,10 @@ WebREPL; `wifi disable` removes it byte for byte. Both show the change first. The password comes from `~/.mpftp/webrepl-passwords.json` for that board (`wifi password`), else `MPFTP_WEBREPL_PASSWORD`; it is at most 9 characters, and WebREPL is unencrypted, so treat it as a LAN courtesy lock. Never print it. +A board Brad set up in VS Code is in that file only if he let VS Code share +it (`mpftp.sharePasswords`); if the connect says there's no password, ask him +to connect to it once in VS Code and share it, or to run `mpftp wifi password +BOARD`. Don't read it out of the board's `boot.py`. Limits worth knowing before you rely on it: diff --git a/docs/plans/wifi-webrepl.md b/docs/plans/wifi-webrepl.md index 6e00f77..b1817a0 100644 --- a/docs/plans/wifi-webrepl.md +++ b/docs/plans/wifi-webrepl.md @@ -44,6 +44,11 @@ What phase 1 left open, as Brad decided it, and what each became. 4 to 9 characters, upstream `webrepl_setup`'s rule. The phase-1 `MPFTP_WEBREPL_PASSWORD` / `webreplPassword` still works as a fallback. No password reaches a log or an RPC reply. + Since mpftp#43 the extension reads that file as well, after its own + SecretStorage, and writes a password there when you let it + (`mpftp.sharePasswords`: ask once per board, always, or never). Before that + a board set up in VS Code was out of reach of the CLI, the PWA and agents + until its password was typed a second time. 2. **boot.py: offered, with your OK.** Over a serial connection, Enable puts a block between `# >>> mpftp wifi-access >>>` and `# <<< mpftp wifi-access <<<` at the top of `boot.py`. It imports the board's `wifi` helper, calls diff --git a/docs/user-guide.md b/docs/user-guide.md index 704233d..4fad2a5 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -65,6 +65,13 @@ joins your network at every reset, and the Connect list offers it under **Wi-Fi** by name. The first connect asks for its WebREPL password (at most 9 characters) and remembers it for that board. +The command line, the PWA and agents keep passwords in +`~/.mpftp/webrepl-passwords.json` (plaintext, readable only by you). VS Code +keeps its own in its secret storage and reads that file too. When you type a +password in VS Code, it asks once whether the others may have it; say yes and +an agent can reach the board you just set up. The setting +`mpftp.sharePasswords` (`ask`, `always`, `never`) answers for every board. + From the CLI, any board command takes `-d ws://BOARD-IP`. `mpftp wifi boards` lists the boards mpftp remembers, and `mpftp wifi find NAME` looks one up by its `.local` name. What each piece does, where passwords live, and what's diff --git a/extension/package.json b/extension/package.json index 283b668..e4a8c88 100644 --- a/extension/package.json +++ b/extension/package.json @@ -285,6 +285,21 @@ "type": "string", "default": "", "description": "Override the esptool command used to flash esp32 (e.g. a Windows python.exe on WSL so it can see COM ports). Leave empty to auto-detect." + }, + "mpftp.sharePasswords": { + "type": "string", + "enum": [ + "ask", + "always", + "never" + ], + "enumDescriptions": [ + "Ask once per board whether the CLI, the PWA and agents may use its password", + "Also save every board's password in ~/.mpftp/webrepl-passwords.json", + "Keep passwords in VS Code's secret storage only" + ], + "default": "ask", + "markdownDescription": "Where a WebREPL or bledev password you type in VS Code is kept. It always goes in VS Code's secret storage. The mpftp command line, the PWA and agents read `~/.mpftp/webrepl-passwords.json` instead (plaintext, readable only by you), so a password shared there works from all of them. VS Code reads that file too, so a password saved by the CLI or the PWA works here without asking." } } }, diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 06c6d31..684f6da 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -45,7 +45,7 @@ export async function activate(context: vscode.ExtensionContext): Promise ); bridge.seedLastDeviceFromGlobalState(); registerWifi(context); - const wifiPasswords = new WifiPasswords(context.secrets); + const wifiPasswords = new WifiPasswords(context.secrets, context.globalState); bridge.passwordFor = (device) => wifiPasswords.get(device); bridge.isKnownWifiBoard = (device) => !!uidForDevice(device); // Any connect (picker, resume, agent RPC) that finds Wi-Fi up remembers the diff --git a/extension/src/wifi/wifi.ts b/extension/src/wifi/wifi.ts index 4e45599..387113a 100644 --- a/extension/src/wifi/wifi.ts +++ b/extension/src/wifi/wifi.ts @@ -9,8 +9,13 @@ import { BoardIdentity, SidecarBridge, isWifiDevice } from "../bridge/SidecarBri * * - Remembered boards live in ~/.mpftp/config.json under "wifiBoards", keyed * by machine.unique_id() hex, the same shape mpftp.boards (CLI, PWA) uses. - * - Passwords live in VS Code's SecretStorage, one per board, never on disk - * in the clear and never in a log. + * - Passwords live in VS Code's SecretStorage, one per board, and never in a + * log. The CLI, the PWA and agents keep theirs in + * ~/.mpftp/webrepl-passwords.json (plaintext, 0600). The extension reads + * that file too, and writes a password there only when you say it may + * (mpftp.sharePasswords), so a board set up here is reachable from + * everything else (mpftp#43). Same keys as mpftp.boards: the board's uid, + * "host:HOST:PORT", or "ble:NAME". * - "Enable / Disable Wi-Fi access" shows the exact boot.py change and writes * nothing without a yes. * @@ -18,6 +23,7 @@ import { BoardIdentity, SidecarBridge, isWifiDevice } from "../bridge/SidecarBri */ const CONFIG_FILE = path.join(os.homedir(), ".mpftp", "config.json"); +const PASSWORDS_FILE = path.join(os.homedir(), ".mpftp", "webrepl-passwords.json"); const WIFI_KEY = "wifiBoards"; const DEFAULT_PORT = 8266; const SECRET_PREFIX = "mpftp.webrepl."; @@ -152,47 +158,209 @@ export function forgetBoard(uid: string): void { } } -/** One WebREPL password per board in SecretStorage: by uid, else by address. */ -export class WifiPasswords { - constructor(private readonly secrets: vscode.SecretStorage) {} +/** ``ble://NAME`` (a bledev board). */ +export function isBleDevice(device: string | undefined): boolean { + return !!device && /^ble:\/\//i.test(device.trim()); +} + +/** + * A board's keys in the password store, preferred first: its uid, else + * "host:HOST:PORT", or "ble:NAME". The same keys mpftp.boards.password_key + * uses for ~/.mpftp/webrepl-passwords.json. + */ +export function passwordKeys(deviceOrUid: string): string[] { + const value = deviceOrUid.trim(); + if (isBleDevice(value)) { + return [`ble:${value.slice("ble://".length).toLowerCase()}`]; + } + if (!isWifiDevice(value)) { + return [value.toLowerCase()]; + } + const keys: string[] = []; + const uid = uidForDevice(value); + if (uid) { + keys.push(uid); + } + const parsed = parseHost(value); + if (parsed) { + keys.push(`host:${parsed.host}:${parsed.port}`); + } + return keys; +} - private keys(deviceOrUid: string): string[] { - if (!isWifiDevice(deviceOrUid)) { - return [SECRET_PREFIX + deviceOrUid.toLowerCase()]; +/** ~/.mpftp/webrepl-passwords.json: what the CLI, the PWA and agents read. */ +export function readSharedPasswords(file: string = PASSWORDS_FILE): Record { + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; } - const keys: string[] = []; - const uid = uidForDevice(deviceOrUid); - if (uid) { - keys.push(SECRET_PREFIX + uid); + const out: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + out[String(k)] = String(v); + } + return out; + } catch { + return {}; + } +} + +/** + * Rewrite the shared file the way mpftp.boards does: sorted keys, a temp file + * created 0600 in the same folder, then a rename over the old one. + */ +export function writeSharedPasswords(data: Record, file: string = PASSWORDS_FILE): void { + const dir = path.dirname(file); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `.webrepl-${process.pid}-${Date.now()}.json`); + const sorted: Record = {}; + for (const k of Object.keys(data).sort()) { + sorted[k] = data[k]; + } + try { + fs.writeFileSync(tmp, JSON.stringify(sorted, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + try { + fs.chmodSync(tmp, 0o600); // mode is masked by the umask on create + } catch { + /* Windows: no POSIX modes; the file inherits the profile's ACL */ } - const parsed = parseHost(deviceOrUid); - if (parsed) { - keys.push(`${SECRET_PREFIX}host:${parsed.host}:${parsed.port}`); + fs.renameSync(tmp, file); + } catch (e) { + try { + fs.unlinkSync(tmp); + } catch { + /* already gone */ } - return keys; + throw e; } +} + +export type ShareMode = "ask" | "always" | "never"; + +function shareMode(): ShareMode { + const v = vscode.workspace.getConfiguration("mpftp").get("sharePasswords", "ask"); + return v === "always" || v === "never" ? v : "ask"; +} + +const DECLINED_KEY = "mpftp.passwordsKeptInVsCode"; + +/** + * One password per board (WebREPL or bledev), in SecretStorage, and in + * ~/.mpftp/webrepl-passwords.json when you let it be shared. Lookups try + * SecretStorage first, then the shared file, so a password saved by the CLI + * or the PWA works here too. + */ +export class WifiPasswords { + constructor( + private readonly secrets: vscode.SecretStorage, + private readonly state?: vscode.Memento, + private readonly file: string = PASSWORDS_FILE + ) {} async get(deviceOrUid: string): Promise { - for (const key of this.keys(deviceOrUid)) { - const value = await this.secrets.get(key); + const keys = passwordKeys(deviceOrUid); + for (const key of keys) { + const value = await this.secrets.get(SECRET_PREFIX + key); if (value) { return value; } } + const shared = readSharedPasswords(this.file); + for (const key of keys) { + if (shared[key]) { + return shared[key]; + } + } return undefined; } + /** + * Keep a password that works. It always goes in SecretStorage; it goes in + * the shared file too when mpftp.sharePasswords says so, or, set to "ask", + * when you say yes. A board you kept to VS Code isn't asked about again. + */ async set(deviceOrUid: string, password: string): Promise { - const key = this.keys(deviceOrUid)[0]; - if (key) { - await this.secrets.store(key, password); + const key = passwordKeys(deviceOrUid)[0]; + if (!key) { + return; + } + await this.secrets.store(SECRET_PREFIX + key, password); + const shared = readSharedPasswords(this.file); + if (shared[key] === password) { + return; + } + const mode = shareMode(); + if (mode === "never") { + return; } + if (mode === "ask") { + const declined = this.state?.get(DECLINED_KEY, []) ?? []; + if (declined.includes(key)) { + return; + } + const share = "Save it for all of mpftp"; + const keep = "Keep it in VS Code only"; + const choice = await vscode.window.showInformationMessage( + "Let the mpftp command line, the PWA and agents use this board's password too?", + { + modal: true, + detail: + `They read ${this.file}. It is plaintext on disk, readable only by you (mode 0600). ` + + "VS Code keeps its own copy in its secret storage either way. " + + 'The setting "mpftp.sharePasswords" answers this for every board.', + }, + share, + keep + ); + if (choice !== share) { + if (choice === keep && this.state) { + await this.state.update(DECLINED_KEY, [...declined, key]); + } + return; + } + } + shared[key] = password; + writeSharedPasswords(shared, this.file); } + /** Forget a board's password everywhere mpftp keeps one. */ async delete(deviceOrUid: string): Promise { - for (const key of this.keys(deviceOrUid)) { - await this.secrets.delete(key); + const keys = passwordKeys(deviceOrUid); + for (const key of keys) { + await this.secrets.delete(SECRET_PREFIX + key); + } + const shared = readSharedPasswords(this.file); + if (keys.some((k) => k in shared)) { + for (const key of keys) { + delete shared[key]; + } + writeSharedPasswords(shared, this.file); + } + if (this.state) { + const declined = this.state.get(DECLINED_KEY, []); + await this.state.update( + DECLINED_KEY, + declined.filter((k) => !keys.includes(k)) + ); + } + } + + /** Where a password saved now would end up, for the Enable dialog. */ + whereSaved(): string { + const mode = shareMode(); + if (mode === "never") { + return "mpftp keeps the password in VS Code's secret storage only (mpftp.sharePasswords is \"never\")."; } + if (mode === "always") { + return ( + `mpftp keeps the password in VS Code's secret storage and in ${this.file} ` + + "(plaintext, readable only by you), where the command line, the PWA and agents find it." + ); + } + return ( + "mpftp keeps the password in VS Code's secret storage, then asks whether the command line, " + + `the PWA and agents may have it too (in ${this.file}, plaintext, readable only by you).` + ); } } @@ -251,7 +419,7 @@ export async function connectWifi( title: `WebREPL password for ${device}`, prompt: /rejected/i.test(message) ? "The board said no to that password. Try again." - : "mpftp has no password saved for this board. It's kept in VS Code's secret storage.", + : "mpftp has no password saved for this board. VS Code keeps it in its secret storage.", password: true, ignoreFocusOut: true, validateInput: validateConnectPassword, @@ -399,7 +567,9 @@ export async function wifiAccessCommand( const verb = plan.delete ? "Delete boot.py" : "Write to boot.py"; const detail = plan.diff + - (action === "enable" ? "\nThe password shows as ***** here; the board gets the real one." : ""); + (action === "enable" + ? "\nThe password shows as ***** here; the board gets the real one.\n" + passwords.whereSaved() + : ""); const choice = await vscode.window.showWarningMessage( action === "enable" ? "Add this Wi-Fi block to the top of the board's boot.py?"