create-workframe 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/NOTICE +12 -12
- package/README.md +8 -92
- package/SECURITY.md +40 -40
- package/bin/workframe.js +329 -329
- package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
- package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
- package/package.json +3 -6
- package/profiles/architect/AGENTS.md +29 -29
- package/profiles/architect/SOUL.md +2 -2
- package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/designer/AGENTS.md +26 -26
- package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/dev/AGENTS.md +28 -28
- package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/docs/AGENTS.md +27 -27
- package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/research/AGENTS.md +26 -26
- package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/visionary/AGENTS.md +25 -25
- package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/workframe-agent/AGENTS.md +37 -37
- package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
- package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
- package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
- package/rules/workspace-README.md +5 -5
- package/scripts/apply-update-hermes.sh +17 -17
- package/scripts/apply-update-workframe.sh +77 -77
- package/scripts/bootstrap-workspace-link.sh +8 -8
- package/scripts/bundle-workframe-ui.mjs +3 -3
- package/scripts/compose-docker-host.sh +37 -37
- package/scripts/ensure-compose-host-paths.mjs +51 -51
- package/scripts/fix-zk-encryption-key.sh +35 -35
- package/scripts/lib/install-identity.mjs +212 -212
- package/scripts/restart-gateway-hermes.sh +12 -12
- package/scripts/set-compose-public-url.mjs +92 -92
- package/scripts/setup-stack-secrets.sh +50 -50
- package/scripts/sync-canonical-to-package.mjs +8 -7
- package/scripts/verify-public-deploy.sh +105 -105
- package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
- package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
- package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
- package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
- package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
- package/workframe-api/README.md +28 -28
- package/workframe-api/action_proxy.py +131 -131
- package/workframe-api/auth_rate_limit.py +49 -49
- package/workframe-api/credential_vault.py +445 -445
- package/workframe-api/data/avatar-catalog.json +41 -41
- package/workframe-api/email_sender.py +220 -220
- package/workframe-api/google_auth.py +90 -90
- package/workframe-api/install_api.py +359 -359
- package/workframe-api/internal_proxy_auth.py +150 -150
- package/workframe-api/llm_proxy.py +277 -277
- package/workframe-api/oidc_jwt.py +108 -108
- package/workframe-api/package.json +12 -13
- package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
- package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
- package/workframe-api/requirements.txt +2 -2
- package/workframe-api/site_meta.py +271 -271
- package/workframe-api/stack_config.py +427 -427
- package/workframe-api/time-bind-chat.py +99 -99
- package/workframe-api/turn_credentials.py +226 -226
- package/workframe-api/updates.py +417 -417
- package/workframe-api/vault_kek.py +159 -159
- package/workframe-api/zk_auth.py +633 -633
- package/workframe-supervisor/Dockerfile +11 -11
- package/workframe-supervisor/server.py +787 -787
- package/workframe-ui/docker/nginx.conf +85 -85
- package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
- package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
- package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
- package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
- package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
- package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
- package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
- package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
- package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
- package/workframe-ui/public/favicon.svg +7 -7
- package/workframe-ui/public/index.html +50 -50
- package/workframe-ui/public/workframe-config.json +3 -3
- package/scripts/security_audit.py +0 -156
- package/scripts/test-scaffold.mjs +0 -390
- package/workframe-api/tests/__init__.py +0 -0
- package/workframe-api/tests/db_setup.py +0 -13
- package/workframe-api/tests/test_admin_updates_gated.py +0 -30
- package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
- package/workframe-api/tests/test_agent_profile_sync.py +0 -76
- package/workframe-api/tests/test_auth_email.py +0 -222
- package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
- package/workframe-api/tests/test_auth_rate_limit.py +0 -19
- package/workframe-api/tests/test_avatar_resolve.py +0 -77
- package/workframe-api/tests/test_child_soul_template.py +0 -71
- package/workframe-api/tests/test_credential_canary.py +0 -135
- package/workframe-api/tests/test_credential_isolation.py +0 -448
- package/workframe-api/tests/test_credential_resolution.py +0 -206
- package/workframe-api/tests/test_device_oauth.py +0 -108
- package/workframe-api/tests/test_doctor_repair.py +0 -103
- package/workframe-api/tests/test_ensure_profile_api.py +0 -77
- package/workframe-api/tests/test_gateway_compose_security.py +0 -136
- package/workframe-api/tests/test_install_secure_host.py +0 -39
- package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
- package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
- package/workframe-api/tests/test_kanban_delegation.py +0 -185
- package/workframe-api/tests/test_llm_proxy.py +0 -155
- package/workframe-api/tests/test_login_access_policy.py +0 -183
- package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
- package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
- package/workframe-api/tests/test_platform_auth.py +0 -47
- package/workframe-api/tests/test_profile_config_path.py +0 -56
- package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
- package/workframe-api/tests/test_profile_create.py +0 -72
- package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
- package/workframe-api/tests/test_profile_install_health.py +0 -45
- package/workframe-api/tests/test_profile_secret_policy.py +0 -57
- package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
- package/workframe-api/tests/test_provider_bootstrap.py +0 -75
- package/workframe-api/tests/test_provider_connect.py +0 -54
- package/workframe-api/tests/test_room_crud.py +0 -192
- package/workframe-api/tests/test_room_tenancy.py +0 -701
- package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
- package/workframe-api/tests/test_site_meta.py +0 -81
- package/workframe-api/tests/test_soul_stub.py +0 -42
- package/workframe-api/tests/test_space_member_sync.py +0 -99
- package/workframe-api/tests/test_stripe_stack_config.py +0 -37
- package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
- package/workframe-api/tests/test_turn_credential_vault.py +0 -125
- package/workframe-api/tests/test_updates.py +0 -176
- package/workframe-api/tests/test_user_cohort.py +0 -113
- package/workframe-api/tests/test_vault_envelope.py +0 -110
- package/workframe-api/tests/test_workspace_members.py +0 -183
- package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
- package/workframe-api/tests/test_workspace_provider_list.py +0 -57
- package/workframe-supervisor/tests/test_exec_guard.py +0 -42
- package/workframe-supervisor/tests/test_server_import.py +0 -21
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
- package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
- package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +0 -1
|
@@ -1,99 +1,99 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Time bind + credential lease on running workframe-api (docker exec)."""
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import sqlite3
|
|
6
|
-
import sys
|
|
7
|
-
import time
|
|
8
|
-
|
|
9
|
-
import server
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def main() -> int:
|
|
13
|
-
conn = sqlite3.connect(str(server.DATA_DIR / "workframe.db"))
|
|
14
|
-
conn.row_factory = sqlite3.Row
|
|
15
|
-
room = conn.execute(
|
|
16
|
-
"""
|
|
17
|
-
SELECT r.id, r.workspace_id, r.agent_profile_id, ap.slug AS agent_slug
|
|
18
|
-
FROM rooms r
|
|
19
|
-
JOIN agent_profiles ap ON ap.id = r.agent_profile_id
|
|
20
|
-
JOIN room_memberships rm ON rm.room_id = r.id AND rm.status = 'active'
|
|
21
|
-
WHERE r.room_type = 'direct' AND r.deleted_at IS NULL
|
|
22
|
-
AND ap.slug = 'workframe-agent'
|
|
23
|
-
ORDER BY r.updated_at DESC
|
|
24
|
-
LIMIT 1
|
|
25
|
-
""",
|
|
26
|
-
).fetchone()
|
|
27
|
-
if not room:
|
|
28
|
-
print("no workframe-agent room", file=sys.stderr)
|
|
29
|
-
return 1
|
|
30
|
-
user = conn.execute(
|
|
31
|
-
"""
|
|
32
|
-
SELECT u.id FROM users u
|
|
33
|
-
JOIN room_memberships rm ON rm.user_id = u.id AND rm.room_id = ?
|
|
34
|
-
WHERE u.status = 'active'
|
|
35
|
-
LIMIT 1
|
|
36
|
-
""",
|
|
37
|
-
(str(room["id"]),),
|
|
38
|
-
).fetchone()
|
|
39
|
-
conn.close()
|
|
40
|
-
if not room or not user:
|
|
41
|
-
print("no room/user", file=sys.stderr)
|
|
42
|
-
return 1
|
|
43
|
-
|
|
44
|
-
user_id = str(user["id"])
|
|
45
|
-
room_id = str(room["id"])
|
|
46
|
-
workspace_id = str(room["workspace_id"])
|
|
47
|
-
payload = {
|
|
48
|
-
"room_id": room_id,
|
|
49
|
-
"workspace_id": workspace_id,
|
|
50
|
-
"source_id": "timing",
|
|
51
|
-
"client_id": "timing",
|
|
52
|
-
"binding_version": 2,
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
t_rt = time.perf_counter()
|
|
56
|
-
prof = server._resolve_chat_hermes_profile("workframe-agent", user_id, room_id, workspace_id)
|
|
57
|
-
rt_ms = (time.perf_counter() - t_rt) * 1000
|
|
58
|
-
print(f"resolve_runtime_ms={rt_ms:.0f} profile={prof}")
|
|
59
|
-
|
|
60
|
-
provider = server._llm_billing_provider(prof)
|
|
61
|
-
t_api = time.perf_counter()
|
|
62
|
-
server.ensure_profile_api(prof, user_id, workspace_id)
|
|
63
|
-
api_ms = (time.perf_counter() - t_api) * 1000
|
|
64
|
-
t_llm = time.perf_counter()
|
|
65
|
-
server._overlay_chat_llm_env(prof, user_id, workspace_id, provider)
|
|
66
|
-
llm_ms = (time.perf_counter() - t_llm) * 1000
|
|
67
|
-
print(f"ensure_profile_api_ms={api_ms:.0f} overlay_llm_ms={llm_ms:.0f}")
|
|
68
|
-
|
|
69
|
-
t0 = time.perf_counter()
|
|
70
|
-
session = server.profile_chat_session("workframe-agent", payload, user_id=user_id)
|
|
71
|
-
session_ms = (time.perf_counter() - t0) * 1000
|
|
72
|
-
sid = str(session.get("session_id") or "")
|
|
73
|
-
print(f"session_ms={session_ms:.0f} session={sid[:40]}")
|
|
74
|
-
|
|
75
|
-
t_hist = time.perf_counter()
|
|
76
|
-
history = server.chat_messages(prof, sid)
|
|
77
|
-
hist_ms = (time.perf_counter() - t_hist) * 1000
|
|
78
|
-
print(f"history_ms={hist_ms:.0f} messages={len(history.get('messages') or [])}")
|
|
79
|
-
|
|
80
|
-
healthy = server._profile_api_healthy(prof)
|
|
81
|
-
print(f"profile_api_healthy={healthy} port={server._profile_api_port(prof)}")
|
|
82
|
-
|
|
83
|
-
run_a = "timing-run-a"
|
|
84
|
-
run_b = "timing-run-b"
|
|
85
|
-
t1 = time.perf_counter()
|
|
86
|
-
server._apply_turn_credential_lease(prof, user_id, workspace_id, provider, run_a)
|
|
87
|
-
lease1_ms = (time.perf_counter() - t1) * 1000
|
|
88
|
-
t2 = time.perf_counter()
|
|
89
|
-
server._apply_turn_credential_lease(prof, user_id, workspace_id, provider, run_b)
|
|
90
|
-
lease2_ms = (time.perf_counter() - t2) * 1000
|
|
91
|
-
print(f"lease_cold_ms={lease1_ms:.0f} lease_reuse_ms={lease2_ms:.0f} provider={provider}")
|
|
92
|
-
|
|
93
|
-
_, model = server._read_model_from_config(prof)
|
|
94
|
-
print(f"model={model}")
|
|
95
|
-
return 0
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if __name__ == "__main__":
|
|
99
|
-
raise SystemExit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Time bind + credential lease on running workframe-api (docker exec)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import server
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> int:
|
|
13
|
+
conn = sqlite3.connect(str(server.DATA_DIR / "workframe.db"))
|
|
14
|
+
conn.row_factory = sqlite3.Row
|
|
15
|
+
room = conn.execute(
|
|
16
|
+
"""
|
|
17
|
+
SELECT r.id, r.workspace_id, r.agent_profile_id, ap.slug AS agent_slug
|
|
18
|
+
FROM rooms r
|
|
19
|
+
JOIN agent_profiles ap ON ap.id = r.agent_profile_id
|
|
20
|
+
JOIN room_memberships rm ON rm.room_id = r.id AND rm.status = 'active'
|
|
21
|
+
WHERE r.room_type = 'direct' AND r.deleted_at IS NULL
|
|
22
|
+
AND ap.slug = 'workframe-agent'
|
|
23
|
+
ORDER BY r.updated_at DESC
|
|
24
|
+
LIMIT 1
|
|
25
|
+
""",
|
|
26
|
+
).fetchone()
|
|
27
|
+
if not room:
|
|
28
|
+
print("no workframe-agent room", file=sys.stderr)
|
|
29
|
+
return 1
|
|
30
|
+
user = conn.execute(
|
|
31
|
+
"""
|
|
32
|
+
SELECT u.id FROM users u
|
|
33
|
+
JOIN room_memberships rm ON rm.user_id = u.id AND rm.room_id = ?
|
|
34
|
+
WHERE u.status = 'active'
|
|
35
|
+
LIMIT 1
|
|
36
|
+
""",
|
|
37
|
+
(str(room["id"]),),
|
|
38
|
+
).fetchone()
|
|
39
|
+
conn.close()
|
|
40
|
+
if not room or not user:
|
|
41
|
+
print("no room/user", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
|
|
44
|
+
user_id = str(user["id"])
|
|
45
|
+
room_id = str(room["id"])
|
|
46
|
+
workspace_id = str(room["workspace_id"])
|
|
47
|
+
payload = {
|
|
48
|
+
"room_id": room_id,
|
|
49
|
+
"workspace_id": workspace_id,
|
|
50
|
+
"source_id": "timing",
|
|
51
|
+
"client_id": "timing",
|
|
52
|
+
"binding_version": 2,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
t_rt = time.perf_counter()
|
|
56
|
+
prof = server._resolve_chat_hermes_profile("workframe-agent", user_id, room_id, workspace_id)
|
|
57
|
+
rt_ms = (time.perf_counter() - t_rt) * 1000
|
|
58
|
+
print(f"resolve_runtime_ms={rt_ms:.0f} profile={prof}")
|
|
59
|
+
|
|
60
|
+
provider = server._llm_billing_provider(prof)
|
|
61
|
+
t_api = time.perf_counter()
|
|
62
|
+
server.ensure_profile_api(prof, user_id, workspace_id)
|
|
63
|
+
api_ms = (time.perf_counter() - t_api) * 1000
|
|
64
|
+
t_llm = time.perf_counter()
|
|
65
|
+
server._overlay_chat_llm_env(prof, user_id, workspace_id, provider)
|
|
66
|
+
llm_ms = (time.perf_counter() - t_llm) * 1000
|
|
67
|
+
print(f"ensure_profile_api_ms={api_ms:.0f} overlay_llm_ms={llm_ms:.0f}")
|
|
68
|
+
|
|
69
|
+
t0 = time.perf_counter()
|
|
70
|
+
session = server.profile_chat_session("workframe-agent", payload, user_id=user_id)
|
|
71
|
+
session_ms = (time.perf_counter() - t0) * 1000
|
|
72
|
+
sid = str(session.get("session_id") or "")
|
|
73
|
+
print(f"session_ms={session_ms:.0f} session={sid[:40]}")
|
|
74
|
+
|
|
75
|
+
t_hist = time.perf_counter()
|
|
76
|
+
history = server.chat_messages(prof, sid)
|
|
77
|
+
hist_ms = (time.perf_counter() - t_hist) * 1000
|
|
78
|
+
print(f"history_ms={hist_ms:.0f} messages={len(history.get('messages') or [])}")
|
|
79
|
+
|
|
80
|
+
healthy = server._profile_api_healthy(prof)
|
|
81
|
+
print(f"profile_api_healthy={healthy} port={server._profile_api_port(prof)}")
|
|
82
|
+
|
|
83
|
+
run_a = "timing-run-a"
|
|
84
|
+
run_b = "timing-run-b"
|
|
85
|
+
t1 = time.perf_counter()
|
|
86
|
+
server._apply_turn_credential_lease(prof, user_id, workspace_id, provider, run_a)
|
|
87
|
+
lease1_ms = (time.perf_counter() - t1) * 1000
|
|
88
|
+
t2 = time.perf_counter()
|
|
89
|
+
server._apply_turn_credential_lease(prof, user_id, workspace_id, provider, run_b)
|
|
90
|
+
lease2_ms = (time.perf_counter() - t2) * 1000
|
|
91
|
+
print(f"lease_cold_ms={lease1_ms:.0f} lease_reuse_ms={lease2_ms:.0f} provider={provider}")
|
|
92
|
+
|
|
93
|
+
_, model = server._read_model_from_config(prof)
|
|
94
|
+
print(f"model={model}")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
raise SystemExit(main())
|
|
@@ -1,226 +1,226 @@
|
|
|
1
|
-
"""Per-run credential leases — opaque tokens for Hermes; real secrets stay in API vault."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
import os
|
|
7
|
-
import secrets
|
|
8
|
-
import sqlite3
|
|
9
|
-
import time
|
|
10
|
-
from datetime import datetime, timedelta, timezone
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any
|
|
13
|
-
|
|
14
|
-
import credential_vault
|
|
15
|
-
|
|
16
|
-
DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
|
|
17
|
-
WORKFRAME_DB = DATA_DIR / "workframe.db"
|
|
18
|
-
LEASE_PREFIX = "wf_rt_"
|
|
19
|
-
DEFAULT_TTL = int(os.environ.get("WORKFRAME_TURN_LEASE_TTL", "900"))
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _connect() -> sqlite3.Connection:
|
|
23
|
-
conn = sqlite3.connect(str(WORKFRAME_DB), timeout=5.0)
|
|
24
|
-
conn.row_factory = sqlite3.Row
|
|
25
|
-
return conn
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# ponytail: schema DDL ran on every validate_lease/issue — ~38ms/call on bind-mounted
|
|
29
|
-
# sqlite. Guard by DB path so it runs once per process (tests reassign WORKFRAME_DB → re-run).
|
|
30
|
-
_SCHEMA_READY: set[str] = set()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def ensure_schema() -> None:
|
|
34
|
-
credential_vault.ensure_schema()
|
|
35
|
-
key = str(WORKFRAME_DB)
|
|
36
|
-
if key in _SCHEMA_READY:
|
|
37
|
-
return
|
|
38
|
-
conn = _connect()
|
|
39
|
-
try:
|
|
40
|
-
conn.execute(
|
|
41
|
-
"""
|
|
42
|
-
CREATE TABLE IF NOT EXISTS turn_credential_leases (
|
|
43
|
-
run_id TEXT PRIMARY KEY,
|
|
44
|
-
token_hash TEXT NOT NULL UNIQUE,
|
|
45
|
-
payer_user_id TEXT NOT NULL,
|
|
46
|
-
workspace_id TEXT NOT NULL,
|
|
47
|
-
provider TEXT NOT NULL,
|
|
48
|
-
credential_binding_id TEXT DEFAULT NULL,
|
|
49
|
-
profile_slug TEXT NOT NULL,
|
|
50
|
-
expires_at TEXT NOT NULL,
|
|
51
|
-
revoked_at TEXT DEFAULT NULL,
|
|
52
|
-
created_at TEXT NOT NULL
|
|
53
|
-
)
|
|
54
|
-
"""
|
|
55
|
-
)
|
|
56
|
-
conn.execute(
|
|
57
|
-
"CREATE INDEX IF NOT EXISTS idx_turn_leases_token "
|
|
58
|
-
"ON turn_credential_leases(token_hash)"
|
|
59
|
-
)
|
|
60
|
-
conn.execute(
|
|
61
|
-
"CREATE INDEX IF NOT EXISTS idx_turn_leases_expiry "
|
|
62
|
-
"ON turn_credential_leases(expires_at)"
|
|
63
|
-
)
|
|
64
|
-
conn.commit()
|
|
65
|
-
finally:
|
|
66
|
-
conn.close()
|
|
67
|
-
_SCHEMA_READY.add(key)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _hash_token(token: str) -> str:
|
|
71
|
-
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _expired(expires_at: str) -> bool:
|
|
75
|
-
value = str(expires_at or "").strip()
|
|
76
|
-
if not value:
|
|
77
|
-
return True
|
|
78
|
-
if value.isdigit():
|
|
79
|
-
return int(value) < int(time.time())
|
|
80
|
-
try:
|
|
81
|
-
return datetime.fromisoformat(value.replace("Z", "+00:00")) < datetime.now(timezone.utc)
|
|
82
|
-
except ValueError:
|
|
83
|
-
return True
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def issue_lease(
|
|
87
|
-
run_id: str,
|
|
88
|
-
payer_user_id: str,
|
|
89
|
-
workspace_id: str,
|
|
90
|
-
provider: str,
|
|
91
|
-
profile_slug: str,
|
|
92
|
-
credential_binding_id: str | None,
|
|
93
|
-
*,
|
|
94
|
-
ttl_seconds: int = DEFAULT_TTL,
|
|
95
|
-
) -> str:
|
|
96
|
-
"""Create lease; return full token value for profile env (wf_rt_…)."""
|
|
97
|
-
run_id = str(run_id or "").strip()
|
|
98
|
-
payer_user_id = str(payer_user_id or "").strip()
|
|
99
|
-
workspace_id = str(workspace_id or "").strip()
|
|
100
|
-
provider = str(provider or "openrouter").strip().lower()
|
|
101
|
-
profile_slug = str(profile_slug or "").strip()
|
|
102
|
-
if not run_id or not payer_user_id or not profile_slug:
|
|
103
|
-
raise ValueError("run_id, payer_user_id, and profile_slug required")
|
|
104
|
-
ttl = int(ttl_seconds or DEFAULT_TTL)
|
|
105
|
-
if ttl <= 0:
|
|
106
|
-
raise ValueError("ttl_seconds must be positive")
|
|
107
|
-
|
|
108
|
-
ensure_schema()
|
|
109
|
-
raw = secrets.token_hex(32)
|
|
110
|
-
token = f"{LEASE_PREFIX}{raw}"
|
|
111
|
-
now = datetime.now(timezone.utc)
|
|
112
|
-
expires_at = (now + timedelta(seconds=ttl)).isoformat()
|
|
113
|
-
created_at = now.isoformat()
|
|
114
|
-
conn = _connect()
|
|
115
|
-
try:
|
|
116
|
-
conn.execute(
|
|
117
|
-
"""
|
|
118
|
-
INSERT INTO turn_credential_leases (
|
|
119
|
-
run_id, token_hash, payer_user_id, workspace_id, provider,
|
|
120
|
-
credential_binding_id, profile_slug, expires_at, created_at
|
|
121
|
-
) VALUES (?,?,?,?,?,?,?,?,?)
|
|
122
|
-
ON CONFLICT(run_id) DO UPDATE SET
|
|
123
|
-
token_hash = excluded.token_hash,
|
|
124
|
-
payer_user_id = excluded.payer_user_id,
|
|
125
|
-
workspace_id = excluded.workspace_id,
|
|
126
|
-
provider = excluded.provider,
|
|
127
|
-
credential_binding_id = excluded.credential_binding_id,
|
|
128
|
-
profile_slug = excluded.profile_slug,
|
|
129
|
-
expires_at = excluded.expires_at,
|
|
130
|
-
revoked_at = NULL,
|
|
131
|
-
created_at = excluded.created_at
|
|
132
|
-
""",
|
|
133
|
-
(
|
|
134
|
-
run_id,
|
|
135
|
-
_hash_token(token),
|
|
136
|
-
payer_user_id,
|
|
137
|
-
workspace_id,
|
|
138
|
-
provider,
|
|
139
|
-
str(credential_binding_id or "") or None,
|
|
140
|
-
profile_slug,
|
|
141
|
-
expires_at,
|
|
142
|
-
created_at,
|
|
143
|
-
),
|
|
144
|
-
)
|
|
145
|
-
conn.commit()
|
|
146
|
-
finally:
|
|
147
|
-
conn.close()
|
|
148
|
-
return token
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def parse_lease_token(value: str) -> str:
|
|
152
|
-
raw = str(value or "").strip()
|
|
153
|
-
if raw.startswith(LEASE_PREFIX):
|
|
154
|
-
return raw
|
|
155
|
-
if raw and not raw.startswith(LEASE_PREFIX):
|
|
156
|
-
return f"{LEASE_PREFIX}{raw}"
|
|
157
|
-
return ""
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def validate_lease(token: str) -> dict[str, Any] | None:
|
|
161
|
-
"""Return lease metadata if token is active (not revoked/expired)."""
|
|
162
|
-
token = parse_lease_token(token)
|
|
163
|
-
if not token:
|
|
164
|
-
return None
|
|
165
|
-
ensure_schema()
|
|
166
|
-
conn = _connect()
|
|
167
|
-
try:
|
|
168
|
-
row = conn.execute(
|
|
169
|
-
"""
|
|
170
|
-
SELECT run_id, payer_user_id, workspace_id, provider,
|
|
171
|
-
credential_binding_id, profile_slug, expires_at, revoked_at
|
|
172
|
-
FROM turn_credential_leases
|
|
173
|
-
WHERE token_hash = ?
|
|
174
|
-
""",
|
|
175
|
-
(_hash_token(token),),
|
|
176
|
-
).fetchone()
|
|
177
|
-
finally:
|
|
178
|
-
conn.close()
|
|
179
|
-
if not row or row["revoked_at"] or _expired(str(row["expires_at"] or "")):
|
|
180
|
-
return None
|
|
181
|
-
return {
|
|
182
|
-
"run_id": str(row["run_id"]),
|
|
183
|
-
"payer_user_id": str(row["payer_user_id"]),
|
|
184
|
-
"workspace_id": str(row["workspace_id"]),
|
|
185
|
-
"provider": str(row["provider"]),
|
|
186
|
-
"credential_binding_id": str(row["credential_binding_id"] or ""),
|
|
187
|
-
"profile_slug": str(row["profile_slug"]),
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def revoke_lease(run_id: str) -> None:
|
|
192
|
-
run_id = str(run_id or "").strip()
|
|
193
|
-
if not run_id:
|
|
194
|
-
return
|
|
195
|
-
ensure_schema()
|
|
196
|
-
now = datetime.now(timezone.utc).isoformat()
|
|
197
|
-
conn = _connect()
|
|
198
|
-
try:
|
|
199
|
-
conn.execute(
|
|
200
|
-
"UPDATE turn_credential_leases SET revoked_at = ? WHERE run_id = ? AND revoked_at IS NULL",
|
|
201
|
-
(now, run_id),
|
|
202
|
-
)
|
|
203
|
-
conn.commit()
|
|
204
|
-
finally:
|
|
205
|
-
conn.close()
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def resolve_lease_secret(lease: dict[str, Any], resolve_secret_fn) -> tuple[str, str]:
|
|
209
|
-
"""resolve_secret_fn(user_id, workspace_id, provider, binding_id) -> (env_var, secret)."""
|
|
210
|
-
binding_id = str(lease.get("credential_binding_id") or "").strip()
|
|
211
|
-
provider = str(lease.get("provider") or "openrouter")
|
|
212
|
-
payer = str(lease.get("payer_user_id") or "")
|
|
213
|
-
workspace = str(lease.get("workspace_id") or "")
|
|
214
|
-
return resolve_secret_fn(payer, workspace, provider, binding_id)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if __name__ == "__main__":
|
|
218
|
-
ensure_schema()
|
|
219
|
-
rid = "run-selfcheck"
|
|
220
|
-
tok = issue_lease(rid, "user-a", "ws-1", "openrouter", "u-user-a-architect", "bind-1")
|
|
221
|
-
assert tok.startswith(LEASE_PREFIX)
|
|
222
|
-
meta = validate_lease(tok)
|
|
223
|
-
assert meta and meta["run_id"] == rid
|
|
224
|
-
revoke_lease(rid)
|
|
225
|
-
assert validate_lease(tok) is None
|
|
226
|
-
print("turn_credentials ok")
|
|
1
|
+
"""Per-run credential leases — opaque tokens for Hermes; real secrets stay in API vault."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import credential_vault
|
|
15
|
+
|
|
16
|
+
DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
|
|
17
|
+
WORKFRAME_DB = DATA_DIR / "workframe.db"
|
|
18
|
+
LEASE_PREFIX = "wf_rt_"
|
|
19
|
+
DEFAULT_TTL = int(os.environ.get("WORKFRAME_TURN_LEASE_TTL", "900"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _connect() -> sqlite3.Connection:
|
|
23
|
+
conn = sqlite3.connect(str(WORKFRAME_DB), timeout=5.0)
|
|
24
|
+
conn.row_factory = sqlite3.Row
|
|
25
|
+
return conn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ponytail: schema DDL ran on every validate_lease/issue — ~38ms/call on bind-mounted
|
|
29
|
+
# sqlite. Guard by DB path so it runs once per process (tests reassign WORKFRAME_DB → re-run).
|
|
30
|
+
_SCHEMA_READY: set[str] = set()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ensure_schema() -> None:
|
|
34
|
+
credential_vault.ensure_schema()
|
|
35
|
+
key = str(WORKFRAME_DB)
|
|
36
|
+
if key in _SCHEMA_READY:
|
|
37
|
+
return
|
|
38
|
+
conn = _connect()
|
|
39
|
+
try:
|
|
40
|
+
conn.execute(
|
|
41
|
+
"""
|
|
42
|
+
CREATE TABLE IF NOT EXISTS turn_credential_leases (
|
|
43
|
+
run_id TEXT PRIMARY KEY,
|
|
44
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
45
|
+
payer_user_id TEXT NOT NULL,
|
|
46
|
+
workspace_id TEXT NOT NULL,
|
|
47
|
+
provider TEXT NOT NULL,
|
|
48
|
+
credential_binding_id TEXT DEFAULT NULL,
|
|
49
|
+
profile_slug TEXT NOT NULL,
|
|
50
|
+
expires_at TEXT NOT NULL,
|
|
51
|
+
revoked_at TEXT DEFAULT NULL,
|
|
52
|
+
created_at TEXT NOT NULL
|
|
53
|
+
)
|
|
54
|
+
"""
|
|
55
|
+
)
|
|
56
|
+
conn.execute(
|
|
57
|
+
"CREATE INDEX IF NOT EXISTS idx_turn_leases_token "
|
|
58
|
+
"ON turn_credential_leases(token_hash)"
|
|
59
|
+
)
|
|
60
|
+
conn.execute(
|
|
61
|
+
"CREATE INDEX IF NOT EXISTS idx_turn_leases_expiry "
|
|
62
|
+
"ON turn_credential_leases(expires_at)"
|
|
63
|
+
)
|
|
64
|
+
conn.commit()
|
|
65
|
+
finally:
|
|
66
|
+
conn.close()
|
|
67
|
+
_SCHEMA_READY.add(key)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _hash_token(token: str) -> str:
|
|
71
|
+
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _expired(expires_at: str) -> bool:
|
|
75
|
+
value = str(expires_at or "").strip()
|
|
76
|
+
if not value:
|
|
77
|
+
return True
|
|
78
|
+
if value.isdigit():
|
|
79
|
+
return int(value) < int(time.time())
|
|
80
|
+
try:
|
|
81
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")) < datetime.now(timezone.utc)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def issue_lease(
|
|
87
|
+
run_id: str,
|
|
88
|
+
payer_user_id: str,
|
|
89
|
+
workspace_id: str,
|
|
90
|
+
provider: str,
|
|
91
|
+
profile_slug: str,
|
|
92
|
+
credential_binding_id: str | None,
|
|
93
|
+
*,
|
|
94
|
+
ttl_seconds: int = DEFAULT_TTL,
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Create lease; return full token value for profile env (wf_rt_…)."""
|
|
97
|
+
run_id = str(run_id or "").strip()
|
|
98
|
+
payer_user_id = str(payer_user_id or "").strip()
|
|
99
|
+
workspace_id = str(workspace_id or "").strip()
|
|
100
|
+
provider = str(provider or "openrouter").strip().lower()
|
|
101
|
+
profile_slug = str(profile_slug or "").strip()
|
|
102
|
+
if not run_id or not payer_user_id or not profile_slug:
|
|
103
|
+
raise ValueError("run_id, payer_user_id, and profile_slug required")
|
|
104
|
+
ttl = int(ttl_seconds or DEFAULT_TTL)
|
|
105
|
+
if ttl <= 0:
|
|
106
|
+
raise ValueError("ttl_seconds must be positive")
|
|
107
|
+
|
|
108
|
+
ensure_schema()
|
|
109
|
+
raw = secrets.token_hex(32)
|
|
110
|
+
token = f"{LEASE_PREFIX}{raw}"
|
|
111
|
+
now = datetime.now(timezone.utc)
|
|
112
|
+
expires_at = (now + timedelta(seconds=ttl)).isoformat()
|
|
113
|
+
created_at = now.isoformat()
|
|
114
|
+
conn = _connect()
|
|
115
|
+
try:
|
|
116
|
+
conn.execute(
|
|
117
|
+
"""
|
|
118
|
+
INSERT INTO turn_credential_leases (
|
|
119
|
+
run_id, token_hash, payer_user_id, workspace_id, provider,
|
|
120
|
+
credential_binding_id, profile_slug, expires_at, created_at
|
|
121
|
+
) VALUES (?,?,?,?,?,?,?,?,?)
|
|
122
|
+
ON CONFLICT(run_id) DO UPDATE SET
|
|
123
|
+
token_hash = excluded.token_hash,
|
|
124
|
+
payer_user_id = excluded.payer_user_id,
|
|
125
|
+
workspace_id = excluded.workspace_id,
|
|
126
|
+
provider = excluded.provider,
|
|
127
|
+
credential_binding_id = excluded.credential_binding_id,
|
|
128
|
+
profile_slug = excluded.profile_slug,
|
|
129
|
+
expires_at = excluded.expires_at,
|
|
130
|
+
revoked_at = NULL,
|
|
131
|
+
created_at = excluded.created_at
|
|
132
|
+
""",
|
|
133
|
+
(
|
|
134
|
+
run_id,
|
|
135
|
+
_hash_token(token),
|
|
136
|
+
payer_user_id,
|
|
137
|
+
workspace_id,
|
|
138
|
+
provider,
|
|
139
|
+
str(credential_binding_id or "") or None,
|
|
140
|
+
profile_slug,
|
|
141
|
+
expires_at,
|
|
142
|
+
created_at,
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
conn.commit()
|
|
146
|
+
finally:
|
|
147
|
+
conn.close()
|
|
148
|
+
return token
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_lease_token(value: str) -> str:
|
|
152
|
+
raw = str(value or "").strip()
|
|
153
|
+
if raw.startswith(LEASE_PREFIX):
|
|
154
|
+
return raw
|
|
155
|
+
if raw and not raw.startswith(LEASE_PREFIX):
|
|
156
|
+
return f"{LEASE_PREFIX}{raw}"
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def validate_lease(token: str) -> dict[str, Any] | None:
|
|
161
|
+
"""Return lease metadata if token is active (not revoked/expired)."""
|
|
162
|
+
token = parse_lease_token(token)
|
|
163
|
+
if not token:
|
|
164
|
+
return None
|
|
165
|
+
ensure_schema()
|
|
166
|
+
conn = _connect()
|
|
167
|
+
try:
|
|
168
|
+
row = conn.execute(
|
|
169
|
+
"""
|
|
170
|
+
SELECT run_id, payer_user_id, workspace_id, provider,
|
|
171
|
+
credential_binding_id, profile_slug, expires_at, revoked_at
|
|
172
|
+
FROM turn_credential_leases
|
|
173
|
+
WHERE token_hash = ?
|
|
174
|
+
""",
|
|
175
|
+
(_hash_token(token),),
|
|
176
|
+
).fetchone()
|
|
177
|
+
finally:
|
|
178
|
+
conn.close()
|
|
179
|
+
if not row or row["revoked_at"] or _expired(str(row["expires_at"] or "")):
|
|
180
|
+
return None
|
|
181
|
+
return {
|
|
182
|
+
"run_id": str(row["run_id"]),
|
|
183
|
+
"payer_user_id": str(row["payer_user_id"]),
|
|
184
|
+
"workspace_id": str(row["workspace_id"]),
|
|
185
|
+
"provider": str(row["provider"]),
|
|
186
|
+
"credential_binding_id": str(row["credential_binding_id"] or ""),
|
|
187
|
+
"profile_slug": str(row["profile_slug"]),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def revoke_lease(run_id: str) -> None:
|
|
192
|
+
run_id = str(run_id or "").strip()
|
|
193
|
+
if not run_id:
|
|
194
|
+
return
|
|
195
|
+
ensure_schema()
|
|
196
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
197
|
+
conn = _connect()
|
|
198
|
+
try:
|
|
199
|
+
conn.execute(
|
|
200
|
+
"UPDATE turn_credential_leases SET revoked_at = ? WHERE run_id = ? AND revoked_at IS NULL",
|
|
201
|
+
(now, run_id),
|
|
202
|
+
)
|
|
203
|
+
conn.commit()
|
|
204
|
+
finally:
|
|
205
|
+
conn.close()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_lease_secret(lease: dict[str, Any], resolve_secret_fn) -> tuple[str, str]:
|
|
209
|
+
"""resolve_secret_fn(user_id, workspace_id, provider, binding_id) -> (env_var, secret)."""
|
|
210
|
+
binding_id = str(lease.get("credential_binding_id") or "").strip()
|
|
211
|
+
provider = str(lease.get("provider") or "openrouter")
|
|
212
|
+
payer = str(lease.get("payer_user_id") or "")
|
|
213
|
+
workspace = str(lease.get("workspace_id") or "")
|
|
214
|
+
return resolve_secret_fn(payer, workspace, provider, binding_id)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
ensure_schema()
|
|
219
|
+
rid = "run-selfcheck"
|
|
220
|
+
tok = issue_lease(rid, "user-a", "ws-1", "openrouter", "u-user-a-architect", "bind-1")
|
|
221
|
+
assert tok.startswith(LEASE_PREFIX)
|
|
222
|
+
meta = validate_lease(tok)
|
|
223
|
+
assert meta and meta["run_id"] == rid
|
|
224
|
+
revoke_lease(rid)
|
|
225
|
+
assert validate_lease(tok) is None
|
|
226
|
+
print("turn_credentials ok")
|