Conversation
…ersona ha detto si' a QUEL messaggio Topics impara a mandare posta e a chiamare Google, e la cosa che rende la capacita' accettabile non e' il trasporto: e' dove sta la conferma. Non nella descrizione dello strumento, che e' un consiglio a un modello, ma nella rotta che spawna la CLI: `server/routes/outbound.ts` apre la domanda sul canale umano che la board gia' usa (commento con risposte rapide nel thread della card, pannello sulla riga dello strumento in una chat) e non spawna niente finche' non arriva una risposta. Un agente che chiama la rotta a mano trova lo stesso cancello. Il si' vale per UN messaggio: la domanda porta un digest del contenuto nella chiave e nel testo, e una risposta che non lo porta non e' un consenso. Per questo non passa dal bridge dei permessi, che e' fatto apposta per poter smettere di chiedere (`allow_always`, sessione libera): giusto per un tool che tocca un file, sbagliato per una mail. Il resto sono conseguenze: - gli argomenti arrivano da un modello, quindi la CLI si chiama per ARGV e mai per una stringa data a una shell; l'eseguibile si risolve su cartelle dichiarate, perche' sotto launchd il PATH non ha /usr/local/bin; - la configurazione vive solo nell'ambiente (il repo e' pubblico, e GATE-07 e' rosso su un indirizzo in un file tracciato): niente indirizzi, niente nomi di account nel codice, la casella Exchange riconosciuta per indirizzo, e una variabile mancante che si nomina invece di ripiegare su un'altra casella; - ogni invio e ogni scrittura lasciano una riga sulla card - chi, a chi, cosa, esito - senza il corpo; anche i rifiuti e i fallimenti; - gli allegati si risolvono DENTRO il workspace della sessione: senza quel controllo `send_mail` e' una via di esfiltrazione. Un difetto ereditato, riparato qui: il registro di `routeAskToTaskThread` si puliva solo alla scadenza o rispondendo dal thread, quindi una risposta arrivata dal pannello lo lasciava sporco e la domanda del messaggio DOPO non usciva piu' sulla card, in silenzio. Le prove girano contro un finto eseguibile che registra argv e ambiente: che l'iniezione (`; rm -rf`, virgolette, a capo, `$(...)`) resti un argomento solo, che senza conferma non parta nessun processo, che un account non dichiarato venga rifiutato invece di sostituito. Nessun invio vero, mai.
… distingue da una rete lenta Il figlio riceveva PATH e HOME e basta. Misurato oggi su questa macchina: con quell'ambiente `gws drive files list` stampa «Using keyring backend: keyring» e non torna piu' (oltre 25 s, due volte); aggiungendo `USER` risponde in un secondo. Entrambe le CLI leggono le credenziali dalla Keychain di macOS, quindi la stessa forma vale anche per la casella Exchange. Era il guasto peggiore disponibile: non un errore, un PIANTO. Da fuori si legge come rete lenta, brucia tutto il tetto di tempo della chiamata, e si sarebbe scoperto solo in produzione. `TMPDIR` invece non cambia niente, provato a parte, e infatti non entra. Il finto eseguibile registra anche l'ambiente, cosi' che chi un domani «ripulisce» quella lista trovi un test rosso invece di una chiamata che pende. E l'elenco dei tool del server MCP avviato come SOTTOPROCESSO (`topics-mcp-server.e2e.test.ts`) ora nomina i due nuovi: era l'unico rosso della CI.
…e prende un file vuole un altro file `TOPICS_GOOGLE_CLIENT_SECRET` punta al client «installed» che Google consegna. Passarlo come `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` sembra la cosa ovvia ed e' sbagliata: quella variabile vuole credenziali di UTENTE autorizzato, e la chiamata muore subito con «Failed to parse authorized user credentials: missing field client_id» (provato oggi). I due valori dentro quel file sono invece cio' che la CLI legge come `GOOGLE_WORKSPACE_CLI_CLIENT_ID` e `..._CLIENT_SECRET` - la stessa cosa che il wrapper della posta su questa macchina esporta a mano. Quindi il file si legge al momento della chiamata e i due campi entrano nell'ambiente del figlio; si accetta anche la forma `web`, che e' l'altra che Google consegna. Gli errori nominano il FILE e il CAMPO che manca, mai i valori. Provato con l'ambiente ESATTO che costruisce la rotta: `calendar events list` torna JSON, e `gmail +send --dry-run` con oggetto fra virgolette e `$(...)` nel corpo produce il POST che spedirebbe, senza spedirlo.
…ttura Due cose che la conferma sbagliava. La domanda mostrava mittente, destinatario, oggetto e «32 caratteri». E' una firma su una busta chiusa: la board dice gia' che se chiedi «confermi X?» chi risponde deve poter vedere X. Adesso il corpo sta nella domanda (tagliato a 1500 caratteri, e il taglio si dice); la TRACCIA resta com'era, una riga senza corpo, perche' quella e' l'archivio e non la decisione. E `watch` era classificato fra le letture perche' suona come un osservatore. Non lo e': in queste API crea un'iscrizione push che continua a consegnare dopo la chiamata, cioe' un cambiamento all'account. Passa fra le scritture, con le sconosciute.
Contributor
Author
|
Tre cose emerse provando la catena vera (nessun invio, solo letture e un
|
…n del disegno `outbound-tools.ts` chiedeva `httpJson` al dispatcher, e il dispatcher chiede a lui gli schemi dei due tool: un cerchio. In ESM regge finche' nessuno dei due legge l'altro a livello di modulo, e GATE-08 (`tests/unit/no-import-cycles.test.ts`) lo rifiuta proprio per questo - il guasto che previene non e' un errore all'avvio ma un `undefined` silenzioso. Le deroghe sono vuote e restano vuote. Quindi la cosa condivisa si sposta dove tutti e due possono importarla: `server/mcp/topics-http.ts` porta `ParsedArgs`, il tetto di tempo di una richiesta, l'init TLS di loopback, l'errore della richiesta persa e `httpJson`. Una direzione sola, nessun cerchio. Code di contorno dello spostamento, tutti misurati dai cancelli e non inventati: i commenti italiani che viaggiavano con quelle righe sono ora in inglese (un file nuovo nasce a zero), `loopbackTlsInit` diventa `loopbackInit` perche' `tls` non e' una parola del dizionario e allargare PROJECT_WORDS avrebbe smosso il debito di altri sei file, e la baseline degli identificatori perde quella voce - scende, non sale. Rinominato anche il fixture `desktopClient` in `installedClient`, che era l'altro rosso della CI.
`resolveAttachments` faceva `resolve()` + `isInsideDir()` + `existsSync()`. Il primo normalizza `../` e non tocca i link; il terzo li SEGUE. Lo dichiara il modulo stesso (path-containment.ts:20-23: «a symlink is not [risolto] ... il chiamante passa percorsi REALI»), e questo chiamante non ne passava. Un agente ha una shell nella propria worktree: `ln -s <segreto> preventivo.pdf` dentro il workspace, e l'allegato esce. Riprodotto con la CLI finta: risposta 200 e la finta CLI legge il contenuto del file fuori. Il test che c'era copriva `../segreto.txt`, cioe' l'unico caso che il `resolve()` gia' chiudeva. Adesso la decisione gira sul percorso reale, candidato e workspace, con `realPathForNewEntry` - lo stesso rimedio che `browser-tool-dispatcher.ts` usa gia' davanti a `checkUploadPath`, per la stessa minaccia. Il workspace si risolve anche lui: sotto `/tmp` un progetto vive in `/private/tmp`, e confrontare un percorso reale contro una radice linkata rifiuterebbe ogni file legittimo. Alla CLI va il percorso REALE, non il nome chiesto: la risoluzione avviene prima della conferma e la conferma dura minuti, quindi un link ancora onesto quando la domanda esce puo' non esserlo piu' quando parte il figlio. E la domanda NOMINA gli allegati, con nome e peso. «Allegati: 1» era la busta chiusa che OUTBOUND-03 vieta due paragrafi sopra per il corpo: e' esattamente cio' che rendeva il link invisibile all'unica persona che poteva fermarlo. Il workspace di una card e' l'intera cartella del progetto, dove sta anche `data/topics.db`, e anche quello contava «1». Terza cosa, OUTBOUND-05: dopo il si' della persona, un eseguibile che la variabile non trova piu' usciva 400 senza `trace()`. La persona ha confermato e niente e' partito - e' un fatto della card quanto un invio riuscito. Vale per la posta e per la scrittura Google, piu' il ramo in cui il cancello stesso solleva.
…a il prefisso `findWaitingToolRow` confrontava `call.name !== toolName` contro la costante `mcp__topics__send_mail`. Quel nome e' quello che scrive la flotta MCP, cioe' un agente pilotato da una CLI. Il runtime nativo non lo scrive: `providers/native/topics-tools.ts` mappa `toolsForProfile` dritto nella lista del modello, quindi li' il tool e' `send_mail` e il nome nudo e' quello che finisce in `blocks` - verificato sul DB di produzione in sola lettura, dove `update_task` compare nudo sotto provider `topics`, che sono 702 topic su 776. Conseguenza in una chat senza card: nessuna riga trovata, `asked` resta falso, e ogni invio e ogni scrittura Google rifiutati con «there is no card thread and no visible tool row to ask on». Non e' la prima volta: `ask-user-detector.ts` porta la stessa regola a tre vie e la stessa storia, osservata il 2026-08-28 su una chat parcheggiata su una domanda senza controllo a schermo. La CI non lo vedeva perche' ogni caso del file usava il nome prefissato. Stessa regola del detector: nome pieno, nome nudo, o un altro punto di montaggio. Il separatore fa parte del confronto, altrimenti un `my_send_mail` di qualcun altro risponderebbe alla nostra domanda.
…secondo messaggio `httpJson` solleva sia quando la richiesta si perde sia su qualunque stato fuori dal 2xx, e il `catch` di `pollOutbound` non sapeva distinguerli: contava tutto come guasto di trasporto, dormiva e ri-POSTAVA lo stesso corpo. Il commento del file diceva il contrario di cio' che il codice faceva («Nothing is ever re-sent after an ANSWER»). Due conseguenze, misurate con un finto fetch. Un 400 `unknown_account` diventava 20 POST identiche e l'agente leggeva «lost contact with topics-app»: cioe' TUTTI gli errori parlanti che OUTBOUND-01 promette (variabile mancante, account non dichiarato, allegato rifiutato) arrivavano travestiti da rete rotta. Un 502 e' peggio: il primo giro ha gia' fatto girare la CLI e CONSUMATO la conferma, quindi il ri-POST apre una domanda NUOVA per lo stesso messaggio - e chi dice si' alla seconda manda la mail due volte. Il tetto dei 90s non chiudeva niente, perche' ogni `pending` e' un httpJson riuscito che azzera l'orologio: si andava avanti fino alle 600 gambe. Quindi l'informazione «il server ha risposto» diventa una CLASSE, `HttpAnswerError` con lo stato, e non un prefisso di messaggio da grepare. Il giro ritenta solo cio' che non e' quella; una risposta torna all'agente com'e', il messaggio preciso ce l'ha gia'. Un 2xx che porta `error` nel corpo e' una risposta anche lui.
…er se' Il referto della change dichiarava riparato «il registro di `routeAskToTaskThread` che restava sporco dopo una risposta arrivata dal pannello». Era falso: la riga in piu' stava dentro `outbound-gate.ts`, cioe' dentro il cancello nuovo. Il percorso da cui il difetto e' ereditato non era toccato - `server/routes/permission.ts` non compare nel diff, e la sua gamba `ask-user` chiamava `clearRoutedAskForSession` SOLO sul ramo della scadenza del TTL. Quindi per `ask_user_question` l'entry restava, e la domanda successiva continuava a non uscire sulla card: «aspetta te» senza il testo di cosa vuole, che e' precisamente il difetto che `board-ask-routing.ts` esiste per chiudere. Adesso la gamba svuota il registro quando la risposta arriva davvero, e quando la domanda viene annullata o superata. Non quando la gamba scade: li' la domanda e' ancora aperta ed e' lo stesso pannello. Per provarlo serviva un seme: `createPermissionRouter` prende ora un `comment` iniettabile, esattamente come `createOutboundRouter`, perche' quello vero passa da `createTaskService` e vuole un DB. Il test e' rosso senza la riga (secondo commento mai scritto) e verde con.
Quattro cose che la spec non diceva e che quindi nessuno poteva contestare al codice: il contenimento si decide sul percorso REALE (e alla CLI va quello), gli allegati si NOMINANO nella domanda, la riga dello strumento si trova anche col nome nudo del runtime nativo, e una risposta del server non si ri-manda. Piu' il fallimento dopo il si' che deve lasciare la sua riga, che OUTBOUND-05 diceva a meta'. Cinque scenari nuovi: il link verso l'esterno, il nome nell'anteprima, il tool senza prefisso, la risposta non ritentata, il confermato-e-non-partito.
`routeAskToTaskThread` esce restituendo il task anche quando NON scrive il commento: succede ogni volta che il registro (per TASK, non per domanda) porta ancora una voce di quella sessione, per esempio quella di un `ask_user_question` di un turno interrotto, che nessuno dei tre clear copre. Il cancello degli invii leggeva quel valore come «la domanda e' stata posta»: la POST rispondeva `pending`, sulla card restava la domanda di prima, e lo strumento bruciava 600 gambe (quattro ore) su una conferma che non esisteva da nessuna parte. Adesso l'instradamento dice `shown`, cioe' «il commento c'e' davvero», e una domanda nuova SOSTITUISCE quella che trova per la stessa sessione: e' la regola del rendez-vous (`waitForAnswer` supersede il waiter che trova) e quella gia' scritta sul registro. Rifiutare l'invio sarebbe stato l'altra strada, ma avrebbe lasciato una persona a sbloccare a mano una domanda che nessuno puo' piu' rispondere. La sostituita viene chiusa con una riga sua: un blocco di risposta rapida che cambia testo sotto gli occhi senza dirlo e' peggio del silenzio.
Il cancello cerca lo strumento in attesa nell'ultima riga assistant del DB, ma su una riga moderna l'unica colonna che porta le chiamate e' `blocks` (sul DB vivo tutte le 6916 righe con blocchi hanno `tool_calls` vuoto), e `blocks` passa dal throttle: fra 1 e 15 secondi per tutto cio' che non sia la prima scrittura del turno o un raddoppio del payload. Un `send_mail` che non fosse il primo tool del suo turno non era ancora sulla riga quando il cancello guardava, e «non ancora persistita» veniva trattata come «non c'e' nessuno a cui chiedere»: un rifiuto SECCO, per giunta, perche' lo strumento solleva sul rifiuto e non c'e' una seconda gamba, mentre bastava aspettarne una (25 s > 15 s). `lib/turn-body-flush.ts` pubblica il flush del turno vivo e la rotta lo forza prima di leggere: e' la stessa mossa che `routes/chat.ts` fa gia' per lo stesso motivo, «a tool that stops to ask is written NOW». Il test guida il throttle VERO con due tool e la scrittura satura a 15 secondi: nessuno dei casi esistenti lo avrebbe visto, perche' ognuno costruisce a mano una riga che contiene gia' il tool.
Due difetti della stessa specie: la conferma prometteva una cosa e ne partiva
un'altra.
GLI ALLEGATI. `resolveAttachments` congelava la STRINGA, non il contenuto, e
fra la domanda e lo spawn c'e' l'attesa della persona - minuti, con l'agente
che ha una shell in quel workspace. `rm preventivo.pdf && ln -s <segreto>
preventivo.pdf` (o la semplice riscrittura del file, senza nessun link) e
quello che parte non e' quello che e' stato confermato, con la persona che ha
letto «preventivo.pdf (9 B)». Riprodotto in entrambe le forme. Adesso i byte si
LEGGONO al momento della domanda e si copiano in una cartella privata del
server (0700, sotto `~/.topics`, fuori da ogni workspace): alla CLI va la copia,
la domanda porta nome, peso e sha256 breve, e l'impronta entra nel digest, cosi'
byte diversi sono un messaggio diverso e una domanda nuova. Non un secondo
controllo prima dello spawn: sarebbe la stessa corsa un giro piu' in la'. Le
copie si cancellano a invio finito e scadono a sei ore, cioe' oltre le quattro
che lo strumento puo' stare in attesa.
GOOGLE. `gws gmail users messages send` manda una mail senza passare da
`send_mail`, e la domanda che la persona firmava era `body: {"raw":"Rnjvbto..."}`
tagliato a 300 caratteri: destinatario, oggetto e corpo invisibili, la busta
chiusa che OUTBOUND-03 vieta, riaperta sulla porta accanto. I metodi di Gmail
che spediscono adesso sono rifiutati rimandando a `send_mail` (elenco esplicito,
insensibile a maiuscole e spazi), e ogni altra scrittura viene riassunta in
parole: un `raw` in base64 viene decodificato in mittente, destinatario, oggetto
e testo, e nessun campo viene tagliato senza dirlo. La traccia sulla card nomina
a chi e con che oggetto, invece del solo metodo.
La CLI finta dei test adesso registra anche il CONTENUTO di ogni argomento che
sia un file: guardare solo argv non distingue una copia congelata da un file
sostituito durante l'attesa.
OUTBOUND-04 prometteva che «il percorso passato alla CLI SHALL essere quello reale, cosi' che un link ripuntato mentre la persona legge la domanda non cambi cio' che parte»: era falso in quella forma, perche' il percorso reale resta un nome, e il nome e' proprio cio' che l'attaccante lascia intatto. Adesso la regola e' quella che il codice applica - i byte letti e copiati al momento della domanda, l'impronta nella domanda e nell'identita' del messaggio - insieme alle due che i difetti hanno scoperto: `google_call` non e' una seconda porta della posta e ogni scrittura si legge in parole (OUTBOUND-04), la riga su cui si dipinge non dipende da una scrittura differita e «chiesto» vuol dire che il commento e' stato scritto (OUTBOUND-03).
Due conflitti, stessa causa: main ha aggiunto una regola numerata al protocollo della board mentre questo ramo ne aggiungeva un'altra. La 5-bis di main (la scelta consigliata) resta dov'e', e quella sulla posta diventa 5-ter; il cancello di parita' la ancora come le altre, e il suo test dedicato sparisce perche' l'elenco dice gia' le stesse tre cose.
`Buffer.from(x, "base64")` non rifiuta niente: davanti a un `raw` che non e' un messaggio restituisce mojibake, senza nemmeno un'intestazione, e il sommario diventava «Da: (non indicato) / A: (non indicato) / Oggetto: (nessuno)» con un corpo vuoto - cioe' MENO del JSON che aveva sostituito. Senza almeno un'intestazione riconosciuta non e' un messaggio, e il corpo si stampa come sta. Nel finto segreto del test TOCTOU la parola «finto» mancava: nel repo pubblico una stringa che si legge come una chiave vera non si lascia in giro nemmeno dentro un fixture.
Un agente dispatchato legge `OUTBOUND_TOOLS_RULE` e nient'altro: senza queste due righe scopriva il rifiuto di `gmail users messages send` sbattendoci contro, e non aveva modo di sapere che il file allegato non si puo' piu' cambiare dopo la domanda. `docs/board-protocol.md` (la copia per gli umani) dice le stesse due cose.
…uo test La riga nomina destinatario e oggetto da ieri, ma niente lo teneva fermo: la spec lo chiede (OUTBOUND-05) e il mutante che riporta la traccia al solo metodo passava tutto verde.
La riga citava «Scrittura Google eseguita: ... - riuscita» dentro un commento inglese, e l'italiano e' proprio il soggetto della citazione: e' il caso che `allow-italian:` esiste per coprire, e il marcatore vale per la riga su cui sta.
…tro slot liberi La copia degli allegati prometteva di stare «dove l'agente non arriva»: falso. Server e agenti girano sotto lo stesso uid, quindi 0700 tiene fuori gli altri utenti e non l'agente, e due strade sono state riprodotte. La cartella si chiamava sha256(sessione, nomi e impronte) e la scrittura era saltata se il file c'era gia': l'agente calcolava il nome, ci piantava il segreto e chiamava send_mail, e quella copia partiva - nessuna corsa, nessun tempismo. La seconda e' riscrivere la copia mentre la persona legge, che `ls -t` trova senza calcolare niente. Ora il nome viene da mkdtemp e vive in una mappa in memoria, ogni copia si crea con O_EXCL, e dopo il si' le copie si rileggono e si ri-hashano prima dello spawn: un'impronta diversa ferma l'invio e lascia la sua riga. La finestra da chiudere era l'attesa della persona, che dura minuti. Il tetto dei 25 MB si misurava sui byte gia' in RAM: 64 MB letti e poi rifiutati sono 64 MB di RSS, cioe' il guasto che il tetto esiste per impedire. Ora la decisione sta sulla stat, la copia va a blocchi, e un allegato che non e' un file regolare e' un rifiuto: leggere una FIFO non ritorna finche' nessuno ci scrive, e il test che lo prova prima congelava l'intero processo senza stampare una riga. Su google_call l'elenco dei metodi che spediscono non poteva bastare: i quattro campi diventano argv, e la CLI ha quattro helper che spediscono (+send, +reply, +reply-all, +forward) che non sono percorsi d'API. Con `resource: "+forward"` e i flag negli altri campi si inoltrava un messaggio qualunque della casella, con i suoi allegati, a un indirizzo scelto da chi chiama. I quattro campi ora devono essere nomi d'API; l'elenco resta come secondo strato perche' «usa send_mail» e' una risposta migliore di «non e' un nome». Il confine di fiducia e' l'utente della macchina, e spec ed envelope adesso lo dicono invece di promettere il contrario.
…FIFO usa per esistere
…ondi Due sessioni dello stesso task esistono per costruzione: il coordinatore e le sue figlie stanno sullo stesso taskId, e il registro delle domande instradate e' chiavato li'. Due conferme di invio aperte insieme diventavano un blocco di tasti solo; la seconda scacciava la prima senza scrivere niente e la risposta veniva consegnata alla chiave corrente. La persona leggeva un messaggio, confermava, e ne partiva un altro verso un altro destinatario. Adesso una domanda ha un id suo - l'id della riga del thread che la porta, che e' l'unica cosa che chi risponde puo' nominare perche' e' il commento su cui clicca. La card lo rimanda indietro (`answerTo`) e una risposta che nomina un id diverso da quello aperto non si consegna, non fa partire niente, e la card scrive perche': il commento e' gia' salvato, e senza quella riga chi ha cliccato crede di aver confermato. Una seconda conferma di invio su una card che ne ha gia' una viva viene RIFIUTATA con la sua ragione - la card disegna un blocco di risposta rapida solo, quindi mostrarla significa togliere la prima, e un atto irreversibile non puo' nemmeno restare in coda in silenzio per ore. Una `ask_user_question` generica invece aspetta il suo turno, perche' il suo bridge ripassa ogni 25 secondi e non perde niente. Se la vecchia non aspetta piu' nessuno la nuova subentra, la riga di sostituzione esce anche fra sessioni diverse, e il rendez-vous sostituito viene annullato. La finestra fra `verifyFrozen` e la open della CLI non e' microsecondi e il percorso non e' segreto: ci stanno un resolveCliPath, uno spawn e un avvio di processo intero, vinti 3 volte su 4 senza nemmeno un ritardo simulato, e il percorso si elenca. La chiusura naturale - aprire la copia, scollegarla, passare al figlio /dev/fd/N - non regge con la CLI che chiamiamo davvero: misurata con `gws gmail +send --dry-run` (nessun invio), torna «--attach '/dev/fd/3' resolves to '/dev/fd/preventivo.pdf' which is outside the current directory», cioe' canonicalizza e riapre il file per nome, e dal nome deriva l'intestazione MIME. Quindi non si finge: spec, intestazione del modulo, commento della rotta ed envelope dicono la misura vera, e che la conferma vale per due cose oneste - impedire gli errori e lasciare una traccia. Lo stesso comando ha mostrato una promessa che il codice non poteva mantenere: `gws` rifiuta ogni allegato che risolva fuori dalla cartella corrente, e le copie congelate stanno sotto ~/.topics mentre il server gira dove l'ha avviato launchd. Nessun allegato confermato sarebbe mai partito. Il figlio adesso parte con la cartella di appoggio come cwd, e il test lo misura sul PWD del processo. La traccia sulla card nomina l'impronta confermata invece di un esito che non puo' provare.
…risposta Due `send_mail` della stessa sessione corrono insieme: il bridge MCP gestisce ogni riga JSON-RPC in un callback che non aspetta, e la rotta e' comunque chiamabile a mano. Riprodotto a 120 ms di distanza sulla rotta vera: il cancello guardava solo le sessioni DIVERSE, quindi la seconda conferma sostituiva la prima e la card finiva con due blocchi di tasti. Adesso la domanda sulla card e' una sola chiunque la chieda, e chi arriva secondo viene rifiutato PRIMA di aprire qualunque cosa di suo: il gate chiede alla card prima di `beginAsk`, cosi' non c'e' niente da ripulire e niente che appartenga a chi stava gia' aspettando. Il registro si svuota per ID della richiesta e non per sessione: chiavato sulla sessione, la gamba che perdeva cancellava la voce di quella che aveva vinto, e il clic sulla conferma ancora a schermo non partiva piu' (`pendingRoutedAsk` tornava null e la card non diceva niente). «Nessuno aspetta piu' quella domanda» si decide su due fatti, non su uno: il rendez-vous e' chiavato sulla sessione e non sa distinguere due richieste della stessa, quindi serve anche il battito delle gambe di poll. La traccia si scrive `quiet`. `pendingQuestionComment` si ferma sulla prima riga d'agente che non e' ne' domanda ne' consegna, quindi «Invio NON partito» scritto sotto la conferma viva le toglieva i tasti e con loro l'`answerTo` del drawer - proprio nello stato che la corsa produce. `quiet` e' la parola che esiste per dire «annoto, non rispondo» ed e' letta da quella funzione sola: la riga resta visibile e resta l'ultima parola della card. Tenerla indietro fino alla risposta avrebbe significato un agente che dice «non e' partito niente» e una card che non dice niente. E la misura di `gws` che il giro prima citava senza produrla e' stata rifatta con la CLI vera (0.22.5, `gmail +send --dry-run`, nessun invio): `--attach /dev/fd/3` torna «resolves to '/dev/fd/3' which is outside the current directory», non il nome del file - identico col file scollegato. Il rifiuto e' il controllo di contenimento, prima che qualcosa venga aperto; col figlio in /dev/fd il contenimento passa e la CLI si ferma un passo dopo, «Cannot read --attach '3': Bad file descriptor», col descrittore dimostrabilmente ereditato. La conclusione regge, l'evidenza adesso e' quella vera nei tre posti dove si legge, e cio' che non e' stato misurato e' dichiarato inferenza.
Il testo diceva «una domanda viva di un'altra sessione» dove il codice adesso rifiuta anche la seconda richiesta della stessa: una riga che descrive una regola che non e' piu' quella manda a cercare il guasto dalla parte sbagliata.
Il giro prima aveva chiuso solo l'istanza che passava dal ramo «occupato» dell'instradamento. Con lo STESSO contenuto le due richieste sono la stessa domanda per quel ramo: la seconda si vedeva consegnare l'askId della prima, entrava nel rendez-vous (chiavato sulla sessione) e lo sostituiva, la prima tornava «superseded» e si portava via la voce comune. Riprodotto a 120 ms: conferma a schermo coi tasti, `pendingRoutedAsk` null, clic che non consegnava e non diceva niente, e la gamba dopo scriveva una SECONDA conferma. Adesso il cancello si serializza da se'. Prende un lucchetto sulla superficie che disegna la domanda - la card, o la sessione quando non ce n'e' una - prima di qualunque scrittura e prima di aprire qualunque rendez-vous, e lo rilascia alla fine, anche sugli errori. Chi non lo prende e' rifiutato senza aver toccato niente. Il lucchetto attraversa le gambe con un gettone che la rotta restituisce insieme a `pending` e lo strumento riporta indietro: con un payload identico non c'e' altro che distingua la seconda gamba di una richiesta da una seconda richiesta. E una richiesta non possiede piu' un id che non ha creato: `created` sull'esito dell'instradamento, tenuto attraverso le gambe dentro il lucchetto. Serve nella finestra reale in cui il lucchetto scade (gamba + margine) mentre la voce nel registro vive ancora (due minuti di silenzio). La stessa cosa succedeva senza nessun duplicato: una `ask_user_question` generica della stessa sessione chiamava `waitForAnswer` ignorando `busy` e uccideva l'attesa dell'invio. Quando la card e' occupata da una richiesta della STESSA sessione, quella gamba adesso spende il suo tempo senza registrarsi e risponde `pending`. I tasti non restano piu' su un blocco morto. Una domanda che finisce senza una risposta nel thread - scaduta, annullata, o risposta dal pannello della chat - viene chiusa con una riga sua, che e' cio' che il lettore dei tasti conta come parola; e un clic su un blocco che non aspetta piu' nessuno riceve una riga sulla card invece di diventare un commento che rimette al lavoro l'agente. Una frase scritta sotto lo stesso blocco resta una nota: e' il contenuto che coincide con un'opzione a dire che qualcuno ha premuto. Testi veri: `check:test-globals` dichiarava «~80s» ed era contato fra i cancelli leggeri - lancia un `bun test` per OGNI file, cioe' la suite unit intera, non e' in CI, e sul Mac di chi lavora si e' preso 18 minuti prima di essere fermato a mano. La doc di `clearRoutedAsk` prometteva una protezione che non dava («un id che ha scritto qualcun altro non e' un id con cui si puo' chiamare»: l'id E' lo stesso quando la domanda e' la stessa). Otto mutanti, ognuno fa rosso almeno un test: lucchetto tolto, gettone ignorato dal client, adozione di un id non creato, `clearRoutedAsk` per sessione, la domanda generica che aspetta lo stesso, la chiusura che non scrive niente, la riga del clic morto tolta, il clic riconosciuto senza guardare le opzioni.
Il rimedio del giro prima pendeva da `routed.busy`, che esiste solo quando la sessione ha una card: in una chat l'instradamento non risponde, la gamba di `ask-user` si registrava lo stesso e la conferma d'invio della stessa sessione tornava «superseded by a newer question» - con il si' letto sul pannello dell'INVIO consegnato alla domanda generica. La regola e' la stessa con o senza card, e il lucchetto del cancello la chiave giusta ce l'ha gia': per una sessione senza card e' `session:<k>`. La gamba occupata spende il suo tempo senza toccare niente, e la persona continua a vedere la conferma. E quattro regole che il giro dichiarava non le teneva nessun test: sopravvivevano il lucchetto senza scadenza, la chiave sulla sessione invece che sulla card, la gamba occupata che non spende il tempo e il vincolo di sessione tolto. Ora ogni mutante fa rosso: un orologio iniettabile per la scadenza, due sessioni della stessa card nella finestra in cui il registro e' gia' vuoto, il tempo misurato sulla gamba, e la domanda di un'altra sessione che deve invece registrarsi.
…hiesto Il rimedio del giro prima parcheggia la domanda generica senza registrarsi, ma il suo pannello resta a schermo - lo dipinge il rilevatore dello stream sul `tool_use`, non la rotta della gamba, e il commento della rotta lo dice. La persona clicca QUELLO, e `/api/chat/tool-response` non guardava quale domanda si stesse rispondendo: `hasPendingAsk(sessionKey)` e' vero, si va dritti a `deliverAnswer(sessionKey, answers)`, e il si' della generica veniva consegnato all'INVIO. Misurato con le due rotte vere e senza card: l'invio rifiutava con «the answer that came back was not about this message» e la generica restava senza risposta. Un clic, due domande rotte - lo speculare esatto del difetto che il giro prima aveva chiuso. La forma giusta la PR ce l'ha gia' sulla strada della board: `answerTo`, il si' appartiene a QUELLA domanda. Adesso ce l'ha anche la chat. L'attesa che possiede il rendez-vous nomina la riga su cui la sua domanda e' dipinta - il cancello d'invio il pannello lo dipinge lui, quindi la nomina - e la rotta consegna solo se il `toolCallId` cliccato combacia. Se non combacia: niente consegna, niente parte, nessuna attesa uccisa, e un 409 con una riga leggibile che il form mostra in linea. Un pannello vecchio o gia' risposto lo dice invece di rubare il turno a quello vivo. Il ramo di compatibilita' e' dichiarato: chi apre l'attesa senza nominare una riga - la gamba generica, che il pannello non lo dipinge - riceve la risposta da qualunque pannello. Ci arriva solo quando nessuna conferma di quella sessione tiene il lucchetto, quindi la sua e' l'unica domanda che possa essere in attesa. E un'attesa senza identita' cancella quella lasciata prima, o un invio abbandonato renderebbe muta la domanda che gli succede. E i due predicati di `outboundHoldOfSession` adesso li tiene un test. Non erano accademici: senza il vincolo di sessione l'invio di UNA sessione parcheggia le domande generiche di TUTTE le altre; senza la scadenza un agente morto fra due gambe lascia la sua sessione senza domande fino al riavvio, perche' niente ripuliva la mappa. L'orologio iniettabile non arrivava qui - l'unico chiamante non lo passa - quindi la scadenza la prova una rotta, con la gamba dell'invio indietro di due minuti. Un lucchetto scaduto adesso esce dalla mappa: era memoria che non tornava piu'. Sei mutanti, ognuno fa rosso almeno un test: vincolo di sessione tolto, scadenza tolta, rimozione dalla mappa tolta, l'invio che non nomina il suo pannello, l'attesa senza identita' che non azzera quella prima, e il cancello di rotta tolto.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cosa cambia
Un agente di card puo' mandare una mail (
send_mail) e leggere/scrivere su Google(
google_call, una porta sola sopra la CLIgws). La parte che conta non e' iltrasporto, che era gia' deciso, ma dove sta la conferma: nella rotta che spawna
la CLI, non nella descrizione dello strumento.
server/routes/outbound.ts- le due porte. Prima di ogni invio e di ogniscrittura apre la domanda sul canale umano che la board gia' usa (commento con
risposte rapide nel thread della card; pannello sulla riga dello strumento in una
chat) e non spawna niente finche' non arriva una risposta. Chi chiama la rotta
a mano trova lo stesso cancello.
server/lib/outbound-gate.ts- il si' vale per UN messaggio: la domanda portaun digest del contenuto nella chiave e nel testo, e una risposta che non lo porta
non e' un consenso. Non passa dal bridge dei permessi apposta: quello e' fatto per
poter smettere di chiedere (
allow_always, sessione libera), che e' giusto per untool che tocca un file e sbagliato per una mail.
server/lib/outbound-staging.ts- i BYTE confermati, congelati: al momentodella domanda gli allegati vengono letti e copiati in una cartella di appoggio del
server (nome da
mkdtemp, file creati conO_EXCL), alla CLI va la copia, e primadello spawn la copia viene riletta e ri-hashata.
server/lib/outbound-summary.ts- cosa legge chi conferma sulla porta diGoogle: i metodi di Gmail che spediscono sono rifiutati (si usa
send_mail), e unrawin base64 viene decodificato in mittente, destinatario, oggetto e testo.server/lib/outbound-cli.ts- gli argomenti arrivano da un modello: la CLI sichiama per ARGV, mai per una stringa data a una shell, e l'eseguibile si risolve su
cartelle dichiarate (sotto launchd il
PATHnon ha/usr/local/bin). Il figlioriceve un ambiente minimo, non quello del server.
server/lib/outbound-config.ts- configurazione solo daprocess.env. Il repoe' pubblico: nessun indirizzo e nessun nome di account nel codice o nei test, la
casella Exchange riconosciuta per INDIRIZZO (
TOPICS_MAIL_EDM_FROM), e unavariabile mancante che si nomina invece di ripiegare in silenzio su un'altra
casella.
chi, cosa, esito), senza il corpo.
send_mailsarebbe una via di esfiltrazione.OUTBOUND_TOOLS_RULEinshared/board.ts(un agentedispatchato legge solo quello) e la sezione 5-ter di
docs/board-protocol.md(5-bis su main e' la scelta consigliata, entrata col merge), con il test di parita'
che le tiene legate.
Un difetto ereditato, riparato qui
Il registro di
routeAskToTaskThreadsi puliva solo alla scadenza o rispondendo dalthread: una risposta arrivata dal pannello lo lasciava sporco, e la domanda del
messaggio DOPO non usciva piu' sulla card, in silenzio.
La prima stesura lo aggirava solo dentro il cancello nuovo (
outbound-gate.ts) elasciava intatto il percorso da cui e' ereditato:
routes/permission.tsnon era neldiff, quindi per
ask_user_questionil difetto c'era ancora tale e quale. Adesso lagamba
ask-usersvuota il registro quando la risposta arriva davvero e quando ladomanda viene annullata; il test e' rosso senza quella riga.
Primo giro di correzioni (review)
Sei cose trovate rileggendo, ognuna chiusa con un test rosso prima della correzione:
resolveAttachmentsdecideva suresolve()+isInsideDir(), che non segue i link (lo dichiarapath-containment.ts) mentreexistsSyncli segue. Un agente con una shellscrive
ln -s <segreto> preventivo.pdfnel workspace e l'allegato esce. Ora ladecisione gira sul percorso REALE di candidato e workspace
(
realPathForNewEntry, lo stesso rimedio dibrowser-tool-dispatcher.ts).busta chiusa che OUTBOUND-03 vieta due paragrafi sopra per il corpo, ed era cio'
che rendeva il punto 1 invisibile.
trova piu'): usciva 400 senza riga sulla card, contro OUTBOUND-05. Posta e
Google.
findWaitingToolRowconfrontava il nome prefissato
mcp__topics__send_mail, maproviders/native/topics-tools.tspubblica i tool col nome NUDO - ed e' ilruntime di 702 topic su 776. Nessuna riga, nessun pannello, ogni invio rifiutato
con «nessuno poteva confermare». Stessa regola a tre vie gia' scritta in
ask-user-detector.tsper lo stesso motivo.fuori dal 2xx passava per guasto di trasporto e lo stesso corpo veniva
ri-POSTato: gli errori parlanti arrivavano travestiti da rete rotta, e su un 502
dopo la conferma il ri-POST apriva una domanda NUOVA per lo stesso messaggio
(una seconda mail). Ora
HttpAnswerErrorporta lo stato e solo cio' che non e'una risposta si ritenta.
Secondo giro: i quattro blocchi della verifica avversaria
La CI era verde e la verifica ha riprodotto quattro difetti, tutti con i moduli veri.
Ognuno ha adesso un test che va rosso senza la correzione (mutante eseguito).
prima aveva chiuso la meta' statica: il percorso resta una stringa, e fra la
domanda e lo spawn c'e' l'attesa della persona. Sostituendo il file (o
ripuntandolo a un link) durante quell'attesa partiva altro, con la persona che
aveva letto «preventivo.pdf (9 B)». Adesso i byte si leggono e si copiano al
momento della domanda in una cartella privata del server (0700, sotto
~/.topics, fuori da ogni workspace), alla CLI va la COPIA, e la domanda portanome, peso e sha256 breve - con l'impronta dentro il digest, cosi' byte diversi
sono una domanda nuova. (Il «niente secondo controllo prima dello spawn» che stava
qui e' caduto al terzo giro: vedi sotto.) Le copie si cancellano a invio finito e scadono
a sei ore. Tetto di 25 MB sul totale, che e' dove si ferma una casella.
google_callera la seconda porta della posta.gws gmail users messages sendmanda una mail senza passare dasend_mail, e il sommario mostrava il bodytagliato a 300 caratteri, cioe' base64 a meta': destinatario, oggetto e corpo
invisibili. Ora i metodi di Gmail che spediscono sono rifiutati con un rimando a
send_mail(elenco esplicito, confronto insensibile a maiuscole e spazi), e ognialtra scrittura viene riassunta in parole -
rawdecodificato, nessun tagliosenza annuncio. La traccia nomina a chi e con che oggetto.
routeAskToTaskThreadesce restituendo il task anche quando NON scrive ilcommento, e il cancello lo leggeva come «chiesto»: l'invio restava appeso, sulla
card compariva la domanda di prima, e lo strumento bruciava 600 gambe. Adesso
l'instradamento dice
shown, e una domanda nuova SOSTITUISCE quella che trovaper la stessa sessione - la vecchia chiusa con una riga sua. Superare invece di
rifiutare perche' e' gia' la regola del rendez-vous (
waitForAnswersupersede) eperche' l'altra strada lascia una persona a sbloccare a mano una domanda a cui
nessuno puo' piu' rispondere: il turno che l'aveva aperta e' finito.
blockse' l'unica colonna che porta le chiamate su una riga moderna (sul DBvivo tutte le 6916 righe con blocchi hanno
tool_callsvuoto) e passa dalthrottle: un
send_mailche non fosse il primo tool del turno non era ancorasulla riga, e veniva rifiutato SECCO - lo strumento solleva sul rifiuto, quindi
niente seconda gamba. Ora
lib/turn-body-flush.tspubblica il flush del turnovivo e la rotta lo forza prima di leggere, come
routes/chat.tsfa gia' per lostesso motivo.
Terzo giro (verifica avversaria)
Due blocchi riprodotti dal verificatore, chiusi qui con il loro test rosso; il terzo
e' respinto con la misura che lo respinge.
l'agente non arriva» e non era vero: server e agenti girano sotto lo stesso uid,
quindi 0700 tiene fuori gli altri utenti, non l'agente. Due strade, entrambe
riprodotte. (a) Il nome della cartella era
sha256(sessione, nomi e impronte)ela scrittura saltava se il file c'era gia' (
if (!existsSync)): l'agentecalcolava il nome, piantava il segreto e chiamava
send_mail- nessuna corsa,nessun tempismo, e partiva la copia piantata. (b) Riscrivere la copia mentre la
persona legge, che
ls -ttrova senza calcolare niente. Ora il nome viene damkdtempe il riuso fra le gambe passa da una mappa in memoria, ogni copia sicrea con
O_EXCL, e dopo il si' le copie si rileggono e si ri-hashano primadello spawn: impronta diversa = invio fermo, con la sua riga sulla card.
L'argomento contro il ri-controllo («la stessa corsa un giro piu' in la'») era
sbagliato: la finestra da chiudere e' l'attesa della persona, che dura minuti.
Quello che questo punto scriveva sulla finestra RESIDUA («microsecondi», «un
percorso che nessuno fuori dal processo conosce») e' stato falsificato al quarto
giro: vedi sotto.
64 MB letti e poi rifiutati sono 64 MB di RSS, cioe' il guasto che il tetto
esiste per impedire; e una FIFO al posto dell'allegato (
mkfifo report.csv, uncomando nella propria worktree) fermava l'intero event loop prima che a qualcuno
fosse chiesto niente. Ora la decisione sta sulla
stat, quello che non e' unfile regolare e' un rifiuto, e la copia va a blocchi da 256 KB. Il test che
doveva coprirlo si intitolava «non entra nella RAM del server» e asseriva solo
.toThrow: adesso misura l'ordine (un file statabile e non leggibile, oltre iltetto, risponde col tetto e non col permesso negato).
google_callcon un elenco solo restava una porta della posta. I quattrocampi diventano argv e la CLI ha quattro helper che spediscono (
+send,+reply,+reply-all,+forward): nessuno e' un percorso d'API, quindinessuno era nell'insieme, e
resource: "+forward"con i flag negli altri campiinoltrava un messaggio qualunque della casella, allegati compresi, a un
indirizzo scelto da chi chiama. I quattro campi ora devono essere nomi d'API
(
^[A-Za-z][A-Za-z0-9_.]*$), il che chiude in un colpo gli helper e i flag;l'elenco resta come secondo strato, allargato ai quattro helper, perche' «usa
send_mail» e' una risposta migliore di «non e' un nome».Respinto: «la conferma se la firma l'agente». Vero come descrizione (su loopback
evaluateIdentityrispondeownere la rotta dei commenti scriveauthor: HUMAN),ma la correzione proposta - chiedere il token del daemon, o un'identita' di
dispositivo appaiato - non regge alla stessa misura che motiva il blocco: gli agenti
girano sotto lo STESSO utente del server, e quel token e' un file
0600diquell'utente mentre i token dei dispositivi stanno in
data/topics.db, che e'0644(misurato con
psestat). Una credenziale che l'attaccante puo' leggere non e'una difesa, e' una fortezza. Il confine di fiducia e' l'utente della macchina: ora lo
dicono OUTBOUND-03, l'envelope e l'intestazione del modulo, invece di promettere il
contrario.
Quarto giro (verifica avversaria)
Due blocchi riprodotti, tutti e due chiusi qui — uno con la correzione, l'altro con
la misura che dice cosa resta aperto e perche' non si chiude da questa parte.
Il si' era legato al TASK, non al messaggio. Il registro di
board-ask-routing.tse' chiavato sutaskId, e due sessioni dello stesso taskesistono per costruzione:
agent-census.tsmappa apposta il coordinatore e lesue figlie sullo stesso task. Due conferme di invio aperte insieme diventavano un
solo blocco di tasti; la seconda scacciava la prima senza scrivere niente, e
answerRoutedAskconsegnava alla chiave CORRENTE. Riprodotto: la persona legge«Preventivo -> cliente@esempio.test», clicca Conferma, e parte un messaggio
diverso verso un altro destinatario, mentre la prima domanda resta muta e
inesigibile.
Adesso una domanda ha un id suo, che e' l'id della riga del thread che la
porta — l'unica cosa che chi risponde puo' NOMINARE, perche' e' il commento su
cui clicca. La card lo rimanda indietro (
answerTo), e una risposta che nominaun id diverso da quello aperto non si consegna, non fa partire niente, e la
card lo dice (senza quella riga la persona ha cliccato Conferma e non e'
successo niente di visibile).
Sulla seconda richiesta si e' scelto di RIFIUTARE, non accodare, e non e' la
stessa scelta per tutti: la card disegna un blocco di risposta rapida solo,
quindi mostrare la seconda significa togliere la prima, e un atto irreversibile
non puo' nemmeno restare in coda in silenzio per ore — l'agente polla quattro ore
e la persona non sa che un invio aspetta. Quindi la conferma di invio riceve un
rifiuto con la sua ragione e la sua riga sulla card. Una
ask_user_questiongenerica invece aspetta il suo turno: il suo bridge ripassa ogni 25 secondi
con la stessa domanda, quindi la coda ha una testa visibile e non perde niente.
Se la domanda che occupa la card non aspetta piu' nessuno (turno interrotto),
la nuova subentra, la sostituzione viene scritta nel thread anche fra sessioni
diverse (prima usciva solo per la stessa) e il rendez-vous della sostituita
viene annullato, cosi' il suo invio fallisce con la sua traccia invece di
raccogliere un si' che non era suo. Tolto anche il «della stessa sessione» dal
testo di OUTBOUND-03, che specificava il buco.
La finestra dopo il si': misurata, non chiusa, e adesso scritta com'e'. Fra
verifyFrozene laopendella CLI ci sonoresolveCliPath,Bun.spawne unavvio di processo intero: il verificatore l'ha vinta 1 volta su 1 coi tempi veri
e 3 su 4 senza nessun ritardo simulato, e il percorso non si indovina — si
ELENCA (
find ~/.topics/outbound-attachments -name ...), cosa che costa niente achi gira come questo utente.
La chiusura naturale (aprire la copia, scollegarla, passare al figlio
/dev/fd/N) e' stata provata contro la CLI vera (gws0.22.5,gmail +send --dry-run, nessun invio), due volte: col descrittore aperto su un filedella cartella corrente e poi col file scollegato, che e' lo stato che il
trucco richiede. Stessa risposta:
Il rifiuto e' il controllo di contenimento del percorso, prima che
qualunque cosa venga aperta: su macOS
/dev/fd/3non ha link da seguire,canonicalizza a se' stesso e non sta dentro nessuna cartella di lavoro. Col
figlio dentro
/dev/fdil contenimento passa e la CLI si ferma un passo dopo,--attach 3:misurato col descrittore dimostrabilmente ereditato (
cat /dev/fd/3nellastessa shell stampa il file). Due strade, due rifiuti: il descrittore alla CLI
non arriva, e una forma su stdin non c'e'. Che poi la CLI riaprirebbe il file
per nome e da quel nome deriverebbe l'intestazione MIME e' un'inferenza mai
misurata — il flag non ci arriva — e non serve alla conclusione. Quindi il
punto (1) della direzione non regge con questa CLI, e non si finge: la copia tiene un nome, e spec, intestazione del
modulo, commento della rotta e testo dell'envelope adesso dicono la misura vera al
posto di «microsecondi» e «percorso che nessuno conosce». Il confine e' l'utente
della macchina: un processo che gira come il proprietario chiama
gwsda se', enessuna conferma dentro Topics glielo impedisce — quindi la conferma vale per due
cose oneste, impedire gli errori e lasciare una traccia, e il testo non promette
una terza.
E una promessa che il codice davvero non poteva mantenere e' caduta col
misurato: lo stesso
--dry-runmostra chegwsrifiuta ogni--attachcherisolva fuori dalla cartella corrente. Le copie congelate stanno sotto
~/.topicse il server gira dove l'ha avviato launchd, quindi nessun allegatoconfermato sarebbe mai partito davvero: il figlio adesso viene avviato con la
cartella di appoggio come
cwd, e il test lo misura sul PWD del processo, nonsulla stringa. La traccia sulla card, che scriveva «allegati: preventivo.pdf -
riuscito», adesso nomina l'impronta confermata («allegati confermati:
preventivo.pdf (sha256 b0e8de4e)») e non piu' un esito che non puo' provare.
Prove del quarto giro (tutte con la CLI finta, nessun invio)
ragione, nessuna domanda nuova sulla card, e il si' che manda il PRIMO messaggio
(il destinatario dell'altro non compare in argv);
e la domanda aperta resta aperta; sulla riga giusta invece si consegna;
piu' una risposta» compare fra sessioni diverse, e la gamba della vecchia
fallisce
cancelled;Mutanti eseguiti (ognuno fa rosso almeno un test): cancello di vitalita' rimosso
(torna il «sostituisce sempre»), confronto dell'id tolto,
cancelAsksulla sessionesostituita tolto,
answerToignorato dalla rotta, rifiuto della seconda confermadisattivato,
cwddel figlio rimosso.Quinto giro (verifica avversaria)
Quattro blocchi, tutti riprodotti. I primi tre erano un guasto solo visto da tre
lati: due
send_maildella STESSA sessione, a 120 ms, mentre la prima e' nellasua gamba di attesa. Il bridge MCP gestisce ogni riga JSON-RPC in un callback che
non aspetta (
topics-mcp-server.ts), quindi due invii di un messaggio corronoinsieme — e la rotta e' comunque chiamabile a mano.
Una conferma per CARD, chiunque la chieda. Il cancello guardava solo le
sessioni DIVERSE, quindi per la stessa si cadeva nel ramo di sostituzione: due
blocchi di conferma vivi sulla card. Adesso la seconda viene rifiutata comunque.
«Nessuno aspetta piu' quella domanda» si decide su due fatti e non su uno:
la sessione della domanda non ha piu' un rendez-vous aperto, oppure nessuno
e' piu' ripassato a ri-porla entro un tempo dichiarato (le gambe del poll sono
il battito). Una misura sola non basta: il rendez-vous e' chiavato sulla
sessione e non sa distinguere due richieste della stessa, e il battito da solo
terrebbe la card per una domanda abbandonata un secondo dopo essere uscita.
Falsificato al sesto giro: valeva solo per due domande DIVERSE. Con lo
stesso contenuto il ramo «occupato» non scattava affatto. Vedi sotto.
Il rifiuto arriva PRIMA di toccare qualunque cosa della prima. Il gate
chiede alla card prima di
beginAsk: chi arriva secondo non apre unrendez-vous, non scrive nel thread e non ha niente da ripulire. Il registro si
svuota per id della richiesta, non per sessione — chiavato sulla sessione,
la gamba che perdeva cancellava la voce di quella che aveva vinto, e il clic
sulla conferma ancora a schermo non partiva piu' (
pendingRoutedAsktornavanull, la card non diceva niente). La rotta della domanda usa lo stesso id; la
sola chiamata che ancora sgombera per sessione e' quella che ha appena chiuso
il rendez-vous di quella sessione, e lo dice nel suo nome.
Corretto al sesto giro: chiavare per id non protegge quando l'id e' lo
STESSO, e con lo stesso contenuto lo e' per costruzione. Cio' che regge e' il
lucchetto, sotto.
La traccia di un invio rifiutato si scrive
quiet.pendingQuestionCommentsi ferma sulla prima riga d'agente che non e' ne'domanda ne' consegna, quindi «Invio NON partito…» scritto sotto la conferma
viva le toglieva i tasti e con loro l'
answerTodel drawer. Delle tre stradepossibili — scriverla in una forma che la funzione salta, scriverla dopo la
risposta, insegnare alla funzione a saltarla — si e' scelta la prima:
quiete'la parola che esiste gia' per dire «annoto, non rispondo», e' letta da
quella funzione sola, e la riga resta visibile e resta l'ultima parola della
card. Tenerla indietro avrebbe significato un agente che dice «non e' partito
niente» e una card che non dice niente.
La misura di
gwse' stata rifatta. Il referto del giro prima incollavacome output verbatim una frase che la CLI non produce (
resolves to '/dev/fd/preventivo.pdf'). Rimisurata qui con la CLI vera e--dry-run: lafrase giusta e'
resolves to '/dev/fd/3', il rifiuto e' il contenimento delpercorso e non una riapertura per nome, e col figlio in
/dev/fdsi ottiene unsecondo rifiuto (
Bad file descriptor). La conclusione regge, l'evidenza e'quella vera nei tre posti dove si leggeva (intestazione del modulo, delta di
spec, questo corpo) e cio' che non e' stato misurato e' dichiarato
inferenza.
Prove del quinto giro (CLI finta, nessun invio)
conferma sulla card (quella del primo messaggio), la riga del rifiuto presente e
marcata come nota,
pendingRoutedAskche indica ancora la prima, il clic su dilei consegnato, e in argv il destinatario confermato e non l'altro;
registro invariato, e il si' che paga il messaggio che nomina;
stessa sessione (turno interrotto: senza questo, ogni conferma successiva
sarebbe rifiutata per una domanda che nessuno puo' rispondere);
quietsotto una domanda non le toglie i tasti, e senzail flag li toglie (lo stesso test tiene i due versi).
Mutanti eseguiti (ognuno fa rosso almeno un test): cancello di vitalita' rimesso a
«solo sessioni diverse» (torna la doppia conferma),
quiettolto dalla traccia,cancelAskrimesso sul ramo del rifiuto (uccide il rendez-vous del primo invio).Sesto giro (verifica avversaria)
Sette blocchi dai due verificatori, e sono facce di un guasto solo: due
richieste di invio potevano essere in volo sulla stessa card. Il giro prima aveva
chiuso solo l'istanza che passa dal ramo «occupato» dell'instradamento — e con lo
STESSO contenuto le due richieste sono la stessa domanda per quel ramo, quindi
non scattava. La seconda si vedeva consegnare l'askId della prima, entrava nel
rendez-vous (chiavato sulla sessione) e lo sostituiva; la prima tornava
«superseded» e si portava via la voce comune. Misurato a 120 ms: conferma a
schermo coi tasti,
pendingRoutedAsknull, clic che non consegnava e non dicevaniente, e la gamba dopo scriveva una SECONDA conferma.
Il cancello si serializza da se'. Prende un lucchetto sulla superficie
che disegna la domanda (la card; la sessione quando non ce n'e' una) prima
di qualunque scrittura e prima di aprire qualunque rendez-vous, e lo
rilascia alla fine, anche sugli errori. Chi non lo prende viene rifiutato con la
sua ragione, e la riga del rifiuto resta una nota che non tocca la domanda viva.
Non guarda il contenuto: due invii identici non arrivano piu' insieme al
rendez-vous. Ordinare le operazioni non basta e non e' piu' la difesa.
Il lucchetto attraversa le gambe con un gettone. La rotta lo restituisce
insieme a
pendingepollOutboundlo riporta a ogni gamba; una gamba che loporta rinnova, una richiesta che non ce l'ha e trova la card occupata e'
rifiutata. Non c'e' altro che possa distinguerli: con un payload identico la
seconda gamba di una richiesta e una seconda richiesta hanno lo stesso digest,
la stessa chiave e lo stesso testo. Il lucchetto scade (gamba dichiarata +
margine), o un agente morto fra due gambe terrebbe la card per sempre.
Una richiesta non possiede un id che non ha creato.
createdsull'esitodell'instradamento, e l'id tenuto attraverso le gambe dentro il lucchetto. La
finestra dove serve e' reale: il lucchetto scade prima della voce nel registro
(due minuti di silenzio), e in quel buco l'instradamento consegnerebbe a una
richiesta identica la riga di chi sta ancora aspettando.
Una
ask_user_questiongenerica non uccide piu' la conferma sotto di se'.Stesso guasto senza nessun duplicato:
routes/permission.tsignoravabusydiproposito e chiamava
waitForAnswersulla stessa sessione. Quando la card e'occupata da una richiesta della STESSA sessione, quella gamba adesso spende il
suo tempo senza registrarsi e risponde
pending— aspetta il turno senzatogliere niente. Un'altra sessione e' un altro rendez-vous e non cambia niente.
Falsificato al settimo giro: valeva solo DENTRO la board. Senza card
routed.busynon esiste e la corsa era intatta. Vedi sotto.I tasti non restano su un blocco morto. Una domanda che finisce senza una
risposta nel thread — scaduta, annullata, o risposta dal pannello della chat —
viene chiusa con una riga sua, che e' cio' che il lettore dei tasti conta come
parola (svuotare il registro e basta lasciava un blocco che non rispondeva a
nessuno, e la traccia accanto e'
quietapposta). E un clic su un blocco chenon aspetta piu' nessuno riceve una riga sulla card, invece di diventare un
commento qualunque che per giunta rimetteva al lavoro l'agente. Una frase
scritta sotto lo stesso blocco resta una nota: e' il contenuto che coincide con
un'opzione di quel blocco a dire che qualcuno ha premuto.
Testi veri.
check:test-globalsdichiarava «~80s» ed era contato fra icancelli leggeri: lancia un
bun testper OGNI file, cioe' la suite unitintera, non sta in
.github/workflows/ci.yml, e sul Mac di chi lavora si e'preso 18 minuti prima di essere fermato a mano. Corretto nelle tre righe che lo
dicevano (lo script, il messaggio del preload, la barra della change). E la doc
di
clearRoutedAskprometteva una protezione che non da': «un id che ha scrittoqualcun altro non e' un id con cui si puo' chiamare» e' falso proprio nel caso
che denunciava, perche' l'id e' lo stesso.
Prove del sesto giro (rotte vere, CLI finta, nessun invio)
conferma sulla card, il registro che la nomina, il clic consegnato, e in argv
un
+sendsolo;pending, nessuna seconda conferma);una richiesta identica senza gettone, e una con un gettone falso, sono
rifiutate e non toccano la domanda a schermo;
invece di adottare quella riga, e il clic sulla domanda viva consegna e spedisce;
/ask-uservera, bridgevero):
pending, niente scritto, registro invariato, l'attesa dell'invio nonannullata, e il si' che la raggiunge;
riga sua e il lettore dei tasti non trova piu' una domanda;
l'agente non viene rimesso al lavoro, e una frase sotto lo stesso blocco
resta un commento normale;
clearRoutedAskcon un id non tocca l'altra voce della stessa sessione (duecard, un agente): e' il caso che la copertura di prima non poteva distinguere;
resta lo stesso messaggio.
Mutanti eseguiti, otto, ognuno fa rosso almeno un test: lucchetto tolto ·
gettone ignorato dal client · adozione di un id non creato ·
clearRoutedAskpersessione · la domanda generica che si registra lo stesso · la chiusura che non
scrive niente · la riga del clic morto tolta · il clic riconosciuto senza guardare
le opzioni.
Settimo giro (verifica avversaria)
Un blocco riprodotto e quattro mutanti sopravvissuti, cioe' quattro regole che il
testo dichiarava e nessun test teneva. Chiusi tutti e cinque con il loro rosso.
Fuori dalla board la corsa era intatta. Il rimedio del giro prima pende da
routed.busy, e quel campo esiste solo quando la sessione ha una card: in unachat
routeAskToTaskThreadtornanull,heldByThisSessionrestava falso e lagamba si registrava lo stesso. Misurato con le rotte vere e nessuna card:
CONSEGNATO: truealla domanda generica — cioe' il si' letto sul pannellodell'INVIO — e l'invio che torna
{"state":"refused","reason":"ask_user_question: superseded by a newer question"}.La regola e' la stessa con o senza card, e il lucchetto del cancello la
chiave giusta ce l'ha gia': per una sessione senza card e'
session:<k>. Adessoil fatto «una conferma di questa sessione aspetta» si legge da li', non dal
registro delle domande (che e' chiavato sul task e in chat non esiste), e quando
e' vero la gamba non tocca niente: nessun commento, nessuna voce nel
registro, nessun waiter.
Il lucchetto scade, e adesso lo dice un orologio. Il caso «lucchetto
scaduto» fingeva la scadenza con
_resetOutboundHolds(): provava il reset, nonil tempo. Con il confronto su
expiresAttolto del tutto la suite restavaverde. L'orologio del contratto ora e' iniettabile e il test lo avanza: dentro
la gamba dichiarata piu' il margine la richiesta senza gettone e' respinta,
oltre passa.
Il lucchetto e' della CARD, e la suite non lo distingueva da «della
sessione». Nei casi gia' coperti a respingere la seconda richiesta bastava il
ramo «occupato» dell'instradamento, che legge il registro. Il caso che li separa
e' quello che conta: la persona ha gia' risposto sulla card, quindi il
registro e' vuoto, ma la prima richiesta e' viva e la sua gamba dopo sta per
spendere quel si'. Con la chiave sulla sessione la sorella dello stesso task
apre una seconda conferma mentre il primo messaggio parte; con la chiave
sulla card e' rifiutata, e in argv c'e' un
+sendsolo, verso il destinatarioconfermato.
La gamba occupata spende davvero il suo tempo. Toglierlo non e' cosmetico:
il client MCP fa
continuesupendingsenza nessuna attesa propria, quindi ladomanda brucerebbe le sue 600 gambe in un istante e morirebbe con «gave up after
600 poll legs» invece di aspettare il turno. Il test misura i millisecondi della
gamba, sulle due strade (con card e in chat).
La domanda di un'ALTRA sessione deve invece registrarsi. «Un'altra sessione
e' un altro rendez-vous e non cambia niente» non lo provava niente, e sbagliarlo
dall'altro verso costa una domanda che non raggiunge nessuno per tutto il tempo
in cui la sorella tiene la card. Adesso il caso c'e': la card e' occupata da
un'altra sessione, la mia gamba si registra, riceve la sua risposta, e la
domanda dell'altra resta aperta e non annullata.
Prove del settimo giro (rotte vere, CLI finta, nessun invio)
pending, la gamba spesa, nessun secondo pannello, e il si' sul pannellodell'invio che paga l'invio (
granted);la mappa dei lucchetti;
sua ragione, UN solo blocco di tasti sulla card, un
+sendsolo in argv el'altro destinatario che non compare;
{answers}e nonpending, e la voce apertadell'altra intatta.
Mutanti eseguiti, cinque, ognuno fa rosso almeno un test: lucchetto letto solo
via
routed.busy(il blocco 1) · lucchetto senza scadenza · chiave del lucchettosulla sessione invece che sulla card · attesa della gamba occupata tolta · vincolo
«stessa sessione» tolto.
Ottavo giro (verifica avversaria)
Un blocco riprodotto e due mutanti sopravvissuti. Il blocco e' lo speculare
esatto di quello del giro prima, sullo stesso canale e nella direzione opposta.
In chat il si' arrivava alla SESSIONE, non alla domanda. Il rimedio del
settimo giro parcheggia la domanda generica senza registrarsi - ma il suo
pannello resta a schermo: lo dipinge il rilevatore dello stream sul
tool_use,non la rotta della gamba (
server/routes/permission.ts:127-131lo dice gia').La persona clicca quello, e
/api/chat/tool-responsenon guardava qualedomanda si stesse rispondendo:
hasPendingAsk(sessionKey)e' vero e si vadritti a
deliverAnswer(sessionKey, answers). Misurato con le due rotte vere esenza card:
consegnato: truee l'invio che torna{"state":"refused","reason":"the answer that came back was not about this message"},con la gamba dopo della generica ancora
{"pending":true}. Un clic, due domanderotte.
La forma giusta questa PR ce l'aveva gia' altrove: sulla strada della board
answerTodice che il si' appartiene a QUELLA domanda. Adesso ce l'ha anche lachat. L'attesa che possiede il rendez-vous nomina la riga su cui la sua
domanda e' dipinta - il cancello d'invio il pannello lo dipinge lui, quindi la
nomina - e la rotta consegna solo se il
toolCallIdcliccato combacia. Se noncombacia: niente consegna, niente parte, nessuna attesa uccisa, e un 409 con
una riga leggibile che il form mostra in linea (
ToolInputFormgia' mostra ilmessaggio dell'
ApiError). Un pannello vecchio o gia' risposto lo dice, invecedi rubare il turno a quello vivo.
Il ramo di compatibilita' e' dichiarato: chi apre l'attesa senza nominare una
riga - la gamba generica, che il pannello non lo dipinge - riceve la risposta da
qualunque pannello. Ci arriva solo quando nessuna conferma di quella sessione
tiene il lucchetto, quindi la sua e' l'unica domanda che possa essere in attesa.
E un'attesa senza identita' cancella quella lasciata prima, o un invio
abbandonato renderebbe muta la domanda che gli succede.
I due predicati di
outboundHoldOfSessionnon li teneva nessun test. Lafunzione che il settimo giro ha aggiunto ha due condizioni e togliendone una
qualunque la suite del ramo restava verde. Nessuna delle due e' accademica:
senza il vincolo di sessione l'invio di UNA sessione parcheggia le domande
generiche di tutte le altre (la mappa dei lucchetti e' globale, e
coordinatore piu' figlie sono piu' sessioni per costruzione); senza la scadenza
un agente morto fra due gambe lascia la sua sessione senza domande fino al
riavvio, perche' niente ripuliva quella mappa. L'orologio iniettabile non
arrivava fin qui - l'unico chiamante non passa il secondo argomento - quindi la
scadenza la prova una rotta, con la gamba dell'invio indietro di due minuti.
E un lucchetto scaduto adesso esce dalla mappa invece di essere solo
ignorato: era memoria che non tornava piu'.
Prove dell'ottavo giro (rotte vere, CLI finta, nessun invio)
server/routes/chat.ask-answer-identity.test.ts, con/api/chat/tool-responsee la gamba/ask-uservere, il rendez-vous e ilcancello veri, nessuna card:
ask_not_current, niente scritto,nessun broadcast, la conferma ancora in attesa - e il si' dato al suo pannello
la paga (
granted);rispondibile;
compatibilita');
registra e riceve la sua risposta - cioe' la scadenza del lucchetto misurata sul
chiamante vero, e l'identita' azzerata;
{answers}e nonpending, e l'inviodell'altra sessione resta in attesa del suo pannello.
In
server/lib/outbound-gate.test.ts, con l'orologio del test: il lucchetto e'vero per la sua sessione e falso per un'altra, vivo fino all'ultimo millisecondo
della gamba piu' il margine, falso subito dopo, e la mappa e' vuota.
Mutanti eseguiti, sei, ognuno fa rosso almeno un test: vincolo di sessione
tolto · scadenza tolta · rimozione dalla mappa tolta · l'invio che non nomina il
suo pannello · l'attesa senza identita' che non azzera quella prima · il cancello
di rotta tolto.
Prove
Tutte contro un finto eseguibile che registra
argv, l'ambiente e il CONTENUTOdi ogni argomento che sia un file. Nessun invio vero, mai.
; rm -rf, virgolette, a capo,$(...)) resta UN argomento e nonesegue niente;
percorso del workspace non e' nemmeno negli argomenti;
workspace, non quello piantato;
card;
gmail +forwardcon i flag nei campi liberi: rifiutato conuse_send_mail;invalid_call;gmail users messages sendrifiutato senza aprire nessuna domanda;rawmostra destinatario, oggetto e testo in chiaro;classificare conta come scrittura;
rifiutata e il pannello finisce sulla riga giusta.
Mutanti verificati (ognuno fa rosso almeno un test): chiave della conferma ignorata,
pendingtrattato come consenso, ripiego silenzioso sull'account predefinito, spawnvia
sh -c, contenimento degli allegati disattivato, alla CLI il percorso delworkspace invece della copia congelata, rifiuto dei metodi che spediscono
disattivato,
rawnon decodificato, flush della riga rimosso dalla rotta, registroche considera «gia' instradata» qualunque voce della stessa sessione.
Change
openspec/changes/posta-e-google(gia' approvata), con delta di specoutbound(OUTBOUND-01..05) e
tasks.md.