[{"data":1,"prerenderedAt":447},["ShallowReactive",2],{"blog-en-the-daemon-is-just-another-ape":3,"header-blog-translations-/en/blog/the-daemon-is-just-another-ape":444},{"id":4,"title":5,"author":6,"body":7,"date":426,"description":427,"draft":428,"extension":429,"image":430,"meta":431,"navigation":432,"path":433,"seo":434,"stem":435,"tags":436,"translationKey":442,"__hash__":443},"blog_en/blog/en/the-daemon-is-just-another-ape.md","The Daemon Isn't a Backdoor, It's Just Another Ape","Patrick Hofmann",{"type":8,"value":9,"toc":419},"minimark",[10,23,45,50,66,69,73,86,97,104,108,111,129,303,309,316,334,338,347,367,371,374,381,384,387,415],[11,12,13,14,18,19,22],"p",{},"The Nest daemon is a control plane: a long-lived process that runs per machine, supervises agents and takes in spawn intents. I built it a WebSocket handler that connects to the ",[15,16,17],"code",{},"troop"," SP so the server can push spawn commands instead of being polled every five minutes. The handler called ",[15,20,21],{},"ensureFreshIdpAuth()"," — get the fresh IdP token, send it over the socket. In the planning phase that looked clean. On the first real start it never ran.",[11,24,25,26,29,30,33,34,37,38,40,41,44],{},"The daemon runs as ",[15,27,28],{},"_openape_nest",", a hidden service user, ",[15,31,32],{},"HOME=/var/openape/nest",". There's no ",[15,35,36],{},"auth.json"," there. ",[15,39,21],{}," had nothing to fetch, because no one ever ran ",[15,42,43],{},"apes login"," there. There's no human in that HOME.",[46,47,49],"h2",{"id":48},"how-it-was-meant-before","How it was meant before",[11,51,52,53,57,58,61,62,65],{},"The assumption in the plan: the daemon is ",[54,55,56],"em",{},"my"," infrastructure. I start it, so it operates with my identity. The old poll model worked exactly like that — except it dodged the problem without me noticing. The 5-minute sync called ",[15,59,60],{},"apes run --as \u003Cagent>"," per agent. Every agent has its own DDISA identity in its own HOME. The setuid jump into the agent user ",[54,63,64],{},"was"," the auth. The daemon itself never needed a token, because it never spoke itself — it only ever let an agent speak.",[11,67,68],{},"The WebSocket path doesn't have that. A persistent connection to the server belongs to the daemon, not to an agent. The daemon has to speak itself. And the moment it speaks itself, it needs an identity — and I had silently assumed in the plan that it was mine. WS auth is a different model than the per-agent sync, not a variant of it. I overlooked that cut.",[46,70,72],{"id":71},"why-the-obvious-solution-is-wrong","Why the obvious solution is wrong",[11,74,75,76,78,79,81,82,85],{},"The reflex is obvious: ",[15,77,43],{}," as me, copy the ",[15,80,36],{}," to ",[15,83,84],{},"/var/openape/nest/",", done. It works too. That's exactly why it's dangerous.",[11,87,88,89,92,93,96],{},"The daemon is long-lived, runs as a system-near service, supervises other processes. That's the place in the architecture where an owner token may least be allowed to go missing. And even if it doesn't leak: every action the daemon then performs carries ",[15,90,91],{},"act:human"," with my subject. When the daemon spawns an agent, the audit log says ",[54,94,95],{},"I"," did that. The log then lies not out of malice, but because the identity is modeled wrong.",[11,98,99,100,103],{},"A control-plane daemon that holds its owner's token is by definition a backdoor. Not because someone makes it one — but because the trust assumption ",[54,101,102],{},"\"this privileged process acts as me\""," is exactly what a security architecture should not rest on.",[46,105,107],{"id":106},"the-shift","The shift",[11,109,110],{},"The daemon isn't my infrastructure with my credentials. It is itself an ape.",[11,112,113,115,116,118,119,121,122,125,126,128],{},[15,114,28],{}," gets its own DDISA agent identity, its own Ed25519 key material, its own ",[15,117,43],{}," in its own HOME. The ",[15,120,17],{}," WS handler accepts this identity — ",[15,123,124],{},"act:agent"," next to ",[15,127,91],{},", not instead of:",[130,131,136],"pre",{"className":132,"code":133,"language":134,"meta":135,"style":135},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","// troop WS handler: both actors are first-class,\n// neither is a special case of the other\nconst claim = verifyActClaim(token);\nif (claim.act === \"human\") {\n  // owner connects from the browser / the CLI\n} else if (claim.act === \"agent\") {\n  // the Nest daemon connects with its own DDISA identity\n} else {\n  return reject(socket, \"unknown actor\");\n}\n","ts","",[15,137,138,147,153,178,213,219,250,256,266,297],{"__ignoreMap":135},[139,140,143],"span",{"class":141,"line":142},"line",1,[139,144,146],{"class":145},"sHwdD","// troop WS handler: both actors are first-class,\n",[139,148,150],{"class":141,"line":149},2,[139,151,152],{"class":145},"// neither is a special case of the other\n",[139,154,156,160,164,168,172,175],{"class":141,"line":155},3,[139,157,159],{"class":158},"spNyl","const",[139,161,163],{"class":162},"sTEyZ"," claim ",[139,165,167],{"class":166},"sMK4o","=",[139,169,171],{"class":170},"s2Zo4"," verifyActClaim",[139,173,174],{"class":162},"(token)",[139,176,177],{"class":166},";\n",[139,179,181,185,188,191,194,197,200,204,207,210],{"class":141,"line":180},4,[139,182,184],{"class":183},"s7zQu","if",[139,186,187],{"class":162}," (claim",[139,189,190],{"class":166},".",[139,192,193],{"class":162},"act ",[139,195,196],{"class":166},"===",[139,198,199],{"class":166}," \"",[139,201,203],{"class":202},"sfazB","human",[139,205,206],{"class":166},"\"",[139,208,209],{"class":162},") ",[139,211,212],{"class":166},"{\n",[139,214,216],{"class":141,"line":215},5,[139,217,218],{"class":145},"  // owner connects from the browser / the CLI\n",[139,220,222,225,228,231,233,235,237,239,241,244,246,248],{"class":141,"line":221},6,[139,223,224],{"class":166},"}",[139,226,227],{"class":183}," else",[139,229,230],{"class":183}," if",[139,232,187],{"class":162},[139,234,190],{"class":166},[139,236,193],{"class":162},[139,238,196],{"class":166},[139,240,199],{"class":166},[139,242,243],{"class":202},"agent",[139,245,206],{"class":166},[139,247,209],{"class":162},[139,249,212],{"class":166},[139,251,253],{"class":141,"line":252},7,[139,254,255],{"class":145},"  // the Nest daemon connects with its own DDISA identity\n",[139,257,259,261,263],{"class":141,"line":258},8,[139,260,224],{"class":166},[139,262,227],{"class":183},[139,264,265],{"class":166}," {\n",[139,267,269,272,275,279,282,285,287,290,292,295],{"class":141,"line":268},9,[139,270,271],{"class":183},"  return",[139,273,274],{"class":170}," reject",[139,276,278],{"class":277},"swJcz","(",[139,280,281],{"class":162},"socket",[139,283,284],{"class":166},",",[139,286,199],{"class":166},[139,288,289],{"class":202},"unknown actor",[139,291,206],{"class":166},[139,293,294],{"class":277},")",[139,296,177],{"class":166},[139,298,300],{"class":141,"line":299},10,[139,301,302],{"class":166},"}\n",[11,304,305,306,308],{},"The daemon now speaks as itself. Spawn intents that come in over this socket aren't ",[54,307,56],{}," actions the daemon passes through — they're actions of the daemon, with its subject in the audit trail.",[11,310,311,312,315],{},"One question remains: a spawn happens ",[54,313,314],{},"for me",". The daemon has its own token, but mine isn't reachable on the machine — and shouldn't be. How does the daemon act on-behalf-of-human without holding my token?",[11,317,318,319,322,323,325,326,329,330,333],{},"Over a delegation grant. RFC 8693 token exchange is actually strict: ",[15,320,321],{},"subject_token"," is mandatory, because the typical case is a confidential client that has both tokens. Our case is a different one — the daemon has its own token, mine is unreachable. Pragmatism: ",[15,324,321],{}," becomes optional when a ",[15,327,328],{},"delegation_grant_id"," is present; the IdP derives the delegator from the grant. No longer strict RFC, but the same effect — and the HITL point stays where it belongs: in the ",[15,331,332],{},"escapes"," grant flow, not in a copied token. I approve a delegation once, the daemon acts within its bounds, every step is server-side auditable.",[46,335,337],{"id":336},"what-fell-away","What fell away",[11,339,340,342,343,346],{},[15,341,21],{}," in the daemon path. The whole ",[54,344,345],{},"\"get Patrick's token, pass it on\""," code path. There's no passed-through owner token anymore, so there's no place it can leak.",[11,348,349,350,352,353,355,356,359,360,362,363,366],{},"Instead: one ",[15,351,43],{}," as ",[15,354,28],{},", the ssh keypair is copied along during the migration to the service user so the IdP identity stays the same — no re-enroll, all delegations and grants persist. One stumbling block: the 1h token expiry hits, and the auto-refresh can fail on the stale ",[15,357,358],{},"key_path"," in the migrated ",[15,361,36],{},". Solution: explicitly trigger ",[15,364,365],{},"apes login --key"," after the migration. The pattern is reusable for any other OpenApe daemon that goes the same way.",[46,368,370],{"id":369},"the-cut","The cut",[11,372,373],{},"I wanted to give the daemon my token. It became: the daemon gets its own identity.",[11,375,376,377,380],{},"That's not just an auth fix. It's a hint I now recognize everywhere. Whenever a component wants to be ",[54,378,379],{},"\"privileged infrastructure\""," — the one that runs with the owner credentials, that acts as the human, that needs a special path in the auth — that's almost always a symptom, not a design goal. It means: this component doesn't have its own identity yet, and I plugged the gap with mine.",[11,382,383],{},"Give it its own, and the special treatment disappears. The daemon is then no longer a backdoor holding my token. It's just another ape — same protocol, own key material, HITL over the same grant flow as everyone else.",[385,386],"hr",{},[11,388,389],{},[54,390,391,392,399,400,403,404,406,407,410,411,414],{},"Code: ",[393,394,398],"a",{"href":395,"rel":396},"https://github.com/openape-ai/openape",[397],"nofollow","github.com/openape-ai/openape",", MIT-licensed. The Nest daemon lives in ",[15,401,402],{},"@openape/nest",", the WS handler in the ",[15,405,17],{}," SP. The delegation-only token-exchange path lives in the IdP auth code (",[15,408,409],{},"exchangeWithDelegation"," in ",[15,412,413],{},"@openape/cli-auth",").",[416,417,418],"style",{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":135,"searchDepth":149,"depth":149,"links":420},[421,422,423,424,425],{"id":48,"depth":149,"text":49},{"id":71,"depth":149,"text":72},{"id":106,"depth":149,"text":107},{"id":336,"depth":149,"text":337},{"id":369,"depth":149,"text":370},"2026-05-12","I had built the Nest daemon's WS handler as if it had to use my IdP token. But the daemon runs as its own service user with no access to it. The solution wasn't to give it my token — but to give it its own identity.",false,"md",null,{},true,"/blog/en/the-daemon-is-just-another-ape",{"title":5,"description":427},"blog/en/the-daemon-is-just-another-ape",[437,438,439,440,441],"OpenApe","AI Agents","Infrastructure","Security","Building in Public","the-daemon-is-just-another-ape","AMqCY7kMojZidV19afldDLVgAl2BJPR2zf_zaQXh7tE",{"en":445,"de":446},"/en/blog/the-daemon-is-just-another-ape","/de/blog/der-daemon-ist-kein-backdoor-er-ist-nur-ein-weiterer-ape",1779001887231]