@bitkyc08/opencodex 2.68.0-preview.20260927 → 2.69.0-preview.20260928
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/bin/ocx.mjs +25 -17
- package/gui/dist/assets/App-cDPyCb_V.js +51 -0
- package/gui/dist/assets/{Tray-DFnMiyD0.js → Tray-H1IAB8mN.js} +1 -1
- package/gui/dist/assets/index-BF9QnKO_.css +1 -0
- package/gui/dist/assets/index-CI6YTp8z.js +86 -0
- package/gui/dist/assets/{quota-summary-i84eOU3o.js → quota-summary-CutOlPu2.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/factory-droid.svg +8 -0
- package/package.json +1 -1
- package/src/adapters/anthropic-output-schema.ts +109 -11
- package/src/adapters/coding-agent/protocol.ts +106 -11
- package/src/adapters/coding-agent/turn.ts +23 -18
- package/src/adapters/google-errors.ts +16 -1
- package/src/adapters/google-http.ts +14 -0
- package/src/adapters/openai-chat/passthrough.ts +2 -0
- package/src/adapters/openai-chat/summary-budget.ts +72 -0
- package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
- package/src/adapters/openai-chat.ts +6 -9
- package/src/bridge/response-json.ts +3 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/desktop-picker.ts +6 -2
- package/src/claude/desktop-system-proxy.ts +155 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
- package/src/claude/intercept/picker-ca.ts +283 -83
- package/src/claude/intercept/picker-models.ts +4 -1
- package/src/claude/intercept/picker-trust.ts +15 -2
- package/src/claude/intercept/runtime.ts +99 -32
- package/src/cli/account-api.ts +5 -2
- package/src/cli/account-extended.ts +47 -11
- package/src/cli/account-target.ts +12 -7
- package/src/cli/account.ts +47 -3
- package/src/cli/capabilities.ts +33 -7
- package/src/cli/claude-agent-startup-sync.ts +2 -0
- package/src/cli/claude-desktop.ts +37 -13
- package/src/cli/cross-home-owner.ts +138 -0
- package/src/cli/dispatch.ts +1 -1
- package/src/cli/doctor.ts +18 -8
- package/src/cli/ensure-desired-integrations.ts +5 -0
- package/src/cli/export-command.ts +6 -6
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +15 -18
- package/src/cli/integrations.ts +8 -1
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +106 -4
- package/src/cli/models.ts +6 -1
- package/src/cli/opencode.ts +2 -83
- package/src/cli/registry.ts +3 -2
- package/src/cli/status.ts +86 -7
- package/src/client/connect.ts +24 -10
- package/src/client/link-ingress.ts +3 -1
- package/src/client/link-join.ts +76 -9
- package/src/client/link-relay-transport.ts +184 -0
- package/src/client/link-relay.ts +16 -4
- package/src/client/machine-listener.ts +2 -0
- package/src/clients/config-export/constants.ts +8 -0
- package/src/clients/config-export/contracts.ts +13 -1
- package/src/clients/config-export/droid.ts +71 -0
- package/src/clients/config-export/kilo.ts +116 -0
- package/src/clients/config-export.ts +33 -2
- package/src/codex/auth-api/routes.ts +1 -1
- package/src/codex/catalog/access-programs.ts +14 -2
- package/src/codex/catalog/provider-models.ts +1 -1
- package/src/codex/catalog-auto-refresh.ts +122 -0
- package/src/codex/config-drift-heal.ts +80 -0
- package/src/codex/desktop-switches.ts +7 -5
- package/src/codex/inject/bounded-config-reader.ts +69 -0
- package/src/codex/inject/config-toml.ts +31 -3
- package/src/codex/inject/routing-target.ts +6 -1
- package/src/codex/inject.ts +4 -0
- package/src/codex/model-entitlements.ts +32 -5
- package/src/codex/native-profile-startup.ts +17 -5
- package/src/codex/project-config-warnings.ts +72 -21
- package/src/codex/quota-auto-refresh-state.ts +8 -2
- package/src/codex/quota-auto-refresh.ts +80 -9
- package/src/codex/sync.ts +10 -0
- package/src/combos/failover.ts +4 -1
- package/src/combos/jev.ts +15 -4
- package/src/combos/types.ts +16 -0
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +29 -0
- package/src/config/macos-system-proxy.ts +114 -0
- package/src/config/proxy-env.ts +67 -20
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +23 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +220 -144
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +12 -3
- package/src/integrations/droid-settings.ts +65 -0
- package/src/integrations/kilo-candidates.ts +30 -0
- package/src/integrations/mutation-plan.ts +62 -7
- package/src/integrations/native/ownership-preflight.ts +46 -12
- package/src/integrations/registry.ts +136 -7
- package/src/integrations/state.ts +52 -4
- package/src/integrations/target.ts +3 -0
- package/src/integrations/writer.ts +55 -14
- package/src/lib/account-selection-events.ts +14 -0
- package/src/lib/jsonc.ts +110 -0
- package/src/lib/standalone.ts +9 -1
- package/src/lib/windows-system-proxy.ts +101 -1
- package/src/link/relay-auth.ts +29 -0
- package/src/link/ssh-argv.ts +6 -3
- package/src/link/ssh-runner.ts +5 -5
- package/src/oauth/generic-account-failover.ts +52 -8
- package/src/oauth/index.ts +37 -14
- package/src/oauth/store.ts +96 -11
- package/src/oauth/token-guardian.ts +7 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +5 -3
- package/src/providers/fastwire.ts +3 -0
- package/src/providers/key-failover.ts +13 -5
- package/src/providers/kiro-model-catalog.ts +4 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-tiers-destination.ts +9 -0
- package/src/providers/quota/account-cache.ts +17 -3
- package/src/providers/quota/vendor-probes-oauth.ts +2 -0
- package/src/providers/quota.ts +2 -2
- package/src/responses/code-mode-helper-compat.ts +14 -1
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/images.ts +70 -20
- package/src/server/index/link-listener.ts +20 -3
- package/src/server/index/link-relay-sessions.ts +138 -0
- package/src/server/index/optional-listeners.ts +11 -1
- package/src/server/index/serve-options.ts +12 -0
- package/src/server/inference/client-encoder-delivery.ts +1 -0
- package/src/server/local-management-read-client.ts +21 -2
- package/src/server/management/agent-settings-routes.ts +19 -5
- package/src/server/management/combo-routes.ts +5 -3
- package/src/server/management/config-routes.ts +32 -6
- package/src/server/management/model-routes.ts +232 -7
- package/src/server/management/model-rows.ts +199 -1
- package/src/server/management/oauth-account-routes.ts +63 -3
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/port-reclaim.ts +170 -32
- package/src/server/responses/adapter-delivery.ts +6 -4
- package/src/server/responses/adapter-dispatch.ts +72 -2
- package/src/server/responses/core-combo.ts +6 -1
- package/src/server/responses/core-normalize.ts +19 -4
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/memory-models.ts +169 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +10 -0
- package/src/server/responses/request-prepare.ts +86 -4
- package/src/server/responses/request-transport.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +5 -2
- package/src/server/responses/shadow-target-availability.ts +17 -4
- package/src/server/responses/sidecar-execution.ts +18 -2
- package/src/server/responses/skills-snapshot.ts +239 -0
- package/src/server/responses-undeclared-tool-guard.ts +43 -6
- package/src/server/search.ts +1 -1
- package/src/service/guards.ts +4 -5
- package/src/service/state.ts +39 -2
- package/src/service.ts +2 -2
- package/src/types/config.ts +46 -4
- package/src/types/provider.ts +9 -0
- package/src/types/request.ts +6 -0
- package/src/types/tools.ts +38 -8
- package/src/types.ts +3 -0
- package/src/update/async-check.ts +5 -1
- package/src/update/index.ts +30 -12
- package/src/update/job.ts +3 -1
- package/src/update/pnpm-read-policy.d.mts +4 -0
- package/src/update/pnpm-read-policy.mjs +44 -0
- package/src/update/worker-launch.ts +152 -11
- package/src/usage/cost.ts +6 -3
- package/src/usage/log.ts +4 -0
- package/src/web-search/backends.ts +2 -2
- package/src/web-search/devin-executor.ts +1 -1
- package/src/web-search/sidecar-providers.ts +5 -5
- package/gui/dist/assets/App-B5-cAEd2.js +0 -51
- package/gui/dist/assets/index-BDUBS8PW.css +0 -1
- package/gui/dist/assets/index-BcXblvem.js +0 -86
|
@@ -33,11 +33,15 @@ import {
|
|
|
33
33
|
} from "./ownership-policy";
|
|
34
34
|
import {
|
|
35
35
|
INTEGRATION_CLIENTS,
|
|
36
|
+
boundIntegrationConfigPath,
|
|
37
|
+
assertDroidPathsUnambiguous,
|
|
38
|
+
assertDroidRecordedSettingsUnambiguous,
|
|
36
39
|
resolveIntegrationPaths,
|
|
37
40
|
unresolvedPathHintFor,
|
|
38
41
|
type IntegrationClientId,
|
|
39
42
|
} from "./registry";
|
|
40
43
|
import { resolveIntegrationTarget, type IntegrationTarget } from "./target";
|
|
44
|
+
import { inspectKiloCandidates } from "./kilo-candidates";
|
|
41
45
|
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
|
|
42
46
|
|
|
43
47
|
export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe";
|
|
@@ -49,6 +53,7 @@ export type StateReason =
|
|
|
49
53
|
/** A container we would have to write through holds a non-object value. */
|
|
50
54
|
| "blocked-container"
|
|
51
55
|
| "ambiguous-selector"
|
|
56
|
+
| "candidate-conflict"
|
|
52
57
|
/** A path selector we cannot resolve, e.g. a relative OPENCLAW_CONFIG_PATH. */
|
|
53
58
|
| "unresolvable-path";
|
|
54
59
|
|
|
@@ -60,6 +65,10 @@ export interface IntegrationStatus {
|
|
|
60
65
|
appliedAt?: string;
|
|
61
66
|
lastOpId?: string;
|
|
62
67
|
reason?: StateReason;
|
|
68
|
+
/** Other Kilo global candidates defining provider.opencodex. */
|
|
69
|
+
conflictPaths?: string[];
|
|
70
|
+
/** Kilo candidate that could not be inspected; may differ from the owned target. */
|
|
71
|
+
candidateFailurePath?: string;
|
|
63
72
|
/**
|
|
64
73
|
* The store this client reads instead of `configPath`.
|
|
65
74
|
*
|
|
@@ -328,7 +337,13 @@ export function classifyIntegration(input: {
|
|
|
328
337
|
}
|
|
329
338
|
throw error;
|
|
330
339
|
}
|
|
331
|
-
|
|
340
|
+
// A catalog can shrink to zero while the record still owns earlier rows.
|
|
341
|
+
// Presence for disable must include those recorded paths, independent of the
|
|
342
|
+
// current export roster; the ownership fingerprint is checked below.
|
|
343
|
+
if (!hasOurFragments(input.parsed, input.contribution)
|
|
344
|
+
&& !(input.record?.fragmentPaths.some(path => readPath(input.parsed, path) !== undefined))) {
|
|
345
|
+
return { state: "absent" };
|
|
346
|
+
}
|
|
332
347
|
|
|
333
348
|
/*
|
|
334
349
|
* Fragments the desired contribution carries beyond the paths this record names. Both
|
|
@@ -520,7 +535,9 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
|
|
|
520
535
|
try {
|
|
521
536
|
// One resolution for both, so a client whose paths come from mutable state
|
|
522
537
|
// cannot report one account's install beside another account's config path.
|
|
523
|
-
const
|
|
538
|
+
const context = exportContextOf(input);
|
|
539
|
+
const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home, context);
|
|
540
|
+
if (input.clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(paths.detectDir, context);
|
|
524
541
|
installed = io.statKind(paths.detectDir) === "dir";
|
|
525
542
|
if (input.clientId === "cline") io = createClineIO(io, paths.configPath, store);
|
|
526
543
|
/*
|
|
@@ -530,8 +547,12 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
|
|
|
530
547
|
* would let the badge and the switch disagree.
|
|
531
548
|
*/
|
|
532
549
|
record = store.readRecords()[input.clientId] ?? null;
|
|
550
|
+
const recordedPath = boundIntegrationConfigPath({
|
|
551
|
+
clientId: input.clientId, record, resolvedPath: paths.configPath,
|
|
552
|
+
statKind: io.statKind, env: input.env, home: input.home,
|
|
553
|
+
});
|
|
533
554
|
effective = resolveIntegrationTarget({
|
|
534
|
-
clientId: input.clientId, configPath:
|
|
555
|
+
clientId: input.clientId, configPath: recordedPath, io, record, env: input.env, home: input.home,
|
|
535
556
|
});
|
|
536
557
|
} catch (error) {
|
|
537
558
|
if (!(error instanceof ClientPathError)) throw error;
|
|
@@ -558,6 +579,20 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
|
|
|
558
579
|
}
|
|
559
580
|
|
|
560
581
|
const configPath = effective.configPath;
|
|
582
|
+
if (input.clientId === "kilo") {
|
|
583
|
+
const candidates = inspectKiloCandidates({ io, selectedPath: configPath, env: input.env, home: input.home });
|
|
584
|
+
if (candidates.kind !== "ok") return {
|
|
585
|
+
clientId: input.clientId,
|
|
586
|
+
state: candidates.kind === "conflict" ? "conflict" : "unsafe",
|
|
587
|
+
installed,
|
|
588
|
+
configPath,
|
|
589
|
+
reason: candidates.kind === "conflict" ? "candidate-conflict" : candidates.why,
|
|
590
|
+
...(candidates.kind === "conflict" ? { conflictPaths: candidates.paths } : {}),
|
|
591
|
+
...(candidates.kind === "unsafe" ? { candidateFailurePath: candidates.path } : {}),
|
|
592
|
+
...(record && record.configPath === configPath ? { appliedAt: record.appliedAt, lastOpId: record.opId } : {}),
|
|
593
|
+
...retention,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
561
596
|
const loaded = loadTarget(io, configPath);
|
|
562
597
|
if (!loaded.ok) {
|
|
563
598
|
return {
|
|
@@ -572,8 +607,13 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
|
|
|
572
607
|
|
|
573
608
|
const parsed = input.clientId === "cline"
|
|
574
609
|
? parseClineDocument(loaded.before)
|
|
575
|
-
: parseConfig(loaded.before, effective.format);
|
|
610
|
+
: parseConfig(loaded.before, effective.format, EXPORT_CLIENTS[input.clientId].jsonc ? { jsonc: true } : undefined);
|
|
576
611
|
const contribution = effective.buildContribution(exportContextOf(input));
|
|
612
|
+
if (input.clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0
|
|
613
|
+
&& (!record || record.configPath !== configPath)) {
|
|
614
|
+
return { clientId: input.clientId, state: "unsafe", installed, configPath,
|
|
615
|
+
reason: "unresolvable-path", ...retention };
|
|
616
|
+
}
|
|
577
617
|
const { state, reason } = classifyIntegration({
|
|
578
618
|
fileText: loaded.before,
|
|
579
619
|
fileIsRegular: true,
|
|
@@ -584,6 +624,14 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
|
|
|
584
624
|
clientId: input.clientId,
|
|
585
625
|
format: effective.format,
|
|
586
626
|
});
|
|
627
|
+
if (input.clientId === "droid" && record && (state === "current" || state === "stale")) {
|
|
628
|
+
try { assertDroidRecordedSettingsUnambiguous(spec.detectDir(input.env, input.home), parsed, record); }
|
|
629
|
+
catch (error) {
|
|
630
|
+
if (!(error instanceof ClientPathError)) throw error;
|
|
631
|
+
return { clientId: input.clientId, state: "unsafe", installed, configPath,
|
|
632
|
+
reason: "unresolvable-path", ...retention };
|
|
633
|
+
}
|
|
634
|
+
}
|
|
587
635
|
|
|
588
636
|
return {
|
|
589
637
|
clientId: input.clientId,
|
|
@@ -194,6 +194,9 @@ export function declaredIntegrationTarget(args: {
|
|
|
194
194
|
}): IntegrationTarget | null {
|
|
195
195
|
const { clientId, configPath, resolvedConfigPath } = args;
|
|
196
196
|
if (configPath === resolvedConfigPath) return configFileTarget(clientId, configPath, null);
|
|
197
|
+
if (INTEGRATION_CLIENTS[clientId].bindsDriftedRecord?.(configPath, args.env, args.home) === true) {
|
|
198
|
+
return configFileTarget(clientId, configPath, null);
|
|
199
|
+
}
|
|
197
200
|
const declared = INTEGRATION_CLIENTS[clientId].currentStore;
|
|
198
201
|
if (!declared) return null;
|
|
199
202
|
try {
|
|
@@ -19,6 +19,7 @@ import { detachedConfigSnapshot } from "../config/admitted-identity";
|
|
|
19
19
|
import { copyPlainData } from "../lib/plain-data";
|
|
20
20
|
import type { OcxConfig } from "../types";
|
|
21
21
|
import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io";
|
|
22
|
+
import { inspectKiloCandidates } from "./kilo-candidates";
|
|
22
23
|
import {
|
|
23
24
|
fingerprint,
|
|
24
25
|
canonicalContribution,
|
|
@@ -33,7 +34,14 @@ import {
|
|
|
33
34
|
semanticProtectedContributionFingerprint,
|
|
34
35
|
} from "./ownership-policy";
|
|
35
36
|
import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge";
|
|
36
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
INTEGRATION_CLIENTS,
|
|
39
|
+
assertDroidPathsUnambiguous,
|
|
40
|
+
isLoopbackOnly,
|
|
41
|
+
resolveIntegrationPaths,
|
|
42
|
+
restoreOwnershipCollision,
|
|
43
|
+
type IntegrationClientId,
|
|
44
|
+
} from "./registry";
|
|
37
45
|
import { declaredIntegrationTarget } from "./target";
|
|
38
46
|
import { exportContextOf } from "./state";
|
|
39
47
|
import type { IntegrationState } from "./state";
|
|
@@ -225,8 +233,8 @@ function sourcePreservingFragmentValue(
|
|
|
225
233
|
* and Cline transaction recovery both write. Translating the planner's refusal into WriteRefused
|
|
226
234
|
* here keeps the planner free of any dependency on this module's result type.
|
|
227
235
|
*/
|
|
228
|
-
function preflight(input: IntegrationWriteInput) {
|
|
229
|
-
const observed = observeIntegration(input, { maintenance: true, recover: true });
|
|
236
|
+
function preflight(input: IntegrationWriteInput, operation: "apply" | "disable") {
|
|
237
|
+
const observed = observeIntegration(input, { maintenance: true, recover: true }, operation);
|
|
230
238
|
if (!observed.failed) return observed;
|
|
231
239
|
const { reason, state, message, snapshotPath, residual } = observed.failed;
|
|
232
240
|
const refused = refuse(input.clientId, reason, state, message, snapshotPath);
|
|
@@ -251,7 +259,7 @@ function applyOrRefreshIntegration(
|
|
|
251
259
|
allowAbsent: boolean,
|
|
252
260
|
conflictPolicy: ConflictPolicy = "refuse",
|
|
253
261
|
): WriteOutcome {
|
|
254
|
-
const pre = preflight(input);
|
|
262
|
+
const pre = preflight(input, "apply");
|
|
255
263
|
if (pre.failed) return pre.failed;
|
|
256
264
|
const { store, io, clientId, spec, target, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
|
|
257
265
|
|
|
@@ -264,6 +272,9 @@ function applyOrRefreshIntegration(
|
|
|
264
272
|
return refuse(clientId, "non_loopback", classified.state,
|
|
265
273
|
`The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
|
|
266
274
|
}
|
|
275
|
+
if (clientId === "droid" && contribution.fragments.length === 0) {
|
|
276
|
+
return refuse(clientId, "unsafe", classified.state, "Factory Droid has no addressable models in the selected catalog");
|
|
277
|
+
}
|
|
267
278
|
/*
|
|
268
279
|
* The write would land, and nothing would read it.
|
|
269
280
|
*
|
|
@@ -429,6 +440,21 @@ function applyOrRefreshIntegration(
|
|
|
429
440
|
if (rechecked === undefined || rechecked !== before) {
|
|
430
441
|
return refuse(clientId, "conflict", "conflict", `${configPath} changed while applying`);
|
|
431
442
|
}
|
|
443
|
+
if (clientId === "kilo") {
|
|
444
|
+
const candidates = inspectKiloCandidates({ io, selectedPath: configPath, env: input.env, home: input.home });
|
|
445
|
+
if (candidates.kind === "conflict") return refuse(clientId, "conflict", "conflict",
|
|
446
|
+
`${configPath} cannot be managed while ${candidates.paths.join(", ")} also defines provider.opencodex`);
|
|
447
|
+
if (candidates.kind === "unsafe") return refuse(clientId, "unsafe", "unsafe",
|
|
448
|
+
`${candidates.path} cannot be inspected safely (${candidates.why})`);
|
|
449
|
+
}
|
|
450
|
+
if (clientId === "droid") {
|
|
451
|
+
try {
|
|
452
|
+
assertDroidPathsUnambiguous(detectDir, exportContextOf(input));
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (!(error instanceof ClientPathError)) throw error;
|
|
455
|
+
return refuse(clientId, "unsafe", "unsafe", messageOf(error));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
432
458
|
|
|
433
459
|
const opId = newOpId();
|
|
434
460
|
const snapshot = store.captureSnapshot(clientId, opId, before);
|
|
@@ -497,7 +523,7 @@ export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
|
497
523
|
}
|
|
498
524
|
|
|
499
525
|
export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
500
|
-
const pre = preflight(input);
|
|
526
|
+
const pre = preflight(input, "disable");
|
|
501
527
|
if (pre.failed) return pre.failed;
|
|
502
528
|
const { store, io, clientId, spec, target, configPath, before, parsed, record, classified } = pre;
|
|
503
529
|
|
|
@@ -625,6 +651,21 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
|
|
|
625
651
|
return refuse(clientId, "conflict", "conflict",
|
|
626
652
|
`that operation was recorded for ${configPath}, which this client no longer writes; it now resolves to ${resolvedPath}`);
|
|
627
653
|
}
|
|
654
|
+
/*
|
|
655
|
+
* Preview refuses this in observeRestore. Refusing here too is what keeps an
|
|
656
|
+
* undo of an older candidate from replacing the record a newer candidate owns.
|
|
657
|
+
*/
|
|
658
|
+
const currentOwner = store.readRecords()[clientId] ?? null;
|
|
659
|
+
const collision = restoreOwnershipCollision({
|
|
660
|
+
clientId,
|
|
661
|
+
journaledPath: configPath,
|
|
662
|
+
currentPath: currentOwner && currentOwner.clientId === clientId ? currentOwner.configPath : null,
|
|
663
|
+
env: input.env,
|
|
664
|
+
home: input.home,
|
|
665
|
+
});
|
|
666
|
+
if (collision !== null) {
|
|
667
|
+
return refuse(clientId, "conflict", "conflict", collision);
|
|
668
|
+
}
|
|
628
669
|
if (clientId === "cline") {
|
|
629
670
|
try { io = createClineIO(io, configPath, store, true); }
|
|
630
671
|
catch (error) {
|
|
@@ -763,6 +804,14 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
|
|
|
763
804
|
const store = input.store ?? createIntegrationStateStore();
|
|
764
805
|
const io = input.io ?? defaultIntegrationIO(store);
|
|
765
806
|
const spec = INTEGRATION_CLIENTS[input.clientId];
|
|
807
|
+
const config = detachedConfigSnapshot(input.config);
|
|
808
|
+
if (config === null) {
|
|
809
|
+
throw new UncopyableIntegrationInputError("the proxy configuration could not be captured for this write");
|
|
810
|
+
}
|
|
811
|
+
const models = copyPlainData(input.models);
|
|
812
|
+
if (!models.ok) {
|
|
813
|
+
throw new UncopyableIntegrationInputError("the model roster could not be captured for this write");
|
|
814
|
+
}
|
|
766
815
|
/*
|
|
767
816
|
* One resolution for both paths. Aside derives them from the account id in
|
|
768
817
|
* its manifest, so two independent calls could verify one account's install
|
|
@@ -770,7 +819,7 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
|
|
|
770
819
|
*/
|
|
771
820
|
const resolvedPaths = input.resolvedPaths
|
|
772
821
|
? { ...input.resolvedPaths }
|
|
773
|
-
: resolveIntegrationPaths(input.clientId, env, home);
|
|
822
|
+
: resolveIntegrationPaths(input.clientId, env, home, exportContextOf({ ...input, config, models: models.value }));
|
|
774
823
|
/*
|
|
775
824
|
* The configuration and the roster are seams like the others, and they were the two still held
|
|
776
825
|
* by reference. A coordinated write plans from this input, awaits the writer lock and a
|
|
@@ -779,14 +828,6 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
|
|
|
779
828
|
* checked in one state and written from another, which is the substitution the fingerprint
|
|
780
829
|
* exists to prevent. Copying both here gives the plan and the document one input.
|
|
781
830
|
*/
|
|
782
|
-
const config = detachedConfigSnapshot(input.config);
|
|
783
|
-
if (config === null) {
|
|
784
|
-
throw new UncopyableIntegrationInputError("the proxy configuration could not be captured for this write");
|
|
785
|
-
}
|
|
786
|
-
const models = copyPlainData(input.models);
|
|
787
|
-
if (!models.ok) {
|
|
788
|
-
throw new UncopyableIntegrationInputError("the model roster could not be captured for this write");
|
|
789
|
-
}
|
|
790
831
|
return { ...input, config, models: models.value, env, home, store, io, resolvedPaths };
|
|
791
832
|
}
|
|
792
833
|
|
|
@@ -6,6 +6,7 @@ export type AccountSelectionEvent = {
|
|
|
6
6
|
};
|
|
7
7
|
|
|
8
8
|
const listeners = new Set<(event: AccountSelectionEvent) => void>();
|
|
9
|
+
const oauthPauseListeners = new Set<(provider: string) => void>();
|
|
9
10
|
let revision = 0;
|
|
10
11
|
|
|
11
12
|
/** Call only after the authoritative selection has been persisted. */
|
|
@@ -27,6 +28,19 @@ export function subscribeAccountSelections(listener: (event: AccountSelectionEve
|
|
|
27
28
|
return () => { listeners.delete(subscription); };
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/** Internal eligibility invalidation; separate from the public selection stream contract. */
|
|
32
|
+
export function publishOAuthAccountPauseChange(provider: string): void {
|
|
33
|
+
for (const listener of [...oauthPauseListeners]) {
|
|
34
|
+
try { listener(provider); } catch { /* A disconnected cache consumer cannot undo persistence. */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function subscribeOAuthAccountPauseChanges(listener: (provider: string) => void): () => void {
|
|
39
|
+
const subscription = (provider: string) => listener(provider);
|
|
40
|
+
oauthPauseListeners.add(subscription);
|
|
41
|
+
return () => { oauthPauseListeners.delete(subscription); };
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
export function currentAccountSelectionRevision(): number {
|
|
31
45
|
return revision;
|
|
32
46
|
}
|
package/src/lib/jsonc.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONC parse used by OpenCode's launcher and Kilo's managed writer.
|
|
3
|
+
*
|
|
4
|
+
* Comments and trailing commas are stripped by escape-aware passes that are
|
|
5
|
+
* identity on valid strict JSON, so no strict-parse probe is ever needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strip `//` and block comments outside string literals. Escape-aware so a quote inside
|
|
10
|
+
* an escaped sequence cannot flip string state and expose config text to the stripper.
|
|
11
|
+
*/
|
|
12
|
+
function stripJsonComments(text: string): string {
|
|
13
|
+
let out = "";
|
|
14
|
+
let inString = false;
|
|
15
|
+
let inLine = false;
|
|
16
|
+
let inBlock = false;
|
|
17
|
+
for (let i = 0; i < text.length; i++) {
|
|
18
|
+
const ch = text[i]!;
|
|
19
|
+
const next = text[i + 1];
|
|
20
|
+
if (inLine) {
|
|
21
|
+
if (ch === "\n") {
|
|
22
|
+
inLine = false;
|
|
23
|
+
out += ch;
|
|
24
|
+
}
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (inBlock) {
|
|
28
|
+
// Newlines are preserved so JSON.parse error positions stay meaningful.
|
|
29
|
+
if (ch === "\n") out += ch;
|
|
30
|
+
else if (ch === "*" && next === "/") {
|
|
31
|
+
// Emit a separator, not nothing: a block comment between two digits is
|
|
32
|
+
// two tokens and must not collapse into one, which would silently
|
|
33
|
+
// change a malformed value into a different valid one. Whitespace is
|
|
34
|
+
// legal wherever a comment was, so this is identity for valid JSONC.
|
|
35
|
+
out += " ";
|
|
36
|
+
inBlock = false;
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (inString) {
|
|
42
|
+
out += ch;
|
|
43
|
+
if (ch === "\\") {
|
|
44
|
+
const escaped = text[i + 1];
|
|
45
|
+
if (escaped !== undefined) { out += escaped; i++; }
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (ch === "\"") inString = false;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === "\"") { inString = true; out += ch; continue; }
|
|
52
|
+
if (ch === "/" && next === "/") { inLine = true; i++; continue; }
|
|
53
|
+
if (ch === "/" && next === "*") { inBlock = true; i++; continue; }
|
|
54
|
+
out += ch;
|
|
55
|
+
}
|
|
56
|
+
/*
|
|
57
|
+
* An unterminated block comment means the remainder of the document was
|
|
58
|
+
* comment text. Returning it stripped would let a trailing `/*` delete an
|
|
59
|
+
* arbitrary malformed tail, so the caller sees a parse failure instead.
|
|
60
|
+
*/
|
|
61
|
+
if (inBlock) throw new SyntaxError("Unterminated block comment");
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Drop commas that sit directly before `}` or `]`, ignoring string contents. */
|
|
66
|
+
function stripTrailingCommas(text: string): string {
|
|
67
|
+
let out = "";
|
|
68
|
+
let inString = false;
|
|
69
|
+
for (let i = 0; i < text.length; i++) {
|
|
70
|
+
const ch = text[i]!;
|
|
71
|
+
if (inString) {
|
|
72
|
+
out += ch;
|
|
73
|
+
if (ch === "\\") {
|
|
74
|
+
const escaped = text[i + 1];
|
|
75
|
+
if (escaped !== undefined) { out += escaped; i++; }
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (ch === "\"") inString = false;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === "\"") { inString = true; out += ch; continue; }
|
|
82
|
+
if (ch === ",") {
|
|
83
|
+
let j = i + 1;
|
|
84
|
+
while (j < text.length && /\s/.test(text[j]!)) j++;
|
|
85
|
+
if (text[j] === "}" || text[j] === "]") continue;
|
|
86
|
+
}
|
|
87
|
+
out += ch;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return JSON that `JSON.parse` will accept. Comments and trailing commas are
|
|
94
|
+
* stripped unconditionally: never probed with a strict `JSON.parse` first,
|
|
95
|
+
* because materializing a deeply nested document before the rewrite guard's
|
|
96
|
+
* depth ceiling would bypass that guard's resource contract. The passes are
|
|
97
|
+
* identity on valid strict JSON — `//`, `/*`, and a comma before `}` or `]`
|
|
98
|
+
* can only appear inside strings there, which the strippers never touch.
|
|
99
|
+
*/
|
|
100
|
+
export function canonicalizeJsonc(text: string): string {
|
|
101
|
+
return stripTrailingCommas(stripJsonComments(text));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* JSON with optional comments and trailing commas. Same stripping rules as
|
|
106
|
+
* `canonicalizeJsonc`; throws on text that is still not JSON afterwards.
|
|
107
|
+
*/
|
|
108
|
+
export function parseJsonc(text: string): unknown {
|
|
109
|
+
return JSON.parse(canonicalizeJsonc(text));
|
|
110
|
+
}
|
package/src/lib/standalone.ts
CHANGED
|
@@ -7,7 +7,15 @@ export function isStandaloneBinary(): boolean {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export function isStandaloneModuleUrl(url: string): boolean {
|
|
10
|
-
|
|
10
|
+
try {
|
|
11
|
+
const parsed = new URL(url);
|
|
12
|
+
if (parsed.protocol !== "file:" || parsed.host !== "") return false;
|
|
13
|
+
// Bun may encode the Windows virtual root's tilde; decode one URL layer only.
|
|
14
|
+
const path = decodeURIComponent(parsed.pathname);
|
|
15
|
+
return path.startsWith("/$bunfs/") || /^\/[A-Za-z]:\/~BUN\//.test(path);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
/** Directory containing the compiled executable and its copied runtime assets. */
|
|
@@ -10,7 +10,8 @@ import { decodeWindowsTextBytes } from "./windows-text";
|
|
|
10
10
|
* normalized `http://host:port` URL when a static proxy is enabled. PAC/WPAD, per-request
|
|
11
11
|
* resolution, ProxyOverride, live refresh, and direct fallback are deliberately out of scope:
|
|
12
12
|
* this is the piece an operator can audit from one log line, and everything else needs the
|
|
13
|
-
* transport boundary the reviewer asked for first.
|
|
13
|
+
* transport boundary the reviewer asked for first. The bypass readers further down serve
|
|
14
|
+
* `ocx doctor` only; egress discovery does not consult them.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
const INTERNET_SETTINGS_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
|
|
@@ -114,6 +115,105 @@ export function readWindowsSystemProxy(
|
|
|
114
115
|
return parseWindowsProxyServer(values.proxyServer);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* The two values that decide whether a host skips the static proxy: `ProxyOverride` (the bypass
|
|
120
|
+
* list) and `AutoConfigURL` (a PAC script, which takes over the decision entirely), plus the
|
|
121
|
+
* "Automatically detect settings" (WPAD) flag, which can also pick a proxy per request. Read on
|
|
122
|
+
* demand by diagnostics only; startup discovery above still ignores all three.
|
|
123
|
+
*/
|
|
124
|
+
export interface WindowsProxyBypassValues {
|
|
125
|
+
proxyOverride: string | null;
|
|
126
|
+
autoConfigUrl: string | null;
|
|
127
|
+
/** `null` when the connection-settings blob could not be read: detection may be on. */
|
|
128
|
+
autoDetect: boolean | null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Output lines of `reg query <key>`, or `null` when the key could not be read at all. */
|
|
132
|
+
export type WindowsRegistryKeyLister = (key: string) => string | null;
|
|
133
|
+
|
|
134
|
+
function listRegistryKey(key: string): string | null {
|
|
135
|
+
try {
|
|
136
|
+
const stdout = execFileSync(registryExe(), ["query", key], {
|
|
137
|
+
encoding: "buffer",
|
|
138
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
139
|
+
timeout: 2000,
|
|
140
|
+
maxBuffer: 256 * 1024,
|
|
141
|
+
windowsHide: true,
|
|
142
|
+
});
|
|
143
|
+
return decodeWindowsTextBytes(stdout);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function registryValue(listing: string, name: string): string | null {
|
|
150
|
+
for (const row of listing.split(/\r?\n/)) {
|
|
151
|
+
// " Name REG_TYPE data" (data is absent for an empty string)
|
|
152
|
+
const match = row.match(/^ {4}(\S.*?) {4}(REG_[A-Z_]+)(?: {4}(.*))?$/);
|
|
153
|
+
if (match && match[1]!.toLowerCase() === name.toLowerCase()) return (match[3] ?? "").trim();
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* WinINET `DefaultConnectionSettings` stores its flags in the ninth byte; 0x08 is
|
|
160
|
+
* "Automatically detect settings". Anything unparseable returns `null` (unknown), never `false`.
|
|
161
|
+
*/
|
|
162
|
+
export function parseWindowsAutoDetect(hex: string | null): boolean | null {
|
|
163
|
+
if (!hex || !/^[0-9a-f]{18,}$/i.test(hex)) return null;
|
|
164
|
+
return (Number.parseInt(hex.slice(16, 18), 16) & 0x08) !== 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reads the bypass values from one listing of the Internet Settings key, so an absent value
|
|
169
|
+
* (`null`) is distinguishable from a failed read (the whole result is `null`).
|
|
170
|
+
*/
|
|
171
|
+
export function readWindowsProxyBypassRegistry(
|
|
172
|
+
list: WindowsRegistryKeyLister = listRegistryKey,
|
|
173
|
+
): WindowsProxyBypassValues | null {
|
|
174
|
+
const settings = list(INTERNET_SETTINGS_KEY);
|
|
175
|
+
if (settings === null) return null;
|
|
176
|
+
const connections = list(`${INTERNET_SETTINGS_KEY}\\Connections`);
|
|
177
|
+
return {
|
|
178
|
+
proxyOverride: registryValue(settings, "ProxyOverride"),
|
|
179
|
+
autoConfigUrl: registryValue(settings, "AutoConfigURL") || null,
|
|
180
|
+
autoDetect: connections === null ? null : parseWindowsAutoDetect(registryValue(connections, "DefaultConnectionSettings")),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Whether a WinINET `ProxyOverride` list exempts an HTTPS `host` (port 443). Entries are
|
|
186
|
+
* semicolon separated, case-insensitive, and may use `*` wildcards, a leading `.` for
|
|
187
|
+
* subdomains, an optional `scheme://` prefix and an optional `:port`. `<local>` matches only
|
|
188
|
+
* dotless names, so it never exempts a public API host.
|
|
189
|
+
*/
|
|
190
|
+
export function windowsProxyOverrideBypasses(proxyOverride: string | null, host: string): boolean {
|
|
191
|
+
if (!proxyOverride) return false;
|
|
192
|
+
const target = host.toLowerCase();
|
|
193
|
+
for (const raw of proxyOverride.split(";")) {
|
|
194
|
+
let entry = raw.trim().toLowerCase();
|
|
195
|
+
if (!entry) continue;
|
|
196
|
+
if (entry === "<local>") {
|
|
197
|
+
if (!target.includes(".")) return true;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const scheme = entry.match(/^([a-z][a-z0-9+.-]*):\/\//);
|
|
201
|
+
if (scheme) {
|
|
202
|
+
if (scheme[1] !== "https") continue;
|
|
203
|
+
entry = entry.slice(scheme[0].length);
|
|
204
|
+
}
|
|
205
|
+
const port = entry.match(/:(\d+)$/);
|
|
206
|
+
if (port) {
|
|
207
|
+
if (port[1] !== "443") continue;
|
|
208
|
+
entry = entry.slice(0, -port[0].length);
|
|
209
|
+
}
|
|
210
|
+
if (entry.startsWith(".")) entry = `*${entry}`;
|
|
211
|
+
const pattern = new RegExp(`^${entry.split("*").map(part => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
|
|
212
|
+
if (pattern.test(target)) return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
117
217
|
/** Log-safe form: origin only, so a credentialed value can never reach the console. */
|
|
118
218
|
export function describeProxyForLog(url: string): string {
|
|
119
219
|
try { return new URL(url).origin; } catch { return "<unparseable>"; }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const LINK_RELAY_AUTH_PATH = "/.well-known/opencodex/link-relay-auth";
|
|
4
|
+
export const LINK_RELAY_SESSION_HEADER = "x-opencodex-link-session";
|
|
5
|
+
export const LINK_RELAY_AUTH_VERSION = "2";
|
|
6
|
+
export const LINK_RELAY_AUTH_TIMEOUT_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
/** The direction and both identities are covered; a caller proof is never a listener proof. */
|
|
9
|
+
export function linkRelayProof(
|
|
10
|
+
fingerprint: string, direction: "caller" | "listener", keyId: string, linkId: string, nonce: string,
|
|
11
|
+
): string | null {
|
|
12
|
+
if (!/^[a-f0-9]{64}$/.test(fingerprint) || !/^lnk_[a-f0-9]{16}$/.test(linkId)
|
|
13
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/.test(keyId) || !/^[a-f0-9]{64}$/.test(nonce)) return null;
|
|
14
|
+
return createHmac("sha256", Buffer.from(fingerprint, "hex"))
|
|
15
|
+
.update(`opencodex-link-relay-v2\0${direction}\0${keyId}\0${linkId}\0${nonce}`).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function linkRelayChallenge(fingerprint: string, keyId: string, linkId: string) {
|
|
19
|
+
const nonce = randomBytes(32).toString("hex");
|
|
20
|
+
const caller = linkRelayProof(fingerprint, "caller", keyId, linkId, nonce);
|
|
21
|
+
const expected = linkRelayProof(fingerprint, "listener", keyId, linkId, nonce);
|
|
22
|
+
if (!caller || !expected) throw new Error("invalid link relay identity");
|
|
23
|
+
return { nonce, caller, expected };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function linkRelayProofMatches(actual: string | null, expected: string | null): boolean {
|
|
27
|
+
return !!actual && !!expected && /^[a-f0-9]{64}$/.test(actual) && /^[a-f0-9]{64}$/.test(expected)
|
|
28
|
+
&& timingSafeEqual(Buffer.from(actual, "hex"), Buffer.from(expected, "hex"));
|
|
29
|
+
}
|
package/src/link/ssh-argv.ts
CHANGED
|
@@ -114,7 +114,7 @@ export function buildTunnelArgv(options: TunnelArgvOptions): string[] {
|
|
|
114
114
|
|
|
115
115
|
export interface ExecArgvOptions {
|
|
116
116
|
alias: string;
|
|
117
|
-
/** Remote argv.
|
|
117
|
+
/** Remote argv. The command must be sh; its arguments are quoted for the remote shell. */
|
|
118
118
|
argv: readonly string[];
|
|
119
119
|
knownHostsFile: string;
|
|
120
120
|
}
|
|
@@ -177,10 +177,13 @@ export function buildResolveArgv(alias: string): string[] {
|
|
|
177
177
|
return ["ssh", "-G", "--", assertSshAlias(alias)];
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
/**
|
|
180
|
+
/** Emit only sh in command position; single-quote every argument for the remote shell. */
|
|
181
181
|
export function quoteRemote(argv: readonly string[]): string {
|
|
182
|
-
|
|
182
|
+
if (argv[0] !== "sh") throw new LinkSshArgumentError("remote command must be sh");
|
|
183
|
+
return argv.map((arg, index) => {
|
|
183
184
|
if (arg.includes("\0")) throw new LinkSshArgumentError("remote argument contains NUL");
|
|
185
|
+
// PowerShell treats a leading quoted word as an expression, not a command invocation.
|
|
186
|
+
if (index === 0) return "sh";
|
|
184
187
|
return `'${arg.replaceAll("'", `'"'"'`)}'`;
|
|
185
188
|
}).join(" ");
|
|
186
189
|
}
|
package/src/link/ssh-runner.ts
CHANGED
|
@@ -93,7 +93,7 @@ export class SshRunnerError extends Error {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void): Promise<string> {
|
|
96
|
+
async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void, fatalUtf8 = true): Promise<string> {
|
|
97
97
|
const reader = stream.getReader();
|
|
98
98
|
const chunks: Uint8Array[] = [];
|
|
99
99
|
let total = 0;
|
|
@@ -118,7 +118,7 @@ async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number,
|
|
|
118
118
|
offset += chunk.byteLength;
|
|
119
119
|
}
|
|
120
120
|
try {
|
|
121
|
-
return new TextDecoder("utf-8", { fatal:
|
|
121
|
+
return new TextDecoder("utf-8", { fatal: fatalUtf8 }).decode(bytes);
|
|
122
122
|
} catch (error) {
|
|
123
123
|
throw new SshRunnerError("decode", "ssh output was not valid UTF-8", { cause: error });
|
|
124
124
|
}
|
|
@@ -150,7 +150,7 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
150
150
|
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
|
151
151
|
}
|
|
152
152
|
const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"));
|
|
153
|
-
const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"));
|
|
153
|
+
const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), false);
|
|
154
154
|
void stdout.catch(() => {});
|
|
155
155
|
void stderr.catch(() => {});
|
|
156
156
|
return {
|
|
@@ -181,8 +181,8 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
181
181
|
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
const stdout = readOutput(outputStream(child.stdout), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"));
|
|
185
|
+
const stderr = readOutput(outputStream(child.stderr), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"), false);
|
|
186
186
|
if (options.stdin !== undefined) {
|
|
187
187
|
try {
|
|
188
188
|
const input = child.stdin;
|