@runuai/host 0.9.1 → 0.9.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.
@@ -35,7 +35,7 @@ import {
35
35
  } from "./agents/types";
36
36
  import { ACTIVE_STATUSES } from "./task-status";
37
37
  import { getHostTask, upsertHostTask } from "./runtime-state";
38
- import { clearRefresh, setupTaskGithub } from "./github-tokens";
38
+ import { clearRefresh, reconcileTaskGitAuth } from "./github-tokens";
39
39
  import { ensureTaskSshIdentity, setupTaskGitIdentity } from "./git-identity";
40
40
  import { dockerCli } from "./docker-exec";
41
41
  import {
@@ -1362,27 +1362,31 @@ export class Orchestrator {
1362
1362
  const initialRoster = [...channel.roster];
1363
1363
 
1364
1364
  // Set the task creator's git author identity in the container (ADR-029).
1365
- // The SSH key itself is installed earlier by task-up.sh (host clone +
1366
- // container), using the creator's per-user key. Best-effort. Awaited but
1365
+ // The optional SSH signing/fallback key is installed earlier by
1366
+ // task-up.sh using the creator's per-user key. Best-effort. Awaited but
1367
1367
  // async (docker exec via dockerCli) so it doesn't block the event loop —
1368
1368
  // this runs on every channel ensure, including each post-restart reconnect.
1369
1369
  await setupTaskGitIdentity(channel.taskId, task.ownerName, task.ownerEmail);
1370
1370
  if (!this.isActiveChannel(channel)) return false;
1371
1371
 
1372
- // GitHub auth for `gh` (ADR-027): mint + inject the access token and
1373
- // (re)start its refresh schedule. Done here — not only at task-up — so it
1374
- // survives a host restart: recovery recreates the channel and re-runs
1375
- // ensureSessions, whereas the in-memory refresh timer from the original
1376
- // task-up is gone. Guarded + best-effort + non-blocking; the token lands
1377
- // before the agents (spawned just below) make their first `gh` call.
1372
+ // GitHub auth for both `gh` and HTTPS Git (ADR-027): inject the token,
1373
+ // configure gh's credential helper, and (re)start refresh. Await the
1374
+ // serialized reconcile so agents cannot race their first fetch/push after
1375
+ // task creation, reconnect, or host recovery.
1378
1376
  if (task.ownerUserId) {
1379
- void setupTaskGithub(channel.taskId, task.ownerUserId);
1377
+ await reconcileTaskGitAuth(channel.taskId, task.ownerUserId);
1380
1378
  }
1381
1379
 
1382
- // SSH push identity + its git config, re-asserted like the gh token above
1383
- // (one-shot at task-up proved fragile recreated containers lose the key
1384
- // silently; live 2026-07-21). Best-effort + non-blocking.
1385
- void ensureTaskSshIdentity(channel.taskId, task.ownerUserId);
1380
+ // SSH signing identity, re-asserted independently of transport. Connected
1381
+ // users still push through gh over HTTPS; disconnected users select the
1382
+ // SSH transport fallback inside the reconcile above.
1383
+ void ensureTaskSshIdentity(channel.taskId, task.ownerUserId).catch((err) =>
1384
+ console.warn(
1385
+ `[ssh] task ${channel.taskId}: signing identity reconciliation failed: ${
1386
+ err instanceof Error ? err.message : String(err)
1387
+ }`,
1388
+ ),
1389
+ );
1386
1390
 
1387
1391
  // ADR-047: install any package skills (native Claude Agent Skills) into the
1388
1392
  // container BEFORE spawning agents, so they're discoverable on the first
@@ -1579,7 +1583,7 @@ export class Orchestrator {
1579
1583
  // Cancel any armed auto-retry chain so this manual attempt runs fresh
1580
1584
  // (setup's duplicate-chain guard would otherwise no-op it).
1581
1585
  clearRefresh(taskId);
1582
- const ok = await setupTaskGithub(taskId, owner);
1586
+ const ok = await reconcileTaskGitAuth(taskId, owner);
1583
1587
  this.emitSystemNote(
1584
1588
  taskId,
1585
1589
  ok
@@ -2772,8 +2776,9 @@ export function buildSystemPreamble(
2772
2776
  ? [
2773
2777
  "## Commit policy",
2774
2778
  "",
2775
- "Commits are SSH-signed automatically (git is configured for it) do",
2776
- "not disable or override signing. Do NOT add any `Co-Authored-By:`",
2779
+ "When this task has an optional SSH signing key configured, Git signs",
2780
+ "commits automatically; do not disable or override it. Do NOT add any",
2781
+ "`Co-Authored-By:`",
2777
2782
  "trailers to commit messages, and do NOT add 'Generated with …' or any",
2778
2783
  "tool/agent attribution footer to commit messages or PR/issue bodies.",
2779
2784
  "Write commit messages and PR descriptions plainly, as the author, with",
@@ -3128,9 +3133,21 @@ async function recoverOneTask(
3128
3133
  // ~8h later. Cheap with the per-user access-token cache — one exchange
3129
3134
  // per user, every task re-injects from it. Best-effort.
3130
3135
  if (task.ownerUserId) {
3131
- void setupTaskGithub(task.taskId, task.ownerUserId);
3136
+ void reconcileTaskGitAuth(task.taskId, task.ownerUserId).catch((err) =>
3137
+ console.warn(
3138
+ `[github] task ${task.taskId}: running recovery reconciliation failed: ${
3139
+ err instanceof Error ? err.message : String(err)
3140
+ }`,
3141
+ ),
3142
+ );
3132
3143
  }
3133
- void ensureTaskSshIdentity(task.taskId, task.ownerUserId);
3144
+ void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
3145
+ console.warn(
3146
+ `[ssh] task ${task.taskId}: running recovery reconciliation failed: ${
3147
+ err instanceof Error ? err.message : String(err)
3148
+ }`,
3149
+ ),
3150
+ );
3134
3151
  return true;
3135
3152
  }
3136
3153
 
@@ -3152,11 +3169,35 @@ async function recoverOneTask(
3152
3169
  // possibly host-uid-owned 0600 files Codex can't read. Failure is surfaced
3153
3170
  // by the inject itself and must not abort the task's recovery.
3154
3171
  await injectCodexIntoContainer(containerName);
3172
+ // Establish the task owner's GitHub transport BEFORE uai-init performs any
3173
+ // dependency-network work. This matters for package manifests that refer to
3174
+ // private GitHub repositories: gh's credential helper must already be live
3175
+ // when pnpm/npm/uv/cargo resolves them. Reconciliation is best-effort, just
3176
+ // like the prior post-init call, but it is now ordered rather than raced.
3177
+ if (task.ownerUserId) {
3178
+ try {
3179
+ await reconcileTaskGitAuth(task.taskId, task.ownerUserId);
3180
+ } catch (err) {
3181
+ console.warn(
3182
+ `[orchestrator] recovery: ${task.taskId} GitHub auth setup failed before uai-init: ${
3183
+ err instanceof Error ? err.message : String(err)
3184
+ }`,
3185
+ );
3186
+ }
3187
+ }
3155
3188
  // uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
3156
3189
  // repo. dockerCli's 30s default would SIGKILL it mid-install.
3157
- if (
3158
- !(await dockerExec(containerName, ["/usr/local/bin/uai-init"], 10 * 60_000))
3159
- ) {
3190
+ const initResult = await dockerCli(
3191
+ [
3192
+ "exec",
3193
+ "-e",
3194
+ "UAI_SKIP_GIT_TRANSPORT=1",
3195
+ containerName,
3196
+ "/usr/local/bin/uai-init",
3197
+ ],
3198
+ { timeoutMs: 10 * 60_000 },
3199
+ );
3200
+ if (initResult.status !== 0) {
3160
3201
  console.warn(
3161
3202
  `[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
3162
3203
  );
@@ -3165,12 +3206,13 @@ async function recoverOneTask(
3165
3206
  db_setRuntime(task.taskId, {
3166
3207
  codeServerPort: port,
3167
3208
  });
3168
- // Same gh re-establish as the running branch — the restarted container's gh
3169
- // config is whatever it held when it exited.
3170
- if (task.ownerUserId) {
3171
- void setupTaskGithub(task.taskId, task.ownerUserId);
3172
- }
3173
- void ensureTaskSshIdentity(task.taskId, task.ownerUserId);
3209
+ void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
3210
+ console.warn(
3211
+ `[ssh] task ${task.taskId}: resumed signing identity reconciliation failed: ${
3212
+ err instanceof Error ? err.message : String(err)
3213
+ }`,
3214
+ ),
3215
+ );
3174
3216
  console.log(
3175
3217
  `[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
3176
3218
  );
package/lib/repo-clone.ts CHANGED
@@ -1,33 +1,18 @@
1
1
  /**
2
- * Bare-mirror clone helper.
2
+ * Retired bare-mirror host command.
3
3
  *
4
- * Used by project creation. A project is a single repo (ADR-022), bare-
5
- * mirrored into `<projectDir>/repo.git/` one mirror per project, no repoId.
6
- * Per-task worktrees pick it up from there (see scripts/agent/task-up.sh).
4
+ * Project creation stores metadata only. Repository access happens at
5
+ * task-up, after the task creator and host are known, using that user's
6
+ * connected GitHub credential or their explicit SSH fallback. This legacy
7
+ * protocol command cannot express either principal, so running Git here would
8
+ * necessarily risk borrowing the host operator's ambient credential.
7
9
  *
8
- * Uses the same init + remote-add + refspec + fetch pattern as `task-up`
9
- * instead of `git clone --bare`. Plain `git clone --bare` copies upstream
10
- * heads straight into `refs/heads/*` and never populates
11
- * `refs/remotes/origin/*`, so a later `git worktree add ...
12
- * origin/<defaultBranch>` would fail with "invalid reference". `task-up`
13
- * self-heals an existing mirror's refspec on subsequent runs, but only
14
- * if the first task-up runs online — initialising the mirror correctly
15
- * here closes the offline-first-task gap.
16
- *
17
- * Sync in v0.1 — the spec doesn't pin sync-vs-async, and async pipelines
18
- * add a state machine we don't need until the UI shows progress.
19
- *
20
- * Idempotent: if the bare mirror already exists, (re-)assert the refspec
21
- * and treat the repo as ready without re-fetching. Healing an existing
22
- * mirror's refspec is cheap and protects against a mirror created before
23
- * this helper learned the right refspec.
10
+ * Keep the command-shaped response for rolling-version compatibility, but
11
+ * fail closed without touching the filesystem or network. A future preflight
12
+ * needs a user/host-aware protocol of its own; see
13
+ * docs/repo-access-preflight.md.
24
14
  */
25
15
 
26
- import { spawnSync } from "node:child_process";
27
- import { existsSync, rmSync, statSync } from "node:fs";
28
- import { mkdirSync } from "node:fs";
29
- import { dirname } from "node:path";
30
-
31
16
  import { projectRepoMirror } from "./env";
32
17
 
33
18
  export interface CloneRepoInput {
@@ -42,86 +27,12 @@ export interface CloneRepoResult {
42
27
  absolutePath: string;
43
28
  }
44
29
 
45
- const REMOTE_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
46
-
47
30
  export function cloneRepo(input: CloneRepoInput): CloneRepoResult {
48
31
  const target = projectRepoMirror(input.projectId);
49
-
50
- if (existsSync(target) && statSync(target).isDirectory()) {
51
- // Heal an existing mirror's refspec — no-op when already correct.
52
- const setRefspec = git(target, [
53
- "config",
54
- "remote.origin.fetch",
55
- REMOTE_REFSPEC,
56
- ]);
57
- if (setRefspec.status !== 0) {
58
- return errorResult(target, setRefspec, "git config remote.origin.fetch");
59
- }
60
- return { status: "ready", absolutePath: target };
61
- }
62
-
63
- mkdirSync(dirname(target), { recursive: true });
64
-
65
- const init = spawnSync("git", ["init", "--bare", target], { encoding: "utf8" });
66
- if (init.status !== 0) {
67
- return errorResult(target, init, "git init --bare");
68
- }
69
-
70
- const remote = git(target, ["remote", "add", "origin", input.url]);
71
- if (remote.status !== 0) {
72
- cleanup(target);
73
- return errorResult(target, remote, "git remote add origin");
74
- }
75
-
76
- const refspec = git(target, [
77
- "config",
78
- "remote.origin.fetch",
79
- REMOTE_REFSPEC,
80
- ]);
81
- if (refspec.status !== 0) {
82
- cleanup(target);
83
- return errorResult(target, refspec, "git config remote.origin.fetch");
84
- }
85
-
86
- const fetch = git(target, ["fetch", "origin"]);
87
- if (fetch.status !== 0) {
88
- // A failed initial fetch leaves a half-built mirror that task-up
89
- // would later mistake for a healthy one. Tear it down so the next
90
- // retry starts clean.
91
- cleanup(target);
92
- return errorResult(target, fetch, "git fetch origin");
93
- }
94
-
95
- return { status: "ready", absolutePath: target };
96
- }
97
-
98
- function git(
99
- cwd: string,
100
- args: string[],
101
- ): ReturnType<typeof spawnSync> {
102
- return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
103
- }
104
-
105
- function cleanup(target: string): void {
106
- try {
107
- rmSync(target, { recursive: true, force: true });
108
- } catch {
109
- // Best-effort: the caller will surface the original error.
110
- }
111
- }
112
-
113
- function errorResult(
114
- target: string,
115
- result: ReturnType<typeof spawnSync>,
116
- step: string,
117
- ): CloneRepoResult {
118
32
  return {
119
33
  status: "error",
120
34
  absolutePath: target,
121
- error: (
122
- `${step}: ${result.stderr || result.stdout || "failed"}`
123
- )
124
- .trim()
125
- .slice(0, 1024),
35
+ error:
36
+ "repo.clone is retired because it has no task creator credential; start a task to perform the user-scoped repository fetch",
126
37
  };
127
38
  }
package/lib/ssh.ts CHANGED
@@ -143,11 +143,12 @@ export function keyPairForUser(
143
143
  };
144
144
  }
145
145
 
146
- // --- per-task on-disk identity (host clone + container, ADR-029) -------------
146
+ // --- per-task on-disk identity (optional SSH fallback + signing, ADR-029) -----
147
147
  //
148
- // task-up.sh installs the creator's key into the container (and uses it for the
149
- // host clone). The cloud relays ssh.key.get/ensure/delete for the Account card
150
- // via the store functions above; there is no separate docker-exec injection.
148
+ // task-up.sh uses the creator's key for host Git only when GitHub is not
149
+ // connected, and installs it in the container for signing (plus that same
150
+ // fallback). The cloud relays ssh.key.get/ensure/delete for the Account card
151
+ // via the store functions above.
151
152
 
152
153
  function taskIdentityDir(taskId: string): string {
153
154
  return resolve(env.dataDir, "identity", "tasks", taskId);
@@ -155,10 +156,12 @@ function taskIdentityDir(taskId: string): string {
155
156
 
156
157
  /**
157
158
  * Materialize the task creator's keypair to a per-task on-disk identity that
158
- * task-up.sh uses for BOTH the host-side bare clone and the container copy
159
- * (ADR-029) so a task clones + pushes as its creator, not the operator.
160
- * Returns the dir, or null when the creator has no key on this host (task-up.sh
161
- * then falls back to the operator identity from `pnpm setup-identity`). The
159
+ * task-up.sh uses for the disconnected user's host-side SSH fallback and the
160
+ * container's optional signing/fallback identity (ADR-029). Connected users
161
+ * clone + push with their GitHub App user token over HTTPS instead.
162
+ * Returns the dir, or null when the creator has no key on this host. A task
163
+ * never borrows the host operator's identity: without a connected GitHub
164
+ * credential, SSH transport is available only through this owner's key. The
162
165
  * decrypted key lives only on the trusted host (ADR-015) and is removed right
163
166
  * after task-up — see {@link removeTaskIdentity}.
164
167
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -204,6 +204,69 @@ UAI_CURRENT_STEP=""
204
204
  UAI_ERROR_CODE="UNKNOWN"
205
205
  UAI_TASK_ID=""
206
206
  UAI_LOCK_HELD=""
207
+ UAI_PROJECT_LOCK_PATH=""
208
+ UAI_PROJECT_LOCK_TOKEN=""
209
+
210
+ # Shared project mirrors are mutated by task-up and task-down across different
211
+ # task processes. A task-row lock cannot serialize those operations, so use a
212
+ # portable atomic directory lock (macOS does not ship `flock`). Never reclaim
213
+ # a stale lock automatically: checking a PID and unlinking a fixed path creates
214
+ # a TOCTOU window where a new owner's lock can be deleted. A stale directory is
215
+ # deliberately surfaced for an operator to inspect and remove.
216
+ acquire_project_lock() {
217
+ local projects_root="$1" project_id="$2"
218
+ local project_parent="$projects_root/$project_id"
219
+ local lock_path="$project_parent/.uai-repo.lock"
220
+ local owner_pid attempts=0
221
+ mkdir -p "$project_parent"
222
+ while ! mkdir "$lock_path" 2>/dev/null; do
223
+ if [ ! -d "$lock_path" ]; then
224
+ emit_err "FETCH_FAILED" \
225
+ "project $project_id has an obsolete or invalid repository lock at $lock_path. Confirm no task-up/task-down process is active, remove that lock manually, then retry." \
226
+ "acquire project cache lock"
227
+ fi
228
+ owner_pid=$(cat "$lock_path/pid" 2>/dev/null || true)
229
+ case "$owner_pid" in
230
+ ''|*[!0-9]*) ;;
231
+ *)
232
+ if ! kill -0 "$owner_pid" 2>/dev/null; then
233
+ emit_err "FETCH_FAILED" \
234
+ "project $project_id has a stale repository lock at $lock_path (owner pid $owner_pid). Confirm no task-up/task-down process is active, remove that lock directory manually, then retry." \
235
+ "acquire project cache lock"
236
+ fi
237
+ ;;
238
+ esac
239
+ attempts=$((attempts + 1))
240
+ if [ "$attempts" -ge 300 ]; then
241
+ emit_err "FETCH_FAILED" \
242
+ "project $project_id is busy in another host operation, or has an incomplete lock at $lock_path. Confirm no task-up/task-down process is active; if none is, remove that lock directory manually, then retry." \
243
+ "acquire project cache lock"
244
+ fi
245
+ sleep 0.1
246
+ done
247
+ UAI_PROJECT_LOCK_TOKEN="$$:${RANDOM:-0}"
248
+ printf '%s\n' "$$" > "$lock_path/pid"
249
+ printf '%s\n' "$UAI_PROJECT_LOCK_TOKEN" > "$lock_path/owner"
250
+ UAI_PROJECT_LOCK_PATH="$lock_path"
251
+ }
252
+
253
+ release_project_lock() {
254
+ [ -n "$UAI_PROJECT_LOCK_PATH" ] || return 0
255
+ local owner
256
+ owner=$(cat "$UAI_PROJECT_LOCK_PATH/owner" 2>/dev/null || true)
257
+ if [ -n "$UAI_PROJECT_LOCK_TOKEN" ] \
258
+ && [ "$owner" = "$UAI_PROJECT_LOCK_TOKEN" ]; then
259
+ rm -f "$UAI_PROJECT_LOCK_PATH/pid" "$UAI_PROJECT_LOCK_PATH/owner" \
260
+ 2>/dev/null || true
261
+ rmdir "$UAI_PROJECT_LOCK_PATH" 2>/dev/null || true
262
+ fi
263
+ UAI_PROJECT_LOCK_PATH=""
264
+ UAI_PROJECT_LOCK_TOKEN=""
265
+ }
266
+
267
+ _uai_on_exit() {
268
+ release_project_lock || true
269
+ }
207
270
 
208
271
  step() {
209
272
  UAI_ERROR_CODE="$1"
@@ -230,6 +293,7 @@ _uai_on_err() {
230
293
 
231
294
  setup_err_trap() {
232
295
  trap _uai_on_err ERR
296
+ trap _uai_on_exit EXIT
233
297
  }
234
298
 
235
299
  # ---------------------------------------------------------------------------
@@ -246,3 +310,153 @@ compose_project_for_task() { printf 'task-%s' "$(_uai_lower "$1")"; }
246
310
  # ever do scale, swap to discovering the container name via the
247
311
  # `com.docker.compose.project` + `com.docker.compose.service` labels.
248
312
  app_container_for_task() { printf 'task-%s-app-1' "$(_uai_lower "$1")"; }
313
+
314
+ # Canonicalise every GitHub URL shape to credential-free HTTPS. task-up selects
315
+ # the credential per task: the creator's connected GitHub token is primary;
316
+ # SSH is a fallback only when that user has no GitHub connection on this host.
317
+ # Keeping the stored/shared origin transport-neutral avoids binding a project
318
+ # to whichever user's credential happened to touch its mirror first.
319
+ #
320
+ # Mirrors normalizeRepoUrl() in lib/github/repo-url.ts — keep the two in step.
321
+ # Non-GitHub remotes are returned untouched: we only know how to speak for
322
+ # GitHub, and Uai supports any git remote.
323
+ #
324
+ # Bash 3.2 (macOS /bin/bash): no ${var,,}, no associative arrays.
325
+ normalize_github_repo_url() {
326
+ local input="$1" url after_scheme authority host_port host host_lc
327
+ local path owner rest repo repo_lc left
328
+ url=$(printf '%s' "$input" | sed \
329
+ -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
330
+ if [ "${url#*://}" != "$url" ]; then
331
+ after_scheme=${url#*://}
332
+ authority=${after_scheme%%/*}
333
+ [ "$after_scheme" != "$authority" ] || {
334
+ printf '%s' "$url"
335
+ return
336
+ }
337
+ host_port=${authority##*@}
338
+ host=${host_port%%:*}
339
+ host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
340
+ case "$host_lc" in
341
+ github.com|www.github.com) path=${after_scheme#*/} ;;
342
+ *)
343
+ printf '%s' "$url"
344
+ return
345
+ ;;
346
+ esac
347
+ elif [ "${url#*:}" != "$url" ]; then
348
+ left=${url%%:*}
349
+ host=${left##*@}
350
+ host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
351
+ case "$host_lc" in
352
+ github.com|www.github.com) path=${url#*:} ;;
353
+ *)
354
+ printf '%s' "$url"
355
+ return
356
+ ;;
357
+ esac
358
+ else
359
+ case "$url" in
360
+ */*/*|/*)
361
+ printf '%s' "$url"
362
+ return
363
+ ;;
364
+ */*) path=$url ;;
365
+ *)
366
+ printf '%s' "$url"
367
+ return
368
+ ;;
369
+ esac
370
+ fi
371
+
372
+ # Keep only owner/repo. Browser URLs can include /tree/branch/path, and a
373
+ # pasted clone URL can carry both `.git` and a trailing slash.
374
+ path=${path%%\?*}
375
+ path=${path%%\#*}
376
+ path=$(printf '%s' "$path" | sed -e 's#/*$##')
377
+ owner=${path%%/*}
378
+ rest=${path#*/}
379
+ if [ -z "$owner" ] || [ "$rest" = "$path" ]; then
380
+ printf '%s' "$url"
381
+ return
382
+ fi
383
+ repo=${rest%%/*}
384
+ repo=${repo%%\?*}
385
+ repo=${repo%%\#*}
386
+ repo_lc=$(printf '%s' "$repo" | tr '[:upper:]' '[:lower:]')
387
+ case "$repo_lc" in
388
+ *.git) repo=${repo%????} ;;
389
+ esac
390
+ if [ -z "$repo" ]; then
391
+ printf '%s' "$url"
392
+ return
393
+ fi
394
+ printf 'https://github.com/%s/%s.git' "$owner" "$repo"
395
+ }
396
+
397
+ # True for a reference whose explicit authority is github.com, even if its
398
+ # path is malformed. Callers use this to fail closed rather than handing a
399
+ # GitHub-looking URL to a generic Git command with ambient credentials.
400
+ is_github_hosted_url() {
401
+ local input="$1" url after_scheme authority host_port host host_lc left
402
+ url=$(printf '%s' "$input" | sed \
403
+ -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
404
+ if [ "${url#*://}" != "$url" ]; then
405
+ after_scheme=${url#*://}
406
+ authority=${after_scheme%%/*}
407
+ host_port=${authority##*@}
408
+ host=${host_port%%:*}
409
+ elif [ "${url#*:}" != "$url" ]; then
410
+ left=${url%%:*}
411
+ host=${left##*@}
412
+ else
413
+ case "$url" in
414
+ */*/*|/*|*://*) return 1 ;;
415
+ */*) return 0 ;;
416
+ *) return 1 ;;
417
+ esac
418
+ fi
419
+ host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
420
+ case "$host_lc" in
421
+ github.com|www.github.com) return 0 ;;
422
+ *) return 1 ;;
423
+ esac
424
+ }
425
+
426
+ # HTTPS spelling of a github.com repository. Empty for non-GitHub remotes.
427
+ github_https_repo_url() {
428
+ local https_url rest owner repo
429
+ https_url=$(normalize_github_repo_url "$1")
430
+ case "$https_url" in
431
+ https://github.com/*)
432
+ rest=${https_url#https://github.com/}
433
+ owner=${rest%%/*}
434
+ repo=${rest#*/}
435
+ if [ "$repo" = "$rest" ]; then
436
+ printf ''
437
+ else
438
+ case "$owner/$repo" in
439
+ /*|*/|*/*/*) printf '' ;;
440
+ *) printf '%s' "$https_url" ;;
441
+ esac
442
+ fi
443
+ ;;
444
+ *)
445
+ printf ''
446
+ ;;
447
+ esac
448
+ }
449
+
450
+ # SSH spelling used only when the task owner has not connected GitHub.
451
+ github_ssh_repo_url() {
452
+ local https_url
453
+ https_url=$(github_https_repo_url "$1")
454
+ case "$https_url" in
455
+ https://github.com/*)
456
+ printf 'git@github.com:%s' "${https_url#https://github.com/}"
457
+ ;;
458
+ *)
459
+ printf ''
460
+ ;;
461
+ esac
462
+ }