{s.platform === 'linux' && (
diff --git a/web/src/pages/Setup.tsx b/web/src/pages/Setup.tsx
index b3471732..ab8e7bd0 100644
--- a/web/src/pages/Setup.tsx
+++ b/web/src/pages/Setup.tsx
@@ -10,7 +10,7 @@ import { getSteps, useSetup } from '../components/setup/SetupContext';
import { setup } from '../lib/api';
// Re-export types for step components that import from here
-export type { WizardData, StepProps } from '../components/setup/SetupContext';
+export type { StepProps } from '../components/setup/SetupContext';
const ALL_STEP_COMPONENTS: Record = {
welcome: WelcomeStep,
From 0f8f03b4de02a85617b8a385e14d26445faf7065 Mon Sep 17 00:00:00 2001
From: TONresistor <240980241+TONresistor@users.noreply.github.com>
Date: Sat, 6 Jun 2026 00:07:30 +0200
Subject: [PATCH 07/16] chore(release): 0.9.0
Drop the non-existent scripts/ entry from package.json files.
---
package.json | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/package.json b/package.json
index 4f3d3f42..763c40dd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "teleton",
- "version": "0.8.6",
+ "version": "0.9.0",
"workspaces": [
"packages/*"
],
@@ -32,7 +32,6 @@
"files": [
"dist/",
"bin/",
- "scripts/",
"src/templates/"
],
"scripts": {
From f2c814c2cf978a402dca74a6e629f26e98302728 Mon Sep 17 00:00:00 2001
From: TONresistor <240980241+TONresistor@users.noreply.github.com>
Date: Sat, 6 Jun 2026 01:05:22 +0200
Subject: [PATCH 08/16] fix(config): migrate removed 'cocoon' provider to
'gocoon'
0.9.0 dropped 'cocoon' from the provider enum. Existing 0.8.6 configs
with agent.provider: cocoon now migrate to 'gocoon' (carrying a custom
cocoon.port) instead of failing config validation, mirroring the
claude-code -> anthropic shim. Adds a loader test for the migration.
---
src/config/__tests__/loader.test.ts | 22 ++++++++++++++++++++++
src/config/loader.ts | 21 +++++++++++++++++++++
2 files changed, 43 insertions(+)
diff --git a/src/config/__tests__/loader.test.ts b/src/config/__tests__/loader.test.ts
index 106d6c80..4d61de59 100644
--- a/src/config/__tests__/loader.test.ts
+++ b/src/config/__tests__/loader.test.ts
@@ -151,6 +151,19 @@ market:
deprecated_field: "should be ignored"
`;
+// Config with the removed 'cocoon' provider (replaced by gocoon in 0.9.0)
+const LEGACY_COCOON = `
+agent:
+ api_key: sk-ant-test
+ provider: cocoon
+telegram:
+ api_id: 12345
+ api_hash: abcdef
+ phone: "+1234567890"
+cocoon:
+ port: 9999
+`;
+
// Config for non-anthropic provider (should auto-set model)
const OPENAI_CONFIG = `
agent:
@@ -563,6 +576,15 @@ storage:
expect((config as any).market).toBeUndefined();
});
+ it("should migrate the removed 'cocoon' provider to 'gocoon' and carry its port", () => {
+ writeTestConfig(LEGACY_COCOON);
+
+ const config = loadConfig(TEST_CONFIG_PATH);
+
+ expect(config.agent.provider).toBe("gocoon");
+ expect(config.gocoon?.port).toBe(9999);
+ });
+
it("should accept config with extra unknown fields", () => {
const configWithExtra = `
agent:
diff --git a/src/config/loader.ts b/src/config/loader.ts
index f80800a6..c5fb7811 100644
--- a/src/config/loader.ts
+++ b/src/config/loader.ts
@@ -59,6 +59,27 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config {
rawAgent.provider = "anthropic";
}
+ // Backward compatibility: the 'cocoon' provider (a proxy to an external
+ // cocoon-cli) was replaced by the native 'gocoon' provider in 0.9.0. Migrate
+ // so existing configs keep loading. gocoon runs its own channel, so the user
+ // must run `teleton gocoon init` and fund it before use.
+ if (rawAgent && rawAgent.provider === "cocoon") {
+ log.warn(
+ "Provider 'cocoon' was removed in 0.9.0; migrating to the native 'gocoon' provider. " +
+ "Run 'teleton gocoon init' and fund the channel before use."
+ );
+ rawAgent.provider = "gocoon";
+ // Carry a custom port from the old top-level cocoon block (the schema is
+ // non-strict and would otherwise drop it).
+ const rawObj = raw as Record;
+ const oldCocoon = rawObj.cocoon as { port?: number } | undefined;
+ if (oldCocoon?.port != null) {
+ const gocoon = (rawObj.gocoon as Record | undefined) ?? {};
+ if (gocoon.port == null) gocoon.port = oldCocoon.port;
+ rawObj.gocoon = gocoon;
+ }
+ }
+
const result = ConfigSchema.safeParse(raw);
if (!result.success) {
throw new Error(`Invalid config: ${result.error.message}`);
From c784f8da424033ac2d0a37fe45d7d6e2e56b3004 Mon Sep 17 00:00:00 2001
From: TONresistor <240980241+TONresistor@users.noreply.github.com>
Date: Sat, 6 Jun 2026 01:05:36 +0200
Subject: [PATCH 09/16] fix(ci): lowercase ghcr path in the release-notes
docker command
The create-release job rendered ghcr.io/${{ github.repository }} (mixed
case) in the public install snippet, which the Docker CLI rejects. Use a
lowercased step output, matching the actual image push.
---
.github/workflows/release.yml | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 069611e3..17358552 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -199,6 +199,10 @@ jobs:
echo "$LOG" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
+ - name: Lowercase image path for the install snippet
+ id: meta
+ run: echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
+
- name: Create GitHub Release
if: steps.check.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
@@ -214,7 +218,7 @@ jobs:
**Docker:**
```bash
- docker run -it -v ~/.teleton:/data ghcr.io/${{ github.repository }}:latest setup
+ docker run -it -v ~/.teleton:/data ghcr.io/${{ steps.meta.outputs.repo }}:latest setup
```
## Changes
From 42d713cdb22f80a1d7788135ec0a2a971bf0c866 Mon Sep 17 00:00:00 2001
From: TONresistor <240980241+TONresistor@users.noreply.github.com>
Date: Sat, 6 Jun 2026 20:23:05 +0200
Subject: [PATCH 10/16] fix(gocoon): harden withdraw against transient errors
and re-runs
- findClientSC throws a typed ChannelNotFoundError only for a genuine
no-channel case; transient tonapi/HTTP errors propagate so withdrawAll
ABORTS instead of draining the liquid wallet while the stake stays
locked and reporting success.
- withdrawAll decides liveness from the channel state (channelInfoOnChain
stateName/stake), not the account status, so a re-run on an
already-closed channel skips the close and sweeps the balance
idempotently instead of re-closing and timing out.
- waitForRefund returns a boolean; on a slow cooperative refund the
withdraw no longer hard-fails (misleading 'lost') but reports the
refund pending (~12h unilateral fallback) and leaves funds for a
re-run.
- resetWallet refuses when the channel state can't be verified (transient
error) rather than risk deleting keys to a live stake.
- gocoon wallet withdraw gets a generous --timeout for slow finality.
---
src/gocoon/lifecycle.ts | 156 +++++++++++++++++++++++++++-------------
1 file changed, 107 insertions(+), 49 deletions(-)
diff --git a/src/gocoon/lifecycle.ts b/src/gocoon/lifecycle.ts
index 445f7c2a..94545102 100644
--- a/src/gocoon/lifecycle.ts
+++ b/src/gocoon/lifecycle.ts
@@ -69,6 +69,12 @@ const COCOON_CLIENT_OPS = new Set([
"CocoonOwnerWithdraw",
]);
+// Thrown by findClientSC ONLY when the wallet has no cocoon_client interaction
+// on chain (no channel was ever staked). Transient/HTTP failures throw plain
+// Errors instead, so callers can abort rather than mistake a lookup failure for
+// "no channel" and drain the liquid wallet while leaving the stake locked.
+class ChannelNotFoundError extends Error {}
+
// Add TON to the open channel. The runner must be active (reads client_sc from /jsonstats).
export async function topup(
amountTon: string,
@@ -100,50 +106,90 @@ export async function withdrawAll(
}
const cocoon = await walletInfo();
- // The COCOON register/topup ops are sent from the fund wallet (the node
- // wallet you fund), not the owner wallet. Scanning ownerAddress finds no
- // channel, silently skips the close, and only the liquid balance is
- // withdrawn while the stake stays locked. Match myduckai: scan fund_address.
- emit(
- "find_channel",
- "started",
- `Looking for an active channel via ${shortAddr(cocoon.fundAddress)}`
- );
+ // COCOON channel ops are executed by the fund (node) wallet, not the owner
+ // wallet — scan fund_address (the prior owner-address scan silently skipped
+ // the close and left the stake locked).
+ emit("find_channel", "started", `Looking for a channel via ${shortAddr(cocoon.fundAddress)}`);
let clientSC: string | null = null;
try {
clientSC = await findClientSC(cocoon.fundAddress);
- emit("find_channel", "ok", "Channel located");
} catch (err) {
- emit(
- "find_channel",
- "skipped",
- `No active channel, will only withdraw wallets (${getErrorMessage(err)})`
- );
+ if (err instanceof ChannelNotFoundError) {
+ emit(
+ "find_channel",
+ "skipped",
+ "No channel was ever staked; withdrawing wallet balance only"
+ );
+ } else {
+ // A transient lookup failure (tonapi rate-limit/5xx, network) must NOT be
+ // treated as "no channel" — that would drain the liquid wallet and leave
+ // the staked TON locked while reporting success. Abort so the user retries.
+ emit("find_channel", "error", `Channel lookup failed: ${getErrorMessage(err)}`);
+ throw new Error(
+ `could not check for an open channel (${getErrorMessage(err)}); aborting so the staked TON is not left locked. Retry the withdraw.`
+ );
+ }
}
if (clientSC) {
- emit("spawn_runner", "started", "Starting gocoon-runner (transient)");
- const runner = await spawnTransientRunner();
- try {
- await waitReady(`${runnerBaseUrl(port)}/jsonstats`, 30_000);
- emit("spawn_runner", "ok", "Runner ready");
-
- emit("close_channel", "started", "Closing channel");
- await streamGocoon([
- "channel",
- "close",
- "--client-sc",
- clientSC,
- "--url",
- runnerBaseUrl(port),
- ]);
- emit("close_channel", "ok", "Channel close transaction sent");
-
- emit("wait_refund", "started", "Waiting for the staked TON to return (up to 3 min)");
- await waitForRefund(REFUND_TIMEOUT_MS);
- emit("wait_refund", "ok", "Refund landed on chain");
- } finally {
- if (runner.pid != null) killProcessGroup(runner.pid);
+ // Decide liveness from the channel STATE, not the account status: a
+ // cooperatively-closed cocoon_client account stays "active" (it holds
+ // storage TON). Re-closing a closed channel would hang the refund wait.
+ const ch = await channelInfoOnChain(clientSC);
+ if (!ch) {
+ emit("find_channel", "error", "Could not read the channel state");
+ throw new Error(
+ "located a channel but could not read its state; aborting so the staked TON is not left locked. Retry the withdraw."
+ );
+ }
+ const live = ch.stateName !== "closed" && ch.stakeNano > 0n;
+ if (live) {
+ emit("find_channel", "ok", "Channel located (open) — closing");
+ emit("spawn_runner", "started", "Starting gocoon-runner (transient)");
+ const runner = await spawnTransientRunner();
+ try {
+ await waitReady(`${runnerBaseUrl(port)}/jsonstats`, 30_000);
+ emit("spawn_runner", "ok", "Runner ready");
+
+ emit("close_channel", "started", "Closing channel");
+ await streamGocoon([
+ "channel",
+ "close",
+ "--client-sc",
+ clientSC,
+ "--url",
+ runnerBaseUrl(port),
+ ]);
+ emit("close_channel", "ok", "Channel close transaction sent");
+
+ emit(
+ "wait_refund",
+ "started",
+ "Waiting for the staked TON to return (cooperative close, up to 3 min)"
+ );
+ const landed = await waitForRefund(REFUND_TIMEOUT_MS);
+ if (!landed) {
+ // The close IS on chain; the stake returns once the node co-signs or
+ // via the ~12h unilateral delay. Leave the wallets untouched so a
+ // later re-run sweeps everything cleanly (the re-run is idempotent).
+ emit(
+ "wait_refund",
+ "warn",
+ "Refund not received within 3 min. The channel close is on chain; the stake returns once the node co-signs or via the ~12h fallback. Re-run the withdraw later to finish."
+ );
+ emit(
+ "complete",
+ "warn",
+ "Channel closed; refund pending. Re-run the withdraw later to sweep the funds (your TON is safe)."
+ );
+ return;
+ }
+ emit("wait_refund", "ok", "Refund landed on chain");
+ } finally {
+ if (runner.pid != null) killProcessGroup(runner.pid);
+ }
+ } else {
+ emit("find_channel", "ok", "Channel already closed — withdrawing the remaining balance");
}
}
@@ -159,6 +205,8 @@ export async function withdrawAll(
clientConfigPath(),
"--to",
dest.address.toString({ bounceable: false }),
+ "--timeout",
+ "10m",
]);
emit("withdraw_cocoon", "ok", "COCOON wallet withdrawn");
} else {
@@ -206,8 +254,15 @@ export async function resetWallet(
// the account status: only a not-closed channel that still holds
// stake controls recoverable value and must block the reset.
liveChannel = !!ch && ch.stateName !== "closed" && ch.stakeNano > 0n;
- } catch {
- liveChannel = false; // no client_sc found (never staked)
+ } catch (err) {
+ if (!(err instanceof ChannelNotFoundError)) {
+ // Could not verify there is no live channel (transient lookup
+ // failure). Refuse rather than risk deleting keys to a live stake.
+ throw new Error(
+ `could not verify the channel state (${getErrorMessage(err)}); refusing to reset. Retry, or reset with force if you are sure.`
+ );
+ }
+ liveChannel = false; // ChannelNotFoundError: no channel was ever staked
}
if (liveChannel) {
throw new Error("a funded channel still exists; withdraw first (or reset with force)");
@@ -251,7 +306,11 @@ async function isRunnerUp(port: number): Promise {
}
}
-// Find the client_sc this wallet interacted with on chain, and verify it is active.
+// Find the client_sc this fund wallet interacted with on chain. Returns the
+// client_sc address; throws ChannelNotFoundError if the wallet never opened a
+// channel, and a plain Error for transient/HTTP failures so callers can abort
+// rather than mistake a lookup failure for "no channel". Open-vs-closed liveness
+// is decided by the caller via channelInfoOnChain.
async function findClientSC(walletAddr: string): Promise {
const res = await tonapiFetch(`/accounts/${encodeURIComponent(walletAddr)}/events?limit=50`);
if (!res.ok) throw new Error(`tonapi events returned HTTP ${res.status}`);
@@ -282,31 +341,30 @@ async function findClientSC(walletAddr: string): Promise {
if (candidate) break;
}
if (!candidate) {
- throw new Error("no cocoon_client interaction found; channel may never have been staked");
+ throw new ChannelNotFoundError(
+ "no cocoon_client interaction found; channel may never have been staked"
+ );
}
- const stat = await tonapiFetch(`/accounts/${encodeURIComponent(candidate)}`);
- if (!stat.ok) throw new Error(`tonapi account returned HTTP ${stat.status}`);
- const status = ((await stat.json()) as { status?: string }).status ?? "unknown";
- if (status !== "active") throw new Error(`client_sc is ${status} (already closed?)`);
-
return Address.parse(candidate).toString({ bounceable: false });
}
// Poll the COCOON wallet balance until it grows by at least 5 TON (the refund).
-async function waitForRefund(timeoutMs: number): Promise {
+// Returns true once the refund lands, false if it has not within timeoutMs (the
+// caller handles the slow/unilateral path without hard-failing the withdraw).
+async function waitForRefund(timeoutMs: number): Promise {
const baseline = (await walletInfo()).balanceNano;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(5_000);
try {
const now = (await walletInfo()).balanceNano;
- if (now > baseline && now - baseline >= REFUND_MIN_DELTA_NANO) return;
+ if (now > baseline && now - baseline >= REFUND_MIN_DELTA_NANO) return true;
} catch {
/* transient RPC error */
}
}
- throw new Error(`refund did not land within ${Math.round(timeoutMs / 1000)}s`);
+ return false;
}
// Send (balance - 0.01 TON) from teleton's agent wallet to dest. Skips if empty.
From 7308ee1aa793128ec298d6edd894230f3297543e Mon Sep 17 00:00:00 2001
From: TONresistor <240980241+TONresistor@users.noreply.github.com>
Date: Sat, 6 Jun 2026 20:23:16 +0200
Subject: [PATCH 11/16] fix(gocoon): surface runner errors through the SSE
proxy
A runner error (non-2xx, a {error} envelope, or a non-JSON body) was
reframed into an empty 200 SSE stream that the OpenAI SDK parses as a
successful zero-token reply, hiding failures (no workers / channel out of
balance) and letting the agent retry silently. Now non-2xx is forwarded
with its status and error envelopes/parse failures become SSE error
events the SDK throws on. Adds completionToSse tests.
---
src/gocoon/__tests__/sse-proxy.test.ts | 53 ++++++++++++++++++++++++++
src/gocoon/sse-proxy.ts | 23 ++++++++++-
2 files changed, 75 insertions(+), 1 deletion(-)
create mode 100644 src/gocoon/__tests__/sse-proxy.test.ts
diff --git a/src/gocoon/__tests__/sse-proxy.test.ts b/src/gocoon/__tests__/sse-proxy.test.ts
new file mode 100644
index 00000000..3ccae719
--- /dev/null
+++ b/src/gocoon/__tests__/sse-proxy.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from "vitest";
+import { completionToSse } from "../sse-proxy.js";
+
+// Parse the `data: {...}` SSE lines back into objects for assertions.
+function parseEvents(events: string[]): any[] {
+ return events.map((e) => JSON.parse(e.replace(/^data: /, "").trim()));
+}
+
+describe("completionToSse", () => {
+ it("frames a chat completion into a delta chunk plus a usage chunk", () => {
+ const completion = JSON.stringify({
+ id: "c1",
+ created: 1,
+ model: "m",
+ choices: [{ index: 0, message: { role: "assistant", content: "hi" }, finish_reason: "stop" }],
+ usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
+ });
+ const evs = parseEvents(completionToSse(completion));
+ expect(evs).toHaveLength(2);
+ expect(evs[0].choices[0].delta).toEqual({ role: "assistant", content: "hi" });
+ expect(evs[0].choices[0].finish_reason).toBe("stop");
+ expect(evs[1].choices).toEqual([]);
+ expect(evs[1].usage.total_tokens).toBe(4);
+ });
+
+ it("emits only a delta chunk when usage is absent", () => {
+ const evs = parseEvents(
+ completionToSse(
+ JSON.stringify({ id: "c2", choices: [{ index: 0, message: { content: "x" } }] })
+ )
+ );
+ expect(evs).toHaveLength(1);
+ expect(evs[0].choices[0].delta.content).toBe("x");
+ });
+
+ it("surfaces a runner error envelope as an SSE error event, not an empty stream", () => {
+ // Regression: a 200 {error} envelope used to be framed into an empty,
+ // zero-token success stream, silently hiding the runner failure.
+ const evs = parseEvents(
+ completionToSse(JSON.stringify({ error: { message: "no workers available" } }))
+ );
+ expect(evs).toHaveLength(1);
+ expect(evs[0].error.message).toBe("no workers available");
+ expect(evs[0].choices).toBeUndefined();
+ });
+
+ it("turns a non-JSON upstream body into an error event carrying a snippet", () => {
+ const evs = parseEvents(completionToSse("upstream 502 bad gateway "));
+ expect(evs).toHaveLength(1);
+ expect(evs[0].error.message).toContain("non-JSON upstream body");
+ expect(evs[0].error.message).toContain("upstream 502");
+ });
+});
diff --git a/src/gocoon/sse-proxy.ts b/src/gocoon/sse-proxy.ts
index f31b0ffb..b1af20c9 100644
--- a/src/gocoon/sse-proxy.ts
+++ b/src/gocoon/sse-proxy.ts
@@ -71,6 +71,18 @@ export class GocoonSseProxy {
// Frame a single JSON completion as SSE only when the client wanted streaming.
if (wantsStream && ctype.includes("application/json")) {
const text = await upstream.text();
+ // A runner error (non-2xx) must surface as a real error, not be reframed
+ // into an empty 200 SSE stream — which the OpenAI SDK parses as a
+ // successful zero-token reply, hiding "no workers / channel out of balance
+ // / model not served" and letting the agent retry silently.
+ if (!upstream.ok) {
+ res.writeHead(upstream.status, { "content-type": "application/json" });
+ res.end(
+ text ||
+ JSON.stringify({ error: { message: `gocoon runner returned HTTP ${upstream.status}` } })
+ );
+ return;
+ }
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
for (const ev of completionToSse(text)) res.write(ev);
res.write("data: [DONE]\n\n");
@@ -100,7 +112,16 @@ export function completionToSse(completion: string): string[] {
try {
doc = JSON.parse(completion) as Record;
} catch {
- return [`data: ${completion}\n\n`];
+ // Non-JSON upstream body labelled application/json: emit a structured error
+ // so the SDK throws a clean APIError instead of an opaque SyntaxError.
+ const snippet = completion.slice(0, 200).replace(/\s+/g, " ").trim();
+ return [
+ `data: ${JSON.stringify({ error: { message: `gocoon sse-proxy: non-JSON upstream body: ${snippet}` } })}\n\n`,
+ ];
+ }
+ // A 200 error envelope must surface as a thrown error, not an empty stream.
+ if (doc.error) {
+ return [`data: ${JSON.stringify({ error: doc.error })}\n\n`];
}
const choices = Array.isArray(doc.choices) ? (doc.choices as Record[]) : [];
const base = () => ({
From b7d7749cb9609a7f09d2a8e9d20f6dccba48431e Mon Sep 17 00:00:00 2001
From: TONresistor <240980241+TONresistor@users.noreply.github.com>
Date: Sat, 6 Jun 2026 20:23:29 +0200
Subject: [PATCH 12/16] fix(gocoon): re-verify binary integrity on launch
The version sentinel only checked a version string, so an out-of-band
swap/corruption of gocoon/gocoon-runner went undetected. The sentinel
now records each binary's sha256 and ensureGocoonBinaries re-hashes
before reuse, re-downloading on mismatch. Legacy string sentinels
re-verify once.
---
src/gocoon/installer.ts | 57 +++++++++++++++++++++++++++++++++++------
1 file changed, 49 insertions(+), 8 deletions(-)
diff --git a/src/gocoon/installer.ts b/src/gocoon/installer.ts
index bc69340a..bdfd55cb 100644
--- a/src/gocoon/installer.ts
+++ b/src/gocoon/installer.ts
@@ -44,20 +44,54 @@ export interface GocoonBinaries {
runner: string;
}
+function sha256File(p: string): string {
+ return createHash("sha256").update(readFileSync(p)).digest("hex");
+}
+
+// The sentinel is JSON { version, gocoon, runner } recording each binary's
+// sha256 at install time. Legacy installs wrote a plain version string; those
+// parse to null so they get re-verified (and re-downloaded) once.
+function readSentinel(): { version: string; gocoon: string; runner: string } | null {
+ try {
+ const j = JSON.parse(readFileSync(versionSentinel(), "utf-8")) as Record;
+ if (
+ typeof j.version === "string" &&
+ typeof j.gocoon === "string" &&
+ typeof j.runner === "string"
+ ) {
+ return { version: j.version, gocoon: j.gocoon, runner: j.runner };
+ }
+ } catch {
+ /* missing or legacy plain-string sentinel */
+ }
+ return null;
+}
+
+// Cheap presence check for status display: pinned version + both files exist.
export function isInstalled(): boolean {
- return (
- existsSync(versionSentinel()) &&
- readFileSync(versionSentinel(), "utf-8").trim() === GOCOON_VERSION &&
- existsSync(gocoonBin()) &&
- existsSync(runnerBin())
- );
+ const s = readSentinel();
+ return !!s && s.version === GOCOON_VERSION && existsSync(gocoonBin()) && existsSync(runnerBin());
+}
+
+// Full integrity check: the on-disk binaries still match the sha256 recorded at
+// install. Catches an out-of-band swap/corruption that the version string alone
+// would miss, so we never launch an unverified runner against a live channel.
+function binariesVerified(): boolean {
+ const s = readSentinel();
+ if (!s || s.version !== GOCOON_VERSION) return false;
+ if (!existsSync(gocoonBin()) || !existsSync(runnerBin())) return false;
+ try {
+ return sha256File(gocoonBin()) === s.gocoon && sha256File(runnerBin()) === s.runner;
+ } catch {
+ return false;
+ }
}
// Download the pinned release, verify its SHA-256, extract both binaries into
// ~/.teleton/bin. Idempotent via the version sentinel.
export async function ensureGocoonBinaries(): Promise {
const out: GocoonBinaries = { gocoon: gocoonBin(), runner: runnerBin() };
- if (isInstalled()) return out;
+ if (binariesVerified()) return out;
const { os, arch } = detectPlatform();
const archive = `gocoon-${GOCOON_VERSION}-${os}-${arch}.tar.gz`;
@@ -100,7 +134,14 @@ export async function ensureGocoonBinaries(): Promise {
rmSync(tmp, { recursive: true, force: true });
}
- writeFileSync(versionSentinel(), GOCOON_VERSION);
+ writeFileSync(
+ versionSentinel(),
+ JSON.stringify({
+ version: GOCOON_VERSION,
+ gocoon: sha256File(out.gocoon),
+ runner: sha256File(out.runner),
+ })
+ );
log.info(`gocoon ${GOCOON_VERSION} installed`);
return out;
}
From cb04e546098bc4a9e53acd4676c972edb2b5e929 Mon Sep 17 00:00:00 2001
From: TONresistor <240980241+TONresistor@users.noreply.github.com>
Date: Sat, 6 Jun 2026 20:23:39 +0200
Subject: [PATCH 13/16] fix(gocoon): show fund address in status, gate top-up
on running
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`gocoon status` printed the owner wallet (no on-chain history, not the
deposit address) labelled 'COCOON wallet' — the same owner/fund
confusion behind the unstake bug; it now shows the fund address. The
WebUI top-up control is disabled while the runner is stopped (top-up
needs a live runner).
---
src/cli/commands/gocoon.ts | 4 ++--
web/src/components/GocoonPanel.tsx | 13 +++++++++++--
2 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/src/cli/commands/gocoon.ts b/src/cli/commands/gocoon.ts
index bdd47626..0eb18826 100644
--- a/src/cli/commands/gocoon.ts
+++ b/src/cli/commands/gocoon.ts
@@ -112,10 +112,10 @@ async function runStatus(): Promise {
if (installed) {
try {
const info = await walletInfo();
- console.log(` COCOON wallet: ${info.ownerAddress}`);
+ console.log(` Fund address: ${info.fundAddress}`);
console.log(` Balance: ${info.balanceTon} TON`);
} catch {
- console.log(` COCOON wallet: ${DIM("not set up, run `teleton gocoon init`")}`);
+ console.log(` Fund address: ${DIM("not set up, run `teleton gocoon init`")}`);
}
}
diff --git a/web/src/components/GocoonPanel.tsx b/web/src/components/GocoonPanel.tsx
index 0c277daa..0d551712 100644
--- a/web/src/components/GocoonPanel.tsx
+++ b/web/src/components/GocoonPanel.tsx
@@ -326,13 +326,22 @@ export function GocoonPanel() {
type="number"
placeholder="TON (e.g. 5)"
value={topupAmount}
+ disabled={!running}
onChange={(e) => setTopupAmount(e.target.value)}
/>
-
-
Adds stake to the channel (the runner must be active).
+
+ {running
+ ? 'Adds stake to the channel.'
+ : 'Start the agent first — top up needs the runner active.'}
+