create-workframe 0.1.11 → 0.1.12

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 (100) hide show
  1. package/README.md +1 -1
  2. package/docs/security.md +85 -0
  3. package/package.json +1 -1
  4. package/scripts/sync-canonical-to-package.mjs +8 -0
  5. package/scripts/verify-public-deploy.sh +59 -3
  6. package/workframe-api/openrouter_catalog.py +4 -4
  7. package/workframe-api/package.json +2 -2
  8. package/workframe-api/profile_config_yaml.py +126 -0
  9. package/workframe-api/route_registry.py +330 -0
  10. package/workframe-api/run-typecheck.mjs +25 -0
  11. package/workframe-api/server.py +1523 -1392
  12. package/workframe-api/test_model_surface_consistency.py +116 -0
  13. package/workframe-api/test_profile_model_yaml.py +99 -0
  14. package/workframe-api/test_public_routes.py +41 -0
  15. package/workframe-api/test_route_registry.py +109 -0
  16. package/workframe-api/time-bind-steps.py +75 -0
  17. package/workframe-api/time-perf.py +81 -0
  18. package/workframe-api/time-warm.py +107 -0
  19. package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-B0OFRGmJ.js} +1 -1
  20. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +1 -0
  21. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DeBNltHS.js} +1 -1
  22. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-BDhCHu7I.js} +1 -1
  23. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-olcDYvtI.js} +1 -1
  24. package/workframe-ui/public/assets/channel-fNHbDpV4.js +1 -0
  25. package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-D0obCBn_.js} +1 -1
  26. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DX93f-ZZ.js} +1 -1
  27. package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-BX9B5wUs.js} +1 -1
  28. package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-C4DGc6RO.js} +1 -1
  29. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-Crlja9Lf.js} +1 -1
  30. package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-JYSm3szI.js} +1 -1
  31. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-_KslxVEl.js} +1 -1
  32. package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-CpTO-jU8.js} +1 -1
  33. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-JGUkCmbI.js} +1 -1
  34. package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-BscBwGaC.js} +1 -1
  35. package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-5ZTZ0bpX.js} +1 -1
  36. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-DxvitYbW.js} +1 -1
  37. package/workframe-ui/public/assets/{chunk-LZXEDZCA-CsRdBqUB.js → chunk-LZXEDZCA-DMfdMUwz.js} +2 -2
  38. package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-Cayl0iV9.js} +1 -1
  39. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-_7dyBR5G.js} +1 -1
  40. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O--xTpD0yi.js} +1 -1
  41. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +1 -0
  42. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +1 -0
  43. package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-CwxJQaeK.js} +1 -1
  44. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +1 -0
  45. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +1 -0
  46. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-CCspENYv.js} +1 -1
  47. package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Dv8V6R60.js} +1 -1
  48. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-Da48hFPT.js} +1 -1
  49. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-dTihc7-3.js} +1 -1
  50. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-D_Jqu699.js} +1 -1
  51. package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-Bt8PtEaZ.js} +1 -1
  52. package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-BtmPjlh0.js} +1 -1
  53. package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-Cz2turIT.js} +1 -1
  54. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-BxMGCflX.js} +1 -1
  55. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +1 -0
  56. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-ivyroIZt.js} +1 -1
  57. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-B9llwShx.js} +1 -1
  58. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-BFQHk4bw.js} +1 -1
  59. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-ursegLbc.js} +1 -1
  60. package/workframe-ui/public/assets/index-8CuZDEIG.css +1 -0
  61. package/workframe-ui/public/assets/index-xS9lFekI.js +129 -0
  62. package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-DQpifAsB.js} +1 -1
  63. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-C16XP2Q7.js} +1 -1
  64. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-CiJoz7rP.js} +1 -1
  65. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-CVwEvvUL.js} +1 -1
  66. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-CWQ6hX5i.js} +1 -1
  67. package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-CgQsmU35.js} +1 -1
  68. package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-Bxbc77dL.js} +1 -1
  69. package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-C_jMeF0a.js} +2 -2
  70. package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-4RKV9MZP.js} +3 -3
  71. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-DZ7y7Sz0.js} +1 -1
  72. package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-B9h2I7Vq.js} +1 -1
  73. package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-B2mP5uHm.js} +1 -1
  74. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-DyYKyKiP.js} +1 -1
  75. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-CL_6nej-.js} +1 -1
  76. package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-CNKNDQ7S.js} +1 -1
  77. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-eWU_mhFa.js} +1 -1
  78. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-DrjcXvEQ.js} +1 -1
  79. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-BHWR1xTJ.js} +1 -1
  80. package/workframe-ui/public/assets/{src-CY_By-r7.js → src-DfJB8kV1.js} +1 -1
  81. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-BGJbSd3I.js} +1 -1
  82. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +1 -0
  83. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-DkwoCjiZ.js} +1 -1
  84. package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-GxfhiqoW.js} +1 -1
  85. package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-DxrOsars.js} +1 -1
  86. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-BsWhpUfr.js} +1 -1
  87. package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-CrFZWMHS.js} +1 -1
  88. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DV8Xpf5I.js} +1 -1
  89. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-DqMqnwdZ.js} +1 -1
  90. package/workframe-ui/public/index.html +4 -5
  91. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
  92. package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
  93. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
  94. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
  95. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
  96. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
  97. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
  98. package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
  99. package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
  100. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Published on npm as **create-workframe**.
4
4
 
5
5
  ```bash
6
- npx create-workframe@0.1.9 MyProject
6
+ npx create-workframe@0.1.12 MyProject
7
7
  ```
8
8
 
9
9
  Scaffolds an isolated Workframe + Hermes project on Windows, macOS, and Linux.
@@ -0,0 +1,85 @@
1
+ # Security
2
+
3
+ Report vulnerabilities via [SECURITY.md](../../SECURITY.md) — not public GitHub issues.
4
+
5
+ ## Modes
6
+
7
+ | Variable | Effect |
8
+ |----------|--------|
9
+ | `SECURE_MODE=true` | Auth on mutating routes, restricted CORS, no Docker socket on API |
10
+ | `DEV_LOCAL_UNSAFE=true` | Development only — open CORS, relaxed auth |
11
+ | *(neither set)* | Secure default |
12
+
13
+ Never enable `DEV_LOCAL_UNSAFE` or `WORKFRAME_E2E=1` on a public HTTPS URL during the install window.
14
+
15
+ ## Supervisor boundary
16
+
17
+ With `SECURE_MODE=true`:
18
+
19
+ - Only `workframe-supervisor` mounts `/var/run/docker.sock`
20
+ - API calls supervisor with `WORKFRAME_SUPERVISOR_TOKEN`
21
+ - Public compose overlay removes the socket from `workframe-api`
22
+
23
+ ## Credentials
24
+
25
+ - **BYOK default** — user keys in per-user Hermes homes
26
+ - **`user_only` providers** — no workspace fallback
27
+ - **Vault** — envelope encryption; not mounted on the gateway container
28
+ - **Per-turn leases** — gateway proxy tokens instead of shared raw secrets
29
+
30
+ ## Agent egress and the credential broker
31
+
32
+ Hermes agents need the open internet — web research, docs, public APIs, and normal tool use must stay fast and unrestricted. Security work is **not** about blocking that traffic.
33
+
34
+ Workframe splits outbound traffic into two classes:
35
+
36
+ | Class | Examples | Policy |
37
+ |-------|----------|--------|
38
+ | **General egress** | Web search, `fetch`, public HTTPS, package indexes | **Allowed** — direct from the gateway container; no broker hop, no per-action approval |
39
+ | **Brokered egress** | LLM providers, GitHub/Vercel/Netlify when using vault credentials | **Required** — `wf_rt_*` lease + internal proxy; raw secrets stay in the API vault |
40
+
41
+ ```text
42
+ Hermes (gateway)
43
+ ├─ general egress ──────────► internet (research, tools, public APIs)
44
+ └─ brokered egress ─────────► workframe-api (/internal/llm/*, /internal/action/*)
45
+ └─ vault + lease validation ─► upstream provider
46
+ ```
47
+
48
+ ### What “unavoidable broker” means
49
+
50
+ In `public_multi_user`, credential-mediated traffic must not depend on agent cooperation alone. Concretely:
51
+
52
+ 1. Gateway must **not** receive raw provider API keys or the vault database.
53
+ 2. Runtime profiles use **lease tokens** (`wf_rt_*`) and internal proxy base URLs for LLM calls.
54
+ 3. Integrated action providers use `/internal/action/*` with the same lease model.
55
+ 4. Gateway must **not** join the supervisor `control-net` (already enforced in compose).
56
+ 5. Optional strict profile (`WORKFRAME_FORCE_AGENT_EGRESS_BROKER=true`) may block **direct** outbound connections to known provider API hostnames so traffic must use the broker — general internet stays open.
57
+
58
+ This is **routing and secret placement**, not disabling `terminal` or adding human approval before each tool call. Lease validation on brokered requests is in-process (milliseconds), not an operator gate.
59
+
60
+ ### Deployment posture
61
+
62
+ | Mode | General egress | Brokered credentials |
63
+ |------|----------------|----------------------|
64
+ | `trusted_team` / dogfood | Unrestricted | Config + vault + leases (today) |
65
+ | `public_multi_user` | Unrestricted (agents stay capable) | Same broker path; verify script reports posture |
66
+ | `public_multi_user` + `WORKFRAME_FORCE_AGENT_EGRESS_BROKER=true` | Unrestricted except provider-host deny/redirect (planned) | Network-enforced broker path |
67
+
68
+ See also [Audit 0027 — Agent Vault comparison](../audits/0027-agent-vault-comparison.md) (advisory; Infisical Agent Vault as a reference pattern, not a required dependency).
69
+
70
+ ## Public multi-user (`public_multi_user`)
71
+
72
+ | Control | Behavior |
73
+ |---------|----------|
74
+ | Boot | Requires HTTPS, vault KEK, SMTP, proxy token, supervisor token |
75
+ | Auth | Invite-only after install |
76
+ | Dashboard | Owner/admin only (`auth_request`) |
77
+ | Runtime RBAC | Members limited to their own `u-{user}-*` profiles |
78
+ | Gateway env | Allowlisted secrets only |
79
+ | Supervisor | Blocks direct access to user `.env` and `auth.json` paths |
80
+
81
+ Full checklist: [PUBLIC_DEPLOY.md](../../infra/compose/workframe/PUBLIC_DEPLOY.md).
82
+
83
+ ## Multi-user filesystem model
84
+
85
+ The Hermes gateway container mounts the shared `Agents/` tree. User isolation uses per-user runtime profiles, role checks in the API, supervisor guards, and encrypted credentials. Read [PUBLIC_DEPLOY.md](../../infra/compose/workframe/PUBLIC_DEPLOY.md) before running a public multi-user install.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-workframe",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Scaffold a Workframe + Hermes workspace with guided onboarding",
5
5
  "type": "module",
6
6
  "bin": {
@@ -137,6 +137,14 @@ if (fs.existsSync(publicDeploySrc)) {
137
137
  console.log('Synced PUBLIC_DEPLOY.md -> package/docs/');
138
138
  }
139
139
 
140
+ const securityPublicSrc = path.join(REPO_ROOT, 'docs/public/security.md');
141
+ const securityPkgDst = path.join(PKG_ROOT, 'docs/security.md');
142
+ if (fs.existsSync(securityPublicSrc)) {
143
+ fs.mkdirSync(path.dirname(securityPkgDst), { recursive: true });
144
+ fs.copyFileSync(securityPublicSrc, securityPkgDst);
145
+ console.log('Synced docs/public/security.md -> package/docs/security.md');
146
+ }
147
+
140
148
  for (const name of ['LICENSE', 'NOTICE', 'SECURITY.md']) {
141
149
  const src = path.join(REPO_ROOT, name);
142
150
  const dst = path.join(PKG_ROOT, name);
@@ -11,6 +11,57 @@ COMPOSE_FILE="$COMPOSE_DIR/docker-compose.yml"
11
11
  fail() { echo "FAIL: $*" >&2; exit 1; }
12
12
  warn() { echo "WARN: $*" >&2; }
13
13
  ok() { echo "OK: $*"; }
14
+ info() { echo "INFO: $*"; }
15
+
16
+ gateway_container_name() {
17
+ local name
18
+ name="$(grep -A20 '^ gateway:' "$COMPOSE_FILE" | grep 'container_name:' | head -n1 | sed 's/.*container_name:[[:space:]]*//' | tr -d '\r"')"
19
+ printf '%s' "${name:-workframe-gateway}"
20
+ }
21
+
22
+ report_egress_posture() {
23
+ local gw force_broker gw_on_control internal_control
24
+ gw="$(gateway_container_name)"
25
+ force_broker="$(env_val WORKFRAME_FORCE_AGENT_EGRESS_BROKER)"
26
+
27
+ if grep -A30 '^ gateway:' "$COMPOSE_FILE" | grep -q 'control-net'; then
28
+ gw_on_control=yes
29
+ else
30
+ gw_on_control=no
31
+ fi
32
+ if grep -A5 'control-net:' "$COMPOSE_FILE" | grep -q 'internal:[[:space:]]*true'; then
33
+ internal_control=yes
34
+ else
35
+ internal_control=no
36
+ fi
37
+
38
+ info "egress gateway_container=$gw"
39
+ info "egress gateway_on_control_net=$gw_on_control (expected no)"
40
+ info "egress control_net_internal=$internal_control (expected yes)"
41
+ [[ "$gw_on_control" == "no" ]] || fail "gateway must not join control-net"
42
+ [[ "$internal_control" == "yes" ]] || warn "control-net is not marked internal:true"
43
+
44
+ if command -v docker >/dev/null 2>&1 && docker inspect "$gw" >/dev/null 2>&1; then
45
+ local nets
46
+ nets="$(docker inspect "$gw" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null || true)"
47
+ info "egress gateway_networks=${nets:-<unknown>}"
48
+ if echo "$nets" | grep -qi 'control'; then
49
+ fail "running gateway is attached to a control network"
50
+ fi
51
+ info "egress gateway_egress_posture=unrestricted_general (broker for LLM/action providers)"
52
+ else
53
+ info "egress gateway_egress_posture=unknown (gateway container not running)"
54
+ fi
55
+
56
+ info "egress broker_path=config_plus_leases (/internal/llm/*, /internal/action/*)"
57
+ info "egress general_internet=allowed (research, terminal, public APIs)"
58
+ info "egress broker_latency=per-request lease validation (no human approval gate)"
59
+
60
+ if [[ "$force_broker" =~ ^(1|true|yes|on)$ ]]; then
61
+ fail "WORKFRAME_FORCE_AGENT_EGRESS_BROKER=true is set but provider-host egress enforcement is not implemented yet"
62
+ fi
63
+ ok "egress posture reported"
64
+ }
14
65
 
15
66
  [[ -f "$ENV_FILE" ]] || fail "missing $ENV_FILE"
16
67
  [[ -f "$COMPOSE_FILE" ]] || fail "missing $COMPOSE_FILE"
@@ -85,6 +136,9 @@ if command -v curl >/dev/null 2>&1; then
85
136
  echo "$health" | grep -q 'workframe_e2e' || warn "API health missing workframe_e2e"
86
137
  echo "$health" | grep -q 'dev_local_unsafe' || warn "API health missing dev_local_unsafe"
87
138
  ok "API health"
139
+ snapshot_code="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${API_PORT}/api/snapshot" || true)"
140
+ [[ "$snapshot_code" == "401" ]] || fail "anonymous /api/snapshot returned ${snapshot_code:-<empty>} (expected 401)"
141
+ ok "anonymous /api/snapshot denied"
88
142
  fi
89
143
  dash_code="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${UI_PORT}/hermes-dashboard/" || true)"
90
144
  [[ "$dash_code" == "403" ]] || warn "hermes-dashboard without session returned $dash_code (expected 403)"
@@ -92,14 +146,16 @@ else
92
146
  warn "curl not found — skipping HTTP checks"
93
147
  fi
94
148
 
95
- if command -v docker >/dev/null 2>&1 && docker inspect workframe-gateway >/dev/null 2>&1; then
96
- gw_env="$(docker inspect workframe-gateway --format '{{range .Config.Env}}{{println .}}{{end}}')"
149
+ if command -v docker >/dev/null 2>&1 && docker inspect "$(gateway_container_name)" >/dev/null 2>&1; then
150
+ gw_env="$(docker inspect "$(gateway_container_name)" --format '{{range .Config.Env}}{{println .}}{{end}}')"
97
151
  for marker in WORKFRAME_SUPERVISOR_TOKEN ZK_AUTH_ SMTP_PASS; do
98
152
  echo "$gw_env" | grep -q "$marker" && fail "gateway env contains $marker"
99
153
  done
100
154
  ok "gateway env allowlist"
101
155
  else
102
- warn "workframe-gateway not running — skipping docker inspect"
156
+ warn "$(gateway_container_name) not running — skipping docker inspect"
103
157
  fi
104
158
 
159
+ report_egress_posture
160
+
105
161
  ok "public_multi_user preflight passed"
@@ -12,7 +12,7 @@ _CACHE: dict[str, Any] = {"at": 0.0, "rows": []}
12
12
  _TTL_SEC = int(__import__("os").environ.get("WORKFRAME_OPENROUTER_CATALOG_TTL", "3600"))
13
13
 
14
14
 
15
- def _fetch_models(api_key: str) -> list[dict[str, str]]:
15
+ def _fetch_models(api_key: str, *, timeout: float = 45) -> list[dict[str, str]]:
16
16
  req = urllib.request.Request(
17
17
  "https://openrouter.ai/api/v1/models",
18
18
  headers={
@@ -22,7 +22,7 @@ def _fetch_models(api_key: str) -> list[dict[str, str]]:
22
22
  },
23
23
  method="GET",
24
24
  )
25
- with urllib.request.urlopen(req, timeout=45) as resp:
25
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
26
26
  data = json.loads(resp.read())
27
27
  rows: list[dict[str, str]] = []
28
28
  for item in data.get("data") or []:
@@ -48,7 +48,7 @@ def _fetch_models(api_key: str) -> list[dict[str, str]]:
48
48
  return rows[:120]
49
49
 
50
50
 
51
- def live_suggestions(api_key: str = "", *, limit: int = 40) -> list[dict[str, str]]:
51
+ def live_suggestions(api_key: str = "", *, limit: int = 40, timeout: float = 45) -> list[dict[str, str]]:
52
52
  """Return cached OpenRouter suggestions; empty without a key."""
53
53
  key = str(api_key or "").strip()
54
54
  if not key:
@@ -57,7 +57,7 @@ def live_suggestions(api_key: str = "", *, limit: int = 40) -> list[dict[str, st
57
57
  if _CACHE["rows"] and now - float(_CACHE["at"]) < _TTL_SEC:
58
58
  return list(_CACHE["rows"])[:limit]
59
59
  try:
60
- rows = _fetch_models(key)
60
+ rows = _fetch_models(key, timeout=timeout)
61
61
  except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError):
62
62
  return list(_CACHE["rows"])[:limit]
63
63
  _CACHE["at"] = now
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@workframe/workframe-api",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "python3 server.py",
8
8
  "start": "python3 server.py",
9
- "typecheck": "python3 -m py_compile server.py zk_auth.py email_sender.py",
9
+ "typecheck": "python -m py_compile server.py zk_auth.py email_sender.py profile_config_yaml.py route_registry.py && python test_public_routes.py && python test_route_registry.py && python test_billing_provider.py && python test_model_surface_consistency.py && python test_profile_model_yaml.py",
10
10
  "build": "python3 -m py_compile server.py zk_auth.py email_sender.py"
11
11
  }
12
12
  }
@@ -0,0 +1,126 @@
1
+ """Single write path for Hermes profile config.yaml model surface (WF-033).
2
+
3
+ Read path stays in server._parse_model_block_from_disk (text scan, no import cycle).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+
13
+ def _salvage_top_level_keys(text: str) -> dict[str, Any]:
14
+ """Keep non-model keys when safe_load cannot parse corrupt yaml (wizard duplicates)."""
15
+ out: dict[str, Any] = {}
16
+ skip = {"model", "fallback_providers"}
17
+ lines = text.splitlines()
18
+ i = 0
19
+ while i < len(lines):
20
+ line = lines[i]
21
+ stripped = line.lstrip()
22
+ if not stripped or stripped.startswith("#") or line.startswith((" ", "\t")):
23
+ i += 1
24
+ continue
25
+ if ":" not in stripped:
26
+ i += 1
27
+ continue
28
+ key = stripped.split(":", 1)[0].strip()
29
+ if key in skip:
30
+ i += 1
31
+ while i < len(lines) and (not lines[i].strip() or lines[i].startswith((" ", "\t", "#"))):
32
+ i += 1
33
+ continue
34
+ after = stripped.split(":", 1)[1].strip()
35
+ if after:
36
+ try:
37
+ out[key] = yaml.safe_load(after)
38
+ except yaml.YAMLError:
39
+ out[key] = after.strip().strip("'\"")
40
+ i += 1
41
+ continue
42
+ block = [line]
43
+ i += 1
44
+ while i < len(lines) and (not lines[i].strip() or lines[i].startswith((" ", "\t", "#"))):
45
+ block.append(lines[i])
46
+ i += 1
47
+ try:
48
+ frag = yaml.safe_load("\n".join(block))
49
+ if isinstance(frag, dict) and key in frag:
50
+ out[key] = frag[key]
51
+ else:
52
+ out[key] = None
53
+ except yaml.YAMLError:
54
+ out[key] = None
55
+ return out
56
+
57
+
58
+ def _load_cfg(path: Path) -> dict[str, Any]:
59
+ if not path.is_file():
60
+ return {}
61
+ text = path.read_text(encoding="utf-8")
62
+ if not text.strip():
63
+ return {}
64
+ try:
65
+ data = yaml.safe_load(text)
66
+ except yaml.YAMLError:
67
+ return _salvage_top_level_keys(text)
68
+ return data if isinstance(data, dict) else {}
69
+
70
+
71
+ def update_model_surface(
72
+ config_path: Path,
73
+ *,
74
+ default: str | None = None,
75
+ provider: str | None = None,
76
+ base_url: str | None = None,
77
+ api_key: str | None = None,
78
+ fallback_chain: list[dict[str, str]] | None = None,
79
+ strip_proxy_fields: bool = False,
80
+ coalesce: bool = False,
81
+ ) -> None:
82
+ """Merge model + fallback_providers keys; preserve other top-level yaml keys.
83
+
84
+ ponytail: comments are not preserved (safe_load/safe_dump); read path uses text scan.
85
+ coalesce=True drops duplicate model:/fallback_providers: blocks before rewrite.
86
+ """
87
+ cfg = _load_cfg(config_path)
88
+ if coalesce:
89
+ cfg.pop("model", None)
90
+ cfg.pop("fallback_providers", None)
91
+ model: dict[str, Any] = {}
92
+ else:
93
+ model = cfg.get("model")
94
+ if not isinstance(model, dict):
95
+ model = {}
96
+ if default is not None:
97
+ model["default"] = default
98
+ if provider is not None:
99
+ model["provider"] = provider
100
+ if base_url is not None:
101
+ if base_url:
102
+ model["base_url"] = base_url
103
+ else:
104
+ model.pop("base_url", None)
105
+ if api_key is not None:
106
+ if api_key:
107
+ model["api_key"] = api_key
108
+ else:
109
+ model.pop("api_key", None)
110
+ if strip_proxy_fields:
111
+ model.pop("api_key", None)
112
+ model.pop("base_url", None)
113
+ if model:
114
+ cfg["model"] = model
115
+ if fallback_chain is not None:
116
+ rows = [
117
+ {"provider": str(e.get("provider", "")).strip(), "model": str(e.get("model", "")).strip()}
118
+ for e in fallback_chain
119
+ if isinstance(e, dict) and str(e.get("provider", "")).strip() and str(e.get("model", "")).strip()
120
+ ]
121
+ if rows:
122
+ cfg["fallback_providers"] = rows
123
+ else:
124
+ cfg.pop("fallback_providers", None)
125
+ config_path.parent.mkdir(parents=True, exist_ok=True)
126
+ config_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")