@dalmasonto/taskflow-mcp 1.0.33 → 2.0.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 (99) hide show
  1. package/README.md +138 -193
  2. package/dist/attachment-download.d.ts +74 -0
  3. package/dist/attachment-download.js +193 -0
  4. package/dist/attachment-download.js.map +1 -0
  5. package/dist/attachments.d.ts +23 -0
  6. package/dist/attachments.js +66 -0
  7. package/dist/attachments.js.map +1 -0
  8. package/dist/client.d.ts +206 -0
  9. package/dist/client.js +279 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +137 -18
  12. package/dist/config.js +187 -106
  13. package/dist/config.js.map +1 -0
  14. package/dist/connect.d.ts +89 -0
  15. package/dist/connect.js +269 -0
  16. package/dist/connect.js.map +1 -0
  17. package/dist/doctor.d.ts +24 -0
  18. package/dist/doctor.js +120 -0
  19. package/dist/doctor.js.map +1 -0
  20. package/dist/events.d.ts +186 -0
  21. package/dist/events.js +407 -0
  22. package/dist/events.js.map +1 -0
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.js +124 -184
  25. package/dist/index.js.map +1 -0
  26. package/dist/instructions.d.ts +12 -0
  27. package/dist/instructions.js +114 -0
  28. package/dist/instructions.js.map +1 -0
  29. package/dist/mint.d.ts +62 -0
  30. package/dist/mint.js +135 -0
  31. package/dist/mint.js.map +1 -0
  32. package/dist/mirror.d.ts +68 -0
  33. package/dist/mirror.js +103 -0
  34. package/dist/mirror.js.map +1 -0
  35. package/dist/pane-queue.d.ts +29 -0
  36. package/dist/pane-queue.js +35 -0
  37. package/dist/pane-queue.js.map +1 -0
  38. package/dist/prompts.d.ts +79 -0
  39. package/dist/prompts.js +211 -0
  40. package/dist/prompts.js.map +1 -0
  41. package/dist/resolve.d.ts +72 -0
  42. package/dist/resolve.js +89 -0
  43. package/dist/resolve.js.map +1 -0
  44. package/dist/runtime.d.ts +54 -0
  45. package/dist/runtime.js +321 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/dist/server.d.ts +56 -0
  48. package/dist/server.js +793 -0
  49. package/dist/server.js.map +1 -0
  50. package/dist/session-identifier.d.ts +48 -0
  51. package/dist/session-identifier.js +44 -0
  52. package/dist/session-identifier.js.map +1 -0
  53. package/dist/sessions-store.d.ts +38 -0
  54. package/dist/sessions-store.js +88 -0
  55. package/dist/sessions-store.js.map +1 -0
  56. package/dist/tmux.d.ts +200 -0
  57. package/dist/tmux.js +580 -0
  58. package/dist/tmux.js.map +1 -0
  59. package/hooks/metadata.mjs +99 -0
  60. package/hooks/permission-prompt.mjs +100 -0
  61. package/hooks/taskflow-hook.mjs +499 -0
  62. package/hooks/tool-logging.mjs +63 -0
  63. package/package.json +38 -29
  64. package/dist/agent-registry.d.ts +0 -27
  65. package/dist/agent-registry.js +0 -153
  66. package/dist/db.d.ts +0 -5
  67. package/dist/db.js +0 -220
  68. package/dist/helpers.d.ts +0 -21
  69. package/dist/helpers.js +0 -27
  70. package/dist/resources.d.ts +0 -2
  71. package/dist/resources.js +0 -89
  72. package/dist/sse.d.ts +0 -10
  73. package/dist/sse.js +0 -766
  74. package/dist/tmux-bridge.d.ts +0 -12
  75. package/dist/tmux-bridge.js +0 -157
  76. package/dist/tools/activity.d.ts +0 -39
  77. package/dist/tools/activity.js +0 -152
  78. package/dist/tools/agent-inbox.d.ts +0 -12
  79. package/dist/tools/agent-inbox.js +0 -257
  80. package/dist/tools/agent.d.ts +0 -14
  81. package/dist/tools/agent.js +0 -168
  82. package/dist/tools/analytics.d.ts +0 -21
  83. package/dist/tools/analytics.js +0 -191
  84. package/dist/tools/checkpoint.d.ts +0 -27
  85. package/dist/tools/checkpoint.js +0 -105
  86. package/dist/tools/notifications.d.ts +0 -31
  87. package/dist/tools/notifications.js +0 -59
  88. package/dist/tools/projects.d.ts +0 -55
  89. package/dist/tools/projects.js +0 -112
  90. package/dist/tools/settings.d.ts +0 -19
  91. package/dist/tools/settings.js +0 -73
  92. package/dist/tools/tasks.d.ts +0 -105
  93. package/dist/tools/tasks.js +0 -403
  94. package/dist/tools/terminal.d.ts +0 -4
  95. package/dist/tools/terminal.js +0 -98
  96. package/dist/tools/timer.d.ts +0 -37
  97. package/dist/tools/timer.js +0 -154
  98. package/dist/types.d.ts +0 -82
  99. package/dist/types.js +0 -30
package/README.md CHANGED
@@ -1,221 +1,166 @@
1
- # TaskFlow MCP Server
1
+ # @dalmasonto/taskflow-mcp — TaskFlow MCP server + Claude Code hooks
2
2
 
3
- A local-first task and time tracking system exposed as [MCP](https://modelcontextprotocol.io) tools. Any MCP-compatible AI agent can manage projects, tasks, timers, analytics, and notifications through this server.
3
+ An **agent client** for the TaskFlow backend. It lets a coding agent (e.g. Claude
4
+ Code) connect to your TaskFlow project using a stable, per-repo credential
5
+ (`.taskflow.json`) and drive the whole agent API: identity, tasks, chat, reviews,
6
+ live sessions, streamed terminal output, and real activity logging.
4
7
 
5
- ## Quickstart
6
-
7
- ### 1. Install and build
8
+ > **2.0.0** is a ground-up rewrite. Where the 1.x server kept its own local SQLite
9
+ > and inferred an agent's identity from `cwd + ppid`, this package holds **no state**
10
+ > and **no local DB**: it talks to a TaskFlow backend over HTTP, and its identity is
11
+ > exactly the `key` in `.taskflow.json`, so an agent linked yesterday keeps the same
12
+ > identity today. Multi-agent collaboration — channels, reviews, terminal streaming,
13
+ > `.taskflow.json` profiles — is new in 2.x.
8
14
 
9
15
  ```bash
10
- cd mcp-server
11
- npm install
12
- npm run build
16
+ npm install -g @dalmasonto/taskflow-mcp
13
17
  ```
14
18
 
15
- ### 2. Configure your MCP client
19
+ This puts two commands on your PATH:
20
+
21
+ - **`taskflow-mcp`** — the MCP server an MCP client (Claude Code, Cursor, …) runs.
22
+ - **`taskflow-hook`** — the Claude Code lifecycle hook (see [Hooks](#hooks)).
23
+
24
+ ## How it works
25
+
26
+ - **`.taskflow.json`** (per repo, gitignored) holds the server URL, project id, a
27
+ `default_profile`, and one or more named `profiles`. Each profile has an
28
+ `agent_id`, a `key` (the `tfk_…` credential), and a `display_name`.
29
+ - Every backend call sends `Authorization: Agent <key>`. The chosen profile's key
30
+ is the whole identity — the server derives the agent, project, and display name
31
+ from it.
32
+ - **Profile selection** (highest priority first): a tool's `profile` argument →
33
+ `TASKFLOW_PROFILE` env → the file's `default_profile` → `"main"`.
16
34
 
17
- Add a `.mcp.json` file to your project root (or wherever your MCP client reads config):
35
+ ## `.taskflow.json` format
18
36
 
19
37
  ```json
20
38
  {
21
- "mcpServers": {
22
- "taskflow": {
23
- "command": "node",
24
- "args": ["/absolute/path/to/mcp-server/dist/index.js"]
25
- }
39
+ "server": "http://localhost:8000",
40
+ "project": 1,
41
+ "default_profile": "main",
42
+ "profiles": {
43
+ "main": { "agent_id": 12, "key": "tfk_…", "display_name": "Builder" },
44
+ "reviewer": { "agent_id": 13, "key": "tfk_…", "display_name": "Reviewer" }
26
45
  }
27
46
  }
28
47
  ```
29
48
 
30
- Replace the path with the absolute path to your built `dist/index.js`.
31
-
32
- ### 3. Auto-allow permissions (Claude Code)
33
-
34
- By default, Claude Code will prompt you to approve each MCP tool call. To allow all TaskFlow tools without prompts, add this to `.claude/settings.local.json`:
49
+ Resolution: the file is read from `TASKFLOW_CONFIG` if set, otherwise found by
50
+ walking up from the working directory. See `.taskflow.json.example`.
51
+
52
+ ## Setup
53
+
54
+ 1. **Install the package** globally so `taskflow-mcp` and `taskflow-hook` are on
55
+ your PATH:
56
+ ```bash
57
+ npm install -g @dalmasonto/taskflow-mcp
58
+ ```
59
+ 2. **Link an agent.** In the TaskFlow dashboard open your project's **API Base**
60
+ page and link an agent (profile `main`, and optionally `reviewer`). It returns
61
+ a block containing the `agent_id`, raw `key`, and `display_name` — shown once.
62
+ 3. **Create `.taskflow.json`** at your repo root and paste the returned profile
63
+ block(s) under `profiles` (see the format above and `.taskflow.json.example`).
64
+ **Do not commit it** — add `.taskflow.json` to your `.gitignore`.
65
+ 4. **Register the MCP server** with Claude Code — copy `.mcp.json.example` to
66
+ `.mcp.json` at your repo root:
67
+ ```json
68
+ {
69
+ "mcpServers": {
70
+ "taskflow": { "command": "taskflow-mcp", "args": [] }
71
+ }
72
+ }
73
+ ```
74
+ 5. **Add the hooks** (optional but recommended) — see [Hooks](#hooks) below.
75
+
76
+ **From a local checkout** (contributing, or running an unpublished build) instead
77
+ of the global install: `cd mcp && npm install && npm run build`, then point the
78
+ MCP `command` at `node` with `args: ["./mcp/dist/index.js"]`, and the hook at
79
+ `node ABS_PATH/mcp/hooks/taskflow-hook.mjs`.
80
+
81
+ ## Hooks
82
+
83
+ The Claude Code hook (`taskflow-hook`) turns the agent's own lifecycle into real,
84
+ attributable activity on the TaskFlow board — no prompting required. On each event
85
+ it resolves your `.taskflow.json` + profile and POSTs to the backend:
86
+
87
+ - **SessionStart** → registers/reconnects your live session (you show "online").
88
+ - **PreToolUse / PostToolUse** → logs meaningful tool calls as activity. Read-only
89
+ noise (Read, Grep, and TaskFlow's own tools, which already write richer rows) is
90
+ filtered out, so the feed stays signal.
91
+ - **Stop** → closes the session cleanly.
92
+ - **Notification** → surfaces permission prompts so a human can answer from the UI.
93
+
94
+ It is **best-effort and never blocks or crashes the agent**: with no `.taskflow.json`,
95
+ or with the backend unreachable, every invocation swallows the error and exits `0`
96
+ in well under its short timeout.
97
+
98
+ **Wire it up** — copy the `hooks` block from `.claude/settings.example.json` into
99
+ your project's `.claude/settings.json`. With the global install the command is just
100
+ `taskflow-hook`:
35
101
 
36
102
  ```json
37
103
  {
38
- "permissions": {
39
- "allow": [
40
- "mcp__taskflow__*"
41
- ]
42
- },
43
- "enableAllProjectMcpServers": true,
44
- "enabledMcpjsonServers": [
45
- "taskflow"
46
- ]
104
+ "hooks": {
105
+ "SessionStart": [{ "hooks": [{ "type": "command", "command": "taskflow-hook" }] }],
106
+ "PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "taskflow-hook" }] }],
107
+ "PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "taskflow-hook" }] }],
108
+ "Stop": [{ "hooks": [{ "type": "command", "command": "taskflow-hook" }] }],
109
+ "Notification": [{ "hooks": [{ "type": "command", "command": "taskflow-hook" }] }]
110
+ }
47
111
  }
48
112
  ```
49
113
 
50
- The `mcp__taskflow__*` wildcard matches every tool exposed by this server.
51
-
52
- For other MCP clients (Cursor, Windsurf, etc.), check their docs for permission/auto-approve configuration.
53
-
54
- ## Agent Integration Guide
55
-
56
- ### How agents discover TaskFlow
57
-
58
- TaskFlow uses two layers to guide agent behavior:
59
-
60
- **Layer 1: Tool descriptions (passive discovery)**
61
- Every tool has a description that hints at when and why to use it. MCP clients surface these descriptions when the agent connects, so the agent learns the workflow organically. For example, `start_timer` says "Call this before beginning work on any task to track focused time."
62
-
63
- **Layer 2: `get_agent_instructions` tool (active onboarding)**
64
- This is the key tool. Its description says **"Call this at the start of every conversation."** When called, it returns:
65
-
66
- - A role description for the agent
67
- - A startup checklist (list projects, check in-progress tasks, check notifications)
68
- - Behavioral rules (when to start/stop timers, how to handle blockers, etc.)
69
- - Live context (current project count, in-progress tasks, blocked tasks, unread notifications)
70
- - The full task status workflow with valid transitions
71
-
72
- Any well-behaved agent will call this tool when it sees the description, without needing explicit user instructions.
73
-
74
- ### Making it automatic (recommended)
75
-
76
- For the most reliable experience, add one line to your project's `CLAUDE.md` (or equivalent agent config):
77
-
78
- ```markdown
79
- ## MCP Integration
80
- At the start of each conversation, call the `get_agent_instructions` tool from the taskflow MCP server to understand your task management workflow.
81
- ```
82
-
83
- This guarantees the agent calls the instruction tool on every conversation start. Without this, the agent will still likely discover the tool via its description, but the CLAUDE.md line makes it deterministic.
84
-
85
- ### Example conversation flow
114
+ The hook reads `TASKFLOW_PROFILE` (else `default_profile`, else `main`) and finds
115
+ `.taskflow.json` by walking up from the working directory (or `TASKFLOW_CONFIG`).
116
+ Set `TASKFLOW_HOOK_DEBUG=1` to see why a hook no-oped on stderr.
117
+
118
+ ## MCP tools
119
+
120
+ | Tool | What it does |
121
+ | --- | --- |
122
+ | `whoami` | Confirm the agent identity + project behind the credential. |
123
+ | `list_tasks(status?, assigned?)` | List project tasks; `assigned='me'` for claimed. |
124
+ | `create_task(title, description?, priority?, claim?)` | Create (optionally claim) a task. |
125
+ | `update_task_status(task, status)` | Advance a task's status. |
126
+ | `claim_task(task)` | Self-assign a task. |
127
+ | `report_review(task, decision, body?)` | Record a review (`approved`/`changes_requested`). |
128
+ | `list_channels` | Channels this agent can see. |
129
+ | `list_agents` | Other agents in the project. |
130
+ | `send_message(channel, body, priority?)` | Post a chat message as this agent. |
131
+ | `check_messages(channel, since?)` | Read a channel's messages + read cursor. |
132
+ | `mark_read(channel, last_read_message)` | Advance this agent's read cursor. |
133
+ | `register_session(session_identifier?, cwd?)` | Register/reconnect a live session. |
134
+ | `heartbeat(status?)` | Bump session liveness (`idle`/`busy`). |
135
+ | `capture_terminal(content, stream?)` | Stream terminal output into the session. |
136
+ | `log_activity(action, body?, task?)` | Log a real activity event. |
137
+ | `get_activity(task?, limit?)` | Read recent project activity. |
138
+
139
+ Every tool also accepts an optional `profile` argument to act as a different
140
+ profile for that one call (e.g. use `reviewer` for `report_review`).
141
+
142
+ ## Development
86
143
 
87
- Here's what a conversation looks like when the agent is properly connected:
88
-
89
- ```
90
- Agent connects sees get_agent_instructions in tool list → calls it
91
-
92
- Gets instructions + live context (3 projects, 2 tasks in progress, 1 blocked)
93
-
94
- Calls list_tasks(status="in_progress") → sees "Build dashboard page" is active
95
-
96
- User: "Let's work on the dashboard"
97
-
98
- Agent: calls get_task(id=5) → reads description for implementation details
99
- Agent: calls start_timer(task_id=5) → time tracking begins
100
-
101
- Agent implements the feature, referencing task description for acceptance criteria
102
-
103
- Agent: calls stop_timer(task_id=5, final_status="done")
104
- Agent: checks if any blocked tasks depended on task 5
144
+ ```bash
145
+ npm run typecheck # tsc --noEmit
146
+ npm run build # compile to dist/
147
+ npm test # vitest (config resolution unit tests)
105
148
  ```
106
149
 
107
- ### Strategies for proactive agent behavior
108
-
109
- The `get_agent_instructions` tool tells agents to:
150
+ ## Smoke test (against a real backend)
110
151
 
111
- 1. **Check tasks before coding** before starting work, search for a matching task and start its timer
112
- 2. **Track time automatically** start_timer when beginning work, pause_timer on context switches, stop_timer when done
113
- 3. **Surface blockers** — if stuck, update the task to "blocked" with context in the description
114
- 4. **Suggest next work** — when the user asks "what should I work on?", surface high-priority unblocked tasks
115
- 5. **Stay in sync** — create tasks for new work items to keep the tracker up to date
116
- 6. **Read descriptions** — task descriptions contain implementation details and acceptance criteria
117
-
118
- ## Agent Inbox — Remote Communication
119
-
120
- The Agent Inbox lets agents ask questions that appear in the TaskFlow UI. Users can respond from any device (phone, browser, another machine), and the response is delivered back to the agent's terminal automatically.
121
-
122
- ### How it works
123
-
124
- 1. Agent calls `ask_user` with a question, context, and optional quick-tap choices
125
- 2. Question appears instantly in the TaskFlow UI at `/inbox`
126
- 3. User responds from the UI — response is injected into the agent's terminal via tmux
127
- 4. Agent also asks in the terminal normally, so the user can answer from either place
128
-
129
- ### Setup for auto-injection
130
-
131
- For responses to be injected directly into the terminal, run Claude Code inside tmux:
152
+ `scripts/smoke.mjs` drives the compiled client against a running backend. It is
153
+ env-driven and safe to run anywhere: with no key or no reachable backend it prints
154
+ `SKIPPED` and exits 0.
132
155
 
133
156
  ```bash
134
- # Install tmux (one-time)
135
- sudo apt-get install -y tmux
136
-
137
- # Start a tmux session and run claude inside it
138
- tmux new -s agent
139
- claude
157
+ npm run build
158
+ SMOKE_SERVER=http://localhost:8010 SMOKE_KEY=tfk_your_agent_key node scripts/smoke.mjs
159
+ # optional: SMOKE_CHANNEL=<id> to force which channel send/check use
140
160
  ```
141
161
 
142
- Without tmux, the inbox still works — agents can use `check_response` to poll for answers, or the user can dismiss questions answered in the terminal.
143
-
144
- See [Terminal Injection Setup](../docs/agent-inbox-terminal-injection.md) for full details, multiple agent setup, and cleanup instructions.
145
-
146
- ## Available Tools
147
-
148
- ### Agent
149
- | Tool | Description |
150
- |------|-------------|
151
- | `get_agent_instructions` | Returns onboarding instructions and live context for AI agents. **Call first.** |
152
-
153
- ### Tasks
154
- | Tool | Description |
155
- |------|-------------|
156
- | `create_task` | Create a task with dependencies, tags, links, and time estimates |
157
- | `list_tasks` | List tasks with filters (status, project, priority, tag) |
158
- | `get_task` | Get a task by ID with time tracking info |
159
- | `update_task` | Update task fields |
160
- | `update_task_status` | Change status with transition validation |
161
- | `delete_task` | Delete a task by ID |
162
- | `bulk_create_tasks` | Create multiple tasks in a single transaction |
163
- | `search_tasks` | Full-text search by title or description |
164
-
165
- ### Projects
166
- | Tool | Description |
167
- |------|-------------|
168
- | `create_project` | Create a project (active_project or project_idea) |
169
- | `list_projects` | List all projects with task counts |
170
- | `get_project` | Get a project with all its tasks |
171
- | `update_project` | Update project fields |
172
- | `delete_project` | Delete a project (tasks are unlinked, not deleted) |
173
-
174
- ### Timer
175
- | Tool | Description |
176
- |------|-------------|
177
- | `start_timer` | Start a timer session (task transitions to in_progress) |
178
- | `pause_timer` | Pause the active session (task transitions to paused) |
179
- | `stop_timer` | Stop timer with final status (done/partial_done/blocked) |
180
- | `list_sessions` | List sessions with optional date range filter |
181
-
182
- ### Analytics
183
- | Tool | Description |
184
- |------|-------------|
185
- | `get_analytics` | Summary: focused time, completion rates, status distribution, time per project |
186
- | `get_timeline` | Focused time grouped by day or week |
187
-
188
- ### Activity
189
- | Tool | Description |
190
- |------|-------------|
191
- | `get_activity_log` | Recent activity: completions, timer events, status changes |
192
- | `clear_activity_log` | Delete all activity log entries |
193
-
194
- ### Notifications
195
- | Tool | Description |
196
- |------|-------------|
197
- | `list_notifications` | List notifications (filter by unread) |
198
- | `mark_notification_read` | Mark a single notification as read |
199
- | `mark_all_notifications_read` | Mark all as read |
200
- | `clear_notifications` | Delete all notifications |
201
-
202
- ### Agent Inbox
203
- | Tool | Description |
204
- |------|-------------|
205
- | `ask_user` | Post a question to the Agent Inbox for remote response. Returns immediately with message ID |
206
- | `check_response` | Check if the user has responded to a previously posted question |
207
-
208
- ### Settings
209
- | Tool | Description |
210
- |------|-------------|
211
- | `get_setting` | Get a setting by key (returns default if not set) |
212
- | `update_setting` | Update or create a setting |
213
-
214
- ## Configuration
215
-
216
- | Setting | Default | Description |
217
- |---------|---------|-------------|
218
- | `TASKFLOW_SSE_PORT` | `3456` | Port for the SSE broadcast server |
219
- | Database location | `~/.taskflow/taskflow.db` | SQLite database with WAL mode |
220
-
221
- The SSE server at `http://localhost:3456/events` broadcasts real-time changes to connected UI clients. The `/sync` endpoint returns a full data dump for initial sync.
162
+ It exercises: `whoami`, `create_task`, `list_tasks`, `list_channels`,
163
+ `send_message`, `check_messages`, `register_session`, `heartbeat`,
164
+ `capture_terminal`, `close_session`, `log_activity`, `get_activity` printing
165
+ `PASS`/`FAIL` per call, and exits non-zero only if a call actually failed against a
166
+ reachable backend.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Download a message attachment to disk.
3
+ *
4
+ * The tool exists as insulation, not as an authorization point. `/media` is
5
+ * currently served without auth; when storage is gated at the framework level,
6
+ * only this module changes — it starts sending the agent key as a header — and
7
+ * the agent-facing contract (url in, path out) stays put.
8
+ *
9
+ * Two untrusted inputs: the `url` (must resolve under `/media/`, or this
10
+ * becomes an arbitrary-URL fetcher with a disk write attached) and the
11
+ * filename (must not escape the download directory).
12
+ */
13
+ export declare class AttachmentDownloadError extends Error {
14
+ constructor(message: string);
15
+ }
16
+ export interface DownloadResult {
17
+ path: string;
18
+ name: string;
19
+ size_bytes: number;
20
+ content_type: string;
21
+ }
22
+ export type BinaryFetch = (url: string, init?: {
23
+ headers?: Record<string, string>;
24
+ }) => Promise<{
25
+ ok: boolean;
26
+ status: number;
27
+ statusText: string;
28
+ headers: {
29
+ get(name: string): string | null;
30
+ };
31
+ arrayBuffer(): Promise<ArrayBuffer>;
32
+ }>;
33
+ /**
34
+ * Validate an attachment url and reduce it to its `/media/...` path.
35
+ *
36
+ * Absolute urls are accepted only for their path — the host is discarded
37
+ * before the fetch (it always targets the configured server, never the url's
38
+ * own host), so a url pointing at another origin can never redirect the
39
+ * fetch there. `server` is free-form user config (any origin, not just
40
+ * loopback), so instead of a hardcoded allowlist, an absolute url's origin is
41
+ * compared against the *configured* server's origin: a match is accepted,
42
+ * anything else is a sign of a confused or hostile caller and is refused
43
+ * outright. When no `server` is given (e.g. validating a url in isolation),
44
+ * there is nothing to compare against, so the host is simply discarded and
45
+ * only the path is used — safe, since it is never fetched.
46
+ *
47
+ * The path is percent-decoded before normalisation so a bare `/media/...`
48
+ * input and an absolute-url input agree on what `%2e%2e`-style traversal
49
+ * resolves to. Anything that does not land under `/media/` after
50
+ * normalisation is rejected.
51
+ *
52
+ * The returned path is DECODED — it is the real storage key, and it is what
53
+ * the on-disk filename is taken from. It must be re-encoded before it goes
54
+ * into a request line; `downloadAttachment` does that per segment.
55
+ */
56
+ export declare function mediaPathFrom(url: string, server?: string): string;
57
+ /**
58
+ * The filename to write, always a bare basename.
59
+ *
60
+ * Storage keys are already UUID-prefixed, so the url basename is unique by
61
+ * construction and needs no extra prefix. An override is accepted for a
62
+ * friendlier name, but is reduced to its basename first.
63
+ */
64
+ export declare function downloadFilenameFrom(mediaPath: string, override?: string): string;
65
+ /** Absolute path of the download directory for a project root. */
66
+ export declare function downloadDirFor(root: string): string;
67
+ export declare function downloadAttachment(opts: {
68
+ url: string;
69
+ server: string;
70
+ root: string;
71
+ name?: string;
72
+ key?: string;
73
+ fetchImpl?: BinaryFetch;
74
+ }): Promise<DownloadResult>;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Download a message attachment to disk.
3
+ *
4
+ * The tool exists as insulation, not as an authorization point. `/media` is
5
+ * currently served without auth; when storage is gated at the framework level,
6
+ * only this module changes — it starts sending the agent key as a header — and
7
+ * the agent-facing contract (url in, path out) stays put.
8
+ *
9
+ * Two untrusted inputs: the `url` (must resolve under `/media/`, or this
10
+ * becomes an arbitrary-URL fetcher with a disk write attached) and the
11
+ * filename (must not escape the download directory).
12
+ */
13
+ import { access, mkdir, writeFile } from "node:fs/promises";
14
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
15
+ export class AttachmentDownloadError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "AttachmentDownloadError";
19
+ }
20
+ }
21
+ const MEDIA_PREFIX = "/media/";
22
+ /** Cap on decode passes — enough for any real url, bounded for a hostile one. */
23
+ const MAX_DECODE_PASSES = 5;
24
+ /**
25
+ * Percent-decode until the string stops changing (bounded).
26
+ *
27
+ * One pass is not enough: `%252e%252e` decodes to the literal `%2e%2e`, which
28
+ * would sail past the traversal check and rely on the upstream decoder to
29
+ * catch it. Looping removes that reliance.
30
+ *
31
+ * A decode failure is NOT fatal. The backend emits urls unencoded, so a lone
32
+ * `%` is a legal character in a real storage key (`50%.png`) and
33
+ * `decodeURIComponent` throws on it. Falling back to the undecoded string
34
+ * treats it as the literal it is.
35
+ */
36
+ function fullyDecode(path) {
37
+ let current = path;
38
+ for (let i = 0; i < MAX_DECODE_PASSES; i++) {
39
+ let next;
40
+ try {
41
+ next = decodeURIComponent(current);
42
+ }
43
+ catch {
44
+ return current;
45
+ }
46
+ if (next === current)
47
+ return current;
48
+ current = next;
49
+ }
50
+ return current;
51
+ }
52
+ /**
53
+ * Encode a decoded media path for use in a request line, segment by segment.
54
+ *
55
+ * The `/` separators stay literal; everything else is percent-encoded. Without
56
+ * this, a key containing `#` or `?` truncates the request at the fragment or
57
+ * query and 404s on a file that exists.
58
+ */
59
+ function encodeMediaPath(mediaPath) {
60
+ return mediaPath.split("/").map(encodeURIComponent).join("/");
61
+ }
62
+ /**
63
+ * Validate an attachment url and reduce it to its `/media/...` path.
64
+ *
65
+ * Absolute urls are accepted only for their path — the host is discarded
66
+ * before the fetch (it always targets the configured server, never the url's
67
+ * own host), so a url pointing at another origin can never redirect the
68
+ * fetch there. `server` is free-form user config (any origin, not just
69
+ * loopback), so instead of a hardcoded allowlist, an absolute url's origin is
70
+ * compared against the *configured* server's origin: a match is accepted,
71
+ * anything else is a sign of a confused or hostile caller and is refused
72
+ * outright. When no `server` is given (e.g. validating a url in isolation),
73
+ * there is nothing to compare against, so the host is simply discarded and
74
+ * only the path is used — safe, since it is never fetched.
75
+ *
76
+ * The path is percent-decoded before normalisation so a bare `/media/...`
77
+ * input and an absolute-url input agree on what `%2e%2e`-style traversal
78
+ * resolves to. Anything that does not land under `/media/` after
79
+ * normalisation is rejected.
80
+ *
81
+ * The returned path is DECODED — it is the real storage key, and it is what
82
+ * the on-disk filename is taken from. It must be re-encoded before it goes
83
+ * into a request line; `downloadAttachment` does that per segment.
84
+ */
85
+ export function mediaPathFrom(url, server) {
86
+ let path;
87
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {
88
+ let parsed;
89
+ try {
90
+ parsed = new URL(url);
91
+ }
92
+ catch {
93
+ throw new AttachmentDownloadError(`Not a valid attachment url: ${url}`);
94
+ }
95
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
96
+ throw new AttachmentDownloadError(`Attachment url must be http(s) or a /media path, got: ${url}`);
97
+ }
98
+ // The host is discarded below regardless -- the actual fetch always goes
99
+ // to the configured server, never to whatever host this url names. But an
100
+ // absolute url naming some OTHER host than the configured server is still
101
+ // a sign of a confused or hostile caller, so it is rejected outright
102
+ // rather than silently tolerated.
103
+ if (server) {
104
+ let serverOrigin;
105
+ try {
106
+ serverOrigin = new URL(server).origin;
107
+ }
108
+ catch {
109
+ throw new AttachmentDownloadError(`Not a valid server origin: ${server}`);
110
+ }
111
+ if (parsed.origin !== serverOrigin) {
112
+ throw new AttachmentDownloadError(`Attachment url points at another host, got: ${url}`);
113
+ }
114
+ }
115
+ path = parsed.pathname;
116
+ }
117
+ else {
118
+ path = url;
119
+ }
120
+ // Percent-decode before normalising, so `/media/%2e%2e/etc/passwd` collapses
121
+ // the same way whether it arrived as a bare path or inside an absolute url.
122
+ const decodedPath = fullyDecode(path);
123
+ // Normalise so `/media/../etc/passwd` cannot masquerade as a media path.
124
+ const normalised = resolve("/", decodedPath);
125
+ if (!normalised.startsWith(MEDIA_PREFIX)) {
126
+ throw new AttachmentDownloadError(`Attachment url must be under ${MEDIA_PREFIX} — got: ${url}`);
127
+ }
128
+ return normalised;
129
+ }
130
+ /**
131
+ * The filename to write, always a bare basename.
132
+ *
133
+ * Storage keys are already UUID-prefixed, so the url basename is unique by
134
+ * construction and needs no extra prefix. An override is accepted for a
135
+ * friendlier name, but is reduced to its basename first.
136
+ */
137
+ export function downloadFilenameFrom(mediaPath, override) {
138
+ const raw = override && override.trim() ? override : mediaPath;
139
+ const name = basename(raw.replace(/[/\\]+$/, ""));
140
+ if (!name || name === "." || name === "..")
141
+ return "attachment";
142
+ return name;
143
+ }
144
+ /** Absolute path of the download directory for a project root. */
145
+ export function downloadDirFor(root) {
146
+ return join(root, ".taskflow", "attachments");
147
+ }
148
+ export async function downloadAttachment(opts) {
149
+ const mediaPath = mediaPathFrom(opts.url, opts.server);
150
+ const filename = downloadFilenameFrom(mediaPath, opts.name);
151
+ const dir = downloadDirFor(opts.root);
152
+ const target = resolve(dir, filename);
153
+ // Belt and braces: the basename reduction above should make escape
154
+ // impossible, so this asserts the invariant rather than trusting it.
155
+ const rel = relative(dir, target);
156
+ if (rel === "" || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
157
+ throw new AttachmentDownloadError(`Refusing to write outside the download directory: ${filename}`);
158
+ }
159
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
160
+ // `mediaPath` is decoded (see `mediaPathFrom`); the backend emits urls
161
+ // unencoded, so it can legitimately contain `#`, `?`, `%`, spaces and
162
+ // unicode. Re-encode per segment before it becomes a request line.
163
+ const absolute = `${opts.server.replace(/\/$/, "")}${encodeMediaPath(mediaPath)}`;
164
+ // When storage is gated at the framework level, the auth header goes HERE and
165
+ // nothing else in the system needs to change.
166
+ const headers = {};
167
+ if (opts.key)
168
+ headers.Authorization = `Agent ${opts.key}`;
169
+ const res = await doFetch(absolute, { headers });
170
+ if (!res.ok) {
171
+ throw new AttachmentDownloadError(`Download failed: ${res.status} ${res.statusText} for ${mediaPath}`);
172
+ }
173
+ const bytes = Buffer.from(await res.arrayBuffer());
174
+ await mkdir(dir, { recursive: true });
175
+ // Downloads are scratch, never committed. Written only when absent — if
176
+ // anything else ever keeps committed state under `.taskflow/`, an
177
+ // unconditional write here would silently ignore it.
178
+ const gitignore = join(opts.root, ".taskflow", ".gitignore");
179
+ try {
180
+ await access(gitignore);
181
+ }
182
+ catch {
183
+ await writeFile(gitignore, "*\n");
184
+ }
185
+ await writeFile(target, bytes);
186
+ return {
187
+ path: target,
188
+ name: filename,
189
+ size_bytes: bytes.length,
190
+ content_type: res.headers.get("content-type") || "application/octet-stream",
191
+ };
192
+ }
193
+ //# sourceMappingURL=attachment-download.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-download.js","sourceRoot":"","sources":["../src/attachment-download.ts"],"names":[],"mappings":"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"}