flowforge-map 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/PKG-INFO +23 -5
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/README.md +21 -4
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/agent/server.py +78 -3
- flowforge_map-0.3.0/backend/flowforge/api/issues.py +150 -0
- flowforge_map-0.3.0/backend/flowforge/api/keys.py +184 -0
- flowforge_map-0.3.0/backend/flowforge/api/lock.py +265 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/cli.py +34 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/main.py +5 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/finders.py +42 -0
- flowforge_map-0.3.0/backend/flowforge/security/fixes.py +246 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/keystore.py +6 -2
- flowforge_map-0.3.0/backend/flowforge/security/lock.py +290 -0
- flowforge_map-0.3.0/frontend/dist/assets/index-BmxPFjrF.js +52 -0
- flowforge_map-0.3.0/frontend/dist/assets/index-C9vBGBLq.css +1 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/index.html +2 -2
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/pyproject.toml +2 -1
- flowforge_map-0.2.0/backend/flowforge/api/keys.py +0 -107
- flowforge_map-0.2.0/frontend/dist/assets/index-C-GjahSy.js +0 -52
- flowforge_map-0.2.0/frontend/dist/assets/index-CWfr5fii.css +0 -1
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/.gitignore +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/LICENSE +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/agent/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/agent.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/checks.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/connectors.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/flowmap.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/runs.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/scan.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/security.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/api/stats.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/checks/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/checks/models.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/checks/probes.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/catalog.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/models.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/presets.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/registry.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/runtime.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/connectors/secrets.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/example_data.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/advice.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/curly.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/index.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/order.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/reach.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flow/rewrite.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/diff.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/fixture.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/models.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/planner.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/service.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/flowmap/validate.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/home.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/base.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/check_node.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/http_node.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/llm_node.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/local_node.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/mcp_node.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/nodes/mock_node.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/facts.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/files.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/manifests.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/mcp_config.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/models.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/patterns.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/python_ast.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/scanner.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/services.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scan/urls.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/cache.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/critical_path.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/durations.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/executor.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/graph.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/scheduler/rate_limit.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/schema.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/findings.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/security/logfilter.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/spa.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/state.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/storage.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/flowforge/templating.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/workflows/diamond_mock.json +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/backend/workflows/stripe_to_razorpay.json +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/.env.example +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/.gitignore +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/.mcp.json +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/README.md +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/config.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/mcp_servers/currency.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/planner.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/requirements.txt +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/__init__.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/geo.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/llm.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/mcp_tools.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/services/weather.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/tools/packing_list.py +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/examples/trip-planner/trips/.gitkeep +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/classic.html +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- {flowforge_map-0.2.0 → flowforge_map-0.3.0}/frontend/dist/favicon.svg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flowforge-map
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Map what your app depends on (LLMs, MCP servers, APIs), check their health and security, and see what could run faster. Local and free; the scan needs no AI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator
|
|
6
6
|
Project-URL: Issues, https://github.com/Vishwa-Thangapandiyan/flowforge-agent-orchestrator/issues
|
|
@@ -29,6 +29,7 @@ Requires-Dist: openai>=1.40
|
|
|
29
29
|
Requires-Dist: pydantic>=2.8
|
|
30
30
|
Requires-Dist: python-dotenv>=1.0
|
|
31
31
|
Requires-Dist: python-multipart>=0.0.32
|
|
32
|
+
Requires-Dist: segno>=1.6
|
|
32
33
|
Requires-Dist: uvicorn[standard]>=0.30
|
|
33
34
|
Description-Content-Type: text/markdown
|
|
34
35
|
|
|
@@ -113,7 +114,18 @@ FlowForge is also an **MCP server**, so Claude Code, Codex or Antigravity can as
|
|
|
113
114
|
claude mcp add flowforge -- uv --directory /path/to/flowforge run flowforge mcp --repo /path/to/your/app
|
|
114
115
|
```
|
|
115
116
|
|
|
116
|
-
Your agent gets
|
|
117
|
+
Your agent gets ten tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys`, `get_fix_requests`, `request_key`, `wait_for_keys` and `what_changed`. All read only, except `request_key`, which only asks you for a key by name. Every answer is masked (key values never appear), the agent can't make FlowForge change your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
|
|
118
|
+
|
|
119
|
+
### Fix what's unsafe, with one click
|
|
120
|
+
|
|
121
|
+
The **Issues** page lists what the scan found unsafe, and every issue has one button:
|
|
122
|
+
|
|
123
|
+
| Issue | Button | What happens |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| A key written in Python, or in JS/TS that runs on Node | **Fix** | FlowForge shows the change (the key masked). On **Approve**, the key goes to your keys and the line reads it from the environment, on a new branch `flowforge/fix-…`. Your branch and files aren't touched; merge it when you're ready. |
|
|
126
|
+
| `http://`, an unpinned MCP server, a server given the whole disk, a key in `.mcp.json`, Dart or browser code | **Send to agent** | It joins your coding agent's list (`get_fix_requests`). Tell your agent "do the FlowForge fixes". |
|
|
127
|
+
|
|
128
|
+
Fix needs a git repo and the file committed. FlowForge works out the change itself, with no AI. Replace a key that was in your code with its provider anyway: older commits still hold it.
|
|
117
129
|
|
|
118
130
|
### Keys your coding agent never sees
|
|
119
131
|
|
|
@@ -126,9 +138,13 @@ flowforge key import # move a .env into FlowForge (then de
|
|
|
126
138
|
flowforge run -- python app.py # your app starts with the keys in its environment
|
|
127
139
|
```
|
|
128
140
|
|
|
129
|
-
Each command takes `--repo PATH` (default: the folder you're in).
|
|
141
|
+
Each command takes `--repo PATH` (default: the folder you're in). In the dashboard, the **Keys** page does the same with no commands: paste a missing key into its box, or click **Move into FlowForge** for one that's in `.env`. On the map, a 🔑 badge marks a block whose key is missing. Your agent's `get_keys` tool gets names, statuses and the command to ask you to run, never a value. With no keychain (a server, CI), keys go to an encrypted file in `~/.flowforge`.
|
|
142
|
+
|
|
143
|
+
**When your agent adds a dependency that needs a key**, it calls `request_key("STRIPE_SECRET_KEY", "for Stripe in pay.py")`. The map says "Your agent asked for a key", and the new block shows in coral, "key missing". Click it, paste the key on the Keys page, and the agent's `wait_for_keys` returns the moment it's saved. The agent only ever sees the name.
|
|
144
|
+
|
|
145
|
+
**See a key again** in the **Vault**: set a password and scan the QR with an authenticator app (Google Authenticator, Authy, 1Password), once. After that, your password, then the 6-digit code on your phone, open it for 5 minutes. It lists every key with its health: healthy, written in code, in `.env`, can't read, unused, or old. It also shows where each key lives, what uses it, and whether it's a live or test key. Each value shows for 30 seconds. Forgot the password? `flowforge lock reset --forget` drops the lock (your keys stay) and the dashboard says so.
|
|
130
146
|
|
|
131
|
-
What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`,
|
|
147
|
+
What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`, read your keychain, or reset the lock (the dashboard then shows it was reset), so it isn't impossible. The 2FA stops web pages, anyone at your browser, and anything without your phone. MCP servers your coding tool starts get their environment from that tool, not from FlowForge.
|
|
132
148
|
|
|
133
149
|
### Send someone the map
|
|
134
150
|
|
|
@@ -255,7 +271,9 @@ FlowForge itself keeps keys out of its runs, events, cache, logs and the dashboa
|
|
|
255
271
|
| `POST /connectors/{id}/test` | One harmless check (MCP and local apps) |
|
|
256
272
|
| `GET /connectors/{id}/activity`, `GET /connectors/{id}/tools` | What an app has done; an MCP server's tools |
|
|
257
273
|
| `GET /security` | The scan's security findings, by level, with the fix and a prompt for each (D31) |
|
|
258
|
-
| `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}` | The keys the code reads and their status; save one (write-only
|
|
274
|
+
| `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}`, `POST /keys/{name}/import` | The keys the code reads and their status; save one (write-only), move one in from `.env`, or forget it (D36, D37) |
|
|
275
|
+
| `GET /issues`, `POST /issues/{id}/preview\|fix\|send` | Every issue with its button; the masked change; make it on a new branch; send it to your agent (D37) |
|
|
276
|
+
| `GET /lock`, `POST /lock/setup\|confirm\|password\|code\|close`, `GET /vault/keys`, `POST /keys/reveal` | The Vault: its lock (your password, then the authenticator code) and, once open, every key's health and one value at a time (D37, D38) |
|
|
259
277
|
| `GET /health/tools`, `GET /stats/savings`, `GET /meta`, `GET /presets` | Tool health, money and time, app info, ready-made apps |
|
|
260
278
|
| `GET /workflows`, `GET /workflows/{name}` | The example workflows |
|
|
261
279
|
| `GET /flow?layer=mine\|planner` | The flow map: blocks, lines, positions, critical path, your edits |
|
|
@@ -79,7 +79,18 @@ FlowForge is also an **MCP server**, so Claude Code, Codex or Antigravity can as
|
|
|
79
79
|
claude mcp add flowforge -- uv --directory /path/to/flowforge run flowforge mcp --repo /path/to/your/app
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
Your agent gets
|
|
82
|
+
Your agent gets ten tools: `get_map`, `get_flow`, `get_advice`, `check_health`, `get_security_findings`, `get_keys`, `get_fix_requests`, `request_key`, `wait_for_keys` and `what_changed`. All read only, except `request_key`, which only asks you for a key by name. Every answer is masked (key values never appear), the agent can't make FlowForge change your code, and a check starts an MCP server only after you said yes to it on the Checks page. FlowForge needs no AI of its own for any of this.
|
|
83
|
+
|
|
84
|
+
### Fix what's unsafe, with one click
|
|
85
|
+
|
|
86
|
+
The **Issues** page lists what the scan found unsafe, and every issue has one button:
|
|
87
|
+
|
|
88
|
+
| Issue | Button | What happens |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| A key written in Python, or in JS/TS that runs on Node | **Fix** | FlowForge shows the change (the key masked). On **Approve**, the key goes to your keys and the line reads it from the environment, on a new branch `flowforge/fix-…`. Your branch and files aren't touched; merge it when you're ready. |
|
|
91
|
+
| `http://`, an unpinned MCP server, a server given the whole disk, a key in `.mcp.json`, Dart or browser code | **Send to agent** | It joins your coding agent's list (`get_fix_requests`). Tell your agent "do the FlowForge fixes". |
|
|
92
|
+
|
|
93
|
+
Fix needs a git repo and the file committed. FlowForge works out the change itself, with no AI. Replace a key that was in your code with its provider anyway: older commits still hold it.
|
|
83
94
|
|
|
84
95
|
### Keys your coding agent never sees
|
|
85
96
|
|
|
@@ -92,9 +103,13 @@ flowforge key import # move a .env into FlowForge (then de
|
|
|
92
103
|
flowforge run -- python app.py # your app starts with the keys in its environment
|
|
93
104
|
```
|
|
94
105
|
|
|
95
|
-
Each command takes `--repo PATH` (default: the folder you're in).
|
|
106
|
+
Each command takes `--repo PATH` (default: the folder you're in). In the dashboard, the **Keys** page does the same with no commands: paste a missing key into its box, or click **Move into FlowForge** for one that's in `.env`. On the map, a 🔑 badge marks a block whose key is missing. Your agent's `get_keys` tool gets names, statuses and the command to ask you to run, never a value. With no keychain (a server, CI), keys go to an encrypted file in `~/.flowforge`.
|
|
107
|
+
|
|
108
|
+
**When your agent adds a dependency that needs a key**, it calls `request_key("STRIPE_SECRET_KEY", "for Stripe in pay.py")`. The map says "Your agent asked for a key", and the new block shows in coral, "key missing". Click it, paste the key on the Keys page, and the agent's `wait_for_keys` returns the moment it's saved. The agent only ever sees the name.
|
|
109
|
+
|
|
110
|
+
**See a key again** in the **Vault**: set a password and scan the QR with an authenticator app (Google Authenticator, Authy, 1Password), once. After that, your password, then the 6-digit code on your phone, open it for 5 minutes. It lists every key with its health: healthy, written in code, in `.env`, can't read, unused, or old. It also shows where each key lives, what uses it, and whether it's a live or test key. Each value shows for 30 seconds. Forgot the password? `flowforge lock reset --forget` drops the lock (your keys stay) and the dashboard says so.
|
|
96
111
|
|
|
97
|
-
What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`,
|
|
112
|
+
What this does and doesn't promise: the key is never handed to your agent and isn't left in a file it reads by habit. An agent that can run commands as you could still run `flowforge run -- printenv`, read your keychain, or reset the lock (the dashboard then shows it was reset), so it isn't impossible. The 2FA stops web pages, anyone at your browser, and anything without your phone. MCP servers your coding tool starts get their environment from that tool, not from FlowForge.
|
|
98
113
|
|
|
99
114
|
### Send someone the map
|
|
100
115
|
|
|
@@ -221,7 +236,9 @@ FlowForge itself keeps keys out of its runs, events, cache, logs and the dashboa
|
|
|
221
236
|
| `POST /connectors/{id}/test` | One harmless check (MCP and local apps) |
|
|
222
237
|
| `GET /connectors/{id}/activity`, `GET /connectors/{id}/tools` | What an app has done; an MCP server's tools |
|
|
223
238
|
| `GET /security` | The scan's security findings, by level, with the fix and a prompt for each (D31) |
|
|
224
|
-
| `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}` | The keys the code reads and their status; save one (write-only
|
|
239
|
+
| `GET /keys`, `PUT /keys/{name}`, `DELETE /keys/{name}`, `POST /keys/{name}/import` | The keys the code reads and their status; save one (write-only), move one in from `.env`, or forget it (D36, D37) |
|
|
240
|
+
| `GET /issues`, `POST /issues/{id}/preview\|fix\|send` | Every issue with its button; the masked change; make it on a new branch; send it to your agent (D37) |
|
|
241
|
+
| `GET /lock`, `POST /lock/setup\|confirm\|password\|code\|close`, `GET /vault/keys`, `POST /keys/reveal` | The Vault: its lock (your password, then the authenticator code) and, once open, every key's health and one value at a time (D37, D38) |
|
|
225
242
|
| `GET /health/tools`, `GET /stats/savings`, `GET /meta`, `GET /presets` | Tool health, money and time, app info, ready-made apps |
|
|
226
243
|
| `GET /workflows`, `GET /workflows/{name}` | The example workflows |
|
|
227
244
|
| `GET /flow?layer=mine\|planner` | The flow map: blocks, lines, positions, critical path, your edits |
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
|
+
import time
|
|
12
13
|
import uuid
|
|
13
14
|
from collections import Counter
|
|
14
15
|
from pathlib import Path
|
|
@@ -19,6 +20,8 @@ from mcp.server.mcpserver import MCPServer
|
|
|
19
20
|
from mcp.types import ToolAnnotations
|
|
20
21
|
|
|
21
22
|
from flowforge.api.checks import CHECKABLE, check_workflow, command_text, confirmed_commands, target_for
|
|
23
|
+
from flowforge.api.issues import FIX_REQUESTS_SETTING, fix_requests
|
|
24
|
+
from flowforge.api.keys import add_key_request, clear_key_requests
|
|
22
25
|
from flowforge.api.scan import REPO_SETTING
|
|
23
26
|
from flowforge.checks import starts_process
|
|
24
27
|
from flowforge.connectors.registry import Registry
|
|
@@ -28,7 +31,7 @@ from flowforge.flowmap.validate import code_writes, critical
|
|
|
28
31
|
from flowforge.nodes.check_node import CheckNode
|
|
29
32
|
from flowforge.scan import ScanResult, scan
|
|
30
33
|
from flowforge.scheduler.executor import StepState, run_workflow
|
|
31
|
-
from flowforge.security.keystore import KeyStore, report
|
|
34
|
+
from flowforge.security.keystore import NAME, KeyStore, report
|
|
32
35
|
from flowforge.security.logfilter import Redactor, redact_code, secret_values_for
|
|
33
36
|
from flowforge.storage import Storage, now_iso
|
|
34
37
|
|
|
@@ -43,10 +46,12 @@ INSTRUCTIONS = (
|
|
|
43
46
|
"each with the file:line it comes from, checks their health, and shows the order the code calls them, the "
|
|
44
47
|
"critical path and which calls could run together, and what is unsafe (keys in code, plain http, risky MCP "
|
|
45
48
|
"servers). It reads code without running it and sends nothing to any "
|
|
46
|
-
"LLM. Answers are masked: key values never appear. It
|
|
47
|
-
"what_changed to confirm."
|
|
49
|
+
"LLM. Answers are masked: key values never appear. It doesn't edit code for you: you make the change, "
|
|
50
|
+
"then call what_changed to confirm. When the user says \"do the FlowForge fixes\", call get_fix_requests."
|
|
48
51
|
)
|
|
49
52
|
READ = ToolAnnotations(read_only_hint=True, open_world_hint=False)
|
|
53
|
+
ASK = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=False)
|
|
54
|
+
MAX_WAIT_S = 110
|
|
50
55
|
CHECKS = ToolAnnotations(read_only_hint=True, open_world_hint=True) # makes requests and may start servers
|
|
51
56
|
|
|
52
57
|
|
|
@@ -165,6 +170,12 @@ def headline(tool: str, data: dict[str, Any]) -> str | None:
|
|
|
165
170
|
counts = data["summary"]
|
|
166
171
|
parts = [f"{counts[k]} {w}" for k, w in (("missing", "missing"), ("env_file", "in .env")) if counts.get(k)]
|
|
167
172
|
return " · ".join(parts) or ("All saved" if data["keys"] else "No keys read")
|
|
173
|
+
if tool == "request_key":
|
|
174
|
+
return f"Asked for {data['name']}"
|
|
175
|
+
if tool == "wait_for_keys":
|
|
176
|
+
return "All saved" if data["done"] else f"{_plural(len(data['waiting']), 'key')} still missing"
|
|
177
|
+
if tool == "get_fix_requests":
|
|
178
|
+
return _plural(len(data["requests"]), "fix", "fixes") + " sent to you" if data["requests"] else "Nothing sent"
|
|
168
179
|
if tool == "what_changed":
|
|
169
180
|
if not data["saved"]:
|
|
170
181
|
return "No saved map yet"
|
|
@@ -195,6 +206,20 @@ def _keys_data(root: Path, result: ScanResult) -> dict[str, Any]:
|
|
|
195
206
|
"`flowforge key list` is what confirms it can be read"}
|
|
196
207
|
|
|
197
208
|
|
|
209
|
+
def _fix_requests_data(r: _Repo) -> dict[str, Any]:
|
|
210
|
+
"""The user's queue (D37), without the issues the code no longer has. Only when the dashboard maps this repo."""
|
|
211
|
+
if not r.shared:
|
|
212
|
+
return {"requests": [], "message": "The dashboard doesn't map this repo, so nothing was sent from it."}
|
|
213
|
+
queue = fix_requests(r.storage)
|
|
214
|
+
here = str(r.root)
|
|
215
|
+
now = {f.id for f in r.scan().findings}
|
|
216
|
+
done = [q for q in queue if q["repo"] == here and q["id"] not in now] # the code no longer has it
|
|
217
|
+
if done:
|
|
218
|
+
r.storage.set_setting(FIX_REQUESTS_SETTING, json.dumps([q for q in queue if q not in done]))
|
|
219
|
+
live = [q for q in queue if q["repo"] == here and q["id"] in now]
|
|
220
|
+
return {"requests": [{k: q.get(k) for k in ("id", "kind", "title", "ref", "fix", "prompt")} for q in live]}
|
|
221
|
+
|
|
222
|
+
|
|
198
223
|
def _flow_data(result: Any) -> dict[str, Any]:
|
|
199
224
|
if result.flow is None:
|
|
200
225
|
return {"available": False, "message": result.message}
|
|
@@ -251,6 +276,8 @@ def preview(repo: Path, storage: Storage) -> dict[str, Any]:
|
|
|
251
276
|
if advice["available"] else None,
|
|
252
277
|
"sub": f"saves ~{_seconds(advice['saved_ms'])}, the change written out" if advice["available"] else None,
|
|
253
278
|
"tone": "warn" if advice["available"] and advice["advice"] else "plain"},
|
|
279
|
+
{"tool": "get_fix_requests", "headline": headline("get_fix_requests", _fix_requests_data(r)),
|
|
280
|
+
"sub": "issues you send from the Issues page", "tone": "plain"},
|
|
254
281
|
{"tool": "what_changed", "headline": None, "sub": "After your agent edits: what it added or removed",
|
|
255
282
|
"tone": "plain"},
|
|
256
283
|
]
|
|
@@ -337,6 +364,54 @@ def build_server(repo: Path, storage: Storage, *, transport: httpx.AsyncBaseTran
|
|
|
337
364
|
run the `command` given (they type the key, hidden). Their app gets the keys with `flowforge run -- <app>`."""
|
|
338
365
|
return r.answer("get_keys", _keys_data(r.root, r.scan()))
|
|
339
366
|
|
|
367
|
+
@server.tool(annotations=ASK)
|
|
368
|
+
def request_key(name: str, why: str) -> dict[str, Any]:
|
|
369
|
+
"""Ask the user for a key your change needs (D38), e.g. after adding Stripe: name="STRIPE_SECRET_KEY",
|
|
370
|
+
why="for Stripe in pay.py". It shows first on FlowForge's Keys page. You never get or send the value: write
|
|
371
|
+
code that reads the name from the environment, tell the user to paste the key on the Keys page, then call
|
|
372
|
+
wait_for_keys. Their app gets it with `flowforge run`."""
|
|
373
|
+
if not NAME.fullmatch(name):
|
|
374
|
+
raise ValueError("name must be an env var name: letters, digits and _, not starting with a digit")
|
|
375
|
+
add_key_request(r.storage, r.root, name, why.strip() or "asked by your coding agent")
|
|
376
|
+
saved = KeyStore(r.root).status([name], verify=False)[name] == "saved"
|
|
377
|
+
if saved:
|
|
378
|
+
clear_key_requests(r.storage, r.root, [name])
|
|
379
|
+
where = "" if r.shared else " (open the dashboard on this repo: `flowforge --repo " + str(r.root) + "`)"
|
|
380
|
+
return r.answer("request_key", {
|
|
381
|
+
"name": name, "status": "saved" if saved else "requested",
|
|
382
|
+
"message": f"{name} is saved already: use it." if saved else
|
|
383
|
+
f"Ask the user to paste {name} on FlowForge's Keys page{where}; it's at the top. Then call wait_for_keys."})
|
|
384
|
+
|
|
385
|
+
@server.tool(annotations=READ)
|
|
386
|
+
async def wait_for_keys(names: list[str], seconds: int = 60) -> dict[str, Any]:
|
|
387
|
+
"""Wait until the user has saved these keys on the Keys page (D38): returns as soon as all are saved, or
|
|
388
|
+
after `seconds` (at most 110; call again to keep waiting). Reads only which names are saved, never a
|
|
389
|
+
value."""
|
|
390
|
+
import asyncio
|
|
391
|
+
|
|
392
|
+
wanted = [n for n in dict.fromkeys(names) if NAME.fullmatch(n)]
|
|
393
|
+
deadline = time.monotonic() + max(1, min(MAX_WAIT_S, int(seconds)))
|
|
394
|
+
store = KeyStore(r.root)
|
|
395
|
+
while True:
|
|
396
|
+
status = store.status(wanted, verify=False)
|
|
397
|
+
saved = [n for n in wanted if status[n] == "saved"]
|
|
398
|
+
if len(saved) == len(wanted) or time.monotonic() >= deadline:
|
|
399
|
+
break
|
|
400
|
+
await asyncio.sleep(1)
|
|
401
|
+
clear_key_requests(r.storage, r.root, saved)
|
|
402
|
+
waiting = [n for n in wanted if n not in saved]
|
|
403
|
+
return r.answer("wait_for_keys", {
|
|
404
|
+
"saved": saved, "waiting": waiting, "done": not waiting,
|
|
405
|
+
"message": "All saved: run the app with `flowforge run -- <its command>`." if not waiting else
|
|
406
|
+
f"Still waiting for {', '.join(waiting)}: call wait_for_keys again, or remind the user."})
|
|
407
|
+
|
|
408
|
+
@server.tool(annotations=READ)
|
|
409
|
+
def get_fix_requests() -> dict[str, Any]:
|
|
410
|
+
"""The issues the user sent you from FlowForge's Issues page ("do the FlowForge fixes"): each with its
|
|
411
|
+
file:line, what is wrong, how to fix it and the whole change described (masked: no key value). An issue leaves
|
|
412
|
+
the list once the code no longer has it, so call what_changed after your edit to confirm."""
|
|
413
|
+
return r.answer("get_fix_requests", _fix_requests_data(r))
|
|
414
|
+
|
|
340
415
|
@server.tool(annotations=READ)
|
|
341
416
|
def what_changed() -> dict[str, Any]:
|
|
342
417
|
"""After an edit: the dependencies added or removed since the map saved in the dashboard, and the flow's
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The Issues page (D37): every security finding with the button it gets. Fix (FlowForge changes the code on a new
|
|
2
|
+
branch) or Send to agent (queued for the coding agent's `get_fix_requests`).
|
|
3
|
+
|
|
4
|
+
GET /issues {repo, example, summary: {high, medium, low}, issues: [{...finding, action, state, branch}]}
|
|
5
|
+
(read from the code each time)
|
|
6
|
+
POST /issues/{id}/preview the change Fix will make, masked, and whether it can be made now
|
|
7
|
+
POST /issues/{id}/fix make it: a new branch, the key saved → {id, branch, commit, file, name}
|
|
8
|
+
POST /issues/{id}/send queue it for the agent → {id, state: "sent"}
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
20
|
+
|
|
21
|
+
from flowforge.api.keys import from_this_machine, learn_saved_keys
|
|
22
|
+
from flowforge.api.scan import repo_path
|
|
23
|
+
from flowforge.scan import scan as scan_repo
|
|
24
|
+
from flowforge.scan.models import Finding
|
|
25
|
+
from flowforge.security.fixes import CantFix, apply_fix, fixable, preview
|
|
26
|
+
from flowforge.state import AppState, app_state
|
|
27
|
+
from flowforge.storage import Storage, now_iso
|
|
28
|
+
|
|
29
|
+
router = APIRouter()
|
|
30
|
+
|
|
31
|
+
FIX_REQUESTS_SETTING = "fix_requests" # [{repo, id, kind, title, ref, fix, prompt, sent_at}]: what the agent is asked
|
|
32
|
+
FIXES_SETTING = "fixes_made" # [{repo, id, branch, commit, at}]: fixes made on a branch, not yet merged
|
|
33
|
+
# Issue ids repeat across repos (kind, path, line), so every entry names its folder and only counts there (Codex review)
|
|
34
|
+
EXAMPLE_WHY = "Example data: FlowForge shows the change but makes none. Map your own repo to fix it."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load(storage: Storage, key: str, empty: Any) -> Any:
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(storage.setting(key) or "null") or empty
|
|
40
|
+
except ValueError:
|
|
41
|
+
return empty
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _entries(storage: Storage, key: str) -> list[dict[str, Any]]:
|
|
45
|
+
"""A list setting's entries that name their folder (older ones didn't, and are dropped)."""
|
|
46
|
+
found = _load(storage, key, [])
|
|
47
|
+
return [e for e in found if isinstance(e, dict) and e.get("repo")] if isinstance(found, list) else []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fix_requests(storage: Storage, repo: Path | None = None) -> list[dict[str, Any]]:
|
|
51
|
+
"""The queue, or only `repo`'s part of it."""
|
|
52
|
+
queue = _entries(storage, FIX_REQUESTS_SETTING)
|
|
53
|
+
return queue if repo is None else [r for r in queue if r["repo"] == str(repo.resolve())]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fixes(storage: Storage, repo: Path) -> dict[str, dict[str, Any]]:
|
|
57
|
+
"""The fixes made in `repo`, by issue id."""
|
|
58
|
+
return {m["id"]: m for m in _entries(storage, FIXES_SETTING) if m["repo"] == str(repo.resolve())}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _branch_exists(repo: Path, branch: str) -> bool:
|
|
62
|
+
done = subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=repo,
|
|
63
|
+
capture_output=True)
|
|
64
|
+
return done.returncode == 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _repo(s: AppState) -> Path:
|
|
68
|
+
repo = repo_path(s)
|
|
69
|
+
if repo is None or not repo.is_dir():
|
|
70
|
+
raise HTTPException(409, "Map a folder first.")
|
|
71
|
+
return repo
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _finding(repo: Path, issue_id: str) -> Finding:
|
|
75
|
+
"""The issue as the code is now (read again, so a fix starts from the current file)."""
|
|
76
|
+
found = next((f for f in scan_repo(repo).findings if f.id == issue_id), None)
|
|
77
|
+
if found is None:
|
|
78
|
+
raise HTTPException(404, "That issue isn't in the code any more. Re-scan.")
|
|
79
|
+
return found
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/issues")
|
|
83
|
+
def issues() -> dict[str, Any]:
|
|
84
|
+
s = app_state()
|
|
85
|
+
repo = repo_path(s)
|
|
86
|
+
if repo is None or not repo.is_dir():
|
|
87
|
+
return {"repo": None, "example": s.example, "summary": {}, "issues": []}
|
|
88
|
+
result = scan_repo(repo) # the code as it is now: a merged fix or an agent's edit shows at once
|
|
89
|
+
sent = {r["id"] for r in fix_requests(s.storage, repo)}
|
|
90
|
+
made = _fixes(s.storage, repo)
|
|
91
|
+
out = []
|
|
92
|
+
for f in result.findings:
|
|
93
|
+
branch = made.get(f.id, {}).get("branch")
|
|
94
|
+
if branch and not _branch_exists(repo, branch):
|
|
95
|
+
branch = None # the branch was deleted: the issue is open again
|
|
96
|
+
state = "fixed" if branch else "sent" if f.id in sent else "open"
|
|
97
|
+
out.append({**f.model_dump(mode="json"), "action": "fix" if fixable(repo, f) else "agent",
|
|
98
|
+
"state": state, "branch": branch})
|
|
99
|
+
return {"repo": repo.name, "example": s.example, "summary": dict(Counter(f.level for f in result.findings)),
|
|
100
|
+
"issues": out}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.post("/issues/{issue_id}/preview")
|
|
104
|
+
def preview_fix(issue_id: str) -> dict[str, Any]:
|
|
105
|
+
s = app_state()
|
|
106
|
+
repo = _repo(s)
|
|
107
|
+
try:
|
|
108
|
+
shown = preview(repo, _finding(repo, issue_id))
|
|
109
|
+
except CantFix as e:
|
|
110
|
+
raise HTTPException(409, str(e)) from None
|
|
111
|
+
if s.example:
|
|
112
|
+
shown.update(can_apply=False, why_not=EXAMPLE_WHY)
|
|
113
|
+
return shown
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.post("/issues/{issue_id}/fix", dependencies=[Depends(from_this_machine)])
|
|
117
|
+
def fix(issue_id: str) -> dict[str, Any]:
|
|
118
|
+
s = app_state()
|
|
119
|
+
repo = _repo(s)
|
|
120
|
+
if s.example:
|
|
121
|
+
raise HTTPException(409, EXAMPLE_WHY)
|
|
122
|
+
made = _fixes(s.storage, repo)
|
|
123
|
+
if made.get(issue_id) and _branch_exists(repo, made[issue_id]["branch"]):
|
|
124
|
+
raise HTTPException(409, f"Already fixed on {made[issue_id]['branch']}: merge that branch.")
|
|
125
|
+
found = _finding(repo, issue_id)
|
|
126
|
+
if not fixable(repo, found):
|
|
127
|
+
raise HTTPException(409, "FlowForge doesn't fix this one itself: send it to your agent.")
|
|
128
|
+
try:
|
|
129
|
+
done = apply_fix(repo, found)
|
|
130
|
+
except CantFix as e:
|
|
131
|
+
raise HTTPException(409, str(e)) from None
|
|
132
|
+
learn_saved_keys(s, repo)
|
|
133
|
+
here = str(repo.resolve())
|
|
134
|
+
kept = [m for m in _entries(s.storage, FIXES_SETTING) if (m["repo"], m.get("id")) != (here, issue_id)]
|
|
135
|
+
kept.append({"repo": here, "id": issue_id, "branch": done["branch"], "commit": done["commit"], "at": now_iso()})
|
|
136
|
+
s.storage.set_setting(FIXES_SETTING, json.dumps(kept))
|
|
137
|
+
return done
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.post("/issues/{issue_id}/send", dependencies=[Depends(from_this_machine)])
|
|
141
|
+
def send(issue_id: str) -> dict[str, Any]:
|
|
142
|
+
s = app_state()
|
|
143
|
+
repo = _repo(s)
|
|
144
|
+
found = _finding(repo, issue_id)
|
|
145
|
+
here = str(repo.resolve())
|
|
146
|
+
queue = [r for r in fix_requests(s.storage) if (r["repo"], r["id"]) != (here, issue_id)]
|
|
147
|
+
queue.append({"repo": here, "id": found.id, "kind": found.kind, "title": found.title, "ref": found.ref,
|
|
148
|
+
"fix": found.fix, "prompt": found.prompt, "sent_at": now_iso()})
|
|
149
|
+
s.storage.set_setting(FIX_REQUESTS_SETTING, json.dumps(queue))
|
|
150
|
+
return {"id": issue_id, "state": "sent"}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Keys the coding agent never sees (D36): their statuses for the map, and the dashboard's write-only box.
|
|
2
|
+
|
|
3
|
+
GET /keys {repo, example, summary: {missing, env_file}, keys: [{name, status, ref, deps, command}]}
|
|
4
|
+
PUT /keys/{name} {"value": "..."} → {name, status: "saved"}; the value is never sent back
|
|
5
|
+
POST /keys/{name}/import the value of `name` in the folder's .env, saved (D37) → {name, status: "saved"}
|
|
6
|
+
DELETE /keys/{name} → {name, removed}
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
19
|
+
from pydantic import BaseModel, ConfigDict
|
|
20
|
+
|
|
21
|
+
from flowforge.api.scan import repo_path
|
|
22
|
+
from flowforge.home import command_line
|
|
23
|
+
from flowforge.scan import ScanResult
|
|
24
|
+
from flowforge.scan import scan as scan_repo
|
|
25
|
+
from flowforge.security.keystore import BadKey, KeyStore, report
|
|
26
|
+
from flowforge.state import AppState, app_state
|
|
27
|
+
from flowforge.storage import Storage, now_iso
|
|
28
|
+
|
|
29
|
+
router = APIRouter()
|
|
30
|
+
|
|
31
|
+
KEY_REQUESTS_SETTING = "key_requests" # [{repo, name, why, at}]: keys your agent asked for (D38), never a value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def key_requests(storage: Storage, repo: Path) -> list[dict[str, Any]]:
|
|
35
|
+
"""`repo`'s open requests, oldest first."""
|
|
36
|
+
try:
|
|
37
|
+
found = json.loads(storage.setting(KEY_REQUESTS_SETTING) or "[]")
|
|
38
|
+
except ValueError:
|
|
39
|
+
return []
|
|
40
|
+
here = str(repo.resolve())
|
|
41
|
+
return [r for r in found if isinstance(r, dict) and r.get("repo") == here] if isinstance(found, list) else []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _all_requests(storage: Storage) -> list[dict[str, Any]]:
|
|
45
|
+
try:
|
|
46
|
+
found = json.loads(storage.setting(KEY_REQUESTS_SETTING) or "[]")
|
|
47
|
+
except ValueError:
|
|
48
|
+
return []
|
|
49
|
+
return [r for r in found if isinstance(r, dict) and r.get("repo")] if isinstance(found, list) else []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def add_key_request(storage: Storage, repo: Path, name: str, why: str) -> None:
|
|
53
|
+
here = str(repo.resolve())
|
|
54
|
+
kept = [r for r in _all_requests(storage) if (r["repo"], r.get("name")) != (here, name)]
|
|
55
|
+
kept.append({"repo": here, "name": name, "why": why[:200], "at": now_iso()})
|
|
56
|
+
storage.set_setting(KEY_REQUESTS_SETTING, json.dumps(kept))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def clear_key_requests(storage: Storage, repo: Path, names: Iterable[str]) -> None:
|
|
60
|
+
"""A request ends once its key is saved."""
|
|
61
|
+
here, done = str(repo.resolve()), set(names)
|
|
62
|
+
found = _all_requests(storage)
|
|
63
|
+
kept = [r for r in found if not (r["repo"] == here and r.get("name") in done)]
|
|
64
|
+
if len(kept) != len(found):
|
|
65
|
+
storage.set_setting(KEY_REQUESTS_SETTING, json.dumps(kept))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class KeyValue(BaseModel):
|
|
69
|
+
model_config = ConfigDict(extra="forbid")
|
|
70
|
+
value: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def learn_saved_keys(s: AppState, repo: Path | None) -> None:
|
|
74
|
+
"""The redactor masks every value saved for this folder from now on, wherever it would show (D36)."""
|
|
75
|
+
if repo is None:
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
s.redactor.learn(KeyStore(repo).env().values())
|
|
79
|
+
except Exception: # a keychain that can't be read must not stop FlowForge
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def stored_scan(s: AppState, repo: Path) -> ScanResult:
|
|
84
|
+
"""The map's stored scan, so a re-scan updates the keys; the folder read again if none is stored."""
|
|
85
|
+
analysis = s.flows["deps"].analysis() if "deps" in s.flows else None
|
|
86
|
+
stored = (analysis or {}).get("scan")
|
|
87
|
+
if stored:
|
|
88
|
+
try:
|
|
89
|
+
return ScanResult.model_validate(stored)
|
|
90
|
+
except ValueError:
|
|
91
|
+
pass
|
|
92
|
+
return scan_repo(repo)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _loopback_peer(address: str | None) -> bool:
|
|
99
|
+
"""The connection itself comes from this machine (127.0.0.0/8 or ::1)."""
|
|
100
|
+
import ipaddress
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
ip = ipaddress.ip_address(address or "")
|
|
104
|
+
except ValueError:
|
|
105
|
+
return False
|
|
106
|
+
mapped = getattr(ip, "ipv4_mapped", None) # ::ffff:127.0.0.1 on a dual-stack socket
|
|
107
|
+
return (mapped or ip).is_loopback
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def from_this_machine(request: Request) -> None:
|
|
111
|
+
"""Saving a key, a fix, the lock and the key view need the dashboard on this machine (D36, D37). The caller's real
|
|
112
|
+
address must be this machine (with --host 0.0.0.0 another machine could fake the headers: Codex review), and
|
|
113
|
+
Host and Origin must name it too, so a web page can't reach it through DNS rebinding or from another site."""
|
|
114
|
+
host = urlsplit(f"//{request.headers.get('host', '')}").hostname
|
|
115
|
+
origin = request.headers.get("origin")
|
|
116
|
+
peer = request.client.host if request.client else None
|
|
117
|
+
if (not _loopback_peer(peer) or host not in _LOOPBACK
|
|
118
|
+
or (origin is not None and urlsplit(origin).hostname not in _LOOPBACK)):
|
|
119
|
+
raise HTTPException(403, "This works only from the dashboard on this machine (open it at localhost).")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _writable(s: AppState) -> Path:
|
|
123
|
+
"""The folder keys are saved for. Example data saves too (D37): its keys belong to the example's folder."""
|
|
124
|
+
repo = repo_path(s)
|
|
125
|
+
if repo is None:
|
|
126
|
+
raise HTTPException(409, "Map a folder first: keys belong to one folder.")
|
|
127
|
+
return repo
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.get("/keys")
|
|
131
|
+
def keys() -> dict[str, Any]:
|
|
132
|
+
s = app_state()
|
|
133
|
+
repo = repo_path(s)
|
|
134
|
+
if repo is None or not repo.is_dir():
|
|
135
|
+
return {"repo": None, "example": s.example, "summary": {}, "keys": []}
|
|
136
|
+
store = KeyStore(repo)
|
|
137
|
+
asked = key_requests(s.storage, repo)
|
|
138
|
+
clear_key_requests(s.storage, repo, [n for n in store.names() if any(r["name"] == n for r in asked)])
|
|
139
|
+
asked = [r for r in asked if r["name"] not in set(store.names())]
|
|
140
|
+
found = report(store, stored_scan(s, repo), also=[r["name"] for r in asked])
|
|
141
|
+
for k in found:
|
|
142
|
+
k["requested"] = any(r["name"] == k["name"] for r in asked)
|
|
143
|
+
summary = Counter(k["status"] for k in found if k["status"] != "saved")
|
|
144
|
+
return {"repo": repo.name, "example": s.example, "summary": dict(summary), "keys": found,
|
|
145
|
+
"requests": [{"name": r["name"], "why": r["why"], "at": r["at"]} for r in asked],
|
|
146
|
+
"run": command_line("run", "--repo", str(repo), "--")} # + how you start your app
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@router.put("/keys/{name}", dependencies=[Depends(from_this_machine)])
|
|
150
|
+
def save_key(name: str, body: KeyValue) -> dict[str, Any]:
|
|
151
|
+
s = app_state()
|
|
152
|
+
repo = _writable(s)
|
|
153
|
+
try:
|
|
154
|
+
KeyStore(repo).add(name, body.value)
|
|
155
|
+
except BadKey as e:
|
|
156
|
+
raise HTTPException(400, str(e)) from None
|
|
157
|
+
s.redactor.learn([body.value.strip()])
|
|
158
|
+
clear_key_requests(s.storage, repo, [name])
|
|
159
|
+
return {"name": name, "status": "saved"}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@router.post("/keys/{name}/import", dependencies=[Depends(from_this_machine)])
|
|
163
|
+
def import_key(name: str) -> dict[str, Any]:
|
|
164
|
+
"""Move one key in from .env: the server reads that value from the file, so nothing is copied by hand (D37)."""
|
|
165
|
+
from dotenv import dotenv_values
|
|
166
|
+
|
|
167
|
+
s = app_state()
|
|
168
|
+
repo = _writable(s)
|
|
169
|
+
value = dotenv_values(repo / ".env").get(name) if (repo / ".env").is_file() else None
|
|
170
|
+
if not value or not value.strip():
|
|
171
|
+
raise HTTPException(404, f"{name} isn't set in .env.")
|
|
172
|
+
try:
|
|
173
|
+
KeyStore(repo).add(name, value)
|
|
174
|
+
except BadKey as e:
|
|
175
|
+
raise HTTPException(400, str(e)) from None
|
|
176
|
+
s.redactor.learn([value.strip()])
|
|
177
|
+
clear_key_requests(s.storage, repo, [name])
|
|
178
|
+
return {"name": name, "status": "saved"}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.delete("/keys/{name}", dependencies=[Depends(from_this_machine)])
|
|
182
|
+
def remove_key(name: str) -> dict[str, Any]:
|
|
183
|
+
repo = _writable(app_state())
|
|
184
|
+
return {"name": name, "removed": KeyStore(repo).remove(name)}
|