clankerbend 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,7 @@ npx clankerbend
30
30
 
31
31
  That starts Codex Desktop with the default ClankerBend profile and bundled apps:
32
32
 
33
+ - **ClankerID**: switch between Codex accounts.
33
34
  - **VimNav**: Vim-style transcript navigation, number rails, role jumps, search,
34
35
  and a side panel.
35
36
  - **Sticky Notes**: select transcript text, click **Add note**, and the
@@ -45,9 +46,21 @@ ClankerBend writes runtime state outside the package:
45
46
 
46
47
  Override with `ONEWILL_CLANKERBEND_STATE_DIR`.
47
48
 
49
+ Managed Codex account profiles live under the same state directory. The primary
50
+ profile remains your normal `CODEX_HOME` or `~/.codex`; managed profiles get
51
+ their own `codex-home` and Electron user-data directories. You can start a
52
+ saved profile directly with:
53
+
54
+ ```sh
55
+ npx clankerbend --account work
56
+ ```
57
+
48
58
  ## Security
49
59
 
50
60
  ClankerBend binds to `127.0.0.1` on an OS-assigned ephemeral port. The launcher
51
- prints the exact host URL, for example `Host: http://127.0.0.1:49152`.
61
+ prints the exact host URL and bearer token, for example
62
+ `Host: http://127.0.0.1:49152` and `Token: ...`.
52
63
  `/clankerbend/*` endpoints require a bearer token by default. For local protocol
53
- debugging, you can set `ONEWILL_CLANKERBEND_DISABLE_AUTH=1`.
64
+ debugging, you can set `ONEWILL_CLANKERBEND_DISABLE_AUTH=1`. Hosted app pages
65
+ receive the same bearer token from their app URL fragment and from a
66
+ host-injected HTML bootstrap so URL rewriting does not break app auth.
@@ -57,11 +57,44 @@ async function runAction(type, payload = {}) {
57
57
 
58
58
  function readToken() {
59
59
  const params = new URLSearchParams(location.hash.startsWith("#") ? location.hash.slice(1) : location.hash);
60
- const value = params.get("clankerbend_token") || "";
61
- if (value) history.replaceState(null, "", location.pathname + location.search);
60
+ const fragmentToken = params.get("clankerbend_token") || "";
61
+ const bootstrapToken = window.__CLANKERBEND_TOKEN || "";
62
+ const metaToken = document.querySelector("meta[name='clankerbend-token']")?.content || "";
63
+ const inlineToken = readInlineBootstrapToken();
64
+ const cachedToken = readCachedToken();
65
+ const value = fragmentToken || bootstrapToken || metaToken || inlineToken || cachedToken;
66
+ if (fragmentToken) history.replaceState(null, "", location.pathname + location.search);
67
+ if (value) writeCachedToken(value);
62
68
  return value;
63
69
  }
64
70
 
71
+ function readInlineBootstrapToken() {
72
+ for (const script of document.scripts) {
73
+ const match = script.textContent?.match(/window\.__CLANKERBEND_TOKEN=("[^"]*");?/);
74
+ if (!match) continue;
75
+ try {
76
+ return JSON.parse(match[1]);
77
+ } catch {
78
+ return "";
79
+ }
80
+ }
81
+ return "";
82
+ }
83
+
84
+ function readCachedToken() {
85
+ try {
86
+ return sessionStorage.getItem("clankerbend_token") || "";
87
+ } catch {
88
+ return "";
89
+ }
90
+ }
91
+
92
+ function writeCachedToken(value) {
93
+ try {
94
+ sessionStorage.setItem("clankerbend_token", value);
95
+ } catch {}
96
+ }
97
+
65
98
  function headers(extra = {}) {
66
99
  return {
67
100
  ...extra,
@@ -47,11 +47,44 @@ async function bootstrap() {
47
47
 
48
48
  function readToken() {
49
49
  const params = new URLSearchParams(location.hash.startsWith("#") ? location.hash.slice(1) : location.hash);
50
- const value = params.get("clankerbend_token") || "";
51
- if (value) history.replaceState(null, "", location.pathname + location.search);
50
+ const fragmentToken = params.get("clankerbend_token") || "";
51
+ const bootstrapToken = window.__CLANKERBEND_TOKEN || "";
52
+ const metaToken = document.querySelector("meta[name='clankerbend-token']")?.content || "";
53
+ const inlineToken = readInlineBootstrapToken();
54
+ const cachedToken = readCachedToken();
55
+ const value = fragmentToken || bootstrapToken || metaToken || inlineToken || cachedToken;
56
+ if (fragmentToken) history.replaceState(null, "", location.pathname + location.search);
57
+ if (value) writeCachedToken(value);
52
58
  return value;
53
59
  }
54
60
 
61
+ function readInlineBootstrapToken() {
62
+ for (const script of document.scripts) {
63
+ const match = script.textContent?.match(/window\.__CLANKERBEND_TOKEN=("[^"]*");?/);
64
+ if (!match) continue;
65
+ try {
66
+ return JSON.parse(match[1]);
67
+ } catch {
68
+ return "";
69
+ }
70
+ }
71
+ return "";
72
+ }
73
+
74
+ function readCachedToken() {
75
+ try {
76
+ return sessionStorage.getItem("clankerbend_token") || "";
77
+ } catch {
78
+ return "";
79
+ }
80
+ }
81
+
82
+ function writeCachedToken(value) {
83
+ try {
84
+ sessionStorage.setItem("clankerbend_token", value);
85
+ } catch {}
86
+ }
87
+
55
88
  function headers(extra = {}) {
56
89
  return {
57
90
  ...extra,
package/cli.mjs CHANGED
@@ -54,6 +54,8 @@ function parseCodexArgs(args) {
54
54
  options.mock = true;
55
55
  } else if (arg === "--state-dir") {
56
56
  options.stateDir = requiredArg(args[++index], "--state-dir value");
57
+ } else if (arg === "--account") {
58
+ options.accountId = requiredArg(args[++index], "--account id");
57
59
  } else if (arg === "--help" || arg === "-h") {
58
60
  printCodexHelp();
59
61
  process.exit(0);
@@ -68,8 +70,8 @@ function printHelp() {
68
70
  console.log(`ClankerBend
69
71
 
70
72
  Usage:
71
- clankerbend [--mock] [--state-dir path]
72
- clankerbend codex [--mock] [--state-dir path]
73
+ clankerbend [--mock] [--state-dir path] [--account id]
74
+ clankerbend codex [--mock] [--state-dir path] [--account id]
73
75
  clankerbend help
74
76
  clankerbend --version
75
77
 
@@ -84,12 +86,13 @@ function printCodexHelp() {
84
86
  console.log(`ClankerBend Codex launcher
85
87
 
86
88
  Usage:
87
- clankerbend [--mock] [--state-dir path]
88
- clankerbend codex [--mock] [--state-dir path]
89
+ clankerbend [--mock] [--state-dir path] [--account id]
90
+ clankerbend codex [--mock] [--state-dir path] [--account id]
89
91
 
90
92
  Options:
91
93
  --mock Start against a mock transcript instead of Codex Desktop.
92
94
  --state-dir path Override ClankerBend runtime state directory.
95
+ --account id Start with a saved ClankerBend Codex account profile.
93
96
  `);
94
97
  }
95
98
 
@@ -46,6 +46,38 @@ Runtime state defaults to:
46
46
 
47
47
  Set `ONEWILL_CLANKERBEND_STATE_DIR` to override the root state directory.
48
48
 
49
+ ## Codex Account Profiles
50
+
51
+ ClankerBend can remember up to 20 Codex account profiles. It still launches at
52
+ most one Codex Desktop instance at a time. Switching accounts stops the current
53
+ ClankerBend-launched Desktop process and starts the selected profile.
54
+
55
+ The `primary` profile always points at the normal Codex home:
56
+
57
+ - `CODEX_HOME` when set
58
+ - otherwise `~/.codex`
59
+
60
+ Managed profiles are created empty under the ClankerBend state directory. Each
61
+ managed profile gets its own `codex-home` and Electron user-data directory. No
62
+ auth, sessions, skills, plugins, or history are copied from the primary profile.
63
+ If the primary profile is file-auth based, the managed profile receives only a
64
+ minimal `config.toml` auth-store setting so Codex can use the same auth mode.
65
+
66
+ ClankerID exposes these operations:
67
+
68
+ - **Switch**: stop the current Desktop process and launch the selected profile.
69
+ - **Launch by default**: choose which profile ClankerBend starts next time. This
70
+ does not change the primary Codex home.
71
+ - **Make primary**: promote a managed profile into the primary Codex home.
72
+ ClankerBend first moves the old primary home to
73
+ `<primary>.backup_<timestamp>` (normally `~/.codex.backup_<timestamp>`),
74
+ preserves it as a managed account, then moves the selected managed home into
75
+ the primary Codex home.
76
+ - **Rollback**: restore a previous primary backup and move the replaced primary
77
+ to `<primary>.rollback_replaced_<timestamp>`.
78
+ - **Archive**: remove a managed profile from the active account list and move
79
+ its directories into deleted-account storage.
80
+
49
81
  ## Ports And Cleanup
50
82
 
51
83
  The host and CDP adapter bind to `127.0.0.1` and use ephemeral ports. Launchers
package/docs/protocol.md CHANGED
@@ -239,17 +239,20 @@ An app must not bypass the host token by calling CDP or app-server directly.
239
239
  ClankerBend `0.1` defines two token bootstrap paths:
240
240
 
241
241
  1. **Hosted panel apps**: the host serves the app entry URL and may append the
242
- token in the URL fragment as `#clankerbend_token=<token>`. App JavaScript reads
243
- the fragment, stores the token in memory, immediately removes the fragment
244
- with `history.replaceState`, and sends the token in the `Authorization`
245
- header for JSON and SSE requests.
242
+ token in the URL fragment as `#clankerbend_token=<token>`. For served HTML,
243
+ the host may also inject a token bootstrap such as
244
+ `window.__CLANKERBEND_TOKEN` or `<meta name="clankerbend-token">` so URL
245
+ rewriting does not strand the app without a token. App JavaScript stores the
246
+ token in memory, may keep it in `sessionStorage` for hash-less reloads,
247
+ immediately removes any fragment with `history.replaceState`, and sends the
248
+ token in the `Authorization` header for JSON and SSE requests.
246
249
  2. **External clients**: the token is provided out of band by the launcher, such
247
- as an environment variable, config file, local IPC channel, or command output.
250
+ as the `Token:` startup line, an environment variable, config file, local IPC
251
+ channel, or command output.
248
252
 
249
253
  Hosts must not require a token in the query string. Query strings are more
250
- likely to be logged or copied than URL fragments. Hosts may additionally set a
251
- same-origin secure session cookie for panels they serve, but clients must still
252
- support the bearer-token header.
254
+ likely to be logged or copied than URL fragments. Clients must support the
255
+ bearer-token header.
253
256
 
254
257
  ## State Model
255
258
 
@@ -758,6 +761,10 @@ ClankerBend can launch Codex Desktop with CDP:
758
761
  --user-data-dir=<test-or-clankerbend-profile>
759
762
  ```
760
763
 
764
+ When account profiles are enabled, the adapter also launches Desktop with the
765
+ selected profile's `CODEX_HOME` and isolated Electron user-data directory.
766
+ ClankerBend `0.1` runs one managed Codex Desktop instance at a time.
767
+
761
768
  The host must not patch `/Applications/Codex.app`.
762
769
 
763
770
  ### Attach
@@ -144,6 +144,8 @@ export async function loadProfileFromManifests(options) {
144
144
  hostId: options.hostId,
145
145
  hostName: options.hostName,
146
146
  runDir: options.runDir,
147
+ runtimePaths: options.runtimePaths,
148
+ accountRegistry: options.accountRegistry,
147
149
  defaultPanelAppId,
148
150
  apps,
149
151
  manifests: loadedApps.map((loaded) => loaded.manifest),
@@ -67,6 +67,8 @@ class CodexDesktopCdpAdapter {
67
67
  this.codexCli = options.codexCli || DEFAULT_CODEX_CLI;
68
68
  this.runDir = options.runDir ? resolve(options.runDir) : null;
69
69
  this.profileDir = options.profileDir || (this.runDir ? join(this.runDir, "codex-profile") : null);
70
+ this.codexHome = options.codexHome ? resolve(options.codexHome) : null;
71
+ this.resetProfileDir = options.resetProfileDir === true;
70
72
  this.rendererBridges = normalizeRendererBridges(options);
71
73
  this.providers = normalizeProviders(options, this.rendererBridges);
72
74
  this.snapshotToTranscript = options.snapshotToTranscript || defaultSnapshotToTranscript;
@@ -93,9 +95,10 @@ class CodexDesktopCdpAdapter {
93
95
  }
94
96
  if (this.runDir) mkdirSync(this.runDir, { recursive: true });
95
97
  if (this.profileDir) {
96
- rmSync(this.profileDir, { recursive: true, force: true });
98
+ if (this.resetProfileDir) rmSync(this.profileDir, { recursive: true, force: true });
97
99
  mkdirSync(this.profileDir, { recursive: true });
98
100
  }
101
+ if (this.codexHome) mkdirSync(this.codexHome, { recursive: true });
99
102
 
100
103
  this.cdpPort = await freePort();
101
104
  this.child = spawn(this.codexApp, [
@@ -105,6 +108,7 @@ class CodexDesktopCdpAdapter {
105
108
  ], {
106
109
  env: {
107
110
  ...process.env,
111
+ ...(this.codexHome ? { CODEX_HOME: this.codexHome } : {}),
108
112
  ...(this.profileDir ? { CODEX_ELECTRON_USER_DATA_PATH: this.profileDir } : {})
109
113
  },
110
114
  stdio: ["ignore", "pipe", "pipe"]
@@ -885,6 +889,24 @@ class CodexDesktopCdpAdapter {
885
889
  await this.host.highlightRange(event.range, { durationMs: 1200 });
886
890
  } else if (event.kind === "highlightAnchor" && event.anchorId) {
887
891
  await this.host.highlightAnchor(event.anchorId, { durationMs: 1200 });
892
+ } else if (event.kind === "codexAccountSwitch" && event.accountId) {
893
+ if (typeof this.host.transcriptAdapter.switchTo !== "function") throw new Error("Codex account switching is unavailable");
894
+ await this.host.transcriptAdapter.switchTo(String(event.accountId));
895
+ } else if (event.kind === "codexAccountCreateAndSwitch") {
896
+ if (typeof this.host.transcriptAdapter.createAccount !== "function" || typeof this.host.transcriptAdapter.switchTo !== "function") {
897
+ throw new Error("Codex account creation is unavailable");
898
+ }
899
+ const account = await this.host.transcriptAdapter.createAccount({ label: String(event.label || "Account") });
900
+ await this.host.transcriptAdapter.switchTo(account.id);
901
+ } else if (event.kind === "codexAccountSetDefault" && event.accountId) {
902
+ if (typeof this.host.transcriptAdapter.setDefault !== "function") throw new Error("Codex account defaults are unavailable");
903
+ await this.host.transcriptAdapter.setDefault(String(event.accountId));
904
+ } else if (event.kind === "codexAccountAdoptAsPrimary" && event.accountId) {
905
+ if (typeof this.host.transcriptAdapter.adoptAsPrimary !== "function") throw new Error("Codex primary adoption is unavailable");
906
+ await this.host.transcriptAdapter.adoptAsPrimary(String(event.accountId));
907
+ } else if (event.kind === "codexAccountDelete" && event.accountId) {
908
+ if (typeof this.host.transcriptAdapter.deleteAccount !== "function") throw new Error("Codex account removal is unavailable");
909
+ await this.host.transcriptAdapter.deleteAccount(String(event.accountId));
888
910
  }
889
911
  }
890
912
  }