@ai-workbench/connect 0.7.9 → 0.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- 安装:在 DSH「插件 → 添加插件」填写 `@ai-workbench/connect@0.7.9`,或运行 `dsh plugin --profile desktop add @ai-workbench/connect@0.7.9`。
1
+ 安装:在 DSH「插件 → 添加插件」填写 `@ai-workbench/connect@0.7.11`,或运行 `dsh plugin --profile desktop add @ai-workbench/connect@0.7.11`。
2
2
 
3
3
  # 远程主机连接插件(L)
4
4
 
@@ -23,7 +23,7 @@ minimumReleaseAgeExclude:
23
23
 
24
24
  D13已通过[PR15](https://github.com/yzsnstotz/ai-workbench-connect/pull/15)合推main7915df5,移除前为909eabb;五项被拒产物及清单引用已撤除。不再生成新的聚合整包或验收ZIP,交付物为插件本身。
25
25
 
26
- **插件版本0.7.9,API保持0.7.0/native-local-v1。** 本轮沿用现有N/X公开API,真实组合由编排验证。L导入动作固定使用官方系统目录窗口:自动能力为native时直接消费其pick;SSH等启动上下文提供browse时,明确调用同一官方原生包的公开pickNativeDirectory,不修改全局选择器。失败原因与临时登录路径只在本节技术详情准确投影。js-yaml/yauzl及其传递运行依赖随tgz打包,官方宿主peer由已安装官方程序提供,员工不另装npm依赖。真实中文GUI/授权及leo E门分别留证。旧140测试/46图只属于前任draft.3;本轮证据独立记录于[第2轮证据](docs/evidence/2026-10-21-L-round2-draft4/)。不得拿旧0.6.3 tgz当native-local-v1的输入。
26
+ **插件版本0.7.11,API保持0.7.0/native-local-v1。** 本轮沿用现有N/X公开API,真实组合由编排验证。L导入动作固定使用官方系统目录窗口:自动能力为native时直接消费其pick;SSH等启动上下文提供browse时,明确调用同一官方原生包的公开pickNativeDirectory,不修改全局选择器。失败原因与临时登录路径只在本节技术详情准确投影。js-yaml/yauzl及其传递运行依赖随tgz打包,官方宿主peer由已安装官方程序提供,员工不另装npm依赖。真实中文GUI/授权及leo E门分别留证。旧140测试/46图只属于前任draft.3;本轮证据独立记录于[第2轮证据](docs/evidence/2026-10-21-L-round2-draft4/)。不得拿旧0.6.3 tgz当native-local-v1的输入。
27
27
 
28
28
  开发使用Node24.13.1,依赖/缓存/构建和临时HOME放自有外接APFS。工程检查为`npm test`、`npm run check`、`npm run build`;它们不替代真实产品路径。安装走官方插件管理,本包自身只声明一个官方bundle条目,不使用wrapper或员工启动器。工人GUI按[SELFTEST](docs/SELFTEST.md)的PID和三项获准偏离执行,不操作owner实例。
29
29
 
@@ -33,7 +33,7 @@ L0.7.4按owner最新纠正分开供给和授权:Electron组件从自身官方
33
33
 
34
34
  官方Electron内置Node会把.asar视作虚拟目录;组件缓存/解压专用physical-fs在Electron中使用original-fs核验物理归档,不修改全局process.noAsar。真实官方运行时新建及跨Node复用缓存均有回归。
35
35
 
36
- 0.7.5仅调整公开 npm 发布元数据和包内容:运行依赖继续随包,合同、源码、测试与证据不进包;无安装脚本。源码与合同见[项目仓库](https://github.com/yzsnstotz/ai-workbench-connect)。发布前可用官方插件页或上述命令安装已核验的tgz绝对路径。0.7.9本轮提供固定tgz,由编排决定发布;L不执行publish。
36
+ 0.7.5仅调整公开 npm 发布元数据和包内容:运行依赖继续随包,合同、源码、测试与证据不进包;无安装脚本。源码与合同见[项目仓库](https://github.com/yzsnstotz/ai-workbench-connect)。发布前可用官方插件页或上述命令安装已核验的tgz绝对路径。0.7.11本轮提供固定tgz,由编排决定发布;L不执行publish。
37
37
 
38
38
  0.7.6:L设置节可显式选择 GitHub 官方源或 npmmirror(Electron 官方文档列出的中国镜像),显示所选来源与理由。未保存选择/未显式配置时默认 npmmirror,依据为编排在leo的可达性实测,不代表自动测速。优先级:本profile设置节已保存选择 → helper.componentDownloadSource初值 → 旧helper.source显式URL → 默认npmmirror;坏设置/保存失败明确报错,不静默切源。预设仍校验原Electron44.0.0固定SHA256。helper.componentObservationIntervalMs默认1000毫秒,只刷新“已N秒无进展”,不触发超时/重试/换源。Claude网页登录的当前流向见下节;Codex本版保留原有N主机代理方式。
39
39
 
@@ -72,3 +72,13 @@ RFC8628的 `verification_uri_complete` 是可选字段;固定0.163.0 CLI只提
72
72
  诊断记录不自动过期、不按条数淘汰、没有隐藏总条数上限,本版没有清除入口。目录目标查询 `getModelCatalogTarget` 同步返回当前已核验且仍 connected 的内存深副本,不连接、不登录、不查凭据、不绑定项目;projectRoot 元数据不构成模型查询 cwd 授权。
73
73
 
74
74
  本单工程证据见 [D19/D21](docs/evidence/2026-10-22-L-D19-D21/RESULT.md)。
75
+
76
+
77
+ ### 0.7.10:登录检查失败的技术原因
78
+
79
+ 员工正文和操作入口保持不变。管理员在远程主机连接的技术详情查看失败阶段:进程启动/输入/输出预算、SSH退出或信号、远端结果帧、官方登录状态JSON。保留固定错误类型、系统错误码、退出码、预期键缺失及stderr安全摘要;不显示原始命令输出、异常message或认证材料。消费方需保留已有ConnectNeed.officialReason进入会话技术详情。未知状态仍无法授权,不用等待/重试/放宽校验掩盖。沿用commandOutputMaxBytes,新增设置0;真实根因和产品验收以本轮回执为准。
80
+
81
+
82
+ 0.7.10还修复实际登录检查结果与启动快照不一致:排队品牌queuedProviders与实际检查checkingProviders分开,成功/失败按品牌及当前代次发布;旧结果不能覆盖新结果。认证未知不自动触发反复重连检查。首次绑定前的失败可通过loginDiagnostics:1及五项登录诊断接口持久记录,在同一设置节“登录前技术详情”查看;与工作区绑定和普通会话详情隔离,不启动授权、不创建关联。详见提供方合同0.7.10 P1定稿。
83
+
84
+ 0.7.11:登录前诊断可保留实例不一致时的实际双 ID;只有固定原因与观察字段一致时接纳,当前连接/事件作用域仍严格核验。详见提供方合同的“0.7.10 P1定稿”及“0.7.11:观察到的登录实例ID”两节。D16 旧安装迁移暂停,未包含迁移实现;等待 owner 决定更新前入口。
package/client/index.cjs CHANGED
@@ -85,11 +85,11 @@ window.__ModuleLoader__.load({id:'@ai-workbench/connect',factory:(require)=>{
85
85
  const publish=next=>{state=next;for(const fn of listeners)fn();};
86
86
  const accept=(value,source='event')=>{if(disposed)return;const next=value?.startup?.revision,id=value?.startup?.instanceId;if(typeof id!=='string'||!id||!Number.isSafeInteger(next)||next<0){publish({value:undefined,error:'CONNECTION_UNKNOWN'});return;}if(id!==instance){if(instance&&(source==='http'||retired.has(id)))return;if(instance)retired.add(instance);instance=id;revision=-1;}if(next<revision)return;revision=next;const incoming=value.loginComponent?.download;if(incoming){if(typeof incoming.instanceId!=='string'||!Number.isSafeInteger(incoming.revision)||incoming.revision<0||retiredDownloads.has(incoming.instanceId)||(download&&incoming.instanceId===download.instanceId&&incoming.revision<download.revision)||(download&&incoming.instanceId!==download.instanceId&&source==='http')){value={...value,loginComponent:{...value.loginComponent,download}};}else{if(download&&incoming.instanceId!==download.instanceId)retiredDownloads.add(download.instanceId);download=incoming;}}const choice=value.loginComponent?.source;if(choice){if(typeof choice.instanceId!=='string'||!Number.isSafeInteger(choice.revision)||choice.revision<0||retiredSources.has(choice.instanceId)||(componentSource&&choice.instanceId===componentSource.instanceId&&choice.revision<componentSource.revision)||(componentSource&&choice.instanceId!==componentSource.instanceId&&source==='http')){if(componentSource)value={...value,loginComponent:{...value.loginComponent,source:componentSource}};}else{if(componentSource&&choice.instanceId!==componentSource.instanceId)retiredSources.add(componentSource.instanceId);componentSource=choice;}}publish({value,error:value.actionErrorCode||value.enrollment?.need?.code||(value.actionError?'ACTION_FAILED':'')});};
87
87
  const failure=()=>{if(!disposed)publish({value:undefined,error:'CONNECTION_UNKNOWN'});};
88
- return {readDetails:query=>call('/conversation-details?'+Object.entries(query).filter(([,v])=>v!==undefined).map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(v)).join('&'),'GET'),getSnapshot:()=>state,subscribe:fn=>{listeners.add(fn);return()=>listeners.delete(fn);},async action(path='/check',body){const at=eventVersion;try{const value=await call(path,'POST',body);if(at===eventVersion)accept(value,'http');}catch{if(at===eventVersion)failure();}},start(){if(disposed||started)return;started=true;off=subscribe(value=>{eventVersion++;accept(value);},()=>{eventVersion++;failure();});const at=eventVersion;void call('/status','GET').then(value=>{if(at===eventVersion)accept(value,'http');},()=>{if(at===eventVersion)failure();});},dispose(){disposed=true;off?.();listeners.clear();}};
88
+ return {readLoginDiagnostics:query=>call('/login-diagnostics?'+Object.entries(query).filter(([,v])=>v!==undefined).map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(v)).join('&'),'GET'),readDetails:query=>call('/conversation-details?'+Object.entries(query).filter(([,v])=>v!==undefined).map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(v)).join('&'),'GET'),getSnapshot:()=>state,subscribe:fn=>{listeners.add(fn);return()=>listeners.delete(fn);},async action(path='/check',body){const at=eventVersion;try{const value=await call(path,'POST',body);if(at===eventVersion)accept(value,'http');}catch{if(at===eventVersion)failure();}},start(){if(disposed||started)return;started=true;off=subscribe(value=>{eventVersion++;accept(value);},()=>{eventVersion++;failure();});const at=eventVersion;void call('/status','GET').then(value=>{if(at===eventVersion)accept(value,'http');},()=>{if(at===eventVersion)failure();});},dispose(){disposed=true;off?.();listeners.clear();}};
89
89
  }
90
90
  function useWorkbench(workbench){const snapshot=React.useSyncExternalStore(workbench.subscribe,workbench.getSnapshot);return {state:snapshot.value,error:snapshot.error,action:workbench.action};}
91
91
  function isLoginQuestion(state){const s=state?.startup;return s?.phase==='login-required'&&s.requestId&&s.provider&&s.logins?.[s.provider]===false&&!s.checkingProviders?.includes(s.provider)&&!s.providerNeeds?.[s.provider];}
92
- function Details({state,error,t}){return h('details',null,h('summary',null,t('details')),h('pre',null,JSON.stringify({actionErrorCode:error||state?.actionErrorCode,enrollment:state?.enrollment,actionError:state?.actionError,inventory:state?.inventory,network:state?.network,connect:state?.connect,workspaces:state?.workspaces,checks:state?.startup?.technicalDetails,providerNeeds:state?.startup?.providerNeeds,checkingProviders:state?.startup?.checkingProviders,loginComponent:state?.loginComponent},null,2)));}
92
+ function Details({state,error,t}){return h('details',null,h('summary',null,t('details')),h('pre',null,JSON.stringify({actionErrorCode:error||state?.actionErrorCode,enrollment:state?.enrollment,actionError:state?.actionError,inventory:state?.inventory,network:state?.network,connect:state?.connect,workspaces:state?.workspaces,checks:state?.startup?.technicalDetails,providerNeeds:state?.startup?.providerNeeds,queuedProviders:state?.startup?.queuedProviders,checkingProviders:state?.startup?.checkingProviders,loginComponent:state?.loginComponent},null,2)));}
93
93
  function Choices({state,action,t}){if(!isLoginQuestion(state))return null;const brand=state.startup.provider==='claude'?'Claude':'Codex';return h('div',null,h('button',{type:'button',onClick:()=>void action('/respond/'+state.startup.requestId+'/authorize')},t('signIn',{brand})),h('button',{type:'button',onClick:()=>void action('/respond/'+state.startup.requestId+'/defer')},t('later')));}
94
94
  function Enrollment({state,action,t}){const value=state?.enrollment;if(!value)return null;return h('div',null,h('p',null,t('importHelp')),value.phase==='imported'?h('p',null,t('imported')):h('button',{type:'button',disabled:value.phase==='importing'||['ENROLLMENT_RESULT_UNKNOWN','ENROLLMENT_REJECTED'].includes(value.need?.code),onClick:()=>void action('/enrollment/import')},t(value.phase==='importing'?'importing':'import')));}
95
95
  function observeNotice(node,notify){
@@ -120,20 +120,20 @@ window.__ModuleLoader__.load({id:'@ai-workbench/connect',factory:(require)=>{
120
120
  function ConversationDetailRecord({item}){
121
121
  return h('details',null,h('summary',null,'序号 '+item.sequence+' · '+severityLabel(item.severity)+' · '+item.summary),h('dl',null,...Object.keys(detailFieldLabels).flatMap(key=>[h('dt',{key:key+'-label'},detailFieldLabels[key]+'('+key+')'),h('dd',{key:key+'-value'},key==='payload'?h('pre',null,JSON.stringify(item.payload,null,2)):item[key]===undefined?'未提供':item[key]===null?'null(官方未提供)':key==='severity'?severityLabel(item[key])+'('+item[key]+')':String(item[key]))])));
122
122
  }
123
- function ConversationDetailSession({session,workbench}){
123
+ function ConversationDetailSession({session,workbench,loginDiagnostics=false}){
124
124
  const [page,setPage]=React.useState(null),[loading,setLoading]=React.useState(false),[error,setError]=React.useState(''),live=React.useRef(true),busy=React.useRef(false);
125
125
  React.useEffect(()=>()=>{live.current=false;},[]);
126
126
  const load=async more=>{if(busy.current)return;busy.current=true;setLoading(true);setError('');try{
127
- const result=await workbench.readDetails({sessionId:session.sessionId,provider:session.provider,...(more&&page?.nextCursor?{cursor:page.nextCursor}:{})});if(!live.current)return;
127
+ const result=await (loginDiagnostics?workbench.readLoginDiagnostics:workbench.readDetails)({sessionId:session.sessionId,provider:session.provider,...(more&&page?.nextCursor?{cursor:page.nextCursor}:{})});if(!live.current)return;
128
128
  if(!result?.ok){setPage(null);setError(result?.error?.message||'详情读取失败,请确认当前连接;未自动重试。');return;}
129
129
  setPage(old=>({...result.value,items:more?[...(old?.items??[]),...result.value.items]:result.value.items}));
130
130
  }catch{if(live.current){setPage(null);setError('详情读取失败,请确认当前连接;未自动重试。');}}finally{busy.current=false;if(live.current)setLoading(false);}};
131
131
  return h('details',null,h('summary',null,session.sessionId+' · '+(session.provider==='claude'?'Claude':'Codex')+' · 共 '+session.total+' 条,未知 '+session.unknownCount+' 条,'+session.actionRequiredCount+' 项需处理'),h('button',{type:'button',disabled:loading,onClick:()=>load(false)},page?'刷新详情':'读取详情'),loading?h('p',{role:'status'},'正在读取详情…'):null,error?h('p',{role:'alert'},error):null,page?h(React.Fragment,null,h('p',null,'本轮详情快照:共 '+page.total+' 条,未知 '+page.unknownCount+' 条,'+page.actionRequiredCount+' 项需处理。'),...page.items.map(item=>h(ConversationDetailRecord,{key:item.eventId,item})),page.nextCursor?h('button',{type:'button',disabled:loading,onClick:()=>load(true)},'继续加载'):null):null);
132
132
  }
133
- function RemoteConversationDetails({state,workbench}){
134
- const details=state?.conversationDetails;if(!details)return null;
133
+ function RemoteConversationDetails({state,workbench,loginDiagnostics=false}){
134
+ const details=loginDiagnostics?state?.loginDiagnostics:state?.conversationDetails;if(!details)return null;
135
135
  const count=(details.sessions??[]).reduce((sum,s)=>sum+s.actionRequiredCount,0);
136
- return h('div',null,count?h('p',{role:'status'},'诊断记录有 '+count+' 项需处理,请展开会话技术详情查看。'):null,h('details',null,h('summary',null,'会话技术详情'),details.phase==='ready'&&details.need?h('p',{role:'status'},'部分历史记录的原生会话或品牌关联无法核实,当前不显示其计数与正文。'):null,details.phase==='ready'?(details.sessions.length?details.sessions.map(session=>h(ConversationDetailSession,{key:details.instanceId+':'+details.epoch+':'+session.provider+':'+session.sessionId,session,workbench})):h('p',null,'当前可信连接没有可读取的诊断记录。')):h('p',{role:'status'},details.phase==='loading'?'正在核验诊断记录…':details.need?.message||'诊断记录暂时无法读取,请确认当前连接。')));
136
+ return h('div',null,count?h('p',{role:'status'},'诊断记录有 '+count+' 项需处理,请展开'+(loginDiagnostics?'登录前技术详情':'会话技术详情')+'查看。'):null,h('details',null,h('summary',null,loginDiagnostics?'登录前技术详情':'会话技术详情'),details.phase==='ready'&&details.need?h('p',{role:'status'},'部分历史记录的原生会话或品牌关联无法核实,当前不显示其计数与正文。'):null,details.phase==='ready'?(details.sessions.length?details.sessions.map(session=>h(ConversationDetailSession,{key:details.instanceId+':'+details.epoch+':'+session.provider+':'+session.sessionId,session,workbench,loginDiagnostics})):h('p',null,'当前可信连接没有可读取的诊断记录。')):h('p',{role:'status'},details.phase==='loading'?'正在核验诊断记录…':details.need?.message||'诊断记录暂时无法读取,请确认当前连接。')));
137
137
  }
138
138
  function ConversationDetailSettings({state,action}){
139
139
  const settings=state?.conversationDetailSettings,[max,setMax]=React.useState(''),[size,setSize]=React.useState(''),[error,setError]=React.useState(''),[saving,setSaving]=React.useState(false);
@@ -142,7 +142,7 @@ window.__ModuleLoader__.load({id:'@ai-workbench/connect',factory:(require)=>{
142
142
  const save=async()=>{const maxEventBytes=Number(max),pageSize=Number(size);if(!/^\d+$/.test(max)||!/^\d+$/.test(size)||![maxEventBytes,pageSize].every(n=>Number.isSafeInteger(n)&&n>0)){setError('设置未保存:两项都需要1至9007199254740991之间的整数。');return;}setError('');setSaving(true);try{await action('/conversation-detail-settings',{maxEventBytes,pageSize});}finally{setSaving(false);}};
143
143
  return h('details',null,h('summary',null,'管理员设置'),h('p',null,'仅调整诊断记录。保存后即时生效,重开继续沿用本节保存值;已保存值优先于官方插件 Config 初值。未保存时使用 Config 或以下默认值。'),h('label',null,'conversationDetails.maxEventBytes',h('input',{type:'number',min:1,max:Number.MAX_SAFE_INTEGER,step:1,value:max,disabled,onChange:event=>setMax(event.target.value)})),h('p',null,'完整 UTF-8 JSON 事件字节上限;默认1048576,范围1至9007199254740991整数。改大增加单事件存储与内存;改小拒收超过新值的新事件,不裁剪、不删除已保存记录,也不阻止原事件的幂等核实。'),h('label',null,'conversationDetails.pageSize',h('input',{type:'number',min:1,max:Number.MAX_SAFE_INTEGER,step:1,value:size,disabled,onChange:event=>setSize(event.target.value)})),h('p',null,'一次详情页条数;默认100,范围1至9007199254740991整数。改大增加一次读取和界面负担;改小增加继续加载次数,不减少完整快照总数。'),h('p',null,'enrollmentMetadataMaxBytes 当前为 '+settings.metadataMaxBytes+' 字节(默认1048576),同时限制诊断设置文件与保存提交JSON;调小可能拒绝设置读取或保存,可在官方插件 Config 修改。'),h('p',null,'诊断记录不自动过期、不按条数淘汰、没有隐藏总条数上限;本版没有清除入口。'),h('p',null,settings.phase==='ready'?(settings.source==='saved'?'当前使用本节已保存设置。':'当前使用官方插件 Config 初值或默认值。'):settings.phase==='saving'?'正在保存设置…':'设置无法核实,未自动使用其他值;请联系管理员。'),error?h('p',{role:'alert'},error):null,settings.errorCode?h('p',{role:'alert'},'设置保存或读取失败:'+settings.errorCode):null,h('button',{type:'button',disabled,onClick:save},'保存诊断设置'));
144
144
  }
145
- function Panel({workbench,t}){const {state,error,action}=useWorkbench(workbench),message=messageKey(state?.startup),errorKey=error==='ENROLLMENT_REQUIRED'&&state?.enrollment?.phase==='blocked'?'enrollmentKeyMissing':error?needKey(error):undefined,bodyVisible=!['downloading','verifying','unpacking'].includes(state?.loginComponent?.download?.phase)&&(!error||!!state?.startup)&&!(state?.enrollment?.phase==='blocked'&&state.enrollment.need);return h('section',{'aria-label':t('title')},h('h2',null,t('title')),h('p',null,t('loginNetworkBoundary')),h(ComponentSource,{state,action}),h(DownloadProgress,{state,action}),h(Enrollment,{state,action,t}),bodyVisible?h('p',{role:errorKey===message?'alert':undefined},t(message)):null,errorKey&&(!bodyVisible||errorKey!==message)?h('p',{role:'alert'},t(errorKey)):null,state?h(React.Fragment,null,...['codex','claude'].map(provider=>{const brand=provider==='claude'?'Claude':'Codex',checking=state.startup?.checkingProviders?.includes(provider),unknown=state.startup?.providerNeeds?.[provider]||state.startup?.logins?.[provider]===undefined;return h('div',{key:provider},h('p',null,t('provider',{brand})+': '+t(checking?'statusChecking':unknown?'statusUnknown':state.startup.logins[provider]?'statusSignedIn':'statusSignIn')),state.startup?.logins?.[provider]===false&&!checking&&!unknown&&(!isLoginQuestion(state)||state.startup.provider!==provider)?h('button',{type:'button',disabled:state.startup.phase==='authorizing',onClick:()=>void action('/start-login/'+provider+'/'+state.startup.instanceId+'/'+state.startup.revision)},t('signIn',{brand})):null);}),h(Choices,{state,action,t}),h(WorkspaceNotices,{state,action}),h('p',null,t('workspaceLimit')),...(state.workspaceBindings?.items??[]).filter(item=>item.notice.provider==='delivered').map(item=>h('p',{key:'delivery-'+item.notice.noticeId},item.workspaceName+':首次说明已记入可核对回复。'))):null,h(RemoteConversationDetails,{state,workbench}),h(ConversationDetailSettings,{state,action}),h(Details,{state,error,t}),h('button',{type:'button',onClick:()=>void action()},t('recheck')));}
145
+ function Panel({workbench,t}){const {state,error,action}=useWorkbench(workbench),message=messageKey(state?.startup),errorKey=error==='ENROLLMENT_REQUIRED'&&state?.enrollment?.phase==='blocked'?'enrollmentKeyMissing':error?needKey(error):undefined,bodyVisible=!['downloading','verifying','unpacking'].includes(state?.loginComponent?.download?.phase)&&(!error||!!state?.startup)&&!(state?.enrollment?.phase==='blocked'&&state.enrollment.need);return h('section',{'aria-label':t('title')},h('h2',null,t('title')),h('p',null,t('loginNetworkBoundary')),h(ComponentSource,{state,action}),h(DownloadProgress,{state,action}),h(Enrollment,{state,action,t}),bodyVisible?h('p',{role:errorKey===message?'alert':undefined},t(message)):null,errorKey&&(!bodyVisible||errorKey!==message)?h('p',{role:'alert'},t(errorKey)):null,state?h(React.Fragment,null,...['codex','claude'].map(provider=>{const brand=provider==='claude'?'Claude':'Codex',checking=state.startup?.checkingProviders?.includes(provider),unknown=state.startup?.providerNeeds?.[provider]||state.startup?.logins?.[provider]===undefined;return h('div',{key:provider},h('p',null,t('provider',{brand})+': '+t(checking?'statusChecking':unknown?'statusUnknown':state.startup.logins[provider]?'statusSignedIn':'statusSignIn')),state.startup?.logins?.[provider]===false&&!checking&&!unknown&&(!isLoginQuestion(state)||state.startup.provider!==provider)?h('button',{type:'button',disabled:state.startup.phase==='authorizing',onClick:()=>void action('/start-login/'+provider+'/'+state.startup.instanceId+'/'+state.startup.revision)},t('signIn',{brand})):null);}),h(Choices,{state,action,t}),h(WorkspaceNotices,{state,action}),h('p',null,t('workspaceLimit')),...(state.workspaceBindings?.items??[]).filter(item=>item.notice.provider==='delivered').map(item=>h('p',{key:'delivery-'+item.notice.noticeId},item.workspaceName+':首次说明已记入可核对回复。'))):null,h(RemoteConversationDetails,{state,workbench}),h(RemoteConversationDetails,{state,workbench,loginDiagnostics:true}),h(ConversationDetailSettings,{state,action}),h(Details,{state,error,t}),h('button',{type:'button',onClick:()=>void action()},t('recheck')));}
146
146
  return {name:'ai-workbench-connect-client',inject:['slots','locale'],createWorkbenchController,Panel,dictionaries,observeNotice,apply(ctx){
147
147
  ctx.effect(()=>ctx.locale.register(NS,dictionaries));const t=ctx.locale.bind(NS);
148
148
  const workbench=createWorkbenchController(async(path,method,body)=>{const response=await fetch('/ai-workbench-connect'+path,{method,...body===undefined?{}:{headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}});const value=await response.json();if(!response.ok&&!value?.startup&&typeof value?.ok!=='boolean')throw Error('connection-unavailable');return value;},(accept,failure)=>{const source=new EventSource('/ai-workbench-connect/events');source.onmessage=event=>{try{accept(JSON.parse(event.data));}catch{failure();}};source.onerror=failure;return()=>source.close();});
@@ -1,7 +1,7 @@
1
1
  // Public transport adapter. SSH status describes this protocol, not the child command.
2
2
  const {spawn}=require('node:child_process');
3
3
  let request='',child,broken=false;
4
- const killChild=()=>{if(child?.pid)try{process.kill(-child.pid,'SIGKILL');}catch(error){if(error?.code!=='ESRCH'){process.stderr.write('AW_REMOTE_COMMAND_KILL_FAILED\n');process.exitCode=2;}}};
4
+ const killChild=()=>{if(child?.pid)try{process.kill(-child.pid,'SIGKILL');}catch(error){if(error?.code!=='ESRCH'){const name=['Error','TypeError','RangeError'].includes(error?.name)?error.name:'UnknownError',code=['EPERM','EACCES','EINVAL'].includes(error?.code)?error.code:'UNKNOWN';process.stderr.write('AW_REMOTE_COMMAND_KILL_FAILED:'+name+':'+code+'\n');process.exitCode=2;}}};
5
5
  const stop=()=>{broken=true;killChild();};
6
6
  process.stdout.on('error',stop);process.on('SIGHUP',stop);process.on('SIGTERM',stop);
7
7
  process.stdin.setEncoding('utf8');process.stdin.on('data',b=>request+=b);
package/lib/api.d.ts CHANGED
@@ -85,6 +85,7 @@ export interface LoginPromptState {
85
85
  requestId?: string;
86
86
  provider?: Provider;
87
87
  logins: Partial<Record<Provider, boolean>>;
88
+ queuedProviders?: Provider[];
88
89
  checkingProviders: Provider[];
89
90
  providerNeeds: Partial<Record<Provider, ConnectNeed>>;
90
91
  technicalDetails: {
@@ -103,6 +104,7 @@ export interface ContractInfo {
103
104
  status: 'implemented';
104
105
  workspaceBinding: 'native-local-v1';
105
106
  remoteConversationDetails?: 1;
107
+ loginDiagnostics?: 1;
106
108
  }
107
109
  export interface WorkspaceUse {
108
110
  provider: Provider;
@@ -222,6 +224,25 @@ export interface ModelCatalogTarget {
222
224
  target: SshTarget;
223
225
  }
224
226
  export interface ConnectApi {
227
+ appendLoginDiagnostic(detail: RemoteConversationDetail): Promise<Result<{
228
+ eventId: string;
229
+ recorded: true;
230
+ }>>;
231
+ getLoginDiagnosticReceipt(query: {
232
+ eventId: string;
233
+ sessionId: string;
234
+ provider: Provider;
235
+ }): Promise<Result<{
236
+ eventId: string;
237
+ recorded: false;
238
+ } | {
239
+ eventId: string;
240
+ recorded: true;
241
+ detail: RemoteConversationDetail;
242
+ }>>;
243
+ listLoginDiagnostics(query: RemoteDetailQuery): Promise<Result<RemoteDetailPage>>;
244
+ getLoginDiagnosticState(): RemoteConversationDetailState;
245
+ onLoginDiagnosticState(listener: (state: RemoteConversationDetailState) => void): () => void;
225
246
  getModelCatalogTarget(provider: Provider): Result<ModelCatalogTarget>;
226
247
  appendRemoteConversationDetail(detail: RemoteConversationDetail): Promise<Result<{
227
248
  eventId: string;
@@ -0,0 +1,22 @@
1
+ import type { NeedCode, Result } from './api.ts';
2
+ export declare function validAuthDiagnosticReason(value: unknown): value is string;
3
+ export declare function outputSummary(value: string): {
4
+ cleanupErrors?: {
5
+ errorName: string;
6
+ errorCode: string;
7
+ }[] | undefined;
8
+ bytes: number;
9
+ sha256: string;
10
+ markers: string[];
11
+ };
12
+ export declare function exceptionSummary(error: unknown): {
13
+ errorCode?: string | undefined;
14
+ errorName: string;
15
+ };
16
+ export declare class AuthDiagnosticError extends Error {
17
+ readonly diagnostic: Record<string, unknown>;
18
+ constructor(diagnostic: Record<string, unknown>, message?: string);
19
+ }
20
+ export declare function authFailure(stage: string, reason: string, details?: Record<string, unknown>, error?: unknown, code?: NeedCode): Extract<Result<never>, {
21
+ ok: false;
22
+ }>;
@@ -0,0 +1,45 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { need } from "./needs.js";
3
+ const markers = ['AW_REMOTE_COMMAND_KILL_FAILED', 'AUTH_STATUS_JSON_INVALID:SyntaxError', 'Permission denied', 'Host key verification failed', 'Connection refused', 'Connection closed', 'command not found', 'No such file or directory'];
4
+ const names = ['Error', 'TypeError', 'SyntaxError', 'RangeError', 'AbortError', 'DOMException', 'UnknownError'];
5
+ const codes = ['ENOENT', 'EPERM', 'ESRCH', 'EACCES', 'EINVAL', 'EPIPE', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ABORT_ERR', 'UNKNOWN'];
6
+ const stages = ['ssh-status', 'ssh-result', 'remote-frame', 'auth-status', 'connect-check-login', 'integration-pre-login', 'process-spawn', 'process-stdin', 'process-output', 'process-runner'];
7
+ const reasons = ['CANCELLED', 'SSH_EXIT_NONZERO', 'SSH_STDERR_NONEMPTY', 'FRAME_TRUNCATED', 'FRAME_JSON_INVALID', 'FRAME_OBJECT_INVALID', 'FRAME_VERSION_INVALID', 'FRAME_NONCE_INVALID', 'FRAME_AFTER_END', 'FRAME_KEYS_INVALID', 'FRAME_START_MISSING', 'FRAME_BASE64_INVALID', 'REMOTE_COMMAND_SIGNAL', 'FRAME_END_EXIT_INVALID', 'FRAME_SEQUENCE_OR_VALUE_INVALID', 'FRAME_END_MISSING', 'STATUS_ARGUMENT_INVALID', 'STATUS_EXECUTION_FAILED', 'STATUS_JSON_INVALID', 'STATUS_OBJECT_INVALID', 'STATUS_KEYS_INVALID', 'STATUS_VERSION_INVALID', 'STATUS_PROVIDER_INVALID', 'STATUS_EXIT_MISMATCH', 'STATUS_LOGGED_IN_INVALID', 'STATUS_STDERR_NONEMPTY', 'STATUS_EXIT_INVALID', 'STATUS_EXCEPTION', 'STATUS_SUPERSEDED', 'SSH_PORT_MISSING', 'STDIN_FAILED', 'SPAWN_FAILED', 'OUTPUT_LIMIT_EXCEEDED', 'PROCESS_SIGNAL'];
8
+ const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
9
+ const integer = (v) => Number.isSafeInteger(v) && Number(v) >= 0;
10
+ function summary(v) { return object(v) && Object.keys(v).every(k => ['bytes', 'sha256', 'markers', 'cleanupErrors'].includes(k)) && integer(v.bytes) && typeof v.sha256 === 'string' && /^[a-f0-9]{64}$/.test(v.sha256) && Array.isArray(v.markers) && v.markers.every((m) => typeof m === 'string' && markers.includes(m)) && (v.cleanupErrors === undefined || Array.isArray(v.cleanupErrors) && v.cleanupErrors.every((e) => object(e) && Object.keys(e).sort().join(',') === 'errorCode,errorName' && ['Error', 'TypeError', 'RangeError', 'UnknownError'].includes(e.errorName) && ['EPERM', 'EACCES', 'EINVAL', 'UNKNOWN'].includes(e.errorCode))); }
11
+ export function validAuthDiagnosticReason(value) {
12
+ if (typeof value !== 'string')
13
+ return false;
14
+ let v;
15
+ try {
16
+ v = JSON.parse(value);
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ if (!object(v) || JSON.stringify(v) !== value || v.version !== 1 || !stages.includes(v.stage) || !reasons.includes(v.reason))
22
+ return false;
23
+ const fields = { version: x => x === 1, stage: x => stages.includes(x), reason: x => reasons.includes(x), errorName: x => names.includes(x), errorCode: x => codes.includes(x), exitCode: x => x === null || typeof x === 'number' && Number.isFinite(x) && Number.isInteger(x), signal: x => ['SIGTERM', 'SIGKILL', 'SIGABRT', 'SIGHUP', 'SIGINT', 'SIGSEGV', 'OTHER'].includes(x), index: integer, unexpectedCount: integer, maxOutputBytes: x => integer(x) && x > 0, observedBytes: integer, stderr: summary, output: summary, missing: x => Array.isArray(x) && x.every(k => ['nonce', 'protocolVersion', 'type', 'data', 'exitCode', 'signal', 'loggedIn', 'provider'].includes(k)), loggedInType: x => ['null', 'undefined', 'object', 'boolean', 'number', 'string', 'function', 'symbol', 'bigint'].includes(x), exitCodeType: x => ['undefined', 'object', 'boolean', 'number', 'string', 'function', 'symbol', 'bigint'].includes(x) };
24
+ return Object.entries(v).every(([key, item]) => Object.hasOwn(fields, key) && fields[key](item));
25
+ }
26
+ // Raw command output and exception messages can contain authentication material.
27
+ // Only fixed markers and content hashes cross the existing technical-detail field.
28
+ export function outputSummary(value) {
29
+ const found = markers.filter(marker => value.includes(marker));
30
+ const cleanupErrors = [...value.matchAll(/AW_REMOTE_COMMAND_KILL_FAILED:(Error|TypeError|RangeError|UnknownError):(EPERM|EACCES|EINVAL|UNKNOWN)(?=\r?\n|$)/g)].map(match => ({ errorName: match[1], errorCode: match[2] }));
31
+ return { bytes: Buffer.byteLength(value), sha256: createHash('sha256').update(value).digest('hex'), markers: found, ...(cleanupErrors.length ? { cleanupErrors } : {}) };
32
+ }
33
+ export function exceptionSummary(error) {
34
+ const value = error;
35
+ return { errorName: typeof value?.name === 'string' && names.includes(value.name) ? value.name : 'UnknownError', ...(typeof value?.code === 'string' && codes.includes(value.code) ? { errorCode: value.code } : {}) };
36
+ }
37
+ export class AuthDiagnosticError extends Error {
38
+ diagnostic;
39
+ constructor(diagnostic, message = 'REMOTE_RESULT_INVALID') { super(message); this.name = 'AuthDiagnosticError'; this.diagnostic = diagnostic; }
40
+ }
41
+ export function authFailure(stage, reason, details = {}, error, code = 'AUTH_STATUS_INVALID') {
42
+ const value = need(code);
43
+ value.officialReason = JSON.stringify({ version: 1, ...(error instanceof AuthDiagnosticError ? error.diagnostic : { stage, reason, ...details, ...(error === undefined ? {} : exceptionSummary(error)) }) });
44
+ return { ok: false, error: value };
45
+ }
@@ -1,18 +1,31 @@
1
- import { fail, ok } from "./needs.js";
1
+ import { ok } from "./needs.js";
2
+ import { authFailure, outputSummary } from "./auth-diagnostics.js";
2
3
  export function parseLoginStatus(provider, result) {
4
+ const invalid = (reason, details = {}) => authFailure('auth-status', reason, { exitCode: result.exitCode, ...(result.stderr !== '' ? { stderr: outputSummary(result.stderr) } : {}), ...details });
3
5
  let v;
4
6
  try {
5
7
  v = JSON.parse(result.stdout);
6
8
  }
7
- catch {
8
- return fail('AUTH_STATUS_INVALID');
9
+ catch (error) {
10
+ return invalid('STATUS_JSON_INVALID', { output: outputSummary(result.stdout), errorName: error instanceof SyntaxError ? 'SyntaxError' : 'UnknownError' });
9
11
  }
10
- if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).sort().join(',') !== 'exitCode,loggedIn,protocolVersion,provider')
11
- return fail('AUTH_STATUS_INVALID');
12
+ if (!v || typeof v !== 'object' || Array.isArray(v))
13
+ return invalid('STATUS_OBJECT_INVALID');
14
+ const expected = ['exitCode', 'loggedIn', 'protocolVersion', 'provider'];
15
+ if (Object.keys(v).sort().join(',') !== expected.join(','))
16
+ return invalid('STATUS_KEYS_INVALID', { missing: expected.filter(key => !Object.hasOwn(v, key)), unexpectedCount: Object.keys(v).filter(key => !expected.includes(key)).length });
12
17
  const status = v;
13
- if (status.protocolVersion !== 1 || status.provider !== provider || status.exitCode !== result.exitCode || typeof status.loggedIn !== 'boolean' || result.stderr !== '')
14
- return fail('AUTH_STATUS_INVALID');
18
+ if (status.protocolVersion !== 1)
19
+ return invalid('STATUS_VERSION_INVALID');
20
+ if (status.provider !== provider)
21
+ return invalid('STATUS_PROVIDER_INVALID');
22
+ if (status.exitCode !== result.exitCode)
23
+ return invalid('STATUS_EXIT_MISMATCH');
24
+ if (typeof status.loggedIn !== 'boolean')
25
+ return invalid('STATUS_LOGGED_IN_INVALID', { loggedInType: status.loggedIn === null ? 'null' : typeof status.loggedIn });
26
+ if (result.stderr !== '')
27
+ return invalid('STATUS_STDERR_NONEMPTY', { stderr: outputSummary(result.stderr) });
15
28
  if (status.loggedIn ? result.exitCode !== 0 : provider === 'codex' ? result.exitCode !== 1 : result.exitCode !== 0 && result.exitCode !== 1)
16
- return fail('AUTH_STATUS_INVALID');
29
+ return invalid('STATUS_EXIT_INVALID');
17
30
  return ok({ provider, loggedIn: status.loggedIn });
18
31
  }
package/lib/components.js CHANGED
@@ -7,7 +7,7 @@ export function registerComponents(ctx, inventory, temporaryDataParent, componen
7
7
  const api = () => ctx.get('aiWorkbenchConnect');
8
8
  const getState = () => {
9
9
  const network = ctx.get('aiWorkbenchNetwork'), connect = api(), state = connect?.getState(), networkState = network?.getState();
10
- return { conversationDetails: connect?.getRemoteConversationDetailState?.() ?? null, conversationDetailSettings: detailSettings() ? { ...detailSettings().getState(), metadataMaxBytes } : null, workspaceBindings: connect?.getWorkspaceBindingState?.() ?? null, loginComponent: { temporaryDataParent: temporaryDataParent ?? null, download: component?.getState() ?? null, source: sources?.getState() ?? null, observationIntervalMs }, startup: connect?.getLoginPromptState?.() ?? null, inventory: inventory ?? null, network: { serviceProvided: !!network, phase: networkState?.phase ?? 'missing', needCode: networkState?.error?.code ?? networkState?.need?.code }, enrollment: connect?.getEnrollmentState?.() ?? null, connection: connect?.getConnectionState?.() ?? null, connect: { serviceProvided: !!connect, phase: state?.phase ?? 'missing', message: state?.need ? employeeMessage(state.need.code) : '连接服务等待就绪' }, codex: inventory?.codex ? { installed: true, message: 'Codex 组件已安装' } : { installed: null, message: '组件清单未装配' } };
10
+ return { loginDiagnostics: connect?.getLoginDiagnosticState?.() ?? null, conversationDetails: connect?.getRemoteConversationDetailState?.() ?? null, conversationDetailSettings: detailSettings() ? { ...detailSettings().getState(), metadataMaxBytes } : null, workspaceBindings: connect?.getWorkspaceBindingState?.() ?? null, loginComponent: { temporaryDataParent: temporaryDataParent ?? null, download: component?.getState() ?? null, source: sources?.getState() ?? null, observationIntervalMs }, startup: connect?.getLoginPromptState?.() ?? null, inventory: inventory ?? null, network: { serviceProvided: !!network, phase: networkState?.phase ?? 'missing', needCode: networkState?.error?.code ?? networkState?.need?.code }, enrollment: connect?.getEnrollmentState?.() ?? null, connection: connect?.getConnectionState?.() ?? null, connect: { serviceProvided: !!connect, phase: state?.phase ?? 'missing', message: state?.need ? employeeMessage(state.need.code) : '连接服务等待就绪' }, codex: inventory?.codex ? { installed: true, message: 'Codex 组件已安装' } : { installed: null, message: '组件清单未装配' } };
11
11
  };
12
12
  ctx.provide('aiWorkbenchComponents', { getState });
13
13
  ctx.inject(['webServer', 'connection'], host => {
@@ -28,9 +28,9 @@ export function registerComponents(ctx, inventory, temporaryDataParent, componen
28
28
  res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
29
29
  res.statusCode = 200;
30
30
  let closed = false;
31
- let unprompt = () => { }, unconnection = () => { }, unenrollment = () => { }, unnative = () => { }, undownload = () => { }, unsource = () => { }, undetails = () => { }, undetailsettings = () => { };
31
+ let unprompt = () => { }, unconnection = () => { }, unenrollment = () => { }, unnative = () => { }, undownload = () => { }, unsource = () => { }, undetails = () => { }, unloginDetails = () => { }, undetailsettings = () => { };
32
32
  const close = () => { if (closed)
33
- return; closed = true; unprompt(); unconnection(); unenrollment(); unnative(); undownload(); unsource(); undetails(); undetailsettings(); streams.delete(close); res.end(); };
33
+ return; closed = true; unprompt(); unconnection(); unenrollment(); unnative(); undownload(); unsource(); undetails(); unloginDetails(); undetailsettings(); streams.delete(close); res.end(); };
34
34
  const send = () => { if (closed)
35
35
  return; try {
36
36
  if (!res.write('data: ' + JSON.stringify(getState()) + '\n\n'))
@@ -46,12 +46,38 @@ export function registerComponents(ctx, inventory, temporaryDataParent, componen
46
46
  undownload = component?.onState(send) ?? (() => { });
47
47
  unsource = sources?.onState(send) ?? (() => { });
48
48
  undetails = service.onRemoteConversationDetailState?.(send) ?? (() => { });
49
+ unloginDetails = service.onLoginDiagnosticState?.(send) ?? (() => { });
49
50
  undetailsettings = detailSettings()?.onState(send) ?? (() => { });
50
51
  streams.add(close);
51
52
  res.once('close', close);
52
53
  send();
53
54
  return;
54
55
  }
56
+ if (req.method === 'GET' && ['/ai-workbench-connect/login-diagnostics', '/ai-workbench-connect/login-diagnostic-receipt'].includes(path)) {
57
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
58
+ const unavailable = () => { res.statusCode = 503; res.end(JSON.stringify(fail('DEPENDENCY_REQUIRED'))); };
59
+ try {
60
+ const isReceipt = path.endsWith('-receipt');
61
+ if (!service || typeof service.getContractInfo !== 'function' || service.getContractInfo()?.loginDiagnostics !== 1 || typeof (isReceipt ? service.getLoginDiagnosticReceipt : service.listLoginDiagnostics) !== 'function') {
62
+ unavailable();
63
+ return;
64
+ }
65
+ const params = new URL(req.url ?? '/', 'http://dsh').searchParams, allowed = isReceipt ? ['sessionId', 'provider', 'eventId'] : ['sessionId', 'provider', 'cursor'];
66
+ if ([...params.keys()].some(k => !allowed.includes(k) || params.getAll(k).length !== 1)) {
67
+ res.statusCode = 400;
68
+ res.end(JSON.stringify(fail('REMOTE_DETAIL_INVALID')));
69
+ return;
70
+ }
71
+ const query = { sessionId: params.get('sessionId') ?? '', provider: params.get('provider') };
72
+ const result = isReceipt ? await service.getLoginDiagnosticReceipt({ ...query, eventId: params.get('eventId') ?? '' }) : await service.listLoginDiagnostics({ ...query, ...(params.has('cursor') ? { cursor: params.get('cursor') } : {}) });
73
+ res.statusCode = result.ok ? 200 : 409;
74
+ res.end(JSON.stringify(result));
75
+ }
76
+ catch {
77
+ unavailable();
78
+ }
79
+ return;
80
+ }
55
81
  if (req.method === 'GET' && ['/ai-workbench-connect/conversation-details', '/ai-workbench-connect/conversation-detail-receipt'].includes(path) && service) {
56
82
  const params = new URL(req.url ?? '/', 'http://dsh').searchParams, isReceipt = path.endsWith('/conversation-detail-receipt'), allowed = isReceipt ? ['sessionId', 'provider', 'eventId'] : ['sessionId', 'provider', 'cursor'];
57
83
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
package/lib/connect.js CHANGED
@@ -3,6 +3,7 @@ import { dependency, fail, ok, need } from "./needs.js";
3
3
  import { isProjectName } from "./projects.js";
4
4
  import { createStartup } from "./startup.js";
5
5
  import { parseLoginStatus } from "./auth-status.js";
6
+ import { authFailure } from "./auth-diagnostics.js";
6
7
  export function validEnrollment(e) {
7
8
  return typeof e === 'object' && e !== null && typeof e.host === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(e.host)
8
9
  && typeof e.username === 'string' && /^emp-[a-z0-9][a-z0-9-]*$/.test(e.username)
@@ -51,10 +52,10 @@ export function createConnect(ports, emit, options = {}) {
51
52
  loginFlights.clear();
52
53
  } const value = block(r); if (!r.ok && !disposed)
53
54
  setConnection('unavailable', r.error); return value; };
54
- const connect = () => {
55
+ const connect = (reuseTrusted = false) => {
55
56
  if (disposed)
56
57
  return Promise.resolve(fail('DISPOSED'));
57
- if (state.target && connection.phase === 'connected' && trustedTarget)
58
+ if ((state.target || reuseTrusted) && connection.phase === 'connected' && trustedTarget)
58
59
  return Promise.resolve(ok(structuredClone(trustedTarget)));
59
60
  if (flight)
60
61
  return flight;
@@ -118,30 +119,46 @@ export function createConnect(ports, emit, options = {}) {
118
119
  void promise.then(clear, clear);
119
120
  return promise;
120
121
  };
121
- const checkLogin = async (provider) => {
122
+ const checkLogin = async (provider, reuseTrusted = false) => {
122
123
  if (provider !== 'claude' && provider !== 'codex')
123
124
  return fail('AUTH_STATUS_INVALID');
124
- const connecting = connect(), g = generation;
125
+ const connecting = connect(reuseTrusted), g = generation;
125
126
  const connected = await connecting;
126
127
  const staleConnection = invalid(g);
127
128
  if (staleConnection)
128
129
  return staleConnection;
129
130
  if (!connected.ok)
130
131
  return connected;
132
+ const signal = controller.signal;
131
133
  publish({ phase: 'checking-login', target: connected.value, provider });
134
+ const stalePublish = invalid(g);
135
+ if (stalePublish)
136
+ return stalePublish;
137
+ const ticket = startup.beginCheck(provider);
138
+ const staleTicket = invalid(g);
139
+ if (staleTicket)
140
+ return staleTicket;
132
141
  try {
133
- const raw = await ports.ssh.status(connected.value, provider, controller.signal);
142
+ const raw = await ports.ssh.status(connected.value, provider, signal);
134
143
  const stale = invalid(g);
135
144
  if (stale)
136
145
  return stale;
137
146
  const status = raw.ok ? parseLoginStatus(provider, raw.value) : raw;
147
+ if (!startup.finishCheck(ticket, status))
148
+ return invalid(g) ?? authFailure('connect-check-login', 'STATUS_SUPERSEDED');
138
149
  if (!status.ok)
139
150
  return ['network', 'ssh', 'enrollment'].includes(status.error.layer) ? transportBlock(status) : block(status);
140
151
  publish(status.value.loggedIn ? { phase: 'ready', target: connected.value, provider } : { phase: 'connected', target: connected.value, provider });
141
- return status;
152
+ return invalid(g) ?? status;
142
153
  }
143
- catch {
144
- return invalid(g) ?? block(fail('AUTH_STATUS_INVALID'));
154
+ catch (error) {
155
+ const stale = invalid(g);
156
+ if (stale)
157
+ return stale;
158
+ const failure = authFailure('connect-check-login', 'STATUS_EXCEPTION', {}, error);
159
+ if (!startup.finishCheck(ticket, failure))
160
+ return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
161
+ return block(failure);
145
162
  }
146
163
  };
147
164
  const login = (provider) => {
@@ -162,21 +179,39 @@ export function createConnect(ports, emit, options = {}) {
162
179
  return connected;
163
180
  if (!ports.login)
164
181
  return block(dependency('Electron 登录助手及已确认设置'));
182
+ const owner = startup.captureCheck(provider), signal = controller.signal;
165
183
  publish({ phase: 'login', target: connected.value, provider });
184
+ const stalePublish = invalid(g);
185
+ if (stalePublish)
186
+ return stalePublish;
187
+ if (!startup.currentTicket(owner))
188
+ return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
166
189
  try {
167
- const r = await ports.login.run(connected.value, provider, controller.signal);
190
+ const r = await ports.login.run(connected.value, provider, signal);
168
191
  const stale = invalid(g);
169
192
  if (stale)
170
193
  return stale;
194
+ if (!startup.currentTicket(owner))
195
+ return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
171
196
  if (!r.ok)
172
197
  return ['network', 'ssh', 'enrollment'].includes(r.error.layer) ? transportBlock(r) : block(r);
173
198
  const checked = await checkLogin(provider), staleChecked = invalid(g);
174
199
  if (staleChecked)
175
200
  return staleChecked;
176
- return checked.ok && !checked.value.loggedIn ? block(fail('LOGIN_FAILED')) : checked;
201
+ if (checked.ok && !checked.value.loggedIn) {
202
+ const failed = block(fail('LOGIN_FAILED'));
203
+ startup.inheritCheck(checked, failed);
204
+ return failed;
205
+ }
206
+ return checked;
177
207
  }
178
208
  catch {
179
- return invalid(g) ?? block(fail('LOGIN_FAILED'));
209
+ const stale = invalid(g);
210
+ if (stale)
211
+ return stale;
212
+ if (!startup.currentTicket(owner))
213
+ return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
214
+ return block(fail('LOGIN_FAILED'));
180
215
  }
181
216
  };
182
217
  const promise = track(run());
@@ -224,7 +259,7 @@ export function createConnect(ports, emit, options = {}) {
224
259
  }
225
260
  });
226
261
  const operations = { getState: () => structuredClone(state), connect, checkLogin: provider => track(checkLogin(provider)), login };
227
- startup = createStartup(operations, options.providers ?? ['codex', 'claude'], options.onPromptState, options.onPromptResult, instanceId);
262
+ startup = createStartup({ ...operations, connect: () => connect(true), checkLogin: provider => track(checkLogin(provider, true)) }, options.providers ?? ['codex', 'claude'], options.onPromptState, options.onPromptResult, instanceId);
228
263
  return {
229
264
  ...operations,
230
265
  getModelCatalogTarget: provider => {
@@ -237,6 +272,11 @@ export function createConnect(ports, emit, options = {}) {
237
272
  return ok({ version: 1, purpose: 'model-catalog', scope: 'host-user', instanceId, epoch: connection.epoch, provider, target: structuredClone(trustedTarget) });
238
273
  },
239
274
  appendRemoteConversationDetail: async () => dependency('官方会话存储与持久诊断记录'),
275
+ appendLoginDiagnostic: async () => dependency('官方会话存储与持久登录诊断记录'),
276
+ getLoginDiagnosticReceipt: async () => dependency('官方会话存储与持久登录诊断记录'),
277
+ listLoginDiagnostics: async () => dependency('官方会话存储与持久登录诊断记录'),
278
+ getLoginDiagnosticState: () => ({ instanceId, epoch: connection.epoch, revision: 0, phase: disposed ? 'disposed' : 'unavailable', sessions: [], need: need(disposed ? 'DISPOSED' : 'DEPENDENCY_REQUIRED') }),
279
+ onLoginDiagnosticState: () => () => { },
240
280
  getRemoteConversationDetailReceipt: async () => dependency('官方会话存储与持久诊断记录'),
241
281
  getRemoteConversationDetailState: () => ({ instanceId, epoch: connection.epoch, revision: 0, phase: disposed ? 'disposed' : 'unavailable', sessions: [], need: need(disposed ? 'DISPOSED' : 'DEPENDENCY_REQUIRED') }),
242
282
  onRemoteConversationDetailState: () => () => { }, listRemoteConversationDetails: async () => dependency('官方会话存储与持久诊断记录'),
@@ -3,6 +3,9 @@ export interface ConversationDetailOptions {
3
3
  profileDir: string;
4
4
  maxEventBytes: number;
5
5
  pageSize: number;
6
+ storageName?: 'conversation-details' | 'login-diagnostics';
7
+ validate?: (detail: RemoteConversationDetail) => boolean;
8
+ knownKinds?: readonly string[];
6
9
  getConnectionState: () => ConnectionState;
7
10
  context: () => Promise<Result<{
8
11
  identity: string;
@@ -48,9 +48,10 @@ export function validRemoteConversationDetail(v) {
48
48
  return false;
49
49
  return v.version === 1 && text(v.eventId) && text(v.instanceId) && integer(v.epoch) && text(v.sessionId) && brand(v.provider) && integer(v.sequence) && [v.turn, v.step].every(x => x === null || text(x) || integer(x)) && text(v.kind) && ['info', 'warning', 'action-required'].includes(v.severity) && text(v.code) && typeof v.summary === 'string' && (v.method === undefined || text(v.method));
50
50
  }
51
- const counts = (items) => ({ total: items.length, unknownCount: items.filter(d => !['extensions', 'commands', 'tools', 'usage', 'action-required'].includes(d.kind)).length, actionRequiredCount: items.filter(d => d.severity === 'action-required' || d.kind === 'action-required').length });
51
+ const counts = (items, knownKinds = ['extensions', 'commands', 'tools', 'usage', 'action-required']) => ({ total: items.length, unknownCount: items.filter(d => !knownKinds.includes(d.kind)).length, actionRequiredCount: items.filter(d => d.severity === 'action-required' || d.kind === 'action-required').length });
52
52
  export function createConversationDetails(options) {
53
- const parent = join(options.profileDir, 'ai-workbench-connect'), root = join(parent, 'conversation-details');
53
+ const parent = join(options.profileDir, 'ai-workbench-connect'), root = join(parent, options.storageName ?? 'conversation-details');
54
+ const valid = (detail) => validRemoteConversationDetail(detail) && (!options.validate || options.validate(detail));
54
55
  let disposed = false, uncertain = false, revision = 0, tail = Promise.resolve();
55
56
  let snapshot = { ...connectionIds(), revision, phase: 'unavailable', sessions: [] };
56
57
  const listeners = new Set();
@@ -162,7 +163,7 @@ export function createConversationDetails(options) {
162
163
  if (!st.isFile() || st.uid !== process.getuid() || st.nlink !== 1 || (st.mode & 0o777) !== 0o600)
163
164
  throw Error('DETAIL_RECORD_INVALID');
164
165
  const record = JSON.parse(await checked(h.readFile('utf8'), before));
165
- if (record.version !== 1 || !validRemoteConversationDetail(record.detail) || record.digest !== digest(canonical(record.detail)) || canonical(record.detail) !== canonical(detail))
166
+ if (record.version !== 1 || !valid(record.detail) || record.digest !== digest(canonical(record.detail)) || canonical(record.detail) !== canonical(detail))
166
167
  throw Error('DETAIL_RECORD_INVALID');
167
168
  await checked(h.sync(), before);
168
169
  }
@@ -200,7 +201,7 @@ export function createConversationDetails(options) {
200
201
  if (!/^[a-f0-9]{64}\.json$/.test(name))
201
202
  throw Error('DETAIL_RECORD_INVALID');
202
203
  const raw = JSON.parse(await checked(privateRead(join(domain, name), before), before)), d = raw.detail;
203
- if (raw.version !== 1 || !validRemoteConversationDetail(d) || raw.digest !== digest(canonical(d)) || name !== digest(d.eventId) + '.json' || ids.has(d.eventId) || sequences.has(canonical([d.sessionId, d.provider, d.sequence])))
204
+ if (raw.version !== 1 || !valid(d) || raw.digest !== digest(canonical(d)) || name !== digest(d.eventId) + '.json' || ids.has(d.eventId) || sequences.has(canonical([d.sessionId, d.provider, d.sequence])))
204
205
  throw Error('DETAIL_RECORD_INVALID');
205
206
  ids.add(d.eventId);
206
207
  sequences.add(canonical([d.sessionId, d.provider, d.sequence]));
@@ -227,7 +228,7 @@ export function createConversationDetails(options) {
227
228
  excluded = true;
228
229
  continue;
229
230
  }
230
- sessions.push({ sessionId: d.sessionId, provider: d.provider, ...counts(items.filter(r => r.sessionId === d.sessionId && r.provider === d.provider)) });
231
+ sessions.push({ sessionId: d.sessionId, provider: d.provider, ...counts(items.filter(r => r.sessionId === d.sessionId && r.provider === d.provider), options.knownKinds) });
231
232
  }
232
233
  check(before);
233
234
  publish('ready', sessions, excluded ? 'REMOTE_DETAIL_SCOPE_INVALID' : undefined);
@@ -250,7 +251,7 @@ export function createConversationDetails(options) {
250
251
  appendRemoteConversationDetail(input) {
251
252
  let detail;
252
253
  try {
253
- if (!validRemoteConversationDetail(input))
254
+ if (!valid(input))
254
255
  return Promise.resolve(fail('REMOTE_DETAIL_INVALID'));
255
256
  detail = structuredClone(input);
256
257
  }
@@ -360,7 +361,7 @@ export function createConversationDetails(options) {
360
361
  await stateRows(items, identity, before);
361
362
  if (await scope(q.sessionId, q.provider, before) !== identity)
362
363
  return fail('REMOTE_DETAIL_SCOPE_INVALID');
363
- const value = { items: structuredClone(page), ...counts(bounded), ...(page.length < remaining.length ? { nextCursor: cursor({ version: 1, identity, sessionId: q.sessionId, provider: q.provider, upper, after: page.at(-1).sequence }, secret) } : {}) };
364
+ const value = { items: structuredClone(page), ...counts(bounded, options.knownKinds), ...(page.length < remaining.length ? { nextCursor: cursor({ version: 1, identity, sessionId: q.sessionId, provider: q.provider, upper, after: page.at(-1).sequence }, secret) } : {}) };
364
365
  return ok(value);
365
366
  });
366
367
  },
package/lib/cordis.d.ts CHANGED
@@ -4,6 +4,7 @@ declare module '@deepseek-ai/cordis' {
4
4
  aiWorkbenchConnect: ConnectApi;
5
5
  }
6
6
  interface Events {
7
+ 'ai-workbench/login-diagnostic-state': (state: RemoteConversationDetailState) => void;
7
8
  'ai-workbench/remote-conversation-detail-state': (state: RemoteConversationDetailState) => void;
8
9
  'ai-workbench/enrollment-state': (state: EnrollmentState) => void;
9
10
  'ai-workbench/workspace-binding-state': (state: WorkspaceBindingState) => void;
@@ -10,6 +10,7 @@ import { runLogin } from "./login.js";
10
10
  import { ensureHelper } from "./cache.js";
11
11
  import { launchElectron } from "./electron.js";
12
12
  import { parseLoginStatus } from "./auth-status.js";
13
+ import { authFailure } from "./auth-diagnostics.js";
13
14
  import { need, fail, ok } from "./needs.js";
14
15
  import { createComponentProgress, downloadComponent } from "./component-download.js";
15
16
  export function configuredPorts(config, provider, component = createComponentProgress(), sources) {
@@ -48,7 +49,7 @@ export function configuredPorts(config, provider, component = createComponentPro
48
49
  return fail('LOGIN_FAILED');
49
50
  // Component preparation may outlive another device's completed login. Recheck immediately before any destructive CLI authorization.
50
51
  if (!ports.ssh)
51
- return fail('AUTH_STATUS_INVALID');
52
+ return authFailure('integration-pre-login', 'SSH_PORT_MISSING');
52
53
  try {
53
54
  const raw = await ports.ssh.status(target, provider, signal);
54
55
  if (signal.aborted)
@@ -61,8 +62,8 @@ export function configuredPorts(config, provider, component = createComponentPro
61
62
  if (current.value.loggedIn)
62
63
  return ok(undefined);
63
64
  }
64
- catch {
65
- return fail('AUTH_STATUS_INVALID');
65
+ catch (error) {
66
+ return authFailure('integration-pre-login', 'STATUS_EXCEPTION', {}, error);
66
67
  }
67
68
  return runLogin(target, provider, {
68
69
  start: (t, p, s) => createRemoteLogin('ssh', [...sshArgs(t), remoteLoginCommand(p, h.remoteKillGraceMs)], p, h.loginOutputMaxBytes, s, 'remote-control'),