Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 3 additions & 2 deletions cli/src/mpftp/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 5 additions & 0 deletions docs/plans/wifi-webrepl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
);
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
Expand Down
220 changes: 195 additions & 25 deletions extension/src/wifi/wifi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,21 @@ 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.
*
* The design note is docs/plans/wifi-webrepl.md.
*/

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.";
Expand Down Expand Up @@ -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<string, string> {
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<string, string> = {};
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<string, string>, 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<string, string> = {};
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<string>("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<string | undefined> {
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<void> {
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<string[]>(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<void> {
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<string[]>(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).`
);
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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?"
Expand Down
Loading