@openplan/dsh-fuse 0.1.0 → 0.2.0

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
@@ -15,12 +15,12 @@ O pacote declara `dsh.bundle`, que é o que faz `dsh plugin add` ativar a
15
15
  camada. Sem essa declaração o pnpm instalaria uma biblioteca inerte.
16
16
 
17
17
  ```bash
18
- # npm — a forma canônica de instalação (publicado, v0.1.0)
18
+ # npm — a forma canônica de instalação (publicado)
19
19
  dsh plugin --profile <perfil> add @openplan/dsh-fuse
20
20
 
21
21
  # tarball (sem depender de registry)
22
22
  pnpm pack # gera openplan-dsh-fuse-<versão>.tgz
23
- dsh plugin --profile <perfil> add ./openplan-dsh-fuse-0.1.0.tgz
23
+ dsh plugin --profile <perfil> add ./openplan-dsh-fuse-<versão>.tgz
24
24
 
25
25
  # direto do git (exige allowlist de build do pnpm >= 10 — veja a doc do harness)
26
26
  dsh plugin --profile <perfil> add github:<org>/<repo>#<sha>
@@ -53,7 +53,9 @@ que duplicaria o id e faria o loader falhar com `duplicate loader entry id`):
53
53
  config:
54
54
  project: meu-projeto
55
55
  dev: eu@empresa.com
56
- # Segredo nunca em texto puro no YAML a tag !!js resolve no load.
56
+ # Caminho headless/CI (máquinas sem navegador). O padrão para humanos é
57
+ # `dsh plugin … exec dsh-fuse-connect` (device flow) — ver acima. Segredo
58
+ # nunca em texto puro no YAML — a tag !!js resolve no load.
57
59
  orgKey: !!js process.env.DSH_ORG_KEY
58
60
  baseUrl: https://dsh-api.openplan.cc
59
61
  # Orçamento local (o fuse corta offline antes de gastar):
@@ -99,6 +101,31 @@ ou uma `cascade` que não intersecta `policies.allowedModels` são recusados no
99
101
  boot. Preço **não** é exigido — o plugin resolve genericamente (abaixo); um
100
102
  modelo sem preço é um estado visível (`unpriced`), não um boot falho.
101
103
 
104
+ ## Conectar ao painel (device flow — o caminho padrão)
105
+
106
+ Para humanos atachando uma máquina à sua org, o jeito moderno é o **RFC 8628
107
+ device flow** (o mesmo padrão de `gh auth login`, `wrangler login`, Stripe):
108
+
109
+ ```bash
110
+ dsh plugin --profile <perfil> exec dsh-fuse-connect
111
+ ```
112
+
113
+ O comando imprime uma URL + código, você aprova no navegador (login no painel),
114
+ e o token fica salvo com permissões `0600` em
115
+ `$DSH_HOME/dsh-fuse/credentials.json` — o plugin passa a sincronizar com o
116
+ SaaS **sem colar chave nenhuma**:
117
+
118
+ - o token é **bound ao usuário** que aprovou (auditoria: quem conectou a
119
+ máquina) e **revogável** no painel ("Sessões conectadas") — revogação cai no
120
+ próximo sync (`401` → o plugin volta a local-only e mantém as linhas).
121
+ - escopos mínimos (`usage:write`, `policy:read`) — o que o batch precisa.
122
+ - cabeçalho de autenticação: `Authorization: Bearer <token>` (o `x-org-key`
123
+ continua aceito para máquinas/CI).
124
+
125
+ Se o seu fluxo é headless (CI, container, máquina sem navegador), o caminho
126
+ disponível é a **org key** no config (abaixo) — a API aceita os dois; o device
127
+ flow é o padrão para humanos.
128
+
102
129
  ## Como o preço é resolvido
103
130
 
104
131
  O harness reporta o modelo com o id do **adapter** (normalmente com prefixo de
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh plugin connect` — the RFC 8628 device-flow CLI (ADR-0020 default).
4
+ *
5
+ * Runs in the user's terminal, never on the boot path:
6
+ * 1. calls `POST {base}/v1/device-auth` and prints the verification URI;
7
+ * 2. polls `POST {base}/v1/device-auth/token` until the user approves in
8
+ * the browser (or the code expires/denies);
9
+ * 3. persists the returned device token via `saveCredentials`
10
+ * (0600 file under ~/.dsh/dsh-fuse/, keychain documented as a future
11
+ * backend), so the plugin's next boot syncs with the SaaS.
12
+ *
13
+ * The org key (`orgKey` in config/env) remains the headless/CI path — this
14
+ * command is the interactive default. Standard CLI hygiene: only prints the
15
+ * verifiable URI + code, never the token; supports `--clipboard` to copy the
16
+ * code (like `gh auth login -c`).
17
+ */
18
+ declare function main(argv: string[]): Promise<void>;
19
+ export { main };
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh plugin connect` — the RFC 8628 device-flow CLI (ADR-0020 default).
4
+ *
5
+ * Runs in the user's terminal, never on the boot path:
6
+ * 1. calls `POST {base}/v1/device-auth` and prints the verification URI;
7
+ * 2. polls `POST {base}/v1/device-auth/token` until the user approves in
8
+ * the browser (or the code expires/denies);
9
+ * 3. persists the returned device token via `saveCredentials`
10
+ * (0600 file under ~/.dsh/dsh-fuse/, keychain documented as a future
11
+ * backend), so the plugin's next boot syncs with the SaaS.
12
+ *
13
+ * The org key (`orgKey` in config/env) remains the headless/CI path — this
14
+ * command is the interactive default. Standard CLI hygiene: only prints the
15
+ * verifiable URI + code, never the token; supports `--clipboard` to copy the
16
+ * code (like `gh auth login -c`).
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import { saveCredentials } from "./credentials.js";
20
+ const DEFAULT_BASE_URL = "https://dsh-api.openplan.cc";
21
+ const POLL_INTERVAL_MS = 5_000;
22
+ function usage() {
23
+ console.error([
24
+ "usage: dsh plugin --profile <perfil> exec connect [options]",
25
+ "",
26
+ "options:",
27
+ ` --base-url <url> SaaS API base (default ${DEFAULT_BASE_URL})`,
28
+ " --clipboard copy the device code to the clipboard",
29
+ " --help show this help",
30
+ ].join("\n"));
31
+ process.exit(2);
32
+ }
33
+ function parseArgs(argv) {
34
+ let baseUrl = DEFAULT_BASE_URL;
35
+ let clipboard = false;
36
+ for (let i = 0; i < argv.length; i += 1) {
37
+ const arg = argv[i];
38
+ if (arg === "--base-url") {
39
+ const value = argv[i + 1];
40
+ if (!value || value.startsWith("-"))
41
+ usage();
42
+ baseUrl = value;
43
+ i += 1;
44
+ }
45
+ else if (arg === "--clipboard") {
46
+ clipboard = true;
47
+ }
48
+ else if (arg === "--help" || arg === "-h") {
49
+ usage();
50
+ }
51
+ else {
52
+ usage();
53
+ }
54
+ }
55
+ return { baseUrl, clipboard };
56
+ }
57
+ async function copyToClipboard(text) {
58
+ try {
59
+ const command = process.platform === "darwin"
60
+ ? "pbcopy"
61
+ : process.platform === "win32"
62
+ ? "clip"
63
+ : "xclip";
64
+ const result = spawnSync(command, [], {
65
+ input: text,
66
+ encoding: "utf8",
67
+ });
68
+ return result.status === 0;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ /** Poll the token endpoint following RFC 8628 §3.3 until it yields. */
75
+ async function pollToken(baseUrl, deviceCode) {
76
+ for (;;) {
77
+ const res = await fetch(`${baseUrl}/v1/device-auth/token`, {
78
+ method: "POST",
79
+ headers: { "content-type": "application/json" },
80
+ body: JSON.stringify({ device_code: deviceCode }),
81
+ });
82
+ if (res.status === 200) {
83
+ const body = (await res.json());
84
+ if (!body.access_token) {
85
+ throw new Error("token exchange returned no access_token");
86
+ }
87
+ return { token: body.access_token };
88
+ }
89
+ const body = (await res.json().catch(() => ({})));
90
+ if (body.error === "authorization_pending") {
91
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
92
+ continue;
93
+ }
94
+ if (body.error === "slow_down") {
95
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS + 5_000));
96
+ continue;
97
+ }
98
+ throw new Error(body.error === "expired"
99
+ ? "o código expirou — rode `dsh plugin connect` de novo"
100
+ : body.error === "denied"
101
+ ? "autorização negada"
102
+ : `falha na troca do código (${body.error ?? res.status})`);
103
+ }
104
+ }
105
+ async function main(argv) {
106
+ const { baseUrl, clipboard } = parseArgs(argv);
107
+ const issued = (await (await fetch(`${baseUrl}/v1/device-auth`, { method: "POST" })).json());
108
+ if (!issued.device_code || !issued.user_code) {
109
+ throw new Error("device-auth não retornou códigos — API compatível?");
110
+ }
111
+ process.stdout.write([
112
+ "",
113
+ "┌────────────────────────────────────────────────────────┐",
114
+ "│ Conecte esta máquina ao dsh.openplan.cc │",
115
+ "└────────────────────────────────────────────────────────┘",
116
+ "",
117
+ ` 1. Abra no navegador: ${issued.verification_uri_complete ?? issued.verification_uri}`,
118
+ ` 2. Confirme o código: ${issued.user_code}`,
119
+ "",
120
+ ].join("\n"));
121
+ if (clipboard) {
122
+ const copied = await copyToClipboard(issued.user_code);
123
+ process.stdout.write(copied
124
+ ? " → código copiado para a área de transferência.\n\n"
125
+ : " → (clipboard indisponível — copie manualmente)\n\n");
126
+ }
127
+ // Prove we're alive while the browser approval is pending (the harness
128
+ // turn may be the terminal that runs this — dot feedback is friendlier).
129
+ let dots = 0;
130
+ const pulse = setInterval(() => {
131
+ dots += 1;
132
+ process.stdout.write(` aguardando aprovação${".".repeat(dots)}\r`);
133
+ }, POLL_INTERVAL_MS);
134
+ try {
135
+ const { token } = await pollToken(baseUrl, issued.device_code);
136
+ clearInterval(pulse);
137
+ process.stdout.write("\n ✓ conectado — token salvo com segurança.\n\n");
138
+ const userHint = undefined; // the API returns no name yet
139
+ saveCredentials({
140
+ baseUrl,
141
+ token,
142
+ connectedAt: new Date().toISOString(),
143
+ ...(userHint ? { userHint } : {}),
144
+ });
145
+ }
146
+ finally {
147
+ clearInterval(pulse);
148
+ }
149
+ }
150
+ // Run only when executed directly (`node dist/connect.js` / the bin entry),
151
+ // never when the module is imported by the plugin or tests.
152
+ const isDirectRun = process.argv[1] !== undefined &&
153
+ import.meta.url === new URL(`file://${process.argv[1]}`).href;
154
+ if (isDirectRun) {
155
+ main(process.argv.slice(2)).catch((error) => {
156
+ console.error(`conexão falhou: ${error instanceof Error ? error.message : String(error)}`);
157
+ process.exit(1);
158
+ });
159
+ }
160
+ export { main };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Machine credentials for the SaaS (ADR-0020): the device token minted by
3
+ * `dsh plugin connect`. Stored as a 0600 JSON file under the harness home —
4
+ * the same directory that already owns the ledger (`local.db`) and the same
5
+ * degradation gh uses when no OS keychain exists. An OS-keychain backend
6
+ * (e.g. @napi-rs/keyring) is a documented future option; the plugin's
7
+ * zero-native-build distribution promise is why the file store ships first.
8
+ *
9
+ * Resolution order in the runtime: credentials file → configured
10
+ * `orgKey`/`baseUrl` (env or YAML) → local-only. The file wins because it is
11
+ * the human-attached identity; the config pair stays the headless/CI path.
12
+ */
13
+ /** What the connect command persists after a successful device flow. */
14
+ export interface DeviceCredentials {
15
+ baseUrl: string;
16
+ token: string;
17
+ /** Human-readable hint of who authorized (display only, from the API). */
18
+ userHint?: string;
19
+ connectedAt: string;
20
+ }
21
+ /** Resolve the harness home the same way the store does (DSH_HOME → ~/.dsh). */
22
+ export declare function dshHome(): string;
23
+ /** The credentials file the plugin and the connect CLI both use. */
24
+ export declare function credentialsFile(): string;
25
+ /** Read the machine credentials; `null` when absent or unreadable. */
26
+ export declare function loadCredentials(): DeviceCredentials | null;
27
+ /**
28
+ * Persist machine credentials atomically (temp + rename) with 0600 perms —
29
+ * the write path for `dsh plugin connect`.
30
+ */
31
+ export declare function saveCredentials(input: DeviceCredentials): void;
32
+ /** Clear the machine credentials — the `disconnect` command / 401 path. */
33
+ export declare function clearCredentials(): void;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Machine credentials for the SaaS (ADR-0020): the device token minted by
3
+ * `dsh plugin connect`. Stored as a 0600 JSON file under the harness home —
4
+ * the same directory that already owns the ledger (`local.db`) and the same
5
+ * degradation gh uses when no OS keychain exists. An OS-keychain backend
6
+ * (e.g. @napi-rs/keyring) is a documented future option; the plugin's
7
+ * zero-native-build distribution promise is why the file store ships first.
8
+ *
9
+ * Resolution order in the runtime: credentials file → configured
10
+ * `orgKey`/`baseUrl` (env or YAML) → local-only. The file wins because it is
11
+ * the human-attached identity; the config pair stays the headless/CI path.
12
+ */
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ /** Resolve the harness home the same way the store does (DSH_HOME → ~/.dsh). */
17
+ export function dshHome() {
18
+ const override = process.env.DSH_HOME?.trim();
19
+ return override ? override : homedir();
20
+ }
21
+ /** The credentials file the plugin and the connect CLI both use. */
22
+ export function credentialsFile() {
23
+ return join(dshHome(), ".dsh", "dsh-fuse", "credentials.json");
24
+ }
25
+ /** Read the machine credentials; `null` when absent or unreadable. */
26
+ export function loadCredentials() {
27
+ const file = credentialsFile();
28
+ if (!existsSync(file))
29
+ return null;
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
32
+ if (typeof parsed !== "object" || parsed === null)
33
+ return null;
34
+ const { baseUrl, token } = parsed;
35
+ if (typeof baseUrl !== "string" || !baseUrl)
36
+ return null;
37
+ if (typeof token !== "string" || !token)
38
+ return null;
39
+ const connectedAt = typeof parsed.connectedAt === "string"
40
+ ? parsed.connectedAt
41
+ : new Date().toISOString();
42
+ const userHint = typeof parsed.userHint === "string"
43
+ ? parsed.userHint
44
+ : undefined;
45
+ return { baseUrl, token, connectedAt, ...(userHint ? { userHint } : {}) };
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Persist machine credentials atomically (temp + rename) with 0600 perms —
53
+ * the write path for `dsh plugin connect`.
54
+ */
55
+ export function saveCredentials(input) {
56
+ const file = credentialsFile();
57
+ mkdirSync(dirname(file), { recursive: true });
58
+ const tmp = `${file}.tmp`;
59
+ writeFileSync(tmp, `${JSON.stringify(input, null, 2)}\n`, {
60
+ mode: 0o600,
61
+ });
62
+ chmodSync(tmp, 0o600);
63
+ renameSync(tmp, file);
64
+ // renameSync keeps the temp's mode, but re-assert for filesystems that
65
+ // lose it on move.
66
+ chmodSync(file, 0o600);
67
+ }
68
+ /** Clear the machine credentials — the `disconnect` command / 401 path. */
69
+ export function clearCredentials() {
70
+ try {
71
+ const file = credentialsFile();
72
+ if (existsSync(file))
73
+ renameSync(file, `${file}.revoked`);
74
+ }
75
+ catch {
76
+ // Nothing to do if the file (or dir) is gone.
77
+ }
78
+ }
package/dist/harness.js CHANGED
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { createBudgetStatusTool, } from "./budget-tool.js";
35
35
  import { assertUsableConfig } from "./config.js";
36
+ import { clearCredentials, loadCredentials } from "./credentials.js";
36
37
  import { fuseDecision } from "./fuse.js";
37
38
  import { hashSessionId, projectCall } from "./meter.js";
38
39
  import { createPricingCache, estimateCostUsd, fetchPricingTable, } from "./pricing.js";
@@ -98,6 +99,65 @@ export function apply(ctx, config) {
98
99
  const logger = ctx.logger;
99
100
  const tokenMeter = () => ctx.get?.("tokenMeter") ?? undefined;
100
101
  const llm = () => ctx.get?.("llm") ?? undefined;
102
+ /**
103
+ * In-session cut notices: when the fuse blocks a step, the harness ends the
104
+ * turn as `{ kind: "blocked" }` with no visible explanation — the user is
105
+ * told nothing. The harness's convention for "something just happened" is a
106
+ * plugin-producer `notice`-form user message on the SESSION LOG (the same
107
+ * `session.append("user/message", …, { surfaceOp: "append" })` the agent
108
+ * loop itself uses at every message boundary), which the client
109
+ * conversation renders as a collapsed one-line row expandable to the full
110
+ * text. One notice per (rule, window) per process: a budget that stays
111
+ * tripped must not restate itself on every blocked step, and a new window
112
+ * that re-trips it should announce that again.
113
+ *
114
+ * NOT `agent.inject`: that writes into the model's inbox (context for the
115
+ * NEXT step — invisible to the GUI), it never touches the session log. A
116
+ * turn that ends blocked has no next step, so an injected notice would sit
117
+ * in the inbox forever, never rendered. This bug is why the 0.1.1 cut
118
+ * alerts were silent in the panel.
119
+ */
120
+ const cutNotices = new Set();
121
+ /** Set when a bearer 401 revokes the device token mid-sync; the next step
122
+ * announces it to the session once (the timer has no agent to address). */
123
+ let revocationNoticePending = false;
124
+ /**
125
+ * Append a plugin-producer `notice`-form user message to the session log —
126
+ * the GUI renders it as a collapsed row. Shared by the fuse cut notice and
127
+ * the revocation announcement.
128
+ */
129
+ function appendSessionNotice(agent, input) {
130
+ try {
131
+ agent.session.append("user/message", {
132
+ id: crypto.randomUUID(),
133
+ role: "user",
134
+ content: [{ type: "text", text: input.detail }],
135
+ source: {
136
+ kind: "plugin",
137
+ plugin: "@openplan/dsh-fuse",
138
+ form: "notice",
139
+ summary: input.summary,
140
+ },
141
+ }, { surfaceOp: "append" });
142
+ }
143
+ catch (error) {
144
+ // A notice must never break the reject/sync path — the underlying
145
+ // event (cut or revocation) already landed; the UI alert is
146
+ // best-effort.
147
+ logger.warn("[dsh] session notice not delivered", {
148
+ summary: input.summary,
149
+ error: String(error),
150
+ });
151
+ }
152
+ }
153
+ function notifyCut(agent, input) {
154
+ const window = windowStart(new Date(), "day");
155
+ const key = `${input.rule}\u0000${window}`;
156
+ if (cutNotices.has(key))
157
+ return;
158
+ cutNotices.add(key);
159
+ appendSessionNotice(agent, input);
160
+ }
101
161
  /**
102
162
  * Documented load-time rule: *"A plugin should also reject schema-valid
103
163
  * config that names an unavailable resource or provider as soon as it can
@@ -151,7 +211,11 @@ export function apply(ctx, config) {
151
211
  gatewayProvider: config.pricingGatewayProvider || undefined,
152
212
  gatewayApiKeyEnv: config.pricingGatewayApiKeyEnv || undefined,
153
213
  override: config.pricingTable,
154
- }), 3_600_000, (table) => void store.setPricingTable(table));
214
+ }), 3_600_000,
215
+ // A refresh can resolve AFTER unload (slow network, test teardown):
216
+ // persisting a table into a closed store must be a swallowed no-op,
217
+ // never an unhandled rejection.
218
+ (table) => void store.setPricingTable(table).catch(() => undefined));
155
219
  void store.pricingTable().then((persisted) => {
156
220
  if (persisted)
157
221
  pricingCache.hydrate(persisted);
@@ -512,6 +576,15 @@ export function apply(ctx, config) {
512
576
  }
513
577
  // ── Fuse: the primary gate, before any token is spent ──────────────────
514
578
  ctx.on("agent/pre-step", async (payload, next) => {
579
+ // One-time revocation announcement: a device-token 401 during sync
580
+ // disconnected the SaaS; tell the user on the next step they run.
581
+ if (revocationNoticePending) {
582
+ revocationNoticePending = false;
583
+ appendSessionNotice(payload.agent, {
584
+ summary: "fuse: conexão com o painel revogada",
585
+ detail: "O token do dispositivo foi revogado no painel — este profile voltou a local-only (nada sincroniza, o fuse continua ativo). Reconecte com `dsh plugin --profile <perfil> exec dsh-fuse-connect` quando quiser. As linhas não sincronizadas ficaram retidas.",
586
+ });
587
+ }
515
588
  const agentId = payload.agent.id;
516
589
  const header = headers.get(agentId);
517
590
  const model = header?.model ||
@@ -532,6 +605,11 @@ export function apply(ctx, config) {
532
605
  resetAt: remoteBlock.resetAt,
533
606
  project,
534
607
  });
608
+ notifyCut(payload.agent, {
609
+ rule: remoteBlock.rule,
610
+ summary: "fuse: chamadas bloqueadas (orçamento do painel)",
611
+ detail: `O painel central cortou as chamadas deste escopo (regra: ${remoteBlock.rule}). O bloqueio vale até ${remoteBlock.resetAt} — o fuse local segue ativo e nenhum token é gasto enquanto isso.`,
612
+ });
535
613
  return { kind: "reject" };
536
614
  }
537
615
  const estimatedCostUsd = estimateStepCostUsd(payload.agent, payload.messages, model, provider);
@@ -565,6 +643,11 @@ export function apply(ctx, config) {
565
643
  rule: decision.rule,
566
644
  project,
567
645
  });
646
+ notifyCut(payload.agent, {
647
+ rule: decision.rule ?? "unknown",
648
+ summary: "fuse: chamada cortada — orçamento atingido",
649
+ detail: `O fuse bloqueou a chamada antes de gastar tokens (regra: ${decision.rule ?? "unknown"}). Ajuste o budget em cordis.patch.yml ou use a ferramenta dsh_budget_status para ver o status; o limite reseta no fim da janela.`,
650
+ });
568
651
  return { kind: "reject" };
569
652
  }
570
653
  return next();
@@ -643,13 +726,30 @@ export function apply(ctx, config) {
643
726
  return current;
644
727
  });
645
728
  // ── SaaS sync + policy pull, released by ctx.effect on unload ──────────
729
+ // Target resolution (ADR-0020): the device token from `dsh plugin connect`
730
+ // (credentials file) wins — it is the human-attached identity; the
731
+ // configured orgKey/baseUrl pair stays the headless/CI path. Local-only
732
+ // mode (neither) still owns the store.
733
+ const resolvedTarget = (() => {
734
+ const connected = loadCredentials();
735
+ if (connected) {
736
+ return {
737
+ baseUrl: connected.baseUrl,
738
+ auth: { kind: "bearer", token: connected.token },
739
+ };
740
+ }
741
+ if (config.baseUrl && config.orgKey) {
742
+ return {
743
+ baseUrl: config.baseUrl,
744
+ auth: { kind: "key", orgKey: config.orgKey },
745
+ };
746
+ }
747
+ return null;
748
+ })();
646
749
  ctx.effect(() => {
647
- if (!config.baseUrl || !config.orgKey)
750
+ if (!resolvedTarget)
648
751
  return () => undefined;
649
- const target = {
650
- baseUrl: config.baseUrl,
651
- orgKey: config.orgKey,
652
- };
752
+ const target = resolvedTarget;
653
753
  let syncing = false;
654
754
  let refreshing = false;
655
755
  let lastPolicyAt = 0;
@@ -709,6 +809,19 @@ export function apply(ctx, config) {
709
809
  }
710
810
  if (!result.delivered) {
711
811
  // Keep the rows: a failed batch is not a delivered batch.
812
+ // A 401 on the BEARER path means the device token was
813
+ // revoked at the SaaS — drop the credentials and stop
814
+ // advertising a connected machine (the fuse keeps running
815
+ // local-only; rows are retained for a future reconnect).
816
+ if (result.status === 401 && target.auth.kind === "bearer") {
817
+ logger.warn("[dsh] device token revoked — disconnecting local sync (rows retained)", { status: result.status });
818
+ clearCredentials();
819
+ // The sync timer has no agent to address; surface the
820
+ // revocation on the NEXT step the user runs, once (the
821
+ // visible counterpart to the fuse cut notice).
822
+ revocationNoticePending = true;
823
+ return;
824
+ }
712
825
  logger.warn("[dsh] sync failed — rows retained for retry", {
713
826
  status: result.status,
714
827
  error: result.error,
@@ -764,7 +877,7 @@ export function apply(ctx, config) {
764
877
  };
765
878
  });
766
879
  // Local-only mode still owns the store; release it on unload.
767
- if (!config.baseUrl || !config.orgKey) {
880
+ if (!resolvedTarget) {
768
881
  ctx.effect(() => async () => {
769
882
  await flushMeter();
770
883
  store.close();
package/dist/index.d.ts CHANGED
@@ -31,7 +31,7 @@ export { type CallProjection, hashSessionId, type MessageProvenance, projectCall
31
31
  export { createPricingCache, DEFAULT_REGISTRY_URL, estimateCostUsd, fetchPricingTable, normalizeModelId, type PricingAliases, type PricingEntry, type PricingResolution, type PricingTable, type PricingVia, parseGatewayModelEntry, parseGatewayModels, parsePricingRegistry, pricingCandidates, resolvePricingEntry, type TokenCounts, } from "./pricing.js";
32
32
  export { type RouteDecision, type RouteReason, type RouterPolicies, routeDecision, } from "./router.js";
33
33
  export { createLocalStore, type LocalStore, type RemotePolicy, type StoredUsage, type UsageRecord, } from "./store.js";
34
- export { fetchPolicy, type OrgKeyTarget, parseRemotePolicy, type SyncResult, syncBatch, } from "./sync.js";
34
+ export { fetchPolicy, parseRemotePolicy, type SyncResult, type SyncTarget, syncBatch, } from "./sync.js";
35
35
  export type { BatchEvent, CutEvent, UsageEvent } from "./wire.js";
36
36
  /**
37
37
  * The Cordis entry.
package/dist/sync.d.ts CHANGED
@@ -36,13 +36,21 @@ export interface SyncResult {
36
36
  /** Transport/HTTP failure detail for the log line, when any. */
37
37
  error?: string;
38
38
  }
39
- /** Shared request shape for the two key-authed endpoints. */
40
- export interface OrgKeyTarget {
41
- baseUrl: string;
39
+ /** Who the machine claims to be on the wire (ADR-0020). */
40
+ export type MachineAuth = {
41
+ kind: "key";
42
42
  orgKey: string;
43
+ } | {
44
+ kind: "bearer";
45
+ token: string;
46
+ };
47
+ /** Shared request shape for the two machine-authed endpoints. */
48
+ export interface SyncTarget {
49
+ baseUrl: string;
50
+ auth: MachineAuth;
43
51
  fetchImpl?: typeof fetch;
44
52
  }
45
- export declare function syncBatch(input: OrgKeyTarget & {
53
+ export declare function syncBatch(input: SyncTarget & {
46
54
  events: BatchEvent[];
47
55
  }): Promise<SyncResult>;
48
56
  /**
@@ -56,7 +64,7 @@ export declare function syncBatch(input: OrgKeyTarget & {
56
64
  * failed — the caller keeps the last cached policy either way (offline-first:
57
65
  * enforcement never depends on the network being up at boot).
58
66
  */
59
- export declare function fetchPolicy(input: OrgKeyTarget): Promise<{
67
+ export declare function fetchPolicy(input: SyncTarget): Promise<{
60
68
  policy: RemotePolicy | null;
61
69
  error?: string;
62
70
  }>;
package/dist/sync.js CHANGED
@@ -11,8 +11,10 @@
11
11
  * states explicitly whether the events reached the SaaS, and the caller only
12
12
  * advances the watermark when it did.
13
13
  */
14
- function keyHeaders(orgKey) {
15
- return { "x-org-key": orgKey };
14
+ function authHeaders(auth) {
15
+ if (auth.kind === "key")
16
+ return { "x-org-key": auth.orgKey };
17
+ return { authorization: `Bearer ${auth.token}` };
16
18
  }
17
19
  export async function syncBatch(input) {
18
20
  const fetchImpl = input.fetchImpl ?? fetch;
@@ -22,7 +24,7 @@ export async function syncBatch(input) {
22
24
  method: "POST",
23
25
  headers: {
24
26
  "content-type": "application/json",
25
- ...keyHeaders(input.orgKey),
27
+ ...authHeaders(input.auth),
26
28
  },
27
29
  body: JSON.stringify({ events: input.events }),
28
30
  });
@@ -86,7 +88,7 @@ export async function fetchPolicy(input) {
86
88
  try {
87
89
  const res = await fetchImpl(`${input.baseUrl}/v1/policy`, {
88
90
  method: "GET",
89
- headers: keyHeaders(input.orgKey),
91
+ headers: authHeaders(input.auth),
90
92
  });
91
93
  if (res.status === 401)
92
94
  return { policy: null, error: "unauthorized" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openplan/dsh-fuse",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Cost policy for the DeepSeek Harness: per-call metering plus a local fuse that enforces budget, model and reasoning-effort limits before any token is spent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,5 +60,8 @@
60
60
  "@types/node": "^22.0.0",
61
61
  "typescript": "^5.9.0",
62
62
  "vitest": "^3.1.0"
63
+ },
64
+ "bin": {
65
+ "dsh-fuse-connect": "./dist/connect.js"
63
66
  }
64
67
  }