@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 +30 -3
- package/dist/connect.d.ts +19 -0
- package/dist/connect.js +160 -0
- package/dist/credentials.d.ts +33 -0
- package/dist/credentials.js +78 -0
- package/dist/harness.js +120 -7
- package/dist/index.d.ts +1 -1
- package/dist/sync.d.ts +13 -5
- package/dist/sync.js +6 -4
- package/package.json +4 -1
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
|
|
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
|
|
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
|
-
#
|
|
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 };
|
package/dist/connect.js
ADDED
|
@@ -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,
|
|
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 (!
|
|
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 (!
|
|
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,
|
|
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
|
-
/**
|
|
40
|
-
export
|
|
41
|
-
|
|
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:
|
|
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:
|
|
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
|
|
15
|
-
|
|
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
|
-
...
|
|
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:
|
|
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.
|
|
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
|
}
|