@openplan/dsh-fuse 0.1.1 → 0.2.1
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 +28 -1
- package/dist/connect.d.ts +19 -0
- package/dist/connect.js +176 -0
- package/dist/credentials.d.ts +33 -0
- package/dist/credentials.js +78 -0
- package/dist/harness.js +89 -25
- package/dist/index.d.ts +1 -1
- package/dist/sync.d.ts +13 -5
- package/dist/sync.js +6 -4
- package/package.json +65 -62
package/README.md
CHANGED
|
@@ -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,176 @@
|
|
|
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 { realpathSync } from "node:fs";
|
|
20
|
+
import { saveCredentials } from "./credentials.js";
|
|
21
|
+
const DEFAULT_BASE_URL = "https://dsh-api.openplan.cc";
|
|
22
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
23
|
+
function usage() {
|
|
24
|
+
console.error([
|
|
25
|
+
"usage: dsh plugin --profile <perfil> exec connect [options]",
|
|
26
|
+
"",
|
|
27
|
+
"options:",
|
|
28
|
+
` --base-url <url> SaaS API base (default ${DEFAULT_BASE_URL})`,
|
|
29
|
+
" --clipboard copy the device code to the clipboard",
|
|
30
|
+
" --help show this help",
|
|
31
|
+
].join("\n"));
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
let baseUrl = DEFAULT_BASE_URL;
|
|
36
|
+
let clipboard = false;
|
|
37
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
38
|
+
const arg = argv[i];
|
|
39
|
+
if (arg === "--base-url") {
|
|
40
|
+
const value = argv[i + 1];
|
|
41
|
+
if (!value || value.startsWith("-"))
|
|
42
|
+
usage();
|
|
43
|
+
baseUrl = value;
|
|
44
|
+
i += 1;
|
|
45
|
+
}
|
|
46
|
+
else if (arg === "--clipboard") {
|
|
47
|
+
clipboard = true;
|
|
48
|
+
}
|
|
49
|
+
else if (arg === "--help" || arg === "-h") {
|
|
50
|
+
usage();
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
usage();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { baseUrl, clipboard };
|
|
57
|
+
}
|
|
58
|
+
async function copyToClipboard(text) {
|
|
59
|
+
try {
|
|
60
|
+
const command = process.platform === "darwin"
|
|
61
|
+
? "pbcopy"
|
|
62
|
+
: process.platform === "win32"
|
|
63
|
+
? "clip"
|
|
64
|
+
: "xclip";
|
|
65
|
+
const result = spawnSync(command, [], {
|
|
66
|
+
input: text,
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
});
|
|
69
|
+
return result.status === 0;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Poll the token endpoint following RFC 8628 §3.3 until it yields. */
|
|
76
|
+
async function pollToken(baseUrl, deviceCode) {
|
|
77
|
+
for (;;) {
|
|
78
|
+
const res = await fetch(`${baseUrl}/v1/device-auth/token`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json" },
|
|
81
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
82
|
+
});
|
|
83
|
+
if (res.status === 200) {
|
|
84
|
+
const body = (await res.json());
|
|
85
|
+
if (!body.access_token) {
|
|
86
|
+
throw new Error("token exchange returned no access_token");
|
|
87
|
+
}
|
|
88
|
+
return { token: body.access_token };
|
|
89
|
+
}
|
|
90
|
+
const body = (await res.json().catch(() => ({})));
|
|
91
|
+
if (body.error === "authorization_pending") {
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (body.error === "slow_down") {
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS + 5_000));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(body.error === "expired"
|
|
100
|
+
? "o código expirou — rode `dsh plugin connect` de novo"
|
|
101
|
+
: body.error === "denied"
|
|
102
|
+
? "autorização negada"
|
|
103
|
+
: `falha na troca do código (${body.error ?? res.status})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function main(argv) {
|
|
107
|
+
const { baseUrl, clipboard } = parseArgs(argv);
|
|
108
|
+
const issued = (await (await fetch(`${baseUrl}/v1/device-auth`, { method: "POST" })).json());
|
|
109
|
+
if (!issued.device_code || !issued.user_code) {
|
|
110
|
+
throw new Error("device-auth não retornou códigos — API compatível?");
|
|
111
|
+
}
|
|
112
|
+
process.stdout.write([
|
|
113
|
+
"",
|
|
114
|
+
"┌────────────────────────────────────────────────────────┐",
|
|
115
|
+
"│ Conecte esta máquina ao dsh.openplan.cc │",
|
|
116
|
+
"└────────────────────────────────────────────────────────┘",
|
|
117
|
+
"",
|
|
118
|
+
` 1. Abra no navegador: ${issued.verification_uri_complete ?? issued.verification_uri}`,
|
|
119
|
+
` 2. Confirme o código: ${issued.user_code}`,
|
|
120
|
+
"",
|
|
121
|
+
].join("\n"));
|
|
122
|
+
if (clipboard) {
|
|
123
|
+
const copied = await copyToClipboard(issued.user_code);
|
|
124
|
+
process.stdout.write(copied
|
|
125
|
+
? " → código copiado para a área de transferência.\n\n"
|
|
126
|
+
: " → (clipboard indisponível — copie manualmente)\n\n");
|
|
127
|
+
}
|
|
128
|
+
// Prove we're alive while the browser approval is pending (the harness
|
|
129
|
+
// turn may be the terminal that runs this — dot feedback is friendlier).
|
|
130
|
+
let dots = 0;
|
|
131
|
+
const pulse = setInterval(() => {
|
|
132
|
+
dots += 1;
|
|
133
|
+
process.stdout.write(` aguardando aprovação${".".repeat(dots)}\r`);
|
|
134
|
+
}, POLL_INTERVAL_MS);
|
|
135
|
+
try {
|
|
136
|
+
const { token } = await pollToken(baseUrl, issued.device_code);
|
|
137
|
+
clearInterval(pulse);
|
|
138
|
+
process.stdout.write("\n ✓ conectado — token salvo com segurança.\n\n");
|
|
139
|
+
const userHint = undefined; // the API returns no name yet
|
|
140
|
+
saveCredentials({
|
|
141
|
+
baseUrl,
|
|
142
|
+
token,
|
|
143
|
+
connectedAt: new Date().toISOString(),
|
|
144
|
+
...(userHint ? { userHint } : {}),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
clearInterval(pulse);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Run only when executed directly (`node dist/connect.js` / the bin entry),
|
|
152
|
+
// never when the module is imported by the plugin or tests.
|
|
153
|
+
//
|
|
154
|
+
// npm installs bins as SYMLINKS into node_modules/.bin/: node's argv[1] is
|
|
155
|
+
// then the symlink path while import.meta.url is the resolved real file, so
|
|
156
|
+
// a naive string compare would silently skip main() — the connect command
|
|
157
|
+
// would exit 0 and do nothing. Reconcile both sides through realpath.
|
|
158
|
+
const isDirectRun = (() => {
|
|
159
|
+
if (process.argv[1] === undefined)
|
|
160
|
+
return false;
|
|
161
|
+
try {
|
|
162
|
+
return (import.meta.url === new URL(`file://${process.argv[1]}`).href ||
|
|
163
|
+
import.meta.url ===
|
|
164
|
+
new URL(`file://${realpathSync(process.argv[1])}`).href);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
})();
|
|
170
|
+
if (isDirectRun) {
|
|
171
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
172
|
+
console.error(`conexão falhou: ${error instanceof Error ? error.message : String(error)}`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
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";
|
|
@@ -101,22 +102,33 @@ export function apply(ctx, config) {
|
|
|
101
102
|
/**
|
|
102
103
|
* In-session cut notices: when the fuse blocks a step, the harness ends the
|
|
103
104
|
* turn as `{ kind: "blocked" }` with no visible explanation — the user is
|
|
104
|
-
* told nothing. The harness's
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* window
|
|
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.
|
|
110
119
|
*/
|
|
111
120
|
const cutNotices = new Set();
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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) {
|
|
118
130
|
try {
|
|
119
|
-
agent.
|
|
131
|
+
agent.session.append("user/message", {
|
|
120
132
|
id: crypto.randomUUID(),
|
|
121
133
|
role: "user",
|
|
122
134
|
content: [{ type: "text", text: input.detail }],
|
|
@@ -126,17 +138,26 @@ export function apply(ctx, config) {
|
|
|
126
138
|
form: "notice",
|
|
127
139
|
summary: input.summary,
|
|
128
140
|
},
|
|
129
|
-
});
|
|
141
|
+
}, { surfaceOp: "append" });
|
|
130
142
|
}
|
|
131
143
|
catch (error) {
|
|
132
|
-
// A notice must never break the reject path — the
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
136
149
|
error: String(error),
|
|
137
150
|
});
|
|
138
151
|
}
|
|
139
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
|
+
}
|
|
140
161
|
/**
|
|
141
162
|
* Documented load-time rule: *"A plugin should also reject schema-valid
|
|
142
163
|
* config that names an unavailable resource or provider as soon as it can
|
|
@@ -190,7 +211,11 @@ export function apply(ctx, config) {
|
|
|
190
211
|
gatewayProvider: config.pricingGatewayProvider || undefined,
|
|
191
212
|
gatewayApiKeyEnv: config.pricingGatewayApiKeyEnv || undefined,
|
|
192
213
|
override: config.pricingTable,
|
|
193
|
-
}), 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));
|
|
194
219
|
void store.pricingTable().then((persisted) => {
|
|
195
220
|
if (persisted)
|
|
196
221
|
pricingCache.hydrate(persisted);
|
|
@@ -551,6 +576,15 @@ export function apply(ctx, config) {
|
|
|
551
576
|
}
|
|
552
577
|
// ── Fuse: the primary gate, before any token is spent ──────────────────
|
|
553
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
|
+
}
|
|
554
588
|
const agentId = payload.agent.id;
|
|
555
589
|
const header = headers.get(agentId);
|
|
556
590
|
const model = header?.model ||
|
|
@@ -692,13 +726,30 @@ export function apply(ctx, config) {
|
|
|
692
726
|
return current;
|
|
693
727
|
});
|
|
694
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
|
+
})();
|
|
695
749
|
ctx.effect(() => {
|
|
696
|
-
if (!
|
|
750
|
+
if (!resolvedTarget)
|
|
697
751
|
return () => undefined;
|
|
698
|
-
const target =
|
|
699
|
-
baseUrl: config.baseUrl,
|
|
700
|
-
orgKey: config.orgKey,
|
|
701
|
-
};
|
|
752
|
+
const target = resolvedTarget;
|
|
702
753
|
let syncing = false;
|
|
703
754
|
let refreshing = false;
|
|
704
755
|
let lastPolicyAt = 0;
|
|
@@ -758,6 +809,19 @@ export function apply(ctx, config) {
|
|
|
758
809
|
}
|
|
759
810
|
if (!result.delivered) {
|
|
760
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
|
+
}
|
|
761
825
|
logger.warn("[dsh] sync failed — rows retained for retry", {
|
|
762
826
|
status: result.status,
|
|
763
827
|
error: result.error,
|
|
@@ -813,7 +877,7 @@ export function apply(ctx, config) {
|
|
|
813
877
|
};
|
|
814
878
|
});
|
|
815
879
|
// Local-only mode still owns the store; release it on unload.
|
|
816
|
-
if (!
|
|
880
|
+
if (!resolvedTarget) {
|
|
817
881
|
ctx.effect(() => async () => {
|
|
818
882
|
await flushMeter();
|
|
819
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,64 +1,67 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
2
|
+
"name": "@openplan/dsh-fuse",
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"private": false,
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"cordis.patch.yml",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"dsh",
|
|
29
|
+
"dsh-plugin",
|
|
30
|
+
"llm",
|
|
31
|
+
"budget",
|
|
32
|
+
"cost",
|
|
33
|
+
"enforcement",
|
|
34
|
+
"cordis"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"prepublishOnly": "pnpm build"
|
|
41
|
+
},
|
|
42
|
+
"dsh": {
|
|
43
|
+
"bundle": {
|
|
44
|
+
"patch": "./cordis.patch.yml"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@deepseek-ai/schemastery": "3.18.2",
|
|
49
|
+
"@libsql/client": "^0.17.4"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@deepseek-ai/cordis": "4.0.1"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
56
|
+
"@deepseek-ai/dsh-agent": "0.1.1-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-llm": "0.1.1-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-session": "0.1.1-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-tools": "0.1.1-rc.2",
|
|
60
|
+
"@types/node": "^22.0.0",
|
|
61
|
+
"typescript": "^5.9.0",
|
|
62
|
+
"vitest": "^3.1.0"
|
|
63
|
+
},
|
|
64
|
+
"bin": {
|
|
65
|
+
"dsh-fuse-connect": "./dist/connect.js"
|
|
66
|
+
}
|
|
64
67
|
}
|