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.
- package/README.md +1 -1
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/sync-canonical-to-package.mjs +8 -0
- package/scripts/verify-public-deploy.sh +59 -3
- package/workframe-api/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_config_yaml.py +126 -0
- package/workframe-api/route_registry.py +330 -0
- package/workframe-api/run-typecheck.mjs +25 -0
- package/workframe-api/server.py +1523 -1392
- package/workframe-api/test_model_surface_consistency.py +116 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +109 -0
- package/workframe-api/time-bind-steps.py +75 -0
- package/workframe-api/time-perf.py +81 -0
- package/workframe-api/time-warm.py +107 -0
- package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-B0OFRGmJ.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DeBNltHS.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-BDhCHu7I.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-olcDYvtI.js} +1 -1
- package/workframe-ui/public/assets/channel-fNHbDpV4.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-D0obCBn_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DX93f-ZZ.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-BX9B5wUs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-C4DGc6RO.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-Crlja9Lf.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-JYSm3szI.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-_KslxVEl.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-CpTO-jU8.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-JGUkCmbI.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-BscBwGaC.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-5ZTZ0bpX.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-DxvitYbW.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-CsRdBqUB.js → chunk-LZXEDZCA-DMfdMUwz.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-Cayl0iV9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-_7dyBR5G.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O--xTpD0yi.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-CwxJQaeK.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-CCspENYv.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Dv8V6R60.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-Da48hFPT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-dTihc7-3.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-D_Jqu699.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-Bt8PtEaZ.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-BtmPjlh0.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-Cz2turIT.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-BxMGCflX.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-ivyroIZt.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-B9llwShx.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-BFQHk4bw.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-ursegLbc.js} +1 -1
- package/workframe-ui/public/assets/index-8CuZDEIG.css +1 -0
- package/workframe-ui/public/assets/index-xS9lFekI.js +129 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-DQpifAsB.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-C16XP2Q7.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-CiJoz7rP.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-CVwEvvUL.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-CWQ6hX5i.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-CgQsmU35.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-Bxbc77dL.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-C_jMeF0a.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-4RKV9MZP.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-DZ7y7Sz0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-B9h2I7Vq.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-B2mP5uHm.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-DyYKyKiP.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-CL_6nej-.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-CNKNDQ7S.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-eWU_mhFa.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-DrjcXvEQ.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-BHWR1xTJ.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-DfJB8kV1.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-BGJbSd3I.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-DkwoCjiZ.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-GxfhiqoW.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-DxrOsars.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-BsWhpUfr.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-CrFZWMHS.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DV8Xpf5I.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-DqMqnwdZ.js} +1 -1
- package/workframe-ui/public/index.html +4 -5
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
- package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
package/README.md
CHANGED
package/docs/security.md
ADDED
|
@@ -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
|
@@ -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
|
|
96
|
-
gw_env="$(docker inspect
|
|
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 "
|
|
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=
|
|
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.
|
|
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": "
|
|
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")
|