handmux 0.27.2 → 0.29.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.
Files changed (78) hide show
  1. package/bin/handmux.js +2 -2
  2. package/dist/bin/handmux.js +51 -21
  3. package/dist/connectors/bridgeClient.js +30 -14
  4. package/dist/connectors/claude/index.js +78 -23
  5. package/dist/hooks/handmux-write.cjs +11 -0
  6. package/dist/package.json +13 -13
  7. package/dist/public/assets/index-BjnnFTLC.css +32 -0
  8. package/dist/public/assets/index-D0tDAqO-.js +356 -0
  9. package/dist/public/index.html +2 -2
  10. package/dist/src/agent-runtime/bridgeTransport.js +31 -4
  11. package/dist/src/agent-runtime/builtinRuntime.js +10 -2
  12. package/dist/src/agent-runtime/conversation.js +3 -3
  13. package/dist/src/agent-runtime/conversationStore.js +2 -2
  14. package/dist/src/agent-runtime/inbox.js +20 -2
  15. package/dist/src/agent-runtime/run.js +5 -1
  16. package/dist/src/agents/claudeConversation.js +69 -6
  17. package/dist/src/agents/claudeLocalCommand.js +54 -0
  18. package/dist/src/agents/claudeNativeTail.js +271 -0
  19. package/dist/src/agents/claudePaneInput.js +47 -0
  20. package/dist/src/agents/codexConversation.js +46 -0
  21. package/dist/src/agents/piInboxBridge.js +64 -5
  22. package/dist/src/agents/processIdentity.js +13 -6
  23. package/dist/src/apiAccountProviders.js +5 -4
  24. package/dist/src/browser/bootstrap.js +5 -0
  25. package/dist/src/browser/credentials.js +68 -0
  26. package/dist/src/browser/manager.js +40 -5
  27. package/dist/src/browser/publicProxy.js +15 -5
  28. package/dist/src/browser/routes.js +7 -0
  29. package/dist/src/browser/workerClient.js +174 -9
  30. package/dist/src/browser/workerServer.js +13 -1
  31. package/dist/src/claudeEvents.js +94 -38
  32. package/dist/src/cli/authCmd.js +202 -0
  33. package/dist/src/cli/authDefaults.js +18 -0
  34. package/dist/src/cli/claudeHooks.js +3 -3
  35. package/dist/src/cli/hookScaffold.js +5 -1
  36. package/dist/src/cli/i18n/en.js +66 -11
  37. package/dist/src/cli/i18n/zh.js +66 -11
  38. package/dist/src/cli/options.js +7 -13
  39. package/dist/src/cli/pushCmd.js +24 -11
  40. package/dist/src/cli/setupModel.js +28 -6
  41. package/dist/src/cli/setupWizard.js +47 -25
  42. package/dist/src/cli/shortcutEditor.js +20 -2
  43. package/dist/src/cli/supervisor.js +5 -7
  44. package/dist/src/codexAppServer.js +25 -15
  45. package/dist/src/codexTranscriptParse.js +40 -1
  46. package/dist/src/codexUsageSnapshot.js +1 -1
  47. package/dist/src/deviceAccess.js +134 -0
  48. package/dist/src/deviceAuth/control.js +154 -0
  49. package/dist/src/deviceAuth/http.js +321 -0
  50. package/dist/src/deviceAuth/service.js +626 -0
  51. package/dist/src/docs.js +3 -0
  52. package/dist/src/httpApi.js +8 -3
  53. package/dist/src/paneInput.js +16 -2
  54. package/dist/src/previewServer.js +26 -2
  55. package/dist/src/previews.js +37 -13
  56. package/dist/src/projectTask/backup.js +28 -0
  57. package/dist/src/projectTask/lock.js +1 -1
  58. package/dist/src/projectTask/migrations.js +30 -1
  59. package/dist/src/projectTask/runtime.js +7 -1
  60. package/dist/src/projectTask/schema.js +2 -2
  61. package/dist/src/push.js +44 -13
  62. package/dist/src/requestAuthority.js +8 -0
  63. package/dist/src/requestOrigin.js +62 -0
  64. package/dist/src/routes/agents.js +3 -1
  65. package/dist/src/routes/files.js +2 -0
  66. package/dist/src/routes/previews.js +9 -3
  67. package/dist/src/routes/push.js +55 -58
  68. package/dist/src/routes/system.js +4 -4
  69. package/dist/src/routes/terminal.js +9 -1
  70. package/dist/src/server.js +88 -12
  71. package/dist/src/terminalStream.js +90 -5
  72. package/dist/src/tmux/commands.js +2 -0
  73. package/dist/src/transcriptParse.js +4 -1
  74. package/dist/src/workspace/environment.js +14 -5
  75. package/dist/src/workspace/runtime.js +16 -1
  76. package/package.json +4 -4
  77. package/dist/public/assets/index-B4pKf3IW.js +0 -356
  78. package/dist/public/assets/index-Cdrh2CUp.css +0 -32
@@ -30,7 +30,7 @@ import { projectTaskRoutes } from './projectTask/routes.js';
30
30
  import { apiAccountRoutes } from './routes/apiAccounts.js';
31
31
  // Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
32
32
  export { isAllowedKey } from './routes/terminal.js';
33
- export function createApiRouter({ token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events, uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES, asrEnv = process.env, previews, shortcuts = DEFAULT_SHORTCUTS, browser, browserBootstrap, previewDomain, workspace, agentRuntime, projectTask, apiAccounts, apiErrors, agentIntegrationContext, home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()), }) {
33
+ export function createApiRouter({ token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events, authentication, deviceAuth = false, uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES, asrEnv = process.env, previews, shortcuts = DEFAULT_SHORTCUTS, shortcutState, browser, browserBootstrap, previewDomain, workspace, agentRuntime, projectTask, apiAccounts, apiErrors, agentIntegrationContext, home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()), }) {
34
34
  const r = express.Router();
35
35
  r.use(apiRequestContext(apiErrors));
36
36
  // Account responses, including auth failures and malformed JSON, must never be cached. Keep the
@@ -38,6 +38,10 @@ export function createApiRouter({ token, commands = defaultCommands, docs = defa
38
38
  if (apiAccounts) {
39
39
  r.use('/api-accounts', (req, res, next) => {
40
40
  res.setHeader('Cache-Control', 'no-store');
41
+ if (authentication) {
42
+ next();
43
+ return;
44
+ }
41
45
  const provided = bearerFrom(req.get('authorization'));
42
46
  if (provided && tokenEquals(provided, token))
43
47
  next();
@@ -47,7 +51,7 @@ export function createApiRouter({ token, commands = defaultCommands, docs = defa
47
51
  });
48
52
  });
49
53
  }
50
- r.use(expressAuth(token));
54
+ r.use(authentication ?? expressAuth(token));
51
55
  r.use(express.json());
52
56
  const eventOptions = { commands, push };
53
57
  const claudeEvents = events || createClaudeEvents(eventOptions);
@@ -59,7 +63,7 @@ export function createApiRouter({ token, commands = defaultCommands, docs = defa
59
63
  })))),
60
64
  };
61
65
  const deps = {
62
- token, commands, docs, git, push, notifications, claudeEvents,
66
+ token, commands, docs, git, push, notifications, claudeEvents, deviceAuth,
63
67
  agentIdentity: agentRuntime ?? legacyAgentIdentity,
64
68
  uploadExts, maxUploadBytes, asrEnv, shortcuts, home, stateFile,
65
69
  ...(agentRuntime !== undefined ? { agentRuntime } : {}),
@@ -69,6 +73,7 @@ export function createApiRouter({ token, commands = defaultCommands, docs = defa
69
73
  ...(browserBootstrap !== undefined ? { browserBootstrap } : {}),
70
74
  ...(previewDomain !== undefined ? { previewDomain } : {}),
71
75
  ...(agentIntegrationContext !== undefined ? { agentIntegrationContext } : {}),
76
+ ...(shortcutState ? { shortcutState } : {}),
72
77
  };
73
78
  r.use(sessionRoutes(deps));
74
79
  r.use(terminalRoutes(deps));
@@ -1,3 +1,4 @@
1
+ import { assertRequestAuthority } from './requestAuthority.js';
1
2
  const SUBMIT_GAP_MS = 120;
2
3
  const paneInputTails = new Map();
3
4
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -5,7 +6,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
6
  // text + submit in the same operation prevents two clients from interleaving `paste-buffer` and Enter.
6
7
  export async function serializePaneInput(paneId, operation) {
7
8
  const previous = paneInputTails.get(paneId) ?? Promise.resolve();
8
- const current = previous.catch(() => { }).then(operation);
9
+ const current = previous.catch(() => { }).then(() => { assertRequestAuthority(); return operation(); });
9
10
  const tail = current.then(() => { }, () => { });
10
11
  paneInputTails.set(paneId, tail);
11
12
  try {
@@ -21,9 +22,11 @@ export function sendPanePrompt(commands, paneId, text, guard) {
21
22
  if (guard && !await guard.validate())
22
23
  return { nativeMutation: false };
23
24
  await commands.exitCopyModeIfActive(paneId);
25
+ assertRequestAuthority();
24
26
  await commands.sendText(paneId, text);
25
27
  if (text)
26
28
  await delay(SUBMIT_GAP_MS);
29
+ assertRequestAuthority();
27
30
  await commands.sendEnter(paneId);
28
31
  return { nativeMutation: true };
29
32
  });
@@ -31,12 +34,23 @@ export function sendPanePrompt(commands, paneId, text, guard) {
31
34
  export function interruptPane(commands, paneId) {
32
35
  return serializePaneInput(paneId, async () => {
33
36
  await commands.exitCopyModeIfActive(paneId);
37
+ assertRequestAuthority();
34
38
  await commands.sendKey(paneId, 'C-c');
35
39
  });
36
40
  }
41
+ export function interruptClaudePane(commands, paneId) {
42
+ return serializePaneInput(paneId, async () => {
43
+ await commands.exitCopyModeIfActive(paneId);
44
+ await commands.sendKey(paneId, 'Escape');
45
+ });
46
+ }
37
47
  export function sendPaneChoice(commands, paneId, choice) {
48
+ if (!/^[1-9]$/.test(choice))
49
+ throw new TypeError('Pane choice requires a single option digit');
38
50
  return serializePaneInput(paneId, async () => {
39
51
  await commands.exitCopyModeIfActive(paneId);
40
- await commands.sendText(paneId, choice);
52
+ assertRequestAuthority();
53
+ // Menu shortcuts are key events; bracketed paste is not handled by Claude's selector.
54
+ await commands.sendKey(paneId, choice);
41
55
  });
42
56
  }
@@ -76,8 +76,25 @@ const errorCode = (error) => {
76
76
  };
77
77
  export function createPreview({ previews }) {
78
78
  const router = express.Router();
79
+ const deviceResponses = new Map();
80
+ previews.onRevoke?.((deviceId) => {
81
+ for (const response of deviceResponses.get(deviceId) || [])
82
+ response.destroy();
83
+ deviceResponses.delete(deviceId);
84
+ });
85
+ function track(entry, res) {
86
+ if (!entry.deviceId)
87
+ return;
88
+ const id = entry.deviceId;
89
+ let responses = deviceResponses.get(id);
90
+ if (!responses)
91
+ deviceResponses.set(id, responses = new Set());
92
+ responses.add(res);
93
+ res.once('close', () => { responses.delete(res); if (!responses.size)
94
+ deviceResponses.delete(id); });
95
+ }
79
96
  function resolveEntry(name, accessToken, res) {
80
- const lookup = previews.get(name);
97
+ const lookup = previews.get(name, accessToken);
81
98
  if (lookup.state === 'missing') {
82
99
  res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
83
100
  return null;
@@ -93,6 +110,7 @@ export function createPreview({ previews }) {
93
110
  return lookup.entry;
94
111
  }
95
112
  async function serve(entry, rest, prefix, res) {
113
+ track(entry, res);
96
114
  previewHeaders(res);
97
115
  const requested = fileFor(rest);
98
116
  const segments = requested.split('/');
@@ -112,6 +130,11 @@ export function createPreview({ previews }) {
112
130
  if (['.html', '.htm', '.css', '.js', '.mjs'].includes(extension)) {
113
131
  try {
114
132
  const source = await fs.readFile(target, 'utf8');
133
+ if (res.destroyed)
134
+ return;
135
+ // File reads yield: a device may have been revoked while the file was being loaded.
136
+ if (!resolveEntry(entry.name, entry.accessToken || '', res))
137
+ return;
115
138
  res.type(extension).send(rewritePreviewText(source, extension, prefix));
116
139
  }
117
140
  catch (error) {
@@ -187,11 +210,12 @@ export function createPreview({ previews }) {
187
210
  catch {
188
211
  return next();
189
212
  }
190
- const lookup = previews.get(name);
213
+ const lookup = previews.get(name, accessToken);
191
214
  if (lookup.state !== 'active')
192
215
  return next();
193
216
  if (!credentialOk(accessToken, lookup.entry.accessToken))
194
217
  return next();
218
+ track(lookup.entry, res);
195
219
  previewHeaders(res);
196
220
  res.sendFile(req.path, { root: lookup.entry.dir, dotfiles: 'deny' }, (err) => {
197
221
  if (err && !res.headersSent)
@@ -45,6 +45,8 @@ function parsePreviewEntry(value, realHome) {
45
45
  if (!isUnder(realDir, realHome))
46
46
  return null;
47
47
  const entry = { name, dir: value.dir, expiresAt: value.expiresAt };
48
+ if (typeof value.deviceId === 'string' && value.deviceId)
49
+ entry.deviceId = value.deviceId;
48
50
  if (value.kind === 'static')
49
51
  entry.kind = 'static';
50
52
  if (typeof value.accessToken === 'string' && value.accessToken)
@@ -53,7 +55,7 @@ function parsePreviewEntry(value, realHome) {
53
55
  entry.createdAt = value.createdAt;
54
56
  return entry;
55
57
  }
56
- export function createPreviews({ home = homedir(), store = process.env.PREVIEW_STORE || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../data/previews.json'), now = () => Date.now(), ttlMs = 2 * 60 * 60_000, randomToken = () => randomBytes(24).toString('base64url'), } = {}) {
58
+ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_STORE || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../data/previews.json'), now = () => Date.now(), ttlMs = 2 * 60 * 60_000, randomToken = () => randomBytes(24).toString('base64url'), isDeviceActive, } = {}) {
57
59
  let realHome;
58
60
  try {
59
61
  realHome = fs.realpathSync(home);
@@ -65,6 +67,13 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
65
67
  let entries = readJsonArray(store)
66
68
  .map((value) => parsePreviewEntry(value, realHome))
67
69
  .filter((entry) => entry !== null);
70
+ // Never inherit capabilities from a previous mode/process, even from legacy files containing tokens.
71
+ for (const entry of entries)
72
+ if (entry.kind !== 'dynamic')
73
+ delete entry.accessToken;
74
+ const authorized = (entry) => isDeviceActive
75
+ ? Boolean(entry.deviceId && isDeviceActive(entry.deviceId)) : !entry.deviceId;
76
+ const revokeListeners = new Set();
68
77
  let flushedExpiries = new Map(entries.map((entry) => [entry.name, entry.expiresAt]));
69
78
  const flush = () => {
70
79
  // Access tokens are runtime capabilities. Open device tabs re-register after a restart and receive
@@ -88,7 +97,7 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
88
97
  // directory, expired row, or process-restored row without a runtime token receives a fresh one.
89
98
  const upsert = (fields) => {
90
99
  const ts = now();
91
- const current = entries.find((entry) => entry.name === fields.name);
100
+ const current = entries.find((entry) => entry.name === fields.name && entry.kind !== 'dynamic' && entry.deviceId === fields.deviceId);
92
101
  if (current?.kind === fields.kind
93
102
  && current.dir === fields.dir
94
103
  && current.expiresAt > ts
@@ -98,7 +107,7 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
98
107
  flush();
99
108
  return resultFor(current);
100
109
  }
101
- entries = entries.filter((entry) => entry.name !== fields.name);
110
+ entries = entries.filter((entry) => entry.name !== fields.name || (entry.kind !== 'dynamic' && entry.deviceId !== fields.deviceId));
102
111
  const entry = {
103
112
  ...fields,
104
113
  accessToken: randomToken(),
@@ -109,7 +118,7 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
109
118
  flush();
110
119
  return resultFor(entry);
111
120
  };
112
- async function register({ name, dir, port }) {
121
+ async function register({ name, dir, port }, deviceId) {
113
122
  const nm = safePreviewName(name);
114
123
  if (!nm)
115
124
  return { error: 'bad name', status: 400 };
@@ -135,15 +144,20 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
135
144
  }
136
145
  if (!st.isDirectory())
137
146
  return { error: 'not a directory', status: 400 };
138
- return upsert({ name: nm, kind: 'static', dir: real });
147
+ if (isDeviceActive && (!deviceId || !isDeviceActive(deviceId)))
148
+ return { error: 'device authorization expired', status: 401 };
149
+ return upsert({ name: nm, kind: 'static', dir: real, ...(isDeviceActive && deviceId ? { deviceId } : {}) });
139
150
  }
140
- function get(name) {
141
- const entry = entries.find((candidate) => candidate.name === name);
151
+ function get(name, accessToken) {
152
+ const entry = entries.find((candidate) => candidate.name === name
153
+ && (!isDeviceActive || (candidate.kind !== 'dynamic' && candidate.accessToken === accessToken)));
142
154
  if (!entry)
143
155
  return { state: 'missing' };
156
+ if (entry.kind !== 'dynamic' && !authorized(entry))
157
+ return { state: 'expired' };
144
158
  const ts = now();
145
159
  if (entry.expiresAt <= ts) {
146
- entries = entries.filter((candidate) => candidate.name !== name);
160
+ entries = entries.filter((candidate) => candidate !== entry);
147
161
  flush();
148
162
  return { state: 'expired' };
149
163
  }
@@ -160,20 +174,30 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
160
174
  flush();
161
175
  return { state: 'active', entry: { ...entry, kind: 'static' } }; // legacy rows (no kind) → static
162
176
  }
163
- function list() {
177
+ function list(deviceId) {
164
178
  const active = entries.filter((entry) => entry.kind !== 'dynamic' && entry.expiresAt > now());
165
179
  if (active.length !== entries.length) {
166
180
  entries = active;
167
181
  flush();
168
182
  }
169
- return active.map((entry) => ({ name: entry.name, kind: 'static', dir: entry.dir, expiresAt: entry.expiresAt }));
183
+ return active.filter((entry) => authorized(entry) && (!isDeviceActive || entry.deviceId === deviceId))
184
+ .map((entry) => ({ name: entry.name, kind: 'static', dir: entry.dir, expiresAt: entry.expiresAt }));
170
185
  }
171
- function remove(name) {
172
- const next = entries.filter((entry) => entry.name !== name);
186
+ function remove(name, deviceId) {
187
+ const next = entries.filter((entry) => entry.name !== name || (isDeviceActive && (entry.kind === 'dynamic' || !deviceId || entry.deviceId !== deviceId)));
173
188
  if (next.length !== entries.length) {
174
189
  entries = next;
175
190
  flush();
176
191
  }
177
192
  }
178
- return { register, get, list, remove };
193
+ function revokeDevice(deviceId) {
194
+ for (const entry of entries)
195
+ if (entry.kind !== 'dynamic' && entry.deviceId === deviceId)
196
+ delete entry.accessToken;
197
+ for (const listener of revokeListeners)
198
+ listener(deviceId);
199
+ }
200
+ return { register, get, list, remove, revokeDevice,
201
+ onRevoke(listener) { revokeListeners.add(listener); return () => { revokeListeners.delete(listener); }; },
202
+ };
179
203
  }
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { migrateProjectDatabase } from './migrations.js';
4
5
  const sqlite = createRequire(import.meta.url)('node:sqlite');
5
6
  function stamp(date) {
6
7
  return date.toISOString().replace(/[:.]/g, '-');
@@ -161,6 +162,33 @@ export async function restoreProjectBackup({ backupPath, databasePath, fs = fsp,
161
162
  const temporary = `${databasePath}.restore`;
162
163
  await fs.copyFile(backupPath, temporary);
163
164
  await fs.chmod(temporary, 0o600);
165
+ // Revoke before publishing the restored file. A crash at any later point cannot resurrect access.
166
+ const restored = new sqlite.DatabaseSync(temporary, { allowExtension: false });
167
+ try {
168
+ // Revoke credentials before publishing the restored file so a crash at any later point cannot
169
+ // resurrect access from a backup snapshot.
170
+ migrateProjectDatabase(restored);
171
+ const hasAuth = restored.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='auth_sessions'").get();
172
+ if (hasAuth) {
173
+ restored.exec('BEGIN IMMEDIATE');
174
+ try {
175
+ const now = Date.now();
176
+ restored.prepare('UPDATE auth_sessions SET revoked_at = ? WHERE revoked_at IS NULL').run(now);
177
+ restored.prepare('UPDATE auth_devices SET revoked_at = ? WHERE revoked_at IS NULL').run(now);
178
+ restored.prepare("DELETE FROM auth_meta WHERE key='token_generation'").run();
179
+ restored.exec('COMMIT');
180
+ }
181
+ catch (error) {
182
+ restored.exec('ROLLBACK');
183
+ throw error;
184
+ }
185
+ }
186
+ restored.exec('PRAGMA wal_checkpoint(TRUNCATE)');
187
+ restored.exec('PRAGMA journal_mode = DELETE');
188
+ }
189
+ finally {
190
+ restored.close();
191
+ }
164
192
  await fs.rename(temporary, databasePath);
165
193
  await fs.rm(recoveryMarkerPath(databasePath), { force: true });
166
194
  }
@@ -99,7 +99,7 @@ export function createProjectStoreLock({ dir, fs = fsp, pid = process.pid, now =
99
99
  if (await reclaimStale(observed))
100
100
  continue;
101
101
  const pidLabel = observed?.owner?.pid ? ` (pid ${observed.owner.pid})` : '';
102
- throw new ProjectTaskError('PROJECT_STORE_LOCKED', 503, `Project data is open in another HandMux instance${pidLabel}; close it and try again`);
102
+ throw new ProjectTaskError('PROJECT_STORE_LOCKED', 503, `Project data is open in another handmux instance${pidLabel}; close it and try again`);
103
103
  }
104
104
  }
105
105
  throw new ProjectTaskError('PROJECT_STORE_LOCKED', 503, 'Project data lock changed while starting; try again');
@@ -1,4 +1,21 @@
1
1
  import { PROJECT_TASK_SCHEMA_VERSION, ProjectTaskError } from './schema.js';
2
+ const V2_AUTH_SCHEMA = `
3
+ CREATE TABLE auth_devices (
4
+ id TEXT PRIMARY KEY, pairing_request_id TEXT NOT NULL UNIQUE,
5
+ name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 80),
6
+ browser_summary TEXT NOT NULL, authorized_at INTEGER NOT NULL,
7
+ expires_at INTEGER, last_used_at INTEGER NOT NULL, revoked_at INTEGER
8
+ ) STRICT;
9
+ CREATE TABLE auth_sessions (
10
+ id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES auth_devices(id),
11
+ secret_hash TEXT NOT NULL UNIQUE, origin TEXT NOT NULL,
12
+ transport TEXT NOT NULL CHECK(transport IN ('http', 'https')),
13
+ created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL, revoked_at INTEGER
14
+ ) STRICT;
15
+ CREATE INDEX auth_sessions_device ON auth_sessions(device_id);
16
+ CREATE INDEX auth_devices_expiry ON auth_devices(expires_at) WHERE revoked_at IS NULL;
17
+ CREATE TABLE auth_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
18
+ `;
2
19
  const V1_SCHEMA = `
3
20
  CREATE TABLE projects (
4
21
  id TEXT PRIMARY KEY,
@@ -68,7 +85,7 @@ export function configureProjectDatabase(db) {
68
85
  export function migrateProjectDatabase(db) {
69
86
  const current = Number(pragmaValue(db, 'user_version') ?? 0);
70
87
  if (current > PROJECT_TASK_SCHEMA_VERSION) {
71
- throw new ProjectTaskError('PROJECT_STORE_TOO_NEW', 503, `Project data requires a newer HandMux version (schema ${current})`);
88
+ throw new ProjectTaskError('PROJECT_STORE_TOO_NEW', 503, `Project data requires a newer handmux version (schema ${current})`);
72
89
  }
73
90
  if (current === PROJECT_TASK_SCHEMA_VERSION)
74
91
  return current;
@@ -76,6 +93,18 @@ export function migrateProjectDatabase(db) {
76
93
  try {
77
94
  if (current === 0)
78
95
  db.exec(V1_SCHEMA);
96
+ if (current < 2) {
97
+ db.exec(V2_AUTH_SCHEMA);
98
+ // Public releases before trusted-device/auth-origin protection used schema 1 and
99
+ // allowed a valid Token from every known entry point. Preserve that behavior when
100
+ // upgrading an existing schema-1 database; a brand-new database (version 0) keeps
101
+ // the secure defaults applied by DeviceAuthService.
102
+ if (current === 1) {
103
+ db.exec("INSERT INTO auth_meta(key,value) VALUES('trusted_device_enabled','0'),('trusted_origin_enabled','0')");
104
+ }
105
+ }
106
+ if (current < 3)
107
+ db.exec('ALTER TABLE auth_devices ADD COLUMN version INTEGER NOT NULL DEFAULT 1 CHECK(version >= 1)');
79
108
  db.exec(`PRAGMA user_version = ${PROJECT_TASK_SCHEMA_VERSION}`);
80
109
  db.exec('COMMIT');
81
110
  }
@@ -150,11 +150,17 @@ export async function createProjectTaskRuntime({ home, databasePath = path.join(
150
150
  }
151
151
  return {
152
152
  status: () => ({ ...state }),
153
+ requireDatabase() {
154
+ if (closed || !db || state.status !== 'ready')
155
+ throw new Error('Authentication database unavailable; restart handmux');
156
+ return db;
157
+ },
158
+ successfulWrite() { backups?.successfulWrite(); },
153
159
  requireStore() {
154
160
  if (closed || !store || state.status !== 'ready') {
155
161
  const error = state.error ?? {
156
162
  code: 'PROJECT_STORE_CORRUPT',
157
- message: 'Project data is unavailable; restart HandMux and try again',
163
+ message: 'Project data is unavailable; restart handmux and try again',
158
164
  };
159
165
  throw new ProjectTaskError(error.code, error.code === 'PROJECT_STORE_FULL' ? 507 : 503, error.message);
160
166
  }
@@ -1,4 +1,4 @@
1
- export const PROJECT_TASK_SCHEMA_VERSION = 1;
1
+ export const PROJECT_TASK_SCHEMA_VERSION = 3;
2
2
  export class ProjectTaskError extends Error {
3
3
  code;
4
4
  status;
@@ -30,7 +30,7 @@ export function projectStorageError(error) {
30
30
  return new ProjectTaskError('PROJECT_STORE_LOCKED', 503, 'Project data is temporarily busy; close other processes using it and try again');
31
31
  }
32
32
  if (code === 'EACCES' || code === 'EPERM' || code === 'SQLITE_READONLY' || primary === 8) {
33
- return new ProjectTaskError('PROJECT_STORE_PERMISSION', 503, 'HandMux cannot access Project data; fix ~/.handmux permissions and restart');
33
+ return new ProjectTaskError('PROJECT_STORE_PERMISSION', 503, 'handmux cannot access Project data; fix ~/.handmux permissions and restart');
34
34
  }
35
35
  if (code === 'ENOSPC' || code === 'SQLITE_FULL' || primary === 13
36
36
  || detail.includes('database or disk is full')) {
package/dist/src/push.js CHANGED
@@ -67,9 +67,28 @@ function parseStoredSubscription(value) {
67
67
  if (wrapped && isRecord(value) && typeof value.pushKey === 'string' && VALID_PUSH_KEY.test(value.pushKey)) {
68
68
  record.pushKey = value.pushKey;
69
69
  }
70
+ if (wrapped && isRecord(value) && typeof value.authDeviceId === 'string')
71
+ record.authDeviceId = value.authDeviceId;
70
72
  return record;
71
73
  }
72
74
  let subs = load();
75
+ let deviceAuthorization = null;
76
+ const allowed = (rec) => {
77
+ try {
78
+ return deviceAuthorization ? (!rec.authDeviceId || deviceAuthorization(rec.authDeviceId)) : !rec.authDeviceId;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ };
84
+ export function setDeviceAuthorization(check) { deviceAuthorization = check; }
85
+ export function revokeDevice(deviceId) {
86
+ const next = subs.filter(rec => rec.authDeviceId !== deviceId);
87
+ if (next.length !== subs.length) {
88
+ subs = next;
89
+ persist();
90
+ }
91
+ }
73
92
  function load() {
74
93
  return readJsonArray(STORE)
75
94
  .map(parseStoredSubscription)
@@ -78,44 +97,54 @@ function load() {
78
97
  function persist() { writeJsonAtomic(STORE, subs); }
79
98
  export function isConfigured() { ensureInit(); return configured; }
80
99
  export function publicKey() { ensureInit(); return process.env.VAPID_PUBLIC || null; }
81
- export function count() { return subs.length; }
82
- export function addSubscription(sub, boundSessions = [], preferredPushKey = null) {
100
+ export function count() { return subs.filter(allowed).length; }
101
+ export function addSubscription(sub, boundSessions = [], preferredPushKey = null, authDeviceId) {
83
102
  const parsed = parsePushSubscription(sub);
84
103
  if (!parsed)
85
104
  return false;
105
+ if (deviceAuthorization && (!authDeviceId || !deviceAuthorization(authDeviceId)))
106
+ return false;
86
107
  const sessions = strings(boundSessions);
87
108
  const endpointRecord = subs.find((s) => s.subscription.endpoint === parsed.endpoint);
109
+ if (endpointRecord && endpointRecord.authDeviceId !== authDeviceId && endpointRecord.authDeviceId && allowed(endpointRecord))
110
+ return false;
88
111
  const requestedKey = typeof preferredPushKey === 'string' && VALID_PUSH_KEY.test(preferredPushKey) ? preferredPushKey : null;
89
- const keyRecord = requestedKey ? subs.find((s) => s.pushKey === requestedKey) : null;
90
- const pushKey = keyRecord?.pushKey || endpointRecord?.pushKey || requestedKey || genKey();
112
+ const keyRecord = requestedKey ? subs.find((s) => s.pushKey === requestedKey && s.authDeviceId === authDeviceId) : null;
113
+ const pushKey = authDeviceId ? keyRecord?.pushKey || (endpointRecord?.authDeviceId === authDeviceId ? endpointRecord?.pushKey : null) || genKey()
114
+ : keyRecord?.pushKey || endpointRecord?.pushKey || requestedKey || genKey();
91
115
  // A browser PushSubscription is transport state, not device identity. Re-enabling push can produce a
92
116
  // different endpoint; replace the old endpoint that owns this stable key instead of creating a new
93
117
  // script-push target. Also collapse any legacy duplicate for the new endpoint.
94
118
  subs = subs.filter((s) => s.subscription.endpoint !== parsed.endpoint && s.pushKey !== pushKey);
95
- subs.push({ subscription: parsed, boundSessions: sessions, pushKey });
119
+ subs.push({ subscription: parsed, boundSessions: sessions, pushKey, ...(authDeviceId ? { authDeviceId } : {}) });
96
120
  persist();
97
121
  return pushKey;
98
122
  }
99
- export function updateBound(endpoint, boundSessions = []) {
100
- const rec = subs.find((s) => s.subscription.endpoint === endpoint);
123
+ export function updateBound(endpoint, boundSessions = [], authDeviceId) {
124
+ const rec = subs.find((s) => s.subscription.endpoint === endpoint && s.authDeviceId === authDeviceId && allowed(s));
101
125
  if (rec) {
102
126
  rec.boundSessions = strings(boundSessions);
103
127
  persist();
104
128
  }
105
129
  }
106
- export function removeSubscription(endpoint) {
107
- const removed = subs.find((s) => s.subscription.endpoint === endpoint);
108
- subs = subs.filter((s) => s.subscription.endpoint !== endpoint);
130
+ export function removeSubscription(endpoint, authDeviceId) {
131
+ const removed = subs.find((s) => s.subscription.endpoint === endpoint && s.authDeviceId === authDeviceId);
132
+ subs = subs.filter((s) => s !== removed);
109
133
  if (removed)
110
134
  persist();
111
135
  return removed?.pushKey || null;
112
136
  }
113
137
  // The device-addressing id (NOT an auth credential — see /api/push/send-local). Lazy-generate for
114
138
  // records stored before the feature existed so an already-subscribed device still has one.
115
- export function getPushKey(endpoint) {
116
- const rec = subs.find((s) => s.subscription.endpoint === endpoint);
139
+ export function getPushKey(endpoint, authDeviceId) {
140
+ const rec = subs.find((s) => s.subscription.endpoint === endpoint && (s.authDeviceId === authDeviceId || (!s.authDeviceId && Boolean(authDeviceId))) && allowed(s));
117
141
  if (!rec)
118
142
  return null;
143
+ // Migrate subscriptions written by 0.28 on first authenticated use.
144
+ if (!rec.authDeviceId && authDeviceId) {
145
+ rec.authDeviceId = authDeviceId;
146
+ persist();
147
+ }
119
148
  if (!rec.pushKey) {
120
149
  rec.pushKey = genKey();
121
150
  persist();
@@ -171,6 +200,8 @@ async function deliver(records, payload, opts = {}) {
171
200
  let sent = 0;
172
201
  let failed = 0;
173
202
  await Promise.all(records.map(async (rec) => {
203
+ if (!allowed(rec))
204
+ return;
174
205
  try {
175
206
  await webpush.sendNotification(rec.subscription, data, options(opts));
176
207
  sent += 1;
@@ -211,7 +242,7 @@ export const resolveTargetKeys = ({ devices, sessions } = {}) => {
211
242
  : (sessions && sessions.length)
212
243
  ? subs.filter((s) => s.boundSessions.some((x) => sessions.includes(x)))
213
244
  : subs;
214
- return [...new Set(pick.map((s) => s.pushKey).filter((key) => typeof key === 'string'))];
245
+ return [...new Set(pick.filter(allowed).map((s) => s.pushKey).filter((key) => typeof key === 'string'))];
215
246
  };
216
247
  // Back-compat: the /push/subscribe welcome still pushes to a single just-added subscription.
217
248
  export async function sendToOne(sub, payload, opts = {}) {
@@ -0,0 +1,8 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ // Carry the HTTP authorization through awaited queues to the actual side-effect edge.
3
+ // No context means an internal/background operation; token mode remains unchanged.
4
+ const authority = new AsyncLocalStorage();
5
+ export function withRequestAuthority(check, operation) {
6
+ return authority.run(check, operation);
7
+ }
8
+ export function assertRequestAuthority() { authority.getStore()?.(); }
@@ -0,0 +1,62 @@
1
+ function sameHost(origin, hostHeader) {
2
+ try {
3
+ const requestHost = new URL(`http://${hostHeader}`);
4
+ const originPort = origin.port || (origin.protocol === 'https:' ? '443' : '80');
5
+ // The backend may receive a Host without a port after TLS termination. In
6
+ // that case, inherit the browser origin's default port; an explicit port
7
+ // must still match exactly (443 remains equivalent to an omitted port).
8
+ const explicitPort = hostHeader.match(/:(\d+)$/)?.[1];
9
+ const requestPort = explicitPort ?? originPort;
10
+ return origin.hostname === requestHost.hostname && originPort === requestPort;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ function isLoopback(address) {
17
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
18
+ }
19
+ function browserProtocol(req, hostHeader) {
20
+ // Only a local TLS-terminating proxy may supply these implicit hints. For a
21
+ // direct remote request, an Origin header is not transport proof and must not
22
+ // turn an HTTP request into a trusted HTTPS origin.
23
+ if (!isLoopback(req.socket.remoteAddress))
24
+ return null;
25
+ const hinted = req.headers.origin;
26
+ if (typeof hinted === 'string' && hinted !== 'null') {
27
+ try {
28
+ const origin = new URL(hinted);
29
+ if (origin.pathname === '/' && !origin.username && !origin.password && !origin.search && !origin.hash
30
+ && ['http:', 'https:'].includes(origin.protocol) && sameHost(origin, hostHeader)) {
31
+ return origin.protocol === 'https:' ? 'https' : 'http';
32
+ }
33
+ }
34
+ catch { /* fall through to the transport and cookie hints */ }
35
+ }
36
+ // TLS-terminating reverse proxies may omit X-Forwarded-Proto. A Secure handmux
37
+ // cookie is only sent by an HTTPS browser, so it provides a stable hint for the
38
+ // follow-up GET that usually omits Origin.
39
+ const cookie = req.headers.cookie;
40
+ if (typeof cookie === 'string' && /(?:^|;\s*)__Host-handmux_(?:session|pairing)(?:[_A-Za-z0-9-]*)?=/.test(cookie))
41
+ return 'https';
42
+ return null;
43
+ }
44
+ /** Parse the effective request origin without deciding whether it is allowed. */
45
+ export function requestOrigin(req) {
46
+ const encrypted = 'encrypted' in req.socket && Boolean(req.socket.encrypted);
47
+ const forwarded = req.headers['x-forwarded-proto'];
48
+ const forwardedHost = req.headers['x-forwarded-host'];
49
+ const hasForwardedHost = typeof forwardedHost === 'string' && forwardedHost.length > 0;
50
+ const hostHeader = hasForwardedHost
51
+ ? (forwardedHost.split(',')[0] ?? '').trim() : req.headers.host;
52
+ if (!hostHeader || /[\s,/@\\?#]/.test(hostHeader))
53
+ return null;
54
+ const protocol = !encrypted && (forwarded === 'http' || forwarded === 'https')
55
+ ? forwarded : encrypted ? 'https' : browserProtocol(req, hostHeader) ?? 'http';
56
+ try {
57
+ return new URL(`${protocol}://${hostHeader}`).origin;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
@@ -276,7 +276,9 @@ export function agentRoutes({ runtime }) {
276
276
  }
277
277
  }
278
278
  const queue = runtime.conversation ? await runtime.conversation.queueSnapshot(lease) : undefined;
279
- return res.json({ controls: { ...controls, ...(queue ? { queue, submissions: queue.submissions } : {}) } });
279
+ // The page and dispatcher consume the same Runtime activity snapshot.
280
+ return res.json({ controls: { ...controls,
281
+ ...(queue ? { activity: queue.activity, queue, submissions: queue.submissions } : {}) } });
280
282
  }
281
283
  catch (error) {
282
284
  if (controlError(error, res))
@@ -161,6 +161,7 @@ export function fileRoutes({ docs, uploadExts, maxUploadBytes }) {
161
161
  // Resolved up front for the response; re-resolved at link time if the race is lost (see below).
162
162
  const origName = name;
163
163
  let finalName = await freeUploadName(target.real, origName);
164
+ res.locals.assertDeviceActive?.();
164
165
  let dest = joinPath(target.real, finalName);
165
166
  const tempPath = joinPath(target.real, `.${finalName}.uploading-${randomBytes(6).toString('hex')}`);
166
167
  tmp = tempPath;
@@ -181,6 +182,7 @@ export function fileRoutes({ docs, uploadExts, maxUploadBytes }) {
181
182
  // file, and a clash auto-suffixes rather than 409s.
182
183
  let linked = false;
183
184
  for (let attempt = 0; attempt < 6 && !linked; attempt++) {
185
+ res.locals.assertDeviceActive?.();
184
186
  try {
185
187
  await fsp.link(tempPath, dest);
186
188
  linked = true;