@michelj/context-guard 0.6.4 → 0.6.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michelj/context-guard",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Next-generation collaboration layer for humans and coding agents on Codex, Cursor, and Claude.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,8 +56,8 @@
56
56
  "node": ">=18"
57
57
  },
58
58
  "devDependencies": {
59
- "@michelj/context-guard-core": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.2/michelj-context-guard-core-1.1.2.tgz",
60
- "@michelj/context-guard-workbench": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.2/michelj-context-guard-workbench-1.1.2.tgz",
59
+ "@michelj/context-guard-core": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.4/michelj-context-guard-core-1.1.4.tgz",
60
+ "@michelj/context-guard-workbench": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.4/michelj-context-guard-workbench-1.1.4.tgz",
61
61
  "marked": "15.0.12",
62
62
  "playwright": "1.62.1"
63
63
  }
@@ -63,6 +63,12 @@ const I18N = {
63
63
  workCopy1:"浏览器在按写死的步骤和稿子走,没有模型在读 OpenClaw 或本仓库。真实 skill 启用时,才由你正在对话的那个 Agent 读代码。",
64
64
  workCopy2:"下面的笔记是预置演示稿。真做时先商量第一层怎么切,定了再往下拆;卡名必须一眼能看懂。演示里第一页仍是定稿后的 4–8 张主干。这不是这次点击实时读仓得到的。",
65
65
  trayTitle:"已取消的提议",
66
+ trayClose:"关闭已取消的提议",
67
+ deviceApprovals:"设备连接申请", deviceApprovalsClose:"关闭设备连接申请", deviceApprovalsRefresh:"刷新申请",
68
+ deviceApprovalScope:"仅允许你发起的连接:读取本项目 Main、读写自身 Session;不授予管理或 Main 发布权限。",
69
+ deviceApprovalsEmpty:"没有待审批申请。", deviceAllow:"允许连接", deviceDeny:"拒绝", devicePending:"待审批", deviceOld:"旧申请",
70
+ deviceApprovalsFailed:"申请读取失败,请重新登录或刷新。", deviceDecisionUnknown:"决定尚未确认,请刷新核对。", deviceDecisionDone:"申请已处理。",
71
+ deviceLoginRequired:"登录已失效,请重新登录。", deviceTicketRefresh:"审批表单已失效,请刷新申请重试。", deviceAccessRevoked:"仓库授权已撤销,无法审批。", deviceScopeUnavailable:"该项目申请不可用,请刷新核对。", deviceAlreadyDecided:"申请已处理,请刷新核对。",
66
72
  trayHint:"已隐藏,后续 Agent 不会读到。可重新加入 Context Map,或永久删除。",
67
73
  openBugs:"Bug 处理状态", openTodos:"TODO 处理状态",
68
74
  boot_ready:"已建图", boot_proposed:"待确认", boot_analyzing:"分析中", boot_pending:"首次使用",
@@ -167,6 +173,12 @@ const I18N = {
167
173
  workCopy1:"The browser is walking hard-coded steps. No model is reading OpenClaw or this repo. When the real skill runs, the agent you are talking to reads the code.",
168
174
  workCopy2:"The notes below are a prepared demo. Real first-use decides L1 with you, then goes deeper; titles must be instantly readable. This demo still shows the agreed 4–8 trunk cards. This is not a live read of the repo.",
169
175
  trayTitle:"Cancelled proposals",
176
+ trayClose:"Close cancelled proposals",
177
+ deviceApprovals:"Device connection requests", deviceApprovalsClose:"Close device connection requests", deviceApprovalsRefresh:"Refresh requests",
178
+ deviceApprovalScope:"Allow only connections you initiated: read this project's Main and read/write your own Sessions; no administration or Main publication permission.",
179
+ deviceApprovalsEmpty:"No pending requests.", deviceAllow:"Allow connection", deviceDeny:"Deny", devicePending:"Pending", deviceOld:"Older request",
180
+ deviceApprovalsFailed:"Requests unavailable. Sign in or refresh.", deviceDecisionUnknown:"Decision not confirmed. Refresh to check.", deviceDecisionDone:"Request processed.",
181
+ deviceLoginRequired:"Sign-in expired. Sign in again.", deviceTicketRefresh:"Approval form expired. Refresh the request.", deviceAccessRevoked:"Repository authorization was revoked.", deviceScopeUnavailable:"Project request unavailable. Refresh to check.", deviceAlreadyDecided:"Request already processed. Refresh to check.",
170
182
  trayHint:"Hidden. Later agents will not read these. Restore them to the map, or delete permanently.",
171
183
  openBugs:"Bug status", openTodos:"TODO status",
172
184
  boot_ready:"Mapped", boot_proposed:"Pending", boot_analyzing:"Analyzing", boot_pending:"First use",
@@ -979,6 +991,7 @@ function syncBootstrapFromTree(){
979
991
  }
980
992
  }
981
993
  let workbenchSync = null;
994
+ let refreshDeviceApprovals = null;
982
995
  let applyingServerMap = false;
983
996
  let renderedAccessSession = null;
984
997
  let hasRenderedAccessSession = false;
@@ -2414,7 +2427,7 @@ function exitBugPath(restore){
2414
2427
  }
2415
2428
  function openBugPanel(open, kind=activeWorkPanelKind){
2416
2429
  if(open){
2417
- document.getElementById("tray").classList.remove("open");
2430
+ closeTray();
2418
2431
  const coordinator=document.getElementById('coordinator-panel');
2419
2432
  if(coordinator?.open)coordinator.setOpen(false);
2420
2433
  }
@@ -2651,13 +2664,35 @@ document.getElementById("btn-auth").onclick = ()=>{
2651
2664
  };
2652
2665
 
2653
2666
  /* ================= 已取消托盘 ================= */
2667
+ function closeTray(restoreFocus=false){
2668
+ const tray = document.getElementById("tray");
2669
+ const focusedInside = tray.contains(document.activeElement);
2670
+ tray.classList.remove("open");
2671
+ const trigger = document.getElementById("btn-tray");
2672
+ trigger.classList.remove("on");
2673
+ trigger.setAttribute("aria-expanded", "false");
2674
+ if(restoreFocus && focusedInside) document.getElementById("btn-settings").focus();
2675
+ }
2654
2676
  document.getElementById("btn-tray").onclick = ()=>{
2655
2677
  const tray = document.getElementById("tray");
2656
2678
  const open = !tray.classList.contains("open");
2679
+ if(!open){ closeTray(); return; }
2657
2680
  tray.classList.toggle("open", open);
2658
2681
  document.getElementById("btn-tray").classList.toggle("on", open);
2659
- if(open) closeSettings();
2682
+ document.getElementById("btn-tray").setAttribute("aria-expanded", "true");
2683
+ closeSettings();
2684
+ document.getElementById("tray-close").focus();
2660
2685
  };
2686
+ document.getElementById("tray-close").onclick = ()=>closeTray(true);
2687
+ document.addEventListener("click", e=>{
2688
+ if(!e.target.closest("#tray, #btn-tray")) closeTray();
2689
+ }, true);
2690
+ document.addEventListener("keydown", e=>{
2691
+ if(e.key!=="Escape" || e.defaultPrevented || e.isComposing || !document.getElementById("tray").classList.contains("open")) return;
2692
+ if(e.target.closest('input, textarea, [contenteditable="true"]') || document.querySelector("dialog[open]")) return;
2693
+ e.preventDefault();
2694
+ closeTray(true);
2695
+ });
2661
2696
  document.getElementById("btn-link-repo").onclick = async ()=>{
2662
2697
  await linkRepo();
2663
2698
  closeSettings();
@@ -4620,7 +4655,7 @@ async function installCoordinatorPanel(sync){
4620
4655
  const setPanelOpen=open=>{
4621
4656
  panel.open=open;panel.toggleAttribute('open',open);
4622
4657
  if(open){
4623
- openBugPanel(false);document.getElementById('tray').classList.remove('open');
4658
+ openBugPanel(false);closeTray();
4624
4659
  inspector.append(panel);inspector.classList.add('coordinator-open');
4625
4660
  }else{
4626
4661
  clearTimeout(timer);history.hidden=true;historyToggle.setAttribute('aria-expanded','false');inspector.classList.remove('coordinator-open');document.body.append(panel);renderDetail();
@@ -5127,6 +5162,69 @@ async function installCoordinatorPanel(sync){
5127
5162
  window.addEventListener('pagehide',()=>{stopped=true;clearTimeout(timer);setPlanningVisible(false);setTyping(false);});
5128
5163
  window.addEventListener('pageshow',()=>{stopped=false;if(panel.open) void refresh();});
5129
5164
  }
5165
+ function installDeviceApprovals(sync){
5166
+ if(!sync.config?.interfaceCapabilities?.deviceAuthorization) return;
5167
+ const trigger=document.getElementById('btn-device-approvals'), panel=document.getElementById('device-approvals');
5168
+ const status=document.getElementById('device-approvals-status'), list=document.getElementById('device-approval-list');
5169
+ trigger.hidden=false;
5170
+ let running=false, deciding=false, stopped=false, refreshFailed=false, renderedRequests=null;
5171
+ const projectId=sync.config.root.slice('cloud:'.length);
5172
+ const errorLabel=(error,decision=false)=>t(error?.code==='UNAUTHORIZED'?'deviceLoginRequired':error?.reason==='repository-access-revoked'?'deviceAccessRevoked':error?.code==='FORBIDDEN'?'deviceTicketRefresh':error?.code==='NOT_FOUND'?'deviceScopeUnavailable':error?.code==='CONFLICT'?'deviceAlreadyDecided':decision&&!error?.serverResponse?'deviceDecisionUnknown':'deviceApprovalsFailed');
5173
+ const refresh=async()=>{
5174
+ if(running||deciding||stopped)return;
5175
+ const requestedDetailed=panel.open;
5176
+ let fullReadNeeded=false;
5177
+ running=true;
5178
+ try{
5179
+ const data=await sync.call('/api/device-authorizations'+(requestedDetailed?'':'?summary=1'),undefined,'GET','main');
5180
+ if(!Number.isSafeInteger(data.count)||data.count<0||data.count>1000||(requestedDetailed&&!Array.isArray(data.requests)))throw Object.assign(new Error('invalid-request-list'),{code:'UNAVAILABLE',serverResponse:true});
5181
+ if(refreshFailed){status.textContent='';refreshFailed=false;}
5182
+ document.getElementById('device-approval-count').textContent=String(data.count);
5183
+ trigger.title=t('deviceApprovals');
5184
+ if(panel.open&&requestedDetailed){
5185
+ const signature=JSON.stringify(data.requests);
5186
+ if(signature===renderedRequests){for(const button of list.querySelectorAll('button'))button.disabled=false;return;}
5187
+ renderedRequests=signature;
5188
+ list.replaceChildren();
5189
+ for(const request of data.requests||[]){
5190
+ const row=document.createElement('article'), heading=document.createElement('b'), detail=document.createElement('p');
5191
+ heading.textContent=request.label;
5192
+ detail.textContent=[request.repository,t('devicePending'),request.createdAt||t('deviceOld')].join(' · ');
5193
+ row.append(heading,detail);
5194
+ for(const [decision,label] of [['approve','deviceAllow'],['deny','deviceDeny']]){
5195
+ const button=document.createElement('button');button.type='button';button.textContent=t(label);
5196
+ button.onclick=async()=>{
5197
+ if(deciding)return;
5198
+ deciding=true;for(const control of list.querySelectorAll('button'))control.disabled=true;
5199
+ try{
5200
+ const ticket=await sync.call('/api/device-authorizations/'+encodeURIComponent(request.requestId),undefined,'GET','main');
5201
+ if(ticket.status!=='pending'){status.textContent=t('deviceDecisionDone');return;}
5202
+ const response=await fetch('/auth/device/decision',{method:'POST',credentials:'same-origin',body:new URLSearchParams({projectId,userCode:ticket.userCode,csrf:ticket.csrf,decision}),signal:AbortSignal.timeout(10000)});
5203
+ if(!response.ok){const error=await response.json().catch(()=>({}));throw Object.assign(new Error('decision-rejected'),{code:error.error?.code,reason:error.error?.reason,serverResponse:true});}
5204
+ status.textContent=t('deviceDecisionDone');
5205
+ }catch(error){status.textContent=errorLabel(error,true);}
5206
+ finally{deciding=false;await refresh();}
5207
+ };
5208
+ row.append(button);
5209
+ }
5210
+ list.append(row);
5211
+ }
5212
+ if(!data.count)list.textContent=t('deviceApprovalsEmpty');
5213
+ }else if(panel.open)fullReadNeeded=true;
5214
+ }catch(error){refreshFailed=true;for(const button of list.querySelectorAll('button'))button.disabled=true;document.getElementById('device-approval-count').textContent='?';trigger.title=errorLabel(error);if(panel.open)status.textContent=errorLabel(error);}
5215
+ finally{running=false;if(fullReadNeeded&&panel.open&&!stopped&&!deciding)void refresh();}
5216
+ };
5217
+ trigger.onclick=()=>{closeTray();document.getElementById('workbench-tools').open=false;status.textContent='';panel.showModal();trigger.setAttribute('aria-expanded','true');document.getElementById('device-approvals-close').focus();void refresh();};
5218
+ document.getElementById('device-approvals-close').onclick=()=>panel.close();
5219
+ document.getElementById('device-approvals-refresh').onclick=()=>void refresh();
5220
+ panel.addEventListener('close',()=>{trigger.setAttribute('aria-expanded','false');document.querySelector('#workbench-tools > summary').focus();});
5221
+ panel.addEventListener('click',event=>{if(event.target!==panel)return;const rect=panel.getBoundingClientRect();if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)panel.close();});
5222
+ window.addEventListener('pagehide',()=>{stopped=true;});
5223
+ window.addEventListener('pageshow',()=>{stopped=false;void refresh();});
5224
+ document.getElementById('workbench-tools').addEventListener('toggle',()=>{if(document.getElementById('workbench-tools').open)void refresh();});
5225
+ refreshDeviceApprovals=refresh;
5226
+ void refresh();
5227
+ }
5130
5228
  async function boot(){
5131
5229
  const stored = readStoredUiLang();
5132
5230
  if(stored) uiLang = stored;
@@ -5150,9 +5248,11 @@ async function boot(){
5150
5248
  pending:()=>{ for(const id of ['nodes','links','currents']) document.getElementById(id)?.replaceChildren(); },
5151
5249
  apply:doc=>{ applyingServerMap=true; try{ applyMapDoc(doc); renderAll(); }finally{ applyingServerMap=false; } },
5152
5250
  setAccess:setWorkbenchAccess,
5153
- statusChanged:()=>renderAll()
5251
+ statusChanged:()=>renderAll(),
5252
+ refreshDeviceApprovals:()=>refreshDeviceApprovals?.()
5154
5253
  });
5155
5254
  const connected=await workbenchSync.start();
5255
+ installDeviceApprovals(workbenchSync);
5156
5256
  if(connected) installCoordinatorPanel(workbenchSync);
5157
5257
  if(!connected){
5158
5258
  if(!window.__CG_SERVER) await loadMapFromHttp();
@@ -459,6 +459,7 @@ export class WorkbenchSync {
459
459
  else if (head.version !== this.version) await this.receive(head);
460
460
  else if (this.status === 'offline') await this.retry();
461
461
  await this.refreshTaskStatuses().catch(() => {});
462
+ void Promise.resolve().then(()=>this.a.refreshDeviceApprovals?.()).catch(()=>{});
462
463
  } catch { if (view === this.viewId && !this.disposed) await this.recoverConnection(); }
463
464
  finally { this.heartbeatRunning = false; this.scheduleHeartbeat(); }
464
465
  }, 10000);
@@ -550,11 +550,13 @@ body.auth-mode .node .add-child,body.auth-mode .node .add-pick{display:none}
550
550
  /* ---------- 已取消托盘 ---------- */
551
551
  .tray{position:fixed;left:16px;top:calc(var(--chrome-top) + 8px);z-index:29;width:330px;background:var(--paper);
552
552
  border:1px solid var(--line);border-radius:10px;box-shadow:0 6px 24px rgba(42,47,43,.14);
553
- padding:14px 16px;display:none}
554
- .tray.open{display:block}
553
+ padding:14px 16px;display:none;box-sizing:border-box;max-width:calc(100vw - 32px);max-height:calc(100dvh - var(--chrome-top) - 16px)}
554
+ .tray.open{display:flex;flex-direction:column}
555
+ .tray-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-shrink:0}
556
+ .tray-head button{width:36px;height:36px;flex-shrink:0;padding:0;font-size:24px;line-height:1;box-shadow:none}
555
557
  .tray h3{margin:0 0 3px;font-size:14px}
556
- .tray p.hint{margin:0 0 10px;font-size:12px;color:var(--muted)}
557
- .tray ul{list-style:none;margin:0;padding:0}
558
+ .tray p.hint{margin:0 0 10px;font-size:12px;color:var(--muted);flex-shrink:0}
559
+ .tray ul{list-style:none;margin:0;padding:0;min-height:0;overflow:auto}
558
560
  .tray li{padding:9px 0;border-top:1px solid var(--line)}
559
561
  .tray li:first-child{border-top:0}
560
562
  .tray .t-title{font-size:13px;color:var(--muted);text-decoration:line-through}
@@ -563,6 +565,13 @@ body.auth-mode .node .add-child,body.auth-mode .node .add-pick{display:none}
563
565
  .tray .t-actions .danger:hover{border-color:var(--red);color:var(--red)}
564
566
  .tray .confirm-row{display:flex;align-items:center;gap:6px;margin-top:6px;font-size:12.5px;color:var(--red)}
565
567
  .tray .empty{color:var(--muted);font-size:12.5px;padding:6px 0}
568
+ .device-approvals{width:min(520px,calc(100vw - 24px));max-height:calc(100dvh - 24px);box-sizing:border-box;border:2px solid var(--line-ink);border-radius:10px;background:var(--paper);color:var(--ink)}
569
+ .device-approvals[open]{display:flex;flex-direction:column;gap:10px}
570
+ .device-approvals::backdrop{background:rgba(42,47,43,.25)}
571
+ #device-approval-list{min-height:0;overflow:auto;overflow-wrap:anywhere}
572
+ #device-approval-list article{border-top:1px solid var(--line);padding:10px 0}
573
+ #device-approval-list button{margin:4px 8px 0 0}
574
+ #device-approvals-refresh{flex-shrink:0}
566
575
 
567
576
  /* ---------- 未修 Bug 面板 ---------- */
568
577
  #btn-bugs.on,#btn-todos.on,#btn-coordinator[aria-expanded="true"]{background:var(--yellow);font-weight:800}
@@ -896,7 +905,7 @@ html.cg-phone .bug-panel{
896
905
  padding-bottom:calc(16px + var(--safe-bottom));
897
906
  }
898
907
  html.cg-phone .tray{
899
- left:8px;right:8px;width:auto;max-height:min(52vh,420px);overflow:auto;
908
+ left:8px;right:8px;width:auto;max-width:calc(100vw - 16px);max-height:min(52vh,420px,calc(100dvh - var(--chrome-top) - 16px));
900
909
  z-index:32
901
910
  }
902
911
  html.cg-phone .node.module{width:min(252px,86vw);max-width:min(252px,86vw)}
@@ -84,6 +84,7 @@
84
84
  <button class="auth-btn" id="btn-auth" data-i18n-title="authMode" title="授权模式" aria-label="授权模式">🔑</button>
85
85
  <button class="tray-btn" id="btn-bugs">🐞 Bug (<span id="bug-count">0</span>)</button>
86
86
  <button class="tray-btn" id="btn-todos">TODO (<span id="todo-count">0</span>)</button>
87
+ <button type="button" class="tray-btn" id="btn-device-approvals" aria-expanded="false" aria-controls="device-approvals" hidden><span data-i18n="deviceApprovals">设备连接申请</span> (<span id="device-approval-count">0</span>)</button>
87
88
  </div>
88
89
  </details>
89
90
  <button type="button" class="tray-btn" id="btn-coordinator" aria-expanded="false" hidden>Coordinator</button>
@@ -110,7 +111,7 @@
110
111
  <div class="rel-toggle" id="lens-toggle" data-i18n-title="lensTitle" title="首次建图:候选模块摆在画布上,与主节点无连线;选中一个,它才挂到主节点下。">
111
112
  <button type="button" id="btn-lens" aria-pressed="false" data-i18n="lensMode">第一层候选</button>
112
113
  </div>
113
- <button class="tray-btn" id="btn-tray">🗂 <span data-i18n="cancelled">已取消</span> (<span id="tray-count">0</span>)</button>
114
+ <button type="button" class="tray-btn" id="btn-tray" aria-expanded="false" aria-controls="tray">🗂 <span data-i18n="cancelled">已取消</span> (<span id="tray-count">0</span>)</button>
114
115
  <button type="button" class="tray-btn" id="btn-link-repo" hidden data-i18n="linkRepo" data-i18n-title="linkRepoTitle" title="选带 SKILL.md 的仓库根,改图可以写回 map.json">连接仓库</button>
115
116
  </div>
116
117
  </div>
@@ -190,9 +191,19 @@
190
191
  </div>
191
192
  </div>
192
193
  <div id="dot-hint" hidden role="tooltip"></div>
194
+ <dialog id="device-approvals" class="device-approvals" aria-labelledby="device-approvals-title">
195
+ <div class="tray-head"><h3 id="device-approvals-title" data-i18n="deviceApprovals">设备连接申请</h3><button type="button" id="device-approvals-close" data-i18n-title="deviceApprovalsClose" aria-label="关闭设备连接申请">×</button></div>
196
+ <p data-i18n="deviceApprovalScope">仅允许你发起的连接:读取本项目 Main、读写自身 Session;不授予管理或 Main 发布权限。</p>
197
+ <p role="status" id="device-approvals-status"></p>
198
+ <div id="device-approval-list"></div>
199
+ <button type="button" id="device-approvals-refresh" data-i18n="deviceApprovalsRefresh">刷新申请</button>
200
+ </dialog>
193
201
 
194
- <div class="tray" id="tray">
195
- <h3 data-i18n="trayTitle">已取消的提议</h3>
202
+ <div class="tray" id="tray" role="region" aria-labelledby="tray-title">
203
+ <div class="tray-head">
204
+ <h3 id="tray-title" data-i18n="trayTitle">已取消的提议</h3>
205
+ <button type="button" id="tray-close" data-i18n-title="trayClose" aria-label="关闭已取消的提议" title="关闭已取消的提议">×</button>
206
+ </div>
196
207
  <p class="hint" data-i18n="trayHint">已隐藏,后续 Agent 不会读到。可重新加入 Context Map,或永久删除。</p>
197
208
  <ul id="tray-list"></ul>
198
209
  </div>
@@ -11,6 +11,21 @@ Use `context-guard workbench connect --url <origin> --root <project> --session
11
11
  connection. The workbench owns the background connection; never start a separate
12
12
  project-wide Map daemon or point a Session at Main to bypass publication.
13
13
 
14
+ Current Cloud keeps pending browser-device approval requests until a human decides.
15
+ The CLI negotiates this explicitly and reuses the same private request after a
16
+ stopped invocation or a later retry; the displayed code does not expire merely
17
+ because ten minutes passed. Each HTTP request still has a 15-second limit. Stop
18
+ the CLI to stop waiting; run the same connection command to resume. An approved
19
+ request has a separate, finite claim window, and older Clouds retain their actual
20
+ finite expiry. Upgrade an older Skill for persistent waiting.
21
+
22
+ An uncertain claim never automatically creates another request or issues another
23
+ credential. It preserves the request and reports failure. After a definite
24
+ rejection or already-claimed receipt, a later explicit connection command starts
25
+ fresh browser approval; it cannot bypass the human decision. None of this changes
26
+ Session identity, existing device permissions, credential renewal or native Hook
27
+ trust.
28
+
14
29
  Local edits enter a durable outbox. Cloud changes are read through events with
15
30
  heartbeat recovery. Receipts, queues and cursors remain isolated per Session:
16
31
 
@@ -75,10 +75,18 @@ Connect once using `context-guard workbench connect --root <project> --url
75
75
  URL and code; the human signs in to Cloud and confirms the project/device in the
76
76
  browser. The waiting backend saves the device credential without exposing it or
77
77
  the password to the Agent. Without `--wait`, the command returns the link at once;
78
- rerun the same command after approval to finish. Requests expire after ten minutes.
79
- The backend counts the returned `expiresIn` seconds locally; Cloud's absolute
80
- `expiresAt` must not require the computer and server clocks to agree.
81
- Rejection/expiry requires a new request. A claimed reply lost before it was saved
78
+ rerun the same command after approval to finish. Pending requests remain until a
79
+ human allows or denies them. The authenticated project's Workbench Tools exposes
80
+ its device connection requests; this is not the host's tool-execution approval.
81
+ New clients negotiate `X-Context-Guard-Device-Grant: persistent-v1` and receive
82
+ `persistent:true`, `expiresAt:null`, `expiresIn:null` while pending. Older clients
83
+ receive a finite waiting budget and may stop after ten minutes; that does not
84
+ expire or remove the server request. Upgrade the client for persistent waiting.
85
+ An approved request still has a finite one-time claim window. Decision form
86
+ tickets, browser login cookies and issued device credentials retain their own
87
+ expiry and authorization rules. Finite client budgets are counted locally;
88
+ Cloud's absolute `expiresAt` must not require the computer and server clocks to agree.
89
+ Rejection/claim expiry requires a new request. A claimed reply lost before it was saved
82
90
  also requires new authorization; consumed grants are never replayed. This is a
83
91
  browser device-pairing flow, not a claim of full OAuth interoperability.
84
92
  Explicit `--input <private-file|->` remains a compatibility login; JSON contains
@@ -5,11 +5,21 @@ import { performance } from 'node:perf_hooks';
5
5
  import { atomicWrite, encode, readJSON, withFileLock, pause } from '../shared/io.mjs';
6
6
  import { fail } from '../shared/protocol.mjs';
7
7
 
8
+ const terminalReasons = new Set(['authorization-already-claimed', 'authorization-denied', 'authorization-expired', 'repository-access-revoked']);
9
+ function lifetime(data) {
10
+ if (data.persistent === true && data.status === 'pending' && data.expiresAt === null && data.expiresIn === null) return { persistent: true, expiresAt: null };
11
+ if (data.persistent !== undefined && data.persistent !== false || data.persistent === false && data.status !== 'approved' ||
12
+ typeof data.expiresAt !== 'string' || !Number.isFinite(Date.parse(data.expiresAt)) || !Number.isInteger(data.expiresIn) || data.expiresIn <= 0 || data.expiresIn > 600) {
13
+ fail('UNAVAILABLE', 'Cloud returned an invalid authorization lifetime');
14
+ }
15
+ return { persistent: false, expiresAt: new Date(Date.now() + data.expiresIn * 1000).toISOString() };
16
+ }
17
+
8
18
  async function request(origin, route, input, fetcher, timeoutMs = 15000) {
9
19
  let response;
10
20
  try {
11
21
  response = await fetcher(new URL(route, origin), { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
12
- headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) });
22
+ headers: { 'Content-Type': 'application/json', ...(route.endsWith('/start') ? { 'X-Context-Guard-Device-Grant': 'persistent-v1' } : {}) }, body: JSON.stringify(input) });
13
23
  } catch (error) {
14
24
  const beforeConnection = ['ENOTFOUND', 'ECONNREFUSED', 'UND_ERR_CONNECT_TIMEOUT'].includes(error?.cause?.code || error?.code);
15
25
  fail('UNAVAILABLE', route.endsWith('/poll') && !beforeConnection ? 'Authorization claim outcome is unknown; explicitly retry browser authorization' : 'Cloud authorization is unavailable; retry the same connection command',
@@ -24,7 +34,8 @@ async function request(origin, route, input, fetcher, timeoutMs = 15000) {
24
34
  result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
25
35
  } catch (error) { if (error.code === 'TOO_LARGE') throw error; fail('UNAVAILABLE', 'Cloud authorization reply was interrupted'); }
26
36
  if (!response.ok || result?.ok !== true) fail(result?.error?.code || 'UNAVAILABLE', result?.error?.message || 'Cloud authorization failed',
27
- { retryableConnection: [502, 503, 504].includes(response.status) && result?.error?.code === 'UNAVAILABLE' && result.error.retryable === true });
37
+ { retryableConnection: [502, 503, 504].includes(response.status) && result?.error?.code === 'UNAVAILABLE' && result.error.retryable === true,
38
+ ...(terminalReasons.has(result?.error?.reason) ? { reason: result.error.reason } : {}) });
28
39
  if (!result.data || typeof result.data !== 'object') fail('UNAVAILABLE', 'Cloud returned an invalid authorization reply');
29
40
  return { data: result.data, credential: response.headers.get('x-context-guard-credential') };
30
41
  }
@@ -39,6 +50,15 @@ export async function browserLogin(device, { repository, repositoryId, wait = fa
39
50
  const checkDeadline = () => {
40
51
  if (wait && clock() >= deadline) fail('UNAUTHORIZED', 'Browser authorization expired; retry the connection command', { reason: 'authorization-wait-expired' });
41
52
  };
53
+ const recordFailure = async (pending, error, phase) => {
54
+ // Keep the exact request after uncertain claims. Only a later explicit
55
+ // invocation may replace a definitively rejected request for fresh approval.
56
+ const terminal = ['UNAUTHORIZED', 'FORBIDDEN'].includes(error.code) && error.details?.reason !== 'authorization-wait-expired' &&
57
+ (phase === 'poll' || terminalReasons.has(error.details?.reason));
58
+ await atomicWrite(file, encode({ ...pending, ...(terminal ? { status: error.code } : {}),
59
+ lastFailure: { code: ['UNAUTHORIZED', 'FORBIDDEN', 'UNAVAILABLE', 'UPGRADE_REQUIRED', 'TOO_LARGE'].includes(error.code) ? error.code : 'UNAVAILABLE',
60
+ ...(terminalReasons.has(error.details?.reason) ? { reason: error.details.reason } : {}), at: new Date().toISOString() } }));
61
+ };
42
62
  const authorize = async (route, input) => {
43
63
  for (let attempt = 0; attempt < 3; attempt++) {
44
64
  checkDeadline();
@@ -72,36 +92,45 @@ export async function browserLogin(device, { repository, repositoryId, wait = fa
72
92
  }
73
93
  let pending = await readJSON(file, null);
74
94
  checkDeadline();
75
- if (grant && ['deviceCode', 'verificationUrl', 'userCode', 'expiresAt', 'origin', 'repository', 'repositoryId'].some(key => pending?.[key] !== grant[key])) fail('CONFLICT', 'Browser authorization changed; retry the connection command');
76
- if (!pending?.deviceCode || pending.origin !== device.origin || pending.repository !== repository || pending.repositoryId !== repositoryId || !Number.isFinite(Date.parse(pending.expiresAt)) || !grant && Date.parse(pending.expiresAt) <= Date.now()) {
95
+ if (grant && ['deviceCode', 'verificationUrl', 'userCode', 'persistent', 'expiresAt', 'origin', 'repository', 'repositoryId'].some(key => pending?.[key] !== grant[key])) fail('CONFLICT', 'Browser authorization changed; retry the connection command');
96
+ if (!pending?.deviceCode || pending.origin !== device.origin || pending.repository !== repository || pending.repositoryId !== repositoryId || !grant && ['UNAUTHORIZED', 'FORBIDDEN'].includes(pending.status)) {
97
+ const previousOutcome = pending?.lastFailure;
77
98
  pending = { origin: device.origin, repository, repositoryId, deviceCode: randomBytes(32).toString('base64url'), label: String(label).slice(0, 80),
78
- expiresAt: new Date(Date.now() + 600000).toISOString() };
99
+ expiresAt: new Date(Date.now() + 600000).toISOString(), ...(previousOutcome ? { previousOutcome } : {}) };
79
100
  await atomicWrite(file, encode(pending));
80
101
  }
81
- if (!pending.verificationUrl) {
102
+ if (pending.verificationUrl && !(pending.persistent === true && pending.expiresAt === null) &&
103
+ (pending.persistent !== undefined && pending.persistent !== false || !Number.isFinite(Date.parse(pending.expiresAt)))) fail('UNAVAILABLE', 'Stored authorization has an invalid lifetime');
104
+ if (!pending.verificationUrl || !grant && pending.persistent !== true && Date.parse(pending.expiresAt) <= Date.now()) {
82
105
  const identity = await device.identity();
83
- const { data } = await authorize('/api/auth/device/start', { repository, clientId: identity.clientId, deviceCode: pending.deviceCode, label: pending.label });
106
+ let data;
107
+ try { ({ data } = await authorize('/api/auth/device/start', { repository, clientId: identity.clientId, deviceCode: pending.deviceCode, label: pending.label })); }
108
+ catch (error) { await recordFailure(pending, error, 'start'); throw error; }
84
109
  const verification = new URL(data.verificationPath, device.origin);
85
- if (verification.origin !== device.origin || verification.pathname !== '/connect' || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(data.userCode) || verification.searchParams.get('code') !== data.userCode || !Number.isFinite(Date.parse(data.expiresAt)) || !Number.isInteger(data.expiresIn) || data.expiresIn <= 0 || data.expiresIn > 600) fail('UNAVAILABLE', 'Cloud returned an invalid authorization link');
86
- pending = { ...pending, verificationUrl: verification.href, userCode: data.userCode, expiresAt: new Date(Date.now() + data.expiresIn * 1000).toISOString() };
110
+ if (verification.origin !== device.origin || verification.pathname !== '/connect' || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(data.userCode) || verification.searchParams.get('code') !== data.userCode) fail('UNAVAILABLE', 'Cloud returned an invalid authorization link');
111
+ pending = { ...pending, verificationUrl: verification.href, userCode: data.userCode, ...lifetime(data) };
87
112
  await atomicWrite(file, encode(pending));
88
113
  }
89
- const safe = { connected: false, authorizationRequired: true, verificationUrl: pending.verificationUrl, userCode: pending.userCode, expiresAt: pending.expiresAt };
114
+ const safe = { connected: false, authorizationRequired: true, verificationUrl: pending.verificationUrl, userCode: pending.userCode,
115
+ persistent: pending.persistent === true, expiresAt: pending.expiresAt };
90
116
  if (wait && !grant) {
91
- // A displayed grant and this invocation's budget never renew in a wait loop.
92
- deadline = Math.min(deadline, clock() + Math.max(0, Date.parse(pending.expiresAt) - Date.now()));
117
+ // Only an explicitly negotiated pending grant has no approval deadline.
118
+ // Finite grants keep the original monotonic invocation budget.
119
+ deadline = pending.persistent === true ? Infinity : Math.min(deadline, clock() + Math.max(0, Date.parse(pending.expiresAt) - Date.now()));
93
120
  grant = { ...pending };
94
121
  }
95
122
  checkDeadline();
96
123
  if (first) await onPending(safe);
97
124
  let result;
98
- try { result = await authorize('/api/auth/device/poll', { deviceCode: pending.deviceCode }); }
125
+ try {
126
+ result = await authorize('/api/auth/device/poll', { deviceCode: pending.deviceCode });
127
+ if (result.data.status === 'pending') return safe;
128
+ if (!result.credential || result.credential.length < 32 || !result.data.projectId || !result.data.capabilities?.includes('device-memory')) fail('UNAVAILABLE', 'Cloud did not issue a project device connection');
129
+ }
99
130
  catch (error) {
100
- if (['UNAUTHORIZED', 'FORBIDDEN'].includes(error.code) && error.details?.reason !== 'authorization-wait-expired') await atomicWrite(file, encode({ status: error.code }));
131
+ await recordFailure(pending, error, 'poll');
101
132
  throw error;
102
133
  }
103
- if (result.data.status === 'pending') return safe;
104
- if (!result.credential || result.credential.length < 32 || !result.data.projectId || !result.data.capabilities?.includes('device-memory')) fail('UNAVAILABLE', 'Cloud did not issue a project device connection');
105
134
  await device.saveConnection(result.data, result.credential, { repositoryId });
106
135
  await atomicWrite(file, encode({ status: 'connected' }));
107
136
  return { connected: true, ...result.data };