create-pmos 0.56.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/REMOTE.md +38 -3
- package/workspace/guide/layout.md +2 -1
- package/workspace/lib/access-jwt.js +66 -0
- package/workspace/lib/access-keys.js +104 -0
- package/workspace/lib/online-mode.js +158 -0
- package/workspace/lib/remote-access.js +5 -3
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/dest-settings.js +56 -3
- package/workspace/public/pm/frame-copy.js +19 -0
- package/workspace/replaced-tests.json +11 -0
- package/workspace/server.js +112 -9
- package/workspace/test-lists/workspace-online-mode.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T11:08:49Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T11:08:49Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T11:08:49Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/REMOTE.md
CHANGED
|
@@ -64,7 +64,7 @@ The steps below do the same thing by hand, from a PMOS checkout.
|
|
|
64
64
|
|
|
65
65
|
Under those lines it prints the phone URL as a **QR code**, and the same URL as text (`http://100.x.y.z:7433/?token=<token>`). The token is shown only at startup and never logged again. A token you supply with `PMOS_TOKEN` is never printed, so that mode prints no QR code.
|
|
66
66
|
|
|
67
|
-
3. **Open it from your phone.** Point the phone camera at the QR code and open the link. Or type the URL into the phone's browser. The server sets a sign-in cookie (
|
|
67
|
+
3. **Open it from your phone.** Point the phone camera at the QR code and open the link. Or type the URL into the phone's browser. The server sets a sign-in cookie (30 days) and redirects to `http://100.x.y.z:7433/`, so the token does not stay in the address bar or the history.
|
|
68
68
|
|
|
69
69
|
If the phone has no valid cookie, it shows a **sign-in page**. Paste the token there. The field is a password field, so the phone's password manager can save it. This is also what you see after the cookie expires, or after a restart that generated a new token.
|
|
70
70
|
|
|
@@ -123,7 +123,40 @@ Two locks protect the workspace, and each one is enforced by the server:
|
|
|
123
123
|
|
|
124
124
|
The Access check in the server is a **presence check**: it confirms that Access is in front, and it fails closed when you forget the Access application. It does not verify the assertion's signature, because that needs Cloudflare's signing keys fetched over the network, and the workspace makes no outbound requests (D79). The origin listens on loopback only, so the tunnel is the only way to reach it with the public hostname.
|
|
125
125
|
|
|
126
|
-
###
|
|
126
|
+
### The Online switch (recommended)
|
|
127
|
+
|
|
128
|
+
With the Online switch, the workspace runs the tunnel itself, and Cloudflare Access is the only login. You do not need a start line, a second terminal or the workspace token on your phone (D89).
|
|
129
|
+
|
|
130
|
+
How the switch protects the workspace:
|
|
131
|
+
|
|
132
|
+
- On the public hostname, the server checks the `Cf-Access-Jwt-Assertion` header on every request and on every terminal connection. The assertion must be signed by your Cloudflare team's key, be for your Access application's audience tag, be in date, and be for the one email you allow. If any check fails, the server returns `403`.
|
|
133
|
+
- To check the signature, the server gets your team's public keys from `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`. It does this only while Online is on, at most once an hour, plus once a minute at most for a new key. This is the only outbound request it makes for Online. It sends nothing of yours.
|
|
134
|
+
- Only the computer that runs the workspace can change the switch or its settings. A phone can see the state but cannot change it.
|
|
135
|
+
|
|
136
|
+
Do these steps one time:
|
|
137
|
+
|
|
138
|
+
1. **Create the tunnel and its DNS record** on the computer that runs the workspace:
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
brew install cloudflared # or your platform's package
|
|
142
|
+
cloudflared tunnel login
|
|
143
|
+
cloudflared tunnel create pmos
|
|
144
|
+
cloudflared tunnel route dns pmos pmos.example.com
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Write `~/.cloudflared/config.yml` as in step 4 of the manual setup below, with your hostname.
|
|
148
|
+
|
|
149
|
+
2. **Create the Access application.** In Cloudflare Zero Trust, go to **Access › Applications › Add an application › Self-hosted**. Set the subdomain and domain to your hostname. Add one policy: **Allow**, **Emails**, your email address. On the application's **Overview**, copy the **Application Audience (AUD) Tag**.
|
|
150
|
+
|
|
151
|
+
3. **Optional: sign in with Google.** Go to **Settings › Authentication › Login methods › Add new › Google**, and follow the steps there to create a Google OAuth client. In the Access application's **Login methods**, select Google and turn on **Instant Auth**, so the login goes directly to Google.
|
|
152
|
+
|
|
153
|
+
4. **Fill in the Online section.** In the workspace on that computer, open **Settings › Online**. Enter the public address, the tunnel name (`pmos`), your team domain (`<team>.cloudflareaccess.com`, shown under **Settings › Custom Pages** in Zero Trust), the AUD tag and your email. Then select **Turn on**.
|
|
154
|
+
|
|
155
|
+
The status line shows when the tunnel runs. Open your hostname on the phone and sign in with Google or the email code. The workspace remembers the switch: if it restarts while Online is on, it goes online again. Select **Turn off** to stop the tunnel. The hostname then gets `403` at once. The tunnel's output is in `online-tunnel.log` in the workspace state folder.
|
|
156
|
+
|
|
157
|
+
If `PMOS_PUBLIC_HOST` is set when the workspace starts, the switch is not available, and the manual setup below applies.
|
|
158
|
+
|
|
159
|
+
### Setup by hand (`PMOS_PUBLIC_HOST`, with the token)
|
|
127
160
|
|
|
128
161
|
1. **Pick the machine.** Use a machine that stays on: your desktop, or a small Linux VPS. Install the workspace there as usual, and sign in to your agent CLI in a workspace terminal.
|
|
129
162
|
|
|
@@ -162,7 +195,7 @@ The Access check in the server is a **presence check**: it confirms that Access
|
|
|
162
195
|
|
|
163
196
|
6. **Start the tunnel:** `cloudflared tunnel run pmos`. To keep it running after a reboot, install it as a service (`sudo cloudflared service install`).
|
|
164
197
|
|
|
165
|
-
7. **Open `https://pm-os.app/`.** Cloudflare asks for your login. The workspace then shows its sign-in page. Paste the token. The workspace sets a `Secure` session cookie for
|
|
198
|
+
7. **Open `https://pm-os.app/`.** Cloudflare asks for your login. The workspace then shows its sign-in page. Paste the token. The workspace sets a `Secure` session cookie for 30 days. Set the Access application's session duration to 1 month as well, so each device signs in about once a month.
|
|
166
199
|
|
|
167
200
|
### Check that it is closed
|
|
168
201
|
|
|
@@ -184,6 +217,8 @@ Run these from a machine that is **not** signed in to Access:
|
|
|
184
217
|
- **`403` "the public host is only reachable through Cloudflare Access"** — the request reached the workspace without passing Access. Create the Access application for the hostname (Setup step 3).
|
|
185
218
|
- **`403` "forbidden origin/host" on the public hostname** — `PMOS_PUBLIC_HOST` is not set, or it names another hostname, or the tunnel rewrites the `Host` header. Remove `httpHostHeader` from the tunnel's ingress rule.
|
|
186
219
|
- **A local reverse proxy now gets `401`** — it adds a forwarding header, so its requests need the token. This is intended.
|
|
220
|
+
- **`403` "your Cloudflare sign-in could not be verified for this workspace"** (Online switch) — check the AUD tag, the team domain and the email in **Settings › Online**. The email must be the one you sign in with. If the Online section says the keys did not load, check the team domain.
|
|
221
|
+
- **Cloudflare error 1033** — the tunnel is not running. In **Settings › Online**, check the status line, and read `online-tunnel.log` in the workspace state folder. Another `cloudflared` for the same tunnel (for example a service you installed) also serves the hostname.
|
|
187
222
|
|
|
188
223
|
- **"refusing to bind … only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted"** — you passed `PMOS_BIND_ADDR` (or `PMOS_TAILNET_IP`) that isn't loopback or tailnet. Use your real `tailscale ip -4` value.
|
|
189
224
|
- **"no Tailscale tailnet address … was found"** — Tailscale isn't up on this machine. Run `tailscale up`, or set `PMOS_TAILNET_IP`.
|
|
@@ -25,12 +25,13 @@ Each place is drawn when its run is built. Until then the place says which run b
|
|
|
25
25
|
|
|
26
26
|
## Settings
|
|
27
27
|
|
|
28
|
-
Settings holds settings only, in
|
|
28
|
+
Settings holds settings only, in six sections:
|
|
29
29
|
|
|
30
30
|
- **Repositories**: every registered repository. Press a repository to make it the current one. Press its ⋯ for **Rename** or **Remove from PMOS**. Each asks in a sheet, and a remove asks you to confirm. **Add repository** shows the scan of a folder before you add it.
|
|
31
31
|
- **Profiles**: the Claude profiles a session can start with. They open in the detail.
|
|
32
32
|
- **New sessions**: the default CLI and the default model for a new session in the dock.
|
|
33
33
|
- **Validate consent**: the consent of the current repository. Turn it on or off here. **Open Validate** opens the Validate page as a tab.
|
|
34
|
+
- **Online**: puts this workspace at your own address, such as `pmos.pm-os.app`, through a Cloudflare tunnel. Enter the public address, the tunnel name, your Cloudflare team domain, the Access audience (AUD) tag and your email, then press **Turn on**. The workspace runs the tunnel and remembers the switch when it restarts. On your phone, open the address and sign in with Cloudflare Access (Google or an email code). The workspace does not ask for its token there. Only the computer that runs the workspace can change this section. A phone sees whether it is on. [REMOTE.md](../REMOTE.md) has the one-time Cloudflare steps.
|
|
34
35
|
- **Theme**: Dark or Light.
|
|
35
36
|
|
|
36
37
|
Your work is never in Settings. It is in Today, Initiatives, Board and Reports.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* access-jwt.js — checks a Cloudflare Access assertion (workspace-online-mode, D89).
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare Access adds `Cf-Access-Jwt-Assertion` to every request it lets through to the origin: a JSON Web
|
|
5
|
+
* Token signed RS256 with one of the team's keys. While Online is on, a request to the public hostname passes
|
|
6
|
+
* on this token instead of the workspace token, so every check below must hold or the request is refused:
|
|
7
|
+
*
|
|
8
|
+
* - three base64url parts, a JSON header and payload;
|
|
9
|
+
* - header.alg is RS256 (never `none`, never HMAC) and header.kid names a key the caller holds;
|
|
10
|
+
* - the RSA-SHA256 signature over `<header>.<payload>` verifies with that key;
|
|
11
|
+
* - iss is `https://<team domain>`;
|
|
12
|
+
* - aud (a string or a list) includes the configured audience tag;
|
|
13
|
+
* - exp is in the future and nbf (when present) is not, with 60 seconds of clock skew;
|
|
14
|
+
* - email is the one allowed address (compared without case).
|
|
15
|
+
*
|
|
16
|
+
* Pure: no I/O and no clock of its own (`now` is passed in, in seconds). The keys come from lib/access-keys.js.
|
|
17
|
+
* Nothing here logs the token, a claim or a key (D08); a refusal carries a short reason for tests and the
|
|
18
|
+
* status line, never the token.
|
|
19
|
+
*/
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
|
|
22
|
+
const SKEW = 60; // seconds
|
|
23
|
+
|
|
24
|
+
function b64urlJson(part) {
|
|
25
|
+
try { return JSON.parse(Buffer.from(String(part), 'base64url').toString('utf8')); } catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Split a compact JWT. Returns null for anything that is not three non-empty base64url parts with JSON in the first two. */
|
|
29
|
+
function parse(token) {
|
|
30
|
+
if (typeof token !== 'string' || token.length > 16384) return null;
|
|
31
|
+
const parts = token.split('.');
|
|
32
|
+
if (parts.length !== 3 || parts.some((p) => !p || !/^[A-Za-z0-9_-]+$/.test(p))) return null;
|
|
33
|
+
const header = b64urlJson(parts[0]);
|
|
34
|
+
const payload = b64urlJson(parts[1]);
|
|
35
|
+
if (!header || typeof header !== 'object' || !payload || typeof payload !== 'object') return null;
|
|
36
|
+
return { header, payload, signingInput: parts[0] + '.' + parts[1], signature: Buffer.from(parts[2], 'base64url') };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* verify(token, { keyFor, teamDomain, audience, email, now }) → { ok: true, email } | { ok: false, reason, kid? }
|
|
41
|
+
* keyFor(kid) → a crypto KeyObject or null. A refusal for an unknown key carries `kid`, so the caller can
|
|
42
|
+
* refetch the key set once and try again.
|
|
43
|
+
*/
|
|
44
|
+
function verify(token, { keyFor, teamDomain, audience, email, now } = {}) {
|
|
45
|
+
const t = parse(token);
|
|
46
|
+
if (!t) return { ok: false, reason: 'malformed' };
|
|
47
|
+
if (t.header.alg !== 'RS256') return { ok: false, reason: 'algorithm' };
|
|
48
|
+
const kid = typeof t.header.kid === 'string' ? t.header.kid : '';
|
|
49
|
+
const key = kid && typeof keyFor === 'function' ? keyFor(kid) : null;
|
|
50
|
+
if (!key) return { ok: false, reason: 'unknown key', kid };
|
|
51
|
+
let good = false;
|
|
52
|
+
try { good = crypto.verify('RSA-SHA256', Buffer.from(t.signingInput), key, t.signature); } catch { good = false; }
|
|
53
|
+
if (!good) return { ok: false, reason: 'signature' };
|
|
54
|
+
const p = t.payload;
|
|
55
|
+
if (!teamDomain || p.iss !== 'https://' + teamDomain) return { ok: false, reason: 'issuer' };
|
|
56
|
+
const auds = Array.isArray(p.aud) ? p.aud : [p.aud];
|
|
57
|
+
if (!audience || !auds.includes(audience)) return { ok: false, reason: 'audience' };
|
|
58
|
+
const n = Number(now);
|
|
59
|
+
if (typeof p.exp !== 'number' || !(p.exp + SKEW > n)) return { ok: false, reason: 'expired' };
|
|
60
|
+
if (p.nbf != null && (typeof p.nbf !== 'number' || p.nbf - SKEW > n)) return { ok: false, reason: 'not yet valid' };
|
|
61
|
+
const who = typeof p.email === 'string' ? p.email.trim().toLowerCase() : '';
|
|
62
|
+
if (!email || !who || who !== String(email).trim().toLowerCase()) return { ok: false, reason: 'email' };
|
|
63
|
+
return { ok: true, email: who };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { verify, parse, SKEW };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* access-keys.js — the team's public signing keys for Cloudflare Access (workspace-online-mode, D89).
|
|
3
|
+
*
|
|
4
|
+
* THE ONE NETWORK CALL OF D89. This module, and no other file, may GET
|
|
5
|
+
* https://<team>.cloudflareaccess.com/cdn-cgi/access/certs
|
|
6
|
+
* where <team>.cloudflareaccess.com is the PM's configured team domain. The request carries no body, no
|
|
7
|
+
* cookie and no credential, and the answer is Cloudflare's PUBLIC key set. no-network.test.js names this
|
|
8
|
+
* file as the second exception beside D79's lib/typesafe.js.
|
|
9
|
+
*
|
|
10
|
+
* When it fetches (createStore):
|
|
11
|
+
* - only when asked: the server asks only while Online is on (D89 rule 1);
|
|
12
|
+
* - a key set older than an hour is refreshed on the next request that needs a key;
|
|
13
|
+
* - a key id the store does not hold triggers one refetch, at most once a minute;
|
|
14
|
+
* - a failed fetch keeps the keys already held. With none held, every lookup returns null and the guard
|
|
15
|
+
* refuses (fail closed).
|
|
16
|
+
* Keys are held in memory only. Nothing here logs a key, a response or a header (D08).
|
|
17
|
+
*/
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const https = require('https');
|
|
20
|
+
|
|
21
|
+
const TEAM_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.cloudflareaccess\.com$/;
|
|
22
|
+
const REFRESH_MS = 60 * 60 * 1000;
|
|
23
|
+
const MISS_MS = 60 * 1000;
|
|
24
|
+
const TIMEOUT_MS = 5000;
|
|
25
|
+
const MAX_BYTES = 64 * 1024;
|
|
26
|
+
|
|
27
|
+
function validTeamDomain(d) { return typeof d === 'string' && TEAM_RE.test(d); }
|
|
28
|
+
function certsUrl(teamDomain) { return 'https://' + teamDomain + '/cdn-cgi/access/certs'; }
|
|
29
|
+
|
|
30
|
+
/** GET the key set. Resolves with the parsed JSON, rejects on any failure. Refuses any host but a team domain. */
|
|
31
|
+
function fetchCerts(teamDomain) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
if (!validTeamDomain(teamDomain)) return reject(new Error('not a cloudflareaccess.com team domain'));
|
|
34
|
+
const req = https.get(certsUrl(teamDomain), { headers: { accept: 'application/json' }, timeout: TIMEOUT_MS }, (res) => {
|
|
35
|
+
if (res.statusCode !== 200) { res.resume(); return reject(new Error('status ' + res.statusCode)); }
|
|
36
|
+
let size = 0; const chunks = [];
|
|
37
|
+
res.on('data', (c) => { size += c.length; if (size > MAX_BYTES) { req.destroy(); reject(new Error('too large')); } else chunks.push(c); });
|
|
38
|
+
res.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(new Error('not JSON')); } });
|
|
39
|
+
});
|
|
40
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
41
|
+
req.on('error', reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A JWKS object → Map(kid → KeyObject). RSA keys only; anything unreadable is skipped. */
|
|
46
|
+
function keysFromJwks(jwks) {
|
|
47
|
+
const out = new Map();
|
|
48
|
+
for (const k of (jwks && Array.isArray(jwks.keys) ? jwks.keys : [])) {
|
|
49
|
+
if (!k || k.kty !== 'RSA' || typeof k.kid !== 'string' || !k.n || !k.e) continue;
|
|
50
|
+
try { out.set(k.kid, crypto.createPublicKey({ key: { kty: 'RSA', n: k.n, e: k.e }, format: 'jwk' })); } catch { /* skip */ }
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* createStore({ teamDomain, fetch = fetchCerts, clock = Date.now }) → {
|
|
57
|
+
* keyFor(kid) the KeyObject held for kid, or null (sync; what access-jwt.verify calls)
|
|
58
|
+
* ensure() Promise: refresh when the set is missing or older than an hour
|
|
59
|
+
* refetchFor(kid) Promise<boolean>: one refetch for an unknown kid, at most once a minute; true when kid is now held
|
|
60
|
+
* status() { keys, fetchedAt, lastError } — counts and times only, never a key
|
|
61
|
+
* }
|
|
62
|
+
*/
|
|
63
|
+
function createStore({ teamDomain, fetch = fetchCerts, clock = Date.now } = {}) {
|
|
64
|
+
let keys = new Map();
|
|
65
|
+
let fetchedAt = 0;
|
|
66
|
+
let lastTry = 0;
|
|
67
|
+
let lastError = null;
|
|
68
|
+
let inflight = null;
|
|
69
|
+
|
|
70
|
+
function load() {
|
|
71
|
+
if (inflight) return inflight;
|
|
72
|
+
lastTry = clock();
|
|
73
|
+
inflight = Promise.resolve()
|
|
74
|
+
.then(() => fetch(teamDomain))
|
|
75
|
+
.then((jwks) => {
|
|
76
|
+
const next = keysFromJwks(jwks);
|
|
77
|
+
if (!next.size) throw new Error('no usable keys');
|
|
78
|
+
keys = next; fetchedAt = clock(); lastError = null;
|
|
79
|
+
})
|
|
80
|
+
.catch((e) => { lastError = String((e && e.message) || 'fetch failed'); })
|
|
81
|
+
.finally(() => { inflight = null; });
|
|
82
|
+
return inflight;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
keyFor: (kid) => keys.get(kid) || null,
|
|
86
|
+
ensure() {
|
|
87
|
+
if (inflight) return inflight; // a request that arrives during the first fetch waits for it
|
|
88
|
+
const now = clock();
|
|
89
|
+
if (keys.size && now - fetchedAt < REFRESH_MS) return Promise.resolve();
|
|
90
|
+
if (lastTry && now - lastTry < MISS_MS) return Promise.resolve(); // a failing fetch is retried at most once a minute
|
|
91
|
+
return load();
|
|
92
|
+
},
|
|
93
|
+
async refetchFor(kid) {
|
|
94
|
+
if (inflight) await inflight;
|
|
95
|
+
if (keys.has(kid)) return true;
|
|
96
|
+
if (clock() - lastTry < MISS_MS) return false;
|
|
97
|
+
await load();
|
|
98
|
+
return keys.has(kid);
|
|
99
|
+
},
|
|
100
|
+
status: () => ({ keys: keys.size, fetchedAt: fetchedAt || null, lastError }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { createStore, fetchCerts, keysFromJwks, validTeamDomain, certsUrl, REFRESH_MS, MISS_MS };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* online-mode.js — the Online switch: its settings record and the tunnel it runs (workspace-online-mode, PRD O1, O2).
|
|
3
|
+
*
|
|
4
|
+
* THE RECORD. `<runtime state>/online.json`, in the per-user runtime directory lib/state-root.js keeps outside every
|
|
5
|
+
* repository, written with mode 0600 through a temporary file and a rename (the typesafe-settings.js pattern):
|
|
6
|
+
* { on, host, tunnel, team, audience, email }
|
|
7
|
+
* host the public hostname, validated by remote-access.parsePublicHost (pmos.pm-os.app)
|
|
8
|
+
* tunnel the Cloudflare tunnel name or id that `cloudflared tunnel run` takes (pmos)
|
|
9
|
+
* team the Cloudflare team domain, <team>.cloudflareaccess.com
|
|
10
|
+
* audience the Access application's audience tag, 64 hex characters
|
|
11
|
+
* email the one address allowed in
|
|
12
|
+
* A field that fails its check is refused with the reason, and nothing is written.
|
|
13
|
+
*
|
|
14
|
+
* THE TUNNEL. createTunnel() runs `cloudflared tunnel --no-autoupdate run <tunnel>` as a child of the server, with its
|
|
15
|
+
* output appended to `<runtime state>/online-tunnel.log`. stop() ends it. If it exits while Online is on, it is started
|
|
16
|
+
* again after 5 seconds. cloudflared is the PM's own installed program; it is found on PATH or in the two Homebrew
|
|
17
|
+
* folders, because a workspace started at login has a short PATH.
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const childProcess = require('child_process');
|
|
22
|
+
const remoteAccess = require('./remote-access');
|
|
23
|
+
const accessKeys = require('./access-keys');
|
|
24
|
+
|
|
25
|
+
const FILE = 'online.json';
|
|
26
|
+
const LOG = 'online-tunnel.log';
|
|
27
|
+
const FIELDS = ['host', 'tunnel', 'team', 'audience', 'email'];
|
|
28
|
+
const RESTART_MS = 5000;
|
|
29
|
+
|
|
30
|
+
function file(stateDir) { return path.join(stateDir, FILE); }
|
|
31
|
+
|
|
32
|
+
const CHECKS = {
|
|
33
|
+
host: (v) => {
|
|
34
|
+
if (!v) return 'give the public hostname, such as pmos.pm-os.app';
|
|
35
|
+
const r = remoteAccess.parsePublicHost(v);
|
|
36
|
+
return r.refused ? 'the hostname must be a bare DNS name, such as pmos.pm-os.app (no https://, port or path)' : null;
|
|
37
|
+
},
|
|
38
|
+
tunnel: (v) => (/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v) ? null : 'give the tunnel name you created with cloudflared, such as pmos'),
|
|
39
|
+
team: (v) => (accessKeys.validTeamDomain(v) ? null : 'give your Cloudflare team domain, such as myteam.cloudflareaccess.com'),
|
|
40
|
+
audience: (v) => (/^[a-f0-9]{64}$/.test(v) ? null : "give the Access application's Audience (AUD) tag: 64 letters and digits from the application's Overview"),
|
|
41
|
+
email: (v) => (/^[^\s@]{1,64}@[^\s@]+\.[^\s@]{2,}$/.test(v) && v.length <= 254 ? null : 'give the email address you sign in with'),
|
|
42
|
+
};
|
|
43
|
+
function norm(field, v) {
|
|
44
|
+
const s = String(v == null ? '' : v).trim();
|
|
45
|
+
return field === 'tunnel' ? s : s.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function read(stateDir) {
|
|
49
|
+
const out = { on: false, host: '', tunnel: '', team: '', audience: '', email: '' };
|
|
50
|
+
try {
|
|
51
|
+
const st = fs.lstatSync(file(stateDir));
|
|
52
|
+
if (!st.isFile()) return out;
|
|
53
|
+
const o = JSON.parse(fs.readFileSync(file(stateDir), 'utf8')) || {};
|
|
54
|
+
for (const f of FIELDS) if (typeof o[f] === 'string' && !CHECKS[f](o[f])) out[f] = o[f];
|
|
55
|
+
out.on = o.on === true && complete(out);
|
|
56
|
+
} catch { /* none yet */ }
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function complete(s) { return FIELDS.every((f) => s[f] && !CHECKS[f](s[f])); }
|
|
61
|
+
|
|
62
|
+
/** The first field that is missing or invalid, as { field, error }, or null when all five are good. */
|
|
63
|
+
function firstProblem(s) {
|
|
64
|
+
for (const f of FIELDS) { const e = CHECKS[f](s[f] || ''); if (e) return { field: f, error: e }; }
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function write(stateDir, data) {
|
|
69
|
+
const target = file(stateDir);
|
|
70
|
+
try { if (fs.lstatSync(target).isSymbolicLink()) return { ok: false, error: 'the settings file is a symlink and was not written' }; }
|
|
71
|
+
catch { /* not there yet */ }
|
|
72
|
+
const tmp = target + '.' + process.pid + '.tmp';
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
75
|
+
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
76
|
+
fs.chmodSync(tmp, 0o600);
|
|
77
|
+
fs.renameSync(tmp, target);
|
|
78
|
+
return { ok: true };
|
|
79
|
+
} catch (e) {
|
|
80
|
+
try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ }
|
|
81
|
+
return { ok: false, error: 'the online settings could not be saved (' + String((e && e.code) || 'write failed') + ')' };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* save(stateDir, patch) → { ok, settings } | { ok: false, field?, error }
|
|
87
|
+
* patch may carry any of the five fields and `on`. Fields are validated first; `on: true` then needs all five.
|
|
88
|
+
* Whether cloudflared is installed is the caller's check, because it is not a property of the record.
|
|
89
|
+
*/
|
|
90
|
+
function save(stateDir, patch = {}) {
|
|
91
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, error: 'the body must be an object' };
|
|
92
|
+
const next = read(stateDir);
|
|
93
|
+
for (const f of FIELDS) {
|
|
94
|
+
if (!(f in patch)) continue;
|
|
95
|
+
const v = norm(f, patch[f]);
|
|
96
|
+
const e = CHECKS[f](v);
|
|
97
|
+
if (e) return { ok: false, field: f, error: e };
|
|
98
|
+
next[f] = v;
|
|
99
|
+
}
|
|
100
|
+
if ('on' in patch) {
|
|
101
|
+
if (typeof patch.on !== 'boolean') return { ok: false, field: 'on', error: 'on must be true or false' };
|
|
102
|
+
if (patch.on) { const p = firstProblem(next); if (p) return { ok: false, ...p }; }
|
|
103
|
+
next.on = patch.on;
|
|
104
|
+
}
|
|
105
|
+
const w = write(stateDir, next);
|
|
106
|
+
return w.ok ? { ok: true, settings: next } : { ok: false, error: w.error };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The cloudflared binary: PATH first, then the two Homebrew folders. null when absent. */
|
|
110
|
+
function findCloudflared({ env = process.env, exists = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } } } = {}) {
|
|
111
|
+
const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean).concat(['/opt/homebrew/bin', '/usr/local/bin']);
|
|
112
|
+
const name = process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
|
|
113
|
+
for (const d of dirs) { const p = path.join(d, name); if (exists(p)) return p; }
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* createTunnel({ stateDir, spawn, find, setTimer }) → { start(name), stop(), status() }
|
|
119
|
+
* status() → { running, pid, since, lastExit } — lastExit is { code, at } of the last exit, or null.
|
|
120
|
+
*/
|
|
121
|
+
function createTunnel({ stateDir, spawn = childProcess.spawn, find = findCloudflared, setTimer = setTimeout } = {}) {
|
|
122
|
+
let child = null; let want = null; let since = null; let lastExit = null; let timer = null;
|
|
123
|
+
function launch() {
|
|
124
|
+
timer = null;
|
|
125
|
+
if (!want || child) return;
|
|
126
|
+
const bin = find();
|
|
127
|
+
if (!bin) { lastExit = { code: null, at: new Date().toISOString(), error: 'cloudflared is not installed' }; return; }
|
|
128
|
+
let fd = 'ignore';
|
|
129
|
+
try { fs.mkdirSync(stateDir, { recursive: true }); fd = fs.openSync(path.join(stateDir, LOG), 'a', 0o600); } catch { fd = 'ignore'; }
|
|
130
|
+
try {
|
|
131
|
+
child = spawn(bin, ['tunnel', '--no-autoupdate', 'run', want], { stdio: ['ignore', fd, fd], detached: false });
|
|
132
|
+
} catch (e) {
|
|
133
|
+
child = null; lastExit = { code: null, at: new Date().toISOString(), error: 'cloudflared could not start' };
|
|
134
|
+
}
|
|
135
|
+
if (typeof fd === 'number') { try { fs.closeSync(fd); } catch { /* ignore */ } }
|
|
136
|
+
if (!child) return;
|
|
137
|
+
since = new Date().toISOString();
|
|
138
|
+
const mine = child;
|
|
139
|
+
mine.on('error', () => {});
|
|
140
|
+
mine.on('exit', (code) => {
|
|
141
|
+
if (child !== mine) return;
|
|
142
|
+
child = null; since = null; lastExit = { code, at: new Date().toISOString() };
|
|
143
|
+
if (want && !timer) timer = setTimer(launch, RESTART_MS);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
start(name) { want = name; if (!child && !timer) launch(); },
|
|
148
|
+
stop() {
|
|
149
|
+
want = null;
|
|
150
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
151
|
+
const c = child; child = null; since = null;
|
|
152
|
+
if (c) { try { c.kill('SIGTERM'); } catch { /* already gone */ } }
|
|
153
|
+
},
|
|
154
|
+
status: () => ({ running: !!child, pid: child ? child.pid : null, since, lastExit }),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { read, save, complete, firstProblem, findCloudflared, createTunnel, FIELDS, FILE, LOG };
|
|
@@ -373,9 +373,11 @@ function stripTokenParam(url) {
|
|
|
373
373
|
// The cookie a valid token seeds. HttpOnly (no script needs it) + SameSite=Strict + the Origin/Host
|
|
374
374
|
// allowlist keep a foreign site from riding it. No Secure flag on the tailnet: it speaks plain HTTP
|
|
375
375
|
// inside WireGuard. On the public host the browser is on HTTPS (TLS ends at the edge), so the cookie
|
|
376
|
-
// is Secure there and is never sent over plain HTTP.
|
|
376
|
+
// is Secure there and is never sent over plain HTTP. 30 days (workspace-remember-device; was 7), so a
|
|
377
|
+
// device signs in about once a month: the PM's own phone, behind Cloudflare Access or the tailnet.
|
|
378
|
+
const SESSION_MAX_AGE = 30 * 24 * 60 * 60;
|
|
377
379
|
function sessionCookie(token, { secure = false } = {}) {
|
|
378
|
-
return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Strict; Path=/; Max-Age
|
|
380
|
+
return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Strict; Path=/; Max-Age=${SESSION_MAX_AGE}`;
|
|
379
381
|
}
|
|
380
382
|
|
|
381
383
|
// The sign-in form posts application/x-www-form-urlencoded `token=<t>` to /auth.
|
|
@@ -425,7 +427,7 @@ module.exports = {
|
|
|
425
427
|
LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
|
|
426
428
|
resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
|
|
427
429
|
checkHostOrigin, isAllowedOrigin, hostnameAllowed,
|
|
428
|
-
parsePublicHost, PROXY_HEADERS, ACCESS_HEADER, isProxied, edgeAllowed,
|
|
430
|
+
parsePublicHost, PROXY_HEADERS, ACCESS_HEADER, isProxied, edgeAllowed, hostnameOf,
|
|
429
431
|
COOKIE_NAME, extractCookieToken, presentedToken,
|
|
430
432
|
phoneUrl, isPageNavigation, stripTokenParam, sessionCookie, AUTH_PATH, parseAuthForm, signInPage,
|
|
431
433
|
};
|
package/workspace/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/* pm/dest-settings.js — the Settings destination, the frame's ⚙ (pm-os-absorb Run 3, slice 3A; PRD R9).
|
|
2
2
|
*
|
|
3
|
-
* Settings holds settings only, in
|
|
3
|
+
* Settings holds settings only, in six sections and in this order:
|
|
4
4
|
* Repositories every registered repository as a row (open it to make it current; ⋯ for more), and Add repository,
|
|
5
5
|
* which draws the existing add-repository flow (view-addrepo.js) in place
|
|
6
6
|
* Profiles one row that opens the Claude profiles in the detail
|
|
7
7
|
* New sessions the default CLI and the default model the dock's + Session applies (dock.js)
|
|
8
8
|
* Validate consent the current repository's consent switch (GET and POST /api/validate/repo)
|
|
9
|
+
* Online the Online switch and its five settings (GET and POST /api/online; workspace-online-mode, D89)
|
|
9
10
|
* Theme Dark or Light
|
|
10
11
|
* The work itself (what needs you, initiatives, items, reports) is never here.
|
|
11
12
|
*
|
|
@@ -26,6 +27,7 @@ import { COPY } from './frame-copy.js';
|
|
|
26
27
|
const P = COPY.page;
|
|
27
28
|
const roots = new WeakMap(); // host -> { root, sig }
|
|
28
29
|
const consentByWs = new Map(); // ws -> { on: boolean | null, error, at, pending }
|
|
30
|
+
const online = { data: null, error: null, note: null, at: 0, pending: false }; // GET /api/online, and the last refusal
|
|
29
31
|
let addOpen = false;
|
|
30
32
|
let addMod = null;
|
|
31
33
|
|
|
@@ -52,11 +54,29 @@ async function setConsent(ws, on) {
|
|
|
52
54
|
setState({});
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
function readOnline(force) {
|
|
58
|
+
if (FIXTURE || online.pending) return;
|
|
59
|
+
if (!force && Date.now() - online.at < 10000) return;
|
|
60
|
+
online.pending = true;
|
|
61
|
+
pmGet('/api/online').then((j) => {
|
|
62
|
+
online.pending = false; online.at = Date.now();
|
|
63
|
+
if (j && j.ok !== false) { online.data = j; online.error = null; } else online.error = P.onlineUnread;
|
|
64
|
+
setState({});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function setOnline(patch) {
|
|
69
|
+
const j = await pmPost('/api/online', patch);
|
|
70
|
+
if (j && j.ok !== false) { online.data = j; online.note = null; } else online.note = (j && j.error) || P.onlineUnread;
|
|
71
|
+
online.at = Date.now();
|
|
72
|
+
setState({});
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
const section = (id, title, kids, action) => el('section', { class: 'fr-set__section', 'data-fr-section': id },
|
|
56
76
|
el('div', { class: 'fr-set__head' }, el('h2', { class: 'fr-set__title' }, title), action || null),
|
|
57
77
|
kids);
|
|
58
78
|
|
|
59
|
-
/* ── the
|
|
79
|
+
/* ── the six sections ───────────────────────────────────────────────────────────────────────────────────────────── */
|
|
60
80
|
|
|
61
81
|
function reposSection(scope) {
|
|
62
82
|
const tuples = repoTuples();
|
|
@@ -166,6 +186,38 @@ function validateSection(scope) {
|
|
|
166
186
|
el('span', { 'data-fr': 'consent-state', 'data-on': known ? String(c.on) : 'unread' }, line), toggle, open)]);
|
|
167
187
|
}
|
|
168
188
|
|
|
189
|
+
function onlineSection() {
|
|
190
|
+
readOnline(false);
|
|
191
|
+
const d = online.data;
|
|
192
|
+
if (!d) return section('online', P.online, [el('p', { class: 'fr-set__note' }, online.error || '')]);
|
|
193
|
+
if (d.envHost) return section('online', P.online, [el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': 'env' }, P.onlineEnv(d.envHost))]);
|
|
194
|
+
const lines = [el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': String(!!d.on) }, d.on ? P.onlineOn(d.host) : P.onlineOff)];
|
|
195
|
+
if (d.on) {
|
|
196
|
+
const r = d.running || {};
|
|
197
|
+
lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-tunnel' },
|
|
198
|
+
r.running ? P.onlineRunning : r.lastExit ? P.onlineExited(r.lastExit.code) : P.onlineStarting));
|
|
199
|
+
if (d.keys && !d.keys.keys) lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-keys' }, d.keys.lastError ? P.onlineKeysError(d.keys.lastError) : P.onlineKeysWait));
|
|
200
|
+
}
|
|
201
|
+
if (!d.canChange) return section('online', P.online, [...lines, el('p', { class: 'fr-set__note' }, P.onlineRemote)]);
|
|
202
|
+
const f = (key, label, placeholder) => field({
|
|
203
|
+
label, value: d[key] || '', placeholder,
|
|
204
|
+
onCommit: (v) => setOnline({ [key]: v }),
|
|
205
|
+
attrs: { 'data-fr': 'online-' + key, disabled: d.on ? true : undefined },
|
|
206
|
+
});
|
|
207
|
+
const fields = el('div', { class: 'fr-set__fields' },
|
|
208
|
+
f('host', P.onlineHost, 'pmos.example.com'), f('tunnel', P.onlineTunnel, 'pmos'), f('team', P.onlineTeam, 'myteam.cloudflareaccess.com'),
|
|
209
|
+
f('audience', P.onlineAudience, ''), f('email', P.onlineEmail, 'you@example.com'));
|
|
210
|
+
const blocked = !d.on && (!d.complete ? P.onlineNeedFields : !d.cloudflared ? P.onlineNoCloudflared : null);
|
|
211
|
+
const toggle = button({
|
|
212
|
+
label: d.on ? P.turnOff : P.turnOn, variant: 'outline', size: 'sm', disabled: !!blocked,
|
|
213
|
+
attrs: { 'data-fr': 'online-toggle' }, onClick: () => setOnline({ on: !d.on }),
|
|
214
|
+
});
|
|
215
|
+
const kids = [el('p', { class: 'fr-set__note' }, P.onlineIntro), fields, el('div', { class: 'fr-set__line' }, ...lines, toggle)];
|
|
216
|
+
if (blocked) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-blocked' }, blocked));
|
|
217
|
+
if (online.note) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-error', role: 'alert' }, online.note));
|
|
218
|
+
return section('online', P.online, kids);
|
|
219
|
+
}
|
|
220
|
+
|
|
169
221
|
function themeSection() {
|
|
170
222
|
return section('theme', P.theme, [tabs({
|
|
171
223
|
variant: 'segmented', role: 'radiogroup', ariaLabel: P.theme, value: state.theme,
|
|
@@ -182,7 +234,7 @@ function signature(scope) {
|
|
|
182
234
|
const addKeys = addOpen ? Object.keys(state).filter((k) => /^add[A-Z]/.test(k)).sort().map((k) => [k, state[k]]) : null;
|
|
183
235
|
let add = '';
|
|
184
236
|
try { add = JSON.stringify(addKeys); } catch { add = String(Date.now()); }
|
|
185
|
-
return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod]) + add;
|
|
237
|
+
return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note]) + add;
|
|
186
238
|
}
|
|
187
239
|
|
|
188
240
|
/** True while the PM is typing in the page or a menu of it is open: a rebuild then would take the focus away. */
|
|
@@ -208,6 +260,7 @@ export function mountDestination(host, ctx) {
|
|
|
208
260
|
profilesSection(),
|
|
209
261
|
sessionsSection(),
|
|
210
262
|
validateSection(scope),
|
|
263
|
+
onlineSection(),
|
|
211
264
|
themeSection(),
|
|
212
265
|
]);
|
|
213
266
|
}
|
|
@@ -150,6 +150,25 @@ export const COPY = {
|
|
|
150
150
|
theme: 'Theme',
|
|
151
151
|
dark: 'Dark',
|
|
152
152
|
light: 'Light',
|
|
153
|
+
online: 'Online',
|
|
154
|
+
onlineIntro: 'Reach this workspace from your phone at your own address. The workspace runs the Cloudflare tunnel, and you sign in with Cloudflare Access (Google or an email code). Only this computer can change it.',
|
|
155
|
+
onlineHost: 'Public address',
|
|
156
|
+
onlineTunnel: 'Tunnel name',
|
|
157
|
+
onlineTeam: 'Cloudflare team domain',
|
|
158
|
+
onlineAudience: 'Access audience (AUD) tag',
|
|
159
|
+
onlineEmail: 'Email allowed in',
|
|
160
|
+
onlineOn: (host) => `Online at https://${host}/`,
|
|
161
|
+
onlineOff: 'Offline. Only this computer can reach the workspace.',
|
|
162
|
+
onlineRunning: 'The tunnel is running.',
|
|
163
|
+
onlineStarting: 'The tunnel is starting.',
|
|
164
|
+
onlineExited: (code) => `The tunnel stopped${code == null ? '' : ` (exit code ${code})`}. It starts again in a few seconds. Its log is online-tunnel.log in the workspace state folder.`,
|
|
165
|
+
onlineKeysWait: 'Cloudflare\'s sign-in keys are not loaded yet. Sign-ins are refused until they are.',
|
|
166
|
+
onlineKeysError: (e) => `Cloudflare\'s sign-in keys did not load (${e}). Check the team domain. Sign-ins are refused until they load.`,
|
|
167
|
+
onlineNeedFields: 'Fill in all five fields to turn Online on.',
|
|
168
|
+
onlineNoCloudflared: 'cloudflared is not installed. Install it (brew install cloudflared), then turn Online on.',
|
|
169
|
+
onlineEnv: (host) => `Online at https://${host}/, set by PMOS_PUBLIC_HOST when the workspace started. It signs in with the token. Restart the workspace without PMOS_PUBLIC_HOST to use this switch.`,
|
|
170
|
+
onlineRemote: 'Only the computer that runs the workspace can change this.',
|
|
171
|
+
onlineUnread: 'The Online settings did not load.',
|
|
153
172
|
},
|
|
154
173
|
};
|
|
155
174
|
|
|
@@ -69,6 +69,17 @@
|
|
|
69
69
|
"names": [
|
|
70
70
|
"(a) calibration: the audit on specimen A gives exactly Today 17, Initiatives 24, Board 33, Reports 14 outside the dock"
|
|
71
71
|
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "SOM-D89",
|
|
75
|
+
"file": "workspace/no-network.test.js",
|
|
76
|
+
"run": "workspace-online-mode",
|
|
77
|
+
"ref": "D89 (ratified by PM wawan on a decision card, 2026-10-06) and planning/prd/workspace-online-mode-prd.md O5",
|
|
78
|
+
"reason": "D89 adds a second named HTTP client, lib/access-keys.js (a GET of the PM's Cloudflare Access team's public key set while Online is on). The two assertions that named lib/typesafe.js as the only client now name both sanctioned modules; three new assertions pin the new module to cloudflareaccess.com, GET only, and its two requirers. The D79 assertions are unchanged.",
|
|
79
|
+
"names": [
|
|
80
|
+
"no-network: the server process opens no HTTP client outside the one TypeSafe module (D79)",
|
|
81
|
+
"no-network: lib/typesafe.js is the ONLY file that opens an HTTP client"
|
|
82
|
+
]
|
|
72
83
|
}
|
|
73
84
|
],
|
|
74
85
|
"entries": [
|
package/workspace/server.js
CHANGED
|
@@ -288,6 +288,64 @@ const layoutModel = require('./lib/layout'); // the arrangement model
|
|
|
288
288
|
// REFUSED (BIND.refused) so the server exits — there is no code path to a public interface.
|
|
289
289
|
const BIND = remoteAccess.resolveBinds({ env: process.env });
|
|
290
290
|
const TOKEN = BIND.token || null; // non-loopback peers must present it; never logged (D08)
|
|
291
|
+
// workspace-online-mode (D89): the Online switch. Its record lives in the per-user state folder; while it is on, the
|
|
292
|
+
// workspace runs the tunnel itself and admits the public hostname, and a request to that hostname passes on a verified
|
|
293
|
+
// Cloudflare Access assertion for the PM's email instead of the token. An environment-set PMOS_PUBLIC_HOST wins and keeps
|
|
294
|
+
// the token (PRD O7). ONLINE.keys is the team's public key set (lib/access-keys.js, the one D89 fetch), only while on.
|
|
295
|
+
const onlineMode = require('./lib/online-mode');
|
|
296
|
+
const accessJwt = require('./lib/access-jwt');
|
|
297
|
+
const accessKeys = require('./lib/access-keys');
|
|
298
|
+
const ONLINE = { settings: onlineMode.read(STATE), keys: null, keysTeam: null, fetchKeys: accessKeys.fetchCerts, find: onlineMode.findCloudflared };
|
|
299
|
+
const tunnel = onlineMode.createTunnel({ stateDir: STATE, find: () => ONLINE.find() });
|
|
300
|
+
/** The public hostname the guards admit right now: the environment's, else the Online switch's while it is on. */
|
|
301
|
+
function publicHost() { return BIND.publicHost || (ONLINE.settings.on ? ONLINE.settings.host : null); }
|
|
302
|
+
/** True when this request is addressed to the Online switch's hostname, so the Access assertion decides it (D89). */
|
|
303
|
+
function viaOnlineHost(headers) {
|
|
304
|
+
return !BIND.publicHost && ONLINE.settings.on && remoteAccess.hostnameOf((headers || {}).host) === ONLINE.settings.host;
|
|
305
|
+
}
|
|
306
|
+
/** Bring the tunnel and the key store in line with the record. Called at start, and after every change of the record. */
|
|
307
|
+
function applyOnline() {
|
|
308
|
+
const s = ONLINE.settings;
|
|
309
|
+
if (s.on && !BIND.publicHost) {
|
|
310
|
+
if (!ONLINE.keys || ONLINE.keysTeam !== s.team) {
|
|
311
|
+
ONLINE.keys = accessKeys.createStore({ teamDomain: s.team, fetch: (d) => ONLINE.fetchKeys(d) });
|
|
312
|
+
ONLINE.keysTeam = s.team;
|
|
313
|
+
ONLINE.keys.ensure().catch(() => {});
|
|
314
|
+
}
|
|
315
|
+
if (ONLINE.tunnelName && ONLINE.tunnelName !== s.tunnel) tunnel.stop();
|
|
316
|
+
ONLINE.tunnelName = s.tunnel;
|
|
317
|
+
tunnel.start(s.tunnel);
|
|
318
|
+
} else {
|
|
319
|
+
tunnel.stop();
|
|
320
|
+
ONLINE.tunnelName = null;
|
|
321
|
+
ONLINE.keys = null; ONLINE.keysTeam = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** The D89 decision for one request: { ok: true } or { ok: false, reason }. The assertion is never logged (D08). */
|
|
325
|
+
async function accessVerdict(headers) {
|
|
326
|
+
const s = ONLINE.settings; const store = ONLINE.keys;
|
|
327
|
+
if (!store) return { ok: false, reason: 'no keys' };
|
|
328
|
+
try { await store.ensure(); } catch { /* the store records its own error */ }
|
|
329
|
+
const token = (headers || {})['cf-access-jwt-assertion'];
|
|
330
|
+
const opts = { keyFor: store.keyFor, teamDomain: s.team, audience: s.audience, email: s.email, now: Math.floor(Date.now() / 1000) };
|
|
331
|
+
let v = accessJwt.verify(token, opts);
|
|
332
|
+
if (!v.ok && v.reason === 'unknown key' && v.kid && await store.refetchFor(v.kid)) v = accessJwt.verify(token, opts);
|
|
333
|
+
return v;
|
|
334
|
+
}
|
|
335
|
+
/** The Online record and state, as GET /api/online returns it. Nothing secret is in it: the fields are not credentials. */
|
|
336
|
+
function onlineView(req) {
|
|
337
|
+
const s = ONLINE.settings;
|
|
338
|
+
return {
|
|
339
|
+
ok: true, on: s.on, host: s.host, tunnel: s.tunnel, team: s.team, audience: s.audience, email: s.email,
|
|
340
|
+
complete: onlineMode.complete(s), envHost: BIND.publicHost || null, cloudflared: !!ONLINE.find(),
|
|
341
|
+
running: tunnel.status(), keys: ONLINE.keys ? ONLINE.keys.status() : null,
|
|
342
|
+
canChange: onlineChangeAllowed(req),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/** PRD O3: only the desktop changes the switch — a loopback peer that came through no proxy. */
|
|
346
|
+
function onlineChangeAllowed(req) {
|
|
347
|
+
return remoteAccess.isLoopbackAddr(req.socket && req.socket.remoteAddress) && !remoteAccess.isProxied(req.headers || {}, publicHost());
|
|
348
|
+
}
|
|
291
349
|
// Per-OS default shell. On Windows there is no `bash` — use ComSpec/PowerShell.
|
|
292
350
|
function defaultShell() {
|
|
293
351
|
if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe';
|
|
@@ -7132,6 +7190,23 @@ async function handleRequest(req, res) {
|
|
|
7132
7190
|
if (p === '/api/typesafe' && req.method === 'GET') {
|
|
7133
7191
|
return json(res, { ok: true, key: tsSettings.status(STATE), host: tsClient.HOST });
|
|
7134
7192
|
}
|
|
7193
|
+
/* ── workspace-online-mode (D89): the Online switch. Any signed-in device may read it; only the desktop changes it (O3). ── */
|
|
7194
|
+
if (p === '/api/online' && req.method === 'GET') return json(res, onlineView(req));
|
|
7195
|
+
if (p === '/api/online' && req.method === 'POST') {
|
|
7196
|
+
let body = {};
|
|
7197
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
7198
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7199
|
+
if (!onlineChangeAllowed(req)) return json(res, { ok: false, error: 'Online can only be changed on the computer that runs the workspace' }, 403);
|
|
7200
|
+
const press = pmGuard.requirePagePress(req, body);
|
|
7201
|
+
if (!press.ok) return json(res, { ok: false, error: press.error }, press.status || 403);
|
|
7202
|
+
if (BIND.publicHost && body.on === true) return json(res, { ok: false, error: `PMOS_PUBLIC_HOST=${BIND.publicHost} was set when the workspace started, so it is already online that way. Restart it without PMOS_PUBLIC_HOST to use the switch.` }, 409);
|
|
7203
|
+
if (body.on === true && !ONLINE.find()) return json(res, { ok: false, field: 'on', error: 'cloudflared is not installed. Install it (brew install cloudflared), then switch Online on again.' }, 409);
|
|
7204
|
+
const out = onlineMode.save(STATE, body);
|
|
7205
|
+
if (!out.ok) return json(res, { ok: false, field: out.field || null, error: out.error }, out.field ? 400 : 500);
|
|
7206
|
+
ONLINE.settings = out.settings;
|
|
7207
|
+
applyOnline();
|
|
7208
|
+
return json(res, onlineView(req));
|
|
7209
|
+
}
|
|
7135
7210
|
if (p === '/api/typesafe/key' && (req.method === 'PUT' || req.method === 'DELETE')) {
|
|
7136
7211
|
let body = {};
|
|
7137
7212
|
if (req.method === 'PUT') {
|
|
@@ -7950,20 +8025,33 @@ function writeChangeContext(dir) {
|
|
|
7950
8025
|
function httpGuard(req, res) {
|
|
7951
8026
|
// Origin/Host allowlist FIRST (D64 A1): reject a cross-site / DNS-rebinding request before the
|
|
7952
8027
|
// token exemption for loopback can be reached. Header values are never logged (D08).
|
|
7953
|
-
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost:
|
|
8028
|
+
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: publicHost() })) {
|
|
7954
8029
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
7955
8030
|
res.end(JSON.stringify({ ok: false, error: 'forbidden origin/host' }));
|
|
7956
8031
|
return;
|
|
7957
8032
|
}
|
|
7958
8033
|
// workspace-public-host: a request to the public hostname must have passed Cloudflare Access. A tunnel
|
|
7959
8034
|
// with no Access application in front fails here, before even the sign-in form can be reached.
|
|
7960
|
-
if (!remoteAccess.edgeAllowed({ headers: req.headers, publicHost:
|
|
8035
|
+
if (!remoteAccess.edgeAllowed({ headers: req.headers, publicHost: publicHost() })) {
|
|
7961
8036
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
7962
8037
|
res.end(JSON.stringify({ ok: false, error: 'the public host is only reachable through Cloudflare Access' }));
|
|
7963
8038
|
return;
|
|
7964
8039
|
}
|
|
8040
|
+
// workspace-online-mode (D89): on the Online switch's hostname the verified Access assertion is the whole check.
|
|
8041
|
+
// It replaces the token, so neither the sign-in form nor the token cookie is reachable there.
|
|
8042
|
+
if (viaOnlineHost(req.headers)) {
|
|
8043
|
+
accessVerdict(req.headers).then((v) => {
|
|
8044
|
+
if (v.ok) return handleRequest(req, res);
|
|
8045
|
+
res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
8046
|
+
res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace' }));
|
|
8047
|
+
}, () => {
|
|
8048
|
+
res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
8049
|
+
res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace' }));
|
|
8050
|
+
});
|
|
8051
|
+
return;
|
|
8052
|
+
}
|
|
7965
8053
|
// The cookie is Secure when the browser reached us on the public host (HTTPS at the edge).
|
|
7966
|
-
const secureCookie = !!
|
|
8054
|
+
const secureCookie = !!publicHost() && remoteAccess.isProxied(req.headers, publicHost());
|
|
7967
8055
|
// Token may arrive as an Authorization header, a ?token= query param, or the seeded cookie. A
|
|
7968
8056
|
// browser only carries the token on the FIRST navigation URL — it cannot attach a header/query to
|
|
7969
8057
|
// sub-resource loads (CSS/JS) or the WS upgrade — so the documented "open http://…/?token=<t>"
|
|
@@ -7986,7 +8074,7 @@ function httpGuard(req, res) {
|
|
|
7986
8074
|
});
|
|
7987
8075
|
return;
|
|
7988
8076
|
}
|
|
7989
|
-
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress, headers: req.headers, publicHost:
|
|
8077
|
+
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress, headers: req.headers, publicHost: publicHost(),
|
|
7990
8078
|
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
7991
8079
|
// A phone's browser gets a page it can act on, not JSON. API calls, scripts and anything else
|
|
7992
8080
|
// that is not a page load keep the JSON 401 they had.
|
|
@@ -8002,7 +8090,7 @@ function httpGuard(req, res) {
|
|
|
8002
8090
|
// navigation, so the browser's subsequent sub-resource + WS-upgrade requests authenticate without it
|
|
8003
8091
|
// in the URL. HttpOnly (JS never needs it) + SameSite=Strict + the D64 Origin/Host allowlist keep a
|
|
8004
8092
|
// foreign site from riding it; no Secure flag because the tailnet speaks plain HTTP (WireGuard-encrypted).
|
|
8005
|
-
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers,
|
|
8093
|
+
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers, publicHost())) {
|
|
8006
8094
|
let qTok = null;
|
|
8007
8095
|
try { qTok = new URL(req.url || '', 'http://localhost').searchParams.get('token'); } catch { /* ignore */ }
|
|
8008
8096
|
if (qTok && remoteAccess.tokenMatches(qTok.trim(), TOKEN)) {
|
|
@@ -8028,20 +8116,31 @@ function handleUpgrade(req, socket, head) {
|
|
|
8028
8116
|
// Same origin/host allowlist on the WS upgrade handshake (D64 A1): a cross-origin page must not be
|
|
8029
8117
|
// able to open ws://127.0.0.1:<port> and spawn/type into a shell. Rejected BEFORE the socket is
|
|
8030
8118
|
// accepted, and before loopback's token exemption applies. Header values never logged (D08).
|
|
8031
|
-
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost:
|
|
8032
|
-
|| !remoteAccess.edgeAllowed({ headers: req.headers, publicHost:
|
|
8119
|
+
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: publicHost() })
|
|
8120
|
+
|| !remoteAccess.edgeAllowed({ headers: req.headers, publicHost: publicHost() })) {
|
|
8033
8121
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
8034
8122
|
socket.destroy();
|
|
8035
8123
|
return;
|
|
8036
8124
|
}
|
|
8125
|
+
// workspace-online-mode (D89): the same verified-assertion rule as the HTTP guard, before the socket is accepted.
|
|
8126
|
+
if (viaOnlineHost(req.headers)) {
|
|
8127
|
+
accessVerdict(req.headers).then((v) => {
|
|
8128
|
+
if (!v.ok) { socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); socket.destroy(); return; }
|
|
8129
|
+
acceptUpgrade(req, socket, head);
|
|
8130
|
+
}, () => { socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); socket.destroy(); });
|
|
8131
|
+
return;
|
|
8132
|
+
}
|
|
8037
8133
|
// A browser WebSocket cannot set a header or a ?token= — it authenticates via the same-origin cookie
|
|
8038
8134
|
// seeded on the page load. presentedToken() resolves header → query → subprotocol → cookie.
|
|
8039
|
-
if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress, headers: req.headers, publicHost:
|
|
8135
|
+
if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress, headers: req.headers, publicHost: publicHost(),
|
|
8040
8136
|
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
8041
8137
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
8042
8138
|
socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
|
|
8043
8139
|
return;
|
|
8044
8140
|
}
|
|
8141
|
+
acceptUpgrade(req, socket, head);
|
|
8142
|
+
}
|
|
8143
|
+
function acceptUpgrade(req, socket, head) {
|
|
8045
8144
|
// R25 (bind): a session started with an initiative is bound to it, a PM-only act. Only a socket the page opened with
|
|
8046
8145
|
// this launch's nonce (`?pmos-nonce=`) may bind; any other socket still spawns, unbound, and is told why.
|
|
8047
8146
|
const bind = pmGuard.requirePmUpgrade(req, { act: 'bind' });
|
|
@@ -8184,6 +8283,7 @@ if (require.main === module) {
|
|
|
8184
8283
|
if (shuttingDown) return; shuttingDown = true;
|
|
8185
8284
|
for (const s of sessions.values()) if (s.pty) { try { persist(s); } catch {} }
|
|
8186
8285
|
for (const l of listeners) { try { l.close(); } catch {} }
|
|
8286
|
+
try { tunnel.stop(); } catch {} // workspace-online-mode: the tunnel is this server's child and ends with it
|
|
8187
8287
|
try { fs.unlinkSync(PID_FILE); } catch {} // stop being discoverable the moment we stop serving
|
|
8188
8288
|
process.exit(0);
|
|
8189
8289
|
};
|
|
@@ -8216,12 +8316,15 @@ if (require.main === module) {
|
|
|
8216
8316
|
// one started by `pmos` — otherwise the next `pmos` cannot see it and starts a second server.
|
|
8217
8317
|
try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
|
|
8218
8318
|
for (const line of remoteAccess.startupLines(BIND, PORT, { ansi: !!process.stdout.isTTY && !process.env.NO_COLOR })) console.log(line);
|
|
8319
|
+
// workspace-online-mode: a workspace that stopped while Online was on comes back online (PRD O2)
|
|
8320
|
+
applyOnline();
|
|
8321
|
+
if (ONLINE.settings.on && !BIND.publicHost) console.log(` online: https://${ONLINE.settings.host}/ (Cloudflare Access sign-in, tunnel ${ONLINE.settings.tunnel})`);
|
|
8219
8322
|
// bring in what another machine pushed while this one was off (D70 as amended); never on the request path
|
|
8220
8323
|
{ const t = setTimeout(() => { autoSync.startup().catch(() => {}); }, 3000); if (t.unref) t.unref(); }
|
|
8221
8324
|
});
|
|
8222
8325
|
});
|
|
8223
8326
|
}
|
|
8224
|
-
module.exports = { server, spawnSession, removeSession, renameSession, sessions, libraryTree, readTextDoc,
|
|
8327
|
+
module.exports = { server, online: { ONLINE, applyOnline, tunnel, publicHost }, spawnSession, removeSession, renameSession, sessions, libraryTree, readTextDoc,
|
|
8225
8328
|
registerWorkspace, forgetWorkspace, workspaces, library, initiativeIndex, fsList,
|
|
8226
8329
|
// R1 — exported so the suite can assert the CARRY-THROUGH: branchInfo separates an ordinary
|
|
8227
8330
|
// non-repo folder from a real git fault, and the whole legibility defect was that distinction
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "workspace-online-mode (D89): the Cloudflare Access assertion check, the key store's fetch rules, the Online record and tunnel process, and the guards and /api/online driven on server.js as a library. No request leaves the process.",
|
|
3
|
+
"modules": ["online-mode.test.js"],
|
|
4
|
+
"seconds": { "online-mode.test.js": 4 }
|
|
5
|
+
}
|