Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,22 @@ curl -L https://registry.npmjs.org/@tloncorp/tlon-skill-linux-arm64/-/tlon-skill
# Cookie-based auth (fastest - ship parsed from cookie)
tlon --url https://your-ship.tlon.network --cookie "urbauth-~your-ship=0v..." contacts self

# Cookie-based auth with explicit ship and code fallback
tlon --url https://your-ship.tlon.network --ship ~your-ship \
--cookie "urbauth-~your-ship=0v..." --code sampel-ticlyt-migfun-falmel contacts self

# Code-based auth (requires all three)
tlon --url https://your-ship.tlon.network --ship ~your-ship --code sampel-ticlyt-migfun-falmel contacts self

# Use skill-dir or cached credentials for one ship
tlon --ship ~your-ship contacts self

# Or use a config file
tlon --config ~/ships/my-ship.json contacts self
```

Valid CLI credential forms are `--config <file>`, `--url <url> --cookie <cookie>` with optional `--ship` and fallback `--code`, `--url <url> --ship <ship> --code <code>`, and `--ship <ship>`. Incomplete or conflicting credential flag sets fail locally instead of merging with environment variables.

Config file format:
```json
// Cookie-based (ship derived from cookie)
Expand All @@ -60,34 +69,42 @@ export URBIT_SHIP="~your-ship"
export URBIT_CODE="sampel-ticlyt-migfun-falmel"
```

**Option 3: OpenClaw config**
`URBIT_*` aliases take precedence over `TLON_*` aliases for the same field. Partial ambient credentials fail locally except ship-only env, which is used for `TLON_SHIP + TLON_SKILL_DIR` or cache lookup.

**Option 3: Skill directory**

If you have OpenClaw configured with a Tlon channel, credentials are loaded automatically.
When `TLON_SHIP` and `TLON_SKILL_DIR` are set, the CLI loads `ships/<ship>.json` from that skill directory before checking cached credentials.

**Option 4: OpenClaw config**

If you have OpenClaw configured with a Tlon channel, credentials are loaded automatically from JSON config. The default path is `~/.openclaw/openclaw.json`; `OPENCLAW_CONFIG` can point to an explicit JSON config path and is parsed as JSON regardless of extension.

**Resolution order:** CLI credential flags -> `TLON_CONFIG_FILE` -> URL + cookie env -> URL + ship + code env -> `TLON_SHIP + TLON_SKILL_DIR` -> ship-only cache lookup -> OpenClaw JSON -> single cached ship.

## Cookie Caching

The skill automatically caches auth cookies to `~/.tlon/cache/<ship>.json` after successful authentication.
The skill caches fresh auth cookies from code login and code fallback to `~/.tlon/cache/<ship>.json`. Provided-cookie flows validate the cookie but do not copy that cookie into cache.

```bash
# First time - auth and cache
$ tlon --url https://zod.tlon.network --ship ~zod --code abcd-efgh contacts self
Note: Credentials cached for ~zod. Next time just run: tlon <command>
Note: Credentials cached for ~zod. Next time run: tlon --ship ~zod <command>

# After that - no flags needed!
$ tlon contacts self
# After that - select the cached ship
$ tlon --ship ~zod contacts self

# Multiple cached ships? Specify which one:
$ tlon --ship ~zod contacts self
```

Clear cache: `rm ~/.tlon/cache/*.json`
Cache entries are ship- and URL-specific. Clear cache: `rm ~/.tlon/cache/*.json`

## Cookie vs Code Authentication

- **Cookie-based auth**: Uses a pre-authenticated session cookie. Faster since it skips login.
- **Code-based auth**: Performs a login request to get a session cookie.
- **Code-based auth**: Performs a login request to get a fresh session cookie.

The ship name is embedded in the cookie (`urbauth-~ship=...`), so you don't need to specify it separately with cookie auth.
The ship name is embedded in the cookie (`urbauth-~ship=...`), so you don't need to specify it separately with cookie auth unless you want to override it. You can provide both cookie and code; the cookie is used first and the code is fallback if the cookie has expired.

## Multi-Ship Usage

Expand Down
29 changes: 21 additions & 8 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,22 @@ Replace `darwin-arm64` with `darwin-x64`, `linux-x64`, or `linux-arm64` as neede
# Cookie-based auth (fastest - ship parsed from cookie name)
tlon --url https://your-ship.tlon.network --cookie "urbauth-~your-ship=0v..." <command>

# Cookie-based auth with explicit ship and code fallback
tlon --url https://your-ship.tlon.network --ship ~your-ship \
--cookie "urbauth-~your-ship=0v..." --code sampel-ticlyt-migfun-falmel <command>

# Code-based auth (requires url + ship + code)
tlon --url https://your-ship.tlon.network --ship ~your-ship --code sampel-ticlyt-migfun-falmel <command>

# Use skill-dir or cached credentials for one ship
tlon --ship ~your-ship <command>

# Or load from a JSON config file
tlon --config ~/ships/my-ship.json <command>
```

Valid CLI credential forms are `--config <file>`, `--url <url> --cookie <cookie>` with optional `--ship` and fallback `--code`, `--url <url> --ship <ship> --code <code>`, and `--ship <ship>`. Incomplete or conflicting credential flag sets fail locally instead of merging with environment variables.

Config file format:

```json
Expand All @@ -91,31 +100,35 @@ export URBIT_SHIP="~your-ship"
export URBIT_CODE="sampel-ticlyt-migfun-falmel"
```

**OpenClaw:** If configured with a Tlon channel, credentials load automatically.
`URBIT_*` aliases take precedence over `TLON_*` aliases for the same field. Partial ambient credentials fail locally except ship-only env, which is used for `TLON_SHIP + TLON_SKILL_DIR` or cache lookup.

**Resolution order:** CLI flags → `TLON_CONFIG_FILE` → `URL + COOKIE` → `URL + SHIP + CODE` → `--ship` with cache → OpenClaw config → cached ships (auto-select if only one)
**Skill directory:** When `TLON_SHIP` and `TLON_SKILL_DIR` are set, the CLI loads `ships/<ship>.json` from that skill directory before checking cached credentials.

**OpenClaw:** If configured with a Tlon channel, credentials load automatically from JSON config. The default path is `~/.openclaw/openclaw.json`; `OPENCLAW_CONFIG` can point to an explicit JSON config path and is parsed as JSON regardless of extension.

**Resolution order:** CLI credential flags → `TLON_CONFIG_FILE` → URL + cookie env → URL + ship + code env → `TLON_SHIP + TLON_SKILL_DIR` → ship-only cache lookup → OpenClaw JSON → single cached ship.

**Cookie vs Code:**

- **Cookie-based:** Uses pre-authenticated session cookie. Ship is parsed from the cookie name (`urbauth-~ship=...`). Fastest option.
- **Code-based:** Performs login to get session cookie. Requires URL + ship + code.
- **Code-based:** Performs login to get a fresh session cookie. Requires URL + ship + code.

You can provide both cookie and code — cookie is used first, code serves as fallback if cookie expires.

## Cookie Caching

The skill automatically caches auth cookies to `~/.tlon/cache/<ship>.json` after successful authentication. This makes subsequent invocations much faster by skipping the login request.
The skill caches fresh auth cookies from code login and code fallback to `~/.tlon/cache/<ship>.json`. Provided-cookie flows validate the cookie but do not copy that cookie into cache. This makes subsequent invocations much faster by skipping the login request.

**How it works:**

```bash
# First time - authenticates and caches
$ tlon --url https://zod.tlon.network --ship ~zod --code abcd-efgh contacts self
~zod
Note: Credentials cached for ~zod. Next time just run: tlon <command>
Note: Credentials cached for ~zod. Next time run: tlon --ship ~zod <command>

# After that - no flags needed (if only one cached ship)
$ tlon contacts self
# After that - select the cached ship
$ tlon --ship ~zod contacts self
~zod

# With multiple cached ships - specify which one
Expand All @@ -128,7 +141,7 @@ $ tlon --ship ~bus contacts self
- Cached cookies are URL-specific (won't use a cookie for the wrong host)
- If only one ship is cached, it's auto-selected (no flags needed)
- If multiple ships are cached, you'll be prompted to specify with `--ship`
- The skill reminds you when you pass credentials that aren't needed
- Code login and code fallback cache fresh cookies; provided-cookie flows do not copy cookies into cache

**Clear cache:** `rm ~/.tlon/cache/*.json`

Expand Down
212 changes: 212 additions & 0 deletions scripts/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import {
type CredentialResolution,
type EnsureClientDeps,
__resetApiClientForTests,
ensureClient,
} from "./api-client";

function resolution(
overrides: Omit<Partial<CredentialResolution>, "config"> & {
config?: Partial<CredentialResolution["config"]>;
} = {}
): CredentialResolution {
const { config: _config, ...rest } = overrides;
const cfg = {
url: "https://zod.tlon.network",
ship: "zod",
code: "",
..._config,
} as CredentialResolution["config"];

return {
config: cfg,
origin: "cli",
authKind: cfg.cookie ? "cookie" : "code",
mayReadAuthCache: false,
mayWriteAuthCache: !!cfg.code || !!overrides.fallbackCode,
provenance: { selectedBy: "cli", ship: "cli" },
...rest,
};
}

function makeDeps(resolved: CredentialResolution, options: { cookieValid?: boolean; freshCookie?: string } = {}) {
const configureCalls: unknown[] = [];
const cacheWrites: Array<{ url: string; ship: string; cookie: string }> = [];

const deps: EnsureClientDeps = {
resolve: () => resolved,
configureClient: async (params) => {
configureCalls.push(params);
},
createCookieClient: () => ({}) as any,
validateCookie: async () => options.cookieValid ?? true,
getAuthenticatedCookie: () => options.freshCookie ?? "urbauth-~zod=0v-fresh",
cacheCookie: (url, ship, cookie) => {
cacheWrites.push({ url, ship, cookie });
},
setupSubscriptions: async () => {},
};

return { deps, configureCalls, cacheWrites };
}

const originalConsoleError = console.error;

beforeEach(() => {
console.error = () => {};
});

afterEach(() => {
console.error = originalConsoleError;
__resetApiClientForTests();
});

describe("ensureClient auth/cache policy", () => {
it("uses provided cookie first and does not cache it when validation succeeds", async () => {
const resolved = resolution({
config: { cookie: "urbauth-~zod=0v-cookie", code: "fallback-code" },
authKind: "cookie",
fallbackCode: "fallback-code",
mayWriteAuthCache: true,
});
const { deps, configureCalls, cacheWrites } = makeDeps(resolved, { cookieValid: true });

await ensureClient([], deps);

expect(configureCalls).toHaveLength(1);
expect(cacheWrites).toEqual([]);
});

it("uses fallback code after a provided cookie expires and caches the fresh cookie", async () => {
const resolved = resolution({
config: { cookie: "urbauth-~zod=0v-cookie", code: "fallback-code" },
authKind: "cookie",
fallbackCode: "fallback-code",
mayWriteAuthCache: true,
});
const { deps, configureCalls, cacheWrites } = makeDeps(resolved, {
cookieValid: false,
freshCookie: "urbauth-~zod=0v-fresh",
});

await ensureClient([], deps);

expect(configureCalls).toHaveLength(2);
expect(cacheWrites).toEqual([
{ url: "https://zod.tlon.network", ship: "zod", cookie: "urbauth-~zod=0v-fresh" },
]);
});

it("does not cache provided-cookie flows without fresh code auth", async () => {
const resolved = resolution({
config: { cookie: "urbauth-~zod=0v-cookie" },
authKind: "cookie",
mayWriteAuthCache: false,
});
const { deps, cacheWrites } = makeDeps(resolved, { cookieValid: true });

await ensureClient([], deps);

expect(cacheWrites).toEqual([]);
});

it("uses source-aware errors for expired provided cookies without fallback code", async () => {
const resolved = resolution({
config: { cookie: "urbauth-~zod=0v-cookie" },
origin: "config-file",
authKind: "cookie",
mayWriteAuthCache: false,
provenance: { selectedBy: "env", ship: "cookie", configPath: "/tmp/zod.json" },
});
const { deps } = makeDeps(resolved, { cookieValid: false });

await expect(ensureClient([], deps)).rejects.toThrow("Cookie credentials for ~zod from config file /tmp/zod.json");
});

it("identifies expired cached cookies as cache sourced", async () => {
const resolved = resolution({
config: { cookie: "urbauth-~zod=0v-cache" },
origin: "ship-cache",
authKind: "cached-cookie",
mayReadAuthCache: true,
mayWriteAuthCache: false,
provenance: { selectedBy: "env", ship: "cache", cachePath: "/tmp/cache/zod.json" },
});
const { deps } = makeDeps(resolved, { cookieValid: false });

await expect(ensureClient([], deps)).rejects.toThrow("Cached cookie for ~zod has expired");
});

it("caches fresh cookies after code login", async () => {
const resolved = resolution({
config: { code: "code" },
authKind: "code",
mayWriteAuthCache: true,
});
const { deps, configureCalls, cacheWrites } = makeDeps(resolved, {
freshCookie: "urbauth-~zod=0v-fresh",
});

await ensureClient([], deps);

expect(configureCalls).toHaveLength(1);
expect(cacheWrites).toEqual([
{ url: "https://zod.tlon.network", ship: "zod", cookie: "urbauth-~zod=0v-fresh" },
]);
});

it("keeps config-file, skill-dir, and OpenClaw cookies out of cache when they validate", async () => {
for (const origin of ["config-file", "skill-dir", "openclaw"] as const) {
__resetApiClientForTests();
const resolved = resolution({
config: { cookie: "urbauth-~zod=0v-cookie", code: "fallback" },
origin,
authKind: "cookie",
fallbackCode: "fallback",
mayWriteAuthCache: true,
});
const { deps, cacheWrites } = makeDeps(resolved, { cookieValid: true });

await ensureClient([], deps);

expect(cacheWrites).toEqual([]);
}
});

it("uses fallback code and caches fresh cookies for expired file-backed cookies", async () => {
for (const origin of ["config-file", "skill-dir", "openclaw"] as const) {
__resetApiClientForTests();
const resolved = resolution({
config: { cookie: "urbauth-~zod=0v-cookie", code: "fallback" },
origin,
authKind: "cookie",
fallbackCode: "fallback",
mayWriteAuthCache: true,
});
const { deps, cacheWrites } = makeDeps(resolved, {
cookieValid: false,
freshCookie: "urbauth-~zod=0v-fresh",
});

await ensureClient([], deps);

expect(cacheWrites).toEqual([
{ url: "https://zod.tlon.network", ship: "zod", cookie: "urbauth-~zod=0v-fresh" },
]);
}
});

it("reset hook clears initialized state for isolated tests", async () => {
const first = makeDeps(resolution({ config: { code: "first" }, authKind: "code", mayWriteAuthCache: true }));
await ensureClient([], first.deps);

__resetApiClientForTests();

const second = makeDeps(resolution({ config: { code: "second" }, authKind: "code", mayWriteAuthCache: true }));
await ensureClient([], second.deps);

expect(first.configureCalls).toHaveLength(1);
expect(second.configureCalls).toHaveLength(1);
});
});
Loading
Loading