@naswerks/periscope 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { FilePairedCredential, FileTokenCache, PeriscopeHost, defaultAgentHome, transcriptsRootUnder, claudeTranscriptResolver, credentialPaths, packageVersion, pairedCredentialPath, protocolFor, readMachineFacts, refresherFor, resolveEndpoints, tokenCachePath, } from '../host/index.js';
|
|
2
|
+
import { escalatingDecider } from '../gate/escalate.js';
|
|
3
|
+
import { PairedHostCredential, TokenCredential, identityPosture } from '../identity/index.js';
|
|
4
|
+
import { SessionRegistry } from '../sessions/registry.js';
|
|
5
|
+
import { readConfigFile } from '../host/config-file.js';
|
|
6
|
+
import { writeLinkState } from '../host/link-state-file.js';
|
|
7
|
+
import { describePosture, postureLine } from './posture.js';
|
|
8
|
+
import { isAbsolutePath } from '../core/paths.js';
|
|
9
|
+
import { systemClock, systemTicker } from '../core/time.js';
|
|
10
|
+
import { configValueProblem, overriddenByEnvironment, reconfigureHost } from './reconfigure.js';
|
|
11
|
+
import { hostConfigurationOf, workspaceCapabilitiesOf, workspacePostureProblem, workspacesFor, } from './workspaces.js';
|
|
12
|
+
/**
|
|
13
|
+
* How long the fatal-credential path holds the event loop open before exiting explicitly.
|
|
14
|
+
*
|
|
15
|
+
* Long enough for the stdout trace and the stderr remedy to flush through their pipes, short enough
|
|
16
|
+
* that a supervisor sees the exit promptly. It is not a grace period for work in flight; the host
|
|
17
|
+
* is already stopped by the time this runs.
|
|
18
|
+
*/
|
|
19
|
+
export const FATAL_EXIT_FLUSH_MS = 50;
|
|
20
|
+
function readConfig(env) {
|
|
21
|
+
const controllerUrl = env['PERISCOPE_CONTROLLER_URL'];
|
|
22
|
+
if (controllerUrl === undefined || controllerUrl === '') {
|
|
23
|
+
return 'PERISCOPE_CONTROLLER_URL is not set';
|
|
24
|
+
}
|
|
25
|
+
// A host with nowhere to ask must not start, and neither alternative is survivable. Defaulting
|
|
26
|
+
// to allow makes this a code-execution service for whoever reaches the socket. Defaulting to deny
|
|
27
|
+
// makes a host that comes up healthy, accepts sessions, and blocks every tool call, which reads
|
|
28
|
+
// to an operator as the agent being broken rather than as this host being unconfigured. So it is
|
|
29
|
+
// named at start-up, once, in the one place that reads the environment.
|
|
30
|
+
const decisionUrl = env['PERISCOPE_DECISION_URL'];
|
|
31
|
+
if (decisionUrl === undefined || decisionUrl === '') {
|
|
32
|
+
return ('PERISCOPE_DECISION_URL is not set — this host has nowhere to send a permission decision, and a ' +
|
|
33
|
+
'host that cannot ask is either an open door or a session where nothing runs. Set it to the ' +
|
|
34
|
+
'endpoint your controller answers on.');
|
|
35
|
+
}
|
|
36
|
+
// The same screen the wire runs on `host_configure`: a controller URL that is not ws(s), a
|
|
37
|
+
// decision URL that is not http(s), refused at boot by name instead of dialled forever.
|
|
38
|
+
for (const [key, value] of [
|
|
39
|
+
['PERISCOPE_CONTROLLER_URL', controllerUrl],
|
|
40
|
+
['PERISCOPE_DECISION_URL', decisionUrl],
|
|
41
|
+
]) {
|
|
42
|
+
const problem = configValueProblem(key, value);
|
|
43
|
+
if (problem !== null)
|
|
44
|
+
return problem;
|
|
45
|
+
}
|
|
46
|
+
const configuredHostId = env['PERISCOPE_HOST_ID'];
|
|
47
|
+
return {
|
|
48
|
+
controllerUrl,
|
|
49
|
+
// An empty value counts as unset, as every other key reads it: a shell that exports the name
|
|
50
|
+
// with nothing behind it has not chosen a host id.
|
|
51
|
+
hostId: configuredHostId === undefined || configuredHostId === ''
|
|
52
|
+
? readMachineFacts().hostname
|
|
53
|
+
: configuredHostId,
|
|
54
|
+
decisionUrl,
|
|
55
|
+
workspaceRoot: env['PERISCOPE_WORKSPACE_ROOT'] ?? null,
|
|
56
|
+
repositoryRoot: env['PERISCOPE_REPOSITORY_ROOT'] ?? null,
|
|
57
|
+
branchScheme: env['PERISCOPE_BRANCH_SCHEME'] ?? null,
|
|
58
|
+
workspaceKey: env['PERISCOPE_WORKSPACE_KEY'] ?? null,
|
|
59
|
+
agentHome: env['PERISCOPE_AGENT_HOME'] ?? null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Which credential this host presents, or null when it presents none.
|
|
64
|
+
*
|
|
65
|
+
* The paired credential is preferred over the OIDC cache, and the preference is the point of
|
|
66
|
+
* pairing: an OIDC refresh token can expire after a period of inactivity, the paired credential has
|
|
67
|
+
* no clock. A machine that has run `periscope pair` dials on the paired credential even when a
|
|
68
|
+
* token cache also exists. A missing paired file falls through to the OIDC postures; a corrupt one
|
|
69
|
+
* is fatal rather than a fallback, because silently degrading to a maybe-dead refresh token would
|
|
70
|
+
* put the host back in exactly the ambiguous posture pairing exists to end.
|
|
71
|
+
*
|
|
72
|
+
* The OIDC three-way decision itself lives in `identity/config.ts` and is tested there; this is the
|
|
73
|
+
* wiring. A misconfiguration returns a string, which `runServe` treats as fatal: a host that was
|
|
74
|
+
* told to use identity and cannot must not come up looking healthy while authenticating as nobody.
|
|
75
|
+
*
|
|
76
|
+
* The `absent` posture returns null rather than a refusing credential, which is the whole
|
|
77
|
+
* difference between "no identity" and "no tool calls". `UnconfiguredCredential` refuses by name on
|
|
78
|
+
* every call, and `escalatingDecider` treats a refusing credential as an outage and does not send
|
|
79
|
+
* the request, so handing one to it would turn every escalation in every session into
|
|
80
|
+
* `permission-decision-unavailable`. That is the exact outcome `readConfig` above refuses to ship
|
|
81
|
+
* for the missing-URL case, in the same file, for the same stated reason: a host that comes up
|
|
82
|
+
* healthy, accepts sessions, and blocks every tool call reads to an operator as the agent being
|
|
83
|
+
* broken. Null means the option is omitted, which `EscalationOptions.credential` documents as the
|
|
84
|
+
* supported no-identity mode; the placeholder stays the exported shape for an embedder that wants a
|
|
85
|
+
* credential that says no out loud.
|
|
86
|
+
*/
|
|
87
|
+
export function readCredential(env, log) {
|
|
88
|
+
const pairedPath = pairedCredentialPath(env);
|
|
89
|
+
if (pairedPath !== null) {
|
|
90
|
+
const paired = new FilePairedCredential(pairedPath).read();
|
|
91
|
+
if (paired.ok) {
|
|
92
|
+
// The one posture line, matching the absent-case line in `runServe`: which credential this
|
|
93
|
+
// host is on is otherwise invisible until the first refusal.
|
|
94
|
+
log('credential', `paired as ${paired.value.hostId} - the paired credential is presented on every dial`, null);
|
|
95
|
+
return { credential: new PairedHostCredential(paired.value), pairedHostId: paired.value.hostId };
|
|
96
|
+
}
|
|
97
|
+
if (paired.refusal.reason !== 'token-unavailable') {
|
|
98
|
+
// Exists and is unreadable: fatal, loud, with the fix in the message (see the docblock).
|
|
99
|
+
return paired.refusal.detail;
|
|
100
|
+
}
|
|
101
|
+
// token-unavailable = this machine has never been paired: the normal fall-through.
|
|
102
|
+
}
|
|
103
|
+
const posture = identityPosture(env);
|
|
104
|
+
if (posture.kind === 'invalid')
|
|
105
|
+
return posture.detail;
|
|
106
|
+
if (posture.kind === 'absent')
|
|
107
|
+
return { credential: null, pairedHostId: null };
|
|
108
|
+
const cachePath = tokenCachePath(env);
|
|
109
|
+
if (cachePath === null) {
|
|
110
|
+
return 'identity is configured but there is nowhere to keep the token cache — set PERISCOPE_CONFIG_DIR';
|
|
111
|
+
}
|
|
112
|
+
const store = new FileTokenCache(cachePath);
|
|
113
|
+
const config = posture.config;
|
|
114
|
+
// The endpoints are resolved on first refresh, not at start-up. Discovery is a network call, and
|
|
115
|
+
// making it at construction would mean this host refuses to start when the provider is briefly
|
|
116
|
+
// unreachable, while holding a perfectly good cached token it could have presented. A host that
|
|
117
|
+
// cannot start is worse than one that refreshes late.
|
|
118
|
+
const refresh = async (cached) => {
|
|
119
|
+
const endpoints = await resolveEndpoints(config);
|
|
120
|
+
if (!endpoints.ok)
|
|
121
|
+
return endpoints;
|
|
122
|
+
return refresherFor(config, endpoints.value, store)(cached);
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
credential: new TokenCredential({
|
|
126
|
+
store,
|
|
127
|
+
config,
|
|
128
|
+
// Loopback is the primary flow; the device-code fallback is a deliberate act, so a host running
|
|
129
|
+
// unattended is configured for the one it will actually use.
|
|
130
|
+
protocol: protocolFor(config),
|
|
131
|
+
nowMs: systemTicker,
|
|
132
|
+
refresh,
|
|
133
|
+
// The one line that tells an operator which of the three happened. Without it a cache hit, a
|
|
134
|
+
// silent refresh and "nobody ever signed in here" are the same silence, and the first difference
|
|
135
|
+
// is a 401 the user reads as a server fault. `[credential] refused` names the one thing they can
|
|
136
|
+
// act on, on the machine where they can act on it.
|
|
137
|
+
report: (outcome) => log('credential', outcome.kind === 'refused' ? `refused — ${outcome.reason}` : outcome.kind, outcome.kind === 'refused' ? outcome.detail : null),
|
|
138
|
+
}),
|
|
139
|
+
pairedHostId: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Running as root is refused by policy.
|
|
144
|
+
*
|
|
145
|
+
* An unattended agent running as root has the whole machine on every tool call, and a container
|
|
146
|
+
* built the obvious way runs as root. The gate decides which calls run; it does not shrink what a
|
|
147
|
+
* call can reach once it runs, and as uid 0 that is everything.
|
|
148
|
+
*
|
|
149
|
+
* Checked first, before configuration, so the answer arrives as one line at startup rather than as
|
|
150
|
+
* a session that can reach everything. A null `getuid` is win32, where the condition cannot arise.
|
|
151
|
+
*/
|
|
152
|
+
function rootRefusal(getuid) {
|
|
153
|
+
if (getuid === null)
|
|
154
|
+
return null;
|
|
155
|
+
if (getuid() !== 0)
|
|
156
|
+
return null;
|
|
157
|
+
return ('refusing to start as root: an unattended agent as uid 0 has the whole machine on every tool call. ' +
|
|
158
|
+
'Run as a non-root user (add a USER line to your container).');
|
|
159
|
+
}
|
|
160
|
+
/** Start the daemon. Returns the running host, or the refusal already written to stderr. */
|
|
161
|
+
export function runServe(views, deps) {
|
|
162
|
+
const refuse = (detail) => {
|
|
163
|
+
deps.stderr(`periscope: ${detail}`);
|
|
164
|
+
deps.setExitCode(1);
|
|
165
|
+
return { ok: false, detail };
|
|
166
|
+
};
|
|
167
|
+
const log = (channel, message, detail) => {
|
|
168
|
+
const line = detail === null ? message : `${message} — ${detail}`;
|
|
169
|
+
deps.log(`${systemClock()} [${channel}] ${line}`);
|
|
170
|
+
};
|
|
171
|
+
const asRoot = rootRefusal(deps.getuid === undefined ? processUid() : deps.getuid);
|
|
172
|
+
if (asRoot !== null)
|
|
173
|
+
return refuse(asRoot);
|
|
174
|
+
// The config file fills absences, and only absences; the merge is `main.ts`'s, shared by every
|
|
175
|
+
// configuration-consuming verb, and a file that cannot be used is fatal rather than empty.
|
|
176
|
+
const env = views.merged;
|
|
177
|
+
if (typeof env === 'string')
|
|
178
|
+
return refuse(env);
|
|
179
|
+
const config = readConfig(env);
|
|
180
|
+
if (typeof config === 'string')
|
|
181
|
+
return refuse(config);
|
|
182
|
+
// The workspace posture is screened at startup, by name. A default workspace key that fails
|
|
183
|
+
// the union screen, or a branch scheme with a typo'd placeholder, would refuse every session,
|
|
184
|
+
// and a machine that will refuse every session must say so when it boots, where the person who
|
|
185
|
+
// can fix it is looking, not when someone finally opens a session.
|
|
186
|
+
const workspaceMisconfiguration = workspacePostureProblem({
|
|
187
|
+
workspaceRoot: config.workspaceRoot,
|
|
188
|
+
repositoryRoot: config.repositoryRoot,
|
|
189
|
+
branchScheme: config.branchScheme,
|
|
190
|
+
workspaceKey: config.workspaceKey,
|
|
191
|
+
});
|
|
192
|
+
if (workspaceMisconfiguration !== null)
|
|
193
|
+
return refuse(workspaceMisconfiguration);
|
|
194
|
+
// The transcripts root is read on demand and jailed, but a relative one means "relative to a
|
|
195
|
+
// cwd nobody chose"; refused at start-up like every other posture problem.
|
|
196
|
+
if (config.agentHome !== null && config.agentHome !== '' && !isAbsolutePath(config.agentHome)) {
|
|
197
|
+
return refuse(`PERISCOPE_AGENT_HOME must be an absolute path — got '${config.agentHome}'`);
|
|
198
|
+
}
|
|
199
|
+
const raw = views.raw;
|
|
200
|
+
// The posture line leads the output and the credential lines follow it: the one-line summary
|
|
201
|
+
// first, then the detail. The credential is read before the summary can be composed, so its
|
|
202
|
+
// lines are held until the summary has printed.
|
|
203
|
+
const held = [];
|
|
204
|
+
const hold = (...line) => {
|
|
205
|
+
held.push(line);
|
|
206
|
+
};
|
|
207
|
+
const posture = readCredential(raw, hold);
|
|
208
|
+
if (typeof posture === 'string')
|
|
209
|
+
return refuse(posture);
|
|
210
|
+
const credential = posture.credential;
|
|
211
|
+
if (credential === null) {
|
|
212
|
+
// A legitimate posture, and a silent one until this line existed: a host with no identity
|
|
213
|
+
// configured dials with no header, and "misconfigured to nobody" and "deliberately anonymous"
|
|
214
|
+
// looked identical from the outside. Said once, at start-up, where the other two postures
|
|
215
|
+
// already speak.
|
|
216
|
+
hold('credential', 'absent - no identity is configured, so this host will dial without authentication', null);
|
|
217
|
+
}
|
|
218
|
+
// A paired credential names its host, and the name wins. The controller refuses a link_hello
|
|
219
|
+
// whose hostId differs from the credential's, so announcing anything else guarantees a closed
|
|
220
|
+
// socket. Said out loud when the environment disagrees, because a silently ignored variable is a
|
|
221
|
+
// misconfiguration nobody finds.
|
|
222
|
+
const hostId = posture.pairedHostId ?? config.hostId;
|
|
223
|
+
if (posture.pairedHostId !== null &&
|
|
224
|
+
config.hostId !== posture.pairedHostId &&
|
|
225
|
+
raw['PERISCOPE_HOST_ID'] !== undefined) {
|
|
226
|
+
hold('credential', `PERISCOPE_HOST_ID ('${config.hostId}') is overridden by the paired credential's host id ('${posture.pairedHostId}')`, null);
|
|
227
|
+
}
|
|
228
|
+
// The one line status prints whole, from the same description, so the two cannot disagree.
|
|
229
|
+
const reading = readConfigFile(raw);
|
|
230
|
+
const cachePath = tokenCachePath(raw);
|
|
231
|
+
const cached = cachePath === null ? null : new FileTokenCache(cachePath).read();
|
|
232
|
+
log('host', `periscope ${packageVersion()} · ` +
|
|
233
|
+
postureLine(describePosture({
|
|
234
|
+
raw,
|
|
235
|
+
merged: env,
|
|
236
|
+
fileValues: reading.problem === null ? reading.values : {},
|
|
237
|
+
credential: posture,
|
|
238
|
+
tokenExpiresAtMs: cached !== null && cached.ok ? cached.value.tokens.expiresAt : null,
|
|
239
|
+
nowMs: Date.now(),
|
|
240
|
+
link: null,
|
|
241
|
+
pidAlive: () => false,
|
|
242
|
+
hostname: readMachineFacts().hostname,
|
|
243
|
+
})), null);
|
|
244
|
+
for (const line of held)
|
|
245
|
+
log(...line);
|
|
246
|
+
const workspaces = workspacesFor(config);
|
|
247
|
+
const agentHome = config.agentHome === null || config.agentHome === '' ? defaultAgentHome(raw) : config.agentHome;
|
|
248
|
+
// The transcripts root derives from the agent home; it is reported in the hello and never set on
|
|
249
|
+
// its own.
|
|
250
|
+
const transcriptsRoot = agentHome === null ? null : transcriptsRootUnder(agentHome);
|
|
251
|
+
const homeDir = raw['USERPROFILE'] ?? raw['HOME'] ?? '';
|
|
252
|
+
// Declared before the host so the event callback can reach it: the callback runs only after
|
|
253
|
+
// `start()` below, so the binding is always assigned by the time anything can fire.
|
|
254
|
+
let host = null;
|
|
255
|
+
// Whether the fatal path has latched the exit code. A later signal must not reset it.
|
|
256
|
+
let fatal = false;
|
|
257
|
+
/**
|
|
258
|
+
* The one event that ends this process unsuccessfully without anyone asking it to.
|
|
259
|
+
*
|
|
260
|
+
* Everything else this host reports is an observation. `credential_rejected` is a conclusion about
|
|
261
|
+
* the host itself: the identity provider has refused its material and will keep refusing until a
|
|
262
|
+
* person signs in, so there is nothing left for the process to do and staying up implies
|
|
263
|
+
* otherwise. Without this, the link fails, no ref'd handle remains, node drains, and the process
|
|
264
|
+
* exits zero, so a supervisor records a clean run and a dashboard shows a finished host that
|
|
265
|
+
* never once connected.
|
|
266
|
+
*
|
|
267
|
+
* The exit code and the stderr line are both required, and they answer different readers. The
|
|
268
|
+
* code is what a supervisor branches on; the line is what the person at the machine acts on. A
|
|
269
|
+
* non-zero exit whose reason appears only in the stdout trace still leaves somebody reading logs.
|
|
270
|
+
*/
|
|
271
|
+
let linkStateProblemSaid = false;
|
|
272
|
+
const onEvent = (event) => {
|
|
273
|
+
if (event.kind === 'link') {
|
|
274
|
+
// The record status reads. A disk that will not take it is said once and never stops the link.
|
|
275
|
+
const problem = writeLinkState(raw, {
|
|
276
|
+
state: event.transition.to,
|
|
277
|
+
cause: event.transition.cause,
|
|
278
|
+
at: event.transition.at,
|
|
279
|
+
detail: event.transition.detail,
|
|
280
|
+
negotiatedVersion: host?.link.negotiatedVersion ?? null,
|
|
281
|
+
pid: process.pid,
|
|
282
|
+
});
|
|
283
|
+
if (problem !== null && !linkStateProblemSaid) {
|
|
284
|
+
linkStateProblemSaid = true;
|
|
285
|
+
log('config', `the link state file is not being written; periscope status will read nothing — ${problem}`, null);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
report(event, log);
|
|
289
|
+
if (event.kind === 'link' && event.transition.cause === 'credential_rejected') {
|
|
290
|
+
// The process exits now, live sessions included, and the count says what that cost. The
|
|
291
|
+
// alternative (serve the survivors and exit when the last one ends) is declined: an idle
|
|
292
|
+
// session never ends on its own, so that shape is a process that can stay up forever behind
|
|
293
|
+
// a link that will never carry another frame, which is the quiet-death posture this file
|
|
294
|
+
// exists to prevent. The transcript mirror keeps what the sessions produced; the one number
|
|
295
|
+
// on this line tells the operator whether work was cut mid-flight.
|
|
296
|
+
const liveSessions = host?.registry.list().length ?? 0;
|
|
297
|
+
// The remedy names the credential the host was actually on: a paired credential is revived by
|
|
298
|
+
// re-pairing, not by signing in. Telling a paired operator to `login` sends them to a flow
|
|
299
|
+
// whose token this host would not even present.
|
|
300
|
+
const remedy = posture.pairedHostId !== null
|
|
301
|
+
? 'run: periscope pair <code> with a freshly minted code, then start this host again'
|
|
302
|
+
: 'run: periscope login, then start this host again';
|
|
303
|
+
deps.stderr('periscope: the controller or its identity provider refused this credential ' +
|
|
304
|
+
`(${liveSessions} live session(s) stopped) - ${remedy}`);
|
|
305
|
+
fatal = true;
|
|
306
|
+
deps.setExitCode(1);
|
|
307
|
+
host?.stop("this host's credential was refused");
|
|
308
|
+
// Exit deliberately: draining is what lets a signal launder this failure. Latching the
|
|
309
|
+
// code and letting the loop empty leaves a window in which node has released the loop but the
|
|
310
|
+
// process still exists with its default signal dispositions restored; a SIGTERM landing there
|
|
311
|
+
// terminates by signal, so the exit code arrives as null and a supervisor reads "killed"
|
|
312
|
+
// rather than "failed", the exact laundering this terminal class exists to prevent. The
|
|
313
|
+
// ref'd timer holds the loop open, which keeps the handlers below installed while the pipes
|
|
314
|
+
// flush, and the explicit exit is then the only way this path can end. Node 22 on Linux
|
|
315
|
+
// loses this race where node 24 wins it.
|
|
316
|
+
setTimeout(() => deps.exit(1), FATAL_EXIT_FLUSH_MS);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
host = new PeriscopeHost({
|
|
320
|
+
controllerUrl: config.controllerUrl,
|
|
321
|
+
hostId,
|
|
322
|
+
// Omitted, not nulled, when there is no identity. Both this option and the decision
|
|
323
|
+
// transport's are optional by type, and omitting is the documented no-identity mode on each.
|
|
324
|
+
// Spelling it as one spread keeps the two transports impossible to configure differently by
|
|
325
|
+
// accident (one authenticated, one not).
|
|
326
|
+
...(credential === null ? {} : { credential }),
|
|
327
|
+
// The same credential the link presents, on the decision POST. The surface that decides
|
|
328
|
+
// whether a tool runs must not ship unauthenticated while the surface that merely reports is
|
|
329
|
+
// authenticated.
|
|
330
|
+
//
|
|
331
|
+
// Per host, not per session, and the distinction is a design constraint rather than a detail:
|
|
332
|
+
// one host serves many sessions, and this is read once from the environment, so nothing here can
|
|
333
|
+
// authenticate as the particular session a decision is about. That is what
|
|
334
|
+
// `DecisionRequest.sessionKey` is for: the controller attributes; the credential authenticates.
|
|
335
|
+
//
|
|
336
|
+
// And it is omitted when there is none, not passed as a credential that refuses. Passing one
|
|
337
|
+
// that refuses does not make the request unauthenticated; it makes there be no request, on
|
|
338
|
+
// every tool call, in every session. See `readCredential` above.
|
|
339
|
+
decide: escalatingDecider({
|
|
340
|
+
url: config.decisionUrl,
|
|
341
|
+
transport: deps.transport ?? fetch,
|
|
342
|
+
...(credential === null ? {} : { credential }),
|
|
343
|
+
}),
|
|
344
|
+
// Derived, never spelled out here: `credentialPaths` is the one source both this and the token
|
|
345
|
+
// cache's own location come from, so they cannot disagree about what is protected.
|
|
346
|
+
protectedPaths: credentialPaths(raw, { agentHome }),
|
|
347
|
+
// The discovery door: read-only, jailed. The root is derived here because reading the
|
|
348
|
+
// environment is the composition root's job; a machine with no resolvable home gets a host
|
|
349
|
+
// whose door answers with a named failure rather than a guessed root. The bulk resolver
|
|
350
|
+
// serves `claude-transcript:` locators through the same jail, reads only; the gate's
|
|
351
|
+
// protection of `~/.claude` against the agent is untouched by either.
|
|
352
|
+
...(transcriptsRoot === null ? {} : { transcriptsRoot, bulk: claudeTranscriptResolver(transcriptsRoot) }),
|
|
353
|
+
...(workspaces === null ? {} : { workspaces }),
|
|
354
|
+
// The workspace mode rides the hello as capability markers, derived from the same config the
|
|
355
|
+
// selector above consumed: the read half of what `periscope config` writes, and what lets a
|
|
356
|
+
// controller verify a host is set up rather than trusting its operator's memory.
|
|
357
|
+
linkCapabilities: workspaceCapabilitiesOf(config),
|
|
358
|
+
// The values behind the markers, from the same reading: which roots, which scheme, which
|
|
359
|
+
// transcripts root, which controller this process dialled.
|
|
360
|
+
configuration: hostConfigurationOf(config, {
|
|
361
|
+
transcriptsRoot,
|
|
362
|
+
controllerUrl: config.controllerUrl,
|
|
363
|
+
decisionUrl: config.decisionUrl,
|
|
364
|
+
agentHome,
|
|
365
|
+
}),
|
|
366
|
+
overriddenByEnvironment: overriddenByEnvironment(raw),
|
|
367
|
+
// The configure seam: validates, writes the config file, rebuilds the provider. It reads
|
|
368
|
+
// the raw environment because the file fills absences in it, and only this file may read that.
|
|
369
|
+
// The addresses this process dialled ride in so a written URL is reported as pending rather than
|
|
370
|
+
// applied: the live link is never re-pointed, the next start reads the file.
|
|
371
|
+
reconfigure: (entries, hostBusy) => reconfigureHost(raw, entries, hostBusy, {
|
|
372
|
+
controllerUrl: config.controllerUrl,
|
|
373
|
+
decisionUrl: config.decisionUrl,
|
|
374
|
+
}),
|
|
375
|
+
// The default an unkeyed session_new resolves to, screened above at startup, so a bad
|
|
376
|
+
// value died before this line. Omitted (not nulled) when unset: absence is the documented mode.
|
|
377
|
+
...(config.workspaceKey === null || config.workspaceKey === ''
|
|
378
|
+
? {}
|
|
379
|
+
: { defaultWorkspaceKey: config.workspaceKey }),
|
|
380
|
+
// The registry is the host's own unless a test supplies the process starter; then it is built
|
|
381
|
+
// here over the same base environment and home the host would have used, and the pair goes to
|
|
382
|
+
// the registry alone: the host refuses the two beside a registry, where nothing would read them.
|
|
383
|
+
...(deps.startProcess === undefined
|
|
384
|
+
? { baseEnv: raw, homeDir }
|
|
385
|
+
: { registry: new SessionRegistry({ baseEnv: raw, homeDir, startProcess: deps.startProcess }) }),
|
|
386
|
+
...(deps.link === undefined ? {} : { link: deps.link }),
|
|
387
|
+
report: onEvent,
|
|
388
|
+
});
|
|
389
|
+
// A signal must leave the process in a state a supervisor can distinguish from a crash, so the
|
|
390
|
+
// sessions and the link are closed deliberately and the exit code says which path was taken.
|
|
391
|
+
const shutdown = (signal) => {
|
|
392
|
+
host?.stop(`received ${signal}`);
|
|
393
|
+
// A signal does not erase a failure already recorded. If the credential was refused first,
|
|
394
|
+
// that exit code stands: a supervisor stopping a host it has just been told is unusable must not
|
|
395
|
+
// read its own stop as evidence the run was fine.
|
|
396
|
+
if (!fatal)
|
|
397
|
+
deps.setExitCode(0);
|
|
398
|
+
};
|
|
399
|
+
deps.onSignal('SIGTERM', () => shutdown('SIGTERM'));
|
|
400
|
+
deps.onSignal('SIGINT', () => shutdown('SIGINT'));
|
|
401
|
+
host.start();
|
|
402
|
+
return { ok: true, host };
|
|
403
|
+
}
|
|
404
|
+
/** The process's effective-uid reader, or null where the platform has none. */
|
|
405
|
+
function processUid() {
|
|
406
|
+
return process.getuid?.bind(process) ?? null;
|
|
407
|
+
}
|
|
408
|
+
/** Every named thing the host reports, as one line each. The only output this process produces. */
|
|
409
|
+
export function report(event, log) {
|
|
410
|
+
switch (event.kind) {
|
|
411
|
+
case 'link':
|
|
412
|
+
return log('link', `${event.transition.from} -> ${event.transition.to} (${event.transition.cause})`, event.transition.detail);
|
|
413
|
+
case 'refusal':
|
|
414
|
+
return log('refused', `${event.sessionKey ?? 'link'}: ${event.refusal.reason}`, event.refusal.detail);
|
|
415
|
+
case 'gap':
|
|
416
|
+
return log('gap', `${event.sessionKey} expected ${event.expected}, received ${event.received}`, null);
|
|
417
|
+
case 'session-opened':
|
|
418
|
+
return log('session', `${event.sessionKey} opened in ${event.cwd}`, null);
|
|
419
|
+
case 'session-closed':
|
|
420
|
+
return log('session', `${event.sessionKey} closed`, null);
|
|
421
|
+
// The held-prompt queue's own trace. Without these the queue would work and nothing would say
|
|
422
|
+
// so, and the gap between this pair is the measurement that a provisioning window was survived.
|
|
423
|
+
case 'prompt-held':
|
|
424
|
+
return log('held', `${event.sessionKey} a turn waits for the session to open`, `${event.held} held`);
|
|
425
|
+
case 'prompt-delivered':
|
|
426
|
+
return log('held', `${event.sessionKey} held turns delivered`, `${event.delivered} delivered`);
|
|
427
|
+
case 'prompt-withdrawn':
|
|
428
|
+
return log('held', `${event.sessionKey} the controller cancelled before the session opened`, `${event.withdrawn} withdrawn`);
|
|
429
|
+
case 'transition':
|
|
430
|
+
return log('state', `${event.sessionKey} ${event.transition.from} -> ${event.transition.to} ` +
|
|
431
|
+
`(${event.transition.cause.kind}/${event.transition.cause.event})`, event.transition.cause.detail);
|
|
432
|
+
case 'degrade':
|
|
433
|
+
// The named conditions that change what is true of a session without ending it: an
|
|
434
|
+
// untrusted workspace, an id collision. The detail is the operator's instruction and it
|
|
435
|
+
// travels whole: the collision's detail is the only place "resume with fork" is ever said.
|
|
436
|
+
return log('degrade', `${event.sessionKey} ${event.degrade.kind}`, event.degrade.detail);
|
|
437
|
+
default:
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
//# sourceMappingURL=serve.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ServeViews } from './serve.js';
|
|
2
|
+
export interface StatusOutcome {
|
|
3
|
+
readonly ok: boolean;
|
|
4
|
+
readonly lines: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
export interface StatusDeps {
|
|
7
|
+
readonly nowMs: () => number;
|
|
8
|
+
readonly pidAlive: (pid: number) => boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function runStatus(views: ServeViews, deps?: StatusDeps): StatusOutcome;
|
|
11
|
+
//# sourceMappingURL=status.d.ts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `periscope status`: what this host is, read from what `serve` reads and from the link record
|
|
3
|
+
* `serve` keeps, never from a socket. A host that cannot dial is exactly when the question is asked.
|
|
4
|
+
*/
|
|
5
|
+
import { readConfigFile } from '../host/config-file.js';
|
|
6
|
+
import { readLinkState } from '../host/link-state-file.js';
|
|
7
|
+
import { tokenCachePath } from '../host/paths.js';
|
|
8
|
+
import { FileTokenCache } from '../host/token-cache.js';
|
|
9
|
+
import { readMachineFacts } from '../host/machine.js';
|
|
10
|
+
import { describePosture, renderPosture } from './posture.js';
|
|
11
|
+
import { packageVersion } from '../host/package-facts.js';
|
|
12
|
+
import { readCredential } from './serve.js';
|
|
13
|
+
function processPidAlive(pid) {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const REAL = { nowMs: () => Date.now(), pidAlive: processPidAlive };
|
|
23
|
+
export function runStatus(views, deps = REAL) {
|
|
24
|
+
if (typeof views.merged === 'string')
|
|
25
|
+
return { ok: false, lines: [views.merged] };
|
|
26
|
+
const raw = views.raw;
|
|
27
|
+
const file = readConfigFile(raw);
|
|
28
|
+
const credential = readCredential(raw, () => undefined);
|
|
29
|
+
let tokenExpiresAtMs = null;
|
|
30
|
+
const cache = tokenCachePath(raw);
|
|
31
|
+
if (cache !== null) {
|
|
32
|
+
const cached = new FileTokenCache(cache).read();
|
|
33
|
+
if (cached.ok)
|
|
34
|
+
tokenExpiresAtMs = cached.value.tokens.expiresAt;
|
|
35
|
+
}
|
|
36
|
+
const posture = describePosture({
|
|
37
|
+
raw,
|
|
38
|
+
merged: views.merged,
|
|
39
|
+
fileValues: file.problem === null ? file.values : {},
|
|
40
|
+
credential,
|
|
41
|
+
tokenExpiresAtMs,
|
|
42
|
+
nowMs: deps.nowMs(),
|
|
43
|
+
link: readLinkState(raw),
|
|
44
|
+
pidAlive: deps.pidAlive,
|
|
45
|
+
hostname: readMachineFacts().hostname,
|
|
46
|
+
});
|
|
47
|
+
return { ok: true, lines: [`periscope ${packageVersion()}`, ...renderPosture(posture)] };
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which workspace provider a real process gets.
|
|
3
|
+
*
|
|
4
|
+
* Split out of the composition root (`main.ts` and `serve.ts`) rather than exported from it, for
|
|
5
|
+
* the reason that file's own header gives: everything below the composition root takes its
|
|
6
|
+
* configuration as arguments, which is what makes the rest of the package testable without a
|
|
7
|
+
* process. The selector is pure and lives where it can be called on its own.
|
|
8
|
+
*/
|
|
9
|
+
import type { HostConfiguration } from '../control/frames.js';
|
|
10
|
+
import type { WorkspaceProvider } from '../workspace/provider.js';
|
|
11
|
+
/** The environment answers the selector reads. A subset of the binary's `Config`, by structure. */
|
|
12
|
+
export interface WorkspaceConfig {
|
|
13
|
+
/** When set, every session gets a directory beneath it instead of the one the controller named. */
|
|
14
|
+
readonly workspaceRoot: string | null;
|
|
15
|
+
/** When set alongside the root, sessions get a linked git worktree on their own branch. */
|
|
16
|
+
readonly repositoryRoot: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* The branch template a worktree's branch is rendered from: `{key}` and `{repo}` are the
|
|
19
|
+
* two placeholders, e.g. `periscope/{repo}/{key}`. Null means the provider's own fallback
|
|
20
|
+
* (`periscope/{sessionId}`).
|
|
21
|
+
*/
|
|
22
|
+
readonly branchScheme: string | null;
|
|
23
|
+
}
|
|
24
|
+
/** Render a branch scheme. Pure; exported so the rule is testable without a provider. */
|
|
25
|
+
export declare function renderBranch(scheme: string, key: string, repo: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* The first way this rendered branch name is illegal, or null.
|
|
28
|
+
*
|
|
29
|
+
* A legal key can render an illegal refname: a `{repo}` carrying a dot-prefixed segment, a
|
|
30
|
+
* scheme with a trailing slash, a literal ending `.lock`. Validating the key and then building an
|
|
31
|
+
* unvalidated branch from it would let a branch die inside git on every open, so the result is
|
|
32
|
+
* screened: per slash-separated component, against the same union rule the key already passed
|
|
33
|
+
* (`refnameOrPathIllegality`, the single source of that class).
|
|
34
|
+
*/
|
|
35
|
+
export declare function branchNameProblem(branch: string): string | null;
|
|
36
|
+
/**
|
|
37
|
+
* What is wrong with this workspace posture, at startup, or null when it is usable.
|
|
38
|
+
*
|
|
39
|
+
* Screened when the process boots, not at the first session. A machine that will refuse every
|
|
40
|
+
* session (an unusable default key, a scheme with a typo'd placeholder) must say so when it starts,
|
|
41
|
+
* where the one person who can fix it is looking, not days later when someone finally opens a
|
|
42
|
+
* session. Pure, and exported precisely so it is testable without starting a host.
|
|
43
|
+
*
|
|
44
|
+
* Three families of refusal, each by name:
|
|
45
|
+
* - a setting that depends on another that is absent (a key with no provider, a scheme with no
|
|
46
|
+
* repository); silently ignoring either is a misconfiguration nobody finds;
|
|
47
|
+
* - a default workspace key that fails the same union screen a wire-supplied key must pass;
|
|
48
|
+
* - a branch scheme with an unknown placeholder or an unmatched brace (never rendered literally;
|
|
49
|
+
* `{repoo}` or a trailing `{repo` in a branch name is a silent wrong answer), or whose literal
|
|
50
|
+
* text already renders illegally.
|
|
51
|
+
*/
|
|
52
|
+
export declare function workspacePostureProblem(posture: {
|
|
53
|
+
readonly workspaceRoot: string | null;
|
|
54
|
+
readonly repositoryRoot: string | null;
|
|
55
|
+
readonly branchScheme: string | null;
|
|
56
|
+
readonly workspaceKey: string | null;
|
|
57
|
+
}): string | null;
|
|
58
|
+
/**
|
|
59
|
+
* The branch scheme a git-worktree host renders when none is configured: the repository's name,
|
|
60
|
+
* then the key. With keys named after the platform's own identities (`session-150`,
|
|
61
|
+
* `run-34`) this puts every session on `repo/session-150`, the name an operator reads in
|
|
62
|
+
* the session's own URL. Explicitly configured schemes override it; the `workspace:branch-scheme`
|
|
63
|
+
* marker means "explicitly configured", never "this default".
|
|
64
|
+
*/
|
|
65
|
+
export declare const DEFAULT_BRANCH_SCHEME = "{repo}/{key}";
|
|
66
|
+
/**
|
|
67
|
+
* The `branchFor` a scheme produces, extracted so the render-and-screen rule is testable without
|
|
68
|
+
* a provider, a repository, or a real git (the same reason this whole file exists).
|
|
69
|
+
*
|
|
70
|
+
* A render the screen refuses throws, and the throw is the contract: the provider calls this
|
|
71
|
+
* inside its own try, so the message lands in a named `workspace-provision-failed` refusal rather
|
|
72
|
+
* than dying inside git. This is the runtime half of the screen, for the violations only a
|
|
73
|
+
* particular key or repo name produces (the startup screen already caught the scheme's literal
|
|
74
|
+
* text).
|
|
75
|
+
*/
|
|
76
|
+
export declare function branchRenderer(scheme: string, repositoryRoot: string): (sessionId: string) => string;
|
|
77
|
+
export declare function workspacesFor(config: WorkspaceConfig): WorkspaceProvider | null;
|
|
78
|
+
/**
|
|
79
|
+
* The mode, as capability markers for the hello: the read half of what `periscope config` writes.
|
|
80
|
+
* A host that can be configured but cannot report how it is configured gives a controller nothing
|
|
81
|
+
* to verify.
|
|
82
|
+
*
|
|
83
|
+
* A pure twin of `workspacesFor`, kept beside it so the two cannot drift: the marker is derived
|
|
84
|
+
* from the same predicates that choose the provider, never from a second reading of the
|
|
85
|
+
* environment. Exactly one `workspace:*` mode marker is always present; absence of all three in a
|
|
86
|
+
* hello therefore means "this build does not report", which is what lets a controller render
|
|
87
|
+
* "not reported" instead of a default.
|
|
88
|
+
*
|
|
89
|
+
* Markers, not values. Which repository root, which scheme text: those would be payload members
|
|
90
|
+
* and a protocol-version change, deliberately not smuggled into marker strings.
|
|
91
|
+
*/
|
|
92
|
+
export declare function workspaceCapabilitiesOf(config: WorkspaceConfig): readonly string[];
|
|
93
|
+
/** The values the hello reports that the workspace config does not hold. */
|
|
94
|
+
export interface HostConfigurationExtras {
|
|
95
|
+
readonly transcriptsRoot: string | null;
|
|
96
|
+
readonly controllerUrl: string | null;
|
|
97
|
+
readonly decisionUrl: string | null;
|
|
98
|
+
readonly agentHome: string | null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The values behind the markers: what the hello reports as `configuration`.
|
|
102
|
+
*
|
|
103
|
+
* The other pure twin of `workspacesFor`. Read from the same `WorkspaceConfig` the selector and
|
|
104
|
+
* the markers consume, so a root the selector treats as unset (null or empty) is reported as
|
|
105
|
+
* null here, never as an empty string a controller would render as a path. A value present here
|
|
106
|
+
* and a `workspace:none` marker cannot both be true of one config, and the test pins that.
|
|
107
|
+
*/
|
|
108
|
+
export declare function hostConfigurationOf(config: WorkspaceConfig, extras: HostConfigurationExtras): HostConfiguration;
|
|
109
|
+
//# sourceMappingURL=workspaces.d.ts.map
|