mixdog 1.3.0 → 1.3.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/NOTICE.md +52 -9
- package/package.json +1 -1
- package/src/defaults/shipped-history.json +4 -1
- package/src/defaults/skills/setup/references/actions.md +4 -1
- package/src/defaults/skills/setup-guide/SKILL.md +10 -7
- package/src/output-styles/common.md +13 -8
- package/src/rules/agent-mode/AGENT_MODE.md +1 -1
- package/src/rules/agent-mode/MIXY.md +11 -4
- package/src/runtime/agent/orchestrator/context/role-instructions.mjs +72 -14
- package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +7 -2
- package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +7 -8
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/lib/tool-call-wire-ids.mjs +65 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +8 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +9 -2
- package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +14 -2
- package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +17 -0
- package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +4 -2
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +14 -4
- package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +37 -7
- package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +5 -3
- package/src/runtime/agent/orchestrator/session/agent-room.d.mts +4 -0
- package/src/runtime/agent/orchestrator/session/agent-room.mjs +17 -8
- package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +32 -5
- package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +108 -6
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +8 -14
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +46 -4
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +18 -8
- package/src/runtime/agent/orchestrator/session/manager/provider-replay.mjs +50 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -20
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/mixy-name.mjs +5 -2
- package/src/runtime/agent/orchestrator/session/routine-result.d.mts +17 -1
- package/src/runtime/agent/orchestrator/session/routine-result.mjs +61 -9
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +14 -9
- package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +92 -15
- package/src/runtime/attachments/store.mjs +6 -0
- package/src/runtime/effort-judge/judge-client.mjs +1 -1
- package/src/runtime/effort-judge/model-manifest.json +7 -7
- package/src/runtime/shared/agent-kind.mjs +152 -0
- package/src/runtime/shared/agent-route-config.d.mts +1 -0
- package/src/runtime/shared/agent-route-config.mjs +22 -8
- package/src/runtime/shared/agent-store.mjs +132 -0
- package/src/runtime/shared/bot-split-migration.mjs +457 -0
- package/src/runtime/shared/config-patch.mjs +20 -1
- package/src/runtime/shared/connector-catalog.d.mts +1 -2
- package/src/runtime/shared/connector-catalog.mjs +2 -4
- package/src/runtime/shared/connector-triggers.mjs +2 -0
- package/src/runtime/shared/err-text.mjs +3 -1
- package/src/runtime/shared/error-presentation.mjs +91 -6
- package/src/runtime/shared/handoff-artifacts.d.mts +20 -0
- package/src/runtime/shared/handoff-artifacts.mjs +61 -0
- package/src/runtime/shared/llm/provider-model-identities.mjs +6 -1
- package/src/runtime/shared/llm/usage-accounting.mjs +7 -1
- package/src/runtime/shared/model-route-fallback.mjs +79 -7
- package/src/runtime/shared/plain-preview.mjs +22 -17
- package/src/runtime/shared/sensitive-path-rules.d.mts +15 -0
- package/src/runtime/shared/sensitive-path-rules.mjs +256 -0
- package/src/runtime/shared/usage-readout-totals.mjs +15 -13
- package/src/runtime/shared/user-data-guard.mjs +5 -2
- package/src/session-runtime/agent-home-delivery.mjs +423 -56
- package/src/session-runtime/agent-room-delivery.mjs +45 -4
- package/src/session-runtime/agent-task-sessions.mjs +299 -39
- package/src/session-runtime/boot/apis.mjs +13 -0
- package/src/session-runtime/boot/providers.mjs +14 -4
- package/src/session-runtime/config-lifecycle.mjs +43 -2
- package/src/session-runtime/handoff-attachments.mjs +593 -0
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +27 -3
- package/src/session-runtime/model-route/route-persist.mjs +2 -2
- package/src/session-runtime/model-route/route-tuning.mjs +7 -1
- package/src/session-runtime/model-route/set-route.mjs +21 -1
- package/src/session-runtime/native-web-search.mjs +2 -2
- package/src/session-runtime/new-session-config.mjs +6 -1
- package/src/session-runtime/route-resolution.mjs +5 -11
- package/src/session-runtime/services/agent-tool/helpers.mjs +24 -5
- package/src/session-runtime/services/agent-tool/send-flow.mjs +11 -3
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +4 -1
- package/src/session-runtime/services/agent-tool/spawn-preset.mjs +5 -8
- package/src/session-runtime/services/agent-tool/tool-def.mjs +7 -0
- package/src/session-runtime/setup-tool/executor.mjs +12 -1
- package/src/session-runtime/setup-tool/extended-actions.mjs +19 -7
- package/src/session-runtime/setup-tool/settings-contract.mjs +6 -0
- package/src/session-runtime/tool-policy-refresh.mjs +13 -2
- package/src/session-runtime/turn/turn-run/turn-prepare.mjs +11 -0
- package/src/session-runtime/workflow-agents-api/agent-editor.mjs +123 -32
- package/src/session-runtime/workflow-agents-api/agent-route.mjs +67 -7
- package/src/session-runtime/workflow-agents-api/onboarding.mjs +11 -4
- package/src/standalone/daemon-session-control.mjs +13 -5
- package/src/standalone/daemon.mjs +16 -4
- package/src/standalone/session-service/agent-tree/agent-turns.mjs +46 -3
- package/src/standalone/session-service/agent-tree.mjs +4 -3
- package/src/standalone/session-service/session-calls/session-turns.mjs +19 -0
- package/src/tui/dist/index.mjs +70 -9
- package/src/tui/session/notice-surface.mjs +3 -4
- package/src/tui/session/session-api/routes.mjs +14 -3
- package/src/tui/session/transcript-intake.mjs +2 -0
- package/src/tui/session/turn-live-call.mjs +11 -3
- package/src/tui/session/turn.mjs +13 -3
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The files a bot's or agent's turn handed back (session-runtime/
|
|
2
|
+
// handoff-attachments.mjs collectTurnArtifacts) as the rows that carry them
|
|
3
|
+
// show them: one model-facing line per file — the attachment id that forwards
|
|
4
|
+
// it, or its path when it is too large or of a type no prompt carries — and
|
|
5
|
+
// the message attachment records of the stored ones. Only those records
|
|
6
|
+
// authorize forwarding by id; the lines are information for the model.
|
|
7
|
+
import { escapeHarnessTags } from './shell-output-summary.mjs';
|
|
8
|
+
import { normalizeMessageAttachments } from './transcript-metadata.mjs';
|
|
9
|
+
|
|
10
|
+
const sizeLabel = (size) => {
|
|
11
|
+
const bytes = Number(size) || 0;
|
|
12
|
+
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
|
13
|
+
return bytes >= 1024 ? `${Math.round(bytes / 1024)} KB` : `${bytes} B`;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const oneLine = (value) => escapeHarnessTags(String(value ?? '').replace(/[\r\n]+/g, ' '));
|
|
17
|
+
|
|
18
|
+
/** `files` ({ name, mimeType, size, path, id? }) normalized: bounded strings,
|
|
19
|
+
* ids only of the attachment-store shape. */
|
|
20
|
+
export function normalizeHandoffFiles(files) {
|
|
21
|
+
return (Array.isArray(files) ? files : [])
|
|
22
|
+
.filter((file) => file && typeof file === 'object' && typeof file.path === 'string' && file.path)
|
|
23
|
+
.slice(0, 8)
|
|
24
|
+
.map((file) => ({
|
|
25
|
+
name: String(file.name || 'file').slice(0, 200),
|
|
26
|
+
path: file.path.slice(0, 1024),
|
|
27
|
+
kind: ['image', 'file', 'text'].includes(file.kind) ? file.kind : 'file',
|
|
28
|
+
mimeType: String(file.mimeType || 'application/octet-stream').slice(0, 100),
|
|
29
|
+
size: Number(file.size) >= 0 ? Number(file.size) : 0,
|
|
30
|
+
...(/^[a-f0-9]{64}$/.test(String(file.id ?? '')) ? { id: file.id } : {}),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The model-facing list of `files`, empty when there are none. `ids`: the
|
|
35
|
+
* row also records the stored files as its attachments (a task report), so
|
|
36
|
+
* their ids can be forwarded; a plain-text handoff (an agent completion)
|
|
37
|
+
* lists paths only — an id named in text is never trusted. */
|
|
38
|
+
export function artifactLines(files, omitted = 0, { ids = true } = {}) {
|
|
39
|
+
const list = normalizeHandoffFiles(files);
|
|
40
|
+
if (!list.length) return [];
|
|
41
|
+
const attachable = (file) => (ids ? ` [attachment:${file.id}]` : ' (attachable: forward it by path)');
|
|
42
|
+
return [
|
|
43
|
+
`Files from this turn (forward one with attachments: [${ids ? 'id or path' : 'path'}]; link a path to show it):`,
|
|
44
|
+
...list.map(
|
|
45
|
+
(file) =>
|
|
46
|
+
`- ${oneLine(file.name)} (${oneLine(file.mimeType)}, ${sizeLabel(file.size)}) — ${oneLine(file.path)}${
|
|
47
|
+
file.id ? attachable(file) : ' (too large or unsupported to attach; link the path)'
|
|
48
|
+
}`
|
|
49
|
+
),
|
|
50
|
+
...(omitted > 0 ? [`- …and ${omitted} more file(s)`] : []),
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The message attachment records of `files` (the stored ones). */
|
|
55
|
+
export function artifactRecords(files) {
|
|
56
|
+
return normalizeMessageAttachments(
|
|
57
|
+
normalizeHandoffFiles(files)
|
|
58
|
+
.filter((file) => file.id)
|
|
59
|
+
.map((file) => ({ ...file, attachmentRef: file.id }))
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -28,7 +28,12 @@ export const ANTIGRAVITY_MODELS = Object.freeze([
|
|
|
28
28
|
low: 'gemini-3.1-pro-low',
|
|
29
29
|
high: 'gemini-3.1-pro-high',
|
|
30
30
|
}),
|
|
31
|
-
|
|
31
|
+
// The gateway serves Argon only through per-tier ids; bare `gemini-4-argon` is a 404.
|
|
32
|
+
fallbackModel('gemini-4-argon', 'Gemini 4 Argon', 'gemini-pro', 1048576, {
|
|
33
|
+
low: 'gemini-4-argon-low',
|
|
34
|
+
medium: 'gemini-4-argon-medium',
|
|
35
|
+
high: 'gemini-4-argon-high',
|
|
36
|
+
}),
|
|
32
37
|
fallbackModel('claude-opus-4-6-thinking', 'Claude Opus 4.6 (Thinking)', null, 250000, 'claude-opus-4-6-thinking', [
|
|
33
38
|
'low',
|
|
34
39
|
'medium',
|
|
@@ -63,6 +63,12 @@ export async function accountProviderSend(provider, instance, send, model, opts
|
|
|
63
63
|
if (!ledger) return;
|
|
64
64
|
recorded.add(owner);
|
|
65
65
|
const usage = result.usage || UNMEASURED_USAGE;
|
|
66
|
+
const durationMs = timed ? Date.now() - startedAt : 0;
|
|
67
|
+
// The one measured request time: the loop's speed figures read it, so
|
|
68
|
+
// the composer and the ledger show the same span.
|
|
69
|
+
if (timed && result && typeof result === 'object') {
|
|
70
|
+
Object.defineProperty(result, 'usageDurationMs', { value: durationMs, configurable: true, writable: true });
|
|
71
|
+
}
|
|
66
72
|
const row = makeUsageRecord({
|
|
67
73
|
id: result.responseId ? undefined : id,
|
|
68
74
|
ts: Date.now(),
|
|
@@ -88,7 +94,7 @@ export async function accountProviderSend(provider, instance, send, model, opts
|
|
|
88
94
|
// The account this send is bound to, so quota history can tell one
|
|
89
95
|
// connected subscription's records from another's.
|
|
90
96
|
account: ACCOUNT_PROVIDERS.includes(provider) ? currentProviderAccountId(provider) : '',
|
|
91
|
-
durationMs
|
|
97
|
+
durationMs,
|
|
92
98
|
});
|
|
93
99
|
// Committed by the ledger worker (batched with concurrent sends); the
|
|
94
100
|
// send still settles only once its row is durable, as before, but the
|
|
@@ -1,10 +1,84 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
boundedCauseChain,
|
|
3
|
+
providerErrorKind,
|
|
4
|
+
typedCodeKind,
|
|
5
|
+
} from '../agent/orchestrator/providers/provider-error-kind.mjs';
|
|
2
6
|
import { readStreamOutcome } from '../agent/orchestrator/providers/lib/stream-outcome.mjs';
|
|
7
|
+
import { namesUnavailableModel } from './error-presentation.mjs';
|
|
3
8
|
|
|
4
|
-
|
|
9
|
+
// When a route's model fails, does the next model in its fallback chain get
|
|
10
|
+
// the request? Only for a refusal scoped to THIS provider, account or model —
|
|
11
|
+
// another provider may serve the identical request:
|
|
12
|
+
// quota_exhausted / entitlement / billing — the plan or account is spent;
|
|
13
|
+
// rate_limited / overloaded / service_tier_unavailable — this provider is
|
|
14
|
+
// saturated once its own retries are spent;
|
|
15
|
+
// auth / permission — the provider's own sign-in refresh already failed, or
|
|
16
|
+
// the account, region or tier is refused;
|
|
17
|
+
// transient — only when the server answered (a typed status or code): its
|
|
18
|
+
// retry ladder is spent. A bare network failure would fail on every
|
|
19
|
+
// provider, so it stays with the transport recovery;
|
|
20
|
+
// invalid_request on 404/410, a typed model code, or provider wording with
|
|
21
|
+
// the model as the refused subject (namesUnavailableModel) — the model is
|
|
22
|
+
// unknown, retired or not offered to this account.
|
|
23
|
+
// A request that fails the same way on any model never hops: request too
|
|
24
|
+
// large, a refused tool schema or other invalid request, a policy refusal,
|
|
25
|
+
// a cancel, a stall and any untyped failure. A context overflow hops only to
|
|
26
|
+
// a model with a larger window (`contextGrows`); compaction runs first.
|
|
27
|
+
// Whatever the kind, nothing may have been produced: no visible or buffered
|
|
28
|
+
// output, no tool call started or dispatched.
|
|
29
|
+
const PROVIDER_SCOPED_KINDS = new Set([
|
|
30
|
+
'quota_exhausted',
|
|
31
|
+
'entitlement',
|
|
32
|
+
'billing',
|
|
33
|
+
'rate_limited',
|
|
34
|
+
'overloaded',
|
|
35
|
+
'service_tier_unavailable',
|
|
36
|
+
'auth',
|
|
37
|
+
'permission',
|
|
38
|
+
]);
|
|
39
|
+
const MODEL_UNAVAILABLE_CODES = new Set(['model_not_found', 'model_not_supported']);
|
|
5
40
|
|
|
6
|
-
|
|
7
|
-
|
|
41
|
+
/** 'hop', 'context' (a context overflow: hop only to a larger window) or 'stay'. */
|
|
42
|
+
// A recovery layer may wrap the provider failure (a context overflow it could
|
|
43
|
+
// not compact away): what the original request exposed lives on the cause.
|
|
44
|
+
// Every node of the chain counts, so a wrapper never launders exposure.
|
|
45
|
+
function chainOutcome(error) {
|
|
46
|
+
return readStreamOutcome(...(error && typeof error === 'object' ? boundedCauseChain(error) : [error]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function routeFallbackVerdict(error, { signal, outcome = chainOutcome(error), committed = false } = {}) {
|
|
50
|
+
if (signal?.aborted || committed || outcome.userAbort || outcome.stallObserved) return 'stay';
|
|
51
|
+
if (!outcome.replaySafe || outcome.observedOutput || outcome.toolCallsStarted || outcome.pendingToolInput) {
|
|
52
|
+
return 'stay';
|
|
53
|
+
}
|
|
54
|
+
const { kind, status, code } = providerErrorKind(error);
|
|
55
|
+
if (PROVIDER_SCOPED_KINDS.has(kind)) return 'hop';
|
|
56
|
+
if (kind === 'transient') return status >= 408 || typedCodeKind(error) === 'transient' ? 'hop' : 'stay';
|
|
57
|
+
if (kind === 'invalid_request') {
|
|
58
|
+
return status === 404 || status === 410 || MODEL_UNAVAILABLE_CODES.has(code) || namesUnavailableModel(error)
|
|
59
|
+
? 'hop'
|
|
60
|
+
: 'stay';
|
|
61
|
+
}
|
|
62
|
+
return kind === 'context_overflow' ? 'context' : 'stay';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function canFallbackRoute(error, { contextGrows = false, ...options } = {}) {
|
|
66
|
+
const verdict = routeFallbackVerdict(error, options);
|
|
67
|
+
return verdict === 'hop' || (verdict === 'context' && contextGrows === true);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const CAPACITY_KINDS = new Set(['rate_limited', 'quota_exhausted', 'overloaded', 'transient']);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The narrower rule a stateless tool route (native web search) keeps: only a
|
|
74
|
+
* capacity refusal the server stated (429 / 503, or an account-pool quota
|
|
75
|
+
* refusal) moves to the next provider; a denied or failed credential
|
|
76
|
+
* surfaces so the user fixes that route.
|
|
77
|
+
*/
|
|
78
|
+
// It also keeps its stricter stream guards: any started or terminal stream
|
|
79
|
+
// stays on its route, since a tool call cannot prove nothing was generated.
|
|
80
|
+
export function canFallbackCapacityRoute(error, { signal, outcome = readStreamOutcome(error) } = {}) {
|
|
81
|
+
if (signal?.aborted || outcome.userAbort || outcome.stallObserved) return false;
|
|
8
82
|
if (
|
|
9
83
|
!outcome.replaySafe ||
|
|
10
84
|
outcome.observedOutput ||
|
|
@@ -15,8 +89,6 @@ export function canFallbackRoute(error, { signal, outcome = readStreamOutcome(er
|
|
|
15
89
|
)
|
|
16
90
|
return false;
|
|
17
91
|
const { kind, status } = providerErrorKind(error);
|
|
18
|
-
if (!
|
|
19
|
-
// A rejected HTTP request or a provider account-pool refusal proves no
|
|
20
|
-
// generation started. A transport disconnect alone does not.
|
|
92
|
+
if (!CAPACITY_KINDS.has(kind)) return false;
|
|
21
93
|
return status === 429 || status === 503 || (kind === 'quota_exhausted' && error?.quotaExhausted != null);
|
|
22
94
|
}
|
|
@@ -5,23 +5,28 @@
|
|
|
5
5
|
/** `text` without markdown syntax, whitespace folded to single spaces. */
|
|
6
6
|
export function plainPreviewText(text) {
|
|
7
7
|
// HTML comments never show in markdown (a greeting's suggestions ride one).
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
8
|
+
return (
|
|
9
|
+
String(text ?? '')
|
|
10
|
+
.replace(/<!--[\s\S]*?(?:-->|$)/g, ' ')
|
|
11
|
+
.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*$/gm, ' ')
|
|
12
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
13
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
14
|
+
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
|
|
15
|
+
.replace(/^[ \t]*>[ \t]?/gm, '')
|
|
16
|
+
.replace(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/gm, '')
|
|
17
|
+
.replace(/^[ \t]*(?:[-*_][ \t]*){3,}$/gm, ' ')
|
|
18
|
+
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '$2')
|
|
19
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?!\w)/g, '$1$2')
|
|
20
|
+
.replace(/(^|[^\w_])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2')
|
|
21
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '$1')
|
|
22
|
+
.replace(/`+([^`\n]*)`+/g, '$1')
|
|
23
|
+
.replace(/\s+/g, ' ')
|
|
24
|
+
// A summary stored before newlines were kept already folded its list: a
|
|
25
|
+
// dash or bullet right after a sentence end is a marker, not a hyphen.
|
|
26
|
+
.replace(/([.!?。…::])\s[-*+•]\s+(?=\S)/g, '$1 ')
|
|
27
|
+
.trim()
|
|
28
|
+
.slice(0, PREVIEW_CAP)
|
|
29
|
+
);
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/** A safety cap only: the row clamps the shown text to two lines. */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SensitiveScope {
|
|
2
|
+
root: string[];
|
|
3
|
+
/** Subfolders of the root that stay reachable. */
|
|
4
|
+
except: readonly string[];
|
|
5
|
+
/** Whole tree (a registered private root) or Mixdog's secret subset. */
|
|
6
|
+
whole: boolean;
|
|
7
|
+
}
|
|
8
|
+
export function sensitivePathSegments(path: string): string[];
|
|
9
|
+
export function matchesSensitivePath(
|
|
10
|
+
path: string,
|
|
11
|
+
within?: readonly SensitiveScope[],
|
|
12
|
+
options?: { envTemplates?: boolean }
|
|
13
|
+
): boolean;
|
|
14
|
+
export function mixdogSensitiveScopes(extraData?: readonly string[]): SensitiveScope[];
|
|
15
|
+
export function isSensitiveLocalPath(path: string, options?: { extraData?: readonly string[] }): boolean;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// The credential / secret file deny list, shared by every lane that hands a
|
|
2
|
+
// local file to someone else: the desktop's remote (paired-device) file lanes
|
|
3
|
+
// (apps/desktop/src/main/remote-sensitive-paths.ts adds its registered private
|
|
4
|
+
// roots and async canonicalization) and the runtime's attachment hand-offs
|
|
5
|
+
// between bots and agents (session-runtime/handoff-attachments.mjs).
|
|
6
|
+
//
|
|
7
|
+
// Matching is on SEGMENTS of a path, lower-cased on every platform, so a
|
|
8
|
+
// denied name cannot hide behind letter case, trailing dots/spaces or an NTFS
|
|
9
|
+
// stream suffix. Callers pass the canonical (realpath) form as well as the
|
|
10
|
+
// lexical one, so a link cannot reach a denied place under an innocent name.
|
|
11
|
+
import { realpathSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
|
|
15
|
+
/** A directory with this name anywhere on the path holds credentials. `.git`
|
|
16
|
+
* is one of them: its config carries remotes with embedded tokens and
|
|
17
|
+
* credential helpers, and its objects hold every secret ever committed. */
|
|
18
|
+
const SECRET_DIRECTORIES = new Set([
|
|
19
|
+
'.ssh',
|
|
20
|
+
'.gnupg',
|
|
21
|
+
'.aws',
|
|
22
|
+
'.azure',
|
|
23
|
+
'.kube',
|
|
24
|
+
'.mozilla',
|
|
25
|
+
'.password-store',
|
|
26
|
+
'.git',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** Consecutive segments anywhere on the path that name a credential store:
|
|
30
|
+
* CLI configs, browser profiles and OS credential stores. */
|
|
31
|
+
const SECRET_SEQUENCES = [
|
|
32
|
+
// Tool credentials in the user's home.
|
|
33
|
+
['.docker', 'config.json'],
|
|
34
|
+
['.config', 'gcloud'],
|
|
35
|
+
['appdata', 'roaming', 'gcloud'],
|
|
36
|
+
['.config', 'gh'],
|
|
37
|
+
['appdata', 'roaming', 'github cli'],
|
|
38
|
+
['.config', 'git', 'credentials'],
|
|
39
|
+
['.cargo', 'credentials'],
|
|
40
|
+
['.cargo', 'credentials.toml'],
|
|
41
|
+
['.terraform.d', 'credentials.tfrc.json'],
|
|
42
|
+
['.claude', '.credentials.json'],
|
|
43
|
+
['.codex', 'auth.json'],
|
|
44
|
+
['.gemini', 'oauth_creds.json'],
|
|
45
|
+
// Browser profiles: cookies, saved logins, session tokens.
|
|
46
|
+
['google', 'chrome', 'user data'],
|
|
47
|
+
['microsoft', 'edge', 'user data'],
|
|
48
|
+
['bravesoftware', 'brave-browser'],
|
|
49
|
+
['chromium', 'user data'],
|
|
50
|
+
['vivaldi', 'user data'],
|
|
51
|
+
['opera software'],
|
|
52
|
+
['mozilla', 'firefox'],
|
|
53
|
+
['application support', 'google', 'chrome'],
|
|
54
|
+
['application support', 'microsoft edge'],
|
|
55
|
+
['application support', 'firefox'],
|
|
56
|
+
['application support', 'chromium'],
|
|
57
|
+
['application support', 'bravesoftware'],
|
|
58
|
+
['application support', 'vivaldi'],
|
|
59
|
+
['library', 'safari'],
|
|
60
|
+
['library', 'cookies'],
|
|
61
|
+
['.config', 'google-chrome'],
|
|
62
|
+
['.config', 'chromium'],
|
|
63
|
+
['.config', 'microsoft-edge'],
|
|
64
|
+
['.config', 'bravesoftware'],
|
|
65
|
+
['.config', 'vivaldi'],
|
|
66
|
+
['snap', 'firefox'],
|
|
67
|
+
['snap', 'chromium'],
|
|
68
|
+
['.var', 'app', 'org.mozilla.firefox'],
|
|
69
|
+
['.var', 'app', 'com.google.chrome'],
|
|
70
|
+
// OS credential stores.
|
|
71
|
+
['microsoft', 'credentials'],
|
|
72
|
+
['microsoft', 'protect'],
|
|
73
|
+
['microsoft', 'vault'],
|
|
74
|
+
['windows', 'system32', 'config'],
|
|
75
|
+
['library', 'keychains'],
|
|
76
|
+
['.local', 'share', 'keyrings'],
|
|
77
|
+
['.local', 'share', 'kwalletd'],
|
|
78
|
+
['etc', 'shadow'],
|
|
79
|
+
['etc', 'gshadow'],
|
|
80
|
+
['etc', 'ssh'],
|
|
81
|
+
['etc', 'ssl', 'private'],
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const KEY_EXTENSIONS = new Set([
|
|
85
|
+
'.pem',
|
|
86
|
+
'.key',
|
|
87
|
+
'.p12',
|
|
88
|
+
'.pfx',
|
|
89
|
+
'.p8',
|
|
90
|
+
'.ppk',
|
|
91
|
+
'.jks',
|
|
92
|
+
'.keystore',
|
|
93
|
+
'.kdbx',
|
|
94
|
+
'.keychain',
|
|
95
|
+
'.keychain-db',
|
|
96
|
+
]);
|
|
97
|
+
const CREDENTIAL_FILES = new Set([
|
|
98
|
+
'.netrc',
|
|
99
|
+
'_netrc',
|
|
100
|
+
'.npmrc',
|
|
101
|
+
'.pypirc',
|
|
102
|
+
'.git-credentials',
|
|
103
|
+
'.pgpass',
|
|
104
|
+
'.vault-token',
|
|
105
|
+
'.htpasswd',
|
|
106
|
+
'.dockercfg',
|
|
107
|
+
'.envrc',
|
|
108
|
+
'.s3cfg',
|
|
109
|
+
'kubeconfig',
|
|
110
|
+
]);
|
|
111
|
+
/** Environment files: every `.env*` name (`.env`, `.env.local`, `.envrc`,
|
|
112
|
+
* `.environment`, and the `.env.example` templates too — a template is
|
|
113
|
+
* checked in often enough with real values that no name is exempt) and
|
|
114
|
+
* `<stage>.env`. */
|
|
115
|
+
const ENV_FILE = /^(?:\.env.*|[^.]{1,64}(?:\.[^.]{1,64}){0,3}\.env)$/;
|
|
116
|
+
/** The dotenv templates a person may still open on purpose from a paired
|
|
117
|
+
* device (the desktop's remote file lanes, `envTemplates: true`); automatic
|
|
118
|
+
* transfers between bots and agents never get this allowance. */
|
|
119
|
+
const ENV_TEMPLATES = new Set(['.env.example', '.env.sample', '.env.template', '.env.dist']);
|
|
120
|
+
/** Credential JSON a cloud or OAuth client writes or downloads:
|
|
121
|
+
* `credentials.json`, `*credentials*.json`, `service-account*.json`,
|
|
122
|
+
* `client_secret*.json`. */
|
|
123
|
+
const CREDENTIAL_JSON = /^(?:.*credential.*|service[-_ ]?account.*|client[-_]?secret.*)\.json$/;
|
|
124
|
+
/** OpenSSH private keys (`id_ed25519`, `id_rsa_work`, `id_ecdsa_sk`, any
|
|
125
|
+
* `id_<type>`): no extension, so the public `.pub` half and documents named
|
|
126
|
+
* after a key type (`id_rsa_notes.md`) are not keys. */
|
|
127
|
+
const SSH_PRIVATE_KEY = /^id_[a-z0-9]{2,16}(?:[_-][^._-]{1,32}){0,3}$/;
|
|
128
|
+
/** A copy an editor or tool leaves beside a file (`x.bak`, `x.old`, `x~`,
|
|
129
|
+
* `x.1`): it holds what the original holds. */
|
|
130
|
+
const BACKUP_SUFFIX = /(?:\.(?:bak|backup|old|orig|prev|save|sav|swp|swo|tmp|copy|\d{1,3})|~)$/;
|
|
131
|
+
const MAX_BACKUP_SUFFIXES = 3;
|
|
132
|
+
|
|
133
|
+
/** The name a backup copy was made of (`.npmrc.bak.1` → `.npmrc`). */
|
|
134
|
+
function backupOriginal(name) {
|
|
135
|
+
let original = name;
|
|
136
|
+
for (let strip = 0; strip < MAX_BACKUP_SUFFIXES; strip += 1) {
|
|
137
|
+
const next = original.replace(BACKUP_SUFFIX, '');
|
|
138
|
+
if (next === original || !next) break;
|
|
139
|
+
original = next;
|
|
140
|
+
}
|
|
141
|
+
return original;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A file whose own name says it holds a secret, wherever it lives: a
|
|
145
|
+
* credential file, a dotenv file, a key or keystore, or a backup of one. */
|
|
146
|
+
function secretFileName(name, { envTemplates = false } = {}) {
|
|
147
|
+
const original = backupOriginal(name);
|
|
148
|
+
if (CREDENTIAL_FILES.has(original) || CREDENTIAL_JSON.test(original)) return true;
|
|
149
|
+
if (ENV_FILE.test(original)) return !(envTemplates && ENV_TEMPLATES.has(original));
|
|
150
|
+
const dot = original.lastIndexOf('.');
|
|
151
|
+
if (dot >= 0 && KEY_EXTENSIONS.has(original.slice(dot))) return true;
|
|
152
|
+
return SSH_PRIVATE_KEY.test(original);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Mixdog's own state. Its home and data folders also hold what tools write
|
|
156
|
+
// (tool results, shell output, media), which stay reachable; only the files
|
|
157
|
+
// and folders that carry provider tokens, API keys, pairing and bridge secrets
|
|
158
|
+
// are denied.
|
|
159
|
+
const MIXDOG_SECRET_DIRECTORIES = new Set([
|
|
160
|
+
'secrets',
|
|
161
|
+
'mcp-oauth',
|
|
162
|
+
'provider-accounts',
|
|
163
|
+
'telegram',
|
|
164
|
+
'relay-data',
|
|
165
|
+
'dev-profile',
|
|
166
|
+
]);
|
|
167
|
+
const MIXDOG_SECRET_FILE =
|
|
168
|
+
/^\.?(mixdog-config|provider-accounts|relay-hook-device|auth\.json|[a-z0-9-]+-oauth(-credentials)?\.json|[a-z0-9-]+-bridge\.json)/;
|
|
169
|
+
const MIXDOG_SECRET_EXTENSIONS = new Set(['.dpapi', '.token']);
|
|
170
|
+
|
|
171
|
+
function mixdogSecret(rest) {
|
|
172
|
+
if (rest.some((segment) => MIXDOG_SECRET_DIRECTORIES.has(segment))) return true;
|
|
173
|
+
const name = rest[rest.length - 1] ?? '';
|
|
174
|
+
const dot = name.lastIndexOf('.');
|
|
175
|
+
return MIXDOG_SECRET_FILE.test(name) || MIXDOG_SECRET_EXTENSIONS.has(dot >= 0 ? name.slice(dot) : '');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The segments of a path as the rules compare them: no extended-path prefix,
|
|
179
|
+
* no NTFS stream suffix, no trailing dots or spaces, lower case. */
|
|
180
|
+
export function sensitivePathSegments(path) {
|
|
181
|
+
return String(path)
|
|
182
|
+
.replace(/^[\\/]{2}[?.][\\/]/, '')
|
|
183
|
+
.split(/[\\/]+/)
|
|
184
|
+
.map((segment) =>
|
|
185
|
+
segment
|
|
186
|
+
.split(':')[0]
|
|
187
|
+
.replace(/[. ]+$/, '')
|
|
188
|
+
.toLowerCase()
|
|
189
|
+
)
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hasRun(segments, run) {
|
|
194
|
+
for (let start = 0; start + run.length <= segments.length; start += 1) {
|
|
195
|
+
if (run.every((segment, offset) => segments[start + offset] === segment)) return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether `path` is denied under `within`: scopes `{ root: segments, except:
|
|
202
|
+
* subfolder names, whole }` — Mixdog's home and data folders (`whole: false`,
|
|
203
|
+
* only their secret subset is denied) and registered private roots (`whole:
|
|
204
|
+
* true`, everything but `except`). Pass both the lexical and canonical form.
|
|
205
|
+
* `envTemplates`: a user-initiated remote open may read the dotenv
|
|
206
|
+
* templates; nothing else gets that allowance.
|
|
207
|
+
*/
|
|
208
|
+
export function matchesSensitivePath(path, within = [], { envTemplates = false } = {}) {
|
|
209
|
+
const segments = sensitivePathSegments(resolve(path));
|
|
210
|
+
const name = segments[segments.length - 1] ?? '';
|
|
211
|
+
if (segments.some((segment) => SECRET_DIRECTORIES.has(segment))) return true;
|
|
212
|
+
if (secretFileName(name, { envTemplates }) || SECRET_SEQUENCES.some((run) => hasRun(segments, run))) return true;
|
|
213
|
+
const containing = within.filter(
|
|
214
|
+
({ root }) => root.length > 0 && root.every((segment, index) => segments[index] === segment)
|
|
215
|
+
);
|
|
216
|
+
// Mixdog's home and data folders follow Mixdog's own rule even inside a
|
|
217
|
+
// registered private root: a dev or isolated profile keeps them in the app
|
|
218
|
+
// profile, and the tool output and media there stay reachable.
|
|
219
|
+
const own = containing.filter(({ whole }) => !whole);
|
|
220
|
+
if (own.length) return own.some(({ root }) => mixdogSecret(segments.slice(root.length)));
|
|
221
|
+
return containing.some(({ root, except }) => {
|
|
222
|
+
const rest = segments.slice(root.length);
|
|
223
|
+
return rest.length > 0 && !except.includes(rest[0]);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function realOrSelf(path) {
|
|
228
|
+
try {
|
|
229
|
+
return realpathSync.native(path);
|
|
230
|
+
} catch {
|
|
231
|
+
const parent = dirname(path);
|
|
232
|
+
return parent === path ? path : join(realOrSelf(parent), path.slice(parent.length).replace(/^[\\/]+/, ''));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Mixdog's home and data folders (MIXDOG_HOME / MIXDOG_DATA_DIR, plus any
|
|
237
|
+
* extra data folder the caller names) as `matchesSensitivePath` scopes. */
|
|
238
|
+
export function mixdogSensitiveScopes(extraData = []) {
|
|
239
|
+
const home = process.env.MIXDOG_HOME || join(homedir(), '.mixdog');
|
|
240
|
+
const roots = [home, process.env.MIXDOG_DATA_DIR || join(home, 'data'), ...extraData].filter(Boolean);
|
|
241
|
+
const scopes = [];
|
|
242
|
+
for (const root of roots) {
|
|
243
|
+
for (const form of new Set([resolve(root), realOrSelf(resolve(root))])) {
|
|
244
|
+
scopes.push({ root: sensitivePathSegments(form), except: [], whole: false });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return scopes;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether `path` — its own name or what its links lead to — is a
|
|
251
|
+
* credential/secret file under the shared deny list and Mixdog's own scopes. */
|
|
252
|
+
export function isSensitiveLocalPath(path, { extraData = [] } = {}) {
|
|
253
|
+
const within = mixdogSensitiveScopes(extraData);
|
|
254
|
+
const absolute = resolve(path);
|
|
255
|
+
return matchesSensitivePath(absolute, within) || matchesSensitivePath(realOrSelf(absolute), within);
|
|
256
|
+
}
|
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
// - cache hit = cacheRead / (input + cacheRead + cacheWrite) over every call
|
|
7
7
|
// whose prompt was measured. A call that could not measure its prompt
|
|
8
8
|
// (promptKnown === false) has unknown zeros, so it adds nothing.
|
|
9
|
-
// - speed = output tokens over
|
|
10
|
-
// response
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// - speed = output tokens over request ms (provider request start → end of the
|
|
10
|
+
// response, the wait for the first token included: the same wall time as the
|
|
11
|
+
// usage ledger's `duration_ms`) of every call with positive output and
|
|
12
|
+
// positive duration: the same eligibility as the usage ledger and the usage
|
|
13
|
+
// dialog, so the readout and the dialog agree. Records saved before this
|
|
14
|
+
// definition carry `speedOutputTokens` / `speedMs` (first visible token →
|
|
15
|
+
// end); they are kept as they are and read only while no request-timed call
|
|
16
|
+
// has landed.
|
|
13
17
|
// - total tokens = input + cacheRead + cacheWrite + output, from the same sums.
|
|
14
|
-
export const MIN_SPEED_OUTPUT_TOKENS = 20;
|
|
15
|
-
export const MIN_SPEED_MS = 500;
|
|
16
18
|
|
|
17
19
|
function num(value) {
|
|
18
20
|
const n = Number(value);
|
|
@@ -21,16 +23,16 @@ function num(value) {
|
|
|
21
23
|
|
|
22
24
|
/** The zeroed totals record. */
|
|
23
25
|
export function emptyUsageReadout() {
|
|
24
|
-
return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0,
|
|
26
|
+
return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, requestOutputTokens: 0, requestMs: 0 };
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
|
-
* Fold one `onUsageDelta` event (and the call's
|
|
30
|
+
* Fold one `onUsageDelta` event (and the call's request ms, 0 when it was
|
|
29
31
|
* not timed) into the totals; returns a new record, never mutates `totals`.
|
|
30
32
|
* Providers differ on whether input counts cache; the prompt total does not,
|
|
31
33
|
* so fresh input is the prompt less its cache read/write.
|
|
32
34
|
*/
|
|
33
|
-
export function foldUsageReadout(totals, delta = {},
|
|
35
|
+
export function foldUsageReadout(totals, delta = {}, requestMs = 0) {
|
|
34
36
|
const base = totals || emptyUsageReadout();
|
|
35
37
|
const next = { ...base };
|
|
36
38
|
if (delta.promptKnown !== false) {
|
|
@@ -47,10 +49,10 @@ export function foldUsageReadout(totals, delta = {}, generationMs = 0) {
|
|
|
47
49
|
// partial one would pass for the whole: it stays without until the session
|
|
48
50
|
// starts over.
|
|
49
51
|
if (Number.isFinite(base.output)) next.output = base.output + output;
|
|
50
|
-
const ms = num(
|
|
51
|
-
if (ms
|
|
52
|
-
next.
|
|
53
|
-
next.
|
|
52
|
+
const ms = num(requestMs);
|
|
53
|
+
if (ms > 0 && output > 0) {
|
|
54
|
+
next.requestOutputTokens = num(base.requestOutputTokens) + output;
|
|
55
|
+
next.requestMs = num(base.requestMs) + ms;
|
|
54
56
|
}
|
|
55
57
|
return next;
|
|
56
58
|
}
|
|
@@ -452,7 +452,10 @@ function finishBackup(dataDir, backupDir, reason) {
|
|
|
452
452
|
legacyEvidenceCache.delete(normalizeDataDir(dataDir));
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
-
|
|
455
|
+
/** `dirs`: more data-dir folders this one snapshot also carries (a one-time
|
|
456
|
+
* migration's, e.g. agents/ and bots/); routine snapshots keep to
|
|
457
|
+
* USER_DATA_DIRS so the ring stays small. */
|
|
458
|
+
export function backupUserData(dataDir, reason = 'snapshot', { dirs = [] } = {}) {
|
|
456
459
|
if (skipBackups()) return { dir: null, copied: [] };
|
|
457
460
|
if (!dataDir || !existsSync(dataDir)) return { dir: null, copied: [] };
|
|
458
461
|
const scopeDir = scopeDirFor(dataDir);
|
|
@@ -462,7 +465,7 @@ export function backupUserData(dataDir, reason = 'snapshot') {
|
|
|
462
465
|
const src = join(dataDir, rel);
|
|
463
466
|
if (existsSync(src)) copyTree(src, join(backupDir, rel), copied);
|
|
464
467
|
}
|
|
465
|
-
for (const rel of USER_DATA_DIRS) {
|
|
468
|
+
for (const rel of [...USER_DATA_DIRS, ...dirs]) {
|
|
466
469
|
const src = join(dataDir, rel);
|
|
467
470
|
if (existsSync(src)) copyTree(src, join(backupDir, rel), copied);
|
|
468
471
|
}
|