@bivy/bivy 0.0.0 → 0.1.0-staging.2
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/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, hkdfSync, createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
|
|
4
|
+
import { seal, open } from "./e2e.js";
|
|
5
|
+
import { HKDF_INFO, ROOM_KEY_BYTES, WRAP_KEY_BYTES, PAIR_SECRET_BYTES } from "./wire-format.js";
|
|
6
|
+
/**
|
|
7
|
+
* Pairing crypto for the X25519 device-linking handshake (replaces shipping the
|
|
8
|
+
* room key inside the QR fragment).
|
|
9
|
+
*
|
|
10
|
+
* Design
|
|
11
|
+
* ------
|
|
12
|
+
* The node keeps a long-term X25519 identity keypair and a symmetric ROOM KEY
|
|
13
|
+
* used for bulk frame encryption (so the relay still broadcasts one ciphertext
|
|
14
|
+
* to all devices, exactly as before). Pairing establishes, per device, a stable
|
|
15
|
+
* "wrap key" via ECDH(node_priv, device_pub); the node delivers the current room
|
|
16
|
+
* key encrypted under that wrap key. The relay can route the wrapped key but
|
|
17
|
+
* cannot derive the wrap key (it holds neither private key).
|
|
18
|
+
*
|
|
19
|
+
* Authenticating the device's public key
|
|
20
|
+
* --------------------------------------
|
|
21
|
+
* The node's public key reaches the phone OUT OF BAND via the QR (a trusted
|
|
22
|
+
* screen), so the phone cannot be fooled about node identity. The phone's public
|
|
23
|
+
* key reaches the node OVER the relay, so a malicious relay could try to
|
|
24
|
+
* substitute its own. We bind the first exchange with a high-entropy
|
|
25
|
+
* `pairSecret` carried only in the QR (never over the relay): the phone proves
|
|
26
|
+
* knowledge of it with HMAC(pairSecret, device_pub). The relay never saw the QR,
|
|
27
|
+
* so it cannot forge the proof — at worst it can cause pairing to fail, never to
|
|
28
|
+
* leak the room key. After the first exchange the node stores the authentic
|
|
29
|
+
* device public key and can re-wrap future room keys (on revoke/rotate) without
|
|
30
|
+
* re-scanning.
|
|
31
|
+
*
|
|
32
|
+
* Wire encoding is base64url for all key material so it survives JSON/QR/URLs.
|
|
33
|
+
*/
|
|
34
|
+
const PAIR_INFO = Buffer.from(HKDF_INFO.pair);
|
|
35
|
+
const ROTATE_INFO = Buffer.from(HKDF_INFO.rotate);
|
|
36
|
+
const MODEL_AUTH_VAULT_INFO = Buffer.from(HKDF_INFO.modelAuthVault);
|
|
37
|
+
const GITHUB_APP_VAULT_INFO = Buffer.from(HKDF_INFO.githubAppVault);
|
|
38
|
+
const EMPTY_SALT = Buffer.alloc(0);
|
|
39
|
+
/** Generate a fresh X25519 keypair (node identity or device ephemeral). */
|
|
40
|
+
export function generatePairingKeypair() {
|
|
41
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
42
|
+
return {
|
|
43
|
+
publicKeyB64: rawPublicKey(publicKey).toString("base64url"),
|
|
44
|
+
privateKeyB64: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64url"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Raw 32-byte X25519 public key (the form WebCrypto exports/imports as "raw"). */
|
|
48
|
+
function rawPublicKey(key) {
|
|
49
|
+
// JWK `x` is the base64url raw public key for OKP/X25519.
|
|
50
|
+
const jwk = key.export({ format: "jwk" });
|
|
51
|
+
if (!jwk.x)
|
|
52
|
+
throw new Error("could not export X25519 public key");
|
|
53
|
+
return Buffer.from(jwk.x, "base64url");
|
|
54
|
+
}
|
|
55
|
+
function publicKeyFromRaw(rawB64) {
|
|
56
|
+
return createPublicKey({
|
|
57
|
+
key: { kty: "OKP", crv: "X25519", x: rawB64 },
|
|
58
|
+
format: "jwk",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function privateKeyFromB64(privB64) {
|
|
62
|
+
return createPrivateKey({ key: Buffer.from(privB64, "base64url"), type: "pkcs8", format: "der" });
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Derive the per-device wrap key. Identical on both ends because ECDH is
|
|
66
|
+
* symmetric: ECDH(node_priv, device_pub) === ECDH(device_priv, node_pub).
|
|
67
|
+
*/
|
|
68
|
+
export function deriveWrapKey(ourPrivateKeyB64, theirPublicKeyB64, purpose) {
|
|
69
|
+
const shared = diffieHellman({
|
|
70
|
+
privateKey: privateKeyFromB64(ourPrivateKeyB64),
|
|
71
|
+
publicKey: publicKeyFromRaw(theirPublicKeyB64),
|
|
72
|
+
});
|
|
73
|
+
const info = purpose === "pair" ? PAIR_INFO
|
|
74
|
+
: purpose === "rotate" ? ROTATE_INFO
|
|
75
|
+
: purpose === "github-app-vault" ? GITHUB_APP_VAULT_INFO
|
|
76
|
+
: MODEL_AUTH_VAULT_INFO;
|
|
77
|
+
return Buffer.from(hkdfSync("sha256", shared, EMPTY_SALT, info, WRAP_KEY_BYTES));
|
|
78
|
+
}
|
|
79
|
+
/** A fresh 32-byte symmetric room key. */
|
|
80
|
+
export function generateRoomKey() {
|
|
81
|
+
return randomBytes(ROOM_KEY_BYTES);
|
|
82
|
+
}
|
|
83
|
+
/** A high-entropy single-use pairing secret carried only in the QR. */
|
|
84
|
+
export function generatePairSecret() {
|
|
85
|
+
return randomBytes(PAIR_SECRET_BYTES).toString("base64url");
|
|
86
|
+
}
|
|
87
|
+
/** HMAC-SHA256(pairSecret, device_pub) — the phone's proof it holds the QR secret. */
|
|
88
|
+
export function pairingProof(pairSecretB64, devicePublicKeyB64) {
|
|
89
|
+
return createHmac("sha256", Buffer.from(pairSecretB64, "base64url"))
|
|
90
|
+
.update(Buffer.from(devicePublicKeyB64, "base64url"))
|
|
91
|
+
.digest("base64url");
|
|
92
|
+
}
|
|
93
|
+
export function verifyPairingProof(pairSecretB64, devicePublicKeyB64, proofB64) {
|
|
94
|
+
const expected = pairingProof(pairSecretB64, devicePublicKeyB64);
|
|
95
|
+
const a = Buffer.from(expected, "base64url");
|
|
96
|
+
const b = Buffer.from(proofB64, "base64url");
|
|
97
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
98
|
+
}
|
|
99
|
+
/** Wrap (encrypt) the room key under a per-device wrap key for delivery. */
|
|
100
|
+
export function wrapRoomKey(wrapKey, roomKey) {
|
|
101
|
+
return seal(wrapKey, roomKey.toString("base64"));
|
|
102
|
+
}
|
|
103
|
+
export function unwrapRoomKey(wrapKey, wrapped) {
|
|
104
|
+
return Buffer.from(open(wrapKey, wrapped), "base64");
|
|
105
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Typed runtime-failure conditions + a classifier.
|
|
5
|
+
//
|
|
6
|
+
// This is the load-bearing seam of policy-driven run orchestration: rules match
|
|
7
|
+
// STABLE condition codes, never raw provider error strings. A raw failure (an
|
|
8
|
+
// opaque `429`, a `credit balance is too low`, a socket hang-up) is classified
|
|
9
|
+
// here exactly once into a `RuntimeCondition` plus whatever recovery metadata we
|
|
10
|
+
// could recover (a `retryAfterMs`, a `resetsAt`), and every downstream rule,
|
|
11
|
+
// evidence event, and effector speaks only that vocabulary. Add a new provider
|
|
12
|
+
// quirk here and the whole policy layer understands it — no rule edits, no
|
|
13
|
+
// regexes scattered through runtimes and the queue poller.
|
|
14
|
+
import { isAnthropicAuthError } from "../runtime/anthropic-preflight.js";
|
|
15
|
+
function rawText(error) {
|
|
16
|
+
if (error instanceof Error)
|
|
17
|
+
return error.message;
|
|
18
|
+
if (typeof error === "string")
|
|
19
|
+
return error;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.stringify(error) ?? String(error);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return String(error);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Parse a "wait N" hint from a raw error into milliseconds, if present. */
|
|
28
|
+
export function parseRetryAfterMs(raw) {
|
|
29
|
+
// `retry-after: 30` / `retry_after=30` (seconds, HTTP-style).
|
|
30
|
+
const header = /retry[\s_-]?after["'\s:=]+(\d{1,5})/i.exec(raw);
|
|
31
|
+
if (header?.[1])
|
|
32
|
+
return Number(header[1]) * 1000;
|
|
33
|
+
// "try again in 12 seconds" / "retry in 5 minutes".
|
|
34
|
+
const phrase = /(?:try again|retry)\s+in\s+(\d{1,5})\s*(ms|s|sec|second|m|min|minute|h|hour)/i.exec(raw);
|
|
35
|
+
if (phrase?.[1]) {
|
|
36
|
+
const n = Number(phrase[1]);
|
|
37
|
+
const unit = phrase[2].toLowerCase();
|
|
38
|
+
if (unit === "ms")
|
|
39
|
+
return n;
|
|
40
|
+
if (unit.startsWith("h"))
|
|
41
|
+
return n * 3_600_000;
|
|
42
|
+
if (unit.startsWith("m") && unit !== "ms")
|
|
43
|
+
return n * 60_000;
|
|
44
|
+
return n * 1000;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Parse an ISO reset timestamp (e.g. `resets_at: 2026-07-27T18:00:00Z`). */
|
|
49
|
+
export function parseResetsAt(raw) {
|
|
50
|
+
const iso = /(20\d\d-\d\d-\d\dT[\d:.]+(?:Z|[+-]\d\d:?\d\d))/.exec(raw);
|
|
51
|
+
return iso?.[1];
|
|
52
|
+
}
|
|
53
|
+
// Ordered classifiers: the FIRST match wins, so more-specific/actionable
|
|
54
|
+
// conditions are tested before broader ones (auth 401 before generic HTTP
|
|
55
|
+
// noise; explicit billing/quota before a bare rate-limit; context-window before
|
|
56
|
+
// a generic "too long").
|
|
57
|
+
const CLASSIFIERS = [
|
|
58
|
+
{ condition: "auth_failed", test: (r) => isAnthropicAuthError(r) },
|
|
59
|
+
{
|
|
60
|
+
condition: "credits_exhausted",
|
|
61
|
+
test: (r) => /\b402\b|payment required|insufficient\s+(?:credit|quota|balance|funds)|credit balance (?:is )?too low|quota (?:exceeded|exhausted)|billing|(?:usage|session) limit (?:reached|hit)|(?:you(?:'ve| have)\s+)?hit your limit|out of credits|plan (?:limit|allowance)/i.test(r),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
condition: "rate_limited",
|
|
65
|
+
test: (r) => /\b429\b|\b529\b|rate[\s_-]?limit|too many requests|overloaded/i.test(r),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
condition: "context_overflow",
|
|
69
|
+
test: (r) => /context[\s_-]?(?:length|window|limit)|maximum context|prompt is too long|too many tokens|exceeds?\b[^.]*\btokens?\b/i.test(r),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
condition: "node_offline",
|
|
73
|
+
test: (r) => /ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|no route to host|node (?:is )?offline|host unreachable/i.test(r),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
condition: "transport_error",
|
|
77
|
+
test: (r) => /ETIMEDOUT|ECONNRESET|EPIPE|EAI_AGAIN|ENOTFOUND|socket hang ?up|network (?:error|timeout)|fetch failed|timed? ?out|temporarily unavailable|\b50[234]\b/i.test(r),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
condition: "task_failed",
|
|
81
|
+
test: (r) => /tests? failed|checks? failed|no progress|made no changes|nothing to commit|assertion/i.test(r),
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
/**
|
|
85
|
+
* Classify a raw runtime failure into a stable `RuntimeCondition` plus any
|
|
86
|
+
* recovery metadata. Unmatched failures are `"unknown"` — deliberately left for
|
|
87
|
+
* a human rather than blindly retried.
|
|
88
|
+
*/
|
|
89
|
+
export function classifyFailure(error) {
|
|
90
|
+
const raw = rawText(error).slice(0, 2000);
|
|
91
|
+
const condition = CLASSIFIERS.find((c) => c.test(raw))?.condition ?? "unknown";
|
|
92
|
+
const out = { condition, raw };
|
|
93
|
+
// Recovery hints are only meaningful for the wait-and-retry conditions.
|
|
94
|
+
if (condition === "rate_limited" || condition === "credits_exhausted") {
|
|
95
|
+
const retryAfterMs = parseRetryAfterMs(raw);
|
|
96
|
+
if (retryAfterMs !== undefined)
|
|
97
|
+
out.retryAfterMs = retryAfterMs;
|
|
98
|
+
const resetsAt = parseResetsAt(raw);
|
|
99
|
+
if (resetsAt !== undefined)
|
|
100
|
+
out.resetsAt = resetsAt;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { guardToolCall } from "../guard.js";
|
|
4
|
+
import { riskCategoryForTool } from "./risk.js";
|
|
5
|
+
/** Runtime-agnostic policy decision layer. Strong runtimes call this before tools.
|
|
6
|
+
* Persistent "remembered decisions" were removed — the engine now only applies
|
|
7
|
+
* the mode-based guard floor; governance beyond that is left to the agents. */
|
|
8
|
+
export class PolicyEngine {
|
|
9
|
+
options;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
}
|
|
13
|
+
decideToolCall(workspace, toolName, input) {
|
|
14
|
+
const risk = riskCategoryForTool(toolName);
|
|
15
|
+
if (this.options.unrestricted)
|
|
16
|
+
return { decision: "allow", risk };
|
|
17
|
+
const base = guardToolCall(workspace, toolName, input, this.options.mode, this.options.isRiskyIntegration ?? (() => false));
|
|
18
|
+
return { ...base, risk };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function riskCategoryForTool(toolName) {
|
|
2
|
+
const tool = toolName.toLowerCase();
|
|
3
|
+
if (["write", "edit", "multi_edit", "patch"].includes(tool))
|
|
4
|
+
return "filesystem";
|
|
5
|
+
if (["bash", "shell", "terminal"].includes(tool))
|
|
6
|
+
return "shell";
|
|
7
|
+
if (tool.includes("git") || tool.includes("github"))
|
|
8
|
+
return "git.write";
|
|
9
|
+
if (tool.includes("secret") || tool.includes("credential") || tool.includes("token"))
|
|
10
|
+
return "secret.read";
|
|
11
|
+
if (tool.includes("stripe") || tool.includes("payment"))
|
|
12
|
+
return "payment";
|
|
13
|
+
if (tool.includes("deploy") || tool.includes("release"))
|
|
14
|
+
return "deploy";
|
|
15
|
+
if (tool.includes("http") || tool.includes("fetch") || tool.includes("curl"))
|
|
16
|
+
return "network.write";
|
|
17
|
+
return "unknown";
|
|
18
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// The ruleset schema + validator + the pure matcher.
|
|
5
|
+
//
|
|
6
|
+
// A ruleset is user-authored policy: which failures are recoverable, how to
|
|
7
|
+
// recover (retry / reroute through a fallback chain / park for a human), and the
|
|
8
|
+
// bounds (max attempts, backoff). It is validated once with typebox into a
|
|
9
|
+
// versioned in-memory shape — we never execute arbitrary YAML expressions — and
|
|
10
|
+
// the same ruleset serves both the interactive session and the work queue,
|
|
11
|
+
// gated by `appliesTo`. The MATCHER here is pure: `(ruleset, condition,
|
|
12
|
+
// context) → Rule | undefined`. How the chosen rule is carried out lives in the
|
|
13
|
+
// context-specific effectors (see run-policy.ts for the queue effector).
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import { Check, Errors } from "typebox/value";
|
|
16
|
+
// ── Schema (typebox) ────────────────────────────────────────────────────────
|
|
17
|
+
// Declared with typebox so a ruleset loaded from disk/YAML is validated before
|
|
18
|
+
// it can steer a live run. Kept intentionally small for v1: the reroute slice.
|
|
19
|
+
// The literals are spelled out (not mapped) so typebox infers the union type.
|
|
20
|
+
const ConditionSchema = Type.Union([
|
|
21
|
+
Type.Literal("rate_limited"),
|
|
22
|
+
Type.Literal("credits_exhausted"),
|
|
23
|
+
Type.Literal("context_overflow"),
|
|
24
|
+
Type.Literal("auth_failed"),
|
|
25
|
+
Type.Literal("node_offline"),
|
|
26
|
+
Type.Literal("transport_error"),
|
|
27
|
+
Type.Literal("task_failed"),
|
|
28
|
+
Type.Literal("unknown"),
|
|
29
|
+
]);
|
|
30
|
+
const RoutingCandidateSchema = Type.Object({
|
|
31
|
+
runtimeId: Type.Optional(Type.String()),
|
|
32
|
+
model: Type.Optional(Type.String()),
|
|
33
|
+
/** Provider/account id to route through — matched against configured creds. */
|
|
34
|
+
account: Type.Optional(Type.String()),
|
|
35
|
+
/** Human-readable label for evidence, if none is derivable. */
|
|
36
|
+
label: Type.Optional(Type.String()),
|
|
37
|
+
}, { additionalProperties: false });
|
|
38
|
+
const BackoffSchema = Type.Object({
|
|
39
|
+
baseMs: Type.Integer({ minimum: 0 }),
|
|
40
|
+
factor: Type.Number({ minimum: 1 }),
|
|
41
|
+
capMs: Type.Integer({ minimum: 0 }),
|
|
42
|
+
jitter: Type.Number({ minimum: 0, maximum: 1 }),
|
|
43
|
+
}, { additionalProperties: false });
|
|
44
|
+
const RuleSchema = Type.Object({
|
|
45
|
+
when: Type.Array(ConditionSchema, { minItems: 1 }),
|
|
46
|
+
action: Type.Union([Type.Literal("retry"), Type.Literal("reroute"), Type.Literal("park")]),
|
|
47
|
+
/** Ordered fallback candidates for `reroute`; first with valid creds wins. */
|
|
48
|
+
chain: Type.Optional(Type.Array(RoutingCandidateSchema)),
|
|
49
|
+
/** What to do when retries/chain are exhausted. Defaults to `park`. */
|
|
50
|
+
onExhausted: Type.Optional(Type.Union([Type.Literal("park"), Type.Literal("give_up")])),
|
|
51
|
+
maxAttempts: Type.Integer({ minimum: 1, maximum: 100 }),
|
|
52
|
+
backoff: Type.Optional(BackoffSchema),
|
|
53
|
+
}, { additionalProperties: false });
|
|
54
|
+
export const RulesetSchema = Type.Object({
|
|
55
|
+
/** Schema version — lets stored rulesets migrate without silent misreads. */
|
|
56
|
+
version: Type.Literal(1),
|
|
57
|
+
name: Type.String({ minLength: 1 }),
|
|
58
|
+
appliesTo: Type.Array(Type.Union([Type.Literal("session"), Type.Literal("queue")]), { minItems: 1 }),
|
|
59
|
+
rules: Type.Array(RuleSchema),
|
|
60
|
+
}, { additionalProperties: false });
|
|
61
|
+
/** Backoff used when a retry/reroute rule doesn't specify one. Mirrors the
|
|
62
|
+
* reconnect layer's defaults (see src/session/reconnect.ts BackoffOptions). */
|
|
63
|
+
export const DEFAULT_BACKOFF = { baseMs: 2000, factor: 2, capMs: 60_000, jitter: 0.3 };
|
|
64
|
+
/**
|
|
65
|
+
* The built-in default ruleset — deliberately SAFE, not clever.
|
|
66
|
+
*
|
|
67
|
+
* Infra hiccups (transient transport, provider rate-limits) retry with backoff;
|
|
68
|
+
* quota/auth/context failures are PARKED for a human (we can't presume a valid
|
|
69
|
+
* fallback model or account for an arbitrary node); genuine task failures and
|
|
70
|
+
* anything unclassified fall through to the caller's existing failure path.
|
|
71
|
+
* Reroute + fallback chains are fully supported (see run-policy.ts) but opt-in:
|
|
72
|
+
* author a rule with a `chain` to enable cross-account/model/provider fallback.
|
|
73
|
+
*/
|
|
74
|
+
export const DEFAULT_RULESET = {
|
|
75
|
+
version: 1,
|
|
76
|
+
name: "default",
|
|
77
|
+
appliesTo: ["queue", "session"],
|
|
78
|
+
rules: [
|
|
79
|
+
{ when: ["transport_error", "node_offline"], action: "retry", maxAttempts: 3, backoff: DEFAULT_BACKOFF },
|
|
80
|
+
{
|
|
81
|
+
when: ["rate_limited"],
|
|
82
|
+
action: "retry",
|
|
83
|
+
maxAttempts: 4,
|
|
84
|
+
backoff: { baseMs: 5000, factor: 2, capMs: 120_000, jitter: 0.3 },
|
|
85
|
+
},
|
|
86
|
+
{ when: ["credits_exhausted"], action: "park", maxAttempts: 1 },
|
|
87
|
+
{ when: ["context_overflow"], action: "park", maxAttempts: 1 },
|
|
88
|
+
{ when: ["auth_failed"], action: "park", maxAttempts: 1 },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Validate an untrusted value (e.g. parsed from a user's YAML/JSON) against the
|
|
93
|
+
* ruleset schema. Returns the typed ruleset on success, or a bounded list of
|
|
94
|
+
* human-readable errors — never throws.
|
|
95
|
+
*/
|
|
96
|
+
export function validateRuleset(value) {
|
|
97
|
+
if (Check(RulesetSchema, value))
|
|
98
|
+
return { ok: true, ruleset: value, errors: [] };
|
|
99
|
+
const errors = [...Errors(RulesetSchema, value)]
|
|
100
|
+
.slice(0, 20)
|
|
101
|
+
.map((e) => `${e.path || "/"}: ${e.message}`);
|
|
102
|
+
return { ok: false, errors: errors.length ? errors : ["ruleset did not match the expected shape"] };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The pure matcher: the first rule that applies to `context` and lists
|
|
106
|
+
* `condition` in its `when`. Undefined means "no policy for this failure" — the
|
|
107
|
+
* caller keeps its existing behavior (e.g. fail the run).
|
|
108
|
+
*/
|
|
109
|
+
export function findRule(ruleset, condition, context) {
|
|
110
|
+
if (!ruleset.appliesTo.includes(context))
|
|
111
|
+
return undefined;
|
|
112
|
+
return ruleset.rules.find((rule) => rule.when.includes(condition));
|
|
113
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Run policy: the pure decision layer the queue effector drives.
|
|
5
|
+
//
|
|
6
|
+
// Given a failed attempt (its current routing, the raw error, the attempt count,
|
|
7
|
+
// and how many reroutes have already been applied), it classifies the failure,
|
|
8
|
+
// finds the matching rule, and returns ONE decision: retry (with a backoff
|
|
9
|
+
// delay), reroute (to the next credentialed candidate in the rule's chain), park
|
|
10
|
+
// (hand to a human), or give_up (no policy — keep the caller's existing
|
|
11
|
+
// behavior). It touches no I/O and no clock beyond an injectable `random`, so
|
|
12
|
+
// it's fully deterministic and unit-testable. The poller (src/control-plane-
|
|
13
|
+
// tasks.ts) is the effector that carries the decision out on the live run.
|
|
14
|
+
import { classifyFailure } from "./conditions.js";
|
|
15
|
+
import { DEFAULT_BACKOFF, DEFAULT_RULESET, findRule, } from "./ruleset.js";
|
|
16
|
+
/** `min(cap, base·factor^n)` spread by ±jitter/2 — the reconnect-layer formula. */
|
|
17
|
+
export function computeBackoffMs(cfg, n, random) {
|
|
18
|
+
const raw = Math.min(cfg.capMs, cfg.baseMs * Math.pow(cfg.factor, n));
|
|
19
|
+
const spread = raw * cfg.jitter * (random() - 0.5);
|
|
20
|
+
return Math.max(0, Math.round(raw + spread));
|
|
21
|
+
}
|
|
22
|
+
function candidateRef(c) {
|
|
23
|
+
return (c.label ||
|
|
24
|
+
[c.runtimeId, c.model, c.account && `@${c.account}`].filter(Boolean).join(" / ") ||
|
|
25
|
+
"fallback route");
|
|
26
|
+
}
|
|
27
|
+
/** Build a run policy bound to a ruleset. Stateless: all per-run state (attempt,
|
|
28
|
+
* rerouteCount) is passed in, so one instance safely serves every run. */
|
|
29
|
+
export function createRunPolicy(deps = {}) {
|
|
30
|
+
const ruleset = deps.ruleset ?? DEFAULT_RULESET;
|
|
31
|
+
const context = deps.context ?? "queue";
|
|
32
|
+
const hasCredential = deps.hasCredential ?? (() => true);
|
|
33
|
+
const random = deps.random ?? Math.random;
|
|
34
|
+
const now = deps.now ?? Date.now;
|
|
35
|
+
return {
|
|
36
|
+
decide(ctx) {
|
|
37
|
+
const classified = classifyFailure(ctx.error);
|
|
38
|
+
const { condition } = classified;
|
|
39
|
+
const rule = findRule(ruleset, condition, context);
|
|
40
|
+
if (!rule)
|
|
41
|
+
return { action: "give_up", condition };
|
|
42
|
+
const exhausted = ctx.attempt >= rule.maxAttempts;
|
|
43
|
+
const nextAttempt = ctx.attempt + 1;
|
|
44
|
+
const backoff = rule.backoff ?? DEFAULT_BACKOFF;
|
|
45
|
+
// A provider-supplied reset is the most precise recovery time. This is
|
|
46
|
+
// especially important for session/usage limits: retrying with ordinary
|
|
47
|
+
// backoff before the window resets only burns attempts. A relative
|
|
48
|
+
// Retry-After is the next-best hint; otherwise use authored backoff.
|
|
49
|
+
const resetAtMs = classified.resetsAt === undefined ? undefined : Date.parse(classified.resetsAt);
|
|
50
|
+
const resetDelayMs = resetAtMs !== undefined && Number.isFinite(resetAtMs)
|
|
51
|
+
? Math.max(0, resetAtMs - now())
|
|
52
|
+
: undefined;
|
|
53
|
+
const delayMs = resetDelayMs ?? classified.retryAfterMs ?? computeBackoffMs(backoff, ctx.attempt - 1, random);
|
|
54
|
+
const onExhausted = () => rule.onExhausted === "give_up"
|
|
55
|
+
? { action: "give_up", condition }
|
|
56
|
+
: {
|
|
57
|
+
action: "park",
|
|
58
|
+
condition,
|
|
59
|
+
summary: `${condition}: no recovery left after ${ctx.attempt} attempt(s) — needs attention.`,
|
|
60
|
+
};
|
|
61
|
+
if (rule.action === "park") {
|
|
62
|
+
return { action: "park", condition, summary: `${condition}: parked for a human — no automatic recovery.` };
|
|
63
|
+
}
|
|
64
|
+
if (rule.action === "retry") {
|
|
65
|
+
if (exhausted)
|
|
66
|
+
return onExhausted();
|
|
67
|
+
const wait = Math.round(delayMs / 1000);
|
|
68
|
+
const timing = resetDelayMs !== undefined && classified.resetsAt
|
|
69
|
+
? ` when the limit resets at ${classified.resetsAt}`
|
|
70
|
+
: wait ? ` in ~${wait}s` : "";
|
|
71
|
+
return {
|
|
72
|
+
action: "retry",
|
|
73
|
+
delayMs,
|
|
74
|
+
condition,
|
|
75
|
+
summary: `${condition}: transient — retrying (attempt ${nextAttempt}/${rule.maxAttempts})${timing}.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// action === "reroute": walk the chain from the current cursor, skipping
|
|
79
|
+
// any candidate we can prove lacks credentials on this node, or that is a
|
|
80
|
+
// no-op (a pure model change equal to the model we're already on).
|
|
81
|
+
const chain = rule.chain ?? [];
|
|
82
|
+
const isNoop = (c) => c.model !== undefined && c.model === ctx.routing.model && c.runtimeId === undefined && c.account === undefined;
|
|
83
|
+
let cursor = ctx.rerouteCount;
|
|
84
|
+
while (cursor < chain.length && (!hasCredential(chain[cursor]) || isNoop(chain[cursor])))
|
|
85
|
+
cursor += 1;
|
|
86
|
+
if (exhausted || cursor >= chain.length)
|
|
87
|
+
return onExhausted();
|
|
88
|
+
const candidate = chain[cursor];
|
|
89
|
+
const routing = {};
|
|
90
|
+
if (candidate.runtimeId !== undefined)
|
|
91
|
+
routing.runtimeId = candidate.runtimeId;
|
|
92
|
+
if (candidate.model !== undefined)
|
|
93
|
+
routing.model = candidate.model;
|
|
94
|
+
if (candidate.account !== undefined)
|
|
95
|
+
routing.account = candidate.account;
|
|
96
|
+
const ref = candidateRef(candidate);
|
|
97
|
+
return {
|
|
98
|
+
action: "reroute",
|
|
99
|
+
delayMs,
|
|
100
|
+
condition,
|
|
101
|
+
routing,
|
|
102
|
+
ref,
|
|
103
|
+
rerouteCount: cursor + 1,
|
|
104
|
+
summary: `${condition}: rerouting to ${ref} (attempt ${nextAttempt}).`,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Session effector — in-session model reroute.
|
|
5
|
+
//
|
|
6
|
+
// The queue effector (src/control-plane-tasks.ts) reruns a failed item under the
|
|
7
|
+
// run policy. This is the interactive-session counterpart, scoped to the ONE
|
|
8
|
+
// action a live session can take fully in-place: swapping the model. When a turn
|
|
9
|
+
// ends in a recoverable error (credits exhausted, rate-limited) and the session
|
|
10
|
+
// ruleset says `reroute` down a model chain, this swaps the model via the
|
|
11
|
+
// runtime's `setModel` and re-sends the same prompt — so the session continues on
|
|
12
|
+
// a cheaper/other model instead of surfacing an error.
|
|
13
|
+
//
|
|
14
|
+
// It deliberately does NOT handle agent (runtime) or node reroutes: those are
|
|
15
|
+
// forks / standby promotions (new session identity, possibly reduced fidelity),
|
|
16
|
+
// not in-place swaps — a separate, heavier effector (see docs/rulesets.md). A
|
|
17
|
+
// chain candidate that changes anything other than the model is skipped here and
|
|
18
|
+
// the error is left to surface.
|
|
19
|
+
//
|
|
20
|
+
// The decision is SYNCHRONOUS (`planReroute`) so the caller can atomically decide
|
|
21
|
+
// whether to suppress the turn's error toast before kicking off the async swap +
|
|
22
|
+
// retry (`applyReroute`). Reroute happens only at the turn boundary, so there is
|
|
23
|
+
// no partial-work hazard.
|
|
24
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
export class SessionRerouteController {
|
|
26
|
+
deps;
|
|
27
|
+
attempt = 1;
|
|
28
|
+
rerouteCount = 0;
|
|
29
|
+
applying = false;
|
|
30
|
+
constructor(deps) {
|
|
31
|
+
this.deps = deps;
|
|
32
|
+
}
|
|
33
|
+
/** Reset the reroute budget at the start of each new user-initiated turn. */
|
|
34
|
+
beginTurn() {
|
|
35
|
+
this.attempt = 1;
|
|
36
|
+
this.rerouteCount = 0;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Synchronously decide whether this turn error should become an in-place model
|
|
40
|
+
* reroute. Returns a plan (the caller should suppress the error toast and call
|
|
41
|
+
* `applyReroute`) or null (let the error surface as usual). Pure w.r.t. the
|
|
42
|
+
* controller's counters — those advance only when the plan is applied.
|
|
43
|
+
*/
|
|
44
|
+
planReroute(rawError, currentModel) {
|
|
45
|
+
if (this.applying)
|
|
46
|
+
return null;
|
|
47
|
+
const decision = this.deps.policy.decide({
|
|
48
|
+
routing: { model: currentModel },
|
|
49
|
+
error: rawError,
|
|
50
|
+
attempt: this.attempt,
|
|
51
|
+
rerouteCount: this.rerouteCount,
|
|
52
|
+
});
|
|
53
|
+
if (decision.action !== "reroute")
|
|
54
|
+
return null;
|
|
55
|
+
const model = decision.routing.model;
|
|
56
|
+
// Only a MODEL change is applicable in-session; a chain candidate that swaps
|
|
57
|
+
// agent/account/node (or is a no-op) is left to surface / a future effector.
|
|
58
|
+
if (!model || model === currentModel)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
model,
|
|
62
|
+
condition: decision.condition,
|
|
63
|
+
summary: decision.summary,
|
|
64
|
+
delayMs: decision.delayMs,
|
|
65
|
+
rerouteCount: decision.rerouteCount,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Apply a plan: swap the model and re-drive the turn. Best-effort; on a failed
|
|
69
|
+
* swap it reports via `onFailed` rather than throwing. */
|
|
70
|
+
async applyReroute(plan, target) {
|
|
71
|
+
if (this.applying)
|
|
72
|
+
return;
|
|
73
|
+
this.applying = true;
|
|
74
|
+
try {
|
|
75
|
+
this.deps.onEvent?.({ kind: "fallback", summary: plan.summary });
|
|
76
|
+
this.deps.onNotice?.({
|
|
77
|
+
level: "info",
|
|
78
|
+
message: `Switching to ${plan.model} after ${plan.condition.replace(/_/g, " ")}, then retrying…`,
|
|
79
|
+
});
|
|
80
|
+
if (plan.delayMs > 0)
|
|
81
|
+
await (this.deps.sleep ?? defaultSleep)(plan.delayMs);
|
|
82
|
+
await target.setModel("", plan.model);
|
|
83
|
+
this.deps.onModelChanged?.();
|
|
84
|
+
this.attempt += 1;
|
|
85
|
+
this.rerouteCount = plan.rerouteCount;
|
|
86
|
+
await target.reprompt();
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
+
this.deps.onFailed?.(`Couldn't reroute to ${plan.model}: ${message}`);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
this.applying = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run a command under a pseudo-terminal while relaying stdio over pipes."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import pty
|
|
6
|
+
import select
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> int:
|
|
13
|
+
if len(sys.argv) < 2:
|
|
14
|
+
print("usage: pty-runner.py command [args...]", file=sys.stderr)
|
|
15
|
+
return 2
|
|
16
|
+
|
|
17
|
+
master_fd, slave_fd = pty.openpty()
|
|
18
|
+
env = os.environ.copy()
|
|
19
|
+
env.setdefault("TERM", "xterm-256color")
|
|
20
|
+
|
|
21
|
+
proc = subprocess.Popen(
|
|
22
|
+
sys.argv[1:],
|
|
23
|
+
stdin=slave_fd,
|
|
24
|
+
stdout=slave_fd,
|
|
25
|
+
stderr=slave_fd,
|
|
26
|
+
env=env,
|
|
27
|
+
close_fds=True,
|
|
28
|
+
start_new_session=True,
|
|
29
|
+
)
|
|
30
|
+
os.close(slave_fd)
|
|
31
|
+
|
|
32
|
+
def forward_signal(signum, _frame):
|
|
33
|
+
try:
|
|
34
|
+
os.killpg(proc.pid, signum)
|
|
35
|
+
except ProcessLookupError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
signal.signal(signal.SIGINT, forward_signal)
|
|
39
|
+
signal.signal(signal.SIGTERM, forward_signal)
|
|
40
|
+
|
|
41
|
+
stdin_fd = sys.stdin.fileno()
|
|
42
|
+
stdout_fd = sys.stdout.fileno()
|
|
43
|
+
stdin_open = True
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
read_fds = [master_fd]
|
|
47
|
+
if stdin_open:
|
|
48
|
+
read_fds.append(stdin_fd)
|
|
49
|
+
|
|
50
|
+
readable, _, _ = select.select(read_fds, [], [], 0.1)
|
|
51
|
+
|
|
52
|
+
if master_fd in readable:
|
|
53
|
+
try:
|
|
54
|
+
data = os.read(master_fd, 8192)
|
|
55
|
+
except OSError:
|
|
56
|
+
data = b""
|
|
57
|
+
if not data:
|
|
58
|
+
break
|
|
59
|
+
os.write(stdout_fd, data)
|
|
60
|
+
|
|
61
|
+
if stdin_open and stdin_fd in readable:
|
|
62
|
+
data = os.read(stdin_fd, 8192)
|
|
63
|
+
if not data:
|
|
64
|
+
stdin_open = False
|
|
65
|
+
else:
|
|
66
|
+
try:
|
|
67
|
+
os.write(master_fd, data)
|
|
68
|
+
except OSError:
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
if proc.poll() is not None:
|
|
72
|
+
# Drain any final PTY output.
|
|
73
|
+
while True:
|
|
74
|
+
readable, _, _ = select.select([master_fd], [], [], 0)
|
|
75
|
+
if not readable:
|
|
76
|
+
break
|
|
77
|
+
try:
|
|
78
|
+
data = os.read(master_fd, 8192)
|
|
79
|
+
except OSError:
|
|
80
|
+
break
|
|
81
|
+
if not data:
|
|
82
|
+
break
|
|
83
|
+
os.write(stdout_fd, data)
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
os.close(master_fd)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
return proc.wait()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
raise SystemExit(main())
|