@michelj/context-guard 0.6.3 → 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 +3 -3
- package/prototype/workbench-app.js +104 -4
- package/prototype/workbench-sync.mjs +12 -2
- package/prototype/workbench.css +14 -5
- package/prototype/workbench.html +14 -3
- package/references/cloud-sync-interface.md +22 -0
- package/references/server-memory.md +12 -4
- package/scripts/shared/protocol-store.mjs +2 -1
- package/scripts/workbench/browser-login.mjs +64 -23
- package/scripts/workbench/claude-runtime.mjs +73 -53
- package/scripts/workbench/listen.mjs +5 -0
- package/scripts/workbench/named-proxy.mjs +11 -2
- package/scripts/workbench/project.mjs +20 -3
- package/scripts/workbench/protocol-device.mjs +34 -6
- package/scripts/workbench/protocol-repository.mjs +28 -10
- package/scripts/workbench/registry.mjs +6 -0
- package/scripts/workbench/server.mjs +41 -4
- package/scripts/workbench/sync-coordinator.mjs +10 -0
- package/scripts/workbench/sync.mjs +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@michelj/context-guard",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
60
|
-
"@michelj/context-guard-workbench": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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();
|
|
@@ -117,6 +117,8 @@ export class WorkbenchSync {
|
|
|
117
117
|
this.urlPinned = Boolean(requestedSession) && !this.config?.root?.startsWith('cloud:');
|
|
118
118
|
this.panel = document.createElement('details'); this.panel.id = 'cg-sync'; this.panel.className = 'set-block sync-settings';
|
|
119
119
|
this.panel.innerHTML = '<summary>同步与恢复</summary><p id="cg-sync-status"></p><span id="cg-sync-version" hidden></span><div class="sync-actions"><button id="cg-sync-initialize" hidden>将当前图设为真实地图</button><button id="cg-sync-retry">重试</button><button id="cg-sync-export">导出草稿/旧缓存</button><button id="cg-sync-import">导入并比较</button><button id="cg-sync-reload">保留草稿后读取磁盘</button></div><label>Agent 会话<select id="cg-sync-session"></select></label><input id="cg-sync-file" type="file" accept="application/json" hidden>';
|
|
120
|
+
this.cloudBindingNotice = document.createElement('p'); this.cloudBindingNotice.hidden = true;
|
|
121
|
+
this.cloudBindingNotice.setAttribute('role', 'status'); this.panel.querySelector('#cg-sync-status').after(this.cloudBindingNotice);
|
|
120
122
|
this.repairButton = document.createElement('button'); this.repairButton.id = 'cg-sync-repair'; this.repairButton.hidden = true;
|
|
121
123
|
this.panel.querySelector('.sync-actions').append(this.repairButton);
|
|
122
124
|
if (this.config?.interfaceCapabilities?.deviceLogin && !this.config.root?.startsWith('cloud:')) {
|
|
@@ -457,6 +459,7 @@ export class WorkbenchSync {
|
|
|
457
459
|
else if (head.version !== this.version) await this.receive(head);
|
|
458
460
|
else if (this.status === 'offline') await this.retry();
|
|
459
461
|
await this.refreshTaskStatuses().catch(() => {});
|
|
462
|
+
void Promise.resolve().then(()=>this.a.refreshDeviceApprovals?.()).catch(()=>{});
|
|
460
463
|
} catch { if (view === this.viewId && !this.disposed) await this.recoverConnection(); }
|
|
461
464
|
finally { this.heartbeatRunning = false; this.scheduleHeartbeat(); }
|
|
462
465
|
}, 10000);
|
|
@@ -764,12 +767,19 @@ export class WorkbenchSync {
|
|
|
764
767
|
this.a.setAccess(active && !unavailableMeta ? this.grants?.[this.activeSession]?.nodes || [] : [], this.activeSession, active, this.activeSession === ALL_SESSIONS, this.project?.main || null);
|
|
765
768
|
}
|
|
766
769
|
renderCloudStatus(status) {
|
|
770
|
+
const bindingLabel = status?.configured && status.status === 'conflict'
|
|
771
|
+
? status.reason === 'session-bound-elsewhere' ? '当前会话绑定旧设备,请新建会话'
|
|
772
|
+
: status.reason === 'binding-conflict' ? '会话绑定冲突,请新建会话' : ''
|
|
773
|
+
: '';
|
|
774
|
+
if (this.cloudBindingNotice) {
|
|
775
|
+
this.cloudBindingNotice.hidden = !bindingLabel; this.cloudBindingNotice.textContent = bindingLabel;
|
|
776
|
+
}
|
|
767
777
|
if (!this.cloudIndicator) return;
|
|
768
778
|
if (!status?.configured) { this.cloudIndicator.hidden = true; return; }
|
|
769
779
|
const value = status.status === 'synced' ? 'synced' : ['conflict', 'error'].includes(status.status) ? status.status : 'syncing';
|
|
770
780
|
this.cloudIndicator.hidden = false; this.cloudIndicator.className = `cloud-sync-status ${value}`;
|
|
771
|
-
const label = value === 'synced' ? '云端已同步' : value === 'syncing' ? '云端同步中' : value === 'conflict' ? '云端同步冲突' : '云端同步失败';
|
|
772
|
-
this.cloudIndicator.setAttribute('aria-label', label); this.cloudIndicator.title = label;
|
|
781
|
+
const label = bindingLabel || (value === 'synced' ? '云端已同步' : value === 'syncing' ? '云端同步中' : value === 'conflict' ? '云端同步冲突' : '云端同步失败');
|
|
782
|
+
this.cloudIndicator.setAttribute('aria-label', label); this.cloudIndicator.title = bindingLabel ? `${label};旧记录与队列保持不变` : label;
|
|
773
783
|
}
|
|
774
784
|
async refreshCloudStatus() {
|
|
775
785
|
if (this.config?.root?.startsWith('cloud:')) { this.renderCloudStatus({ configured: true, status: this.pendingSession ? 'syncing' : 'synced' }); return; }
|
package/prototype/workbench.css
CHANGED
|
@@ -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:
|
|
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);
|
|
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)}
|
package/prototype/workbench.html
CHANGED
|
@@ -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
|
-
<
|
|
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
|
|
|
@@ -61,6 +76,13 @@ Network failure leaves current queues on disk. Reconnect retries with backoff;
|
|
|
61
76
|
conflicts require explicit reconciliation. Do not manufacture a new request ID,
|
|
62
77
|
erase private state or report success merely because a connection is alive.
|
|
63
78
|
|
|
79
|
+
A confirmed Session-binding rejection stops automatic binding retries. When a
|
|
80
|
+
Session belongs to another device, create a new actual conversation in the host
|
|
81
|
+
and connect using that host-issued Session ID. Do not take over the old Session,
|
|
82
|
+
alias/change its ID, discard its failed receipt or move its tasks/queues to the
|
|
83
|
+
new conversation. Older binding-conflict receipts cannot prove which device owns
|
|
84
|
+
the Session; they also require a new host conversation, not blind retries.
|
|
85
|
+
|
|
64
86
|
The private Session service uses authorized `/v1/projects/:project/sessions/`
|
|
65
87
|
reads, changes/events and map writes. Session generations and server authorization
|
|
66
88
|
remain enforced. Credentials never belong in Maps, logs or generated HTML.
|
|
@@ -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.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
@@ -465,7 +465,8 @@ export class ProtocolStore extends EventEmitter {
|
|
|
465
465
|
previous.sessionId = payload.sessionId;
|
|
466
466
|
return { session: { id: payload.sessionId, generation: previous.generation }, bindingVersion: previous.version };
|
|
467
467
|
}
|
|
468
|
-
if (previous &&
|
|
468
|
+
if (previous && previous.deviceId !== p.deviceId) fail('CONFLICT', 'Session is already bound to another device', { reason: 'session-bound-elsewhere' });
|
|
469
|
+
if (previous && (!options.allowMigration || payload.expectedBindingVersion !== previous.version)) fail('CONFLICT', 'Migration requires the owning device and current binding version', { currentVersion: previous.version });
|
|
469
470
|
if (!previous && payload.expectedBindingVersion) fail('CONFLICT', 'Binding does not exist', { currentVersion: '' });
|
|
470
471
|
const binding = { sessionId: payload.sessionId, deviceId: p.deviceId, agentId: payload.agentId, worktreeId: payload.worktreeId, generation: (previous?.generation || 0) + 1, version: randomUUID() };
|
|
471
472
|
for (const creation of Object.values(state.sessionCreations || {})) {
|
|
@@ -5,12 +5,26 @@ 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) });
|
|
13
|
-
} catch
|
|
22
|
+
headers: { 'Content-Type': 'application/json', ...(route.endsWith('/start') ? { 'X-Context-Guard-Device-Grant': 'persistent-v1' } : {}) }, body: JSON.stringify(input) });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const beforeConnection = ['ENOTFOUND', 'ECONNREFUSED', 'UND_ERR_CONNECT_TIMEOUT'].includes(error?.cause?.code || error?.code);
|
|
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',
|
|
26
|
+
{ retryableConnection: route.endsWith('/start') || beforeConnection });
|
|
27
|
+
}
|
|
14
28
|
if ([404, 405, 426].includes(response.status)) fail('UPGRADE_REQUIRED', 'Cloud does not support browser authorization; upgrade Cloud or use the explicit private-input login');
|
|
15
29
|
if (!response.headers.get('content-type')?.includes('application/json')) fail('UNAVAILABLE', 'Cloud returned an invalid authorization reply');
|
|
16
30
|
let result;
|
|
@@ -19,7 +33,9 @@ async function request(origin, route, input, fetcher, timeoutMs = 15000) {
|
|
|
19
33
|
for await (const chunk of response.body) { size += chunk.length; if (size > 16384) fail('TOO_LARGE', 'Authorization reply is too large'); chunks.push(chunk); }
|
|
20
34
|
result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
21
35
|
} catch (error) { if (error.code === 'TOO_LARGE') throw error; fail('UNAVAILABLE', 'Cloud authorization reply was interrupted'); }
|
|
22
|
-
if (!response.ok || result?.ok !== true) fail(result?.error?.code || 'UNAVAILABLE', result?.error?.message || 'Cloud authorization failed'
|
|
36
|
+
if (!response.ok || result?.ok !== true) fail(result?.error?.code || 'UNAVAILABLE', result?.error?.message || 'Cloud authorization failed',
|
|
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 } : {}) });
|
|
23
39
|
if (!result.data || typeof result.data !== 'object') fail('UNAVAILABLE', 'Cloud returned an invalid authorization reply');
|
|
24
40
|
return { data: result.data, credential: response.headers.get('x-context-guard-credential') };
|
|
25
41
|
}
|
|
@@ -32,15 +48,31 @@ export async function browserLogin(device, { repository, repositoryId, wait = fa
|
|
|
32
48
|
const file = path.join(device.directory, 'browser-login.json');
|
|
33
49
|
let deadline = clock() + 600000, grant;
|
|
34
50
|
const checkDeadline = () => {
|
|
35
|
-
if (wait && clock() >= deadline) fail('UNAUTHORIZED', 'Browser authorization expired; retry the connection command');
|
|
51
|
+
if (wait && clock() >= deadline) fail('UNAUTHORIZED', 'Browser authorization expired; retry the connection command', { reason: 'authorization-wait-expired' });
|
|
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() } }));
|
|
36
61
|
};
|
|
37
62
|
const authorize = async (route, input) => {
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const result = await request(device.origin, route, input, fetcher, wait ? Math.max(1, Math.min(15000, Math.ceil(deadline - clock()))) : 15000);
|
|
63
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
41
64
|
checkDeadline();
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
try {
|
|
66
|
+
const result = await request(device.origin, route, input, fetcher, wait ? Math.max(1, Math.min(15000, Math.ceil(deadline - clock()))) : 15000);
|
|
67
|
+
checkDeadline();
|
|
68
|
+
return result;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
checkDeadline();
|
|
71
|
+
if (attempt === 2 || error.code !== 'UNAVAILABLE' || error.details?.retryableConnection !== true) throw error;
|
|
72
|
+
await sleep(wait ? Math.min(250 * 2 ** attempt, Math.max(1, deadline - clock())) : 250 * 2 ** attempt);
|
|
73
|
+
checkDeadline();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
44
76
|
};
|
|
45
77
|
let first = true;
|
|
46
78
|
for (;;) {
|
|
@@ -60,36 +92,45 @@ export async function browserLogin(device, { repository, repositoryId, wait = fa
|
|
|
60
92
|
}
|
|
61
93
|
let pending = await readJSON(file, null);
|
|
62
94
|
checkDeadline();
|
|
63
|
-
if (grant && ['deviceCode', 'verificationUrl', 'userCode', 'expiresAt', 'origin', 'repository', 'repositoryId'].some(key => pending?.[key] !== grant[key])) fail('CONFLICT', 'Browser authorization changed; retry the connection command');
|
|
64
|
-
if (!pending?.deviceCode || pending.origin !== device.origin || pending.repository !== repository || pending.repositoryId !== repositoryId || !
|
|
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;
|
|
65
98
|
pending = { origin: device.origin, repository, repositoryId, deviceCode: randomBytes(32).toString('base64url'), label: String(label).slice(0, 80),
|
|
66
|
-
expiresAt: new Date(Date.now() + 600000).toISOString() };
|
|
99
|
+
expiresAt: new Date(Date.now() + 600000).toISOString(), ...(previousOutcome ? { previousOutcome } : {}) };
|
|
67
100
|
await atomicWrite(file, encode(pending));
|
|
68
101
|
}
|
|
69
|
-
if (!pending.
|
|
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()) {
|
|
70
105
|
const identity = await device.identity();
|
|
71
|
-
|
|
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; }
|
|
72
109
|
const verification = new URL(data.verificationPath, device.origin);
|
|
73
|
-
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
|
|
74
|
-
pending = { ...pending, verificationUrl: verification.href, userCode: data.userCode,
|
|
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) };
|
|
75
112
|
await atomicWrite(file, encode(pending));
|
|
76
113
|
}
|
|
77
|
-
const safe = { connected: false, authorizationRequired: true, verificationUrl: pending.verificationUrl, userCode: pending.userCode,
|
|
114
|
+
const safe = { connected: false, authorizationRequired: true, verificationUrl: pending.verificationUrl, userCode: pending.userCode,
|
|
115
|
+
persistent: pending.persistent === true, expiresAt: pending.expiresAt };
|
|
78
116
|
if (wait && !grant) {
|
|
79
|
-
//
|
|
80
|
-
|
|
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()));
|
|
81
120
|
grant = { ...pending };
|
|
82
121
|
}
|
|
83
122
|
checkDeadline();
|
|
84
123
|
if (first) await onPending(safe);
|
|
85
124
|
let result;
|
|
86
|
-
try {
|
|
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
|
+
}
|
|
87
130
|
catch (error) {
|
|
88
|
-
|
|
131
|
+
await recordFailure(pending, error, 'poll');
|
|
89
132
|
throw error;
|
|
90
133
|
}
|
|
91
|
-
if (result.data.status === 'pending') return safe;
|
|
92
|
-
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');
|
|
93
134
|
await device.saveConnection(result.data, result.credential, { repositoryId });
|
|
94
135
|
await atomicWrite(file, encode({ status: 'connected' }));
|
|
95
136
|
return { connected: true, ...result.data };
|
|
@@ -292,6 +292,68 @@ export class ClaudeRuntime {
|
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// Monitor an already-created native process. The worker calls this immediately
|
|
296
|
+
// after spawn; it does not wait for a model-specific readiness handshake.
|
|
297
|
+
export async function runClaudeTurn(child, { config, sessionId, message, logFile, onStarted, onFinished }) {
|
|
298
|
+
const exit = new Promise((resolve, reject) => { child.once('close', resolve); child.once('error', reject); });
|
|
299
|
+
exit.catch(() => {}); // A spawn failure may precede opening the private log.
|
|
300
|
+
let buffer = '', bytes = 0, result = null, initialized = false, stopReason = null;
|
|
301
|
+
const decoder = new StringDecoder('utf8');
|
|
302
|
+
const output = await fs.open(logFile, 'a', 0o600);
|
|
303
|
+
let writes = Promise.resolve();
|
|
304
|
+
const idleMs = config.timeoutMs || 600000;
|
|
305
|
+
let idleTimer, killTimer;
|
|
306
|
+
const terminate = (reason = 'CLAUDE_TIMEOUT_OR_INTERRUPTED') => {
|
|
307
|
+
if (stopReason) return;
|
|
308
|
+
stopReason = reason; child.kill('SIGTERM');
|
|
309
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
310
|
+
};
|
|
311
|
+
const active = () => { clearTimeout(idleTimer); idleTimer = setTimeout(terminate, idleMs); };
|
|
312
|
+
active();
|
|
313
|
+
// Activity keeps the idle timer alive, but cannot extend a native turn
|
|
314
|
+
// indefinitely. A stopped turn stays recoverable in the same Session.
|
|
315
|
+
const hardTimer = setTimeout(() => terminate('CLAUDE_TURN_LIMIT'), Math.max(idleMs, config.maxTurnMs || 1800000));
|
|
316
|
+
const interrupted = () => terminate();
|
|
317
|
+
const stdinError = () => {};
|
|
318
|
+
const receive = chunk => {
|
|
319
|
+
bytes += chunk.length;
|
|
320
|
+
if (bytes > (config.outputLimitBytes || 64 * 1024 * 1024)) { terminate('CLAUDE_OUTPUT_LIMIT'); return; }
|
|
321
|
+
active();
|
|
322
|
+
writes = writes.then(() => output.write(chunk));
|
|
323
|
+
buffer += decoder.write(chunk);
|
|
324
|
+
for (;;) {
|
|
325
|
+
const index = buffer.indexOf('\n'); if (index < 0) break;
|
|
326
|
+
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
327
|
+
try {
|
|
328
|
+
const event = JSON.parse(line);
|
|
329
|
+
if (event.session_id && event.session_id !== sessionId) { terminate('CLAUDE_SESSION_MISMATCH'); break; }
|
|
330
|
+
if (event.type === 'system' && event.subtype === 'init') initialized = true;
|
|
331
|
+
if (event.type === 'result') result = event;
|
|
332
|
+
} catch { /* Native diagnostic text is not a protocol receipt. */ }
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
process.once('SIGTERM', interrupted); process.once('SIGINT', interrupted);
|
|
336
|
+
child.stdout.on('data', receive);
|
|
337
|
+
child.stderr.on('data', active); // Activity only; never publish provider stderr or credentials.
|
|
338
|
+
child.stdin.on('error', stdinError);
|
|
339
|
+
try {
|
|
340
|
+
child.stdin.end(message);
|
|
341
|
+
await onStarted?.(); // Same point as the worker's original running update.
|
|
342
|
+
const code = await exit;
|
|
343
|
+
await writes; await output.sync();
|
|
344
|
+
const outcome = { state: stopReason || !result ? 'interrupted' : code === 0 && !result.is_error ? 'finished' : 'failed',
|
|
345
|
+
error: stopReason || (!result ? 'CLAUDE_NO_RESULT' : result.is_error ? 'CLAUDE_FAILED' : null), initialized };
|
|
346
|
+
await onFinished?.(outcome); // Preserve persistence before timer/log cleanup.
|
|
347
|
+
return outcome;
|
|
348
|
+
} finally {
|
|
349
|
+
clearTimeout(idleTimer); clearTimeout(hardTimer); clearTimeout(killTimer);
|
|
350
|
+
process.removeListener('SIGTERM', interrupted); process.removeListener('SIGINT', interrupted);
|
|
351
|
+
child.stdout.removeListener('data', receive); child.stderr.removeListener('data', active);
|
|
352
|
+
child.stdin.removeListener('error', stdinError);
|
|
353
|
+
await output.close();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
295
357
|
async function runWorker(file, jobFile) {
|
|
296
358
|
const session = await readJSON(file), config = session.config;
|
|
297
359
|
const job = await readJSON(jobFile);
|
|
@@ -303,66 +365,24 @@ async function runWorker(file, jobFile) {
|
|
|
303
365
|
// Written before spawning Claude. A crash after this point is uncertain, not
|
|
304
366
|
// permission to submit the same prompt again.
|
|
305
367
|
await update({ workerPid: process.pid, state: 'dispatching' });
|
|
306
|
-
let child, stopReason = null;
|
|
307
368
|
try {
|
|
308
369
|
const credentials = await readJSON(config.environmentFile);
|
|
309
370
|
if (Object.keys(credentials).some(key => !providerKeys.has(key)) || Object.values(credentials).some(value => typeof value !== 'string')) fail('INVALID_ENVIRONMENT', 'Only provider settings are accepted in the private environment file');
|
|
310
371
|
const env = await claudeEnvironment(config, credentials);
|
|
311
|
-
child = spawn(config.command, claudeArguments(config, { sessionId: session.sessionId, resume: job.resume }), {
|
|
372
|
+
const child = spawn(config.command, claudeArguments(config, { sessionId: session.sessionId, resume: job.resume }), {
|
|
312
373
|
cwd: config.root, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
313
374
|
});
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
stopReason = reason; child.kill('SIGTERM');
|
|
325
|
-
killTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
326
|
-
};
|
|
327
|
-
const active = () => { clearTimeout(idleTimer); idleTimer = setTimeout(terminate, idleMs); };
|
|
328
|
-
active();
|
|
329
|
-
// Activity keeps the idle timer alive, but cannot extend a native turn
|
|
330
|
-
// indefinitely. A stopped turn stays recoverable in the same Session.
|
|
331
|
-
const hardTimer = setTimeout(() => terminate('CLAUDE_TURN_LIMIT'), Math.max(idleMs, config.maxTurnMs || 1800000));
|
|
332
|
-
const interrupted = () => terminate();
|
|
333
|
-
process.once('SIGTERM', interrupted); process.once('SIGINT', interrupted);
|
|
334
|
-
child.stdout.on('data', chunk => {
|
|
335
|
-
bytes += chunk.length;
|
|
336
|
-
if (bytes > (config.outputLimitBytes || 64 * 1024 * 1024)) { terminate('CLAUDE_OUTPUT_LIMIT'); return; }
|
|
337
|
-
active();
|
|
338
|
-
writes = writes.then(() => output.write(chunk));
|
|
339
|
-
buffer += decoder.write(chunk);
|
|
340
|
-
for (;;) {
|
|
341
|
-
const index = buffer.indexOf('\n'); if (index < 0) break;
|
|
342
|
-
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
343
|
-
try {
|
|
344
|
-
const event = JSON.parse(line);
|
|
345
|
-
if (event.session_id && event.session_id !== session.sessionId) { terminate('CLAUDE_SESSION_MISMATCH'); break; }
|
|
346
|
-
if (event.type === 'system' && event.subtype === 'init') initialized = true;
|
|
347
|
-
if (event.type === 'result') result = event;
|
|
348
|
-
} catch { /* Native diagnostic text is not a protocol receipt. */ }
|
|
349
|
-
}
|
|
375
|
+
await runClaudeTurn(child, { config, sessionId: session.sessionId, message: job.message,
|
|
376
|
+
logFile: jobFile + '.jsonl', onStarted: () => update({ state: 'running', childPid: child.pid }),
|
|
377
|
+
onFinished: async ({ initialized, ...outcome }) => {
|
|
378
|
+
await update(outcome);
|
|
379
|
+
await withFileLock(file + '.lock', async () => {
|
|
380
|
+
const latest = await readJSON(file);
|
|
381
|
+
await atomicWrite(file, encode({ ...latest, initialized: latest.initialized || initialized,
|
|
382
|
+
...(['finished', 'failed'].includes(job.state) ? { active: null } : {}), updatedAt: job.updatedAt }));
|
|
383
|
+
});
|
|
384
|
+
},
|
|
350
385
|
});
|
|
351
|
-
child.stderr.on('data', active); // Activity only; never publish provider stderr or credentials.
|
|
352
|
-
child.stdin.on('error', () => {});
|
|
353
|
-
child.stdin.end(job.message);
|
|
354
|
-
try {
|
|
355
|
-
await update({ state: 'running', childPid: child.pid });
|
|
356
|
-
const code = await exit;
|
|
357
|
-
await writes; await output.sync();
|
|
358
|
-
await update({ state: stopReason || !result ? 'interrupted' : code === 0 && !result.is_error ? 'finished' : 'failed',
|
|
359
|
-
error: stopReason || (!result ? 'CLAUDE_NO_RESULT' : result.is_error ? 'CLAUDE_FAILED' : null) });
|
|
360
|
-
await withFileLock(file + '.lock', async () => {
|
|
361
|
-
const latest = await readJSON(file);
|
|
362
|
-
await atomicWrite(file, encode({ ...latest, initialized: latest.initialized || initialized,
|
|
363
|
-
...(['finished', 'failed'].includes(job.state) ? { active: null } : {}), updatedAt: job.updatedAt }));
|
|
364
|
-
});
|
|
365
|
-
} finally { clearTimeout(idleTimer); clearTimeout(hardTimer); clearTimeout(killTimer); await output.close(); }
|
|
366
386
|
} catch (error) { await update({ state: 'interrupted', error: String(error.code || 'CLAUDE_LAUNCH_FAILED').slice(0, 100) }); }
|
|
367
387
|
}
|
|
368
388
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function canRetryWorkbenchListen(error, port, attempt, platform = process.platform) {
|
|
2
|
+
if (port === 0 || attempt >= 20) return false;
|
|
3
|
+
return error?.code === 'EADDRINUSE' || platform === 'win32' && error?.code === 'EACCES'
|
|
4
|
+
&& error.syscall === 'listen' && error.address === '127.0.0.1' && error.port === port + attempt;
|
|
5
|
+
}
|
|
@@ -6,6 +6,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
6
6
|
import { atomicWrite, encode, readJSON } from '../shared/io.mjs';
|
|
7
7
|
import { RouteStore } from './portless-routes.mjs';
|
|
8
8
|
import { compatibleRuntime } from './runtime.mjs';
|
|
9
|
+
import { canRetryWorkbenchListen } from './listen.mjs';
|
|
9
10
|
import { DeviceHeartbeat } from './device-heartbeat.mjs';
|
|
10
11
|
|
|
11
12
|
const secret = () => randomBytes(32).toString('base64url');
|
|
@@ -81,8 +82,16 @@ export async function startNamedProxy({ dir, port = 1355 } = {}) {
|
|
|
81
82
|
server.on('upgrade', (_req, socket) => socket.destroy()); // Workbench uses SSE, not WebSockets.
|
|
82
83
|
server.requestTimeout = 15000; server.headersTimeout = 10000;
|
|
83
84
|
for (let attempt = 0; ; attempt++) {
|
|
84
|
-
try {
|
|
85
|
-
|
|
85
|
+
try {
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
88
|
+
const onError = error => { server.off('error', onError); server.off('listening', onListening); reject(error); };
|
|
89
|
+
server.once('error', onError);
|
|
90
|
+
try { server.listen(port ? port + attempt : 0, '127.0.0.1', onListening); }
|
|
91
|
+
catch (error) { onError(error); }
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
} catch (error) { if (!canRetryWorkbenchListen(error, port, attempt)) throw error; }
|
|
86
95
|
}
|
|
87
96
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
88
97
|
const state = { version: 1, runtimeSchema: PROXY_RUNTIME_SCHEMA, instance, pid: process.pid, base, adminToken };
|
|
@@ -89,14 +89,31 @@ async function physicalGitDirectories(root) {
|
|
|
89
89
|
return { topLevel, commonDir };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
async function commitIds(root, mainRef) {
|
|
93
|
+
if (!mainRef) return [await git(root, ['rev-parse', 'HEAD'], { optional: true }), ''];
|
|
94
|
+
try {
|
|
95
|
+
const output = await git(root, ['rev-parse', '--revs-only', '--end-of-options', 'HEAD', `${mainRef}^{commit}`]);
|
|
96
|
+
const records = output.split(/\r?\n/);
|
|
97
|
+
if (records.length === 2 && records.every(value => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value))
|
|
98
|
+
&& records[0].length === records[1].length) return records;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!optionalGitMiss(error)) throw error;
|
|
101
|
+
}
|
|
102
|
+
// Missing refs, older options and ambiguous output retain the independent
|
|
103
|
+
// fresh reads; process/system failures must not silently enter this fallback.
|
|
104
|
+
return Promise.all([
|
|
105
|
+
git(root, ['rev-parse', 'HEAD'], { optional: true }),
|
|
106
|
+
git(root, ['rev-parse', '--verify', '--quiet', `${mainRef}^{commit}`], { optional: true }),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
async function worktreeMetadata(root, main = null, physicalGitDir) {
|
|
93
111
|
const mainBranch = main?.branch || '';
|
|
94
112
|
const mainRef = main?.ref || '';
|
|
95
|
-
const [head, currentBranch, rawGitDir
|
|
96
|
-
|
|
113
|
+
const [[head, mainSha], currentBranch, rawGitDir] = await Promise.all([
|
|
114
|
+
commitIds(root, mainRef),
|
|
97
115
|
git(root, ['branch', '--show-current'], { optional: true }),
|
|
98
116
|
physicalGitDir === undefined ? git(root, ['rev-parse', '--path-format=absolute', '--git-dir'], { optional: true }) : physicalGitDir,
|
|
99
|
-
mainRef ? git(root, ['rev-parse', '--verify', '--quiet', `${mainRef}^{commit}`], { optional: true }) : '',
|
|
100
117
|
]);
|
|
101
118
|
const gitDir = rawGitDir ? await fs.realpath(rawGitDir).catch(() => path.resolve(root, rawGitDir)) : '';
|
|
102
119
|
return { branch: currentBranch, head, gitDir, mainBranch, mainRef, mainSha };
|
|
@@ -9,6 +9,13 @@ import { sendMessage, ProjectMessagePump } from './protocol-client.mjs';
|
|
|
9
9
|
|
|
10
10
|
const readTypes = new Set(['sync.heartbeat', 'sync.read', 'workbench.read', 'object.read', 'blob.get']);
|
|
11
11
|
const messageScope = message => message.session?.id || message.payload.sessionId || 'project';
|
|
12
|
+
function rejectedBindingReason(record, sessionId) {
|
|
13
|
+
if (record?.state !== 'rejected' || record.message?.type !== 'session.bind' || record.message.payload?.sessionId !== sessionId || record.error?.code !== 'CONFLICT') return '';
|
|
14
|
+
if (record.error.details?.reason === 'session-bound-elsewhere') return 'session-bound-elsewhere';
|
|
15
|
+
// This older rejection also covered same-device migration/version conflicts.
|
|
16
|
+
if (record.error.message === 'Migration requires the owning device and current binding version' && typeof record.error.details?.currentVersion === 'string') return 'binding-conflict';
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
12
19
|
// A single project host owns this connection. Agents never receive its credential.
|
|
13
20
|
export class DeviceConnection {
|
|
14
21
|
constructor({ directory, origin, transport = sendMessage, allowLoopback = false }) {
|
|
@@ -180,9 +187,19 @@ export class DeviceConnection {
|
|
|
180
187
|
const file = path.join(this.directory, 'bindings', `${hash(message.payload.sessionId)}.json`);
|
|
181
188
|
return withFileLock(`${file}.lock`, async () => {
|
|
182
189
|
const versions = await readJSON(file, {});
|
|
183
|
-
|
|
190
|
+
let remote;
|
|
191
|
+
try { remote = await this.send(message, wire => ({ ...wire, payload: { ...wire.payload,
|
|
184
192
|
expectedBindingVersion: versions[wire.payload.expectedBindingVersion] || '',
|
|
185
|
-
} }));
|
|
193
|
+
} })); }
|
|
194
|
+
catch (error) {
|
|
195
|
+
const receipt = await readJSON(path.join(this.directory, 'outcomes', `${hash(message.id)}.json`), null);
|
|
196
|
+
if (rejectedBindingReason(receipt, localBinding.session.id)) {
|
|
197
|
+
versions.rejections ||= {};
|
|
198
|
+
versions.rejections[localBinding.bindingVersion] = message.id;
|
|
199
|
+
await atomicWrite(file, encode(versions));
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
186
203
|
if (remote.session.id !== localBinding.session.id || remote.session.generation !== localBinding.session.generation) {
|
|
187
204
|
fail('STALE_SESSION', 'Cloud and local binding generations differ');
|
|
188
205
|
}
|
|
@@ -192,16 +209,27 @@ export class DeviceConnection {
|
|
|
192
209
|
});
|
|
193
210
|
}
|
|
194
211
|
async ensureBinding(binding) {
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
212
|
+
const status = await this.bindingStatus(binding);
|
|
213
|
+
if (status.status === 'ready') return;
|
|
214
|
+
if (status.status === 'conflict') fail('CONFLICT', 'Session binding requires a new host Session', { reason: status.reason });
|
|
197
215
|
await this.bind({ v: 2, id: `bind:${hash(binding.version)}`, type: 'session.bind', payload: {
|
|
198
216
|
sessionId: binding.sessionId, worktreeId: binding.worktreeId, agentId: binding.agentId,
|
|
199
217
|
expectedBindingVersion: binding.version,
|
|
200
218
|
} }, { session: { id: binding.sessionId, generation: binding.generation }, bindingVersion: binding.version });
|
|
201
219
|
}
|
|
202
|
-
async
|
|
220
|
+
async bindingStatus(binding) {
|
|
221
|
+
if (!binding) return { status: 'pending' };
|
|
203
222
|
const versions = await readJSON(path.join(this.directory, 'bindings', `${hash(binding.sessionId)}.json`), {});
|
|
204
|
-
if (versions[binding.version]) return
|
|
223
|
+
if (versions[binding.version]) return { status: 'ready' };
|
|
224
|
+
const requestId = versions.rejections?.[binding.version] || `bind:${hash(binding.version)}`;
|
|
225
|
+
const receipt = await readJSON(path.join(this.directory, 'outcomes', `${hash(requestId)}.json`), null);
|
|
226
|
+
const reason = rejectedBindingReason(receipt, binding.sessionId);
|
|
227
|
+
return reason ? { status: 'conflict', code: 'CONFLICT', reason } : { status: 'pending' };
|
|
228
|
+
}
|
|
229
|
+
async bindingReady(binding) {
|
|
230
|
+
const status = await this.bindingStatus(binding);
|
|
231
|
+
if (status.status === 'ready') return true;
|
|
232
|
+
if (status.status === 'conflict') return false;
|
|
205
233
|
this.enrolling ||= new Map();
|
|
206
234
|
if (!this.enrolling.has(binding.sessionId) && this.enrolling.size < 4) {
|
|
207
235
|
const job = this.ensureBinding(binding).catch(error => { this.lastError = error.code || 'UNAVAILABLE'; })
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fail } from '../shared/protocol.mjs';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import { pause } from '../shared/io.mjs';
|
|
4
6
|
|
|
5
7
|
const execFileAsync = promisify(execFile);
|
|
6
8
|
|
|
@@ -20,15 +22,35 @@ export async function lookupRepository(slug, {
|
|
|
20
22
|
fetcher = fetch,
|
|
21
23
|
token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
|
|
22
24
|
tokenProvider = keychainToken,
|
|
25
|
+
clock = () => performance.now(),
|
|
26
|
+
sleep = pause,
|
|
23
27
|
} = {}) {
|
|
24
28
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(slug) || slug.split('/').some(part => ['.', '..'].includes(part))) fail('INVALID_ARGUMENT', 'Invalid GitHub repository');
|
|
25
29
|
let url = new URL(`https://api.github.com/repos/${slug}`);
|
|
26
|
-
|
|
30
|
+
const deadline = clock() + 40000;
|
|
31
|
+
const checkDeadline = () => { if (clock() >= deadline) fail('UNAVAILABLE', 'GitHub repository identity verification timed out'); };
|
|
32
|
+
const authorization = token || await tokenProvider() || '';
|
|
33
|
+
checkDeadline();
|
|
34
|
+
const request = async target => {
|
|
35
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
36
|
+
checkDeadline();
|
|
37
|
+
let response;
|
|
38
|
+
try {
|
|
39
|
+
response = await fetcher(target, { redirect: 'manual', signal: AbortSignal.timeout(Math.max(1, Math.min(10000, Math.ceil(deadline - clock())))),
|
|
40
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Context-Guard', ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}) } });
|
|
41
|
+
} catch {
|
|
42
|
+
checkDeadline();
|
|
43
|
+
if (attempt === 2) fail('UNAVAILABLE', 'GitHub repository identity could not be verified');
|
|
44
|
+
}
|
|
45
|
+
checkDeadline();
|
|
46
|
+
if (response && ![502, 503, 504].includes(response.status)) return response;
|
|
47
|
+
await response?.body?.cancel();
|
|
48
|
+
if (attempt === 2) fail('UNAVAILABLE', 'GitHub repository is temporarily unavailable');
|
|
49
|
+
await sleep(Math.min(250 * 2 ** attempt, Math.max(1, deadline - clock())));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
27
52
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
28
|
-
|
|
29
|
-
try { response = await fetcher(url, { redirect: 'manual', signal: AbortSignal.timeout(10000),
|
|
30
|
-
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Context-Guard', ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}) } }); }
|
|
31
|
-
catch { fail('UNAVAILABLE', 'GitHub repository identity could not be verified'); }
|
|
53
|
+
const response = await request(url);
|
|
32
54
|
if ([301, 302, 307, 308].includes(response.status)) {
|
|
33
55
|
const location = response.headers.get('location'); await response.body?.cancel();
|
|
34
56
|
if (!location) fail('UNAVAILABLE', 'GitHub redirect is missing');
|
|
@@ -38,11 +60,6 @@ export async function lookupRepository(slug, {
|
|
|
38
60
|
}
|
|
39
61
|
if (!response.ok) {
|
|
40
62
|
await response.body?.cancel();
|
|
41
|
-
if (!authorization && !keychainChecked && [401, 403, 404].includes(response.status)) {
|
|
42
|
-
keychainChecked = true;
|
|
43
|
-
authorization = await tokenProvider();
|
|
44
|
-
if (authorization) { attempt -= 1; continue; }
|
|
45
|
-
}
|
|
46
63
|
fail(response.status === 404 ? 'NOT_FOUND' : [401, 403].includes(response.status) ? 'FORBIDDEN' : 'UNAVAILABLE', 'GitHub repository is unavailable to this backend');
|
|
47
64
|
}
|
|
48
65
|
let value;
|
|
@@ -51,6 +68,7 @@ export async function lookupRepository(slug, {
|
|
|
51
68
|
for await (const chunk of response.body) { size += chunk.length; if (size > 1024 * 1024) fail('TOO_LARGE', 'GitHub response exceeds limit'); chunks.push(chunk); }
|
|
52
69
|
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
53
70
|
} catch { fail('UNAVAILABLE', 'GitHub returned an invalid repository identity'); }
|
|
71
|
+
checkDeadline();
|
|
54
72
|
if (!Number.isSafeInteger(value.id) || value.id <= 0 || typeof value.full_name !== 'string') fail('UNAVAILABLE', 'GitHub returned an invalid repository identity');
|
|
55
73
|
return { repositoryId: String(value.id), slug: value.full_name };
|
|
56
74
|
}
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import { constants as fsConstants } from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
6
|
import { atomicWrite, encode, readJSON, withFileLock } from '../shared/io.mjs';
|
|
6
7
|
import { MapError } from '../shared/map-model.mjs';
|
|
7
8
|
|
|
@@ -100,6 +101,11 @@ export async function rememberProject(project, { dir = globalWorkbenchDirectory(
|
|
|
100
101
|
} : previous?.runtime || null,
|
|
101
102
|
updatedAt: new Date().toISOString(),
|
|
102
103
|
};
|
|
104
|
+
if (previous) {
|
|
105
|
+
const { updatedAt: _previousUpdatedAt, ...previousContent } = previous;
|
|
106
|
+
const { updatedAt: _nextUpdatedAt, ...nextContent } = record;
|
|
107
|
+
if (isDeepStrictEqual(previousContent, nextContent)) return previous;
|
|
108
|
+
}
|
|
103
109
|
const next = {
|
|
104
110
|
version: 1,
|
|
105
111
|
projects: [...registry.projects.filter(item => item.projectId !== project.projectId), record]
|
|
@@ -15,6 +15,7 @@ import { memoryRequest, sessionMemoryDir, memoryConfigPath } from './memory.mjs'
|
|
|
15
15
|
import { MemorySyncCoordinator } from './sync-coordinator.mjs';
|
|
16
16
|
import { inspectRetiredSync } from './sync.mjs';
|
|
17
17
|
import { runtimeIdentity } from './runtime.mjs';
|
|
18
|
+
import { canRetryWorkbenchListen } from './listen.mjs';
|
|
18
19
|
import { Attachments } from './attachments.mjs';
|
|
19
20
|
import { ProtocolStore } from '../shared/protocol-store.mjs';
|
|
20
21
|
import { ProtocolBlobs, serveBlob } from '../shared/protocol-blobs.mjs';
|
|
@@ -144,6 +145,8 @@ export async function health(state) {
|
|
|
144
145
|
return result?.ok ? result.value : null;
|
|
145
146
|
}
|
|
146
147
|
export { loopbackJSON };
|
|
148
|
+
export { canRetryWorkbenchListen };
|
|
149
|
+
|
|
147
150
|
export async function startServer({ root, port = 8877, host = '127.0.0.1', fault, messageQueue = queueCodexMessage, repositoryLookup = lookupRepository } = {}) {
|
|
148
151
|
if (!['127.0.0.1', 'localhost'].includes(host)) throw new MapError('INVALID_HOST', 'Workbench only listens on loopback');
|
|
149
152
|
const directory = await defaultDirectoryAvailability();
|
|
@@ -476,6 +479,14 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
476
479
|
const connection = await projectDevice();
|
|
477
480
|
const coordinator = new MemorySyncCoordinator({ project: sessionProject, sessionId, store: target, directory: syncDirectory,
|
|
478
481
|
managed: !!connection && await connection.supports('private-map-heads'),
|
|
482
|
+
request: async (...args) => {
|
|
483
|
+
if (connection && await connection.connected()) {
|
|
484
|
+
const binding = await protocolStore.registeredBinding(backendPrincipal, sessionId);
|
|
485
|
+
const status = await connection.bindingStatus(binding);
|
|
486
|
+
if (status.status === 'conflict') throw new MapError('CONFLICT', 'Session binding requires a new host Session', 409, { reason: status.reason });
|
|
487
|
+
}
|
|
488
|
+
return memoryRequest(...args);
|
|
489
|
+
},
|
|
479
490
|
display: async () => {
|
|
480
491
|
const identity = (await access.sessionRegistry()).find(item => item.id === sessionId);
|
|
481
492
|
return identity?.name ? { name: identity.name, platform: identity.platform } : null;
|
|
@@ -555,6 +566,13 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
555
566
|
return refreshing;
|
|
556
567
|
}
|
|
557
568
|
async function cloudSyncStatus(viewId = 'main') {
|
|
569
|
+
if (viewId.startsWith('session:')) {
|
|
570
|
+
const connection = await projectDevice();
|
|
571
|
+
if (connection && await connection.connected()) {
|
|
572
|
+
const status = await connection.bindingStatus(await protocolStore.registeredBinding(backendPrincipal, viewId.slice(8)));
|
|
573
|
+
if (status.status === 'conflict') return { ...(syncCoordinators.get(viewId)?.snapshot() || {}), configured: true, status: 'conflict', error: status.code, reason: status.reason };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
558
576
|
if (syncCoordinators.has(viewId)) return syncCoordinators.get(viewId).snapshot();
|
|
559
577
|
return { configured: false, status: 'disabled', cursor: 0, receivedCursor: 0, conflict: null, serviceAlive: false };
|
|
560
578
|
}
|
|
@@ -593,7 +611,17 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
593
611
|
else await connection.bind(bindMessage, protocolBinding);
|
|
594
612
|
cloudBinding = { status: 'ready' };
|
|
595
613
|
}
|
|
596
|
-
catch (error) {
|
|
614
|
+
catch (error) {
|
|
615
|
+
const status = await connection.bindingStatus({ sessionId: prepared.sessionId, version: protocolBinding.bindingVersion });
|
|
616
|
+
cloudBinding = status.status === 'conflict' ? status : { status: 'pending', code: error.code || 'UNAVAILABLE' };
|
|
617
|
+
if (cloudBinding.status === 'conflict') {
|
|
618
|
+
const directory = path.join(sessionMemoryDir(prepared.sessionProject, prepared.sessionId), 'remote-sync');
|
|
619
|
+
const stateFile = path.join(directory, 'state.json');
|
|
620
|
+
await atomicWrite(stateFile, encode({ ...await readJSON(stateFile, {}), configured: true,
|
|
621
|
+
status: 'conflict', error: 'CONFLICT', reason: cloudBinding.reason,
|
|
622
|
+
pending: await readJSON(path.join(directory, 'outbox.json'), null) ? 1 : 0 }));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
597
625
|
}
|
|
598
626
|
const lazyCloudMap = connection && await connection.supports('private-map-heads');
|
|
599
627
|
const sessionFiles = project.kind === 'git' && !lazyCloudMap && !stores.has(`session:${prepared.sessionId}`) ? await ensureSessionMap(prepared.sessionProject, prepared.sessionId) : null;
|
|
@@ -965,8 +993,8 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
965
993
|
if (route.startsWith('/api/')) {
|
|
966
994
|
const actor = auth(req, url);
|
|
967
995
|
const viewId = viewFor(actor, url);
|
|
968
|
-
const activeStore = await storeFor(viewId);
|
|
969
996
|
if (route === '/api/cloud-sync' && req.method === 'GET') { isHuman(actor); return send(res, 200, await cloudSyncStatus(viewId)); }
|
|
997
|
+
const activeStore = await storeFor(viewId);
|
|
970
998
|
if (route === '/api/events' && req.method === 'GET') {
|
|
971
999
|
isHuman(actor); const id = url.searchParams.get('clientId');
|
|
972
1000
|
if (!id || id.length > 100) throw new MapError('INVALID_CLIENT', 'Invalid clientId');
|
|
@@ -1108,8 +1136,17 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
1108
1136
|
});
|
|
1109
1137
|
server.requestTimeout = 10000; server.headersTimeout = 10000;
|
|
1110
1138
|
for (let attempt = 0; ; attempt++) {
|
|
1111
|
-
try {
|
|
1112
|
-
|
|
1139
|
+
try {
|
|
1140
|
+
await new Promise((resolve, reject) => {
|
|
1141
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
1142
|
+
const onError = error => { server.off('error', onError); server.off('listening', onListening); reject(error); };
|
|
1143
|
+
server.once('error', onError);
|
|
1144
|
+
try { server.listen(port === 0 ? 0 : port + attempt, '127.0.0.1', onListening); }
|
|
1145
|
+
catch (error) { onError(error); }
|
|
1146
|
+
});
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
catch (e) { if (!canRetryWorkbenchListen(e, port, attempt)) throw e; }
|
|
1113
1150
|
}
|
|
1114
1151
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
1115
1152
|
const state = { ...runtimeIdentity(), root, projectId: project.projectId, worktreeRoot: project.worktreeRoot, worktreeId: project.worktreeId, sharedDir: project.sharedDir, pid: process.pid, instance, url: base + '/prototype/workbench.html', adminToken };
|
|
@@ -126,6 +126,13 @@ export class MemorySyncCoordinator extends EventEmitter {
|
|
|
126
126
|
this.update(fields);
|
|
127
127
|
await atomicWrite(this.stateFile, encode(this.status));
|
|
128
128
|
}
|
|
129
|
+
async preserveBindingConflict(error) {
|
|
130
|
+
const reason = error?.details?.reason;
|
|
131
|
+
if (error?.code !== 'CONFLICT' || !['session-bound-elsewhere', 'binding-conflict'].includes(reason)) return false;
|
|
132
|
+
await this.persist({ status: 'conflict', error: 'CONFLICT', reason,
|
|
133
|
+
conflict: { code: 'CONFLICT', reason }, pending: await readJSON(this.outboxFile, null) ? 1 : 0 });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
129
136
|
|
|
130
137
|
async start() {
|
|
131
138
|
await fs.mkdir(this.directory, { recursive: true });
|
|
@@ -272,6 +279,7 @@ export class MemorySyncCoordinator extends EventEmitter {
|
|
|
272
279
|
await this.persist({ status: equalDocument(acknowledged, this.store.doc) ? 'synced' : 'syncing', pending: 0, lastSyncedAt: result.persistedAt || new Date().toISOString() });
|
|
273
280
|
if (!equalDocument(acknowledged, this.store.doc)) await this.queueLocal();
|
|
274
281
|
} catch (error) {
|
|
282
|
+
if (await this.preserveBindingConflict(error)) return;
|
|
275
283
|
if (error.code === 'SESSION_REOPEN_REQUIRED') {
|
|
276
284
|
await this.createSessionGeneration();
|
|
277
285
|
return;
|
|
@@ -451,6 +459,7 @@ export class MemorySyncCoordinator extends EventEmitter {
|
|
|
451
459
|
this.retryDelay = this.retryMin;
|
|
452
460
|
} catch (error) {
|
|
453
461
|
clearTimeout(this.streamTimer);
|
|
462
|
+
if (await this.preserveBindingConflict(error)) break;
|
|
454
463
|
if (this.closed || this.managed) break;
|
|
455
464
|
if (this.abort?.signal.reason?.code === 'EVENT_STREAM_TIMEOUT') error = this.abort.signal.reason;
|
|
456
465
|
await this.schedule(async () => {
|
|
@@ -497,6 +506,7 @@ export class MemorySyncCoordinator extends EventEmitter {
|
|
|
497
506
|
if (this.status.pending) await this.flush();
|
|
498
507
|
else if (['offline', 'error', 'connecting'].includes(this.status.status)) await this.persist({ status: 'synced', error: null });
|
|
499
508
|
}).catch(async error => {
|
|
509
|
+
if (await this.preserveBindingConflict(error)) throw error;
|
|
500
510
|
if (!this.status.conflict) await this.persist({ status: error.code === 'UNAUTHORIZED' ? 'error' : 'offline', error: error.code || 'MEMORY_UNAVAILABLE' });
|
|
501
511
|
throw error;
|
|
502
512
|
});
|
|
@@ -77,6 +77,7 @@ export async function sessionSync(root, sessionId, action) {
|
|
|
77
77
|
const project = await resolveProject(root);
|
|
78
78
|
if (!(await bindingStatus(project, sessionId)).session.bound) throw new MapError('SESSION_BINDING_REQUIRED', 'Bind the actual Session before synchronization', 409);
|
|
79
79
|
if (!status.configured) throw new MapError('MEMORY_NOT_CONFIGURED', 'Connect this project using workbench connect', 503);
|
|
80
|
+
if (status.state?.status === 'conflict' && ['session-bound-elsewhere', 'binding-conflict'].includes(status.state.reason)) throw new MapError('CONFLICT', 'Session binding requires a new host Session', 409, { reason: status.state.reason });
|
|
80
81
|
if (['prepare', 'pull', 'checkpoint'].includes(action)) return prepareMemory(project, sessionId);
|
|
81
82
|
if (action === 'finish') {
|
|
82
83
|
const receipt = await synchronizeMemory(root, sessionId);
|