workoffdesk 0.0.0-stage → 1.0.1

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 (128) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/build.json +1 -0
  6. package/dist/server/agents/claude/accounts.js +105 -0
  7. package/dist/server/agents/claude/hooks/asks.js +135 -0
  8. package/dist/server/agents/claude/hooks/hooks.js +114 -0
  9. package/dist/server/agents/claude/hooks/status.js +34 -0
  10. package/dist/server/agents/claude/hooks/tracker.js +79 -0
  11. package/dist/server/agents/claude/offers/commands.js +219 -0
  12. package/dist/server/agents/claude/offers/files.js +51 -0
  13. package/dist/server/agents/claude/offers/initialize.js +59 -0
  14. package/dist/server/agents/claude/offers/models.js +20 -0
  15. package/dist/server/agents/claude/screen/choices.js +189 -0
  16. package/dist/server/agents/claude/screen/compacting.js +20 -0
  17. package/dist/server/agents/claude/screen/live.js +63 -0
  18. package/dist/server/agents/claude/screen/panel.js +140 -0
  19. package/dist/server/agents/claude/screen/prompt.js +23 -0
  20. package/dist/server/agents/claude/screen/screen.js +79 -0
  21. package/dist/server/agents/claude/terminals.js +110 -0
  22. package/dist/server/agents/claude/transcript/chat.js +254 -0
  23. package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
  24. package/dist/server/agents/claude/transcript/transcripts.js +210 -0
  25. package/dist/server/agents/claude/transcript/turn.js +136 -0
  26. package/dist/server/agents/claude/transcript/watch.js +60 -0
  27. package/dist/server/agents/claude/usage.js +36 -0
  28. package/dist/server/agents/codex/agent.js +263 -0
  29. package/dist/server/agents/codex/appServer.js +38 -0
  30. package/dist/server/agents/codex/items.js +226 -0
  31. package/dist/server/agents/codex/notifications.js +192 -0
  32. package/dist/server/agents/codex/rpc.js +121 -0
  33. package/dist/server/agents/codex/screen.js +5 -0
  34. package/dist/server/agents/codex/threads.js +208 -0
  35. package/dist/server/agents/types.js +1 -0
  36. package/dist/server/cli.js +49 -0
  37. package/dist/server/commands/account.js +61 -0
  38. package/dist/server/commands/attach.js +27 -0
  39. package/dist/server/commands/common.js +114 -0
  40. package/dist/server/commands/doctor.js +43 -0
  41. package/dist/server/commands/install.js +169 -0
  42. package/dist/server/commands/open.js +31 -0
  43. package/dist/server/commands/qr.js +33 -0
  44. package/dist/server/commands/serve.js +45 -0
  45. package/dist/server/commands/service.js +87 -0
  46. package/dist/server/commands/setup.js +36 -0
  47. package/dist/server/commands/setupWeb.js +87 -0
  48. package/dist/server/commands/status.js +32 -0
  49. package/dist/server/commands/wizard/probe.js +19 -0
  50. package/dist/server/commands/wizard/server.js +162 -0
  51. package/dist/server/http/app.js +144 -0
  52. package/dist/server/http/auth/guard.js +78 -0
  53. package/dist/server/http/auth/lockout.js +41 -0
  54. package/dist/server/http/auth/passkey.js +151 -0
  55. package/dist/server/http/auth/password.js +23 -0
  56. package/dist/server/http/auth/sessions.js +46 -0
  57. package/dist/server/http/routes/appUpdate.js +97 -0
  58. package/dist/server/http/routes/auth.js +79 -0
  59. package/dist/server/http/routes/chat.js +216 -0
  60. package/dist/server/http/routes/claude.js +69 -0
  61. package/dist/server/http/routes/codex.js +58 -0
  62. package/dist/server/http/routes/context.js +1 -0
  63. package/dist/server/http/routes/health.js +56 -0
  64. package/dist/server/http/routes/journal.js +35 -0
  65. package/dist/server/http/routes/mac.js +39 -0
  66. package/dist/server/http/routes/media.js +83 -0
  67. package/dist/server/http/routes/push.js +78 -0
  68. package/dist/server/http/routes/sessions.js +104 -0
  69. package/dist/server/http/routes/web.js +28 -0
  70. package/dist/server/http/serve.js +129 -0
  71. package/dist/server/http/sessionSocket.js +199 -0
  72. package/dist/server/ios-update/appUpdate.js +95 -0
  73. package/dist/server/ios-update/bundlePatch.js +56 -0
  74. package/dist/server/ios-update/bundlePatch.worker.js +11 -0
  75. package/dist/server/journal/db.js +243 -0
  76. package/dist/server/journal/derive.js +50 -0
  77. package/dist/server/journal/index.js +256 -0
  78. package/dist/server/journal/ingest.js +151 -0
  79. package/dist/server/notify/apns.js +126 -0
  80. package/dist/server/notify/push.js +109 -0
  81. package/dist/server/ops/awake.js +10 -0
  82. package/dist/server/ops/build.js +40 -0
  83. package/dist/server/ops/doctor.js +110 -0
  84. package/dist/server/ops/lastOp.js +34 -0
  85. package/dist/server/ops/problems.js +54 -0
  86. package/dist/server/ops/release.js +206 -0
  87. package/dist/server/ops/report.js +40 -0
  88. package/dist/server/ops/service.js +39 -0
  89. package/dist/server/ops/shutdown.js +12 -0
  90. package/dist/server/sessions/control.js +97 -0
  91. package/dist/server/sessions/events.js +77 -0
  92. package/dist/server/sessions/machine/accounts.js +55 -0
  93. package/dist/server/sessions/machine/input.js +208 -0
  94. package/dist/server/sessions/machine/lifecycle.js +357 -0
  95. package/dist/server/sessions/machine/moves.js +71 -0
  96. package/dist/server/sessions/machine/state.js +71 -0
  97. package/dist/server/sessions/machine/status.js +257 -0
  98. package/dist/server/sessions/machine/types.js +2 -0
  99. package/dist/server/sessions/machine.js +300 -0
  100. package/dist/server/sessions/outbox.js +194 -0
  101. package/dist/server/sessions/panes.js +60 -0
  102. package/dist/server/sessions/sessionName.js +7 -0
  103. package/dist/server/sessions/terminalEnv.js +40 -0
  104. package/dist/server/system/config.js +71 -0
  105. package/dist/server/system/dirs.js +54 -0
  106. package/dist/server/system/errors.js +37 -0
  107. package/dist/server/system/log.js +40 -0
  108. package/dist/server/system/messages.js +378 -0
  109. package/dist/server/system/node.js +6 -0
  110. package/dist/server/system/paths.js +51 -0
  111. package/dist/server/system/store.js +43 -0
  112. package/dist/server/system/tmux.js +198 -0
  113. package/dist/server/system/version.js +16 -0
  114. package/dist/server/uploads/fileUpload.js +171 -0
  115. package/dist/server/uploads/media.js +139 -0
  116. package/dist/web/assets/Setup-C27bNf5I.js +1 -0
  117. package/dist/web/assets/main-C8GiLoJy.css +1 -0
  118. package/dist/web/assets/main-DQFM4yJU.js +132 -0
  119. package/dist/web/icon-180.png +0 -0
  120. package/dist/web/icon-192.png +0 -0
  121. package/dist/web/icon-512.png +0 -0
  122. package/dist/web/icon.svg +6 -0
  123. package/dist/web/index.html +25 -0
  124. package/dist/web/manifest.webmanifest +15 -0
  125. package/dist/web/sw.js +8 -0
  126. package/dist/web/theme.js +11 -0
  127. package/package.json +77 -4
  128. package/scripts/smoke.mjs +130 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 1.0.1
4
+
5
+ The first release published from GitHub Actions, with npm provenance.
6
+
7
+ - A session that ends just as the phone opens it now closes on the phone, instead of staying open on nothing.
8
+
9
+ ## 1.0.0
10
+
11
+ The first public release.
12
+
13
+ - Chat with the Claude Code sessions on your Mac from your phone, in a web app you can add to the home
14
+ screen. The interface is in English or Chinese, following the phone.
15
+ - Claude's questions, permission prompts and panels such as `/usage` become cards you answer with a tap.
16
+ - Sessions run in tmux on the Mac and outlive the phone; `workoffdesk attach` joins one from the Mac's
17
+ terminal.
18
+ - One user per install, with a password and passkeys; sign-ins can be revoked device by device.
19
+ - Web push when Claude waits for you or finishes a turn.
20
+ - `install.sh` installs the dependencies and the npm package; `workoffdesk install` runs the service under
21
+ launchd; `workoffdesk deploy` and `rollback` upgrade it and switch back when the new version does not start.
22
+ - `workoffdesk doctor` checks tmux, Claude Code, the service, the address, idle sleep and the push key, and says
23
+ what to do about each problem. `setup` and `doctor` print the address as a QR code.
24
+ - While the service runs, the Mac does not go to idle sleep.
25
+ - `workoffdesk --version`.
26
+ - An iPhone app you build with your own Apple Developer account, with notifications through APNs. It keeps
27
+ every account you sign in to, and you move between them from Settings.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 workoffdesk contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,200 @@
1
- # Temporary Holding Version
1
+ # workoffdesk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Chat with the Claude Code and Codex sessions on your Mac from your phone.**
4
+
5
+ English · [简体中文](https://github.com/workoffdesk/workoffdesk/blob/main/README.zh-CN.md)
6
+
7
+ <p align="center"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/demo.gif" alt="Giving a session on the Mac work from the phone, and answering its question with a tap" width="320"></p>
8
+
9
+ ## What makes it different
10
+
11
+ - **No account, no relay of ours.** It is a small web app on your own Mac; your phone reaches it over the
12
+ path you choose, such as Tailscale, a Cloudflare Tunnel or your own server.
13
+ - **Sessions outlive the phone.** Each one runs in tmux on the Mac: closing the page, locking the phone or
14
+ restarting the service does not end it.
15
+ - **Leaving the desk, take it with you; back at it, keep going.** A Claude Code conversation running in a
16
+ terminal on the Mac can be taken over from the phone, and `workoffdesk attach` joins any session from the
17
+ Mac's terminal.
18
+ - **Tap instead of typing keys.** Claude's questions, permission prompts and panels like `/usage` become
19
+ cards on the phone.
20
+ - **Claude Code or Codex.** Start either in a project folder; each session is named after what its
21
+ conversation is about, so the list says what each one does.
22
+
23
+ ## Screens
24
+
25
+ <table width="100%">
26
+ <tr><th width="33%">Chat</th><th width="33%">A question from Claude</th><th width="33%">Sessions</th></tr>
27
+ <tr><td width="33%"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/chat.png" alt="Chat" width="100%"></td><td width="33%"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/choice.png" alt="A choice card" width="100%"></td><td width="33%"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/sessions.png" alt="The session list" width="100%"></td></tr>
28
+ </table>
29
+
30
+ ## How it fits together
31
+
32
+ <p align="center"><img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/docs/screens/how-it-works.gif" alt="How it fits together: the phone, an HTTPS tunnel you pick, the service on your Mac, sessions in tmux" width="100%"></p>
33
+
34
+ ```text
35
+ phone: the web app, or the iPhone app
36
+ │ HTTPS, over the path you choose: Tailscale, a Cloudflare Tunnel, your own server
37
+ ▼
38
+ the workoffdesk service on your Mac (listens on 127.0.0.1, runs as a launchd agent)
39
+ │
40
+ ▼
41
+ tmux sessions: Claude Code or Codex, each in a project folder
42
+ ```
43
+
44
+ Everything runs on your Mac, and everything it keeps is in `~/.workoffdesk`. You look after the service from
45
+ any of four places; they run the same checks:
46
+
47
+ | Where | What it is for |
48
+ |---|---|
49
+ | `workoffdesk` in a terminal | Set up, install, start, stop, check, join a session, upgrade and roll back. See [Commands](#commands). |
50
+ | The setup page (`workoffdesk setup --web`) | The first setup, step by step in your browser: the checks, your account, the address, the service, and the address as a QR code. |
51
+ | The menu bar app (`apps/mac`, built from source) | Whether the service runs at a glance; failed checks with their fixes, start, stop and restart, the QR code, the Claude account. |
52
+ | Settings → **This Mac**, on the phone or the web | The checks, the version, the newest warnings and errors, Restart and Roll back, and which Claude account new sessions run on. |
53
+
54
+ ## Quick start
55
+
56
+ On the Mac (macOS, with [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed, and
57
+ [Codex](https://github.com/openai/codex) if you use it):
58
+
59
+ ```bash
60
+ curl -fsSL https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/install.sh | bash
61
+ ```
62
+
63
+ `install.sh` installs tmux and Node with Homebrew if they are missing and never uses `sudo`. Run in a terminal
64
+ on a Mac not set up yet, it then opens the setup page in your browser. Without the script, or by hand:
65
+
66
+ ```bash
67
+ npm i -g workoffdesk # needs Node 22.13+ and tmux 3.3+
68
+ workoffdesk setup --origin https://workoffdesk.example.com
69
+ workoffdesk install # then run the launchctl line it prints
70
+ workoffdesk doctor
71
+ ```
72
+
73
+ `setup` asks for a username and a password; `install` writes a launchd service and prints the command that
74
+ starts it; `doctor` checks everything and says what to do about anything that fails. See
75
+ [Install](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/install.md).
76
+
77
+ ## Reach it from your phone
78
+
79
+ The server listens on `127.0.0.1` only. Put HTTPS in front of it: `tailscale serve` keeps the traffic
80
+ between your own devices, while a Cloudflare Tunnel works from any network but Cloudflare sees the traffic.
81
+ [Remote access](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/remote-access.md) compares the options and sets each one up.
82
+
83
+ > [!WARNING]
84
+ > Whoever signs in can run any command on your Mac as you. Use a strong password, keep the address to
85
+ > yourself, and read [SECURITY.md](https://github.com/workoffdesk/workoffdesk/blob/main/SECURITY.md).
86
+
87
+ ## Using it
88
+
89
+ A typical day: start a task at the desk with `workoffdesk claude`, or in a terminal as usual and take it over
90
+ from the phone later. Away from the desk, answer Claude's questions and send the next steps from the phone.
91
+ Back at the desk, `workoffdesk attach` joins the same session, with everything the phone did in it.
92
+
93
+ ### On the phone
94
+
95
+ 1. Open your address (`setup` prints it as a QR code) and sign in.
96
+ 2. Add it to the home screen.
97
+ 3. In Settings, add a passkey and turn on notifications.
98
+
99
+ Then:
100
+
101
+ - **Start or go on.** ⊕ starts Claude or Codex in a project folder, or opens a recent conversation from the
102
+ Mac to read and continue. One still open in a terminal on the Mac can be taken over, now or once its
103
+ current step is done.
104
+ - **Read it as a chat.** Claude's replies are rendered as Markdown and tool calls are folded. Questions,
105
+ permission prompts and panels like `/usage` are cards to tap; **Screen** shows the Mac's screen as text
106
+ when the chat cannot show what Claude waits on.
107
+ - **Send.** Type, hold to talk, or add pictures, videos and files. What you send while Claude works goes
108
+ into Claude's own queue. **Stop** interrupts it; **Rewind** goes back to an earlier point.
109
+ - **Be told.** A notification comes when Claude waits for you or finishes a turn.
110
+
111
+ Prefer a native app? [Build the iPhone app](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/ios-app.md) with your own Apple Developer account.
112
+ Everything else is in [Usage](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/usage.md).
113
+
114
+ ### At the Mac
115
+
116
+ ```bash
117
+ workoffdesk claude # start Claude in this folder as a session, and join it
118
+ workoffdesk codex # the same for Codex
119
+ workoffdesk attach # join a session, picked from a list
120
+ ```
121
+
122
+ It is the same tmux session the phone shows, so both can be on it at once. `Ctrl-b d` detaches and leaves
123
+ it running. In a session on the phone, ⋯ → **Continue on the Mac** shows the `attach` command to copy.
124
+
125
+ ### Looking after the service
126
+
127
+ `workoffdesk doctor` checks macOS, Node, tmux, Claude Code, Codex, the setup, the service, your address, idle
128
+ sleep and the iPhone push key, and prints the fix for each failure. The same checks, with the service's
129
+ newest warnings and errors, are on Settings → **This Mac** and in the menu bar app. A new version is installed
130
+ with `workoffdesk deploy`, which smoke-tests it before switching and switches back if it does not come up;
131
+ `workoffdesk rollback` goes back one release. See [Upgrade](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/upgrade.md) and
132
+ [Troubleshooting](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/troubleshooting.md).
133
+
134
+ While the service runs it keeps the Mac from idle sleep; the display still sleeps, and closing the lid still
135
+ sleeps the Mac.
136
+
137
+ ## Commands
138
+
139
+ Each is run as `workoffdesk <command>`.
140
+
141
+ | Command | What it does |
142
+ |---|---|
143
+ | `setup` | Asks for a username and a password (12 characters or more) and writes the config. Run again, it sets a new password and signs every device out. |
144
+ | `setup --web` | The setup as a page in your browser, including the address and the service. |
145
+ | `install` | Writes the launchd agent and `~/.local/bin/workoffdesk`, starts nothing, and prints the commands to run. `--tunnel <name>` also sets up an existing Cloudflare tunnel. |
146
+ | `start` | Starts the service and waits up to 30 seconds for it to answer. |
147
+ | `stop` | Stops the service until the next login or `start`. |
148
+ | `restart` | Restarts the service. Sessions keep running. |
149
+ | `doctor` | Runs every check, prints the fix for each failure, and exits 1 while anything fails. |
150
+ | `status --json` | The checks, the version, the releases, the address, whether the service runs, and its newest 20 warnings and errors. |
151
+ | `claude [--resume <session id>]` | Starts Claude in this folder as a session and joins it; with `--resume`, going on with that conversation. |
152
+ | `codex [--resume <thread id>]` | The same for Codex. |
153
+ | `attach [id]` | Joins a session from this terminal; without an id, lists them to pick one. |
154
+ | `serve` | Runs the server in this terminal instead of as the service. |
155
+ | `deploy <build folder>` | Installs a build as a new release, smoke-tests it, switches to it and restarts the service; keeps the newest 3. |
156
+ | `rollback` | Goes back to the release before. |
157
+ | `--version` | Prints the installed version. |
158
+ | `--help` | Prints this list, one line for each command; so does `workoffdesk` with no command. |
159
+
160
+ Everything is kept in `~/.workoffdesk`; set `WORKOFFDESK_HOME` to keep it elsewhere. Settings such as
161
+ `pinnedDirs` are in [Configuration](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/configuration.md).
162
+
163
+ ## Compared with similar tools
164
+
165
+ Checked on 2026-10-07. These products change quickly; check again before quoting this table.
166
+
167
+ | | Works with | Traffic goes through | Account needed | After the network drops | Native apps | License |
168
+ |---|---|---|---|---|---|---|
169
+ | **workoffdesk** | Claude Code, Codex | Your Mac and the path you choose | None | Session keeps running in tmux | iPhone (build it yourself); web app | MIT |
170
+ | [Claude Code Remote Control](https://code.claude.com/docs/en/remote-control) | Claude Code | Anthropic API | Claude Pro, Max, Team or Enterprise | Reconnects; the local process must keep running | Claude iOS and Android | Not stated |
171
+ | [Happy](https://github.com/slopus/happy) | Claude Code, Codex | Happy Server, end-to-end encrypted | Not stated | Not stated | iOS, Android, macOS, web | MIT |
172
+ | [zalify/offdesk](https://github.com/zalify/offdesk) | Anything in tmux | A hub, end-to-end encrypted | None to self-host | Session keeps running in tmux | iOS (TestFlight), Android, desktop | MIT |
173
+ | [VibeTunnel](https://github.com/amantus-ai/vibetunnel) | Any terminal program | The tunnel you choose | Not stated | Not stated | macOS; iOS in progress | MIT |
174
+
175
+ <!--
176
+ Sources, read 2026-10-07:
177
+ - workoffdesk: docs/facts.md (Codex: Sessions)
178
+ - Claude Code Remote Control: https://code.claude.com/docs/en/remote-control
179
+ (traffic through the Anthropic API; Pro/Max/Team/Enterprise plan, API keys not supported;
180
+ "reconnects automatically"; the local process must keep running; Claude iOS and Android apps)
181
+ - Happy: https://github.com/slopus/happy ("happy claude", "happy codex"; Happy Server with encrypted sync;
182
+ iOS, Android, macOS, web; MIT)
183
+ - zalify/offdesk: https://github.com/zalify/offdesk ("anything that runs in tmux"; hub with end-to-end
184
+ encryption; "Self-hosting needs no vendor account"; tmux keeps sessions alive; releases:
185
+ https://github.com/zalify/offdesk/releases; MIT)
186
+ - VibeTunnel: https://github.com/amantus-ai/vibetunnel ("Turn any browser into your Mac terminal";
187
+ Tailscale, ngrok, Cloudflare Quick Tunnel; native macOS app, iOS app in progress; MIT)
188
+ -->
189
+
190
+ ## Documentation
191
+
192
+ - [Install](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/install.md) · [Remote access](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/remote-access.md) · [Usage](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/usage.md)
193
+ - [Configuration](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/configuration.md) · [Upgrade](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/upgrade.md) ·
194
+ [Troubleshooting](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/troubleshooting.md)
195
+ - [How it works](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/how-it-works.md) · [iPhone app](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/ios-app.md)
196
+ - [Contributing](https://github.com/workoffdesk/workoffdesk/blob/main/CONTRIBUTING.md) · [Changelog](https://github.com/workoffdesk/workoffdesk/blob/main/apps/server/CHANGELOG.md) · [Security](https://github.com/workoffdesk/workoffdesk/blob/main/SECURITY.md)
197
+
198
+ ## License
199
+
200
+ MIT, see [LICENSE](https://github.com/workoffdesk/workoffdesk/blob/main/LICENSE). Not affiliated with Anthropic. Claude is a trademark of Anthropic.
package/SECURITY.md ADDED
@@ -0,0 +1,50 @@
1
+ # Security
2
+
3
+ ## What you are putting on the internet
4
+
5
+ workoffdesk is a remote terminal. **Whoever signs in can run anything your macOS user can run**: read your
6
+ files, use your SSH keys and cloud credentials, start Claude Code with your account. Treat the password
7
+ like your login password and the site like an SSH port.
8
+
9
+ ## What it does to keep others out
10
+
11
+ - **One user.** There is exactly one account per install, made with `workoffdesk setup`. Running `setup`
12
+ again replaces the password and signs every device out.
13
+ - **Password and passkeys.** Passwords (12 characters or more) are stored as scrypt hashes. After signing in
14
+ you can add passkeys (WebAuthn, e.g. Face ID) and sign in with those instead.
15
+ - **Lockout.** Five failed sign-ins from one address within 15 minutes lock that address for 15 minutes;
16
+ twenty failures from anywhere within an hour lock sign-in for everyone for an hour. Every attempt is
17
+ written to `logs/audit.log`.
18
+ - **Sessions you can revoke.** A sign-in lasts 30 days. Each signed-in device is listed in Settings and can
19
+ be revoked; it is sent back to the sign-in page on its next request.
20
+ - **Origin checks.** Requests that change anything, and every WebSocket, must come from the configured
21
+ `origin`; anything else is refused.
22
+ - **Content Security Policy.** Pages load scripts, styles and images only from the site itself; images
23
+ from the web inside Claude's replies are shown as links, never loaded.
24
+ - **Loopback only.** The server listens on `127.0.0.1` and does not trust `X-Forwarded-*` headers.
25
+
26
+ ## Who can read your traffic
27
+
28
+ The path between your phone and the Mac decides who else sees what you type and what Claude answers. See
29
+ [Remote access](https://github.com/workoffdesk/workoffdesk/blob/main/docs/guide/remote-access.md).
30
+
31
+ - **Tailscale** (`tailscale serve`): TLS ends on your Mac; only your own devices take part.
32
+ - **A Cloudflare Tunnel**: Cloudflare terminates TLS at its edge, so Cloudflare can read the traffic.
33
+ - **Your own server** with a reverse proxy: that server terminates TLS and can read the traffic.
34
+
35
+ ## How to run it safely
36
+
37
+ - **Always serve it over HTTPS.** Never expose the port directly or forward it on your router: cookies are
38
+ `Secure` and passkeys need HTTPS, and a plain-HTTP site sends your password in the clear.
39
+ - Choose a long password, add a passkey, and revoke devices you no longer use.
40
+ - An access layer in front, such as a VPN or Tailscale, adds a second lock: only your devices can reach the
41
+ sign-in page at all.
42
+ - **`claudeArgs` widens what a session does without you.** `--dangerously-skip-permissions` lets Claude run
43
+ any command and edit any file without asking; combined with remote access, a prompt injection in
44
+ something Claude reads can act on your Mac while you are away. Add it only if you accept that.
45
+
46
+ ## Reporting a vulnerability
47
+
48
+ Report it privately through the repository's
49
+ [security advisories](https://github.com/workoffdesk/workoffdesk/security/advisories/new). Please do not open a
50
+ public issue for anything that lets someone sign in, read files or run commands without the password.
@@ -0,0 +1 @@
1
+ {"version":"1.0.1","commit":"0c91daa8df475d40277aa688bddd0ed166ec32db","dirty":false}
@@ -0,0 +1,105 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ const FRESH_MS = 10_000;
6
+ const SECURITY_MS = 5_000;
7
+ const real = (p) => {
8
+ try {
9
+ return realpathSync(p);
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ };
15
+ function readAccount(file) {
16
+ try {
17
+ const j = JSON.parse(readFileSync(file, 'utf8'));
18
+ const email = j.oauthAccount?.emailAddress;
19
+ return typeof email === 'string' && email ? { email, ready: j.hasCompletedOnboarding === true } : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ // Only the item's attributes are read: no prompt, and no secret leaves the Keychain.
26
+ const keychainHas = (service) => new Promise((resolve) => execFile('/usr/bin/security', ['find-generic-password', '-s', service], { timeout: SECURITY_MS }, (err) => resolve(!err)));
27
+ export class ClaudeAccounts {
28
+ o;
29
+ last = null;
30
+ reading = null;
31
+ constructor(o) {
32
+ this.o = o;
33
+ }
34
+ // Read anew once the last read is FRESH_MS old, one read at a time.
35
+ list() {
36
+ if (this.last && Date.now() - this.last.at < FRESH_MS)
37
+ return Promise.resolve(this.last.accounts);
38
+ this.reading ??= this.read().finally(() => (this.reading = null));
39
+ return this.reading;
40
+ }
41
+ // The last read, for what cannot wait on one.
42
+ known() {
43
+ return this.last?.accounts ?? [];
44
+ }
45
+ pickable(a) {
46
+ return a.signedIn && a.ready && a.shared;
47
+ }
48
+ // The account a config dir is, however it is spelled; unset is the default. undefined: a dir that is gone.
49
+ idOf(dir) {
50
+ if (!dir)
51
+ return '';
52
+ const r = real(dir);
53
+ if (r === null)
54
+ return undefined;
55
+ if (r === real(join(this.o.home, '.claude')))
56
+ return '';
57
+ return this.known().find((a) => a.id && real(a.id) === r)?.id ?? r;
58
+ }
59
+ // The account this server's own environment gives a Claude.
60
+ ownId() {
61
+ return this.idOf(this.o.own) ?? '';
62
+ }
63
+ folder(dir) {
64
+ return dir.startsWith(`${this.o.home}/`) ? `~${dir.slice(this.o.home.length)}` : dir;
65
+ }
66
+ async read() {
67
+ const has = this.o.keychainHas ?? keychainHas;
68
+ const projects = real(this.o.projectsRoot);
69
+ const home = join(this.o.home, '.claude');
70
+ const dirs = this.o.own ? [this.o.own] : [];
71
+ for (const parent of [this.o.home, join(this.o.home, '.config')]) {
72
+ let names = [];
73
+ try {
74
+ names = readdirSync(parent);
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ for (const name of names)
80
+ if (parent !== this.o.home || name.startsWith('.'))
81
+ dirs.push(join(parent, name));
82
+ }
83
+ const accounts = [];
84
+ const seen = new Set();
85
+ const add = async (id, dir, file, service) => {
86
+ const r = real(dir);
87
+ if (r === null || seen.has(r))
88
+ return;
89
+ const found = readAccount(file);
90
+ if (!found)
91
+ return;
92
+ seen.add(r);
93
+ const signedIn = (await has(service)) || existsSync(join(dir, '.credentials.json'));
94
+ accounts.push({ id, folder: this.folder(dir), email: found.email, signedIn, ready: found.ready, shared: projects !== null && real(join(dir, 'projects')) === projects });
95
+ };
96
+ await add('', home, join(this.o.home, '.claude.json'), 'Claude Code-credentials');
97
+ for (const dir of dirs) {
98
+ if (dir === home)
99
+ continue;
100
+ await add(dir, dir, join(dir, '.claude.json'), `Claude Code-credentials-${createHash('sha256').update(dir).digest('hex').slice(0, 8)}`);
101
+ }
102
+ this.last = { at: Date.now(), accounts };
103
+ return accounts;
104
+ }
105
+ }
@@ -0,0 +1,135 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { toolSummary } from '../transcript/chat.js';
3
+ // Tools whose dialog says more than yes or no (Claude's questions, its plan): left to the screen.
4
+ const ON_SCREEN = new Set(['AskUserQuestion', 'ExitPlanMode']);
5
+ const DETAIL_MAX = 4000;
6
+ // A prompt is let go a little before its hook would be stopped (PERMISSION_HOLD_S in hooks.ts), so the
7
+ // hook ends with no word rather than being killed, which reads as the Mac's No.
8
+ export const HOLD_MS = 3500_000;
9
+ const DECLINED = 'The user declined this from their phone.';
10
+ // Whether the prompt's dialog is one the phone leaves to the Mac's screen.
11
+ export const leftToScreen = (payload) => typeof payload.tool_name === 'string' && ON_SCREEN.has(payload.tool_name);
12
+ // What a prompt asks of the person: Claude's question, its plan, or the tool and what it would do with it.
13
+ export function askedOf(payload) {
14
+ const tool = typeof payload.tool_name === 'string' ? payload.tool_name : '';
15
+ const input = isRecord(payload.tool_input) ? payload.tool_input : {};
16
+ if (tool === 'AskUserQuestion') {
17
+ const first = Array.isArray(input.questions) ? input.questions[0] : null;
18
+ return isRecord(first) ? str(first.question) : '';
19
+ }
20
+ if (tool === 'ExitPlanMode')
21
+ return str(input.plan);
22
+ return tool && `${tool} ${toolSummary(tool, input)}`.trim();
23
+ }
24
+ // The permission prompts Claude Code's PermissionRequest hooks hold open for the phone, per session. The
25
+ // dialog stays on the Mac's screen all the while: whichever answers first is the answer, and a prompt is
26
+ // over when the phone answers it, the Mac does, or the turn moves on without it.
27
+ export class PermissionAsks {
28
+ changed;
29
+ held = new Map();
30
+ // changed: a session's asks came or went.
31
+ constructor(changed = () => { }) {
32
+ this.changed = changed;
33
+ }
34
+ // The ask the phone is shown for a prompt, or null when it is one left to the screen (and the hook has
35
+ // been answered with nothing).
36
+ hold(termId, payload, hook) {
37
+ const tool = typeof payload.tool_name === 'string' ? payload.tool_name : '';
38
+ const input = isRecord(payload.tool_input) ? payload.tool_input : null;
39
+ if (!tool || !input || ON_SCREEN.has(tool)) {
40
+ hook.reply(null);
41
+ return null;
42
+ }
43
+ const suggestions = Array.isArray(payload.permission_suggestions) ? payload.permission_suggestions.filter(isRecord) : [];
44
+ const ask = { id: randomUUID(), kind: 'permission', tool, title: toolSummary(tool, input), detail: detailOf(tool, input), always: alwaysOf(suggestions) };
45
+ const timer = setTimeout(() => this.take(termId, (h) => h.ask.id === ask.id)?.hook.reply(null), HOLD_MS).unref();
46
+ const list = this.held.get(termId) ?? [];
47
+ list.push({ ask, call: callKey(tool, input), suggestions, hook, timer });
48
+ this.held.set(termId, list);
49
+ this.changed(termId);
50
+ return ask;
51
+ }
52
+ list(termId) {
53
+ return (this.held.get(termId) ?? []).map((h) => h.ask);
54
+ }
55
+ // False when the ask is over. "From now on" takes every suggestion Claude Code made, as its own dialog's
56
+ // option does.
57
+ answer(termId, askId, answer) {
58
+ const now = this.take(termId, (h) => h.ask.id === askId);
59
+ if (!now)
60
+ return false;
61
+ const keep = answer.allow && answer.always && now.suggestions.length ? { updatedPermissions: now.suggestions } : {};
62
+ now.hook.reply(decision(answer.allow ? { behavior: 'allow', ...keep } : { behavior: 'deny', message: DECLINED, interrupt: true }));
63
+ return true;
64
+ }
65
+ // The hook ended without an answer from here: Claude Code stopped it. True when it was still held.
66
+ gone(termId, askId) {
67
+ return !!this.take(termId, (h) => h.ask.id === askId);
68
+ }
69
+ // The tool of a prompt ran (or failed): it was allowed at the Mac, and its hook waits on nothing.
70
+ toolDone(termId, tool, input) {
71
+ if (typeof tool !== 'string' || !isRecord(input))
72
+ return;
73
+ const call = callKey(tool, input);
74
+ this.take(termId, (h) => h.call === call)?.hook.reply(null);
75
+ }
76
+ // The turn is over or the session gone: nothing it held is asked any more.
77
+ endAll(termId) {
78
+ for (const h of this.held.get(termId) ?? []) {
79
+ clearTimeout(h.timer);
80
+ h.hook.reply(null);
81
+ }
82
+ if (this.held.delete(termId))
83
+ this.changed(termId);
84
+ }
85
+ take(termId, which) {
86
+ const list = this.held.get(termId) ?? [];
87
+ const i = list.findIndex(which);
88
+ if (i < 0)
89
+ return undefined;
90
+ const [h] = list.splice(i, 1);
91
+ if (!list.length)
92
+ this.held.delete(termId);
93
+ clearTimeout(h.timer);
94
+ this.changed(termId);
95
+ return h;
96
+ }
97
+ }
98
+ const decision = (d) => ({ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: d } });
99
+ const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
100
+ const str = (v) => (typeof v === 'string' ? v : '');
101
+ const clip = (s) => (s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}…` : s);
102
+ const marked = (mark, s) => s.split('\n').map((l) => `${mark} ${l}`).join('\n');
103
+ // The whole of what the tool is asked to do, as far as the card has room for it.
104
+ function detailOf(tool, input) {
105
+ if (tool === 'Bash')
106
+ return clip(str(input.command));
107
+ if (tool === 'Write')
108
+ return clip(str(input.content));
109
+ if (tool === 'Edit')
110
+ return clip(`${marked('-', str(input.old_string))}\n${marked('+', str(input.new_string))}`);
111
+ return clip(JSON.stringify(input, null, 2));
112
+ }
113
+ // What Claude Code's suggestions would allow from now on; null when it made none the phone can name.
114
+ function alwaysOf(suggestions) {
115
+ const list = (v) => (Array.isArray(v) ? v : []);
116
+ const always = { rules: [], dirs: [], mode: null };
117
+ for (const s of suggestions) {
118
+ if (s.type === 'addRules' && s.behavior === 'allow') {
119
+ for (const r of list(s.rules).filter(isRecord))
120
+ always.rules.push(str(r.ruleContent) ? `${str(r.toolName)}(${str(r.ruleContent)})` : str(r.toolName));
121
+ }
122
+ else if (s.type === 'addDirectories')
123
+ always.dirs.push(...list(s.directories).map(str).filter(Boolean));
124
+ else if (s.type === 'setMode' && str(s.mode))
125
+ always.mode = str(s.mode);
126
+ else
127
+ return null;
128
+ }
129
+ return always.rules.length || always.dirs.length || always.mode ? always : null;
130
+ }
131
+ // A tool call as the same text however its input's keys are ordered, to know a PostToolUse for it.
132
+ function callKey(tool, input) {
133
+ const sorted = (v) => Array.isArray(v) ? v.map(sorted) : isRecord(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])])) : v;
134
+ return JSON.stringify([tool, sorted(input)]);
135
+ }