create-pmos 0.55.5 → 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/docs.md +96 -0
- package/workspace/guide/index.md +3 -1
- 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/doc-sync-store.js +322 -0
- package/workspace/lib/doc-sync.js +225 -0
- package/workspace/lib/docs-cli.js +221 -0
- package/workspace/lib/online-mode.js +158 -0
- package/workspace/lib/pmos-verbs.js +3 -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/app.js +2 -2
- package/workspace/public/pm/components.css +146 -0
- package/workspace/public/pm/dest-docs.js +405 -0
- package/workspace/public/pm/dest-settings.js +56 -3
- package/workspace/public/pm/frame-copy.js +19 -0
- package/workspace/public/pm/registry.js +2 -0
- package/workspace/replaced-tests.json +25 -0
- package/workspace/server.js +215 -9
- package/workspace/skills/doc-check.skill +40 -0
- package/workspace/skills/doc-sync.skill +34 -0
- package/workspace/test-lists/docs-three-way-sync.json +6 -0
- package/workspace/test-lists/workspace-online-mode.json +5 -0
package/workspace/server.js
CHANGED
|
@@ -128,6 +128,12 @@ const profilesLib = require('./lib/profiles'); // readProfiles(homeDir)
|
|
|
128
128
|
const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
|
|
129
129
|
const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
|
|
130
130
|
const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
|
|
131
|
+
const docSyncStore = require('./lib/doc-sync-store');
|
|
132
|
+
// The session each repository's Check now started (workspace id → session id), so a second press while it runs refuses.
|
|
133
|
+
const docCheckSessions = new Map();
|
|
134
|
+
// The session each Use … version press started (workspace id|path|kind → { session, use }), so the line says "Updating…"
|
|
135
|
+
// before the session has opened its sync, and a second press on the same copy refuses.
|
|
136
|
+
const docSyncSessions = new Map();
|
|
131
137
|
const planItems = require('./lib/plan-items.js'); // pm-os-absorb Run 1 — the ONE writer and fold of plan-items.jsonl (control graph)
|
|
132
138
|
const planTree = require('./lib/plan-tree.js'); // …and the Breakdown layout: derived stages, owed rows, the Board columns
|
|
133
139
|
const routeModules = require('./lib/route-modules.js'); // pm-os-absorb Runs 2–5 — each run's routes in its own lib/routes-<name>.js
|
|
@@ -282,6 +288,64 @@ const layoutModel = require('./lib/layout'); // the arrangement model
|
|
|
282
288
|
// REFUSED (BIND.refused) so the server exits — there is no code path to a public interface.
|
|
283
289
|
const BIND = remoteAccess.resolveBinds({ env: process.env });
|
|
284
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
|
+
}
|
|
285
349
|
// Per-OS default shell. On Windows there is no `bash` — use ComSpec/PowerShell.
|
|
286
350
|
function defaultShell() {
|
|
287
351
|
if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe';
|
|
@@ -6389,6 +6453,103 @@ async function handleRequest(req, res) {
|
|
|
6389
6453
|
pushSessions();
|
|
6390
6454
|
return json(res, { ok: true, id: s.id, initiative: next, attributed_by: s.attributedBy, previous });
|
|
6391
6455
|
}
|
|
6456
|
+
// ── Docs: the sync signal for documents linked to Google Docs and Confluence (docs-three-way-sync) ──────────────
|
|
6457
|
+
// GET reads the links (the artifact-link store), the check readings and the files, and decides one signal per copy in
|
|
6458
|
+
// lib/doc-sync.js. The POSTs add or remove a link, or start the read-only `doc-check` skill as a session — the same
|
|
6459
|
+
// path a scheduled agent skill takes. No route here reads Google or Confluence: a session holding the PM's own
|
|
6460
|
+
// connectors does, and records what it read (D53). There is no sync and no schedule: the PM asked for a signal.
|
|
6461
|
+
if (p === '/api/docs' && req.method === 'GET') {
|
|
6462
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
6463
|
+
const root = w ? w.root : SELF_ROOT;
|
|
6464
|
+
const v = docSyncStore.view(root);
|
|
6465
|
+
if (!v.ok) return json(res, v, 500);
|
|
6466
|
+
const alive = (id) => !!(id && sessions.get(id) && sessions.get(id).state !== 'exited');
|
|
6467
|
+
const sess = docCheckSessions.get(w ? w.id : '') || null;
|
|
6468
|
+
const live = alive(sess) ? sess : null;
|
|
6469
|
+
// Group by initiative: the library's own attribution of each file (pmos-shaped repositories only).
|
|
6470
|
+
const owner = new Map();
|
|
6471
|
+
try {
|
|
6472
|
+
if (wsShape(root) === 'pmos') {
|
|
6473
|
+
for (const g of initiativeIndex(root).initiatives) for (const a of g.artifacts) if (!owner.has(a.path)) owner.set(a.path, { id: g.id, title: g.title || g.id });
|
|
6474
|
+
}
|
|
6475
|
+
} catch { /* no grouping: every document is under Other */ }
|
|
6476
|
+
const wid = w ? w.id : '';
|
|
6477
|
+
const docs = v.docs.map((d) => ({
|
|
6478
|
+
...d,
|
|
6479
|
+
initiative: owner.get(d.path) || (d.initiative_id ? { id: d.initiative_id, title: d.initiative_id } : null),
|
|
6480
|
+
links: d.links.map((l) => {
|
|
6481
|
+
if (l.syncing) return l;
|
|
6482
|
+
const ps = docSyncSessions.get(`${wid}|${d.path}|${l.kind}`);
|
|
6483
|
+
return ps && alive(ps.session) ? { ...l, syncing: { run: null, use: ps.use, started_at: ps.at } } : l;
|
|
6484
|
+
}),
|
|
6485
|
+
}));
|
|
6486
|
+
return json(res, { ...v, docs, ws: w ? w.id : null, session: live, connectors: fourPanel.connectors(os.homedir()) });
|
|
6487
|
+
}
|
|
6488
|
+
if (p === '/api/docs/files' && req.method === 'GET') {
|
|
6489
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
6490
|
+
const root = w ? w.root : SELF_ROOT;
|
|
6491
|
+
const found = docSyncStore.findFiles(root, String(url.searchParams.get('q') || '').slice(0, 200));
|
|
6492
|
+
const v = docSyncStore.view(root);
|
|
6493
|
+
const linked = new Set(v.ok ? v.docs.map((d) => d.path) : []);
|
|
6494
|
+
return json(res, { ok: true, files: found.filter((f) => !linked.has(f.path)) });
|
|
6495
|
+
}
|
|
6496
|
+
if ((p === '/api/docs/link' || p === '/api/docs/unlink' || p === '/api/docs/check' || p === '/api/docs/sync') && req.method === 'POST') {
|
|
6497
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6498
|
+
const g = pmGuard.requirePagePress(req, body);
|
|
6499
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6500
|
+
const w = body.ws ? workspaces.get(String(body.ws)) : getWs(null);
|
|
6501
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 404);
|
|
6502
|
+
if (p === '/api/docs/link') {
|
|
6503
|
+
const r = docSyncStore.addLink(w.root, { path: String(body.path || '').trim(), kind: body.kind, target: String(body.target || '').trim() });
|
|
6504
|
+
return json(res, r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? 200 : 400);
|
|
6505
|
+
}
|
|
6506
|
+
if (p === '/api/docs/unlink') {
|
|
6507
|
+
const r = docSyncStore.removeLink(w.root, { path: String(body.path || ''), kind: body.kind });
|
|
6508
|
+
return json(res, r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? 200 : 400);
|
|
6509
|
+
}
|
|
6510
|
+
// Use … version (amended 2026-10-06): one doc-sync session for one copy, in the direction the PM pressed. The page
|
|
6511
|
+
// holds the press for five seconds behind Undo before it posts here. The session writes one side with the PM's own
|
|
6512
|
+
// connectors and records it through `pmos docs sync-start|sync-finish|sync-fail`; nothing here touches a copy.
|
|
6513
|
+
if (p === '/api/docs/sync') {
|
|
6514
|
+
const rel = String(body.path || '').trim();
|
|
6515
|
+
const kind = String(body.kind || '');
|
|
6516
|
+
const use = String(body.use || '');
|
|
6517
|
+
if (!['pmos', 'copy'].includes(use)) return json(res, { ok: false, error: 'say which version to use: pmos or copy' }, 400);
|
|
6518
|
+
const v = docSyncStore.view(w.root);
|
|
6519
|
+
if (!v.ok) return json(res, v, 500);
|
|
6520
|
+
const doc = v.docs.find((d) => d.path === rel);
|
|
6521
|
+
const link = doc && doc.links.find((l) => l.kind === kind);
|
|
6522
|
+
if (!link) return json(res, { ok: false, error: `${rel} has no such copy` }, 404);
|
|
6523
|
+
const key = `${w.id}|${rel}|${kind}`;
|
|
6524
|
+
const prevSync = docSyncSessions.get(key);
|
|
6525
|
+
if (link.syncing || (prevSync && sessions.get(prevSync.session) && sessions.get(prevSync.session).state !== 'exited')) {
|
|
6526
|
+
return json(res, { ok: false, error: 'that copy is already being synced' }, 409);
|
|
6527
|
+
}
|
|
6528
|
+
const task = taskById('doc-sync', w.root);
|
|
6529
|
+
if (!task || !task.prompt) return json(res, { ok: false, error: 'the doc-sync skill is not installed' }, 500);
|
|
6530
|
+
const ask = `\n\nThis sync:\n- file: ${rel}\n- copy: ${kind} (${link.target})\n- use: ${use}`;
|
|
6531
|
+
const s = spawnSession({ name: 'Doc sync', agent: task.agent || 'claude', ws: w.id, origin: 'docs:sync',
|
|
6532
|
+
initialInput: task.prompt + ask + '\r', modelAsk: { skillTier: task.tier || null, skill: task.skill } });
|
|
6533
|
+
const started = s.state !== 'exited';
|
|
6534
|
+
if (started) docSyncSessions.set(key, { session: s.id, use, at: new Date().toISOString() });
|
|
6535
|
+
return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
|
|
6536
|
+
}
|
|
6537
|
+
// Check now: one check per repository. A session already running, or a check the ledger still holds open, refuses.
|
|
6538
|
+
const prev = docCheckSessions.get(w.id);
|
|
6539
|
+
if (prev && sessions.get(prev) && sessions.get(prev).state !== 'exited') {
|
|
6540
|
+
return json(res, { ok: false, error: 'a check is already running', session: prev }, 409);
|
|
6541
|
+
}
|
|
6542
|
+
const v = docSyncStore.view(w.root);
|
|
6543
|
+
if (v.ok && v.open) return json(res, { ok: false, error: `a check is already running (started ${v.open.started_at})` }, 409);
|
|
6544
|
+
if (v.ok && !v.docs.length) return json(res, { ok: false, error: 'no document is linked yet — add a link first' }, 400);
|
|
6545
|
+
const task = taskById('doc-check', w.root);
|
|
6546
|
+
if (!task || !task.prompt) return json(res, { ok: false, error: 'the doc-check skill is not installed' }, 500);
|
|
6547
|
+
const s = spawnSession({ name: 'Doc check', agent: task.agent || 'claude', ws: w.id, origin: 'docs:check',
|
|
6548
|
+
initialInput: task.prompt + '\r', modelAsk: { skillTier: task.tier || null, skill: task.skill } });
|
|
6549
|
+
const started = s.state !== 'exited';
|
|
6550
|
+
if (started) docCheckSessions.set(w.id, s.id);
|
|
6551
|
+
return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
|
|
6552
|
+
}
|
|
6392
6553
|
if (p === '/api/initiative/link' && req.method === 'POST') {
|
|
6393
6554
|
// Recording that an artifact corresponds to an external page. This writes a DECLARATION and
|
|
6394
6555
|
// makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
|
|
@@ -7029,6 +7190,23 @@ async function handleRequest(req, res) {
|
|
|
7029
7190
|
if (p === '/api/typesafe' && req.method === 'GET') {
|
|
7030
7191
|
return json(res, { ok: true, key: tsSettings.status(STATE), host: tsClient.HOST });
|
|
7031
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
|
+
}
|
|
7032
7210
|
if (p === '/api/typesafe/key' && (req.method === 'PUT' || req.method === 'DELETE')) {
|
|
7033
7211
|
let body = {};
|
|
7034
7212
|
if (req.method === 'PUT') {
|
|
@@ -7847,20 +8025,33 @@ function writeChangeContext(dir) {
|
|
|
7847
8025
|
function httpGuard(req, res) {
|
|
7848
8026
|
// Origin/Host allowlist FIRST (D64 A1): reject a cross-site / DNS-rebinding request before the
|
|
7849
8027
|
// token exemption for loopback can be reached. Header values are never logged (D08).
|
|
7850
|
-
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() })) {
|
|
7851
8029
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
7852
8030
|
res.end(JSON.stringify({ ok: false, error: 'forbidden origin/host' }));
|
|
7853
8031
|
return;
|
|
7854
8032
|
}
|
|
7855
8033
|
// workspace-public-host: a request to the public hostname must have passed Cloudflare Access. A tunnel
|
|
7856
8034
|
// with no Access application in front fails here, before even the sign-in form can be reached.
|
|
7857
|
-
if (!remoteAccess.edgeAllowed({ headers: req.headers, publicHost:
|
|
8035
|
+
if (!remoteAccess.edgeAllowed({ headers: req.headers, publicHost: publicHost() })) {
|
|
7858
8036
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
7859
8037
|
res.end(JSON.stringify({ ok: false, error: 'the public host is only reachable through Cloudflare Access' }));
|
|
7860
8038
|
return;
|
|
7861
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
|
+
}
|
|
7862
8053
|
// The cookie is Secure when the browser reached us on the public host (HTTPS at the edge).
|
|
7863
|
-
const secureCookie = !!
|
|
8054
|
+
const secureCookie = !!publicHost() && remoteAccess.isProxied(req.headers, publicHost());
|
|
7864
8055
|
// Token may arrive as an Authorization header, a ?token= query param, or the seeded cookie. A
|
|
7865
8056
|
// browser only carries the token on the FIRST navigation URL — it cannot attach a header/query to
|
|
7866
8057
|
// sub-resource loads (CSS/JS) or the WS upgrade — so the documented "open http://…/?token=<t>"
|
|
@@ -7883,7 +8074,7 @@ function httpGuard(req, res) {
|
|
|
7883
8074
|
});
|
|
7884
8075
|
return;
|
|
7885
8076
|
}
|
|
7886
|
-
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress, headers: req.headers, publicHost:
|
|
8077
|
+
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress, headers: req.headers, publicHost: publicHost(),
|
|
7887
8078
|
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
7888
8079
|
// A phone's browser gets a page it can act on, not JSON. API calls, scripts and anything else
|
|
7889
8080
|
// that is not a page load keep the JSON 401 they had.
|
|
@@ -7899,7 +8090,7 @@ function httpGuard(req, res) {
|
|
|
7899
8090
|
// navigation, so the browser's subsequent sub-resource + WS-upgrade requests authenticate without it
|
|
7900
8091
|
// in the URL. HttpOnly (JS never needs it) + SameSite=Strict + the D64 Origin/Host allowlist keep a
|
|
7901
8092
|
// foreign site from riding it; no Secure flag because the tailnet speaks plain HTTP (WireGuard-encrypted).
|
|
7902
|
-
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers,
|
|
8093
|
+
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers, publicHost())) {
|
|
7903
8094
|
let qTok = null;
|
|
7904
8095
|
try { qTok = new URL(req.url || '', 'http://localhost').searchParams.get('token'); } catch { /* ignore */ }
|
|
7905
8096
|
if (qTok && remoteAccess.tokenMatches(qTok.trim(), TOKEN)) {
|
|
@@ -7925,20 +8116,31 @@ function handleUpgrade(req, socket, head) {
|
|
|
7925
8116
|
// Same origin/host allowlist on the WS upgrade handshake (D64 A1): a cross-origin page must not be
|
|
7926
8117
|
// able to open ws://127.0.0.1:<port> and spawn/type into a shell. Rejected BEFORE the socket is
|
|
7927
8118
|
// accepted, and before loopback's token exemption applies. Header values never logged (D08).
|
|
7928
|
-
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost:
|
|
7929
|
-
|| !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() })) {
|
|
7930
8121
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
7931
8122
|
socket.destroy();
|
|
7932
8123
|
return;
|
|
7933
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
|
+
}
|
|
7934
8133
|
// A browser WebSocket cannot set a header or a ?token= — it authenticates via the same-origin cookie
|
|
7935
8134
|
// seeded on the page load. presentedToken() resolves header → query → subprotocol → cookie.
|
|
7936
|
-
if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress, headers: req.headers, publicHost:
|
|
8135
|
+
if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress, headers: req.headers, publicHost: publicHost(),
|
|
7937
8136
|
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
7938
8137
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
7939
8138
|
socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
|
|
7940
8139
|
return;
|
|
7941
8140
|
}
|
|
8141
|
+
acceptUpgrade(req, socket, head);
|
|
8142
|
+
}
|
|
8143
|
+
function acceptUpgrade(req, socket, head) {
|
|
7942
8144
|
// R25 (bind): a session started with an initiative is bound to it, a PM-only act. Only a socket the page opened with
|
|
7943
8145
|
// this launch's nonce (`?pmos-nonce=`) may bind; any other socket still spawns, unbound, and is told why.
|
|
7944
8146
|
const bind = pmGuard.requirePmUpgrade(req, { act: 'bind' });
|
|
@@ -8081,6 +8283,7 @@ if (require.main === module) {
|
|
|
8081
8283
|
if (shuttingDown) return; shuttingDown = true;
|
|
8082
8284
|
for (const s of sessions.values()) if (s.pty) { try { persist(s); } catch {} }
|
|
8083
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
|
|
8084
8287
|
try { fs.unlinkSync(PID_FILE); } catch {} // stop being discoverable the moment we stop serving
|
|
8085
8288
|
process.exit(0);
|
|
8086
8289
|
};
|
|
@@ -8113,12 +8316,15 @@ if (require.main === module) {
|
|
|
8113
8316
|
// one started by `pmos` — otherwise the next `pmos` cannot see it and starts a second server.
|
|
8114
8317
|
try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
|
|
8115
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})`);
|
|
8116
8322
|
// bring in what another machine pushed while this one was off (D70 as amended); never on the request path
|
|
8117
8323
|
{ const t = setTimeout(() => { autoSync.startup().catch(() => {}); }, 3000); if (t.unref) t.unref(); }
|
|
8118
8324
|
});
|
|
8119
8325
|
});
|
|
8120
8326
|
}
|
|
8121
|
-
module.exports = { server, spawnSession, removeSession, renameSession, sessions, libraryTree, readTextDoc,
|
|
8327
|
+
module.exports = { server, online: { ONLINE, applyOnline, tunnel, publicHost }, spawnSession, removeSession, renameSession, sessions, libraryTree, readTextDoc,
|
|
8122
8328
|
registerWorkspace, forgetWorkspace, workspaces, library, initiativeIndex, fsList,
|
|
8123
8329
|
// R1 — exported so the suite can assert the CARRY-THROUGH: branchInfo separates an ordinary
|
|
8124
8330
|
// non-repo folder from a real git fault, and the whole legibility defect was that distinction
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doc-check
|
|
3
|
+
label: "Check linked documents against Google Docs and Confluence"
|
|
4
|
+
description: "Read the Google Docs and Confluence copies of every linked document and record whether each is the same as the repository file. Read-only: it writes no document, page or file."
|
|
5
|
+
run: agent
|
|
6
|
+
agent: claude
|
|
7
|
+
output:
|
|
8
|
+
- Same
|
|
9
|
+
- Ahead or behind
|
|
10
|
+
- Edited in both
|
|
11
|
+
- Could not check
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
/doc-check — read every Google Docs and Confluence copy of a linked document in THIS repository and record what you saw. PMOS then shows the PM, per document, whether the repository copy is the same as, ahead of or behind each copy.
|
|
15
|
+
|
|
16
|
+
The PM asked for a signal, not a sync. You read; you never write.
|
|
17
|
+
|
|
18
|
+
Rules that always hold:
|
|
19
|
+
1. READ-ONLY EVERYWHERE. Never create, update, move, rename, comment on, share or delete a Google Doc, a Drive file or a Confluence page. Never edit, create or delete a file in this repository. Never commit or push.
|
|
20
|
+
2. THE ONLY WRITES are `pmos docs check-start`, `pmos docs record` and `pmos docs check-finish`. Never write a `*.jsonl` file by hand.
|
|
21
|
+
3. RECORD ONLY WHAT YOU READ. A version, a modified time or a title that you did not read from the copy is not recorded. If you cannot read a copy, record the error. Do not guess.
|
|
22
|
+
4. USE THE PM'S CONNECTORS. Google Docs: the Google Drive tools (file metadata and file content). Confluence: the Atlassian tools (get the page, with its version). If a connector is missing or refuses, record that as the error for each of its copies and go on with the others.
|
|
23
|
+
5. "SAME" MEANS THE SAME DOCUMENT. Compare the copy's text with the repository file's body as documents: the same headings, paragraphs, lists and tables, in the same order. Ignore formatting, whitespace, the YAML frontmatter of the repository file and the copy's title. If any sentence, item or row differs, it is not the same. If you cannot tell, it is not the same: set `same: false` and say why in `note`.
|
|
24
|
+
|
|
25
|
+
Procedure:
|
|
26
|
+
1. Run `pmos docs check-start --json`. Keep `run` (the check id) and `links`. Each link has `path`, `kind` (google or confluence), `target` (the address), `remote_id` and `repo_hash`. If it says a check is already running, stop and report that.
|
|
27
|
+
2. For each link, in order:
|
|
28
|
+
a. Read the copy's metadata. Google: the file's `modifiedTime`, its revision or version, who last modified it, and whether it is trashed. Confluence: the page's `version.number`, its last-modified time and who made that version.
|
|
29
|
+
b. If the copy does not exist or is in the trash, the reading is `{"path","kind","remote_id","exists":false}`.
|
|
30
|
+
c. If reading failed for any other reason (no access, no connector, an error), the reading is `{"path","kind","remote_id","error":"<the error, in one line>"}`.
|
|
31
|
+
d. Otherwise read the copy's content and the repository file `path` (skip this when `repo_hash` is null: the file is not in the repository). Decide `same` by rule 5. The reading is `{"path","kind","remote_id","exists":true,"version":"<version>","modified_at":"<ISO time>","modified_by":"<the person's name, when the copy says it>","title":"<the copy's title>","url":"<its address>","same":true|false,"repo_hash":"<repo_hash from step 1>","note":"<one line when same is false: the first difference>"}`.
|
|
32
|
+
3. Write all readings as a JSON list to a file outside the repository (for example in /tmp) and run `pmos docs record <run> --file <that file>`. If a reading is refused, fix that reading from what you actually read and record it again; never invent a value to get it accepted.
|
|
33
|
+
4. Run `pmos docs check-finish <run> --summary "<n> same, <n> ahead or behind, <n> edited in both, <n> could not check"`.
|
|
34
|
+
5. Run `pmos docs list` and report it in the output sections below.
|
|
35
|
+
|
|
36
|
+
Output contract — produce EXACTLY these sections, the same shape every run, one short line per document and copy:
|
|
37
|
+
- Same
|
|
38
|
+
- Ahead or behind
|
|
39
|
+
- Edited in both
|
|
40
|
+
- Could not check
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doc-sync
|
|
3
|
+
label: "Sync one linked document with its Google Docs or Confluence copy"
|
|
4
|
+
description: "Write one version of a linked document over the other, in the direction the PM pressed: the PMOS version to the copy, or the copy's version to the repository file. One copy, one side, nothing else."
|
|
5
|
+
run: agent
|
|
6
|
+
agent: claude
|
|
7
|
+
output:
|
|
8
|
+
- Written
|
|
9
|
+
- Not written
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
/doc-sync — the PM pressed "Use … version" for ONE copy of ONE document in Docs. Write that version over the other side, record it, and stop. The lines after this procedure name the file, the copy and the version to use.
|
|
13
|
+
|
|
14
|
+
Rules that always hold:
|
|
15
|
+
1. ONE COPY, ONE SIDE. With `use: pmos`, write the repository file's text to the copy, and change nothing in the repository. With `use: copy`, write the copy's text to the repository file, and change nothing in Google Docs or Confluence. Never write the other side, another copy, or another file.
|
|
16
|
+
2. NEVER CREATE, MOVE, RENAME, SHARE OR DELETE. Update the existing Google Doc or Confluence page in place. If it does not exist or is in the trash, do not create it: record the sync as failed.
|
|
17
|
+
3. KEEP THE FRONTMATTER. The repository file may start with a YAML frontmatter block (between two `---` lines). With `use: copy`, keep that block exactly as it is and replace only the text after it. With `use: pmos`, do not write the frontmatter to the copy.
|
|
18
|
+
4. KEEP THE DOCUMENT. Convert between markdown and the copy's format faithfully: the same headings, paragraphs, lists, tables and links, in the same order. Do not add a summary, a note or a heading of your own.
|
|
19
|
+
5. THE ONLY RECORDS are `pmos docs sync-start`, `pmos docs sync-finish` and `pmos docs sync-fail`. Never write a `*.jsonl` file by hand. Never commit or push: the PM's own Sync does that.
|
|
20
|
+
6. USE THE PM'S CONNECTORS. Google Docs: the Google Drive tools. Confluence: the Atlassian tools. If the PM is asked to allow the write, that is expected; wait for their answer. If the connector is missing or refuses, record the sync as failed with the error.
|
|
21
|
+
|
|
22
|
+
Procedure:
|
|
23
|
+
1. Run `pmos docs sync-start <file> <google|confluence> --use <pmos|copy> --json` with the values below. Keep `run`, `target` and `remote_id`. If it is refused (for example, the copy is already being synced), stop and report the reason.
|
|
24
|
+
2. Read the copy (its content, its version and its last-modified time) and the repository file.
|
|
25
|
+
3. Write, by rule 1 to rule 4:
|
|
26
|
+
- `use: pmos`: update the copy's content with the repository file's body. Confluence: update the page with its next version number and keep its title. Google Docs: replace the document's content.
|
|
27
|
+
- `use: copy`: replace the repository file's body with the copy's content, converted to markdown. Keep the frontmatter.
|
|
28
|
+
4. Read the copy again and take its version and last-modified time AFTER the write (with `use: copy` the copy did not change; take what you read in step 2).
|
|
29
|
+
5. Run `pmos docs sync-finish <run> --version "<version>" --modified-at "<ISO time>" --url "<the copy's address>"`.
|
|
30
|
+
6. If any step failed, run `pmos docs sync-fail <run> --reason "<the error, in one line>"` instead of step 5, and say what was and was not written.
|
|
31
|
+
|
|
32
|
+
Output contract — produce EXACTLY these sections, one short line each:
|
|
33
|
+
- Written
|
|
34
|
+
- Not written
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "docs-three-way-sync: the sync signal for documents linked to Google Docs and Confluence.",
|
|
3
|
+
"modules": ["doc-sync.test.js", "docs-cli.test.js"],
|
|
4
|
+
"panels": ["pm-docs-test.js"],
|
|
5
|
+
"seconds": { "doc-sync.test.js": 1, "docs-cli.test.js": 6, "pm-docs-test.js": 60 }
|
|
6
|
+
}
|
|
@@ -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
|
+
}
|