opensocrates 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ import {
15
15
  chmod,
16
16
  chown,
17
17
  cp,
18
+ copyFile,
18
19
  lstat,
19
20
  mkdir,
20
21
  mkdtemp,
@@ -34,15 +35,16 @@ import { Readable } from "node:stream";
34
35
  import { pipeline } from "node:stream/promises";
35
36
  import { fileURLToPath } from "node:url";
36
37
 
37
- export const PRODUCT_VERSION = "1.4.0";
38
+ export const PRODUCT_VERSION = "1.5.1";
38
39
  export const REPOSITORY = "ParkerHwang/OpenSocrates";
39
40
  export const MARKETPLACE_NAME = "opensocrates";
40
41
  export const PLUGIN_NAME = "opensocrates";
41
42
  export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
42
43
  export const DEFAULT_HOST = "codex";
43
44
  export const SUPPORTED_HOSTS = Object.freeze(["codex"]);
45
+ export const MACOS_PROFILE_HOSTS = Object.freeze(["claude", "antigravity", "claude-chat"]);
44
46
  export const ALL_HOST = "all";
45
- export const HOST_CHOICES = Object.freeze([ALL_HOST, ...SUPPORTED_HOSTS]);
47
+ export const HOST_CHOICES = Object.freeze([ALL_HOST, ...SUPPORTED_HOSTS, ...MACOS_PROFILE_HOSTS]);
46
48
  export function assetNameFor(host = DEFAULT_HOST) {
47
49
  if (!SUPPORTED_HOSTS.includes(host)) {
48
50
  fail(`unsupported host ${JSON.stringify(host)}`);
@@ -1599,6 +1601,10 @@ export function parseCli(argv) {
1599
1601
  "verify",
1600
1602
  "auto-update",
1601
1603
  "help",
1604
+ "diagnose",
1605
+ "disable",
1606
+ "enable",
1607
+ "export",
1602
1608
  ]);
1603
1609
  const args = [...argv];
1604
1610
  let action = "install";
@@ -1635,6 +1641,8 @@ export function parseCli(argv) {
1635
1641
  force: false,
1636
1642
  purge: false,
1637
1643
  resetTrust: false,
1644
+ workspace: null,
1645
+ output: null,
1638
1646
  };
1639
1647
  const seenOptions = new Set();
1640
1648
  while (args.length > 0) {
@@ -1652,6 +1660,12 @@ export function parseCli(argv) {
1652
1660
  options.host = host;
1653
1661
  continue;
1654
1662
  }
1663
+ if (flag === "--workspace" || flag === "--output") {
1664
+ const value = args.shift();
1665
+ if (!value || value.startsWith("-")) fail(`${flag} requires a path`);
1666
+ options[flag === "--workspace" ? "workspace" : "output"] = resolve(value);
1667
+ continue;
1668
+ }
1655
1669
  if (flag === "--allow-major") {
1656
1670
  seenOptions.add("allow-major");
1657
1671
  options.allowMajor = true;
@@ -1738,7 +1752,7 @@ export function parseCli(argv) {
1738
1752
  ) {
1739
1753
  fail("host-qualified asset options are only valid with --host all");
1740
1754
  }
1741
- if (["status", "remove", "help"].includes(options.action) && options.asset !== null) {
1755
+ if (["status", "remove", "help", "diagnose", "disable", "enable"].includes(options.action) && options.asset !== null) {
1742
1756
  fail(`${options.action} does not accept --asset or --checksum`);
1743
1757
  }
1744
1758
  if (
@@ -1785,9 +1799,67 @@ export function parseCli(argv) {
1785
1799
  if (autoUpdateRun && seenOptions.has("host") && options.host !== ALL_HOST) {
1786
1800
  fail("auto-update run always reconciles desired state and only accepts --host all");
1787
1801
  }
1802
+ if (options.workspace !== null && options.host !== "antigravity") {
1803
+ fail("--workspace is only valid for Antigravity");
1804
+ }
1805
+ if (options.output !== null && !(options.host === "claude-chat" && options.action === "export")) {
1806
+ fail("--output is only valid for the Claude account export");
1807
+ }
1808
+ if (options.host === "claude-chat" && !["export", "verify", "help"].includes(options.action)) {
1809
+ fail("Claude account delivery uses export/verify; account activation is a separate UI action");
1810
+ }
1811
+ if (MACOS_PROFILE_HOSTS.includes(options.host) && (options.purge || options.resetTrust || options.action === "auto-update")) {
1812
+ fail("additional profiles do not broaden purge, trust reset or automatic updates");
1813
+ }
1814
+ if (!MACOS_PROFILE_HOSTS.includes(options.host) && ["diagnose", "disable", "enable", "export"].includes(options.action)) {
1815
+ fail(`${options.action} requires an additional profile`);
1816
+ }
1788
1817
  return options;
1789
1818
  }
1790
1819
 
1820
+ function profileAssetName(host) {
1821
+ if (!MACOS_PROFILE_HOSTS.includes(host)) fail("unknown content/native profile");
1822
+ const suffix = host === "claude-chat" ? "skills" : "plugin";
1823
+ return `opensocrates-${PRODUCT_VERSION}-${host}-${suffix}.zip`;
1824
+ }
1825
+
1826
+ async function prepareProfilePackage(host, options) {
1827
+ const scratch = await mkdtemp(join(tmpdir(), "opensocrates-profile-"));
1828
+ try {
1829
+ if (process.platform === "win32") windowsAction("seal-new", scratch);
1830
+ const name = profileAssetName(host);
1831
+ let asset = options.asset;
1832
+ let checksum = options.checksum;
1833
+ if (asset === null) {
1834
+ asset = join(scratch, name);
1835
+ checksum = join(scratch, name + ".sha256");
1836
+ const base = `https://github.com/${REPOSITORY}/releases/download/v${PRODUCT_VERSION}`;
1837
+ await downloadFile(base + "/" + name, asset);
1838
+ await downloadFile(base + "/" + name + ".sha256", checksum);
1839
+ }
1840
+ const expected = parseChecksumText(await readFile(checksum, "utf8"), basename(asset));
1841
+ if (await sha256File(asset) !== expected) fail("profile archive checksum mismatch");
1842
+ await requireRegularFileEntry(asset, "profile archive");
1843
+ const captured = join(scratch, "verified.zip");
1844
+ await copyFile(asset, captured, fsConstants.COPYFILE_EXCL);
1845
+ if (await sha256File(captured) !== expected) fail("profile archive changed during preparation");
1846
+ asset = captured;
1847
+ archiveEntries(asset);
1848
+ if (process.platform !== "win32") {
1849
+ const zipinfo = run("/usr/bin/zipinfo", ["-l", asset]);
1850
+ if (/^[lbcps][-rwx]/mu.test(zipinfo.stdout)) fail("profile archive contains non-regular entries");
1851
+ }
1852
+ const root = join(scratch, "package");
1853
+ await extractArchive(asset, root);
1854
+ await walkFiles(root);
1855
+ if (process.platform === "win32") windowsAction("seal-new", root);
1856
+ return { root, asset, cleanup: async () => rm(scratch, { recursive: true, force: true }) };
1857
+ } catch (error) {
1858
+ await rm(scratch, { recursive: true, force: true });
1859
+ throw error;
1860
+ }
1861
+ }
1862
+
1791
1863
  function showHelp() {
1792
1864
  console.log(`OpenSocrates ${PRODUCT_VERSION}
1793
1865
 
@@ -1797,6 +1869,15 @@ Usage:
1797
1869
  opensocrates update [--host codex|all] [--asset ZIP --checksum FILE]
1798
1870
  opensocrates remove [--host codex|all] [--purge [--reset-trust]]
1799
1871
  opensocrates verify [--host codex|all] [--asset ZIP --checksum FILE]
1872
+ opensocrates install|update --host claude [--asset ZIP --checksum FILE]
1873
+ opensocrates install|update --host antigravity [--workspace FOLDER]
1874
+ [--asset ZIP --checksum FILE]
1875
+ opensocrates status|diagnose|disable|enable|remove --host claude|antigravity
1876
+ [--workspace FOLDER]
1877
+ opensocrates verify --host claude|antigravity|claude-chat
1878
+ [--asset ZIP --checksum FILE]
1879
+ opensocrates export --host claude-chat --output ZIP
1880
+ [--asset ZIP --checksum FILE]
1800
1881
  opensocrates auto-update enable [--host codex|all]
1801
1882
  [--channel stable|next] [--interval-hours ${AUTO_UPDATE_DEFAULT_INTERVAL_HOURS}]
1802
1883
  [--allow-major]
@@ -1807,7 +1888,13 @@ Without --asset, install, update, and verify download the v${PRODUCT_VERSION}
1807
1888
  package and checksum from GitHub Releases. The default lifecycle host is codex.
1808
1889
  With --host all, supplied host-qualified asset/checksum pairs define the exact
1809
1890
  transaction set and are never mixed with downloads for other hosts. Without
1810
- qualified assets, every ready host participates. Automatic updates are opt-in.
1891
+ qualified assets, ready Codex hosts participate. The additional profiles
1892
+ require an explicit --host; all does not activate them. Automatic updates are opt-in
1893
+ for Codex. Claude native lifecycle requires macOS arm64 and the Claude CLI.
1894
+ Antigravity installs owned rules and skills globally or in the explicit workspace
1895
+ on macOS arm64 and Windows x64. Claude Chat exports a verified portable skill ZIP;
1896
+ upload and account activation happen
1897
+ through Claude's interface. These profiles do not accept purge or reset-trust.
1811
1898
 
1812
1899
  Ordinary remove unregisters the selected host and removes installer-managed
1813
1900
  files, but may leave host-owned caches, installer state, and Codex hook trust.
@@ -4344,9 +4431,22 @@ async function verifyPackages(options) {
4344
4431
 
4345
4432
  export async function main(argv = process.argv.slice(2), internalDependencies = {}) {
4346
4433
  const options = parseCli(argv);
4434
+ if (MACOS_PROFILE_HOSTS.includes(options.host) && options.action !== "help") {
4435
+ const { runManagedHost } = await import("./managed-hosts.mjs");
4436
+ const operation = () => runManagedHost(options, {
4437
+ version: PRODUCT_VERSION,
4438
+ platform: process.platform,
4439
+ claudeBinary: process.env.CLAUDE_BIN || "claude",
4440
+ preparePackage: prepareProfilePackage,
4441
+ });
4442
+ const result = ["status", "diagnose", "verify", "export"].includes(options.action)
4443
+ ? await operation() : await withOperationLock(operation);
4444
+ console.log(JSON.stringify(result, null, 2));
4445
+ return 0;
4446
+ }
4347
4447
  if (process.platform === "win32" && options.action === "auto-update") {
4348
4448
  const message =
4349
- "Automatic updates: unavailable on Windows in 1.4.0. Use opensocrates update --host all manually.";
4449
+ `Automatic updates: unavailable on Windows in ${PRODUCT_VERSION}. Use opensocrates update --host all manually.`;
4350
4450
  if (options.autoUpdateAction === "status" || options.autoUpdateAction === "disable") {
4351
4451
  console.log(message);
4352
4452
  return 0;
@@ -1,8 +1,113 @@
1
1
  # Paths are data in environment variables, never interpolated PowerShell code.
2
- param([ValidateSet('entries','extract','private')][string]$Action)
2
+ param([ValidateSet('entries','extract','private','check','check-tree','mkdir-private','seal-new','lease')][string]$Action)
3
3
  $ErrorActionPreference = 'Stop'
4
4
  [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
5
- $targetPath = $env:OPENSOCRATES_WINDOWS_PATH
5
+ function Native-Path([string]$path) {
6
+ $absolute = [IO.Path]::GetFullPath($path)
7
+ # PowerShell 5.1's FileSystemInfo ACL methods otherwise fail beyond MAX_PATH,
8
+ # including an owned tree temporarily moved below a transaction backup.
9
+ # Convert an already absolute local drive path; caller path/alias policy and
10
+ # every ownership, DACL and reparse check remain in force.
11
+ if ($absolute -match '^[a-zA-Z]:\\') { return '\\?\' + $absolute }
12
+ return $absolute
13
+ }
14
+ $targetPath = Native-Path $env:OPENSOCRATES_WINDOWS_PATH
15
+ $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
16
+ $currentSid = $currentIdentity.User
17
+ $trustedSids = @($currentSid.Value,'S-1-5-18','S-1-5-32-544')
18
+ function Assert-Parents([string]$path) {
19
+ $cursor = [IO.Path]::GetFullPath($path)
20
+ while ($cursor) {
21
+ $item = Get-Item -LiteralPath $cursor -Force
22
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse ancestor refused' }
23
+ $cursor = [IO.Path]::GetDirectoryName($cursor)
24
+ }
25
+ }
26
+ function Private-Acl([bool]$directory) {
27
+ $acl = if ($directory) { [Security.AccessControl.DirectorySecurity]::new() } else { [Security.AccessControl.FileSecurity]::new() }
28
+ $acl.SetOwner($currentSid)
29
+ $acl.SetAccessRuleProtection($true,$false)
30
+ $inherit = if ($directory) { 'ContainerInherit,ObjectInherit' } else { 'None' }
31
+ foreach ($identity in $trustedSids) {
32
+ $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($identity),'FullControl',$inherit,'None','Allow'))
33
+ }
34
+ return $acl
35
+ }
36
+ function Assert-Owned($item) {
37
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse point refused' }
38
+ $acl = $item.GetAccessControl()
39
+ if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $currentSid.Value) { throw 'Foreign owner refused' }
40
+ $writes = [Security.AccessControl.FileSystemRights]::Write -bor [Security.AccessControl.FileSystemRights]::Delete -bor [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [Security.AccessControl.FileSystemRights]::ChangePermissions -bor [Security.AccessControl.FileSystemRights]::TakeOwnership
41
+ foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) {
42
+ if ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) { continue }
43
+ if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $writes) -and $rule.IdentityReference.Value -notin $trustedSids) { throw 'Untrusted writable DACL refused' }
44
+ }
45
+ }
46
+ function Tree-Items($rootItem) {
47
+ $pending = [Collections.Generic.Queue[IO.FileSystemInfo]]::new()
48
+ $pending.Enqueue($rootItem)
49
+ $count = 0
50
+ while ($pending.Count) {
51
+ $item = $pending.Dequeue()
52
+ if (++$count -gt 20000) { throw 'Filesystem limit exceeded' }
53
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse point refused' }
54
+ $item
55
+ if ($item -is [IO.DirectoryInfo]) {
56
+ foreach ($child in $item.EnumerateFileSystemInfos()) { $pending.Enqueue($child) }
57
+ }
58
+ }
59
+ }
60
+ if ($Action -eq 'mkdir-private') {
61
+ if (Test-Path -LiteralPath $targetPath) { throw 'New private directory collision' }
62
+ Assert-Parents ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($targetPath)))
63
+ ([IO.DirectoryInfo]::new($targetPath)).Create((Private-Acl $true))
64
+ Assert-Owned (Get-Item -LiteralPath $targetPath -Force)
65
+ exit 0
66
+ }
67
+ if ($Action -in @('check','check-tree','seal-new','lease')) {
68
+ Assert-Parents $targetPath
69
+ $rootItem = Get-Item -LiteralPath $targetPath -Force
70
+ if ($Action -eq 'seal-new') {
71
+ # Caller uses this only for an exclusively created staging tree/file.
72
+ $items = @(Tree-Items $rootItem)
73
+ foreach ($item in $items) {
74
+ $owner = $item.GetAccessControl().GetOwner([Security.Principal.SecurityIdentifier]).Value
75
+ if ($owner -notin @($currentSid.Value,$currentIdentity.Owner.Value)) { throw 'Foreign staging owner refused' }
76
+ }
77
+ foreach ($item in $items) { $item.SetAccessControl((Private-Acl ($item -is [IO.DirectoryInfo]))) }
78
+ } elseif ($Action -eq 'check-tree') {
79
+ foreach ($item in (Tree-Items $rootItem)) { Assert-Owned $item }
80
+ } else { Assert-Owned $rootItem }
81
+ if ($Action -ne 'lease') { exit 0 }
82
+ # Pin ancestors without FILE_SHARE_DELETE while Node holds its operation lock.
83
+ Add-Type @'
84
+ using System;
85
+ using System.Runtime.InteropServices;
86
+ using Microsoft.Win32.SafeHandles;
87
+ public static class OpenSocratesDirectoryLease {
88
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
89
+ public static extern SafeFileHandle CreateFile(string name, uint access, uint share, IntPtr security, uint mode, uint flags, IntPtr template);
90
+ }
91
+ '@
92
+ $handles = [Collections.Generic.List[Microsoft.Win32.SafeHandles.SafeFileHandle]]::new()
93
+ try {
94
+ $cursor = [IO.Path]::GetFullPath($targetPath)
95
+ while ($cursor) {
96
+ # FILE_LIST_DIRECTORY establishes read sharing; access=0 does not
97
+ # participate in deletion sharing and cannot protect against rename.
98
+ $handle = [OpenSocratesDirectoryLease]::CreateFile($cursor,1,3,[IntPtr]::Zero,3,0x02200000,[IntPtr]::Zero)
99
+ if ($handle.IsInvalid) { throw 'Cannot pin managed ancestor' }
100
+ $handles.Add($handle)
101
+ $cursor = [IO.Path]::GetDirectoryName($cursor)
102
+ }
103
+ Assert-Parents $targetPath
104
+ Assert-Owned (Get-Item -LiteralPath $targetPath -Force)
105
+ [Console]::WriteLine('ready')
106
+ [Console]::Out.Flush()
107
+ [void][Console]::In.ReadLine()
108
+ } finally { foreach ($handle in $handles) { $handle.Dispose() } }
109
+ exit 0
110
+ }
6
111
  if ($Action -eq 'private') {
7
112
  $item = Get-Item -LiteralPath $targetPath -Force
8
113
  if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse point refused' }
@@ -20,23 +125,37 @@ if ($Action -eq 'private') {
20
125
  exit 0
21
126
  }
22
127
  Add-Type -AssemblyName System.IO.Compression.FileSystem
128
+ Assert-Parents $targetPath
23
129
  $zip = [IO.Compression.ZipFile]::OpenRead($targetPath)
24
130
  try {
25
131
  $names = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
132
+ $directories = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
26
133
  $total = 0L
27
134
  foreach ($entry in $zip.Entries) {
28
135
  $name = $entry.FullName
136
+ if ($name.EndsWith('/')) { [void]$directories.Add($name.TrimEnd('/')) }
29
137
  $parts = $name.TrimEnd('/').Split('/')
30
138
  if (!$name -or $name.Contains('\') -or $name.StartsWith('/') -or $name -match '[\x00-\x1f<>:"|?*]' -or
31
139
  ($parts | Where-Object { !$_ -or $_ -eq '.' -or $_ -eq '..' -or $_ -match '[. ]$' -or $_ -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)' }) -or !$names.Add($name.TrimEnd('/'))) { throw 'Unsafe or duplicate ZIP path' }
32
- if ((($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'ZIP symlink refused' }
140
+ $kind = ($entry.ExternalAttributes -shr 16) -band 0xF000
141
+ if ($kind -notin @(0,0x4000,0x8000) -or ($entry.ExternalAttributes -band 0x400)) { throw 'ZIP non-regular entry refused' }
142
+ if ($kind -eq 0x4000 -and !$name.EndsWith('/')) { throw 'ZIP directory type mismatch' }
33
143
  $total += $entry.Length
34
144
  if ($total -gt 2147483648 -or $zip.Entries.Count -gt 20000) { throw 'ZIP limit exceeded' }
35
145
  }
146
+ foreach ($entry in $zip.Entries) {
147
+ $parentName = $entry.FullName.TrimEnd('/')
148
+ while ($parentName.Contains('/')) {
149
+ $parentName = $parentName.Substring(0,$parentName.LastIndexOf('/'))
150
+ if ($names.Contains($parentName) -and !$directories.Contains($parentName)) { throw 'ZIP file-directory collision' }
151
+ }
152
+ }
36
153
  if ($Action -eq 'entries') { ConvertTo-Json -InputObject @($zip.Entries | ForEach-Object { $_.FullName }) -Compress; exit 0 }
37
- $destination = [IO.Path]::GetFullPath($env:OPENSOCRATES_WINDOWS_DESTINATION).TrimEnd('\') + '\'
154
+ $destination = (Native-Path $env:OPENSOCRATES_WINDOWS_DESTINATION).TrimEnd('\') + '\'
155
+ Assert-Parents $destination
38
156
  foreach ($entry in $zip.Entries) {
39
- $output = [IO.Path]::GetFullPath([IO.Path]::Combine($destination,$entry.FullName))
157
+ # Extended Win32 paths do not translate the ZIP's portable slash syntax.
158
+ $output = [IO.Path]::GetFullPath([IO.Path]::Combine($destination,$entry.FullName.Replace('/','\')))
40
159
  if (!$output.StartsWith($destination,[StringComparison]::OrdinalIgnoreCase)) { throw 'ZIP path escape' }
41
160
  if ($entry.FullName.EndsWith('/')) { [void][IO.Directory]::CreateDirectory($output); continue }
42
161
  [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($output))
package/package.json CHANGED
@@ -1,24 +1,25 @@
1
1
  {
2
2
  "name": "opensocrates",
3
- "version": "1.4.0",
4
- "description": "Install and manage OpenSocrates for Codex on macOS and Windows.",
3
+ "version": "1.5.1",
4
+ "description": "Original reasoning assistance with Codex, Claude account skills and Antigravity delivery.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opensocrates": "installer/opensocrates.mjs"
8
8
  },
9
9
  "files": [
10
10
  "installer/opensocrates.mjs",
11
+ "installer/managed-hosts.mjs",
11
12
  "installer/windows.ps1",
12
13
  "CHANGELOG.md",
13
14
  "SECURITY.md",
14
15
  "VERSION"
15
16
  ],
16
17
  "scripts": {
17
- "test": "node --test installer/opensocrates.test.mjs installer/lifecycle.test.mjs tools/clean_machine_acceptance.test.mjs tools/reinstall_cycle_acceptance.test.mjs tools/release_gate.test.mjs && npm run test:npx",
18
+ "test": "node --test installer/opensocrates.test.mjs installer/lifecycle.test.mjs installer/managed-hosts.test.mjs tools/clean_machine_acceptance.test.mjs tools/reinstall_cycle_acceptance.test.mjs tools/release_gate.test.mjs && npm run test:npx",
18
19
  "test:npx": "node installer/package-smoke.mjs",
19
20
  "pack:check": "npm pack --dry-run",
20
21
  "prepublishOnly": "npm test",
21
- "test:windows": "node --test installer/windows.test.mjs"
22
+ "test:windows": "node --test installer/windows.test.mjs installer/managed-windows.test.mjs"
22
23
  },
23
24
  "engines": {
24
25
  "node": ">=20"