@hellcoder/companion 0.98.0 → 0.98.1-preview.20260515064257.15d15e5

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.
Files changed (30) hide show
  1. package/dist/assets/{AgentsPage-BL55sxWq.js → AgentsPage-rAYJ4cSY.js} +1 -1
  2. package/dist/assets/{CronManager-DkycDZO7.js → CronManager-D3Evml4L.js} +1 -1
  3. package/dist/assets/{IntegrationsPage-CKzuwKSL.js → IntegrationsPage-BlTiQtFv.js} +1 -1
  4. package/dist/assets/{LinearOAuthSettingsPage-D7FyIauR.js → LinearOAuthSettingsPage-C20zrdLZ.js} +1 -1
  5. package/dist/assets/{LinearSettingsPage-Cu39iFUZ.js → LinearSettingsPage-YbEOl57Z.js} +1 -1
  6. package/dist/assets/{Playground-KsXOh1KL.js → Playground-peCPHUft.js} +1 -1
  7. package/dist/assets/{PromptsPage-BSmcBPRr.js → PromptsPage-B6L_sVBa.js} +1 -1
  8. package/dist/assets/{RunsPage-Bdyig9xV.js → RunsPage-B4fzcy4J.js} +1 -1
  9. package/dist/assets/{SandboxManager-BVls-Ijd.js → SandboxManager-BPVyIBrj.js} +1 -1
  10. package/dist/assets/{SettingsPage-B6PJ98wg.js → SettingsPage-jrLolcpw.js} +1 -1
  11. package/dist/assets/{TailscalePage-OzuS9aO8.js → TailscalePage-DpGOGAEd.js} +1 -1
  12. package/dist/assets/index-BjomRUsd.css +1 -0
  13. package/dist/assets/{index-DKeYkY1b.js → index-Du0oTC_8.js} +51 -51
  14. package/dist/assets/{sw-register-lhAZpK0T.js → sw-register-DE3JFRS4.js} +1 -1
  15. package/dist/index.html +2 -2
  16. package/dist/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/server/claude-compat-checker.ts +221 -0
  19. package/server/claude-patcher.test.ts +85 -0
  20. package/server/claude-patcher.ts +258 -0
  21. package/server/claude-tls.ts +84 -0
  22. package/server/claude-versions.test.ts +96 -0
  23. package/server/claude-versions.ts +76 -0
  24. package/server/cli-ingress-server.ts +101 -0
  25. package/server/cli-launcher.ts +25 -5
  26. package/server/index.ts +26 -0
  27. package/server/routes/system-routes.ts +134 -1
  28. package/server/settings-manager.test.ts +9 -0
  29. package/server/settings-manager.ts +30 -1
  30. package/dist/assets/index-DwVmncqT.css +0 -1
@@ -1 +1 @@
1
- import{_ as u}from"./index-DKeYkY1b.js";function w(r={}){const{immediate:i=!1,onNeedReload:d,onNeedRefresh:_,onOfflineReady:s,onRegistered:n,onRegisteredSW:o,onRegisterError:t}=r;let a,c;const l=async(e=!0)=>{await c};async function f(){if("serviceWorker"in navigator){if(a=await u(async()=>{const{Workbox:e}=await import("./workbox-window.prod.es5-BBnX5xw4.js");return{Workbox:e}},[]).then(({Workbox:e})=>new e("/sw.js",{scope:"/",type:"classic"})).catch(e=>{t==null||t(e)}),!a)return;a.addEventListener("activated",e=>{(e.isUpdate||e.isExternal)&&(d?d():window.location.reload())}),a.addEventListener("installed",e=>{e.isUpdate||s==null||s()}),a.register({immediate:i}).then(e=>{o?o("/sw.js",e):n==null||n(e)}).catch(e=>{t==null||t(e)})}}return c=f(),l}w({onRegisteredSW(r,i){i&&setInterval(()=>{i.update()},3600*1e3)},onOfflineReady(){console.log("[SW] Offline-ready: all assets precached")}});
1
+ import{_ as u}from"./index-Du0oTC_8.js";function w(r={}){const{immediate:i=!1,onNeedReload:d,onNeedRefresh:_,onOfflineReady:s,onRegistered:n,onRegisteredSW:o,onRegisterError:t}=r;let a,c;const l=async(e=!0)=>{await c};async function f(){if("serviceWorker"in navigator){if(a=await u(async()=>{const{Workbox:e}=await import("./workbox-window.prod.es5-BBnX5xw4.js");return{Workbox:e}},[]).then(({Workbox:e})=>new e("/sw.js",{scope:"/",type:"classic"})).catch(e=>{t==null||t(e)}),!a)return;a.addEventListener("activated",e=>{(e.isUpdate||e.isExternal)&&(d?d():window.location.reload())}),a.addEventListener("installed",e=>{e.isUpdate||s==null||s()}),a.register({immediate:i}).then(e=>{o?o("/sw.js",e):n==null||n(e)}).catch(e=>{t==null||t(e)})}}return c=f(),l}w({onRegisteredSW(r,i){i&&setInterval(()=>{i.update()},3600*1e3)},onOfflineReady(){console.log("[SW] Offline-ready: all assets precached")}});
package/dist/index.html CHANGED
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="Companion ME" />
12
12
  <meta name="theme-color" content="#d97757" />
13
13
  <title>The Companion — Moritz Edition</title>
14
- <script type="module" crossorigin src="/assets/index-DKeYkY1b.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DwVmncqT.css">
14
+ <script type="module" crossorigin src="/assets/index-Du0oTC_8.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BjomRUsd.css">
16
16
  </head>
17
17
  <body class="bg-cc-bg text-cc-fg">
18
18
  <div id="root"></div>
package/dist/sw.js CHANGED
@@ -1,2 +1,2 @@
1
1
  try{self["workbox:core:7.4.0"]&&_()}catch{}const x=(a,...e)=>{let t=a;return e.length>0&&(t+=` :: ${JSON.stringify(e)}`),t},N=x;class l extends Error{constructor(e,t){const s=N(e,t);super(s),this.name=e,this.details=t}}const E=new Set,f={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:typeof registration<"u"?registration.scope:""},b=a=>[f.prefix,a,f.suffix].filter(e=>e&&e.length>0).join("-"),O=a=>{for(const e of Object.keys(f))a(e)},C={updateDetails:a=>{O(e=>{typeof a[e]=="string"&&(f[e]=a[e])})},getGoogleAnalyticsName:a=>a||b(f.googleAnalytics),getPrecacheName:a=>a||b(f.precache),getPrefix:()=>f.prefix,getRuntimeName:a=>a||b(f.runtime),getSuffix:()=>f.suffix};function P(a,e){const t=new URL(a);for(const s of e)t.searchParams.delete(s);return t.href}async function I(a,e,t,s){const n=P(e.url,t);if(e.url===n)return a.match(e,s);const r=Object.assign(Object.assign({},s),{ignoreSearch:!0}),c=await a.keys(e,r);for(const i of c){const o=P(i.url,t);if(n===o)return a.match(i,s)}}let y;function M(){if(y===void 0){const a=new Response("");if("body"in a)try{new Response(a.body),y=!0}catch{y=!1}y=!1}return y}class D{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}}async function S(){for(const a of E)await a()}const W=a=>new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),"");function A(a){return new Promise(e=>setTimeout(e,a))}function K(a,e){const t=e();return a.waitUntil(t),t}async function q(a,e){let t=null;if(a.url&&(t=new URL(a.url).origin),t!==self.location.origin)throw new l("cross-origin-copy-response",{origin:t});const s=a.clone(),r={headers:new Headers(s.headers),status:s.status,statusText:s.statusText},c=M()?s.body:await s.blob();return new Response(c,r)}function j(){self.addEventListener("activate",()=>self.clients.claim())}try{self["workbox:precaching:7.4.0"]&&_()}catch{}const H="__WB_REVISION__";function F(a){if(!a)throw new l("add-to-cache-list-unexpected-type",{entry:a});if(typeof a=="string"){const r=new URL(a,location.href);return{cacheKey:r.href,url:r.href}}const{revision:e,url:t}=a;if(!t)throw new l("add-to-cache-list-unexpected-type",{entry:a});if(!e){const r=new URL(t,location.href);return{cacheKey:r.href,url:r.href}}const s=new URL(t,location.href),n=new URL(t,location.href);return s.searchParams.set(H,e),{cacheKey:s.href,url:n.href}}class B{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)},this.cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:s})=>{if(e.type==="install"&&t&&t.originalRequest&&t.originalRequest instanceof Request){const n=t.originalRequest.url;s?this.notUpdatedURLs.push(n):this.updatedURLs.push(n)}return s}}}class ${constructor({precacheController:e}){this.cacheKeyWillBeUsed=async({request:t,params:s})=>{const n=(s==null?void 0:s.cacheKey)||this._precacheController.getCacheKeyForURL(t.url);return n?new Request(n,{headers:t.headers}):t},this._precacheController=e}}try{self["workbox:strategies:7.4.0"]&&_()}catch{}function m(a){return typeof a=="string"?new Request(a):a}class G{constructor(e,t){this._cacheKeys={},Object.assign(this,t),this.event=t.event,this._strategy=e,this._handlerDeferred=new D,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map;for(const s of this._plugins)this._pluginStateMap.set(s,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){const{event:t}=this;let s=m(e);if(s.mode==="navigate"&&t instanceof FetchEvent&&t.preloadResponse){const c=await t.preloadResponse;if(c)return c}const n=this.hasCallback("fetchDidFail")?s.clone():null;try{for(const c of this.iterateCallbacks("requestWillFetch"))s=await c({request:s.clone(),event:t})}catch(c){if(c instanceof Error)throw new l("plugin-error-request-will-fetch",{thrownErrorMessage:c.message})}const r=s.clone();try{let c;c=await fetch(s,s.mode==="navigate"?void 0:this._strategy.fetchOptions);for(const i of this.iterateCallbacks("fetchDidSucceed"))c=await i({event:t,request:r,response:c});return c}catch(c){throw n&&await this.runCallbacks("fetchDidFail",{error:c,event:t,originalRequest:n.clone(),request:r.clone()}),c}}async fetchAndCachePut(e){const t=await this.fetch(e),s=t.clone();return this.waitUntil(this.cachePut(e,s)),t}async cacheMatch(e){const t=m(e);let s;const{cacheName:n,matchOptions:r}=this._strategy,c=await this.getCacheKey(t,"read"),i=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(c,i);for(const o of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await o({cacheName:n,matchOptions:r,cachedResponse:s,request:c,event:this.event})||void 0;return s}async cachePut(e,t){const s=m(e);await A(0);const n=await this.getCacheKey(s,"write");if(!t)throw new l("cache-put-with-no-response",{url:W(n.url)});const r=await this._ensureResponseSafeToCache(t);if(!r)return!1;const{cacheName:c,matchOptions:i}=this._strategy,o=await self.caches.open(c),h=this.hasCallback("cacheDidUpdate"),p=h?await I(o,n.clone(),["__WB_REVISION__"],i):null;try{await o.put(n,h?r.clone():r)}catch(u){if(u instanceof Error)throw u.name==="QuotaExceededError"&&await S(),u}for(const u of this.iterateCallbacks("cacheDidUpdate"))await u({cacheName:c,oldResponse:p,newResponse:r.clone(),request:n,event:this.event});return!0}async getCacheKey(e,t){const s=`${e.url} | ${t}`;if(!this._cacheKeys[s]){let n=e;for(const r of this.iterateCallbacks("cacheKeyWillBeUsed"))n=m(await r({mode:t,request:n,event:this.event,params:this.params}));this._cacheKeys[s]=n}return this._cacheKeys[s]}hasCallback(e){for(const t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(const s of this.iterateCallbacks(e))await s(t)}*iterateCallbacks(e){for(const t of this._strategy.plugins)if(typeof t[e]=="function"){const s=this._pluginStateMap.get(t);yield r=>{const c=Object.assign(Object.assign({},r),{state:s});return t[e](c)}}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){for(;this._extendLifetimePromises.length;){const e=this._extendLifetimePromises.splice(0),s=(await Promise.allSettled(e)).find(n=>n.status==="rejected");if(s)throw s.reason}}destroy(){this._handlerDeferred.resolve(null)}async _ensureResponseSafeToCache(e){let t=e,s=!1;for(const n of this.iterateCallbacks("cacheWillUpdate"))if(t=await n({request:this.request,response:t,event:this.event})||void 0,s=!0,!t)break;return s||t&&t.status!==200&&(t=void 0),t}}class V{constructor(e={}){this.cacheName=C.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){const[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});const t=e.event,s=typeof e.request=="string"?new Request(e.request):e.request,n="params"in e?e.params:void 0,r=new G(this,{event:t,request:s,params:n}),c=this._getResponse(r,s,t),i=this._awaitComplete(c,r,s,t);return[c,i]}async _getResponse(e,t,s){await e.runCallbacks("handlerWillStart",{event:s,request:t});let n;try{if(n=await this._handle(t,e),!n||n.type==="error")throw new l("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(const c of e.iterateCallbacks("handlerDidError"))if(n=await c({error:r,event:s,request:t}),n)break}if(!n)throw r}for(const r of e.iterateCallbacks("handlerWillRespond"))n=await r({event:s,request:t,response:n});return n}async _awaitComplete(e,t,s,n){let r,c;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:n,request:s,response:r}),await t.doneWaiting()}catch(i){i instanceof Error&&(c=i)}if(await t.runCallbacks("handlerDidComplete",{event:n,request:s,response:r,error:c}),t.destroy(),c)throw c}}class d extends V{constructor(e={}){e.cacheName=C.getPrecacheName(e.cacheName),super(e),this._fallbackToNetwork=e.fallbackToNetwork!==!1,this.plugins.push(d.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){const s=await t.cacheMatch(e);return s||(t.event&&t.event.type==="install"?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let s;const n=t.params||{};if(this._fallbackToNetwork){const r=n.integrity,c=e.integrity,i=!c||c===r;s=await t.fetch(new Request(e,{integrity:e.mode!=="no-cors"?c||r:void 0})),r&&i&&e.mode!=="no-cors"&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,s.clone()))}else throw new l("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return s}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();const s=await t.fetch(e);if(!await t.cachePut(e,s.clone()))throw new l("bad-precaching-response",{url:e.url,status:s.status});return s}_useDefaultCacheabilityPluginIfNeeded(){let e=null,t=0;for(const[s,n]of this.plugins.entries())n!==d.copyRedirectedCacheableResponsesPlugin&&(n===d.defaultPrecacheCacheabilityPlugin&&(e=s),n.cacheWillUpdate&&t++);t===0?this.plugins.push(d.defaultPrecacheCacheabilityPlugin):t>1&&e!==null&&this.plugins.splice(e,1)}}d.defaultPrecacheCacheabilityPlugin={async cacheWillUpdate({response:a}){return!a||a.status>=400?null:a}};d.copyRedirectedCacheableResponsesPlugin={async cacheWillUpdate({response:a}){return a.redirected?await q(a):a}};class Q{constructor({cacheName:e,plugins:t=[],fallbackToNetwork:s=!0}={}){this._urlsToCacheKeys=new Map,this._urlsToCacheModes=new Map,this._cacheKeysToIntegrities=new Map,this._strategy=new d({cacheName:C.getPrecacheName(e),plugins:[...t,new $({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this._strategy}precache(e){this.addToCacheList(e),this._installAndActiveListenersAdded||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this._installAndActiveListenersAdded=!0)}addToCacheList(e){const t=[];for(const s of e){typeof s=="string"?t.push(s):s&&s.revision===void 0&&t.push(s.url);const{cacheKey:n,url:r}=F(s),c=typeof s!="string"&&s.revision?"reload":"default";if(this._urlsToCacheKeys.has(r)&&this._urlsToCacheKeys.get(r)!==n)throw new l("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(r),secondEntry:n});if(typeof s!="string"&&s.integrity){if(this._cacheKeysToIntegrities.has(n)&&this._cacheKeysToIntegrities.get(n)!==s.integrity)throw new l("add-to-cache-list-conflicting-integrities",{url:r});this._cacheKeysToIntegrities.set(n,s.integrity)}if(this._urlsToCacheKeys.set(r,n),this._urlsToCacheModes.set(r,c),t.length>0){const i=`Workbox is precaching URLs without revision info: ${t.join(", ")}
2
- This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(i)}}}install(e){return K(e,async()=>{const t=new B;this.strategy.plugins.push(t);for(const[r,c]of this._urlsToCacheKeys){const i=this._cacheKeysToIntegrities.get(c),o=this._urlsToCacheModes.get(r),h=new Request(r,{integrity:i,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:c},request:h,event:e}))}const{updatedURLs:s,notUpdatedURLs:n}=t;return{updatedURLs:s,notUpdatedURLs:n}})}activate(e){return K(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),n=new Set(this._urlsToCacheKeys.values()),r=[];for(const c of s)n.has(c.url)||(await t.delete(c),r.push(c.url));return{deletedURLs:r}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new l("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let U;const L=()=>(U||(U=new Q),U);try{self["workbox:routing:7.4.0"]&&_()}catch{}const v="GET",R=a=>a&&typeof a=="object"?a:{handle:a};class g{constructor(e,t,s=v){this.handler=R(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=R(e)}}class z extends g{constructor(e,t,s){const n=({url:r})=>{const c=e.exec(r.href);if(c&&!(r.origin!==location.origin&&c.index!==0))return c.slice(1)};super(n,t,s)}}class J{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(n=>{typeof n=="string"&&(n=[n]);const r=new Request(...n);return this.handleRequest({request:r,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:c}=this.findMatchingRoute({event:t,request:e,sameOrigin:n,url:s});let i=c&&c.handler;const o=e.method;if(!i&&this._defaultHandlerMap.has(o)&&(i=this._defaultHandlerMap.get(o)),!i)return;let h;try{h=i.handle({url:s,request:e,event:t,params:r})}catch(u){h=Promise.reject(u)}const p=c&&c.catchHandler;return h instanceof Promise&&(this._catchHandler||p)&&(h=h.catch(async u=>{if(p)try{return await p.handle({url:s,request:e,event:t,params:r})}catch(k){k instanceof Error&&(u=k)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),h}findMatchingRoute({url:e,sameOrigin:t,request:s,event:n}){const r=this._routes.get(s.method)||[];for(const c of r){let i;const o=c.match({url:e,sameOrigin:t,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&i.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(i=void 0),{route:c,params:i}}return{}}setDefaultHandler(e,t=v){this._defaultHandlerMap.set(t,R(e))}setCatchHandler(e){this._catchHandler=R(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}}let w;const X=()=>(w||(w=new J,w.addFetchListener(),w.addCacheListener()),w);function T(a,e,t){let s;if(typeof a=="string"){const r=new URL(a,location.href),c=({url:i})=>i.href===r.href;s=new g(c,e,t)}else if(a instanceof RegExp)s=new z(a,e,t);else if(typeof a=="function")s=new g(a,e,t);else if(a instanceof g)s=a;else throw new l("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return X().registerRoute(s),s}function Y(a,e=[]){for(const t of[...a.searchParams.keys()])e.some(s=>s.test(t))&&a.searchParams.delete(t);return a}function*Z(a,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:n}={}){const r=new URL(a,location.href);r.hash="",yield r.href;const c=Y(r,e);if(yield c.href,t&&c.pathname.endsWith("/")){const i=new URL(c.href);i.pathname+=t,yield i.href}if(s){const i=new URL(c.href);i.pathname+=".html",yield i.href}if(n){const i=n({url:r});for(const o of i)yield o.href}}class ee extends g{constructor(e,t){const s=({request:n})=>{const r=e.getURLsToCacheKeys();for(const c of Z(n.url,t)){const i=r.get(c);if(i){const o=e.getIntegrityForCacheKey(i);return{cacheKey:i,integrity:o}}}};super(s,e.strategy)}}function te(a){const e=L(),t=new ee(e,a);T(t)}const se="-precache-",ae=async(a,e=se)=>{const s=(await self.caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==a);return await Promise.all(s.map(n=>self.caches.delete(n))),s};function ne(){self.addEventListener("activate",(a=>{const e=C.getPrecacheName();a.waitUntil(ae(e).then(t=>{}))}))}function re(a){return L().createHandlerBoundToURL(a)}function ce(a){L().precache(a)}function ie(a,e){ce(a),te(e)}class oe extends g{constructor(e,{allowlist:t=[/./],denylist:s=[]}={}){super(n=>this._match(n),e),this._allowlist=t,this._denylist=s}_match({url:e,request:t}){if(t&&t.mode!=="navigate")return!1;const s=e.pathname+e.search;for(const n of this._denylist)if(n.test(s))return!1;return!!this._allowlist.some(n=>n.test(s))}}self.skipWaiting();j();ie([{"revision":"fab346dacb0b9b9af12554e9a59f004d","url":"logo.svg"},{"revision":"623840baec64a3438663496bb1050d30","url":"logo-docker.svg"},{"revision":"c5c8611b432190ec28522f1a2dfcbb7f","url":"logo-codex.svg"},{"revision":"8bac0c6e276e43853454ce190fb96b63","url":"index.html"},{"revision":"1421e9c6cad8218ef5689b85034d3a95","url":"icon-512.png"},{"revision":"b96da5a0b6636012517f54b0e69c304d","url":"icon-192.png"},{"revision":"c64ca8ae8596d4ffe13d0f591beb2274","url":"favicon.svg"},{"revision":"6481face04bd08e6f0bfc2a633840b57","url":"apple-touch-icon.png"},{"revision":"83304194a3c8be2b5b61242eeb1c1046","url":"fonts/MesloLGSNerdFontMono-Regular.woff2"},{"revision":"60fafe18cbcb717c51cdabf87f9490f0","url":"fonts/MesloLGSNerdFontMono-Bold.woff2"},{"revision":null,"url":"assets/workbox-window.prod.es5-BBnX5xw4.js"},{"revision":null,"url":"assets/sw-register-lhAZpK0T.js"},{"revision":null,"url":"assets/index-DwVmncqT.css"},{"revision":null,"url":"assets/index-DKeYkY1b.js"},{"revision":null,"url":"assets/TailscalePage-OzuS9aO8.js"},{"revision":null,"url":"assets/SettingsPage-B6PJ98wg.js"},{"revision":null,"url":"assets/SandboxManager-BVls-Ijd.js"},{"revision":null,"url":"assets/RunsPage-Bdyig9xV.js"},{"revision":null,"url":"assets/PromptsPage-BSmcBPRr.js"},{"revision":null,"url":"assets/Playground-KsXOh1KL.js"},{"revision":null,"url":"assets/LinearSettingsPage-Cu39iFUZ.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-D7FyIauR.js"},{"revision":null,"url":"assets/IntegrationsPage-CKzuwKSL.js"},{"revision":null,"url":"assets/CronManager-DkycDZO7.js"},{"revision":null,"url":"assets/AgentsPage-BL55sxWq.js"}]);ne();T(new oe(re("index.html"),{denylist:[/^\/api/,/^\/ws/]}));
2
+ This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(i)}}}install(e){return K(e,async()=>{const t=new B;this.strategy.plugins.push(t);for(const[r,c]of this._urlsToCacheKeys){const i=this._cacheKeysToIntegrities.get(c),o=this._urlsToCacheModes.get(r),h=new Request(r,{integrity:i,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:c},request:h,event:e}))}const{updatedURLs:s,notUpdatedURLs:n}=t;return{updatedURLs:s,notUpdatedURLs:n}})}activate(e){return K(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),n=new Set(this._urlsToCacheKeys.values()),r=[];for(const c of s)n.has(c.url)||(await t.delete(c),r.push(c.url));return{deletedURLs:r}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new l("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let U;const L=()=>(U||(U=new Q),U);try{self["workbox:routing:7.4.0"]&&_()}catch{}const v="GET",R=a=>a&&typeof a=="object"?a:{handle:a};class g{constructor(e,t,s=v){this.handler=R(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=R(e)}}class z extends g{constructor(e,t,s){const n=({url:r})=>{const c=e.exec(r.href);if(c&&!(r.origin!==location.origin&&c.index!==0))return c.slice(1)};super(n,t,s)}}class J{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(n=>{typeof n=="string"&&(n=[n]);const r=new Request(...n);return this.handleRequest({request:r,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:c}=this.findMatchingRoute({event:t,request:e,sameOrigin:n,url:s});let i=c&&c.handler;const o=e.method;if(!i&&this._defaultHandlerMap.has(o)&&(i=this._defaultHandlerMap.get(o)),!i)return;let h;try{h=i.handle({url:s,request:e,event:t,params:r})}catch(u){h=Promise.reject(u)}const p=c&&c.catchHandler;return h instanceof Promise&&(this._catchHandler||p)&&(h=h.catch(async u=>{if(p)try{return await p.handle({url:s,request:e,event:t,params:r})}catch(k){k instanceof Error&&(u=k)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),h}findMatchingRoute({url:e,sameOrigin:t,request:s,event:n}){const r=this._routes.get(s.method)||[];for(const c of r){let i;const o=c.match({url:e,sameOrigin:t,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&i.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(i=void 0),{route:c,params:i}}return{}}setDefaultHandler(e,t=v){this._defaultHandlerMap.set(t,R(e))}setCatchHandler(e){this._catchHandler=R(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}}let w;const X=()=>(w||(w=new J,w.addFetchListener(),w.addCacheListener()),w);function T(a,e,t){let s;if(typeof a=="string"){const r=new URL(a,location.href),c=({url:i})=>i.href===r.href;s=new g(c,e,t)}else if(a instanceof RegExp)s=new z(a,e,t);else if(typeof a=="function")s=new g(a,e,t);else if(a instanceof g)s=a;else throw new l("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return X().registerRoute(s),s}function Y(a,e=[]){for(const t of[...a.searchParams.keys()])e.some(s=>s.test(t))&&a.searchParams.delete(t);return a}function*Z(a,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:n}={}){const r=new URL(a,location.href);r.hash="",yield r.href;const c=Y(r,e);if(yield c.href,t&&c.pathname.endsWith("/")){const i=new URL(c.href);i.pathname+=t,yield i.href}if(s){const i=new URL(c.href);i.pathname+=".html",yield i.href}if(n){const i=n({url:r});for(const o of i)yield o.href}}class ee extends g{constructor(e,t){const s=({request:n})=>{const r=e.getURLsToCacheKeys();for(const c of Z(n.url,t)){const i=r.get(c);if(i){const o=e.getIntegrityForCacheKey(i);return{cacheKey:i,integrity:o}}}};super(s,e.strategy)}}function te(a){const e=L(),t=new ee(e,a);T(t)}const se="-precache-",ae=async(a,e=se)=>{const s=(await self.caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==a);return await Promise.all(s.map(n=>self.caches.delete(n))),s};function ne(){self.addEventListener("activate",(a=>{const e=C.getPrecacheName();a.waitUntil(ae(e).then(t=>{}))}))}function re(a){return L().createHandlerBoundToURL(a)}function ce(a){L().precache(a)}function ie(a,e){ce(a),te(e)}class oe extends g{constructor(e,{allowlist:t=[/./],denylist:s=[]}={}){super(n=>this._match(n),e),this._allowlist=t,this._denylist=s}_match({url:e,request:t}){if(t&&t.mode!=="navigate")return!1;const s=e.pathname+e.search;for(const n of this._denylist)if(n.test(s))return!1;return!!this._allowlist.some(n=>n.test(s))}}self.skipWaiting();j();ie([{"revision":"fab346dacb0b9b9af12554e9a59f004d","url":"logo.svg"},{"revision":"623840baec64a3438663496bb1050d30","url":"logo-docker.svg"},{"revision":"c5c8611b432190ec28522f1a2dfcbb7f","url":"logo-codex.svg"},{"revision":"402c3b33b33b4b621a75640a79a3c069","url":"index.html"},{"revision":"1421e9c6cad8218ef5689b85034d3a95","url":"icon-512.png"},{"revision":"b96da5a0b6636012517f54b0e69c304d","url":"icon-192.png"},{"revision":"c64ca8ae8596d4ffe13d0f591beb2274","url":"favicon.svg"},{"revision":"6481face04bd08e6f0bfc2a633840b57","url":"apple-touch-icon.png"},{"revision":"83304194a3c8be2b5b61242eeb1c1046","url":"fonts/MesloLGSNerdFontMono-Regular.woff2"},{"revision":"60fafe18cbcb717c51cdabf87f9490f0","url":"fonts/MesloLGSNerdFontMono-Bold.woff2"},{"revision":null,"url":"assets/workbox-window.prod.es5-BBnX5xw4.js"},{"revision":null,"url":"assets/sw-register-DE3JFRS4.js"},{"revision":null,"url":"assets/index-Du0oTC_8.js"},{"revision":null,"url":"assets/index-BjomRUsd.css"},{"revision":null,"url":"assets/TailscalePage-DpGOGAEd.js"},{"revision":null,"url":"assets/SettingsPage-jrLolcpw.js"},{"revision":null,"url":"assets/SandboxManager-BPVyIBrj.js"},{"revision":null,"url":"assets/RunsPage-B4fzcy4J.js"},{"revision":null,"url":"assets/PromptsPage-B6L_sVBa.js"},{"revision":null,"url":"assets/Playground-peCPHUft.js"},{"revision":null,"url":"assets/LinearSettingsPage-YbEOl57Z.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-C20zrdLZ.js"},{"revision":null,"url":"assets/IntegrationsPage-BlTiQtFv.js"},{"revision":null,"url":"assets/CronManager-D3Evml4L.js"},{"revision":null,"url":"assets/AgentsPage-rAYJ4cSY.js"}]);ne();T(new oe(re("index.html"),{denylist:[/^\/api/,/^\/ws/]}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellcoder/companion",
3
- "version": "0.98.0",
3
+ "version": "0.98.1-preview.20260515064257.15d15e5",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Periodic detection of the installed Claude Code CLI version and its
3
+ * compatibility with the companion's --sdk-url-based bridge.
4
+ *
5
+ * Mirrors update-checker.ts in shape: lazy initial check after a short delay,
6
+ * then refresh every CHECK_INTERVAL_MS. State is exposed via getCompatState().
7
+ */
8
+
9
+ import { existsSync, readdirSync, readlinkSync, statSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { getEnrichedPath, resolveBinary } from "./path-resolver.js";
13
+ import {
14
+ type ClaudeVersion,
15
+ formatVersion,
16
+ isIncompatibleVersion,
17
+ parseClaudeVersion,
18
+ pickPinTarget,
19
+ } from "./claude-versions.js";
20
+
21
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
22
+ const INITIAL_DELAY_MS = 5_000; // 5s after boot — fast enough to surface on first paint
23
+
24
+ /**
25
+ * Marker string that the byte-replace patcher writes into the binary in place
26
+ * of `claude-staging.fedstart.com`. Same byte length (27). When present in
27
+ * the binary at the resolved symlink, the binary is "patched" and the
28
+ * companion should be running in TLS-bridge mode.
29
+ */
30
+ export const PATCHED_BINARY_MARKER = "[000:000:000:000:000:0:0:1]";
31
+
32
+ export interface CompatState {
33
+ /** Stringified version of the binary that `claude` currently resolves to, e.g. "2.1.142". */
34
+ installedVersion: string | null;
35
+ /** Absolute path the `claude` shim/symlink resolves to. */
36
+ installedPath: string | null;
37
+ /** True if installed version is >= 2.1.121 AND not already patched. */
38
+ isIncompatible: boolean;
39
+ /** True if the resolved binary contains the patcher's marker bytes. */
40
+ isPatched: boolean;
41
+ /** Versions found under ~/.local/share/claude/versions/ that are still known-good. */
42
+ availableKnownGood: string[];
43
+ /** Best version to pin to (or null if no cached known-good exists). */
44
+ suggestedPinTarget: string | null;
45
+ /** ms epoch of last successful refresh. 0 means never. */
46
+ lastChecked: number;
47
+ /** Most recent error during version detection, if any. */
48
+ error: string | null;
49
+ }
50
+
51
+ const state: CompatState = {
52
+ installedVersion: null,
53
+ installedPath: null,
54
+ isIncompatible: false,
55
+ isPatched: false,
56
+ availableKnownGood: [],
57
+ suggestedPinTarget: null,
58
+ lastChecked: 0,
59
+ error: null,
60
+ };
61
+
62
+ let checking = false;
63
+ let intervalId: ReturnType<typeof setInterval> | null = null;
64
+
65
+ export function getCompatState(): Readonly<CompatState> {
66
+ return { ...state, availableKnownGood: [...state.availableKnownGood] };
67
+ }
68
+
69
+ /** Run `claude --version` and return the trimmed first line of stdout. */
70
+ async function spawnVersion(binary: string): Promise<string> {
71
+ const proc = Bun.spawn([binary, "--version"], {
72
+ stdout: "pipe",
73
+ stderr: "pipe",
74
+ env: { ...process.env, PATH: getEnrichedPath() },
75
+ });
76
+ const exitCode = await proc.exited;
77
+ if (exitCode !== 0) {
78
+ const stderr = await new Response(proc.stderr).text();
79
+ throw new Error(`exit ${exitCode}: ${stderr.trim() || "no stderr"}`);
80
+ }
81
+ const out = await new Response(proc.stdout).text();
82
+ return out.trim();
83
+ }
84
+
85
+ /**
86
+ * Resolve the absolute file path the `claude` binary on PATH refers to.
87
+ * Anthropic's installer puts a symlink in ~/.local/bin pointing into
88
+ * ~/.local/share/claude/versions/<version>. Returns the final target file
89
+ * after resolving one level of symlink.
90
+ */
91
+ function resolveClaudeTarget(): string | null {
92
+ const onPath = resolveBinary("claude");
93
+ if (!onPath) return null;
94
+ try {
95
+ const st = statSync(onPath);
96
+ if (st.isSymbolicLink()) {
97
+ const target = readlinkSync(onPath);
98
+ return target.startsWith("/") ? target : join(onPath, "..", target);
99
+ }
100
+ return onPath;
101
+ } catch {
102
+ return onPath;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Scan ~/.local/share/claude/versions/<X.Y.Z> directory entries and parse
108
+ * each filename as a version. Some entries may be ".patched" copies the
109
+ * companion produced — skip those.
110
+ */
111
+ function listCachedVersions(): ClaudeVersion[] {
112
+ const versionsDir = join(homedir(), ".local", "share", "claude", "versions");
113
+ if (!existsSync(versionsDir)) return [];
114
+ let entries: string[];
115
+ try {
116
+ entries = readdirSync(versionsDir);
117
+ } catch {
118
+ return [];
119
+ }
120
+ const out: ClaudeVersion[] = [];
121
+ for (const name of entries) {
122
+ if (name.endsWith(".patched")) continue;
123
+ const parsed = parseClaudeVersion(name);
124
+ if (parsed) out.push(parsed);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Detect whether the binary at `path` has been patched.
131
+ * Reads up to FIRST_CHUNK_BYTES from the start of the file and looks for the
132
+ * marker string. Cheap — the marker lives in a constant section near other
133
+ * string literals, typically within the first 256 MiB; we only read 32 MiB
134
+ * to keep this fast, which empirically covers it.
135
+ */
136
+ const PATCH_DETECT_BYTES = 32 * 1024 * 1024;
137
+ async function detectPatched(path: string): Promise<boolean> {
138
+ try {
139
+ const file = Bun.file(path);
140
+ if (!(await file.exists())) return false;
141
+ const slice = file.slice(0, Math.min(file.size, PATCH_DETECT_BYTES));
142
+ const text = await slice.text();
143
+ return text.includes(PATCHED_BINARY_MARKER);
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ export async function checkCompat(): Promise<void> {
150
+ if (checking) return;
151
+ checking = true;
152
+ try {
153
+ const installedPath = resolveClaudeTarget();
154
+ if (!installedPath) {
155
+ state.installedPath = null;
156
+ state.installedVersion = null;
157
+ state.isIncompatible = false;
158
+ state.isPatched = false;
159
+ state.availableKnownGood = [];
160
+ state.suggestedPinTarget = null;
161
+ state.error = "claude binary not found in PATH";
162
+ state.lastChecked = Date.now();
163
+ return;
164
+ }
165
+ state.installedPath = installedPath;
166
+
167
+ const versionOut = await spawnVersion(installedPath).catch((err: Error) => {
168
+ state.error = err.message;
169
+ return "";
170
+ });
171
+ const parsed = parseClaudeVersion(versionOut);
172
+ state.installedVersion = parsed ? formatVersion(parsed) : null;
173
+ state.error = parsed ? null : state.error || `could not parse version from "${versionOut}"`;
174
+
175
+ const patched = await detectPatched(installedPath);
176
+ state.isPatched = patched;
177
+
178
+ // A binary is "incompatible" only if it's on the lockdown side AND not patched.
179
+ // Patched binaries accept wss://[::1]:<port> so they're functional again.
180
+ state.isIncompatible = parsed ? isIncompatibleVersion(parsed) && !patched : false;
181
+
182
+ const cached = listCachedVersions();
183
+ const knownGoodOnly = cached
184
+ .filter((v) => !isIncompatibleVersion(v))
185
+ .sort((a, b) => (a.major - b.major) || (a.minor - b.minor) || (a.patch - b.patch));
186
+ state.availableKnownGood = knownGoodOnly.map(formatVersion);
187
+ const pinTarget = pickPinTarget(cached);
188
+ state.suggestedPinTarget = pinTarget ? formatVersion(pinTarget) : null;
189
+
190
+ state.lastChecked = Date.now();
191
+ } catch (err) {
192
+ state.error = err instanceof Error ? err.message : String(err);
193
+ } finally {
194
+ checking = false;
195
+ }
196
+ }
197
+
198
+ export function startPeriodicCheck(): void {
199
+ setTimeout(() => { void checkCompat(); }, INITIAL_DELAY_MS);
200
+ intervalId = setInterval(() => { void checkCompat(); }, CHECK_INTERVAL_MS);
201
+ }
202
+
203
+ export function stopPeriodicCheck(): void {
204
+ if (intervalId) {
205
+ clearInterval(intervalId);
206
+ intervalId = null;
207
+ }
208
+ }
209
+
210
+ /** Test-only: reset module state. */
211
+ export function _resetForTest(): void {
212
+ state.installedVersion = null;
213
+ state.installedPath = null;
214
+ state.isIncompatible = false;
215
+ state.isPatched = false;
216
+ state.availableKnownGood = [];
217
+ state.suggestedPinTarget = null;
218
+ state.lastChecked = 0;
219
+ state.error = null;
220
+ checking = false;
221
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { replaceAllBytes, PATCHED_BINARY_MARKER, _PATCHER_PATHS_FOR_TEST, _internalForTest } from "./claude-patcher.js";
3
+
4
+ const { ORIGINAL_HOSTNAME, PATCHED_HOSTNAME } = _PATCHER_PATHS_FOR_TEST;
5
+
6
+ describe("replaceAllBytes", () => {
7
+ // The patcher's core invariant: substitution must be length-preserving so
8
+ // that bundle offsets (V8 bytecode caches, source maps, etc.) don't shift.
9
+ it("rejects mismatched lengths", () => {
10
+ const buf = new Uint8Array([1, 2, 3]);
11
+ expect(() => replaceAllBytes(buf, new Uint8Array([1]), new Uint8Array([1, 2])))
12
+ .toThrow(/equal lengths/);
13
+ });
14
+
15
+ it("replaces every occurrence in place", () => {
16
+ const enc = new TextEncoder();
17
+ const haystack = enc.encode("AA bb AA cc AA");
18
+ const from = enc.encode("AA");
19
+ const to = enc.encode("ZZ");
20
+ const { out, replacements } = replaceAllBytes(haystack, from, to);
21
+ expect(replacements).toBe(3);
22
+ expect(new TextDecoder().decode(out)).toBe("ZZ bb ZZ cc ZZ");
23
+ });
24
+
25
+ it("returns a fresh buffer rather than mutating input (caller safety)", () => {
26
+ const enc = new TextEncoder();
27
+ const original = enc.encode("AA bb");
28
+ const snapshot = new Uint8Array(original);
29
+ replaceAllBytes(original, enc.encode("AA"), enc.encode("ZZ"));
30
+ // The original buffer must be untouched — patcher writes the patched copy
31
+ // to a separate file and must never modify the user's installed binary.
32
+ expect(original).toEqual(snapshot);
33
+ });
34
+
35
+ it("handles zero occurrences cleanly", () => {
36
+ const enc = new TextEncoder();
37
+ const haystack = enc.encode("nothing here");
38
+ const { out, replacements } = replaceAllBytes(haystack, enc.encode("AA"), enc.encode("ZZ"));
39
+ expect(replacements).toBe(0);
40
+ expect(new TextDecoder().decode(out)).toBe("nothing here");
41
+ });
42
+ });
43
+
44
+ describe("patch hostname constants", () => {
45
+ // The whole approach hinges on these two strings being the same byte length.
46
+ // If a future Claude release renames the staging host, the replacement string
47
+ // here must stay 27 bytes — that's the size of the marker we look up at
48
+ // runtime in claude-compat-checker.ts.
49
+ it("ORIGINAL_HOSTNAME and PATCHED_HOSTNAME are the same byte length", () => {
50
+ const enc = new TextEncoder();
51
+ expect(enc.encode(ORIGINAL_HOSTNAME).length).toBe(enc.encode(PATCHED_HOSTNAME).length);
52
+ });
53
+
54
+ it("PATCHED_HOSTNAME matches the marker checked by the compat checker", () => {
55
+ expect(PATCHED_HOSTNAME).toBe(PATCHED_BINARY_MARKER);
56
+ });
57
+
58
+ it("PATCHED_HOSTNAME canonicalizes to [::1] via the same URL parser the Claude validator uses", () => {
59
+ // This test pins the whole technique: both ORIGINAL_HOSTNAME (in KU5 at
60
+ // Claude build time) and PATCHED_HOSTNAME (after our byte-replace) must
61
+ // run through new URL().hostname and converge on the same value at the
62
+ // --sdk-url check point. theshadow27/mcp-cli#1808 documented this; we
63
+ // assert it here so a future refactor can't quietly break it.
64
+ const patchedAsHost = new URL(`https://${PATCHED_HOSTNAME}`).hostname;
65
+ const literalLoopback = new URL("https://[::1]").hostname;
66
+ expect(patchedAsHost).toBe(literalLoopback); // both "[::1]"
67
+ });
68
+ });
69
+
70
+ describe("countOccurrences (internal)", () => {
71
+ // Sanity check the substring search — the patcher uses this to validate
72
+ // that the binary contains the expected number of hostname occurrences
73
+ // before going through with a patch.
74
+ it("counts non-overlapping matches", () => {
75
+ const enc = new TextEncoder();
76
+ const buf = enc.encode("xxx-AA-yyy-AA-zzz");
77
+ expect(_internalForTest.countOccurrences(buf, enc.encode("AA"))).toBe(2);
78
+ });
79
+
80
+ it("returns 0 on empty needle searches in larger buffers", () => {
81
+ const enc = new TextEncoder();
82
+ const buf = enc.encode("nothing matches");
83
+ expect(_internalForTest.countOccurrences(buf, enc.encode("ABC"))).toBe(0);
84
+ });
85
+ });
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Two compatibility actions exposed to the UI:
3
+ *
4
+ * pinToVersion(v) — re-point ~/.local/bin/claude at a cached known-good
5
+ * version of the binary. No binary modification. Fast and reversible.
6
+ *
7
+ * patchBinary() — copy the currently-installed binary to a sibling
8
+ * "<version>.patched" file, byte-replace `claude-staging.fedstart.com`
9
+ * with `[000:000:000:000:000:0:0:1]` (4 occurrences, length-preserving),
10
+ * and atomically swap the ~/.local/bin/claude symlink to point at the
11
+ * patched copy. After this, the CLI accepts `wss://[::1]:<port>/...` as
12
+ * --sdk-url (the byte-replace puts [::1] into the allowlist; the scheme
13
+ * check still demands wss://, hence the parallel TLS ingress server).
14
+ *
15
+ * Reference: theshadow27/mcp-cli#1808 documents the same approach validated
16
+ * end-to-end on macOS. We follow that recipe on Linux (no codesign step).
17
+ */
18
+
19
+ import {
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ readdirSync,
24
+ statSync,
25
+ writeFileSync,
26
+ chmodSync,
27
+ } from "node:fs";
28
+ import { join, dirname } from "node:path";
29
+ import { homedir } from "node:os";
30
+ import { PATCHED_BINARY_MARKER } from "./claude-compat-checker.js";
31
+
32
+ /**
33
+ * The 27-byte hostname literal we replace. Same length as PATCHED_BINARY_MARKER
34
+ * so the surrounding bundle offsets stay intact and the JS parser doesn't choke.
35
+ *
36
+ * Both strings flow through `new URL(...).hostname` at runtime: the patched
37
+ * hostname canonicalizes to "[::1]" (IPv6 loopback), which is then matched
38
+ * against the same canonical form when --sdk-url passes "wss://[::1]:..."
39
+ * through the validator. Both sides converge.
40
+ */
41
+ const ORIGINAL_HOSTNAME = "claude-staging.fedstart.com";
42
+ const PATCHED_HOSTNAME = PATCHED_BINARY_MARKER;
43
+
44
+ /** Number of occurrences we expect to find in a clean (unpatched) binary. */
45
+ const EXPECTED_OCCURRENCES = 4;
46
+
47
+ const CLAUDE_SYMLINK_DIR = join(homedir(), ".local", "bin");
48
+ const CLAUDE_SYMLINK = join(CLAUDE_SYMLINK_DIR, "claude");
49
+ const CLAUDE_VERSIONS_DIR = join(homedir(), ".local", "share", "claude", "versions");
50
+
51
+ export type PatcherResult<T> = { ok: true } & T | { ok: false; error: string };
52
+
53
+ function countOccurrences(buf: Uint8Array, needle: Uint8Array): number {
54
+ let count = 0;
55
+ outer: for (let i = 0; i <= buf.length - needle.length; i++) {
56
+ for (let j = 0; j < needle.length; j++) {
57
+ if (buf[i + j] !== needle[j]) continue outer;
58
+ }
59
+ count++;
60
+ i += needle.length - 1;
61
+ }
62
+ return count;
63
+ }
64
+
65
+ /** Pure byte-replace: returns a new buffer with every `from` occurrence rewritten as `to`. */
66
+ export function replaceAllBytes(
67
+ buf: Uint8Array,
68
+ from: Uint8Array,
69
+ to: Uint8Array,
70
+ ): { out: Uint8Array; replacements: number } {
71
+ if (from.length !== to.length) {
72
+ throw new Error(`replaceAllBytes requires equal lengths (${from.length} vs ${to.length})`);
73
+ }
74
+ const out = new Uint8Array(buf);
75
+ let replacements = 0;
76
+ outer: for (let i = 0; i <= out.length - from.length; i++) {
77
+ for (let j = 0; j < from.length; j++) {
78
+ if (out[i + j] !== from[j]) continue outer;
79
+ }
80
+ out.set(to, i);
81
+ replacements++;
82
+ i += from.length - 1;
83
+ }
84
+ return { out, replacements };
85
+ }
86
+
87
+ /** Atomic symlink swap: write to a temp link then rename(2) onto the target. */
88
+ async function atomicSymlinkSwap(target: string, linkPath: string): Promise<void> {
89
+ mkdirSync(dirname(linkPath), { recursive: true });
90
+ // `ln -sfn` is atomic on POSIX (rename(2) under the hood) and handles the
91
+ // existing-symlink case without the unlink-then-symlink race.
92
+ const proc = Bun.spawn(["ln", "-sfn", target, linkPath], {
93
+ stdout: "pipe", stderr: "pipe",
94
+ });
95
+ const exitCode = await proc.exited;
96
+ if (exitCode !== 0) {
97
+ const stderr = await new Response(proc.stderr).text();
98
+ throw new Error(`ln -sfn failed (exit ${exitCode}): ${stderr.trim()}`);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Re-point ~/.local/bin/claude at a cached known-good version (e.g. 2.1.120).
104
+ * Doesn't modify any binary content — just a symlink swap.
105
+ */
106
+ export async function pinToVersion(version: string): Promise<PatcherResult<{ target: string }>> {
107
+ const target = join(CLAUDE_VERSIONS_DIR, version);
108
+ if (!existsSync(target)) {
109
+ return {
110
+ ok: false,
111
+ error: `No cached Claude ${version} binary at ${target}. Install it with Anthropic's installer first.`,
112
+ };
113
+ }
114
+ try {
115
+ await atomicSymlinkSwap(target, CLAUDE_SYMLINK);
116
+ return { ok: true, target };
117
+ } catch (err) {
118
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
119
+ }
120
+ }
121
+
122
+ /** Locate the binary the `claude` symlink currently resolves to. */
123
+ function resolveCurrentBinaryPath(): string | null {
124
+ if (!existsSync(CLAUDE_SYMLINK)) return null;
125
+ try {
126
+ const stat = statSync(CLAUDE_SYMLINK);
127
+ if (!stat.isFile()) return null;
128
+ return readFileSync(CLAUDE_SYMLINK).length > 0 ? CLAUDE_SYMLINK : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Take whatever the `claude` symlink currently points at, copy it to a
136
+ * sibling "<filename>.patched", byte-replace the hostname, swap the symlink
137
+ * to the patched copy.
138
+ *
139
+ * Idempotent: if the resolved binary is already patched (contains the
140
+ * marker) the function returns { ok: true, alreadyPatched: true }.
141
+ *
142
+ * We never modify the user's original binary in place — Anthropic's
143
+ * auto-updater treats `~/.local/share/claude/versions/<X.Y.Z>` as
144
+ * authoritative and rewrites it on its own schedule. Patched copies live
145
+ * alongside as <X.Y.Z>.patched.
146
+ */
147
+ export async function patchBinary(): Promise<PatcherResult<{ patchedPath: string; replacements: number }>> {
148
+ const source = resolveCurrentBinaryPath();
149
+ if (!source) {
150
+ return { ok: false, error: `Could not resolve a real file from ${CLAUDE_SYMLINK}` };
151
+ }
152
+
153
+ // The symlink itself points into versions/<X.Y.Z>. We patch a sibling, not the original.
154
+ // statSync on a symlink follows by default; resolve via readlinkSync if symlink.
155
+ let sourceFile = source;
156
+ try {
157
+ const lstat = statSync(source, { throwIfNoEntry: false });
158
+ if (lstat?.isSymbolicLink?.()) {
159
+ const { readlinkSync } = await import("node:fs");
160
+ const link = readlinkSync(source);
161
+ sourceFile = link.startsWith("/") ? link : join(dirname(source), link);
162
+ }
163
+ } catch { /* fall through, use source */ }
164
+
165
+ // Strip any trailing ".patched" if we somehow point at a patched file already.
166
+ const baseFile = sourceFile.replace(/\.patched$/, "");
167
+ const patchedPath = `${baseFile}.patched`;
168
+
169
+ try {
170
+ const buf = readFileSync(baseFile);
171
+ const fromBytes = new TextEncoder().encode(ORIGINAL_HOSTNAME);
172
+ const toBytes = new TextEncoder().encode(PATCHED_HOSTNAME);
173
+
174
+ const occurrences = countOccurrences(buf, fromBytes);
175
+ if (occurrences === 0) {
176
+ // Already-patched binary won't contain the original hostname anymore.
177
+ // Check the marker as a sanity guard.
178
+ const markerBytes = new TextEncoder().encode(PATCHED_BINARY_MARKER);
179
+ if (countOccurrences(buf, markerBytes) > 0) {
180
+ // Already patched — make sure the symlink is pointing at us.
181
+ if (!existsSync(patchedPath)) {
182
+ writeFileSync(patchedPath, buf);
183
+ chmodSync(patchedPath, 0o755);
184
+ }
185
+ await atomicSymlinkSwap(patchedPath, CLAUDE_SYMLINK);
186
+ return { ok: true, patchedPath, replacements: 0 };
187
+ }
188
+ return {
189
+ ok: false,
190
+ error: `Hostname literal "${ORIGINAL_HOSTNAME}" not found in ${baseFile}. ` +
191
+ `This Claude build does not match the known patch profile.`,
192
+ };
193
+ }
194
+ if (occurrences !== EXPECTED_OCCURRENCES) {
195
+ console.warn(
196
+ `[claude-patcher] Found ${occurrences} hostname occurrences (expected ${EXPECTED_OCCURRENCES}). ` +
197
+ `Patching anyway — the build may have changed.`,
198
+ );
199
+ }
200
+
201
+ const { out, replacements } = replaceAllBytes(buf, fromBytes, toBytes);
202
+ if (replacements === 0) {
203
+ return { ok: false, error: "Byte replace ran but produced no changes" };
204
+ }
205
+
206
+ writeFileSync(patchedPath, out);
207
+ chmodSync(patchedPath, 0o755);
208
+ await atomicSymlinkSwap(patchedPath, CLAUDE_SYMLINK);
209
+
210
+ return { ok: true, patchedPath, replacements };
211
+ } catch (err) {
212
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Revert: point ~/.local/bin/claude back at the original (non-patched) sibling
218
+ * of whatever it's currently pointing at. Doesn't delete the patched file.
219
+ */
220
+ export async function unpatch(): Promise<PatcherResult<{ target: string }>> {
221
+ if (!existsSync(CLAUDE_SYMLINK)) {
222
+ return { ok: false, error: `${CLAUDE_SYMLINK} does not exist` };
223
+ }
224
+ try {
225
+ const { readlinkSync } = await import("node:fs");
226
+ const link = readlinkSync(CLAUDE_SYMLINK);
227
+ const linkAbs = link.startsWith("/") ? link : join(dirname(CLAUDE_SYMLINK), link);
228
+ if (!linkAbs.endsWith(".patched")) {
229
+ return { ok: false, error: "Current binary is not a patched copy; nothing to revert" };
230
+ }
231
+ const original = linkAbs.replace(/\.patched$/, "");
232
+ if (!existsSync(original)) {
233
+ return { ok: false, error: `Original binary missing at ${original}` };
234
+ }
235
+ await atomicSymlinkSwap(original, CLAUDE_SYMLINK);
236
+ return { ok: true, target: original };
237
+ } catch (err) {
238
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
239
+ }
240
+ }
241
+
242
+ /** Test-only: paths used by the module, so tests can override / inspect. */
243
+ export const _PATCHER_PATHS_FOR_TEST = {
244
+ CLAUDE_SYMLINK_DIR,
245
+ CLAUDE_SYMLINK,
246
+ CLAUDE_VERSIONS_DIR,
247
+ ORIGINAL_HOSTNAME,
248
+ PATCHED_HOSTNAME,
249
+ EXPECTED_OCCURRENCES,
250
+ };
251
+
252
+ /** Test-only: pure helpers exported separately so tests can hit them directly. */
253
+ export const _internalForTest = {
254
+ countOccurrences,
255
+ };
256
+
257
+ // Re-export the constants used in patcher tests
258
+ export { PATCHED_BINARY_MARKER };